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,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"`
|
||||
}
|
||||
Reference in New Issue
Block a user