Files
cc-backend/internal/fleet/services_test.go
T
moebiusbandandClaude Opus 5 ecb1ba2bbe 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>
2026-08-27 08:06:50 +02:00

128 lines
3.8 KiB
Go

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