Files
DDI/server/internal/store/revisions.go
T
beug.k@beugsiud.deandClaude Opus 5.5 8e76c0cf32 feat(store): agents, enrollment tokens, revisions, deployments and leases
Migration 00003 adds the PKI key table, agents, enrollment tokens,
immutable configuration revisions with their files, deployment reports
and DHCP leases, each with database constraints. Repositories and DB
tests for all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:54:12 +02:00

267 lines
8.5 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// MaxRevision is the highest revision number (it doubles as SOA serial).
const MaxRevision = 1<<32 - 1
// revisionLockKey serializes revision creation (pg_advisory_xact_lock).
const revisionLockKey = 0x64646901
// Revision is a row of config_revisions. DNSFiles, DHCPFiles and
// WarningCount are filled by ListRevisions.
type Revision struct {
Number int64
CreatedAt time.Time
CreatedBy *string
CreatedByName string
Comment string
Warnings json.RawMessage
DNSFiles int
DHCPFiles int
WarningCount int
}
// RevisionFile is one artifact of a revision.
type RevisionFile struct {
Role string
Path string
Mode uint32
CreateOnly bool
Content []byte
}
// LockRevisions takes a transaction-scoped lock that serializes revision
// creation. It must run inside a transaction.
func (t *Tx) LockRevisions(ctx context.Context) error {
_, err := t.q.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, int64(revisionLockKey))
return mapErr(err)
}
// LatestRevision returns the newest revision or ErrNotFound.
func (t *Tx) LatestRevision(ctx context.Context) (*Revision, error) {
return t.getRevision(ctx, `ORDER BY number DESC LIMIT 1`)
}
// GetRevision returns one revision without files.
func (t *Tx) GetRevision(ctx context.Context, number int64) (*Revision, error) {
return t.getRevision(ctx, `WHERE number = $1`, number)
}
func (t *Tx) getRevision(ctx context.Context, cond string, args ...any) (*Revision, error) {
var r Revision
var warnings []byte
err := t.q.QueryRow(ctx, `SELECT number, created_at, created_by, created_by_name, comment, warnings
FROM config_revisions `+cond, args...).Scan(&r.Number, &r.CreatedAt, &r.CreatedBy, &r.CreatedByName, &r.Comment, &warnings)
if err != nil {
return nil, mapErr(err)
}
r.Warnings = warnings
return &r, nil
}
// InsertRevision stores a revision with its files (in the given order per
// role). CreatedAt is taken from r.
func (t *Tx) InsertRevision(ctx context.Context, r *Revision, files []RevisionFile) error {
warnings := r.Warnings
if len(warnings) == 0 {
warnings = json.RawMessage("[]")
}
if _, err := t.q.Exec(ctx, `INSERT INTO config_revisions (number, created_at, created_by, created_by_name, comment, warnings)
VALUES ($1, $2, $3, $4, $5, $6)`, r.Number, r.CreatedAt, r.CreatedBy, r.CreatedByName, r.Comment, []byte(warnings)); err != nil {
return mapErr(err)
}
b := &pgx.Batch{}
ordinal := map[string]int32{}
for _, f := range files {
n := ordinal[f.Role]
ordinal[f.Role] = n + 1
b.Queue(`INSERT INTO config_revision_files (revision, role, ordinal, path, mode, create_only, content)
VALUES ($1, $2::agent_role, $3, $4, $5, $6, $7)`,
r.Number, f.Role, n, f.Path, int32(f.Mode), f.CreateOnly, f.Content) //nolint:gosec // modes are at most 0o777 (checked by the database)
}
return t.execBatch(ctx, b)
}
// RevisionFiles returns the files of a revision ordered by role and
// generator order. role "" returns both roles.
func (t *Tx) RevisionFiles(ctx context.Context, number int64, role string) ([]RevisionFile, error) {
sql := `SELECT role, path, mode, create_only, content FROM config_revision_files WHERE revision = $1`
args := []any{number}
if role != "" {
sql += ` AND role = $2::agent_role`
args = append(args, role)
}
rows, err := t.q.Query(ctx, sql+` ORDER BY role, ordinal`, args...)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
out := []RevisionFile{}
for rows.Next() {
var f RevisionFile
var mode int32
if err := rows.Scan(&f.Role, &f.Path, &mode, &f.CreateOnly, &f.Content); err != nil {
return nil, mapErr(err)
}
f.Mode = uint32(mode) //nolint:gosec // 0..511 by constraint
out = append(out, f)
}
return out, mapErr(rows.Err())
}
// ListRevisions returns up to limit revisions with a number below before
// (0 = no bound), newest first.
func (t *Tx) ListRevisions(ctx context.Context, limit int, before int64) ([]*Revision, error) {
sql := `SELECT r.number, r.created_at, r.created_by, r.created_by_name, r.comment, jsonb_array_length(r.warnings),
(SELECT count(*) FROM config_revision_files f WHERE f.revision = r.number AND f.role = 'dns'),
(SELECT count(*) FROM config_revision_files f WHERE f.revision = r.number AND f.role = 'dhcp')
FROM config_revisions r`
args := []any{}
if before > 0 {
sql += ` WHERE r.number < $1`
args = append(args, before)
}
sql += fmt.Sprintf(` ORDER BY r.number DESC LIMIT %d`, limit)
rows, err := t.q.Query(ctx, sql, args...)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
var out []*Revision
for rows.Next() {
var r Revision
if err := rows.Scan(&r.Number, &r.CreatedAt, &r.CreatedBy, &r.CreatedByName, &r.Comment, &r.WarningCount,
&r.DNSFiles, &r.DHCPFiles); err != nil {
return nil, mapErr(err)
}
out = append(out, &r)
}
return out, mapErr(rows.Err())
}
// Deployment is a row of deployments; AgentHostname comes from the agent.
type Deployment struct {
ID int64
AgentID string
AgentHostname string
CorrelationID string
Revision int64
Trigger string
Outcome string
Message string
ActiveRevision int64
StartedAt time.Time
FinishedAt time.Time
ReportedAt time.Time
}
const deploymentColumns = `d.id, d.agent_id, a.hostname, d.correlation_id, d.revision, d.trigger_kind, d.outcome, d.message,
d.active_revision, d.started_at, d.finished_at, d.reported_at`
func scanDeployment(row pgx.Row) (*Deployment, error) {
var d Deployment
if err := row.Scan(&d.ID, &d.AgentID, &d.AgentHostname, &d.CorrelationID, &d.Revision, &d.Trigger, &d.Outcome,
&d.Message, &d.ActiveRevision, &d.StartedAt, &d.FinishedAt, &d.ReportedAt); err != nil {
return nil, mapErr(err)
}
return &d, nil
}
// InsertDeployment stores a deployment report; ID and ReportedAt are set.
func (t *Tx) InsertDeployment(ctx context.Context, d *Deployment) error {
return mapErr(t.q.QueryRow(ctx, `
INSERT INTO deployments (agent_id, correlation_id, revision, trigger_kind, outcome, message, active_revision,
started_at, finished_at)
VALUES ($1, $2, $3, $4::deployment_trigger, $5::deployment_outcome, $6, $7, $8, $9)
RETURNING id, reported_at`,
d.AgentID, d.CorrelationID, d.Revision, d.Trigger, d.Outcome, d.Message, d.ActiveRevision,
d.StartedAt, d.FinishedAt).Scan(&d.ID, &d.ReportedAt))
}
// DeploymentFilter selects deployments. Zero values mean "no restriction".
type DeploymentFilter struct {
AgentID string
Revision int64
BeforeID int64
Limit int
}
// ListDeployments returns deployments newest first.
func (t *Tx) ListDeployments(ctx context.Context, f DeploymentFilter) ([]*Deployment, error) {
var where []string
var args []any
add := func(cond string, v any) {
args = append(args, v)
where = append(where, fmt.Sprintf(cond, len(args)))
}
if f.AgentID != "" {
add("d.agent_id = $%d", f.AgentID)
}
if f.Revision > 0 {
add("d.revision = $%d", f.Revision)
}
if f.BeforeID > 0 {
add("d.id < $%d", f.BeforeID)
}
limit := f.Limit
if limit <= 0 {
limit = 100
}
sql := `SELECT ` + deploymentColumns + ` FROM deployments d JOIN agents a ON a.id = d.agent_id`
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += fmt.Sprintf(" ORDER BY d.id DESC LIMIT %d", limit)
rows, err := t.q.Query(ctx, sql, args...)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
var out []*Deployment
for rows.Next() {
d, err := scanDeployment(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, mapErr(rows.Err())
}
// LatestDeployments returns the newest deployment of every agent that has
// one, keyed by agent ID.
func (t *Tx) LatestDeployments(ctx context.Context) (map[string]*Deployment, error) {
rows, err := t.q.Query(ctx, `SELECT DISTINCT ON (d.agent_id) `+deploymentColumns+`
FROM deployments d JOIN agents a ON a.id = d.agent_id ORDER BY d.agent_id, d.id DESC`)
if err != nil {
return nil, mapErr(err)
}
defer rows.Close()
out := map[string]*Deployment{}
for rows.Next() {
d, err := scanDeployment(rows)
if err != nil {
return nil, err
}
out[d.AgentID] = d
}
return out, mapErr(rows.Err())
}
// LatestDeployment returns the newest deployment of an agent or ErrNotFound.
func (t *Tx) LatestDeployment(ctx context.Context, agentID string) (*Deployment, error) {
return scanDeployment(t.q.QueryRow(ctx, `SELECT `+deploymentColumns+`
FROM deployments d JOIN agents a ON a.id = d.agent_id WHERE d.agent_id = $1 ORDER BY d.id DESC LIMIT 1`, agentID))
}