Private
Public Access
feat: initial Go/JS/Rust Origin check-in client SDKs
CI / rust (push) Successful in 3m6s
CI / go (push) Successful in 33s
CI / js (push) Successful in 34s
CI / rust (push) Successful in 3m6s
CI / go (push) Successful in 33s
CI / js (push) Successful in 34s
Standalone clients that resolve a machine unique ID (OS machine ID first, falling back to a generated UUID persisted to disk), detect hostname/IP/ container/orchestrator, and register+re-ping origin.warky.dev's public service-checkin endpoint on a daily interval. Ported from icy2's internal origincheckin package but extended to the full check-in contract and made dependency-light for external reuse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
go:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: go
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go/go.mod
|
||||||
|
- run: go build ./...
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: test -z "$(gofmt -l .)"
|
||||||
|
- run: go test ./...
|
||||||
|
|
||||||
|
js:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: js
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: js/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run build
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: rust
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- run: cargo fmt -- --check
|
||||||
|
- run: cargo clippy --all-targets -- -D warnings
|
||||||
|
- run: cargo build
|
||||||
|
- run: cargo test
|
||||||
+15
-180
@@ -1,183 +1,18 @@
|
|||||||
# ---> Go
|
# Go
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
go/*.test
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
go/coverage.out
|
||||||
#
|
|
||||||
# Binaries for programs and plugins
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Node
|
||||||
*.test
|
js/node_modules/
|
||||||
|
js/dist/
|
||||||
|
js/coverage/
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
# Rust
|
||||||
*.out
|
rust/target/
|
||||||
|
Cargo.lock
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
|
||||||
go.work.sum
|
|
||||||
|
|
||||||
# env file
|
|
||||||
.env
|
|
||||||
|
|
||||||
# ---> Rust
|
|
||||||
# Generated by Cargo
|
|
||||||
# will have compiled files and executables
|
|
||||||
debug/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
|
||||||
**/*.rs.bk
|
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
||||||
*.pdb
|
|
||||||
|
|
||||||
# RustRover
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
# ---> Node
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
|
# Editors/OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
Copyright (c) 2026 Warky Devs Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction, and
|
||||||
|
distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||||
|
owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||||
|
that control, are controlled by, or are under common control with that entity.
|
||||||
|
For the purposes of this definition, "control" means (i) the power, direct or
|
||||||
|
indirect, to cause the direction or management of such entity, whether by
|
||||||
|
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||||
|
permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications, including
|
||||||
|
but not limited to software source code, documentation source, and configuration
|
||||||
|
files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical transformation or
|
||||||
|
translation of a Source form, including but not limited to compiled object
|
||||||
|
code, generated documentation, and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||||
|
made available under the License, as indicated by a copyright notice that is
|
||||||
|
included in or attached to the work.
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
||||||
|
is based on (or derived from) the Work and for which the editorial revisions,
|
||||||
|
annotations, elaborations, or other modifications represent, as a whole, an
|
||||||
|
original work of authorship.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including the original
|
||||||
|
version of the Work and any modifications or additions to that Work or
|
||||||
|
Derivative Works thereof, that is intentionally submitted to Licensor for
|
||||||
|
inclusion in the Work by the copyright owner or by an individual or Legal
|
||||||
|
Entity authorized to submit on behalf of the copyright owner.
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||||
|
of whom a Contribution has been received by Licensor and subsequently
|
||||||
|
incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of this
|
||||||
|
License, each Contributor hereby grants to You a perpetual, worldwide,
|
||||||
|
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
|
||||||
|
reproduce, prepare Derivative Works of, publicly display, publicly perform,
|
||||||
|
sublicense, and distribute the Work and such Derivative Works in Source or
|
||||||
|
Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of this
|
||||||
|
License, each Contributor hereby grants to You a perpetual, worldwide,
|
||||||
|
non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
|
||||||
|
section) patent license to make, have made, use, offer to sell, sell, import,
|
||||||
|
and otherwise transfer the Work.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the Work or
|
||||||
|
Derivative Works thereof in any medium, with or without modifications, and in
|
||||||
|
Source or Object form, provided that You meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or Derivative Works a
|
||||||
|
copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices stating
|
||||||
|
that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works that You
|
||||||
|
distribute, all copyright, patent, trademark, and attribution notices
|
||||||
|
from the Source form of the Work; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its distribution,
|
||||||
|
then any Derivative Works that You distribute must include a readable
|
||||||
|
copy of the attribution notices contained within such NOTICE file.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise, any
|
||||||
|
Contribution intentionally submitted for inclusion in the Work by You to the
|
||||||
|
Licensor shall be under the terms and conditions of this License, without any
|
||||||
|
additional terms or conditions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade names,
|
||||||
|
trademarks, service marks, or product names of the Licensor.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
|
||||||
|
writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||||
|
CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory shall any
|
||||||
|
Contributor be liable to You for damages arising as a result of this License
|
||||||
|
or out of the use or inability to use the Work.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing the Work
|
||||||
|
or Derivative Works thereof, You may choose to offer, and charge a fee for,
|
||||||
|
acceptance of support, warranty, indemnity, or other liability obligations
|
||||||
|
and/or rights consistent with this License.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
@@ -1,3 +1,44 @@
|
|||||||
# origin_client
|
# origin_client
|
||||||
|
|
||||||
origin client
|
Client SDKs that register a running service with
|
||||||
|
[Origin](https://origin.warky.dev)'s public check-in endpoint and re-ping it
|
||||||
|
on an interval. One package per language, same wire contract, same behavior.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
| Language | Path | Module/package |
|
||||||
|
|---|---|---|
|
||||||
|
| Go | [`go/`](go/) | `git.warky.dev/wdevs/origin_client/go` |
|
||||||
|
| JavaScript/TypeScript | [`js/`](js/) | `@warkanum/origin-client` |
|
||||||
|
| Rust | [`rust/`](rust/) | `origin-client` |
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Resolves a machine unique ID: real OS ID first (`/etc/machine-id` on
|
||||||
|
Linux, `IOPlatformUUID` on macOS, `MachineGuid` on Windows), falling back
|
||||||
|
to a generated UUID persisted to disk.
|
||||||
|
- Detects hostname, outbound IP, container ID (Docker/cgroup), and
|
||||||
|
orchestrator (Kubernetes/Docker) best-effort.
|
||||||
|
- POSTs a check-in payload to Origin immediately, then again every
|
||||||
|
`interval_hours` (default 24), until stopped. Network/server failures are
|
||||||
|
logged and swallowed — check-in never crashes or blocks the host app.
|
||||||
|
|
||||||
|
## Wire contract
|
||||||
|
|
||||||
|
`POST {base_url}` (default `https://origin.warky.dev/api/public/service-checkin`)
|
||||||
|
with header `X-Origin-Service-Key: <your key>`.
|
||||||
|
|
||||||
|
Required: `type`, `version`, `name`, `unique_install_id`.
|
||||||
|
Optional: `site`, `environment`, `database_type`, `database_name`,
|
||||||
|
`app_type`, `hostname`, `port`, `base_url`, `internal_url`, `container_id`,
|
||||||
|
`orchestrator`, `description`.
|
||||||
|
|
||||||
|
Response: `success`, `service_instance_id`, `unique_install_id`, `status`,
|
||||||
|
`pinged_at`.
|
||||||
|
|
||||||
|
Full contract: see `origin/doc/service-checkin.md` in the Origin repo.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Structured for independent versioning/publishing per package. Not yet
|
||||||
|
published to any registry.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# originclient (Go)
|
||||||
|
|
||||||
|
```
|
||||||
|
go get git.warky.dev/wdevs/origin_client/go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
c, err := originclient.New(originclient.Config{
|
||||||
|
ServiceKey: "...",
|
||||||
|
Type: "myservice",
|
||||||
|
Version: "1.2.3",
|
||||||
|
Name: "myservice-nova",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
go c.Run(ctx) // immediate check-in, then every 24h until ctx is done
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `c.CheckinOnce(ctx)` directly instead of `Run` to send a single
|
||||||
|
check-in and observe the error/response.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Required | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `ServiceKey` | yes | — |
|
||||||
|
| `Type`, `Version`, `Name` | yes | — |
|
||||||
|
| `URL` | no | `DefaultURL` (`https://origin.warky.dev/api/public/service-checkin`) |
|
||||||
|
| `AppType` | no | `"go"` |
|
||||||
|
| `Hostname` | no | OS hostname |
|
||||||
|
| `ContainerID` | no | best-effort Docker/cgroup detection |
|
||||||
|
| `Orchestrator` | no | best-effort Kubernetes/Docker detection |
|
||||||
|
| `InstallID` | no | OS machine ID, else a generated UUID persisted to `InstallIDPath` |
|
||||||
|
| `IntervalHours` | no | `DefaultIntervalHours` (24) |
|
||||||
|
| `Logger` | no | discards warnings |
|
||||||
|
|
||||||
|
`Site`, `Environment`, `DatabaseType`, `DatabaseName`, `Port`, `BaseURL`,
|
||||||
|
`InternalURL`, `Description` are optional metadata with no default.
|
||||||
|
|
||||||
|
`Hostname()`, `OutboundIP()`, `ContainerID()`, `Orchestrator()`, and
|
||||||
|
`ResolveMachineID()` are exported standalone for callers that want the
|
||||||
|
detection logic without the HTTP client.
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
// Package originclient registers a running service with Origin
|
||||||
|
// (origin.warky.dev)'s public check-in endpoint and re-pings it on an
|
||||||
|
// interval, so Origin can track which instances of a service are alive,
|
||||||
|
// their version, and where they run. A check-in failure is logged and
|
||||||
|
// otherwise ignored: it must never affect the host application's own
|
||||||
|
// availability.
|
||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultURL is Origin's public service-checkin endpoint.
|
||||||
|
DefaultURL = "https://origin.warky.dev/api/public/service-checkin"
|
||||||
|
// DefaultIntervalHours is how often Run repeats when Config.IntervalHours is unset.
|
||||||
|
DefaultIntervalHours = 24
|
||||||
|
|
||||||
|
requestTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures a Client. ServiceKey, Type, Version, and Name identify
|
||||||
|
// the caller to Origin and are required; everything else is optional
|
||||||
|
// metadata, or overrides for values Client would otherwise auto-detect.
|
||||||
|
type Config struct {
|
||||||
|
// URL is the check-in endpoint. Defaults to DefaultURL.
|
||||||
|
URL string
|
||||||
|
// ServiceKey is sent as the X-Origin-Service-Key header. Required.
|
||||||
|
ServiceKey string
|
||||||
|
|
||||||
|
// Type is the Origin service type, which must already be registered in
|
||||||
|
// Origin (e.g. "icy2"). Required.
|
||||||
|
Type string
|
||||||
|
// Version is the caller's own version string. Required.
|
||||||
|
Version string
|
||||||
|
// Name identifies this deployment/instance to Origin, e.g. "icy2-nova". Required.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
Site string
|
||||||
|
Environment string
|
||||||
|
DatabaseType string
|
||||||
|
DatabaseName string
|
||||||
|
// AppType defaults to "go" when empty.
|
||||||
|
AppType string
|
||||||
|
// Hostname defaults to the OS hostname (see Hostname) when empty.
|
||||||
|
Hostname string
|
||||||
|
Port int
|
||||||
|
BaseURL string
|
||||||
|
InternalURL string
|
||||||
|
// ContainerID defaults to best-effort Docker/cgroup detection (see
|
||||||
|
// ContainerID) when empty.
|
||||||
|
ContainerID string
|
||||||
|
// Orchestrator defaults to best-effort Kubernetes/Docker detection (see
|
||||||
|
// Orchestrator) when empty.
|
||||||
|
Orchestrator string
|
||||||
|
Description string
|
||||||
|
|
||||||
|
// InstallID, if set, overrides machine-ID resolution entirely.
|
||||||
|
InstallID string
|
||||||
|
// InstallIDPath is where a generated fallback install ID is persisted
|
||||||
|
// when no real OS machine ID is available. Defaults to the OS
|
||||||
|
// user-config directory (see ResolveMachineID).
|
||||||
|
InstallIDPath string
|
||||||
|
|
||||||
|
// IntervalHours sets how often Run repeats. Defaults to DefaultIntervalHours.
|
||||||
|
IntervalHours int
|
||||||
|
|
||||||
|
// Logger receives non-fatal warnings. Defaults to discarding them.
|
||||||
|
Logger Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client checks a service in with Origin.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
http *http.Client
|
||||||
|
installID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validates cfg, resolves the machine/install ID, and returns a ready
|
||||||
|
// Client. Resolution happens once, at construction, so every check-in made
|
||||||
|
// by this Client reports the same UniqueInstallID.
|
||||||
|
func New(cfg Config) (*Client, error) {
|
||||||
|
if cfg.ServiceKey == "" || cfg.Type == "" || cfg.Version == "" || cfg.Name == "" {
|
||||||
|
return nil, errors.New("originclient: ServiceKey, Type, Version, and Name are required")
|
||||||
|
}
|
||||||
|
if cfg.URL == "" {
|
||||||
|
cfg.URL = DefaultURL
|
||||||
|
}
|
||||||
|
if cfg.AppType == "" {
|
||||||
|
cfg.AppType = "go"
|
||||||
|
}
|
||||||
|
if cfg.IntervalHours <= 0 {
|
||||||
|
cfg.IntervalHours = DefaultIntervalHours
|
||||||
|
}
|
||||||
|
if cfg.Logger == nil {
|
||||||
|
cfg.Logger = noopLogger{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
http: &http.Client{Timeout: requestTimeout},
|
||||||
|
installID: ResolveMachineID(cfg.InstallID, cfg.InstallIDPath, cfg.Logger),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run performs an immediate check-in, then repeats every
|
||||||
|
// Config.IntervalHours until ctx is done. Run blocks the calling goroutine,
|
||||||
|
// so callers invoke it with `go client.Run(ctx)`. Check-in errors are
|
||||||
|
// logged via Config.Logger and otherwise ignored; call CheckinOnce directly
|
||||||
|
// if the caller needs to observe failures.
|
||||||
|
func (c *Client) Run(ctx context.Context) {
|
||||||
|
_, _ = c.CheckinOnce(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(c.cfg.IntervalHours) * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_, _ = c.CheckinOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckinOnce sends a single check-in request and returns Origin's
|
||||||
|
// response. Failures are logged via Config.Logger and also returned, so
|
||||||
|
// callers that only want the fire-and-forget behavior of Run can ignore
|
||||||
|
// the returned error.
|
||||||
|
func (c *Client) CheckinOnce(ctx context.Context) (*Response, error) {
|
||||||
|
payload := c.buildPayload()
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
c.cfg.Logger.Warn("origin checkin: encode payload failed", err)
|
||||||
|
return nil, fmt.Errorf("originclient: encode payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.cfg.URL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
c.cfg.Logger.Warn("origin checkin: build request failed", err)
|
||||||
|
return nil, fmt.Errorf("originclient: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Origin-Service-Key", c.cfg.ServiceKey)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.cfg.Logger.Warn("origin checkin: request failed", err)
|
||||||
|
return nil, fmt.Errorf("originclient: do request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("originclient: non-2xx response: %d", resp.StatusCode)
|
||||||
|
c.cfg.Logger.Warn("origin checkin: non-2xx response", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Response
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
c.cfg.Logger.Warn("origin checkin: decode response failed", err)
|
||||||
|
return nil, fmt.Errorf("originclient: decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) buildPayload() Payload {
|
||||||
|
hostname := c.cfg.Hostname
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = Hostname()
|
||||||
|
}
|
||||||
|
containerID := c.cfg.ContainerID
|
||||||
|
if containerID == "" {
|
||||||
|
containerID = ContainerID()
|
||||||
|
}
|
||||||
|
orchestrator := c.cfg.Orchestrator
|
||||||
|
if orchestrator == "" {
|
||||||
|
orchestrator = Orchestrator()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Payload{
|
||||||
|
Type: c.cfg.Type,
|
||||||
|
Version: c.cfg.Version,
|
||||||
|
Name: c.cfg.Name,
|
||||||
|
UniqueInstallID: c.installID,
|
||||||
|
Site: c.cfg.Site,
|
||||||
|
Environment: c.cfg.Environment,
|
||||||
|
DatabaseType: c.cfg.DatabaseType,
|
||||||
|
DatabaseName: c.cfg.DatabaseName,
|
||||||
|
AppType: c.cfg.AppType,
|
||||||
|
Hostname: hostname,
|
||||||
|
Port: c.cfg.Port,
|
||||||
|
BaseURL: c.cfg.BaseURL,
|
||||||
|
InternalURL: c.cfg.InternalURL,
|
||||||
|
ContainerID: containerID,
|
||||||
|
Orchestrator: orchestrator,
|
||||||
|
Description: c.cfg.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testConfig(url string) Config {
|
||||||
|
return Config{
|
||||||
|
URL: url,
|
||||||
|
ServiceKey: "test-key",
|
||||||
|
Type: "icy2",
|
||||||
|
Version: "v1.0.0",
|
||||||
|
Name: "icy2-test",
|
||||||
|
Site: "Test Site",
|
||||||
|
Environment: "test",
|
||||||
|
IntervalHours: 24,
|
||||||
|
InstallID: "test-install-id",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_RequiresFields(t *testing.T) {
|
||||||
|
_, err := New(Config{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_AppliesDefaults(t *testing.T) {
|
||||||
|
c, err := New(Config{ServiceKey: "k", Type: "t", Version: "v", Name: "n", InstallID: "id"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, DefaultURL, c.cfg.URL)
|
||||||
|
assert.Equal(t, "go", c.cfg.AppType)
|
||||||
|
assert.Equal(t, DefaultIntervalHours, c.cfg.IntervalHours)
|
||||||
|
assert.Equal(t, "id", c.installID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPayload(t *testing.T) {
|
||||||
|
c, err := New(testConfig("https://example.invalid/checkin"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
payload := c.buildPayload()
|
||||||
|
|
||||||
|
assert.Equal(t, "icy2", payload.Type)
|
||||||
|
assert.Equal(t, "go", payload.AppType)
|
||||||
|
assert.Equal(t, "v1.0.0", payload.Version)
|
||||||
|
assert.Equal(t, "icy2-test", payload.Name)
|
||||||
|
assert.Equal(t, "Test Site", payload.Site)
|
||||||
|
assert.Equal(t, "test", payload.Environment)
|
||||||
|
assert.Equal(t, "test-install-id", payload.UniqueInstallID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckinOnce_Success(t *testing.T) {
|
||||||
|
var gotKey string
|
||||||
|
var gotBody Payload
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotKey = r.Header.Get("X-Origin-Service-Key")
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(Response{
|
||||||
|
Success: true,
|
||||||
|
ServiceInstanceID: 42,
|
||||||
|
UniqueInstallID: gotBody.UniqueInstallID,
|
||||||
|
Status: "provisioning",
|
||||||
|
PingedAt: "2026-08-01T00:00:00Z",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c, err := New(testConfig(server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := c.CheckinOnce(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "test-key", gotKey)
|
||||||
|
assert.Equal(t, "icy2", gotBody.Type)
|
||||||
|
assert.Equal(t, "icy2-test", gotBody.Name)
|
||||||
|
assert.Equal(t, "test-install-id", gotBody.UniqueInstallID)
|
||||||
|
assert.True(t, resp.Success)
|
||||||
|
assert.Equal(t, int64(42), resp.ServiceInstanceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckinOnce_ServerErrorReturnsError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c, err := New(testConfig(server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := c.CheckinOnce(t.Context())
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckinOnce_UnreachableDoesNotPanic(t *testing.T) {
|
||||||
|
c, err := New(testConfig("http://127.0.0.1:1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
_, _ = c.CheckinOnce(t.Context())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckinOnce_LogsOnFailure(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := testConfig(server.URL)
|
||||||
|
var warned bool
|
||||||
|
cfg.Logger = LoggerFunc(func(msg string, err error) { warned = true })
|
||||||
|
|
||||||
|
c, err := New(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, _ = c.CheckinOnce(t.Context())
|
||||||
|
assert.True(t, warned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ChecksInImmediatelyThenStopsOnContextDone(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(Response{Success: true})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c, err := New(testConfig(server.URL))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c.Run(ctx)
|
||||||
|
|
||||||
|
assert.Equal(t, int32(1), calls.Load())
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module git.warky.dev/wdevs/origin_client/go
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
golang.org/x/sys v0.29.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hostname returns the OS hostname, or "" if it cannot be determined.
|
||||||
|
func Hostname() string {
|
||||||
|
h, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutboundIP returns the first non-loopback IPv4 address bound to this
|
||||||
|
// host. It is a LAN-facing address, not the host's public internet
|
||||||
|
// address, and is not part of Origin's wire contract — it exists so
|
||||||
|
// callers can populate BaseURL/InternalURL/Description themselves.
|
||||||
|
func OutboundIP() string {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ipNet, ok := addr.(*net.IPNet)
|
||||||
|
if !ok || ipNet.IP.IsLoopback() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip4 := ipNet.IP.To4(); ip4 != nil {
|
||||||
|
return ip4.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerID best-effort detects the Docker/Kubernetes container ID this
|
||||||
|
// process runs in. It returns "" outside a container or when detection
|
||||||
|
// fails; an empty container ID is treated as "not containerized" rather
|
||||||
|
// than an error.
|
||||||
|
func ContainerID() string {
|
||||||
|
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||||
|
if id := parseCgroupContainerID(data); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||||
|
if h := Hostname(); isContainerIDLike(h) {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orchestrator best-effort detects the orchestration environment:
|
||||||
|
// "kubernetes" under Kubernetes, "docker" in a plain Docker container, ""
|
||||||
|
// otherwise.
|
||||||
|
func Orchestrator() string {
|
||||||
|
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
|
||||||
|
return "kubernetes"
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||||
|
return "docker"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCgroupContainerID extracts a container ID from /proc/self/cgroup
|
||||||
|
// content, handling both cgroup v1 (".../docker/<id>") and cgroup v2
|
||||||
|
// systemd-scope (".../docker-<id>.scope") layouts.
|
||||||
|
func parseCgroupContainerID(data []byte) string {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
idx := strings.LastIndex(line, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segment := line[idx+1:]
|
||||||
|
segment = strings.TrimSuffix(segment, ".scope")
|
||||||
|
segment = strings.TrimPrefix(segment, "docker-")
|
||||||
|
if isContainerIDLike(segment) {
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isContainerIDLike(s string) bool {
|
||||||
|
if len(s) < 12 || len(s) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCgroupContainerID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cgroup v1 docker path",
|
||||||
|
data: "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n",
|
||||||
|
want: "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cgroup v2 systemd scope",
|
||||||
|
data: "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n",
|
||||||
|
want: "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-container host",
|
||||||
|
data: "0::/user.slice/user-1000.slice/session-2.scope\n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
data: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, parseCgroupContainerID([]byte(tc.data)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsContainerIDLike(t *testing.T) {
|
||||||
|
assert.True(t, isContainerIDLike("9f8b7c6d5e4f"))
|
||||||
|
assert.False(t, isContainerIDLike("short"))
|
||||||
|
assert.False(t, isContainerIDLike("session-2"))
|
||||||
|
assert.False(t, isContainerIDLike(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrchestrator_Kubernetes(t *testing.T) {
|
||||||
|
t.Setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
|
||||||
|
assert.Equal(t, "kubernetes", Orchestrator())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostname_NotEmpty(t *testing.T) {
|
||||||
|
assert.NotEmpty(t, Hostname())
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package originclient
|
||||||
|
|
||||||
|
// Logger receives non-fatal warnings from the client (encode/build/request
|
||||||
|
// failures, machine-id/persistence fallbacks). It intentionally mirrors the
|
||||||
|
// smallest common subset of structured loggers so callers can adapt zap,
|
||||||
|
// slog, logrus, or anything else with one line.
|
||||||
|
type Logger interface {
|
||||||
|
Warn(msg string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoggerFunc adapts a function to Logger.
|
||||||
|
type LoggerFunc func(msg string, err error)
|
||||||
|
|
||||||
|
// Warn implements Logger.
|
||||||
|
func (f LoggerFunc) Warn(msg string, err error) { f(msg, err) }
|
||||||
|
|
||||||
|
// noopLogger discards every warning.
|
||||||
|
type noopLogger struct{}
|
||||||
|
|
||||||
|
func (noopLogger) Warn(string, error) {}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errMachineIDUnavailable is returned by the OS-specific machineID() when
|
||||||
|
// no real machine ID could be read.
|
||||||
|
var errMachineIDUnavailable = errors.New("originclient: OS machine id unavailable")
|
||||||
|
|
||||||
|
// machineIDFunc is a variable indirection over the OS-specific machineID()
|
||||||
|
// so tests can stub OS machine-id lookup without depending on the actual
|
||||||
|
// host's state.
|
||||||
|
var machineIDFunc = machineID
|
||||||
|
|
||||||
|
// ResolveMachineID returns the unique install ID to report to Origin, in
|
||||||
|
// priority order:
|
||||||
|
//
|
||||||
|
// 1. explicit, if non-empty — a caller-supplied override.
|
||||||
|
// 2. the real OS machine ID (/etc/machine-id on Linux, IOPlatformUUID on
|
||||||
|
// macOS, MachineGuid on Windows), if readable.
|
||||||
|
// 3. the ID already persisted at path, if present.
|
||||||
|
// 4. a newly generated UUID, which ResolveMachineID attempts to persist to
|
||||||
|
// path for future runs.
|
||||||
|
//
|
||||||
|
// OS-ID and persistence failures are logged as warnings, not returned as
|
||||||
|
// errors: ResolveMachineID always returns a usable ID, falling back to one
|
||||||
|
// that only lives for this process if it cannot be read from the OS or
|
||||||
|
// saved to disk. logger may be nil.
|
||||||
|
func ResolveMachineID(explicit, path string, logger Logger) string {
|
||||||
|
if logger == nil {
|
||||||
|
logger = noopLogger{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if id := strings.TrimSpace(explicit); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
if id, err := machineIDFunc(); err == nil {
|
||||||
|
if id = strings.TrimSpace(id); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warn("machine id: OS machine id unavailable, falling back to a generated id", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
path = defaultInstallIDPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
if id := strings.TrimSpace(string(data)); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
if err := persistInstallID(path, id); err != nil {
|
||||||
|
logger.Warn("machine id: failed to persist generated id, using an ephemeral id for this run", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistInstallID(path, id string) error {
|
||||||
|
if dir := filepath.Dir(path); dir != "." && dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(id+"\n"), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultInstallIDPath returns the fallback-UUID persistence path used when
|
||||||
|
// Config.InstallIDPath is unset: the OS user-config directory, or the OS
|
||||||
|
// temp directory if that cannot be determined.
|
||||||
|
func defaultInstallIDPath() string {
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil || dir == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "origin-client", "install-id")
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ioregUUIDPattern = regexp.MustCompile(`"IOPlatformUUID" = "([0-9A-Fa-f-]+)"`)
|
||||||
|
|
||||||
|
// machineID reads the hardware IOPlatformUUID via ioreg, which is stable
|
||||||
|
// across reboots and unique per physical/virtual Mac.
|
||||||
|
func machineID() (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx, "ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
match := ioregUUIDPattern.FindSubmatch(out)
|
||||||
|
if match == nil {
|
||||||
|
return "", errMachineIDUnavailable
|
||||||
|
}
|
||||||
|
return string(match[1]), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// machineID reads the systemd/dbus machine ID, which is stable across
|
||||||
|
// reboots and unique per Linux install (but not per container, since it is
|
||||||
|
// typically inherited from the image unless explicitly reset).
|
||||||
|
func machineID() (string, error) {
|
||||||
|
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(string(data)); id != "" {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errMachineIDUnavailable
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package originclient
|
||||||
|
|
||||||
|
// machineID has no known OS-specific implementation on this platform;
|
||||||
|
// ResolveMachineID falls back to a generated, persisted UUID.
|
||||||
|
func machineID() (string, error) {
|
||||||
|
return "", errMachineIDUnavailable
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package originclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withStubMachineID(t *testing.T, id string, err error) {
|
||||||
|
t.Helper()
|
||||||
|
original := machineIDFunc
|
||||||
|
machineIDFunc = func() (string, error) { return id, err }
|
||||||
|
t.Cleanup(func() { machineIDFunc = original })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMachineID_ExplicitOverrideWins(t *testing.T) {
|
||||||
|
withStubMachineID(t, "os-machine-id", nil)
|
||||||
|
got := ResolveMachineID("explicit-id", filepath.Join(t.TempDir(), "install-id"), nil)
|
||||||
|
assert.Equal(t, "explicit-id", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMachineID_UsesOSMachineIDWhenAvailable(t *testing.T) {
|
||||||
|
withStubMachineID(t, "os-machine-id", nil)
|
||||||
|
got := ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), nil)
|
||||||
|
assert.Equal(t, "os-machine-id", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMachineID_FallsBackAndPersistsWhenOSIDUnavailable(t *testing.T) {
|
||||||
|
withStubMachineID(t, "", errMachineIDUnavailable)
|
||||||
|
path := filepath.Join(t.TempDir(), "nested", "install-id")
|
||||||
|
|
||||||
|
first := ResolveMachineID("", path, nil)
|
||||||
|
assert.NotEmpty(t, first)
|
||||||
|
|
||||||
|
second := ResolveMachineID("", path, nil)
|
||||||
|
assert.Equal(t, first, second, "second call should read the persisted id rather than generating a new one")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMachineID_NilLoggerDoesNotPanic(t *testing.T) {
|
||||||
|
withStubMachineID(t, "", errMachineIDUnavailable)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMachineID_LogsWarningWhenOSIDUnavailable(t *testing.T) {
|
||||||
|
withStubMachineID(t, "", errMachineIDUnavailable)
|
||||||
|
var warned bool
|
||||||
|
logger := LoggerFunc(func(msg string, err error) { warned = true })
|
||||||
|
|
||||||
|
ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), logger)
|
||||||
|
|
||||||
|
assert.True(t, warned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersistInstallID(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "nested", "dir", "install-id")
|
||||||
|
require.NoError(t, persistInstallID(path, "abc-123"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package originclient
|
||||||
|
|
||||||
|
import "golang.org/x/sys/windows/registry"
|
||||||
|
|
||||||
|
// machineID reads MachineGuid from the registry, which is generated at
|
||||||
|
// Windows install time and stable across reboots.
|
||||||
|
func machineID() (string, error) {
|
||||||
|
key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE|registry.WOW64_64KEY)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
value, _, err := key.GetStringValue("MachineGuid")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package originclient
|
||||||
|
|
||||||
|
// Payload is the JSON body posted to Origin's public service-checkin
|
||||||
|
// endpoint. Field names and requiredness mirror
|
||||||
|
// POST /api/public/service-checkin as documented in
|
||||||
|
// origin/doc/service-checkin.md: Type, Version, Name, and UniqueInstallID
|
||||||
|
// are required; everything else is optional and, when omitted, does not
|
||||||
|
// overwrite existing instance data on Origin's side.
|
||||||
|
type Payload struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UniqueInstallID string `json:"unique_install_id"`
|
||||||
|
Site string `json:"site,omitempty"`
|
||||||
|
Environment string `json:"environment,omitempty"`
|
||||||
|
DatabaseType string `json:"database_type,omitempty"`
|
||||||
|
DatabaseName string `json:"database_name,omitempty"`
|
||||||
|
AppType string `json:"app_type,omitempty"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
InternalURL string `json:"internal_url,omitempty"`
|
||||||
|
ContainerID string `json:"container_id,omitempty"`
|
||||||
|
Orchestrator string `json:"orchestrator,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is returned by Origin after a successful check-in.
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ServiceInstanceID int64 `json:"service_instance_id"`
|
||||||
|
UniqueInstallID string `json:"unique_install_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PingedAt string `json:"pinged_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# @warkanum/origin-client (JS/TS)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @warkanum/origin-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Node >=18, ESM only.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { OriginClient } from "@warkanum/origin-client";
|
||||||
|
|
||||||
|
const client = new OriginClient({
|
||||||
|
serviceKey: "...",
|
||||||
|
type: "myservice",
|
||||||
|
version: "1.2.3",
|
||||||
|
name: "myservice-nova",
|
||||||
|
});
|
||||||
|
|
||||||
|
client.start(); // immediate check-in, then every 24h until stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `client.checkinOnce()` directly instead of `start()` to send a single
|
||||||
|
check-in and observe the result/error.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Required | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `serviceKey` | yes | — |
|
||||||
|
| `type`, `version`, `name` | yes | — |
|
||||||
|
| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) |
|
||||||
|
| `appType` | no | `"javascript"` |
|
||||||
|
| `hostname` | no | OS hostname |
|
||||||
|
| `containerId` | no | best-effort Docker/cgroup detection |
|
||||||
|
| `orchestrator` | no | best-effort Kubernetes/Docker detection |
|
||||||
|
| `installId` | no | OS machine ID, else a generated UUID persisted to `installIdPath` |
|
||||||
|
| `intervalHours` | no | `DEFAULT_INTERVAL_HOURS` (24) |
|
||||||
|
| `logger` | no | discards warnings |
|
||||||
|
|
||||||
|
`site`, `environment`, `databaseType`, `databaseName`, `port`, `baseUrl`,
|
||||||
|
`internalUrl`, `description` are optional metadata with no default.
|
||||||
|
|
||||||
|
`hostname()`, `outboundIp()`, `containerId()`, `orchestrator()`, and
|
||||||
|
`resolveMachineId()` are exported standalone for callers that want the
|
||||||
|
detection logic without the HTTP client.
|
||||||
Generated
+1331
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@warkanum/origin-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval.",
|
||||||
|
"type": "module",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { containerId as detectContainerId, hostname as detectHostname, orchestrator as detectOrchestrator } from "./hostInfo.js";
|
||||||
|
import type { Logger } from "./machineId.js";
|
||||||
|
import { resolveMachineId } from "./machineId.js";
|
||||||
|
import type { CheckinResponse, Payload } from "./payload.js";
|
||||||
|
|
||||||
|
export const DEFAULT_URL = "https://origin.warky.dev/api/public/service-checkin";
|
||||||
|
export const DEFAULT_INTERVAL_HOURS = 24;
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
/** Check-in endpoint. Defaults to DEFAULT_URL. */
|
||||||
|
url?: string;
|
||||||
|
/** Sent as the X-Origin-Service-Key header. Required. */
|
||||||
|
serviceKey: string;
|
||||||
|
|
||||||
|
/** Origin service type, which must already be registered in Origin (e.g. "icy2"). Required. */
|
||||||
|
type: string;
|
||||||
|
/** Caller's own version string. Required. */
|
||||||
|
version: string;
|
||||||
|
/** Identifies this deployment/instance to Origin, e.g. "icy2-nova". Required. */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
site?: string;
|
||||||
|
environment?: string;
|
||||||
|
databaseType?: string;
|
||||||
|
databaseName?: string;
|
||||||
|
/** Defaults to "javascript" when unset. */
|
||||||
|
appType?: string;
|
||||||
|
/** Defaults to the OS hostname when unset. */
|
||||||
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
baseUrl?: string;
|
||||||
|
internalUrl?: string;
|
||||||
|
/** Defaults to best-effort Docker/cgroup detection when unset. */
|
||||||
|
containerId?: string;
|
||||||
|
/** Defaults to best-effort Kubernetes/Docker detection when unset. */
|
||||||
|
orchestrator?: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** Overrides machine-ID resolution entirely when set. */
|
||||||
|
installId?: string;
|
||||||
|
/** Where a generated fallback install ID is persisted when no real OS machine ID is available. */
|
||||||
|
installIdPath?: string;
|
||||||
|
|
||||||
|
/** How often start() repeats. Defaults to DEFAULT_INTERVAL_HOURS. */
|
||||||
|
intervalHours?: number;
|
||||||
|
|
||||||
|
/** Receives non-fatal warnings. Defaults to discarding them. */
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedConfig = Config & { url: string; appType: string; intervalHours: number; logger: Logger };
|
||||||
|
|
||||||
|
/** OriginClient checks a service in with Origin. */
|
||||||
|
export class OriginClient {
|
||||||
|
private readonly cfg: ResolvedConfig;
|
||||||
|
private readonly installId: string;
|
||||||
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates config, resolves the machine/install ID, and returns a ready
|
||||||
|
* client. Resolution happens once, at construction, so every check-in
|
||||||
|
* made by this client reports the same unique_install_id.
|
||||||
|
*/
|
||||||
|
constructor(config: Config) {
|
||||||
|
if (!config.serviceKey || !config.type || !config.version || !config.name) {
|
||||||
|
throw new Error("originclient: serviceKey, type, version, and name are required");
|
||||||
|
}
|
||||||
|
const logger = config.logger ?? { warn: () => {} };
|
||||||
|
this.cfg = {
|
||||||
|
...config,
|
||||||
|
url: config.url || DEFAULT_URL,
|
||||||
|
appType: config.appType || "javascript",
|
||||||
|
intervalHours: config.intervalHours && config.intervalHours > 0 ? config.intervalHours : DEFAULT_INTERVAL_HOURS,
|
||||||
|
logger,
|
||||||
|
};
|
||||||
|
this.installId = resolveMachineId(config.installId, config.installIdPath, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPayload(): Payload {
|
||||||
|
const hostname = this.cfg.hostname || detectHostname();
|
||||||
|
const containerId = this.cfg.containerId || detectContainerId();
|
||||||
|
const orchestrator = this.cfg.orchestrator || detectOrchestrator();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: this.cfg.type,
|
||||||
|
version: this.cfg.version,
|
||||||
|
name: this.cfg.name,
|
||||||
|
unique_install_id: this.installId,
|
||||||
|
site: this.cfg.site,
|
||||||
|
environment: this.cfg.environment,
|
||||||
|
database_type: this.cfg.databaseType,
|
||||||
|
database_name: this.cfg.databaseName,
|
||||||
|
app_type: this.cfg.appType,
|
||||||
|
hostname: hostname || undefined,
|
||||||
|
port: this.cfg.port,
|
||||||
|
base_url: this.cfg.baseUrl,
|
||||||
|
internal_url: this.cfg.internalUrl,
|
||||||
|
container_id: containerId || undefined,
|
||||||
|
orchestrator: orchestrator || undefined,
|
||||||
|
description: this.cfg.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a single check-in request and returns Origin's response. Throws
|
||||||
|
* on any encode/network/non-2xx failure, after logging it via
|
||||||
|
* config.logger.
|
||||||
|
*/
|
||||||
|
async checkinOnce(): Promise<CheckinResponse> {
|
||||||
|
const payload = this.buildPayload();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.cfg.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Origin-Service-Key": this.cfg.serviceKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`originclient: non-2xx response: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as CheckinResponse;
|
||||||
|
} catch (err) {
|
||||||
|
this.cfg.logger.warn("origin checkin: request failed", err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs an immediate check-in, then repeats every intervalHours until
|
||||||
|
* stop() is called. Check-in failures are logged via config.logger and
|
||||||
|
* otherwise ignored; call checkinOnce() directly to observe them.
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
void this.checkinOnce().catch(() => {});
|
||||||
|
const intervalMs = this.cfg.intervalHours * 60 * 60 * 1000;
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.checkinOnce().catch(() => {});
|
||||||
|
}, intervalMs);
|
||||||
|
this.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the interval started by start(). Safe to call even if start() was never called. */
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { hostname as osHostname, networkInterfaces } from "node:os";
|
||||||
|
|
||||||
|
/** hostname returns the OS hostname, or "" if it cannot be determined. */
|
||||||
|
export function hostname(): string {
|
||||||
|
try {
|
||||||
|
return osHostname();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* outboundIp returns the first non-loopback IPv4 address bound to this
|
||||||
|
* host. It is a LAN-facing address, not the host's public internet
|
||||||
|
* address, and is not part of Origin's wire contract - it exists so
|
||||||
|
* callers can populate base_url/internal_url/description themselves.
|
||||||
|
*/
|
||||||
|
export function outboundIp(): string {
|
||||||
|
const interfaces = networkInterfaces();
|
||||||
|
for (const name of Object.keys(interfaces)) {
|
||||||
|
for (const info of interfaces[name] ?? []) {
|
||||||
|
if (info.family === "IPv4" && !info.internal) {
|
||||||
|
return info.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* containerId best-effort detects the Docker/Kubernetes container ID this
|
||||||
|
* process runs in. It returns "" outside a container or when detection
|
||||||
|
* fails; an empty container ID is treated as "not containerized" rather
|
||||||
|
* than an error.
|
||||||
|
*/
|
||||||
|
export function containerId(): string {
|
||||||
|
try {
|
||||||
|
if (existsSync("/proc/self/cgroup")) {
|
||||||
|
const id = parseCgroupContainerId(readFileSync("/proc/self/cgroup", "utf8"));
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to dockerenv check
|
||||||
|
}
|
||||||
|
if (existsSync("/.dockerenv")) {
|
||||||
|
const h = hostname();
|
||||||
|
if (isContainerIdLike(h)) return h;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* orchestrator best-effort detects the orchestration environment:
|
||||||
|
* "kubernetes" under Kubernetes, "docker" in a plain Docker container, ""
|
||||||
|
* otherwise.
|
||||||
|
*/
|
||||||
|
export function orchestrator(): string {
|
||||||
|
if (process.env.KUBERNETES_SERVICE_HOST) return "kubernetes";
|
||||||
|
if (existsSync("/.dockerenv")) return "docker";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parseCgroupContainerId extracts a container ID from /proc/self/cgroup
|
||||||
|
* content, handling both cgroup v1 (".../docker/<id>") and cgroup v2
|
||||||
|
* systemd-scope (".../docker-<id>.scope") layouts.
|
||||||
|
*/
|
||||||
|
export function parseCgroupContainerId(data: string): string {
|
||||||
|
for (const rawLine of data.split("\n")) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
const idx = line.lastIndexOf("/");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
let segment = line.slice(idx + 1);
|
||||||
|
segment = segment.replace(/\.scope$/, "");
|
||||||
|
segment = segment.replace(/^docker-/, "");
|
||||||
|
if (isContainerIdLike(segment)) return segment;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isContainerIdLike(s: string): boolean {
|
||||||
|
if (s.length < 12 || s.length > 64) return false;
|
||||||
|
return /^[0-9a-f]+$/.test(s);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { DEFAULT_INTERVAL_HOURS, DEFAULT_URL, OriginClient } from "./client.js";
|
||||||
|
export type { Config } from "./client.js";
|
||||||
|
export { containerId, hostname, orchestrator, outboundIp } from "./hostInfo.js";
|
||||||
|
export { defaultInstallIdPath, detectOsMachineId, resolveMachineId } from "./machineId.js";
|
||||||
|
export type { Logger } from "./machineId.js";
|
||||||
|
export type { CheckinResponse, Payload } from "./payload.js";
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir, platform, tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
warn(msg: string, err?: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopLogger: Logger = { warn: () => {} };
|
||||||
|
|
||||||
|
function readLinuxMachineId(): string | undefined {
|
||||||
|
for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
||||||
|
try {
|
||||||
|
const id = readFileSync(path, "utf8").trim();
|
||||||
|
if (id) return id;
|
||||||
|
} catch {
|
||||||
|
// try the next candidate path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDarwinMachineId(): string | undefined {
|
||||||
|
try {
|
||||||
|
const out = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
return out.match(/"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"/)?.[1];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWindowsMachineId(): string | undefined {
|
||||||
|
try {
|
||||||
|
const out = execFileSync(
|
||||||
|
"reg",
|
||||||
|
["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
|
||||||
|
{ encoding: "utf8", timeout: 2000 },
|
||||||
|
);
|
||||||
|
return out.match(/MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]+)/)?.[1];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** detectOsMachineId reads the real OS machine ID, or undefined if unavailable/unsupported. */
|
||||||
|
export function detectOsMachineId(): string | undefined {
|
||||||
|
switch (platform()) {
|
||||||
|
case "linux":
|
||||||
|
return readLinuxMachineId();
|
||||||
|
case "darwin":
|
||||||
|
return readDarwinMachineId();
|
||||||
|
case "win32":
|
||||||
|
return readWindowsMachineId();
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* machineIdDetector is a mutable indirection over detectOsMachineId so
|
||||||
|
* tests can stub OS machine-id lookup without depending on the actual
|
||||||
|
* host's state. It's a settable-property holder rather than a plain
|
||||||
|
* exported `let` because ES module namespace bindings can't be reassigned
|
||||||
|
* from outside the module.
|
||||||
|
*/
|
||||||
|
export const machineIdDetector: { current: () => string | undefined } = {
|
||||||
|
current: detectOsMachineId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** defaultInstallIdPath returns the fallback-UUID persistence path used when no explicit path is given. */
|
||||||
|
export function defaultInstallIdPath(): string {
|
||||||
|
const base = process.env.XDG_CONFIG_HOME || join(homedir() || tmpdir(), ".config");
|
||||||
|
return join(base, "origin-client", "install-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveMachineId returns the unique install ID to report to Origin, in
|
||||||
|
* priority order:
|
||||||
|
*
|
||||||
|
* 1. explicit, if non-empty - a caller-supplied override.
|
||||||
|
* 2. the real OS machine ID (/etc/machine-id on Linux, IOPlatformUUID on
|
||||||
|
* macOS, MachineGuid on Windows), if readable.
|
||||||
|
* 3. the ID already persisted at path, if present.
|
||||||
|
* 4. a newly generated UUID, which resolveMachineId attempts to persist
|
||||||
|
* to path for future runs.
|
||||||
|
*
|
||||||
|
* OS-ID and persistence failures are logged as warnings, not thrown:
|
||||||
|
* resolveMachineId always returns a usable ID.
|
||||||
|
*/
|
||||||
|
export function resolveMachineId(explicit?: string, path?: string, logger: Logger = noopLogger): string {
|
||||||
|
const trimmedExplicit = explicit?.trim();
|
||||||
|
if (trimmedExplicit) return trimmedExplicit;
|
||||||
|
|
||||||
|
const osId = machineIdDetector.current();
|
||||||
|
if (osId) {
|
||||||
|
const trimmed = osId.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
} else {
|
||||||
|
logger.warn("machine id: OS machine id unavailable, falling back to a generated id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = path && path.length > 0 ? path : defaultInstallIdPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = readFileSync(resolvedPath, "utf8").trim();
|
||||||
|
if (existing) return existing;
|
||||||
|
} catch {
|
||||||
|
// no persisted id yet
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o750 });
|
||||||
|
writeFileSync(resolvedPath, id + "\n", { mode: 0o600 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn("machine id: failed to persist generated id, using an ephemeral id for this run", err);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Payload is the JSON body posted to Origin's public service-checkin
|
||||||
|
* endpoint. Field names and requiredness mirror
|
||||||
|
* POST /api/public/service-checkin as documented in
|
||||||
|
* origin/doc/service-checkin.md: type, version, name, and
|
||||||
|
* unique_install_id are required; everything else is optional and, when
|
||||||
|
* omitted, does not overwrite existing instance data on Origin's side.
|
||||||
|
*/
|
||||||
|
export interface Payload {
|
||||||
|
type: string;
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
unique_install_id: string;
|
||||||
|
site?: string;
|
||||||
|
environment?: string;
|
||||||
|
database_type?: string;
|
||||||
|
database_name?: string;
|
||||||
|
app_type?: string;
|
||||||
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
base_url?: string;
|
||||||
|
internal_url?: string;
|
||||||
|
container_id?: string;
|
||||||
|
orchestrator?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CheckinResponse is returned by Origin after a successful check-in. */
|
||||||
|
export interface CheckinResponse {
|
||||||
|
success: boolean;
|
||||||
|
service_instance_id: number;
|
||||||
|
unique_install_id: string;
|
||||||
|
status: string;
|
||||||
|
pinged_at: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { OriginClient } from "../src/client.js";
|
||||||
|
|
||||||
|
function withServer(
|
||||||
|
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
||||||
|
): Promise<{ server: Server; url: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = createServer(handler);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === "object" && address ? address.port : 0;
|
||||||
|
resolve({ server, url: `http://127.0.0.1:${port}` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OriginClient", () => {
|
||||||
|
it("requires serviceKey/type/version/name", () => {
|
||||||
|
expect(() => new OriginClient({ serviceKey: "", type: "", version: "", name: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the expected payload and headers on checkinOnce", async () => {
|
||||||
|
let gotKey = "";
|
||||||
|
let gotBody: Record<string, unknown> = {};
|
||||||
|
const { server, url } = await withServer((req, res) => {
|
||||||
|
gotKey = req.headers["x-origin-service-key"] as string;
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
gotBody = JSON.parse(raw);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
service_instance_id: 42,
|
||||||
|
unique_install_id: gotBody.unique_install_id,
|
||||||
|
status: "provisioning",
|
||||||
|
pinged_at: "2026-08-01T00:00:00Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new OriginClient({
|
||||||
|
url,
|
||||||
|
serviceKey: "test-key",
|
||||||
|
type: "icy2",
|
||||||
|
version: "v1.0.0",
|
||||||
|
name: "icy2-test",
|
||||||
|
installId: "test-install-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resp = await client.checkinOnce();
|
||||||
|
|
||||||
|
expect(gotKey).toBe("test-key");
|
||||||
|
expect(gotBody.type).toBe("icy2");
|
||||||
|
expect(gotBody.name).toBe("icy2-test");
|
||||||
|
expect(gotBody.unique_install_id).toBe("test-install-id");
|
||||||
|
expect(resp.success).toBe(true);
|
||||||
|
expect(resp.service_instance_id).toBe(42);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a non-2xx response", async () => {
|
||||||
|
const { server, url } = await withServer((_req, res) => {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new OriginClient({
|
||||||
|
url,
|
||||||
|
serviceKey: "test-key",
|
||||||
|
type: "icy2",
|
||||||
|
version: "v1.0.0",
|
||||||
|
name: "icy2-test",
|
||||||
|
installId: "test-install-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.checkinOnce()).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw for an unreachable server", async () => {
|
||||||
|
const client = new OriginClient({
|
||||||
|
url: "http://127.0.0.1:1",
|
||||||
|
serviceKey: "test-key",
|
||||||
|
type: "icy2",
|
||||||
|
version: "v1.0.0",
|
||||||
|
name: "icy2-test",
|
||||||
|
installId: "test-install-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.checkinOnce()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("start() checks in immediately and stop() prevents further calls", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const { server, url } = await withServer((_req, res) => {
|
||||||
|
calls += 1;
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
service_instance_id: 1,
|
||||||
|
unique_install_id: "x",
|
||||||
|
status: "provisioning",
|
||||||
|
pinged_at: "2026-08-01T00:00:00Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new OriginClient({
|
||||||
|
url,
|
||||||
|
serviceKey: "test-key",
|
||||||
|
type: "icy2",
|
||||||
|
version: "v1.0.0",
|
||||||
|
name: "icy2-test",
|
||||||
|
installId: "test-install-id",
|
||||||
|
intervalHours: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
client.stop();
|
||||||
|
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hostname, isContainerIdLike, parseCgroupContainerId } from "../src/hostInfo.js";
|
||||||
|
|
||||||
|
describe("parseCgroupContainerId", () => {
|
||||||
|
it("parses a cgroup v1 docker path", () => {
|
||||||
|
const data = "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n";
|
||||||
|
expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a cgroup v2 systemd scope", () => {
|
||||||
|
const data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n";
|
||||||
|
expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty on a non-container host", () => {
|
||||||
|
expect(parseCgroupContainerId("0::/user.slice/user-1000.slice/session-2.scope\n")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty for empty input", () => {
|
||||||
|
expect(parseCgroupContainerId("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isContainerIdLike", () => {
|
||||||
|
it("accepts a hex id of valid length", () => {
|
||||||
|
expect(isContainerIdLike("9f8b7c6d5e4f")).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects short strings", () => {
|
||||||
|
expect(isContainerIdLike("short")).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects non-hex strings", () => {
|
||||||
|
expect(isContainerIdLike("session-2")).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects empty strings", () => {
|
||||||
|
expect(isContainerIdLike("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hostname", () => {
|
||||||
|
it("returns a non-empty value", () => {
|
||||||
|
expect(hostname().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as machineId from "../src/machineId.js";
|
||||||
|
|
||||||
|
function tempInstallIdPath(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "origin-client-test-"));
|
||||||
|
return join(dir, "install-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveMachineId", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
machineId.machineIdDetector.current = machineId.detectOsMachineId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit override", () => {
|
||||||
|
machineId.machineIdDetector.current = () => "os-machine-id";
|
||||||
|
expect(machineId.resolveMachineId("explicit-id", tempInstallIdPath())).toBe("explicit-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the OS machine id when available", () => {
|
||||||
|
machineId.machineIdDetector.current = () => "os-machine-id";
|
||||||
|
expect(machineId.resolveMachineId(undefined, tempInstallIdPath())).toBe("os-machine-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a generated id and persists it across calls", () => {
|
||||||
|
machineId.machineIdDetector.current = () => undefined;
|
||||||
|
const path = tempInstallIdPath();
|
||||||
|
|
||||||
|
const first = machineId.resolveMachineId(undefined, path);
|
||||||
|
expect(first.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const second = machineId.resolveMachineId(undefined, path);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a warning when the OS id is unavailable", () => {
|
||||||
|
machineId.machineIdDetector.current = () => undefined;
|
||||||
|
const warn = vi.fn();
|
||||||
|
machineId.resolveMachineId(undefined, tempInstallIdPath(), { warn });
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "origin-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
description = "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval."
|
||||||
|
repository = "https://git.warky.dev/wdevs/origin_client"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dirs = "5"
|
||||||
|
gethostname = "0.5"
|
||||||
|
if-addrs = "0.13"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["rt", "time", "macros", "sync"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
winreg = "0.52"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mockito = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# origin-client (Rust)
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo add origin-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Async, built on `tokio` + `reqwest` (rustls, no OpenSSL dependency).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use origin_client::{Client, Config};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
let client = Client::new(Config {
|
||||||
|
service_key: "...".into(),
|
||||||
|
type_: "myservice".into(),
|
||||||
|
version: "1.2.3".into(),
|
||||||
|
name: "myservice-nova".into(),
|
||||||
|
..Default::default()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
tokio::spawn({
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
async move { client.run(cancel).await }
|
||||||
|
}); // immediate check-in, then every 24h until cancel.cancel() is called
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `client.checkin_once().await` directly instead of `run` to send a
|
||||||
|
single check-in and observe the result/error.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Required | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `service_key` | yes | — |
|
||||||
|
| `type_`, `version`, `name` | yes | — |
|
||||||
|
| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) |
|
||||||
|
| `app_type` | no | `"rust"` |
|
||||||
|
| `hostname` | no | OS hostname |
|
||||||
|
| `container_id` | no | best-effort Docker/cgroup detection |
|
||||||
|
| `orchestrator` | no | best-effort Kubernetes/Docker detection |
|
||||||
|
| `install_id` | no | OS machine ID, else a generated UUID persisted to `install_id_path` |
|
||||||
|
| `interval_hours` | no | `DEFAULT_INTERVAL_HOURS` (24) |
|
||||||
|
| `logger` | no | discards warnings (`NoopLogger`) |
|
||||||
|
|
||||||
|
`site`, `environment`, `database_type`, `database_name`, `port`,
|
||||||
|
`base_url`, `internal_url`, `description` are optional metadata with no
|
||||||
|
default. `Config` implements `Default`, so use struct-update syntax
|
||||||
|
(`..Default::default()`) as shown above.
|
||||||
|
|
||||||
|
`hostname()`, `outbound_ip()`, `container_id()`, `orchestrator()`, and
|
||||||
|
`resolve_machine_id()` are exported standalone for callers that want the
|
||||||
|
detection logic without the HTTP client.
|
||||||
|
|
||||||
|
## Platform notes
|
||||||
|
|
||||||
|
macOS/Windows machine-ID detection is compiled but not exercised by CI
|
||||||
|
(this repo's CI runs on Linux). `cargo build --target ...` for those
|
||||||
|
targets is a reasonable spot-check before release.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::host_info::{container_id, hostname, orchestrator};
|
||||||
|
use crate::logger::{Logger, NoopLogger};
|
||||||
|
use crate::machine_id::resolve_machine_id;
|
||||||
|
use crate::payload::{CheckinResponse, Payload};
|
||||||
|
|
||||||
|
/// Origin's public service-checkin endpoint.
|
||||||
|
pub const DEFAULT_URL: &str = "https://origin.warky.dev/api/public/service-checkin";
|
||||||
|
/// How often `Client::run` repeats when `Config::interval_hours` is unset.
|
||||||
|
pub const DEFAULT_INTERVAL_HOURS: u32 = 24;
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Config configures a Client. `service_key`, `type_`, `version`, and
|
||||||
|
/// `name` identify the caller to Origin and are required; everything else
|
||||||
|
/// is optional metadata, or overrides for values Client would otherwise
|
||||||
|
/// auto-detect.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Check-in endpoint. Defaults to [`DEFAULT_URL`] when `None`.
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Sent as the `X-Origin-Service-Key` header. Required.
|
||||||
|
pub service_key: String,
|
||||||
|
|
||||||
|
/// Origin service type, which must already be registered in Origin
|
||||||
|
/// (e.g. `"icy2"`). Required.
|
||||||
|
pub type_: String,
|
||||||
|
/// Caller's own version string. Required.
|
||||||
|
pub version: String,
|
||||||
|
/// Identifies this deployment/instance to Origin, e.g. `"icy2-nova"`. Required.
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
pub site: Option<String>,
|
||||||
|
pub environment: Option<String>,
|
||||||
|
pub database_type: Option<String>,
|
||||||
|
pub database_name: Option<String>,
|
||||||
|
/// Defaults to `"rust"` when `None`.
|
||||||
|
pub app_type: Option<String>,
|
||||||
|
/// Defaults to the OS hostname when `None`.
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub internal_url: Option<String>,
|
||||||
|
/// Defaults to best-effort Docker/cgroup detection when `None`.
|
||||||
|
pub container_id: Option<String>,
|
||||||
|
/// Defaults to best-effort Kubernetes/Docker detection when `None`.
|
||||||
|
pub orchestrator: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// Overrides machine-ID resolution entirely when set.
|
||||||
|
pub install_id: Option<String>,
|
||||||
|
/// Where a generated fallback install ID is persisted when no real OS
|
||||||
|
/// machine ID is available.
|
||||||
|
pub install_id_path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// How often `run` repeats. Defaults to [`DEFAULT_INTERVAL_HOURS`] when `None`.
|
||||||
|
pub interval_hours: Option<u32>,
|
||||||
|
|
||||||
|
/// Receives non-fatal warnings. Defaults to discarding them.
|
||||||
|
pub logger: Arc<dyn Logger>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
url: None,
|
||||||
|
service_key: String::new(),
|
||||||
|
type_: String::new(),
|
||||||
|
version: String::new(),
|
||||||
|
name: String::new(),
|
||||||
|
site: None,
|
||||||
|
environment: None,
|
||||||
|
database_type: None,
|
||||||
|
database_name: None,
|
||||||
|
app_type: None,
|
||||||
|
hostname: None,
|
||||||
|
port: None,
|
||||||
|
base_url: None,
|
||||||
|
internal_url: None,
|
||||||
|
container_id: None,
|
||||||
|
orchestrator: None,
|
||||||
|
description: None,
|
||||||
|
install_id: None,
|
||||||
|
install_id_path: None,
|
||||||
|
interval_hours: None,
|
||||||
|
logger: Arc::new(NoopLogger),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error returned by [`Client::new`] and [`Client::checkin_once`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
MissingRequiredConfig,
|
||||||
|
BuildClient(reqwest::Error),
|
||||||
|
Request(reqwest::Error),
|
||||||
|
Status(reqwest::StatusCode),
|
||||||
|
Decode(reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::MissingRequiredConfig => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"origin-client: service_key, type, version, and name are required"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Error::BuildClient(e) => write!(f, "origin-client: build http client: {e}"),
|
||||||
|
Error::Request(e) => write!(f, "origin-client: request failed: {e}"),
|
||||||
|
Error::Status(code) => write!(f, "origin-client: non-2xx response: {code}"),
|
||||||
|
Error::Decode(e) => write!(f, "origin-client: decode response: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Error::BuildClient(e) | Error::Request(e) | Error::Decode(e) => Some(e),
|
||||||
|
Error::MissingRequiredConfig | Error::Status(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client checks a service in with Origin.
|
||||||
|
pub struct Client {
|
||||||
|
cfg: Config,
|
||||||
|
http: reqwest::Client,
|
||||||
|
install_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Validates `cfg`, resolves the machine/install ID, and returns a
|
||||||
|
/// ready Client. Resolution happens once, at construction, so every
|
||||||
|
/// check-in made by this Client reports the same `unique_install_id`.
|
||||||
|
pub fn new(cfg: Config) -> Result<Self, Error> {
|
||||||
|
if cfg.service_key.is_empty()
|
||||||
|
|| cfg.type_.is_empty()
|
||||||
|
|| cfg.version.is_empty()
|
||||||
|
|| cfg.name.is_empty()
|
||||||
|
{
|
||||||
|
return Err(Error::MissingRequiredConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.map_err(Error::BuildClient)?;
|
||||||
|
|
||||||
|
let install_id = resolve_machine_id(
|
||||||
|
cfg.install_id.as_deref(),
|
||||||
|
cfg.install_id_path.as_deref(),
|
||||||
|
cfg.logger.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
cfg,
|
||||||
|
http,
|
||||||
|
install_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self) -> &str {
|
||||||
|
self.cfg.url.as_deref().unwrap_or(DEFAULT_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_type(&self) -> String {
|
||||||
|
self.cfg
|
||||||
|
.app_type
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "rust".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interval_hours(&self) -> u32 {
|
||||||
|
self.cfg
|
||||||
|
.interval_hours
|
||||||
|
.filter(|&h| h > 0)
|
||||||
|
.unwrap_or(DEFAULT_INTERVAL_HOURS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_payload(&self) -> Payload {
|
||||||
|
let hostname_value = self.cfg.hostname.clone().unwrap_or_else(hostname);
|
||||||
|
let container_id_value = self.cfg.container_id.clone().unwrap_or_else(container_id);
|
||||||
|
let orchestrator_value = self.cfg.orchestrator.clone().unwrap_or_else(orchestrator);
|
||||||
|
|
||||||
|
Payload {
|
||||||
|
type_: self.cfg.type_.clone(),
|
||||||
|
version: self.cfg.version.clone(),
|
||||||
|
name: self.cfg.name.clone(),
|
||||||
|
unique_install_id: self.install_id.clone(),
|
||||||
|
site: self.cfg.site.clone(),
|
||||||
|
environment: self.cfg.environment.clone(),
|
||||||
|
database_type: self.cfg.database_type.clone(),
|
||||||
|
database_name: self.cfg.database_name.clone(),
|
||||||
|
app_type: Some(self.app_type()),
|
||||||
|
hostname: non_empty(hostname_value),
|
||||||
|
port: self.cfg.port,
|
||||||
|
base_url: self.cfg.base_url.clone(),
|
||||||
|
internal_url: self.cfg.internal_url.clone(),
|
||||||
|
container_id: non_empty(container_id_value),
|
||||||
|
orchestrator: non_empty(orchestrator_value),
|
||||||
|
description: self.cfg.description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a single check-in request and returns Origin's response.
|
||||||
|
/// Failures are logged via `Config::logger` and also returned, so
|
||||||
|
/// callers that only want the fire-and-forget behavior of `run` can
|
||||||
|
/// ignore the returned error.
|
||||||
|
pub async fn checkin_once(&self) -> Result<CheckinResponse, Error> {
|
||||||
|
let payload = self.build_payload();
|
||||||
|
|
||||||
|
let response = match self
|
||||||
|
.http
|
||||||
|
.post(self.url())
|
||||||
|
.header("X-Origin-Service-Key", &self.cfg.service_key)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(err) => {
|
||||||
|
self.cfg
|
||||||
|
.logger
|
||||||
|
.warn("origin checkin: request failed", Some(&err));
|
||||||
|
return Err(Error::Request(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
self.cfg
|
||||||
|
.logger
|
||||||
|
.warn("origin checkin: non-2xx response", None);
|
||||||
|
return Err(Error::Status(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
match response.json::<CheckinResponse>().await {
|
||||||
|
Ok(body) => Ok(body),
|
||||||
|
Err(err) => {
|
||||||
|
self.cfg
|
||||||
|
.logger
|
||||||
|
.warn("origin checkin: decode response failed", Some(&err));
|
||||||
|
Err(Error::Decode(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an immediate check-in, then repeats every
|
||||||
|
/// `Config::interval_hours` until `cancel` is cancelled. Intended to be
|
||||||
|
/// spawned: `tokio::spawn(async move { client.run(cancel).await })`.
|
||||||
|
/// Check-in errors are logged via `Config::logger` and otherwise
|
||||||
|
/// ignored; call [`Client::checkin_once`] directly to observe them.
|
||||||
|
pub async fn run(&self, cancel: CancellationToken) {
|
||||||
|
let _ = self.checkin_once().await;
|
||||||
|
|
||||||
|
let interval = Duration::from_secs(u64::from(self.interval_hours()) * 3600);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
() = tokio::time::sleep(interval) => {
|
||||||
|
let _ = self.checkin_once().await;
|
||||||
|
}
|
||||||
|
() = cancel.cancelled() => {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(s: String) -> Option<String> {
|
||||||
|
if s.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Returns the OS hostname, or "" if it cannot be determined.
|
||||||
|
pub fn hostname() -> String {
|
||||||
|
gethostname::gethostname().to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first non-loopback IPv4 address bound to this host. It is a
|
||||||
|
/// LAN-facing address, not the host's public internet address, and is not
|
||||||
|
/// part of Origin's wire contract - it exists so callers can populate
|
||||||
|
/// `base_url`/`internal_url`/`description` themselves.
|
||||||
|
pub fn outbound_ip() -> String {
|
||||||
|
if_addrs::get_if_addrs()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|iface| !iface.is_loopback())
|
||||||
|
.find_map(|iface| match iface.ip() {
|
||||||
|
IpAddr::V4(v4) => Some(v4.to_string()),
|
||||||
|
IpAddr::V6(_) => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort detects the Docker/Kubernetes container ID this process
|
||||||
|
/// runs in. Returns "" outside a container or when detection fails; an
|
||||||
|
/// empty container ID is treated as "not containerized" rather than an
|
||||||
|
/// error.
|
||||||
|
pub fn container_id() -> String {
|
||||||
|
if let Ok(data) = fs::read_to_string("/proc/self/cgroup") {
|
||||||
|
let id = parse_cgroup_container_id(&data);
|
||||||
|
if !id.is_empty() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Path::new("/.dockerenv").exists() {
|
||||||
|
let h = hostname();
|
||||||
|
if is_container_id_like(&h) {
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort detects the orchestration environment: "kubernetes" under
|
||||||
|
/// Kubernetes, "docker" in a plain Docker container, "" otherwise.
|
||||||
|
pub fn orchestrator() -> String {
|
||||||
|
if env::var("KUBERNETES_SERVICE_HOST").is_ok_and(|v| !v.is_empty()) {
|
||||||
|
return "kubernetes".to_string();
|
||||||
|
}
|
||||||
|
if Path::new("/.dockerenv").exists() {
|
||||||
|
return "docker".to_string();
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts a container ID from /proc/self/cgroup content, handling both
|
||||||
|
/// cgroup v1 (".../docker/<id>") and cgroup v2 systemd-scope
|
||||||
|
/// (".../docker-<id>.scope") layouts.
|
||||||
|
pub fn parse_cgroup_container_id(data: &str) -> String {
|
||||||
|
for raw_line in data.lines() {
|
||||||
|
let line = raw_line.trim();
|
||||||
|
let idx = match line.rfind('/') {
|
||||||
|
Some(i) => i,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let mut segment = &line[idx + 1..];
|
||||||
|
segment = segment.strip_suffix(".scope").unwrap_or(segment);
|
||||||
|
segment = segment.strip_prefix("docker-").unwrap_or(segment);
|
||||||
|
if is_container_id_like(segment) {
|
||||||
|
return segment.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_container_id_like(s: &str) -> bool {
|
||||||
|
if s.len() < 12 || s.len() > 64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s.chars()
|
||||||
|
.all(|c| c.is_ascii_digit() || (c.is_ascii_lowercase() && c.is_ascii_hexdigit()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cgroup_v1_docker_path() {
|
||||||
|
let data =
|
||||||
|
"12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_cgroup_container_id(data),
|
||||||
|
"9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cgroup_v2_systemd_scope() {
|
||||||
|
let data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_cgroup_container_id(data),
|
||||||
|
"9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_empty_on_non_container_host() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_cgroup_container_id("0::/user.slice/user-1000.slice/session-2.scope\n"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_empty_for_empty_input() {
|
||||||
|
assert_eq!(parse_cgroup_container_id(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_container_id_like_accepts_valid_hex_id() {
|
||||||
|
assert!(is_container_id_like("9f8b7c6d5e4f"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_container_id_like_rejects_short_strings() {
|
||||||
|
assert!(!is_container_id_like("short"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_container_id_like_rejects_non_hex_strings() {
|
||||||
|
assert!(!is_container_id_like("session-2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_container_id_like_rejects_empty_strings() {
|
||||||
|
assert!(!is_container_id_like(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_is_not_empty() {
|
||||||
|
assert!(!hostname().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestrator_detects_kubernetes() {
|
||||||
|
std::env::set_var("KUBERNETES_SERVICE_HOST", "10.0.0.1");
|
||||||
|
assert_eq!(orchestrator(), "kubernetes");
|
||||||
|
std::env::remove_var("KUBERNETES_SERVICE_HOST");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! origin-client registers a running service with Origin
|
||||||
|
//! (origin.warky.dev)'s public check-in endpoint and re-pings it on an
|
||||||
|
//! interval, so Origin can track which instances of a service are alive,
|
||||||
|
//! their version, and where they run. A check-in failure is logged and
|
||||||
|
//! otherwise ignored: it must never affect the host application's own
|
||||||
|
//! availability.
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod host_info;
|
||||||
|
mod logger;
|
||||||
|
mod machine_id;
|
||||||
|
mod payload;
|
||||||
|
|
||||||
|
pub use client::{Client, Config, Error, DEFAULT_INTERVAL_HOURS, DEFAULT_URL};
|
||||||
|
pub use host_info::{container_id, hostname, orchestrator, outbound_ip};
|
||||||
|
pub use logger::{FnLogger, Logger, NoopLogger};
|
||||||
|
pub use machine_id::{default_install_id_path, detect_os_machine_id, resolve_machine_id};
|
||||||
|
pub use payload::{CheckinResponse, Payload};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use std::error::Error as StdError;
|
||||||
|
|
||||||
|
/// Logger receives non-fatal warnings from the client (request failures,
|
||||||
|
/// machine-id/persistence fallbacks). Implement it to route warnings into
|
||||||
|
/// your own logging framework.
|
||||||
|
pub trait Logger: Send + Sync {
|
||||||
|
fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NoopLogger discards every warning. The default when no logger is configured.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopLogger;
|
||||||
|
|
||||||
|
impl Logger for NoopLogger {
|
||||||
|
fn warn(&self, _msg: &str, _err: Option<&(dyn StdError + Send + Sync)>) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FnLogger adapts a closure to Logger.
|
||||||
|
pub struct FnLogger<F>(pub F)
|
||||||
|
where
|
||||||
|
F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync;
|
||||||
|
|
||||||
|
impl<F> Logger for FnLogger<F>
|
||||||
|
where
|
||||||
|
F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync,
|
||||||
|
{
|
||||||
|
fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>) {
|
||||||
|
(self.0)(msg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::logger::Logger;
|
||||||
|
|
||||||
|
/// Reads the real OS machine ID, or `None` if unavailable/unsupported.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn detect_os_machine_id() -> Option<String> {
|
||||||
|
for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
|
||||||
|
if let Ok(data) = fs::read_to_string(path) {
|
||||||
|
let id = data.trim();
|
||||||
|
if !id.is_empty() {
|
||||||
|
return Some(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the hardware IOPlatformUUID via `ioreg`, which is stable across
|
||||||
|
/// reboots and unique per physical/virtual Mac.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn detect_os_machine_id() -> Option<String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let output = Command::new("ioreg")
|
||||||
|
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let marker = "\"IOPlatformUUID\" = \"";
|
||||||
|
let start = text.find(marker)? + marker.len();
|
||||||
|
let rest = &text[start..];
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `MachineGuid` from the registry, which is generated at Windows
|
||||||
|
/// install time and stable across reboots.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn detect_os_machine_id() -> Option<String> {
|
||||||
|
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||||
|
let key = hklm.open_subkey("SOFTWARE\\Microsoft\\Cryptography").ok()?;
|
||||||
|
key.get_value("MachineGuid").ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No known OS-specific implementation on this platform; `resolve_machine_id`
|
||||||
|
/// falls back to a generated, persisted UUID.
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
|
pub fn detect_os_machine_id() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the fallback-UUID persistence path used when no explicit path
|
||||||
|
/// is given: the OS user-config directory, or the OS temp directory if
|
||||||
|
/// that cannot be determined.
|
||||||
|
pub fn default_install_id_path() -> PathBuf {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(dirs::config_dir)
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
base.join("origin-client").join("install-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the unique install ID to report to Origin, in priority order:
|
||||||
|
///
|
||||||
|
/// 1. `explicit`, if non-empty - a caller-supplied override.
|
||||||
|
/// 2. the real OS machine ID (`/etc/machine-id` on Linux, `IOPlatformUUID`
|
||||||
|
/// on macOS, `MachineGuid` on Windows), if readable.
|
||||||
|
/// 3. the ID already persisted at `path`, if present.
|
||||||
|
/// 4. a newly generated UUID, persisted to `path` for future runs.
|
||||||
|
///
|
||||||
|
/// OS-ID and persistence failures are logged as warnings, not returned as
|
||||||
|
/// errors: this always returns a usable ID.
|
||||||
|
pub fn resolve_machine_id(
|
||||||
|
explicit: Option<&str>,
|
||||||
|
path: Option<&Path>,
|
||||||
|
logger: &dyn Logger,
|
||||||
|
) -> String {
|
||||||
|
resolve_machine_id_with(detect_os_machine_id, explicit, path, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_machine_id_with(
|
||||||
|
detect: impl Fn() -> Option<String>,
|
||||||
|
explicit: Option<&str>,
|
||||||
|
path: Option<&Path>,
|
||||||
|
logger: &dyn Logger,
|
||||||
|
) -> String {
|
||||||
|
if let Some(id) = explicit.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
return id.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
match detect() {
|
||||||
|
Some(id) if !id.trim().is_empty() => return id.trim().to_string(),
|
||||||
|
_ => logger.warn(
|
||||||
|
"machine id: OS machine id unavailable, falling back to a generated id",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved_path: PathBuf = path
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(default_install_id_path);
|
||||||
|
|
||||||
|
if let Ok(data) = fs::read_to_string(&resolved_path) {
|
||||||
|
let existing = data.trim();
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return existing.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
if let Err(err) = persist_install_id(&resolved_path, &id) {
|
||||||
|
logger.warn(
|
||||||
|
"machine id: failed to persist generated id, using an ephemeral id for this run",
|
||||||
|
Some(&err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_install_id(path: &Path, id: &str) -> std::io::Result<()> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
if !dir.as_os_str().is_empty() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::write(path, format!("{id}\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::logger::FnLogger;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
fn temp_install_id_path() -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("origin-client-test-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir.join("install-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_override_wins() {
|
||||||
|
let got = resolve_machine_id_with(
|
||||||
|
|| Some("os-machine-id".to_string()),
|
||||||
|
Some("explicit-id"),
|
||||||
|
Some(&temp_install_id_path()),
|
||||||
|
&crate::logger::NoopLogger,
|
||||||
|
);
|
||||||
|
assert_eq!(got, "explicit-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_os_machine_id_when_available() {
|
||||||
|
let got = resolve_machine_id_with(
|
||||||
|
|| Some("os-machine-id".to_string()),
|
||||||
|
None,
|
||||||
|
Some(&temp_install_id_path()),
|
||||||
|
&crate::logger::NoopLogger,
|
||||||
|
);
|
||||||
|
assert_eq!(got, "os-machine-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_and_persists_when_os_id_unavailable() {
|
||||||
|
let path = temp_install_id_path();
|
||||||
|
|
||||||
|
let first = resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger);
|
||||||
|
assert!(!first.is_empty());
|
||||||
|
|
||||||
|
let second =
|
||||||
|
resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger);
|
||||||
|
assert_eq!(
|
||||||
|
first, second,
|
||||||
|
"second call should read the persisted id rather than generating a new one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logs_warning_when_os_id_unavailable() {
|
||||||
|
let warned = AtomicBool::new(false);
|
||||||
|
let logger = FnLogger(
|
||||||
|
|_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| {
|
||||||
|
warned.store(true, Ordering::SeqCst);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
resolve_machine_id_with(|| None, None, Some(&temp_install_id_path()), &logger);
|
||||||
|
|
||||||
|
assert!(warned.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Payload is the JSON body posted to Origin's public service-checkin
|
||||||
|
/// endpoint. Field names and requiredness mirror
|
||||||
|
/// `POST /api/public/service-checkin` as documented in
|
||||||
|
/// `origin/doc/service-checkin.md`: `type`, `version`, `name`, and
|
||||||
|
/// `unique_install_id` are required; everything else is optional and, when
|
||||||
|
/// omitted, does not overwrite existing instance data on Origin's side.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct Payload {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
pub version: String,
|
||||||
|
pub name: String,
|
||||||
|
pub unique_install_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub site: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub environment: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub database_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub database_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub app_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub internal_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub container_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub orchestrator: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CheckinResponse is returned by Origin after a successful check-in.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CheckinResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub service_instance_id: i64,
|
||||||
|
pub unique_install_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub pinged_at: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use origin_client::{Client, Config, FnLogger};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
fn test_config(url: String) -> Config {
|
||||||
|
Config {
|
||||||
|
url: Some(url),
|
||||||
|
service_key: "test-key".to_string(),
|
||||||
|
type_: "icy2".to_string(),
|
||||||
|
version: "v1.0.0".to_string(),
|
||||||
|
name: "icy2-test".to_string(),
|
||||||
|
site: Some("Test Site".to_string()),
|
||||||
|
environment: Some("test".to_string()),
|
||||||
|
install_id: Some("test-install-id".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn new_requires_fields() {
|
||||||
|
assert!(Client::new(Config::default()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkin_once_success() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.match_header("x-origin-service-key", "test-key")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"success":true,"service_instance_id":42,"unique_install_id":"test-install-id","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = Client::new(test_config(server.url())).unwrap();
|
||||||
|
let resp = client.checkin_once().await.unwrap();
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
assert!(resp.success);
|
||||||
|
assert_eq!(resp.service_instance_id, 42);
|
||||||
|
assert_eq!(resp.unique_install_id, "test-install-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkin_once_server_error_returns_error() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.with_status(500)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = Client::new(test_config(server.url())).unwrap();
|
||||||
|
assert!(client.checkin_once().await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkin_once_unreachable_does_not_panic() {
|
||||||
|
let client = Client::new(test_config("http://127.0.0.1:1".to_string())).unwrap();
|
||||||
|
let result = client.checkin_once().await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkin_once_logs_on_failure() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.with_status(500)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let warned = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let warned_clone = warned.clone();
|
||||||
|
|
||||||
|
let mut cfg = test_config(server.url());
|
||||||
|
cfg.logger = Arc::new(FnLogger(
|
||||||
|
move |_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| {
|
||||||
|
warned_clone.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let client = Client::new(cfg).unwrap();
|
||||||
|
let _ = client.checkin_once().await;
|
||||||
|
|
||||||
|
assert!(warned.load(std::sync::atomic::Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_checks_in_immediately_then_stops_on_cancel() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"success":true,"service_instance_id":1,"unique_install_id":"x","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#,
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = Client::new(test_config(server.url())).unwrap();
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
let cancel_clone = cancel.clone();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
client.run(cancel_clone).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The immediate check-in happens synchronously at the start of run();
|
||||||
|
// give the spawned task a moment to execute it, then cancel before the
|
||||||
|
// (24h) interval could ever fire again.
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
cancel.cancel();
|
||||||
|
handle.await.unwrap();
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user