Files
DDI/server/internal/store/deployments.go
T
beug.k@beugsiud.deandClaude Opus 5.5 28dc177310 feat(store): migration 00004 for push deployments, target revisions and revision sources
Agents get a target revision (manual mode), revisions store their
generator input (jsonb) for rollbacks, deployments gain the outcomes
pending and superseded, a nullable end and the requesting user. The
duplicate protection now covers agent reports only. Transactions can
register hooks that run after the commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:45 +02:00

236 lines
8.4 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// Deployment outcomes (enum deployment_outcome).
const (
OutcomePending = "pending"
OutcomeSucceeded = "succeeded"
OutcomeRejected = "rejected"
OutcomeRolledBack = "rolled_back"
OutcomeFailed = "failed"
OutcomeSuperseded = "superseded"
)
// Deployment is a row of deployments; AgentHostname comes from the agent.
// Rows created by a push from the server (ADR 0022) have RequestedByName
// set and stay pending until the agent reports.
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
RequestedBy *string
RequestedByName *string
}
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, d.requested_by, d.requested_by_name`
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, &d.RequestedBy,
&d.RequestedByName); err != nil {
return nil, mapErr(err)
}
return &d, nil
}
func scanDeployments(rows pgx.Rows, err error) ([]*Deployment, error) {
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())
}
// InsertDeployment stores a deployment report of an agent; ID and
// ReportedAt are set. A report with the same agent, revision and start time
// exists already if inserted is false (resent after an agent crash).
func (t *Tx) InsertDeployment(ctx context.Context, d *Deployment) (inserted bool, err error) {
err = 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)
ON CONFLICT (agent_id, revision, started_at) WHERE requested_by_name IS NULL DO NOTHING
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))
if errors.Is(err, ErrNotFound) {
return false, nil
}
return err == nil, err
}
// InsertPushDeployment stores a pending push deployment requested by a user
// (RequestedByName must be set). ID, ReportedAt and Outcome are set.
func (t *Tx) InsertPushDeployment(ctx context.Context, d *Deployment) error {
d.Trigger, d.Outcome, d.FinishedAt = "push", OutcomePending, nil
return mapErr(t.q.QueryRow(ctx, `
INSERT INTO deployments (agent_id, revision, trigger_kind, outcome, started_at, requested_by, requested_by_name)
VALUES ($1, $2, 'push', 'pending', $3, $4, $5)
RETURNING id, reported_at`,
d.AgentID, d.Revision, d.StartedAt, d.RequestedBy, d.RequestedByName).Scan(&d.ID, &d.ReportedAt))
}
// PendingDeployment returns the newest pending deployment of an agent or
// ErrNotFound.
func (t *Tx) PendingDeployment(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 AND d.outcome = 'pending' ORDER BY d.id DESC LIMIT 1`, agentID))
}
// SupersedeFilter selects pending deployments to supersede.
type SupersedeFilter struct {
// AgentID restricts the update to one agent ("" = all agents).
AgentID string
// AutoPullOnly restricts it to agents in auto_pull mode.
AutoPullOnly bool
// KeepRevision keeps pending deployments of this revision (0 = none).
KeepRevision int64
}
// SupersedeDeployments marks matching pending deployments superseded at the
// given time and returns them.
func (t *Tx) SupersedeDeployments(ctx context.Context, f SupersedeFilter, at time.Time) ([]*Deployment, error) {
where := []string{"a.id = d.agent_id", "d.outcome = 'pending'"}
args := []any{at}
if f.AgentID != "" {
args = append(args, f.AgentID)
where = append(where, fmt.Sprintf("d.agent_id = $%d", len(args)))
}
if f.AutoPullOnly {
where = append(where, "a.mode = 'auto_pull'")
}
if f.KeepRevision > 0 {
args = append(args, f.KeepRevision)
where = append(where, fmt.Sprintf("d.revision <> $%d", len(args)))
}
return scanDeployments(t.q.Query(ctx, `UPDATE deployments d SET outcome = 'superseded', finished_at = greatest($1, d.started_at)
FROM agents a WHERE `+strings.Join(where, " AND ")+` RETURNING `+deploymentColumns, args...))
}
// DeploymentResult is what an agent reports for a push deployment.
type DeploymentResult struct {
Outcome string
Message string
ActiveRevision int64
StartedAt time.Time
FinishedAt time.Time
}
// CompleteDeployment stores the agent's result in the push deployment id of
// agentID for revision. A deployment that is pending or was superseded in
// the meantime is updated (completed true). One that has a result already
// is returned unchanged (a resent report). ErrNotFound means there is no
// such deployment of that agent and revision.
func (t *Tx) CompleteDeployment(ctx context.Context, id int64, agentID string, revision int64, r DeploymentResult) (d *Deployment, completed bool, err error) {
d, err = scanDeployment(t.q.QueryRow(ctx, `
UPDATE deployments d SET outcome = $4::deployment_outcome, message = $5, active_revision = $6, started_at = $7,
finished_at = $8, reported_at = now()
FROM agents a
WHERE a.id = d.agent_id AND d.id = $1 AND d.agent_id = $2 AND d.revision = $3 AND d.requested_by_name IS NOT NULL
AND d.outcome IN ('pending', 'superseded')
RETURNING `+deploymentColumns,
id, agentID, revision, r.Outcome, r.Message, r.ActiveRevision, r.StartedAt, r.FinishedAt))
if err == nil {
return d, true, nil
}
if !errors.Is(err, ErrNotFound) {
return nil, false, err
}
d, err = scanDeployment(t.q.QueryRow(ctx, `SELECT `+deploymentColumns+`
FROM deployments d JOIN agents a ON a.id = d.agent_id
WHERE d.id = $1 AND d.agent_id = $2 AND d.revision = $3 AND d.requested_by_name IS NOT NULL`, id, agentID, revision))
if err != nil {
return nil, false, err
}
return d, false, nil
}
// 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)
return scanDeployments(t.q.Query(ctx, sql, args...))
}
// 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) {
list, err := scanDeployments(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, err
}
out := make(map[string]*Deployment, len(list))
for _, d := range list {
out[d.AgentID] = d
}
return out, nil
}
// 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))
}