mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-08-31 00:47:15 +02:00
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>
271 lines
8.9 KiB
Go
271 lines
8.9 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 repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"sync"
|
|
|
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
|
"github.com/ClusterCockpit/cc-lib/v2/lrucache"
|
|
sq "github.com/Masterminds/squirrel"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
var (
|
|
fleetRepoOnce sync.Once
|
|
fleetRepoInstance *FleetRepository
|
|
)
|
|
|
|
type FleetRepository struct {
|
|
DB *sqlx.DB
|
|
cache *lrucache.Cache
|
|
driver string
|
|
}
|
|
|
|
func GetFleetRepository() *FleetRepository {
|
|
fleetRepoOnce.Do(func() {
|
|
db := GetConnection()
|
|
|
|
fleetRepoInstance = &FleetRepository{
|
|
DB: db.DB,
|
|
driver: db.Driver,
|
|
cache: lrucache.New(repoConfig.CacheSize),
|
|
}
|
|
})
|
|
return fleetRepoInstance
|
|
}
|
|
|
|
// ServiceDB is the row shape of the "service" table: one row per registered
|
|
// (cluster, hostname, service_type) fleet member.
|
|
type ServiceDB struct {
|
|
ID int64 `db:"id"`
|
|
Cluster string `db:"cluster"`
|
|
Hostname string `db:"hostname"`
|
|
ServiceType string `db:"service_type"`
|
|
InstanceID string `db:"instance_id"`
|
|
State string `db:"state"`
|
|
RegisteredAt int64 `db:"registered_at"`
|
|
LastHeartbeat sql.NullInt64 `db:"last_heartbeat"`
|
|
ConfigRevision int64 `db:"config_revision"`
|
|
MetaData sql.NullString `db:"meta_data"`
|
|
// Scope is 'cluster' for per-node agents (the default) or 'infra' for
|
|
// cluster-independent monitoring infrastructure services. Infra rows carry
|
|
// an empty cluster.
|
|
Scope string `db:"scope"`
|
|
}
|
|
|
|
const namedServiceInsert string = `
|
|
INSERT INTO service (cluster, hostname, service_type, instance_id, scope, state, registered_at, config_revision, meta_data)
|
|
VALUES (:cluster, :hostname, :service_type, :instance_id, :scope, 'pending', :registered_at, 0, :meta_data);`
|
|
|
|
// RegisterService upserts a service by (cluster, hostname, service_type): a
|
|
// service registering for the first time is inserted with config_revision 0;
|
|
// one re-registering (e.g. after a restart) keeps its existing last_heartbeat
|
|
// and config_revision but gets a fresh instance_id and is reset to 'pending'
|
|
// until its next heartbeat. Returns the row id.
|
|
func (r *FleetRepository) RegisterService(svc *ServiceDB) (int64, error) {
|
|
var id int64
|
|
err := sq.Select("id").From("service").
|
|
Where("cluster = ?", svc.Cluster).
|
|
Where("hostname = ?", svc.Hostname).
|
|
Where("service_type = ?", svc.ServiceType).
|
|
RunWith(r.DB).QueryRow().Scan(&id)
|
|
|
|
switch err {
|
|
case nil:
|
|
if _, uerr := sq.Update("service").
|
|
Set("instance_id", svc.InstanceID).
|
|
Set("scope", svc.Scope).
|
|
Set("state", "pending").
|
|
Set("registered_at", svc.RegisteredAt).
|
|
Set("meta_data", svc.MetaData).
|
|
Where("id = ?", id).
|
|
RunWith(r.DB).Exec(); uerr != nil {
|
|
cclog.Errorf("Error while re-registering service '%s/%s/%s': %v", svc.Cluster, svc.Hostname, svc.ServiceType, uerr)
|
|
return 0, uerr
|
|
}
|
|
return id, nil
|
|
case sql.ErrNoRows:
|
|
res, ierr := r.DB.NamedExec(namedServiceInsert, svc)
|
|
if ierr != nil {
|
|
cclog.Errorf("Error while registering service '%s/%s/%s': %v", svc.Cluster, svc.Hostname, svc.ServiceType, ierr)
|
|
return 0, ierr
|
|
}
|
|
return res.LastInsertId()
|
|
default:
|
|
cclog.Errorf("Error while looking up service '%s/%s/%s': %v", svc.Cluster, svc.Hostname, svc.ServiceType, err)
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
// Heartbeat marks the instance active as of timestamp. It never creates a row:
|
|
// the returned row count is 0 for an unknown or deregistered instance_id, so
|
|
// callers (e.g. an unauthenticated NATS consumer) can tell a spoofed/expired
|
|
// heartbeat from a real one without the repository silently upserting it.
|
|
func (r *FleetRepository) Heartbeat(instanceID string, timestamp int64) (int64, error) {
|
|
res, err := sq.Update("service").
|
|
Set("last_heartbeat", timestamp).
|
|
Set("state", "active").
|
|
Where("instance_id = ?", instanceID).
|
|
Where("state <> ?", "deregistered").
|
|
RunWith(r.DB).Exec()
|
|
if err != nil {
|
|
cclog.Errorf("Error while recording heartbeat for instance '%s': %v", instanceID, err)
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
// MarkStale flips 'active' services with last_heartbeat older than cutoff to
|
|
// 'stale'. Intended to be called periodically by a sweep goroutine.
|
|
func (r *FleetRepository) MarkStale(cutoff int64) (int64, error) {
|
|
res, err := sq.Update("service").
|
|
Set("state", "stale").
|
|
Where("state = ?", "active").
|
|
Where("last_heartbeat < ?", cutoff).
|
|
RunWith(r.DB).Exec()
|
|
if err != nil {
|
|
cclog.Errorf("Error while marking stale services (cutoff %d): %v", cutoff, err)
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
func (r *FleetRepository) Deregister(instanceID string) error {
|
|
if _, err := sq.Update("service").
|
|
Set("state", "deregistered").
|
|
Where("instance_id = ?", instanceID).
|
|
RunWith(r.DB).Exec(); err != nil {
|
|
cclog.Errorf("Error while deregistering instance '%s': %v", instanceID, err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetConfigRevision records which config revision a service last received.
|
|
// Called after the service pulls its config over REST.
|
|
func (r *FleetRepository) SetConfigRevision(instanceID string, revision int64) error {
|
|
if _, err := sq.Update("service").
|
|
Set("config_revision", revision).
|
|
Where("instance_id = ?", instanceID).
|
|
RunWith(r.DB).Exec(); err != nil {
|
|
cclog.Errorf("Error while setting config revision for instance '%s': %v", instanceID, err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var serviceColumns = []string{
|
|
"id", "cluster", "hostname", "service_type", "instance_id",
|
|
"state", "registered_at", "last_heartbeat", "config_revision", "meta_data", "scope",
|
|
}
|
|
|
|
func scanService(row interface{ Scan(...any) error }) (*ServiceDB, error) {
|
|
svc := &ServiceDB{}
|
|
if err := row.Scan(&svc.ID, &svc.Cluster, &svc.Hostname, &svc.ServiceType, &svc.InstanceID,
|
|
&svc.State, &svc.RegisteredAt, &svc.LastHeartbeat, &svc.ConfigRevision, &svc.MetaData, &svc.Scope); err != nil {
|
|
return nil, err
|
|
}
|
|
return svc, nil
|
|
}
|
|
|
|
func (r *FleetRepository) GetByID(id int64) (*ServiceDB, error) {
|
|
row := sq.Select(serviceColumns...).From("service").Where("id = ?", id).RunWith(r.DB).QueryRow()
|
|
svc, err := scanService(row)
|
|
if err != nil {
|
|
cclog.Errorf("Error while querying service id '%d': %v", id, err)
|
|
return nil, err
|
|
}
|
|
return svc, nil
|
|
}
|
|
|
|
func (r *FleetRepository) GetByInstanceID(instanceID string) (*ServiceDB, error) {
|
|
row := sq.Select(serviceColumns...).From("service").Where("instance_id = ?", instanceID).RunWith(r.DB).QueryRow()
|
|
svc, err := scanService(row)
|
|
if err != nil {
|
|
cclog.Errorf("Error while querying service instance '%s': %v", instanceID, err)
|
|
return nil, err
|
|
}
|
|
return svc, nil
|
|
}
|
|
|
|
func (r *FleetRepository) ListByCluster(cluster string) ([]*ServiceDB, error) {
|
|
rows, err := sq.Select(serviceColumns...).From("service").
|
|
Where("cluster = ?", cluster).
|
|
OrderBy("hostname ASC", "service_type ASC").
|
|
RunWith(r.DB).Query()
|
|
if err != nil {
|
|
cclog.Errorf("Error while listing services for cluster '%s': %v", cluster, err)
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
services := make([]*ServiceDB, 0)
|
|
for rows.Next() {
|
|
svc, err := scanService(rows)
|
|
if err != nil {
|
|
cclog.Warn("Error while scanning rows (ListByCluster)")
|
|
return nil, err
|
|
}
|
|
services = append(services, svc)
|
|
}
|
|
return services, rows.Err()
|
|
}
|
|
|
|
// ListActive returns all services currently in the 'active' state, ordered by
|
|
// cluster then hostname. It is the roster source for the fleet discovery
|
|
// publisher: only live services are advertised to peers.
|
|
func (r *FleetRepository) ListActive() ([]*ServiceDB, error) {
|
|
rows, err := sq.Select(serviceColumns...).From("service").
|
|
Where("state = ?", "active").
|
|
OrderBy("cluster ASC", "hostname ASC").
|
|
RunWith(r.DB).Query()
|
|
if err != nil {
|
|
cclog.Errorf("Error while listing active services: %v", err)
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
services := make([]*ServiceDB, 0)
|
|
for rows.Next() {
|
|
svc, err := scanService(rows)
|
|
if err != nil {
|
|
cclog.Warn("Error while scanning rows (ListActive)")
|
|
return nil, err
|
|
}
|
|
services = append(services, svc)
|
|
}
|
|
return services, rows.Err()
|
|
}
|
|
|
|
// ListByScope returns all services with the given scope ('cluster' or
|
|
// 'infra'), ordered by hostname/service_type. Used to enumerate
|
|
// cluster-independent monitoring infrastructure services, which carry an empty
|
|
// cluster and therefore are not discoverable via ListByCluster.
|
|
func (r *FleetRepository) ListByScope(scope string) ([]*ServiceDB, error) {
|
|
rows, err := sq.Select(serviceColumns...).From("service").
|
|
Where("scope = ?", scope).
|
|
OrderBy("hostname ASC", "service_type ASC").
|
|
RunWith(r.DB).Query()
|
|
if err != nil {
|
|
cclog.Errorf("Error while listing services for scope '%s': %v", scope, err)
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
services := make([]*ServiceDB, 0)
|
|
for rows.Next() {
|
|
svc, err := scanService(rows)
|
|
if err != nil {
|
|
cclog.Warn("Error while scanning rows (ListByScope)")
|
|
return nil, err
|
|
}
|
|
services = append(services, svc)
|
|
}
|
|
return services, rows.Err()
|
|
}
|