Private
Public Access
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>
214 lines
6.4 KiB
Go
214 lines
6.4 KiB
Go
// 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,
|
|
}
|
|
}
|