fleet

package
v2.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 19 Imported by: 0

README

Fleet client

Package fleet is the client for the cc-backend fleet service. With it a ClusterCockpit service (cc-metric-store, cc-metric-collector, cc-slurm-adapter, …) does the following:

  • registers with cc-backend and gets an instance id,
  • receives its configuration from the central configuration tree, with ETag-based polling,
  • stays marked alive by sending heartbeats over NATS, or over REST when NATS is unavailable,
  • receives discovery rosters of the peers it depends on,
  • deregisters on shutdown.

The package also holds the wire definitions shared by client and server: the service type codes, the registration bodies, the roster entry, and the line-protocol encoding of heartbeats and rosters.

For the server side (enabling the service, authoring the configuration tree) see internal/fleet/README.md in cc-backend.

Configuration

The application keeps a local fleet section. It only says how to reach cc-backend and never comes from the fleet service itself.

{
  "fleet": {
    "url": "https://cc.example.org",
    "token": "<jwt with api role>",
    "cluster": "fritz",
    "heartbeat-interval": "30s",
    "config-poll-interval": "60s",
    "heartbeat-subject": "cc.fleet.event",
    "cache-path": "/var/lib/cc-metric-collector/fleet-config.json"
  }
}
Key Default Meaning
url — (required) cc-backend base URL
token — JWT with the api role
cluster — (infra scope) Cluster of this service; empty registers in infra scope
hostname system hostname Hostname to register with
heartbeat-interval 30s Keep it well below cc-backend's stale-after
config-poll-interval 60s The unchanged case is a header-only round trip
heartbeat-subject — (REST heartbeats) NATS heartbeat subject, as configured in cc-backend
discovery-subject-prefix cc.fleet.discovery Prefix of the roster subjects
cache-path — (no cache) Last configuration, for starting while cc-backend is down
timeout 10s Timeout of a single HTTP request

CC_FLEET_TOKEN, or the file named by CC_FLEET_TOKEN_FILE, overrides token. ConfigSchema holds the JSON schema of the section.

Usage

cfg, err := fleet.ParseConfig(ccconfig.GetPackageConfig("fleet"))
client, err := fleet.New(fleet.Options{
    Config:      cfg,
    ServiceType: fleet.ServiceMetricCollector,
    Meta:        map[string]string{"version": version}, // broadcast: no secrets
    NATS:        natsClient.Load, // func() *nats.Client, nil until connected
})

initial, err := client.Bootstrap(ctx, 10*time.Second)
// err == nil:             initial.Source is SourceFleet, or SourceNone (nothing authored)
// errors.Is(err, fleet.ErrUnreachable): initial is the cached config (SourceCache) or none
// errors.Is(err, fleet.ErrUnauthorized): token or IP allowlist problem
applyConfig(initial.Config) // nil: run with the local configuration

go client.Run(ctx)
defer client.Close(shutdownCtx) // stops Run, then deregisters

for {
    select {
    case u := <-client.Configs():
        applyConfig(u.Config) // only sent when the configuration really changed
    case providers := <-client.Rosters():
        replaceProviders(providers) // full list, not a delta
    case <-ctx.Done():
        return
    }
}

Configs() and Rosters() each hold at most one value. An update that has not been read yet is replaced by the newer one, so a slow main loop always sees the latest state and never blocks the client.

Merging the received configuration with the local one is left to the application. Which keys must stay local (the fleet section itself, paths, credentials) and which need a restart differs from service to service.

Behaviour

  • Heartbeats go to heartbeat-subject while the NATS client is connected, and over REST otherwise. A disconnected NATS client never buffers them silently.
  • Unknown instance. When cc-backend answers 404 (the id was rotated or deregistered), Run registers again and pulls the configuration at once. NATS heartbeats cannot detect this, so the config poll does.
  • Failures are logged once as a warning, repeated at debug level and followed by an info message on recovery. Registration attempts back off exponentially (1 s up to 5 min), so a rejected token does not hammer cc-backend.
  • Change detection compares the configuration body. A cosmetic edit on the server, or a response without an ETag, does not deliver the same configuration again.
  • Discovery. Run subscribes to <prefix>.<cluster|infra>.<service type> as soon as the NATS provider returns a connected client, and again if it starts returning a different client.
  • Cache. The cache file is written atomically with mode 0600, because the configuration may contain secrets. It never contains the instance id and is ignored if it was written for another service type, scope, cluster or host. A 204 from cc-backend deletes it.

Low-level API

Register, PullConfig, Heartbeat and Deregister are exported for applications that drive their own loop. Errors unwrap to ErrUnknownInstance (404), ErrUnauthorized (401/403) or ErrNotRegistered. Other unexpected statuses are *StatusError.

EncodeHeartbeat, ParseHeartbeat, DecodeHeartbeats, EncodeRoster, DecodeRoster, DiscoverySubject and Bucket implement the NATS wire format for both sides.

Documentation

Overview

Package fleet is the client side of the cc-backend fleet service. With it a ClusterCockpit service registers with cc-backend, receives its configuration from the central configuration tree, keeps itself marked as alive and learns about the peers it depends on.

The package also holds the wire definitions shared by client and server: the service type codes, the registration request and response bodies, the discovery roster entry, and the line-protocol encoding of heartbeats and rosters. Both sides should use them instead of keeping their own copies.

Lifecycle

A member walks through these steps:

  1. Register over authenticated REST and receive an instance id.
  2. Pull the merged configuration. The ETag makes later polls a header-only round trip, and 204 means "no configuration authored", not an error.
  3. Send a heartbeat periodically, over NATS when a heartbeat subject is configured and the connection is up, and over REST otherwise.
  4. Optionally, receive discovery rosters on <prefix>.<cluster|infra>.<service type>. Each roster replaces the previous list in full; it is not a delta.
  5. Deregister on shutdown.

Client.Bootstrap covers steps 1 and 2 at startup and falls back to the optional on-disk cache when cc-backend cannot be reached. Client.Run drives steps 2 to 4, re-registers when cc-backend reports the instance as unknown, and delivers changes on Client.Configs and Client.Rosters. Client.Close stops Run and performs step 5.

Security

The instance id is a bearer credential for heartbeat, config pull and deregistration. The client never logs it and never writes it to the cache file. Registration metadata (Options.Meta) is broadcast unauthenticated in discovery rosters, so it must carry connection hints only, never secrets. The cached configuration may contain secrets, so the cache file is written with mode 0600.

Example

Example shows the lifecycle of a fleet member: bootstrap, run, apply updates from the main loop, close on shutdown.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"os/signal"
	"sync/atomic"
	"syscall"
	"time"

	ccconfig "github.com/ClusterCockpit/cc-lib/v2/ccConfig"
	"github.com/ClusterCockpit/cc-lib/v2/fleet"
	"github.com/ClusterCockpit/cc-lib/v2/nats"
)

// natsClient is set once the application has connected to NATS, which may
// happen only after the fleet configuration delivered the NATS settings.
var natsClient atomic.Pointer[nats.Client]

func applyConfig(json.RawMessage)             {}
func useProviders(providers []fleet.Provider) {}

// Example shows the lifecycle of a fleet member: bootstrap, run, apply
// updates from the main loop, close on shutdown.
func main() {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	cfg, err := fleet.ParseConfig(ccconfig.GetPackageConfig("fleet"))
	if err != nil {
		log.Fatal(err)
	}
	client, err := fleet.New(fleet.Options{
		Config:      cfg,
		ServiceType: fleet.ServiceMetricCollector,
		Meta:        map[string]string{"version": "1.0.0"},
		NATS:        natsClient.Load, // nil until NATS is connected: REST heartbeats
	})
	if err != nil {
		log.Fatal(err)
	}

	// Start with the fleet configuration, the cached one, or none at all.
	initial, err := client.Bootstrap(ctx, 10*time.Second)
	if errors.Is(err, fleet.ErrUnreachable) {
		log.Printf("cc-backend unreachable, starting with %s configuration", initial.Source)
	} else if err != nil {
		log.Printf("fleet bootstrap: %v", err)
	}
	applyConfig(initial.Config) // nil: no fleet configuration, use local defaults

	go func() { _ = client.Run(ctx) }()
	defer func() {
		closeCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		defer cancel()
		if err := client.Close(closeCtx); err != nil {
			log.Printf("fleet deregistration: %v", err)
		}
	}()

	for {
		select {
		case <-ctx.Done():
			return
		case u := <-client.Configs():
			applyConfig(u.Config)
		case providers := <-client.Rosters():
			useProviders(providers) // full replacement, not a delta
		}
	}
}

Index

Examples

Constants

View Source
const (
	DefaultHeartbeatInterval  = 30 * time.Second
	DefaultConfigPollInterval = 60 * time.Second
	DefaultTimeout            = 10 * time.Second
)

Defaults for the optional durations.

View Source
const (
	// HeartbeatMeasurement is the line-protocol measurement of a heartbeat.
	HeartbeatMeasurement = "fleet"
	// HeartbeatFunction is the value of the "function" tag of a heartbeat,
	// the only function cc-backend accepts on the heartbeat subject.
	HeartbeatFunction = "heartbeat"
	// DiscoveryMeasurement is the line-protocol measurement of a roster.
	DiscoveryMeasurement = "fleetdiscovery"
	// DefaultDiscoveryPrefix is the default NATS subject prefix of rosters.
	DefaultDiscoveryPrefix = "cc.fleet.discovery"
	// InfraBucket is the discovery bucket of infra-scope services.
	InfraBucket = "infra"
	// HeaderConfigRevision carries the config revision next to the ETag.
	HeaderConfigRevision = "X-CC-Config-Revision"
)

Wire constants shared by client and server.

View Source
const ConfigSchema = `` /* 2001-byte string literal not displayed */
View Source
const EnvToken = "CC_FLEET_TOKEN"

EnvToken overrides Config.Token. CC_FLEET_TOKEN_FILE names a file holding the token instead; see util.SecretFromEnv for the precedence rules.

Variables

View Source
var (
	// ErrUnknownInstance is returned when cc-backend does not know the
	// instance id (never registered, rotated or deregistered). The client
	// forgets the id; registering again issues a new one.
	ErrUnknownInstance = errors.New("fleet: unknown or deregistered instance")
	// ErrUnauthorized is returned for 401 and 403: the token is missing,
	// invalid or lacks the api role, or the source IP is not allowlisted.
	ErrUnauthorized = errors.New("fleet: request not authorized")
	// ErrNotRegistered is returned by calls that need an instance id before
	// Register succeeded.
	ErrNotRegistered = errors.New("fleet: not registered")
	// ErrUnreachable is returned by Bootstrap when cc-backend could not be
	// reached at all.
	ErrUnreachable = errors.New("fleet: cc-backend unreachable")
)

ServiceTypes lists all known service types.

Functions

func Bucket

func Bucket(cluster string) string

Bucket returns the discovery bucket of a member: its cluster for a cluster-scope service, or InfraBucket when cluster is empty.

func DecodeHeartbeats

func DecodeHeartbeats(data []byte) ([]string, error)

DecodeHeartbeats decodes a heartbeat message, which may hold several lines, and returns the instance ids. It fails on the first invalid line.

func DiscoverySubject

func DiscoverySubject(prefix, bucket string, consumer ServiceType) string

DiscoverySubject returns the NATS subject on which a consumer of the given type in the given bucket receives its roster. An empty prefix selects DefaultDiscoveryPrefix.

func EncodeHeartbeat

func EncodeHeartbeat(t time.Time, instanceIDs ...string) ([]byte, error)

EncodeHeartbeat encodes one heartbeat line per instance id. Several lines in one message are how an edge aggregator batches heartbeats. cc-backend ignores the timestamp, but the line protocol requires one.

func EncodeRoster

func EncodeRoster(bucket string, consumer ServiceType, providers []Provider, t time.Time) ([]byte, error)

EncodeRoster encodes the roster for one (bucket, consumer type) subject.

func ParseHeartbeat

func ParseHeartbeat(m lp.CCMessage) (string, error)

ParseHeartbeat validates one decoded message as a heartbeat and returns its instance id. Unknown JSON keys in the payload are rejected.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a fleet member. Its methods are safe for concurrent use.

func New

func New(opts Options) (*Client, error)

New validates the options and creates a client. It does not contact cc-backend. The token is resolved from CC_FLEET_TOKEN, CC_FLEET_TOKEN_FILE or Config.Token, in that order.

func (*Client) Bootstrap

func (c *Client) Bootstrap(ctx context.Context, timeout time.Duration) (Update, error)

Bootstrap gets the configuration to start with. It loads the cache file (if configured), then registers and pulls within timeout.

On success the returned update has Source SourceFleet, or SourceNone when no configuration is authored for this member. On failure the cached configuration (SourceCache) or an empty update (SourceNone) is returned together with the error: ErrUnreachable for transport errors, a *StatusError otherwise. The application can start in either case; Run keeps trying to register. Call Bootstrap at most once, before Run.

func (*Client) Close

func (c *Client) Close(ctx context.Context) error

Close stops Run, waits for it to return and deregisters, so the member leaves the discovery rosters at once. ctx bounds the wait and the deregistration. Skipping Close is not fatal: cc-backend marks the member stale after stale-after.

func (*Client) Configs

func (c *Client) Configs() <-chan Update

Configs delivers configuration changes found by Run. The channel holds at most one value; an unread update is replaced by the newer one.

func (*Client) Deregister

func (c *Client) Deregister(ctx context.Context) error

Deregister removes the member from the fleet and from discovery rosters at once. It is idempotent: without an instance id, or when cc-backend does not know the id any more, it returns nil.

func (*Client) Heartbeat

func (c *Client) Heartbeat(ctx context.Context) error

Heartbeat marks the member alive. It publishes on the heartbeat subject when one is configured and the NATS client is connected, and falls back to REST otherwise, so heartbeats are never buffered silently by a disconnected client. Only the REST path can detect an unknown instance; with NATS, Run relies on the config poll for that.

func (*Client) PullConfig

func (c *Client) PullConfig(ctx context.Context) (Update, bool, error)

PullConfig polls the configuration. changed is true when the delivered configuration differs from the previous one, including the transition to "no configuration" (204, Update.Config nil). An unchanged answer (304, or 200 with an identical body) returns the current configuration with changed false.

func (*Client) Register

func (c *Client) Register(ctx context.Context) (RegisterResponse, error)

Register registers the member and stores the new instance id. Any previous id becomes invalid. The cached ETag is kept, so an unchanged configuration is not delivered again after re-registration.

func (*Client) Registered

func (c *Client) Registered() bool

Registered reports whether the client holds an instance id.

func (*Client) Rosters

func (c *Client) Rosters() <-chan []Provider

Rosters delivers discovery rosters. Each one is the full provider list, not a delta. The channel holds at most one value; an unread roster is replaced by the newer one.

func (*Client) Run

func (c *Client) Run(ctx context.Context) error

Run keeps the membership alive until ctx is cancelled or Close is called. It pulls the configuration at once and then every config-poll-interval, sends a heartbeat every heartbeat-interval, (re-)registers whenever needed, and subscribes to the discovery roster once the NATS client is connected. Changes are delivered on Configs and Rosters. Run returns nil on cancellation and an error only if it is already running or closed.

func (*Client) Scope

func (c *Client) Scope() Scope

Scope returns the registration scope, derived from the configured cluster.

type Config

type Config struct {
	URL                    string `json:"url"`                                // cc-backend base URL
	Token                  string `json:"token,omitempty"`                    // JWT with the api role
	Cluster                string `json:"cluster,omitempty"`                  // empty selects infra scope
	Hostname               string `json:"hostname,omitempty"`                 // default os.Hostname()
	HeartbeatInterval      string `json:"heartbeat-interval,omitempty"`       // default 30s
	ConfigPollInterval     string `json:"config-poll-interval,omitempty"`     // default 60s
	HeartbeatSubject       string `json:"heartbeat-subject,omitempty"`        // empty: REST heartbeats only
	DiscoverySubjectPrefix string `json:"discovery-subject-prefix,omitempty"` // default DefaultDiscoveryPrefix
	CachePath              string `json:"cache-path,omitempty"`               // empty: no cache
	Timeout                string `json:"timeout,omitempty"`                  // HTTP timeout, default 10s
}

Config is the local fleet section of an application's configuration. It tells the client how to reach cc-backend; it never comes from the fleet service itself.

func ParseConfig

func ParseConfig(raw json.RawMessage) (Config, error)

ParseConfig decodes and validates a fleet configuration section. Unknown keys and malformed durations are errors.

type Options

type Options struct {
	Config      Config
	ServiceType ServiceType
	// Meta is registration metadata. It is broadcast unauthenticated in
	// discovery rosters: connection hints only, never secrets.
	Meta map[string]string
	// NATS returns the NATS client used for heartbeats and discovery. It is
	// called on every use and may return nil until a connection exists, for
	// example because the NATS settings arrive with the fleet configuration.
	// Without it the client works over REST only.
	NATS func() *nats.Client
	// HTTPClient overrides the HTTP client, e.g. for custom TLS. The default
	// uses Config.Timeout.
	HTTPClient *http.Client
}

Options configures a Client.

type Provider

type Provider struct {
	Type     ServiceType       `json:"type"`
	Hostname string            `json:"hostname"`
	State    string            `json:"state"`
	Meta     map[string]string `json:"meta,omitempty"`
}

Provider is one entry of a discovery roster.

func DecodeRoster

func DecodeRoster(data []byte) ([]Provider, error)

DecodeRoster decodes a roster message into the full provider list. An empty roster yields an empty, non-nil slice.

type RegisterRequest

type RegisterRequest struct {
	Cluster     string            `json:"cluster,omitempty"`
	Hostname    string            `json:"hostname"`
	ServiceType ServiceType       `json:"serviceType"`
	MetaData    map[string]string `json:"metaData,omitempty"`
}

RegisterRequest is the body of POST /api/fleet/register/{cluster|infra}/.

type RegisterResponse

type RegisterResponse struct {
	InstanceID string `json:"instanceId"`
	// ConfigRevision is the revision this identity last acknowledged, or 0 if
	// it never pulled a configuration.
	ConfigRevision int64 `json:"configRevision"`
}

RegisterResponse is the body of a successful (201) registration.

type Scope

type Scope string

Scope selects the registration endpoint and with it the configuration layers and the discovery bucket of a member.

const (
	// ScopeCluster is for services that belong to exactly one cluster.
	ScopeCluster Scope = "cluster"
	// ScopeInfra is for cluster-independent monitoring infrastructure.
	ScopeInfra Scope = "infra"
)

type ServiceType

type ServiceType string

ServiceType is the short code a fleet member declares itself as. It is used as a database value, a directory name in the configuration tree and a NATS subject token.

const (
	ServiceMetricStore     ServiceType = "ccms" // cc-metric-store
	ServiceMetricCollector ServiceType = "ccmc" // cc-metric-collector
	ServiceBackend         ServiceType = "ccb"  // cc-backend
	ServiceEventStore      ServiceType = "cces" // cc-event-store
	ServiceSlurmAdapter    ServiceType = "ccsa" // cc-slurm-adapter
	ServiceNodeController  ServiceType = "ccnc" // cc-node-controller
	ServiceEnergyManager   ServiceType = "ccem" // cc-energy-manager
)

func (ServiceType) Valid

func (t ServiceType) Valid() bool

Valid reports whether t is a known service type.

type Source

type Source int

Source says where a configuration update came from.

const (
	// SourceNone means no fleet configuration applies to this member; run
	// with the local or built-in configuration.
	SourceNone Source = iota
	// SourceFleet is a configuration received from cc-backend.
	SourceFleet
	// SourceCache is the last known configuration, read from the cache file
	// because cc-backend was unreachable.
	SourceCache
)

func (Source) String

func (s Source) String() string

type StatusError

type StatusError struct {
	Op     string // register, config, heartbeat, deregister
	Code   int
	Status string
	Detail string // start of the response body, if any
}

StatusError reports an unexpected HTTP status. It unwraps to ErrUnknownInstance for 404 and to ErrUnauthorized for 401 and 403.

func (*StatusError) Error

func (e *StatusError) Error() string

func (*StatusError) Unwrap

func (e *StatusError) Unwrap() error

type Update

type Update struct {
	// Config is the merged configuration object, or nil when no fleet
	// configuration applies (Source is SourceNone).
	Config   json.RawMessage
	Revision string
	Source   Source
}

Update is a configuration delivered to the application.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL