runs: add non-destructive flag + operator Cancel button
Non-destructive pre-declares "don't touch the disks" on Start: the Storage stage skips wipe-probe, badblocks -w, and write-mode fio, and reports a read-only summary. Runs a new non_destructive column; threaded through Claim → agent tests.Deps → Storage stage. Cancel halts an in-flight run. The orchestrator transitions to a new StateCancelled via TriggerOperatorCancelled (valid from any active state); the agent's next heartbeat returns cmd=cancel_stage, which fires a stored CancelFunc on the per-stage context. Stage subprocesses spawned with exec.CommandContext die with the context, the agent posts a cancelled outcome, then powers the host off. Destructive stages mid-run may leave the host in an intermediate state — the UI confirm dialog warns the operator; recovery is manual for now. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+19
-15
@@ -14,12 +14,16 @@ type Runs struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (r *Runs) Create(ctx context.Context, hostID int64, tokenHash string) (int64, error) {
|
||||
func (r *Runs) Create(ctx context.Context, hostID int64, tokenHash string, nonDestructive bool) (int64, error) {
|
||||
now := time.Now().UTC()
|
||||
nd := 0
|
||||
if nonDestructive {
|
||||
nd = 1
|
||||
}
|
||||
res, err := r.DB.ExecContext(ctx, `
|
||||
INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
`, hostID, string(model.StateQueued), tokenHash, "linux", now)
|
||||
INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at, non_destructive)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
`, hostID, string(model.StateQueued), tokenHash, "linux", now, nd)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert run: %w", err)
|
||||
}
|
||||
@@ -103,14 +107,14 @@ func (r *Runs) Get(ctx context.Context, id int64) (*model.Run, error) {
|
||||
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
|
||||
COALESCE(next_boot_target,''), agent_token_hash, started_at,
|
||||
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
|
||||
COALESCE(override_flags_json,'')
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
|
||||
FROM runs WHERE id = ?
|
||||
`, id)
|
||||
var run model.Run
|
||||
var completedAt sql.NullTime
|
||||
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
|
||||
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON)
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -129,7 +133,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
|
||||
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
|
||||
COALESCE(next_boot_target,''), agent_token_hash, started_at,
|
||||
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
|
||||
COALESCE(override_flags_json,'')
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
|
||||
FROM runs WHERE host_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`, hostID)
|
||||
@@ -137,7 +141,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
|
||||
var completedAt sql.NullTime
|
||||
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
|
||||
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON)
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -156,9 +160,9 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
||||
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
|
||||
COALESCE(next_boot_target,''), agent_token_hash, started_at,
|
||||
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
|
||||
COALESCE(override_flags_json,'')
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
|
||||
FROM runs
|
||||
WHERE state NOT IN ('Completed','Released')
|
||||
WHERE state NOT IN ('Completed','Released','Cancelled')
|
||||
ORDER BY id
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -171,7 +175,7 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
||||
var completedAt sql.NullTime
|
||||
if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
|
||||
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON); err != nil {
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if completedAt.Valid {
|
||||
@@ -189,7 +193,7 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
||||
func (r *Runs) CompletedOlderThan(ctx context.Context, cutoff time.Time) ([]int64, error) {
|
||||
rows, err := r.DB.QueryContext(ctx, `
|
||||
SELECT id FROM runs
|
||||
WHERE state IN ('Completed','Released','FailedHolding')
|
||||
WHERE state IN ('Completed','Released','FailedHolding','Cancelled')
|
||||
AND COALESCE(completed_at, started_at) < ?
|
||||
ORDER BY id
|
||||
`, cutoff)
|
||||
@@ -215,17 +219,17 @@ func (r *Runs) FindActiveByMAC(ctx context.Context, mac string) (*model.Run, err
|
||||
SELECT r.id, r.host_id, r.state, COALESCE(r.result,''), COALESCE(r.failed_stage,''),
|
||||
COALESCE(r.next_boot_target,''), r.agent_token_hash, r.started_at,
|
||||
r.completed_at, COALESCE(r.report_path,''), COALESCE(r.hold_ip,''),
|
||||
COALESCE(r.override_flags_json,'')
|
||||
COALESCE(r.override_flags_json,''), COALESCE(r.non_destructive,0)
|
||||
FROM runs r
|
||||
JOIN hosts h ON h.id = r.host_id
|
||||
WHERE h.mac = ? AND r.state NOT IN ('Completed','Released')
|
||||
WHERE h.mac = ? AND r.state NOT IN ('Completed','Released','Cancelled')
|
||||
ORDER BY r.id DESC LIMIT 1
|
||||
`, mac)
|
||||
var run model.Run
|
||||
var completedAt sql.NullTime
|
||||
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
|
||||
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON)
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func seedRun(t *testing.T, runs *store.Runs) (int64, int64) {
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(context.Background(), hostID, "deadbeef")
|
||||
runID, err := runs.Create(context.Background(), hostID, "deadbeef", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user