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>
236 lines
8.4 KiB
Go
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))
|
|
}
|