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>
267 lines
8.5 KiB
Go
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))
|
|
}
|