// 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, } }