feat(fleet): add service type enum and infra scope

Replace the free-form service_type string with a ServiceType enum (ccms,
ccmc, ccb, cces, ccsa, ccnc, ccem). The short code is the canonical value
stored in the DB, the config-tree directory name and a NATS discovery
subject token; Description() gives the human-readable name.

Add a second scope alongside per-node agents: ScopeInfra covers
cluster-independent monitoring infrastructure services, which are
identified by (hostname, service_type) and carry an empty cluster.
InfraRegistry is the ScopeInfra sibling of Registry and shares the same
table, state machine and config-revision handshake. It deliberately
exposes no StartSweep: MarkStale ages rows by last_heartbeat regardless
of scope, so a single sweep goroutine per process covers both scopes.

On the repository side ServiceDB gains a Scope column and the two
queries the discovery publisher and infra enumeration need, ListActive()
and ListByScope(). ResetConnection() now also resets the fleet
repository singleton so tests get a fresh handle.

Migration 13 is edited in place rather than superseded by a new
migration: the service table is unreleased, so no existing deployment
has it yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 08:06:50 +02:00
co-authored by Claude Opus 5
parent 5f94ae3b68
commit ecb1ba2bbe
11 changed files with 618 additions and 11 deletions
+11 -6
View File
@@ -21,6 +21,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/ClusterCockpit/cc-backend/internal/repository"
@@ -36,7 +37,7 @@ var ErrUnknownInstance = errors.New("fleet: unknown or deregistered instance id"
type Service struct {
Cluster string
Hostname string
ServiceType string
ServiceType ServiceType
InstanceID string
State string
RegisteredAt time.Time
@@ -50,7 +51,7 @@ type Service struct {
type RegistrationRequest struct {
Cluster string
Hostname string
ServiceType string
ServiceType ServiceType
MetaData map[string]string
}
@@ -85,8 +86,11 @@ func NewRegistry(staleAfter time.Duration) *Registry {
// instance_id plus the config_revision it currently has on record (0 for a
// never-before-seen service, so the caller knows to pull its initial config).
func (r *Registry) Register(req RegistrationRequest) (*Registration, error) {
if req.Cluster == "" || req.Hostname == "" || req.ServiceType == "" {
return nil, errors.New("fleet: cluster, hostname and service_type are required")
if req.Cluster == "" || req.Hostname == "" {
return nil, errors.New("fleet: cluster and hostname are required")
}
if !req.ServiceType.Valid() {
return nil, fmt.Errorf("fleet: unknown service_type %q", req.ServiceType)
}
instanceID, err := generateInstanceID()
@@ -102,8 +106,9 @@ func (r *Registry) Register(req RegistrationRequest) (*Registration, error) {
svc := &repository.ServiceDB{
Cluster: req.Cluster,
Hostname: req.Hostname,
ServiceType: req.ServiceType,
ServiceType: string(req.ServiceType),
InstanceID: instanceID,
Scope: ScopeCluster,
RegisteredAt: time.Now().Unix(),
MetaData: metaJSON,
}
@@ -239,7 +244,7 @@ func toService(row *repository.ServiceDB) (*Service, error) {
svc := &Service{
Cluster: row.Cluster,
Hostname: row.Hostname,
ServiceType: row.ServiceType,
ServiceType: ServiceType(row.ServiceType),
InstanceID: row.InstanceID,
State: row.State,
RegisteredAt: time.Unix(row.RegisteredAt, 0),
+160
View File
@@ -0,0 +1,160 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package fleet
import (
"errors"
"fmt"
"time"
"github.com/ClusterCockpit/cc-backend/internal/repository"
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
)
// Scope values distinguish the two kinds of fleet member persisted in the
// shared "service" table:
//
// - ScopeCluster: per-node agents, identified by (cluster, hostname,
// service_type). This is the default and what Registry issues.
// - ScopeInfra: cluster-independent monitoring infrastructure services
// (metric stores, collectors, gateways). They are not tied to a single
// cluster, so their cluster column is empty and identity is
// (hostname, service_type). InfraRegistry issues these.
const (
ScopeCluster = "cluster"
ScopeInfra = "infra"
)
// InfraRegistrationRequest is what a cluster-independent monitoring
// infrastructure service posts to the REST registration endpoint. Unlike
// RegistrationRequest it has no Cluster field: these services span clusters.
type InfraRegistrationRequest struct {
Hostname string
ServiceType ServiceType
MetaData map[string]string
}
// InfraRegistry is the business-logic layer for cluster-independent monitoring
// infrastructure services. It is the ScopeInfra sibling of Registry and shares
// the same FleetRepository and "service" table; identity issuance, the
// pending/active/stale/deregistered state machine and the config-revision
// handshake are identical. The only differences are that registration takes no
// cluster and discovery is by scope rather than by cluster.
//
// Staleness: MarkStale (driven by StartSweep) ages any service by its
// last_heartbeat regardless of scope, so infra rows are already covered by the
// sweep. Do NOT start a second sweep goroutine here — exactly one StartSweep
// across the whole process is sufficient. InfraRegistry therefore deliberately
// exposes no StartSweep.
type InfraRegistry struct {
repo *repository.FleetRepository
}
// NewInfraRegistry returns an InfraRegistry backed by the singleton
// FleetRepository.
func NewInfraRegistry() *InfraRegistry {
return &InfraRegistry{
repo: repository.GetFleetRepository(),
}
}
// Register upserts a cluster-independent service's identity by
// (hostname, service_type) and returns a freshly issued instance_id plus the
// config_revision currently on record (0 for a never-before-seen service, so
// the caller knows to pull its initial config).
func (r *InfraRegistry) Register(req InfraRegistrationRequest) (*Registration, error) {
if req.Hostname == "" {
return nil, errors.New("fleet: hostname is required")
}
if !req.ServiceType.Valid() {
return nil, fmt.Errorf("fleet: unknown service_type %q", req.ServiceType)
}
instanceID, err := generateInstanceID()
if err != nil {
return nil, err
}
metaJSON, err := marshalMeta(req.MetaData)
if err != nil {
return nil, err
}
svc := &repository.ServiceDB{
Cluster: "",
Hostname: req.Hostname,
ServiceType: string(req.ServiceType),
InstanceID: instanceID,
Scope: ScopeInfra,
RegisteredAt: time.Now().Unix(),
MetaData: metaJSON,
}
id, err := r.repo.RegisterService(svc)
if err != nil {
return nil, err
}
stored, err := r.repo.GetByID(id)
if err != nil {
return nil, err
}
cclog.Infof("fleet: registered infra %s/%s as instance '%s'", req.Hostname, req.ServiceType, instanceID)
return &Registration{InstanceID: instanceID, ConfigRevision: stored.ConfigRevision}, nil
}
// Heartbeat refreshes liveness for an already-registered infra instance. It is
// a no-op for unknown or deregistered instance IDs — see the package doc for
// why that must hold when this is reachable over NATS.
func (r *InfraRegistry) Heartbeat(instanceID string, at time.Time) error {
affected, err := r.repo.Heartbeat(instanceID, at.Unix())
if err != nil {
return err
}
if affected == 0 {
return ErrUnknownInstance
}
return nil
}
// Deregister marks an infra instance as deregistered. Idempotent.
func (r *InfraRegistry) Deregister(instanceID string) error {
return r.repo.Deregister(instanceID)
}
// AckConfig records that instanceID has pulled configRevision. Called by the
// REST config-pull handler after it serves the config payload.
func (r *InfraRegistry) AckConfig(instanceID string, configRevision int64) error {
return r.repo.SetConfigRevision(instanceID, configRevision)
}
// Get returns a single infra service by instance_id.
func (r *InfraRegistry) Get(instanceID string) (*Service, error) {
svc, err := r.repo.GetByInstanceID(instanceID)
if err != nil {
return nil, err
}
return toService(svc)
}
// List returns all registered cluster-independent infrastructure services.
func (r *InfraRegistry) List() ([]*Service, error) {
rows, err := r.repo.ListByScope(ScopeInfra)
if err != nil {
return nil, err
}
services := make([]*Service, 0, len(rows))
for _, row := range rows {
svc, err := toService(row)
if err != nil {
return nil, err
}
services = append(services, svc)
}
return services, nil
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package fleet
import (
"errors"
"path/filepath"
"testing"
"time"
"github.com/ClusterCockpit/cc-backend/internal/repository"
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
_ "github.com/mattn/go-sqlite3"
)
// setupDB migrates a fresh temp database to the current schema and wires the
// repository singletons to it, isolated per test.
func setupDB(t *testing.T) {
t.Helper()
cclog.Init("warn", true)
dbfile := filepath.Join(t.TempDir(), "fleet.db")
if err := repository.ResetConnection(); err != nil {
t.Fatal(err)
}
if err := repository.MigrateDB(dbfile); err != nil {
t.Fatal(err)
}
repository.Connect(dbfile)
t.Cleanup(func() { repository.ResetConnection() })
}
func TestInfraRegistry(t *testing.T) {
setupDB(t)
reg := NewInfraRegistry()
t.Run("register requires hostname and a valid service_type", func(t *testing.T) {
if _, err := reg.Register(InfraRegistrationRequest{ServiceType: ServiceTypeMetricStore}); err == nil {
t.Fatal("expected error for missing hostname")
}
if _, err := reg.Register(InfraRegistrationRequest{Hostname: "ms01"}); err == nil {
t.Fatal("expected error for missing service_type")
}
if _, err := reg.Register(InfraRegistrationRequest{Hostname: "ms01", ServiceType: "bogus"}); err == nil {
t.Fatal("expected error for unknown service_type")
}
})
var instanceID string
t.Run("register issues identity with revision 0", func(t *testing.T) {
r, err := reg.Register(InfraRegistrationRequest{
Hostname: "ms01", ServiceType: ServiceTypeMetricStore,
MetaData: map[string]string{"version": "1.2.3"},
})
if err != nil {
t.Fatal(err)
}
if r.InstanceID == "" || r.ConfigRevision != 0 {
t.Fatalf("unexpected registration: %+v", r)
}
instanceID = r.InstanceID
})
t.Run("get returns infra scope and empty cluster", func(t *testing.T) {
svc, err := reg.Get(instanceID)
if err != nil {
t.Fatal(err)
}
if svc.Cluster != "" || svc.ServiceType != ServiceTypeMetricStore || svc.State != "pending" {
t.Fatalf("unexpected service: %+v", svc)
}
if svc.MetaData["version"] != "1.2.3" {
t.Fatalf("metadata not round-tripped: %+v", svc.MetaData)
}
})
t.Run("heartbeat activates known, rejects unknown", func(t *testing.T) {
if err := reg.Heartbeat(instanceID, time.Unix(5000, 0)); err != nil {
t.Fatal(err)
}
svc, _ := reg.Get(instanceID)
if svc.State != "active" || svc.LastHeartbeat == nil {
t.Fatalf("heartbeat did not activate: %+v", svc)
}
if err := reg.Heartbeat("unknown", time.Unix(5000, 0)); !errors.Is(err, ErrUnknownInstance) {
t.Fatalf("want ErrUnknownInstance, got %v", err)
}
})
t.Run("list returns only infra services", func(t *testing.T) {
// A cluster-scope agent must not appear in the infra listing.
cReg := NewRegistry(time.Hour)
if _, err := cReg.Register(RegistrationRequest{
Cluster: "fritz", Hostname: "node01", ServiceType: ServiceTypeCollector,
}); err != nil {
t.Fatal(err)
}
list, err := reg.List()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].ServiceType != ServiceTypeMetricStore {
t.Fatalf("unexpected infra list: %+v", list)
}
})
t.Run("ack config records revision, deregister is terminal", func(t *testing.T) {
if err := reg.AckConfig(instanceID, 99); err != nil {
t.Fatal(err)
}
svc, _ := reg.Get(instanceID)
if svc.ConfigRevision != 99 {
t.Fatalf("want revision 99, got %d", svc.ConfigRevision)
}
if err := reg.Deregister(instanceID); err != nil {
t.Fatal(err)
}
if err := reg.Heartbeat(instanceID, time.Unix(6000, 0)); !errors.Is(err, ErrUnknownInstance) {
t.Fatalf("heartbeat after deregister should fail: %v", err)
}
})
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package fleet
// ServiceType is the kind of cc-* service a fleet member is. It is a
// string-backed enum (mirroring schema.MonitoringState and the local
// ScopeCluster/ScopeInfra constants): the short code is the canonical value
// stored in the DB, used as the config-tree directory name, and used as a NATS
// discovery-subject token. Description() gives the human-readable name.
type ServiceType string
const (
ServiceTypeMetricStore ServiceType = "ccms" // cc-metric-store
ServiceTypeCollector ServiceType = "ccmc" // cc-metric-collector
ServiceTypeBackend ServiceType = "ccb" // cc-backend
ServiceTypeEventStore ServiceType = "cces" // cc-event-store
ServiceTypeSlurmAdapter ServiceType = "ccsa" // cc-slurm-adapter
ServiceTypeNodeController ServiceType = "ccnc" // cc-node-controller
ServiceTypeEnergyManager ServiceType = "ccem" // cc-energy-manager
)
// AllServiceTypes lists every valid service type. Order is stable so callers
// that iterate (e.g. the discovery publisher) produce deterministic output.
var AllServiceTypes = []ServiceType{
ServiceTypeMetricStore,
ServiceTypeCollector,
ServiceTypeBackend,
ServiceTypeEventStore,
ServiceTypeSlurmAdapter,
ServiceTypeNodeController,
ServiceTypeEnergyManager,
}
var serviceTypeDescriptions = map[ServiceType]string{
ServiceTypeMetricStore: "cc-metric-store",
ServiceTypeCollector: "cc-metric-collector",
ServiceTypeBackend: "cc-backend",
ServiceTypeEventStore: "cc-event-store",
ServiceTypeSlurmAdapter: "cc-slurm-adapter",
ServiceTypeNodeController: "cc-node-controller",
ServiceTypeEnergyManager: "cc-energy-manager",
}
// Valid reports whether t is a known service type.
func (t ServiceType) Valid() bool {
_, ok := serviceTypeDescriptions[t]
return ok
}
// Description returns the full human-readable service name (e.g. "cc-metric-store"),
// or the raw code if unknown.
func (t ServiceType) Description() string {
if d, ok := serviceTypeDescriptions[t]; ok {
return d
}
return string(t)
}
// relevantProviders is the single source of truth for which provider service
// types each consumer type needs to discover. Keep it here and edit in one
// place.
//
// Confirmed universal edge: every service must reach cc-backend (ccb) to
// register and pull its config, so ccb is relevant to all of them. Richer
// peer edges (a collector wanting the metric store, an energy manager wanting
// the node controller, …) are left commented for an operator to enable once the
// concrete topology is settled — they are intentionally not assumed here.
var relevantProviders = map[ServiceType][]ServiceType{
ServiceTypeMetricStore: {ServiceTypeBackend},
ServiceTypeCollector: {ServiceTypeBackend /*, ServiceTypeMetricStore */},
ServiceTypeEventStore: {ServiceTypeBackend},
ServiceTypeSlurmAdapter: {ServiceTypeBackend},
ServiceTypeNodeController: {ServiceTypeBackend},
ServiceTypeEnergyManager: {ServiceTypeBackend /*, ServiceTypeMetricStore, ServiceTypeNodeController */},
ServiceTypeBackend: {}, // ccb discovers no peers by default
}
// RelevantProviders returns the provider service types that consumer should
// discover. The returned slice must not be mutated by callers.
func RelevantProviders(consumer ServiceType) []ServiceType {
return relevantProviders[consumer]
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package fleet
import "testing"
func TestServiceTypeValid(t *testing.T) {
for _, st := range AllServiceTypes {
if !st.Valid() {
t.Errorf("%q should be valid", st)
}
}
for _, bad := range []ServiceType{"", "bogus", "metric-store", "CCMS"} {
if bad.Valid() {
t.Errorf("%q should be invalid", bad)
}
}
}
func TestServiceTypeDescription(t *testing.T) {
if got := ServiceTypeMetricStore.Description(); got != "cc-metric-store" {
t.Errorf("ccms description = %q, want cc-metric-store", got)
}
// Unknown code falls back to the raw string.
if got := ServiceType("bogus").Description(); got != "bogus" {
t.Errorf("unknown description = %q, want bogus", got)
}
}
func TestRelevantProviders(t *testing.T) {
// Confirmed universal edge: every non-ccb service must discover ccb.
for _, st := range AllServiceTypes {
if st == ServiceTypeBackend {
continue
}
rel := RelevantProviders(st)
found := false
for _, p := range rel {
if p == ServiceTypeBackend {
found = true
}
}
if !found {
t.Errorf("%q must have ccb as a relevant provider, got %v", st, rel)
}
}
// ccb discovers no peers by default.
if rel := RelevantProviders(ServiceTypeBackend); len(rel) != 0 {
t.Errorf("ccb should have no relevant providers, got %v", rel)
}
}