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
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
# Go
|
||||
go/*.test
|
||||
go/coverage.out
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
# Node
|
||||
js/node_modules/
|
||||
js/dist/
|
||||
js/coverage/
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# 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.*
|
||||
# Rust
|
||||
rust/target/
|
||||
Cargo.lock
|
||||
|
||||
# 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
|
||||
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