diff --git a/.gitattributes b/.gitattributes index b359f8668..9ad90d864 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,4 +2,5 @@ api/tangled/** linguist-generated -diff api/tangled/*_ext.go -linguist-generated diff flake.lock -diff web/src/lib/api/lexicons/** linguist-generated -diff +spindle/mill/proto/gen/** linguist-generated -diff Cargo.lock -diff diff --git a/shuttle/src/gen/file_descriptor_set.bin b/shuttle/src/gen/file_descriptor_set.bin index 1519daeadb97ae6e318984475e10ca2b7cffd2a3..64c381791c542302ec1a72646014de6fe93b4de0 100644 GIT binary patch delta 52383 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 delta 51006 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 ? order by id asc @@ -96,7 +96,7 @@ func (d *DB) CompleteMillLease( } func (d *DB) GetStatus(workflowId models.WorkflowId) (models.StatusKind, error) { - pipelineId := workflowId.PipelineId.Rkey + pipelineId := workflowId.PipelineId var status string err := d.QueryRow( @@ -104,7 +104,7 @@ func (d *DB) GetStatus(workflowId models.WorkflowId) (models.StatusKind, error) select status from workflow_statuses where - rkey = ? + pipeline_id = ? and workflow = ? order by id desc @@ -135,8 +135,8 @@ func workflowStartupDelay(ctx context.Context, q statusQueryer, wid models.Workf min(case when status = 'pending' then created_at end), min(case when status = 'running' then created_at end) from workflow_statuses - where rkey = ? and workflow = ? - `, wid.PipelineId.Rkey, wid.Name).Scan(&pending, &running) + where pipeline_id = ? and workflow = ? + `, string(wid.PipelineId), wid.Name).Scan(&pending, &running) if err != nil { return 0, false, err } @@ -171,9 +171,9 @@ func (tx *EventBatchTx) HasWorkflowStatus(ctx context.Context, wid models.Workfl err := tx.tx.QueryRowContext(ctx, ` select exists( select 1 from workflow_statuses - where rkey = ? and workflow = ? and status = ? + where pipeline_id = ? and workflow = ? and status = ? ) - `, wid.PipelineId.Rkey, wid.Name, status).Scan(&present) + `, string(wid.PipelineId), wid.Name, status).Scan(&present) return present, err } @@ -202,13 +202,12 @@ func (d *DB) StatusTimeout(workflowId models.WorkflowId, n *notifier.Notifier) e } type PipelineWorkflow struct { - Knot string - Rkey string - Name string + PipelineID models.PipelineId + Name string } func (d *DB) ListPipelineWorkflows(repoDid string) ([]PipelineWorkflow, error) { - rows, err := d.Query(`select rkey, knot, payload from pipelines where repo_did = ?`, repoDid) + rows, err := d.Query(`select pipeline_id, payload from pipelines where repo_did = ?`, repoDid) if err != nil { return nil, err } @@ -216,8 +215,8 @@ func (d *DB) ListPipelineWorkflows(repoDid string) ([]PipelineWorkflow, error) { var out []PipelineWorkflow for rows.Next() { - var rkey, knot, raw string - if err := rows.Scan(&rkey, &knot, &raw); err != nil { + var pipelineID, raw string + if err := rows.Scan(&pipelineID, &raw); err != nil { return nil, err } var p tangled.CiPipeline @@ -226,57 +225,51 @@ func (d *DB) ListPipelineWorkflows(repoDid string) ([]PipelineWorkflow, error) { } for _, wf := range p.Workflows { if wf != nil { - out = append(out, PipelineWorkflow{Knot: knot, Rkey: rkey, Name: wf.Name}) + out = append(out, PipelineWorkflow{PipelineID: models.PipelineId(pipelineID), Name: wf.Name}) } } } return out, rows.Err() } -// PipelineKey is one exact pipeline at-uri identity -type PipelineKey struct { - Knot string - Rkey string -} - -func (d *DB) DeleteEventsByRepo(repoDid string, extra []PipelineKey) error { +func (d *DB) DeletePipelinesByRepo(repoDid string, extra []models.PipelineId) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() - rows, err := tx.Query(`select rkey from pipelines where repo_did = ?`, repoDid) + rows, err := tx.Query(`select pipeline_id from pipelines where repo_did = ?`, repoDid) if err != nil { return err } seen := make(map[string]bool) - var rkeys []string - add := func(rkey string) { - if rkey != "" && !seen[rkey] { - seen[rkey] = true - rkeys = append(rkeys, rkey) + var pipelineIDs []string + add := func(pipelineID string) { + if pipelineID != "" && !seen[pipelineID] { + seen[pipelineID] = true + pipelineIDs = append(pipelineIDs, pipelineID) } } for rows.Next() { - var rkey string - if err := rows.Scan(&rkey); err != nil { + var pipelineID string + if err := rows.Scan(&pipelineID); err != nil { rows.Close() return err } - add(rkey) + add(pipelineID) } if err := rows.Err(); err != nil { rows.Close() return err } rows.Close() - for _, key := range extra { - add(key.Rkey) + for _, id := range extra { + add(string(id)) } - for _, rkey := range rkeys { - if _, err := tx.Exec(`delete from workflow_statuses where rkey = ?`, rkey); err != nil { + for _, pipelineID := range pipelineIDs { + if _, err := tx.Exec(`delete from workflow_statuses where pipeline_id = ?`, pipelineID); err != nil { return err } } diff --git a/spindle/db/events_metrics_test.go b/spindle/db/events_metrics_test.go index e73c0aa92..811c4fe5b 100644 --- a/spindle/db/events_metrics_test.go +++ b/spindle/db/events_metrics_test.go @@ -20,7 +20,7 @@ func TestWorkflowStartupDelay(t *testing.T) { n := notifier.New() wid := models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "knot.example.com", Rkey: "pipeline"}, + PipelineId: models.PipelineId("pipeline"), Name: "build", } if err := database.StatusPending(wid, &n); err != nil { @@ -59,7 +59,7 @@ func TestWorkflowStartupDelayUsesStatusIndex(t *testing.T) { min(case when status = 'pending' then created_at end), min(case when status = 'running' then created_at end) from workflow_statuses - where rkey = ? and workflow = ? + where pipeline_id = ? and workflow = ? `, "pipeline", "build") if err != nil { t.Fatal(err) diff --git a/spindle/db/jobs.go b/spindle/db/jobs.go index 9b627dc6e..9d2a91768 100644 --- a/spindle/db/jobs.go +++ b/spindle/db/jobs.go @@ -13,15 +13,14 @@ import ( ) type JobRow struct { - Id int64 - RepoDid string - PipelineIdKnot string - PipelineIdRkey string - SourceRepo *tangled.Pipeline_TriggerRepo - Tpl tangled.Pipeline - Traceparent string - Tracestate string - CreatedAtNs int64 + Id int64 + RepoDid string + PipelineId models.PipelineId + SourceRepo *tangled.Pipeline_TriggerRepo + Tpl tangled.Pipeline + Traceparent string + Tracestate string + CreatedAtNs int64 } // the atomic check rejects a banned job before admission @@ -95,10 +94,10 @@ func (d *DB) enqueueJob( } if _, err = tx.ExecContext(ctx, ` insert into jobs ( - repo_did, pipeline_id_knot, pipeline_id_rkey, source_repo, tpl, + repo_did, pipeline_id, source_repo, tpl, traceparent, tracestate, created_at, created_at_ns - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, repoDid, pipelineID.Knot, pipelineID.Rkey, sourceRepoValue, string(tplJSON), + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `, repoDid, pipelineID, sourceRepoValue, string(tplJSON), traceparent, tracestate, time.Unix(0, createdAtNS).Unix(), createdAtNS, ); err != nil { return err @@ -138,8 +137,8 @@ func (d *DB) DequeueJob(ctx context.Context) (*JobRow, error) { order by id asc limit 1 ) - returning id, repo_did, pipeline_id_knot, pipeline_id_rkey, source_repo, tpl, traceparent, tracestate, created_at_ns - `).Scan(&row.Id, &row.RepoDid, &row.PipelineIdKnot, &row.PipelineIdRkey, &sourceRepoStr, &tplJson, &row.Traceparent, &row.Tracestate, &row.CreatedAtNs) + returning id, repo_did, pipeline_id, source_repo, tpl, traceparent, tracestate, created_at_ns + `).Scan(&row.Id, &row.RepoDid, &row.PipelineId, &sourceRepoStr, &tplJson, &row.Traceparent, &row.Tracestate, &row.CreatedAtNs) if err != nil { if err == sql.ErrNoRows { return nil, nil diff --git a/spindle/db/jobs_test.go b/spindle/db/jobs_test.go index e05a03276..f908f4ab5 100644 --- a/spindle/db/jobs_test.go +++ b/spindle/db/jobs_test.go @@ -20,7 +20,7 @@ func TestJobTraceContextSurvivesEnqueue(t *testing.T) { } t.Cleanup(func() { database.Close() }) - pipelineID := models.PipelineId{Knot: "knot.example.com", Rkey: "pipeline"} + pipelineID := models.PipelineId("pipeline") const traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" const tracestate = "vendor=value" if err := database.EnqueueJob( @@ -59,7 +59,7 @@ func TestEnqueueJobWithPendingCommitsJobAndStatusesTogether(t *testing.T) { t.Cleanup(func() { database.Close() }) n := notifier.New() - pipelineID := models.PipelineId{Knot: "knot.example.com", Rkey: "pipeline"} + pipelineID := models.PipelineId("pipeline") tpl := tangled.Pipeline{Workflows: []*tangled.Pipeline_Workflow{{Name: "build"}}} if err := database.EnqueueJobWithPending( ctx, diff --git a/spindle/db/log_migration.go b/spindle/db/log_migration.go new file mode 100644 index 000000000..992749524 --- /dev/null +++ b/spindle/db/log_migration.go @@ -0,0 +1,207 @@ +package db + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + + "tangled.org/core/api/tangled" + "tangled.org/core/spindle/models" +) + +type pipelineLogRename struct { + knot string + pipelineID models.PipelineId + workflow string +} + +func stagePipelineLogRenames(tx *sql.Tx) error { + var renames []pipelineLogRename + hasLegacyPipelineID, err := tableHasColumn(tx, "pipelines", "rkey") + if err != nil { + return err + } + if hasLegacyPipelineID { + rows, err := tx.Query(`select rkey, knot, payload from pipelines`) + if err != nil { + return err + } + for rows.Next() { + var rkey, knot, payload string + if err := rows.Scan(&rkey, &knot, &payload); err != nil { + rows.Close() + return err + } + var pipeline tangled.CiPipeline + if err := json.Unmarshal([]byte(payload), &pipeline); err != nil { + rows.Close() + return fmt.Errorf("decode pipeline %s while staging log migration: %w", rkey, err) + } + for _, workflow := range pipeline.Workflows { + if workflow != nil && workflow.Name != "" { + renames = append(renames, pipelineLogRename{knot, models.PipelineId(rkey), workflow.Name}) + } + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + } + + rows, err := tx.Query(` + select knot, rkey, workflow from mill_leases + union + select knot, rkey, workflow from mill_artifacts + union + select knot, rkey, workflow from executor_pending_artifacts + `) + if err != nil { + return err + } + for rows.Next() { + var knot, rkey, workflow string + if err := rows.Scan(&knot, &rkey, &workflow); err != nil { + rows.Close() + return err + } + renames = append(renames, pipelineLogRename{knot, models.PipelineId(rkey), workflow}) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + for _, rename := range renames { + if _, err := tx.Exec( + `insert or ignore into pipeline_log_renames (knot, pipeline_id, workflow) values (?, ?, ?)`, + rename.knot, rename.pipelineID, rename.workflow, + ); err != nil { + return err + } + } + return nil +} + +func (d *DB) MigratePipelineLogFiles(logDir string) error { + var tableExists int + if err := d.QueryRow(` + select count(*) from sqlite_master + where type = 'table' and name = 'pipeline_log_renames' + `).Scan(&tableExists); err != nil { + return err + } + if tableExists == 0 { + return nil + } + if logDir == "" { + return d.finishPipelineLogMigration() + } + if _, err := os.Stat(logDir); err != nil { + if os.IsNotExist(err) { + return d.finishPipelineLogMigration() + } + return err + } + + rows, err := d.Query(`select knot, pipeline_id, workflow from pipeline_log_renames`) + if err != nil { + return err + } + var renames []pipelineLogRename + for rows.Next() { + var rename pipelineLogRename + if err := rows.Scan(&rename.knot, &rename.pipelineID, &rename.workflow); err != nil { + rows.Close() + return err + } + renames = append(renames, rename) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + type plannedRename struct { + rename pipelineLogRename + oldPath string + newPath string + destinationIsSameFile bool + } + var planned []plannedRename + var stale []pipelineLogRename + destinations := make(map[string]string) + for _, rename := range renames { + oldPath := models.LegacyLogFilePath(logDir, rename.knot, rename.pipelineID, rename.workflow) + newPath := models.LogFilePath(logDir, models.WorkflowId{PipelineId: rename.pipelineID, Name: rename.workflow}) + oldInfo, err := os.Stat(oldPath) + if err != nil { + if os.IsNotExist(err) { + stale = append(stale, rename) + continue + } + return fmt.Errorf("stat legacy pipeline log %s: %w", oldPath, err) + } + if !oldInfo.Mode().IsRegular() { + return fmt.Errorf("legacy pipeline log is not a regular file: %s", oldPath) + } + if previous, ok := destinations[newPath]; ok && previous != oldPath { + return fmt.Errorf("legacy pipeline logs %s and %s both map to %s", previous, oldPath, newPath) + } + destinations[newPath] = oldPath + + sameFile := false + if newInfo, err := os.Stat(newPath); err == nil { + if !os.SameFile(oldInfo, newInfo) { + return fmt.Errorf("refusing to overwrite pipeline log %s while migrating %s", newPath, oldPath) + } + sameFile = true + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat pipeline log %s: %w", newPath, err) + } + planned = append(planned, plannedRename{rename, oldPath, newPath, sameFile}) + } + + for _, rename := range stale { + if err := d.clearPipelineLogRename(rename); err != nil { + return err + } + } + for _, plan := range planned { + if !plan.destinationIsSameFile { + if err := os.Link(plan.oldPath, plan.newPath); err != nil { + return fmt.Errorf("link pipeline log %s to %s: %w", plan.oldPath, plan.newPath, err) + } + } + if err := os.Remove(plan.oldPath); err != nil { + return fmt.Errorf("remove legacy pipeline log %s: %w", plan.oldPath, err) + } + if err := d.clearPipelineLogRename(plan.rename); err != nil { + return err + } + } + return d.finishPipelineLogMigration() +} + +func (d *DB) finishPipelineLogMigration() error { + _, err := d.Exec(`drop table if exists pipeline_log_renames`) + return err +} + +func (d *DB) clearPipelineLogRename(rename pipelineLogRename) error { + _, err := d.Exec( + `delete from pipeline_log_renames where knot = ? and pipeline_id = ? and workflow = ?`, + rename.knot, rename.pipelineID, rename.workflow, + ) + return err +} diff --git a/spindle/db/mill_state.go b/spindle/db/mill_state.go index 9d8e8cd54..3f6f0535b 100644 --- a/spindle/db/mill_state.go +++ b/spindle/db/mill_state.go @@ -14,8 +14,7 @@ type MillLease struct { NodeID string Epoch string Engine string - Knot string - Rkey string + PipelineID string Workflow string State string QuotaReservationID string @@ -45,16 +44,16 @@ type OutboxDeletion struct { func (d *DB) SaveMillLease(l MillLease) error { _, err := d.Exec( `insert into mill_leases ( - lease_id, node_id, epoch, engine, knot, rkey, workflow, state, + lease_id, node_id, epoch, engine, pipeline_id, workflow, state, quota_reservation_id, owner_did, repo_did, mill_records_terminal_metrics - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(lease_id) do update set state = excluded.state, quota_reservation_id = excluded.quota_reservation_id, owner_did = excluded.owner_did, repo_did = excluded.repo_did, mill_records_terminal_metrics = excluded.mill_records_terminal_metrics`, - l.LeaseID, l.NodeID, l.Epoch, l.Engine, l.Knot, l.Rkey, l.Workflow, l.State, + l.LeaseID, l.NodeID, l.Epoch, l.Engine, l.PipelineID, l.Workflow, l.State, l.QuotaReservationID, l.OwnerDID, l.RepoDID, l.MillRecordsTerminalMetrics, ) return err @@ -67,7 +66,7 @@ func (d *DB) DeleteMillLease(leaseID string) error { func (d *DB) ListMillLeases() ([]MillLease, error) { rows, err := d.Query(` - select lease_id, node_id, epoch, engine, knot, rkey, workflow, state, + select lease_id, node_id, epoch, engine, pipeline_id, workflow, state, coalesce(quota_reservation_id, ''), coalesce(owner_did, ''), coalesce(repo_did, ''), mill_records_terminal_metrics from mill_leases @@ -81,7 +80,7 @@ func (d *DB) ListMillLeases() ([]MillLease, error) { for rows.Next() { var l MillLease if err := rows.Scan( - &l.LeaseID, &l.NodeID, &l.Epoch, &l.Engine, &l.Knot, &l.Rkey, &l.Workflow, &l.State, + &l.LeaseID, &l.NodeID, &l.Epoch, &l.Engine, &l.PipelineID, &l.Workflow, &l.State, &l.QuotaReservationID, &l.OwnerDID, &l.RepoDID, &l.MillRecordsTerminalMetrics, ); err != nil { return nil, err @@ -329,17 +328,16 @@ func (tx *EventBatchTx) InsertArtifactRef(leaseID, repoDid string, wid models.Wo return err } _, err := tx.tx.Exec( - `insert into mill_artifacts (lease_id, repo_did, knot, rkey, workflow, ref, hash) - values (?, ?, ?, ?, ?, ?, ?)`, - leaseID, repoDid, wid.Knot, wid.Rkey, wid.Name, ref, hash, + `insert into mill_artifacts (lease_id, repo_did, pipeline_id, workflow, ref, hash) + values (?, ?, ?, ?, ?, ?)`, + leaseID, repoDid, string(wid.PipelineId), wid.Name, ref, hash, ) return err } type PendingArtifact struct { LeaseID string - Knot string - Rkey string + PipelineID string Workflow string Status string Error string @@ -361,12 +359,11 @@ func (d *DB) SavePendingArtifact( ) error { _, err := d.Exec( `insert into executor_pending_artifacts ( - lease_id, knot, rkey, workflow, status, error, exit_code, ref, hash, + lease_id, pipeline_id, workflow, status, error, exit_code, ref, hash, failure_class, failure_reason, mill_records_terminal_metrics - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(lease_id) do update set - knot = excluded.knot, - rkey = excluded.rkey, + pipeline_id = excluded.pipeline_id, workflow = excluded.workflow, status = excluded.status, error = excluded.error, @@ -376,7 +373,7 @@ func (d *DB) SavePendingArtifact( failure_class = excluded.failure_class, failure_reason = excluded.failure_reason, mill_records_terminal_metrics = excluded.mill_records_terminal_metrics`, - leaseID, wid.Knot, wid.Rkey, wid.Name, status, errStr, exitCode, ref, hash, + leaseID, string(wid.PipelineId), wid.Name, status, errStr, exitCode, ref, hash, failureClass, failureReason, millRecordsTerminalMetrics, ) return err @@ -389,7 +386,7 @@ func (d *DB) RemovePendingArtifact(leaseID string) error { func (d *DB) ListPendingArtifacts() ([]PendingArtifact, error) { rows, err := d.Query(` - select lease_id, knot, rkey, workflow, status, error, exit_code, ref, hash, + select lease_id, pipeline_id, workflow, status, error, exit_code, ref, hash, failure_class, failure_reason, mill_records_terminal_metrics from executor_pending_artifacts `) @@ -402,7 +399,7 @@ func (d *DB) ListPendingArtifacts() ([]PendingArtifact, error) { for rows.Next() { var p PendingArtifact if err := rows.Scan( - &p.LeaseID, &p.Knot, &p.Rkey, &p.Workflow, &p.Status, &p.Error, &p.ExitCode, &p.Ref, &p.Hash, + &p.LeaseID, &p.PipelineID, &p.Workflow, &p.Status, &p.Error, &p.ExitCode, &p.Ref, &p.Hash, &p.FailureClass, &p.FailureReason, &p.MillRecordsTerminalMetrics, ); err != nil { return nil, err diff --git a/spindle/db/mill_state_test.go b/spindle/db/mill_state_test.go index 5884ed7eb..963bbf4a4 100644 --- a/spindle/db/mill_state_test.go +++ b/spindle/db/mill_state_test.go @@ -20,8 +20,7 @@ func TestMillLeaseRoundTrip(t *testing.T) { NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", - Knot: "knot.example", - Rkey: "rkey1", + PipelineID: "rkey1", Workflow: "build", State: "reserved", MillRecordsTerminalMetrics: true, @@ -92,7 +91,7 @@ func TestCompleteMillLeaseIsAtomic(t *testing.T) { d := newTestDB(t) lease := MillLease{ LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", - Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: "running", + PipelineID: "rkey1", Workflow: "build", State: "running", } if err := d.SaveMillLease(lease); err != nil { t.Fatalf("SaveMillLease: %v", err) @@ -112,7 +111,7 @@ func TestCompleteMillLeaseIsAtomic(t *testing.T) { defer n.Unsubscribe(notifications) err := d.CompleteMillLease( "lease-1", - models.WorkflowId{PipelineId: models.PipelineId{Rkey: "rkey1"}, Name: "build"}, + models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"}, "failed", nil, nil, @@ -142,7 +141,7 @@ func TestCompleteMillLeaseIsAtomic(t *testing.T) { } if err := d.CompleteMillLease( "lease-1", - models.WorkflowId{PipelineId: models.PipelineId{Rkey: "rkey1"}, Name: "build"}, + models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"}, "failed", nil, nil, @@ -175,8 +174,7 @@ func TestRestartPersistence(t *testing.T) { } lease := MillLease{ - LeaseID: "lease-p", NodeID: "node-p", Epoch: "inc-p", Engine: "dummy", - Knot: "k", Rkey: "r", Workflow: "w", State: "running", + LeaseID: "lease-p", NodeID: "node-p", Epoch: "inc-p", Engine: "dummy", PipelineID: "r", Workflow: "w", State: "running", } if err := d.SaveMillLease(lease); err != nil { t.Fatalf("SaveMillLease: %v", err) @@ -360,8 +358,7 @@ func TestBatchRollback(t *testing.T) { d := newTestDB(t) lease := MillLease{ - LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", - Knot: "k", Rkey: "r", Workflow: "w", State: "running", + LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", PipelineID: "r", Workflow: "w", State: "running", } if err := d.SaveMillLease(lease); err != nil { t.Fatalf("SaveMillLease: %v", err) @@ -403,8 +400,7 @@ func TestTerminalCursorAtomicity(t *testing.T) { d := newTestDB(t) lease := MillLease{ - LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", - Knot: "k", Rkey: "r", Workflow: "w", State: "running", + LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", PipelineID: "r", Workflow: "w", State: "running", } if err := d.SaveMillLease(lease); err != nil { t.Fatalf("SaveMillLease: %v", err) @@ -527,24 +523,24 @@ func TestPendingArtifactWorkflowIdentityMigration(t *testing.T) { if err != nil { t.Fatal(err) } - if len(rows) != 1 || rows[0].Knot != "" || rows[0].Rkey != "" || rows[0].Workflow != "build" { + if len(rows) != 1 || rows[0].PipelineID != "" || rows[0].Workflow != "build" { t.Fatalf("migrated pending artifact = %+v", rows) } var identityColumns int if err := d.QueryRow(` select count(*) from pragma_table_info('executor_pending_artifacts') - where name in ('knot', 'rkey') + where name = 'pipeline_id' `).Scan(&identityColumns); err != nil { t.Fatal(err) } - if identityColumns != 2 { - t.Fatalf("pending artifact identity columns = %d, want 2", identityColumns) + if identityColumns != 1 { + t.Fatalf("pending artifact identity columns = %d, want 1", identityColumns) } } func TestClearPendingArtifacts(t *testing.T) { d := newTestDB(t) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} if err := d.SavePendingArtifact("lease-1", wid, "success", "", 0, "ref", "sha256:x", "none", "success", true); err != nil { t.Fatalf("SavePendingArtifact: %v", err) } @@ -552,7 +548,7 @@ func TestClearPendingArtifacts(t *testing.T) { if err != nil { t.Fatal(err) } - if len(rows) != 1 || rows[0].Knot != wid.Knot || rows[0].Rkey != wid.Rkey || rows[0].Workflow != wid.Name || + if len(rows) != 1 || rows[0].PipelineID != string(wid.PipelineId) || rows[0].Workflow != wid.Name || rows[0].FailureClass != "none" || rows[0].FailureReason != "success" || !rows[0].MillRecordsTerminalMetrics { t.Fatalf("pending artifact attribution = %+v", rows) } diff --git a/spindle/db/pipelines.go b/spindle/db/pipelines.go index 7ceb5f6ce..cfe921adc 100644 --- a/spindle/db/pipelines.go +++ b/spindle/db/pipelines.go @@ -91,49 +91,36 @@ func (d *DB) QueryPipelines(ctx context.Context, repoDid string, commits []strin return pipelines, nextCursor, total, nil } -// GetPipelineWithKnot also returns the knot the pipeline was created on, which -// subscribePipelineLogs needs to locate the workflow log file. -func (d *DB) GetPipelineWithKnot(ctx context.Context, rkey string) (*tangled.CiPipeline, string, error) { - var payload, knot string +func (d *DB) GetPipeline(ctx context.Context, id models.PipelineId) (*tangled.CiPipeline, error) { + var payload string if err := d.QueryRowContext(ctx, - `select payload, knot from pipelines where rkey = ?`, rkey, - ).Scan(&payload, &knot); err != nil { - return nil, "", err + `select payload from pipelines where pipeline_id = ?`, id, + ).Scan(&payload); err != nil { + return nil, err } var p tangled.CiPipeline if err := json.Unmarshal([]byte(payload), &p); err != nil { - return nil, "", err + return nil, err } if err := d.applyStatuses(ctx, []*tangled.CiPipeline{&p}); err != nil { - return nil, "", err + return nil, err } - return &p, knot, nil -} - -func (d *DB) GetPipeline(ctx context.Context, rkey string) (*tangled.CiPipeline, error) { - p, _, err := d.GetPipelineWithKnot(ctx, rkey) - return p, err + return &p, nil } -// mapToCiPipeline converts a compiled legacy pipeline into the sh.tangled.ci.pipeline -// payload we store, plus the columns we index on. workflow statuses in the returned -// payload are always "pending": the read path overwrites them from workflow_statuses, -// so the payload never has to be rewritten as a pipeline progresses. -// -// kind is taken straight from raw.TriggerMetadata.Kind, which already holds the -// workflow.TriggerKind vocabulary the queryPipelines `kinds` param uses. Producing it -// in the same switch as the trigger union keeps the two from disagreeing. -func mapToCiPipeline(rkey string, createdAt time.Time, raw tangled.Pipeline) (*tangled.CiPipeline, workflow.TriggerKind) { +// mapToCiPipeline stores the initial status; reads overlay live workflow_statuses rows. +func mapToCiPipeline(id models.PipelineId, createdAt time.Time, raw tangled.Pipeline) (*tangled.CiPipeline, workflow.TriggerKind) { createdAtStr := createdAt.Format(time.RFC3339) + metadata := raw.TriggerMetadata var repoDidStr string - if raw.TriggerMetadata != nil && raw.TriggerMetadata.Repo != nil { - if rd := raw.TriggerMetadata.Repo.RepoDid; rd != nil && *rd != "" { + if metadata != nil && metadata.Repo != nil { + if rd := metadata.Repo.RepoDid; rd != nil && *rd != "" { repoDidStr = *rd } else { - repoDidStr = raw.TriggerMetadata.Repo.Did + repoDidStr = metadata.Repo.Did } } @@ -141,38 +128,38 @@ func mapToCiPipeline(rkey string, createdAt time.Time, raw tangled.Pipeline) (*t var trigger tangled.CiPipeline_Trigger var kind workflow.TriggerKind - if raw.TriggerMetadata != nil { - kind = workflow.TriggerKind(raw.TriggerMetadata.Kind) + if metadata != nil { + kind = workflow.TriggerKind(metadata.Kind) switch kind { case workflow.TriggerKindPush: - if raw.TriggerMetadata.Push != nil { - commitSha = raw.TriggerMetadata.Push.NewSha + if metadata.Push != nil { + commitSha = metadata.Push.NewSha trigger.CiTrigger_Push = &tangled.CiTrigger_Push{ - NewSha: raw.TriggerMetadata.Push.NewSha, - OldSha: raw.TriggerMetadata.Push.OldSha, - Ref: raw.TriggerMetadata.Push.Ref, + NewSha: metadata.Push.NewSha, + OldSha: metadata.Push.OldSha, + Ref: metadata.Push.Ref, } } case workflow.TriggerKindPullRequest: - if raw.TriggerMetadata.PullRequest != nil { - commitSha = raw.TriggerMetadata.PullRequest.SourceSha + if metadata.PullRequest != nil { + commitSha = metadata.PullRequest.SourceSha trigger.CiTrigger_PullRequest = &tangled.CiTrigger_PullRequest{ - Action: raw.TriggerMetadata.PullRequest.Action, - SourceBranch: &raw.TriggerMetadata.PullRequest.SourceBranch, - SourceRepo: raw.TriggerMetadata.SourceRepo, - SourceSha: raw.TriggerMetadata.PullRequest.SourceSha, - TargetBranch: raw.TriggerMetadata.PullRequest.TargetBranch, - Pull: raw.TriggerMetadata.PullRequest.Pull, + Action: metadata.PullRequest.Action, + SourceBranch: &metadata.PullRequest.SourceBranch, + SourceRepo: metadata.SourceRepo, + SourceSha: metadata.PullRequest.SourceSha, + TargetBranch: metadata.PullRequest.TargetBranch, + Pull: metadata.PullRequest.Pull, } } case workflow.TriggerKindManual: - if raw.TriggerMetadata.Manual != nil { - commitSha = raw.TriggerMetadata.Manual.Sha + if metadata.Manual != nil { + commitSha = metadata.Manual.Sha trigger.CiTrigger_Manual = &tangled.CiTrigger_Manual{ - Inputs: pipelinePairsToCiTriggerPairs(raw.TriggerMetadata.Manual.Inputs), - Ref: raw.TriggerMetadata.Manual.Ref, - Sha: raw.TriggerMetadata.Manual.Sha, - SourceRepo: raw.TriggerMetadata.SourceRepo, + Inputs: pipelinePairsToCiTriggerPairs(metadata.Manual.Inputs), + Ref: metadata.Manual.Ref, + Sha: metadata.Manual.Sha, + SourceRepo: metadata.SourceRepo, } } } @@ -184,24 +171,20 @@ func mapToCiPipeline(rkey string, createdAt time.Time, raw tangled.Pipeline) (*t continue } - // NOTE: workflow statuses will be filled from caller workflows = append(workflows, &tangled.CiPipeline_Workflow{ - Id: wf.Name, - Name: wf.Name, - Status: string(models.StatusKindPending), - StartedAt: nil, - FinishedAt: nil, - Error: nil, + Id: wf.Name, + Name: wf.Name, + Status: string(models.StatusKindPending), }) } var sourceRepo *string - if raw.TriggerMetadata != nil { - sourceRepo = raw.TriggerMetadata.SourceRepo + if metadata != nil { + sourceRepo = metadata.SourceRepo } return &tangled.CiPipeline{ - Id: rkey, + Id: string(id), Commit: commitSha, Repo: repoDidStr, CreatedAt: &createdAtStr, @@ -228,24 +211,20 @@ func pipelinePairsToCiTriggerPairs(inputs []*tangled.Pipeline_Pair) []*tangled.C return pairs } -// knot is stored only because the workflow log path still embeds it; it is not -// part of pipeline identity and nothing resolves or dials it. func (d *DB) CreatePipeline(id models.PipelineId, raw tangled.Pipeline) error { - p, kind := mapToCiPipeline(id.Rkey, time.Now(), raw) + p, kind := mapToCiPipeline(id, time.Now(), raw) payload, err := json.Marshal(p) if err != nil { return err } _, err = d.Exec( - `insert into pipelines (rkey, knot, repo_did, commit_sha, kind, payload) values (?, ?, ?, ?, ?, ?)`, - id.Rkey, id.Knot, p.Repo, p.Commit, string(kind), string(payload), + `insert into pipelines (pipeline_id, repo_did, commit_sha, kind, payload) values (?, ?, ?, ?, ?)`, + id, p.Repo, p.Commit, string(kind), string(payload), ) return err } -// applyStatuses overlays live workflow status onto stored payloads with a single -// query for the whole page, replacing the old per-workflow GetStatus + -// GetWorkflowTimes fan-out. CiPipeline.Id is the rkey, so no parallel slice. +// applyStatuses overlays the latest workflow statuses onto stored pipelines. func (d *DB) applyStatuses(ctx context.Context, pipelines []*tangled.CiPipeline) error { if len(pipelines) == 0 { return nil @@ -280,8 +259,8 @@ func (d *DB) applyStatuses(ctx context.Context, pipelines []*tangled.CiPipeline) } type wfKey struct { - Rkey string - Workflow string + PipelineID string + Workflow string } type wfStatus struct { @@ -291,10 +270,10 @@ type wfStatus struct { FinishedAt *string } -func (d *DB) workflowStatuses(ctx context.Context, rkeys []string) (map[wfKey]wfStatus, error) { - filter := orm.FilterIn("rkey", rkeys) +func (d *DB) workflowStatuses(ctx context.Context, pipelineIDs []string) (map[wfKey]wfStatus, error) { + filter := orm.FilterIn("pipeline_id", pipelineIDs) rows, err := d.QueryContext(ctx, - `select rkey, workflow, status, error, created_at + `select pipeline_id, workflow, status, error, created_at from workflow_statuses where `+filter.Condition()+` order by id asc`, filter.Arg()...) @@ -305,13 +284,13 @@ func (d *DB) workflowStatuses(ctx context.Context, rkeys []string) (map[wfKey]wf out := make(map[wfKey]wfStatus) for rows.Next() { - var rkey, wfName, status, createdAt string + var pipelineID, wfName, status, createdAt string var wfError *string - if err := rows.Scan(&rkey, &wfName, &status, &wfError, &createdAt); err != nil { + if err := rows.Scan(&pipelineID, &wfName, &status, &wfError, &createdAt); err != nil { return nil, err } - k := wfKey{rkey, wfName} + k := wfKey{pipelineID, wfName} st := out[k] // rows arrive in insertion order, so the last one wins for the current status diff --git a/spindle/db/pipelines_test.go b/spindle/db/pipelines_test.go index d2fdebb25..c18688532 100644 --- a/spindle/db/pipelines_test.go +++ b/spindle/db/pipelines_test.go @@ -32,7 +32,7 @@ func seedPipeline(t *testing.T, d *DB, rkey, repoDid, kind string) { TriggerMetadata: tm, Workflows: []*tangled.Pipeline_Workflow{{Name: "ci.yml"}}, } - if err := d.CreatePipeline(models.PipelineId{Knot: "knot.test", Rkey: rkey}, raw); err != nil { + if err := d.CreatePipeline(models.PipelineId(rkey), raw); err != nil { t.Fatalf("seed pipeline %s: %v", rkey, err) } } @@ -126,12 +126,12 @@ func TestQueryPipelines_WorkflowStatuses(t *testing.T) { }, Workflows: []*tangled.Pipeline_Workflow{{Name: "a"}, {Name: "b"}}, } - if err := d.CreatePipeline(models.PipelineId{Knot: "knot.test", Rkey: "pl1"}, raw); err != nil { + if err := d.CreatePipeline(models.PipelineId("pl1"), raw); err != nil { t.Fatalf("CreatePipeline: %v", err) } - widA := models.WorkflowId{PipelineId: models.PipelineId{Rkey: "pl1"}, Name: "a"} - widB := models.WorkflowId{PipelineId: models.PipelineId{Rkey: "pl1"}, Name: "b"} + widA := models.WorkflowId{PipelineId: models.PipelineId("pl1"), Name: "a"} + widB := models.WorkflowId{PipelineId: models.PipelineId("pl1"), Name: "b"} for _, step := range []func() error{ func() error { return d.StatusPending(widA, &n) }, diff --git a/spindle/engine/engine.go b/spindle/engine/engine.go index a2d33dd4d..17574c021 100644 --- a/spindle/engine/engine.go +++ b/spindle/engine/engine.go @@ -269,7 +269,7 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, q wl := l.With( "owner_did", w.OwnerDID, "repo_did", repoDID, - "pipeline_id", pipelineId.AtUri().String(), + "pipeline_id", pipelineId.String(), "workflow_id", wid.String(), ) @@ -303,7 +303,7 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, q )) if span.IsRecording() { attrs := []attribute.KeyValue{ - attribute.String(observability.PipelineIDKey, pipelineId.AtUri().String()), + attribute.String(observability.PipelineIDKey, pipelineId.String()), attribute.String(observability.WorkflowIDKey, wid.String()), } if w.OwnerDID != "" { @@ -479,7 +479,7 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, q }() if qm != nil && hasQuotaReporter { - resID := quota.WorkflowReservationID(w.RunID, w.OwnerDID, w.RepoDID, wid.Knot, wid.Rkey, wid.Name) + resID := quota.WorkflowReservationID(w.RunID, w.OwnerDID, w.RepoDID, string(wid.PipelineId), wid.Name) req := quota.ReserveRequest{ ID: resID, Kind: quota.KindWorkflow, @@ -563,7 +563,7 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, q if stepSpan.IsRecording() { attrs := []attribute.KeyValue{ attribute.String(observability.WorkflowEngineKey, engName), - attribute.String(observability.PipelineIDKey, pipelineId.AtUri().String()), + attribute.String(observability.PipelineIDKey, pipelineId.String()), attribute.String(observability.WorkflowIDKey, wid.String()), attribute.Int(observability.StepIndexKey, stepIdx), attribute.String(observability.StepNameKey, step.Name()), diff --git a/spindle/engine/engine_quota_test.go b/spindle/engine/engine_quota_test.go index 0c1223405..62bfdbc89 100644 --- a/spindle/engine/engine_quota_test.go +++ b/spindle/engine/engine_quota_test.go @@ -99,7 +99,7 @@ func TestStartWorkflows_NoDoubleAcquisitionByNoReporterEngine(t *testing.T) { eng: {wf}, }, } - pipelineId := models.PipelineId{Knot: "knot", Rkey: "rkey"} + pipelineId := models.PipelineId("rkey") StartWorkflows(logger, nil, cfg, qm, nil, testDB, nil, context.Background(), pipeline, pipelineId) @@ -141,7 +141,7 @@ func TestStartWorkflows_QuotaAcquisitionAndIdentity(t *testing.T) { eng: {wf}, }, } - pipelineId := models.PipelineId{Knot: "knot", Rkey: "rkey"} + pipelineId := models.PipelineId("rkey") StartWorkflows(logger, nil, cfg, qm, nil, testDB, nil, context.Background(), pipeline, pipelineId) @@ -158,7 +158,7 @@ func TestStartWorkflows_QuotaAcquisitionAndIdentity(t *testing.T) { t.Errorf("unexpected identity in reservation: %+v", req.Identity) } - expectedID := quota.WorkflowReservationID("", "did:web:alice", "did:web:alice/repo", "knot", "rkey", "job1") + expectedID := quota.WorkflowReservationID("", "did:web:alice", "did:web:alice/repo", "rkey", "job1") if req.ID != expectedID { t.Errorf("expected reservation ID %q, got %q", expectedID, req.ID) } @@ -236,7 +236,7 @@ func TestStartWorkflows_QuotaFailureRecordsWorkflowFailure(t *testing.T) { } metrics := observability.NewMetrics() ctx := observability.WithMetrics(context.Background(), metrics) - StartWorkflows(logger, nil, cfg, qm, nil, testDB, nil, ctx, pipeline, models.PipelineId{Knot: "knot", Rkey: "rkey"}) + StartWorkflows(logger, nil, cfg, qm, nil, testDB, nil, ctx, pipeline, models.PipelineId("rkey")) families, err := metrics.Registry().Gather() if err != nil { diff --git a/spindle/engine/engine_test.go b/spindle/engine/engine_test.go index 8134c4c49..39ba955ae 100644 --- a/spindle/engine/engine_test.go +++ b/spindle/engine/engine_test.go @@ -111,10 +111,7 @@ func TestStartWorkflows_CollisionRejection(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) eng := &mockEngine{} - pipelineId := models.PipelineId{ - Knot: "test-knot", - Rkey: "test-rkey", - } + pipelineId := models.PipelineId("test-rkey") // two names that normalize to the same wid must not both run wfColliding1 := models.Workflow{ @@ -194,10 +191,7 @@ func TestCancelWorkflow_NotOverwritten(t *testing.T) { }, } - pipelineId := models.PipelineId{ - Knot: "test-knot", - Rkey: "test-rkey", - } + pipelineId := models.PipelineId("test-rkey") wid := models.WorkflowId{ PipelineId: pipelineId, @@ -292,7 +286,7 @@ func TestStartWorkflows_FlushesLogBeforeTerminalStatus(t *testing.T) { }, } - pipelineId := models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"} + pipelineId := models.PipelineId("test-rkey") wid := models.WorkflowId{PipelineId: pipelineId, Name: "failed_job"} pipeline := &models.Pipeline{ RepoDid: repoDid, @@ -354,7 +348,7 @@ func TestSetupTimeout_ReportsTimeout(t *testing.T) { }, } - pipelineId := models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"} + pipelineId := models.PipelineId("test-rkey") wid := models.WorkflowId{PipelineId: pipelineId, Name: "timeout_job"} pipeline := &models.Pipeline{ @@ -393,10 +387,7 @@ func TestStartWorkflows_SpanEnrichment(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) eng := &mockEngine{} - pipelineId := models.PipelineId{ - Knot: "test-knot", - Rkey: "test-rkey", - } + pipelineId := models.PipelineId("test-rkey") pipeline := &models.Pipeline{ RepoDid: "did:web:test-repo", @@ -448,7 +439,7 @@ func TestStartWorkflows_SpanEnrichment(t *testing.T) { expectedWfAttrs := map[string]string{ observability.OwnerDIDKey: "did:web:test-owner", observability.RepoDIDKey: "did:web:test-repo", - observability.PipelineIDKey: pipelineId.AtUri().String(), + observability.PipelineIDKey: pipelineId.String(), observability.WorkflowIDKey: wid.String(), } for k, wantVal := range expectedWfAttrs { @@ -465,7 +456,7 @@ func TestStartWorkflows_SpanEnrichment(t *testing.T) { expectedStepAttrs := map[string]string{ observability.OwnerDIDKey: "did:web:test-owner", observability.RepoDIDKey: "did:web:test-repo", - observability.PipelineIDKey: pipelineId.AtUri().String(), + observability.PipelineIDKey: pipelineId.String(), observability.WorkflowIDKey: wid.String(), observability.StepNameKey: "step-1", } @@ -552,10 +543,7 @@ func TestStartWorkflows_ResourceTracking(t *testing.T) { usageOk: true, } - pipelineId := models.PipelineId{ - Knot: "test-knot", - Rkey: "test-rkey", - } + pipelineId := models.PipelineId("test-rkey") pipeline := &models.Pipeline{ RepoDid: "did:web:test-repo", diff --git a/spindle/engines/microvm/debug_e2e_test.go b/spindle/engines/microvm/debug_e2e_test.go index 6d4136241..673222a59 100644 --- a/spindle/engines/microvm/debug_e2e_test.go +++ b/spindle/engines/microvm/debug_e2e_test.go @@ -112,7 +112,7 @@ func TestDebugShellStaysOpenWhileIdleE2E(t *testing.T) { } wid := models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "pipeline"}, + PipelineId: models.PipelineId("pipeline"), Name: "debug-idle-e2e.yml", } engine := &Engine{l: logger, debug: make(map[string]debugTarget)} diff --git a/spindle/knotfeed.go b/spindle/knotfeed.go index 652218b81..1f1641160 100644 --- a/spindle/knotfeed.go +++ b/spindle/knotfeed.go @@ -173,11 +173,11 @@ func (s *Spindle) handleRefOp(ctx context.Context, knot string, repoDid syntax.D if err != nil { return err } - if pipelineId.Rkey == "" { + if pipelineId == "" { l.Info("no workflow matched 'push' trigger, skipping the event") return nil } - l.Info("pipeline triggered", "pipeline", pipelineId.AtUri()) + l.Info("pipeline triggered", "pipeline", pipelineId) return nil } diff --git a/spindle/logview/logview_test.go b/spindle/logview/logview_test.go index 2b004424a..009770f66 100644 --- a/spindle/logview/logview_test.go +++ b/spindle/logview/logview_test.go @@ -32,8 +32,8 @@ func TestFollowDoesNotUseAnotherPipelineArtifact(t *testing.T) { } t.Cleanup(func() { d.Close() }) - target := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example.com", Rkey: "target"}, Name: "build.yml"} - other := models.WorkflowId{PipelineId: models.PipelineId{Knot: target.Knot, Rkey: "other"}, Name: target.Name} + target := models.WorkflowId{PipelineId: models.PipelineId("target"), Name: "build.yml"} + other := models.WorkflowId{PipelineId: models.PipelineId("other"), Name: target.Name} if err := d.SaveArtifactRef("other-lease", "did:plc:other", other, "other.log", "hash"); err != nil { t.Fatal(err) } @@ -54,8 +54,8 @@ func TestFollowReadsExactPipelineArtifact(t *testing.T) { } t.Cleanup(func() { d.Close() }) - target := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example.com", Rkey: "target"}, Name: "build.yml"} - other := models.WorkflowId{PipelineId: models.PipelineId{Knot: target.Knot, Rkey: "other"}, Name: target.Name} + target := models.WorkflowId{PipelineId: models.PipelineId("target"), Name: "build.yml"} + other := models.WorkflowId{PipelineId: models.PipelineId("other"), Name: target.Name} if err := d.SaveArtifactRef("other-lease", "did:plc:other", other, "other.log", "hash"); err != nil { t.Fatal(err) } diff --git a/spindle/mill/auth_test.go b/spindle/mill/auth_test.go index 47843d789..65c60fec2 100644 --- a/spindle/mill/auth_test.go +++ b/spindle/mill/auth_test.go @@ -272,9 +272,9 @@ func TestOnStatusEventOwnership(t *testing.T) { m.Attach(bdb, &n, testQuotaManager(t, bdb)) foreign := newLease("lease-foreign", "node-x", "inc-x", "dummy") - foreign.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "foreign"}, Name: "build"} + foreign.wid = models.WorkflowId{PipelineId: models.PipelineId("foreign"), Name: "build"} owned := newLease("lease-owned", "node-z", "inc-z", "dummy") - owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "owned"}, Name: "build"} + owned.wid = models.WorkflowId{PipelineId: models.PipelineId("owned"), Name: "build"} m.mu.Lock() m.leases[foreign.id] = foreign m.leases[owned.id] = owned @@ -432,6 +432,31 @@ func TestAuthLabelEscalation(t *testing.T) { } } +func TestProtocolVersionMismatchClosesConnection(t *testing.T) { + _, _, server, secret := setupTestServer(t, []string{"linux"}) + header := http.Header{} + header.Set("Authorization", "Bearer "+secret) + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), header) + if err != nil { + t.Fatalf("dial failed: %v", err) + } + defer conn.Close() + + stream := millproto.NewWSStream(conn) + enc := millproto.NewEncoder(stream) + if err := enc.Encode(&millproto.Message{Hello: &millv1.Hello{ + ProtocolVersion: millproto.ProtocolVersion - 1, + Arch: "amd64", + Labels: []string{"linux"}, + Epoch: "inc-1", + }}); err != nil { + t.Fatalf("encode hello: %v", err) + } + if _, err := millproto.NewDecoder(stream).Decode(); err == nil { + t.Fatal("expected server to close a stale protocol connection") + } +} + func TestHandshakeTimeoutAndConcurrency(t *testing.T) { _, _, server, secret := setupTestServer(t, []string{"linux"}) wsUrl := "ws" + strings.TrimPrefix(server.URL, "http") diff --git a/spindle/mill/executor/capability_test.go b/spindle/mill/executor/capability_test.go index 1aa4001c0..137a439e5 100644 --- a/spindle/mill/executor/capability_test.go +++ b/spindle/mill/executor/capability_test.go @@ -50,8 +50,7 @@ func TestHandleReserveRejectsMissingTriggerMetadata(t *testing.T) { TargetEngine: "microvm", RawWorkflowJson: string(twf), RawPipelineJson: string(tpl), - Knot: "k", - Rkey: "r", + PipelineId: "r", }) result := (<-enc.messages).GetReserveResult() @@ -94,8 +93,7 @@ func TestHandleReserveValidatesPlacementBeforeAcquiringSlot(t *testing.T) { TargetEngine: "microvm", RawWorkflowJson: string(twf), RawPipelineJson: string(tpl), - Knot: "k", - Rkey: "r", + PipelineId: "r", }) result := (<-enc.messages).GetReserveResult() diff --git a/spindle/mill/executor/executor.go b/spindle/mill/executor/executor.go index df229876f..14f57af91 100644 --- a/spindle/mill/executor/executor.go +++ b/spindle/mill/executor/executor.go @@ -395,7 +395,7 @@ func (e *Executor) handleReserve(ctx context.Context, rs *millv1.ReserveSeat) { span.SetAttributes( attribute.String(observability.LeaseIDKey, rs.GetLeaseId()), attribute.String(observability.ExecutorNodeIDKey, e.nodeID), - attribute.String(observability.PipelineIDKey, (&models.PipelineId{Knot: rs.GetKnot(), Rkey: rs.GetRkey()}).AtUri().String()), + attribute.String(observability.PipelineIDKey, rs.GetPipelineId()), ) } accepted := false @@ -450,7 +450,7 @@ func (e *Executor) handleReserve(ctx context.Context, rs *millv1.ReserveSeat) { reject("bad workflow json", millv1.RejectClass_REJECT_CLASS_INCOMPATIBLE) return } - pipelineId := models.PipelineId{Knot: rs.GetKnot(), Rkey: rs.GetRkey()} + pipelineId := models.PipelineId(rs.GetPipelineId()) wid := models.WorkflowId{PipelineId: pipelineId, Name: twf.Name} if span.IsRecording() { span.SetAttributes(attribute.String(observability.WorkflowIDKey, wid.String())) @@ -603,7 +603,7 @@ func (e *Executor) handleCommit(ctx context.Context, cl *millv1.CommitLease) { attribute.String(observability.WorkflowIDKey, res.wid.String()), attribute.String(observability.LeaseIDKey, res.leaseID), attribute.String(observability.ExecutorNodeIDKey, e.nodeID), - attribute.String(observability.PipelineIDKey, res.wid.PipelineId.AtUri().String()), + attribute.String(observability.PipelineIDKey, res.wid.PipelineId.String()), } if res.repoDid.String() != "" { attrs = append(attrs, attribute.String(observability.RepoDIDKey, res.repoDid.String())) diff --git a/spindle/mill/executor/observe.go b/spindle/mill/executor/observe.go index b88c54ffe..a82173f5d 100644 --- a/spindle/mill/executor/observe.go +++ b/spindle/mill/executor/observe.go @@ -171,11 +171,11 @@ func (e *Executor) finishJob(res *reservation, st *db.StatusRow) error { return nil } -func (e *Executor) reservationFor(rkey, workflow string) *reservation { +func (e *Executor) reservationFor(pipelineID, workflow string) *reservation { e.mu.Lock() defer e.mu.Unlock() for _, res := range e.active { - if res.wid.PipelineId.Rkey == rkey && res.wid.Name == workflow { + if res.wid.PipelineId == models.PipelineId(pipelineID) && res.wid.Name == workflow { return res } } diff --git a/spindle/mill/executor/outbox.go b/spindle/mill/executor/outbox.go index 5bf45a596..7483d258a 100644 --- a/spindle/mill/executor/outbox.go +++ b/spindle/mill/executor/outbox.go @@ -216,7 +216,7 @@ func (e *Executor) recoverPendingArtifacts(parent context.Context) error { logDir = e.cfg.Server.LogDir } wid := models.WorkflowId{ - PipelineId: models.PipelineId{Knot: p.Knot, Rkey: p.Rkey}, + PipelineId: models.PipelineId(p.PipelineID), Name: p.Workflow, } logPath := models.LogFilePath(logDir, wid) diff --git a/spindle/mill/executor/reserved_test.go b/spindle/mill/executor/reserved_test.go index 27e14bb94..7809dde8b 100644 --- a/spindle/mill/executor/reserved_test.go +++ b/spindle/mill/executor/reserved_test.go @@ -166,6 +166,7 @@ func testReserveSeat(t *testing.T, leaseID, engineName string) *millv1.ReserveSe TargetEngine: engineName, RawWorkflowJson: string(twf), RawPipelineJson: string(tpl), + PipelineId: "pipeline", } } @@ -362,7 +363,7 @@ func TestPendingArtifactRecoveryCompletesWithoutDeadlock(t *testing.T) { d := testDB(t) logDir := t.TempDir() wid := models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "3abc"}, + PipelineId: models.PipelineId("3abc"), Name: "build", } logPath := models.LogFilePath(logDir, wid) @@ -435,7 +436,7 @@ func TestPendingArtifactRecoveryCompletesWithoutDeadlock(t *testing.T) { func TestPendingArtifactRecoveryKeepsRowWhenLogIsMissing(t *testing.T) { d := testDB(t) wid := models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "3abc"}, + PipelineId: models.PipelineId("3abc"), Name: "build", } if err := d.SavePendingArtifact( @@ -482,8 +483,8 @@ func TestPendingArtifactRecoveryUsesAggregateDeadline(t *testing.T) { d := testDB(t) logDir := t.TempDir() wids := []models.WorkflowId{ - {PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "3abc"}, Name: "build"}, - {PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "3def"}, Name: "test"}, + {PipelineId: models.PipelineId("3abc"), Name: "build"}, + {PipelineId: models.PipelineId("3def"), Name: "test"}, } for i, wid := range wids { logPath := models.LogFilePath(logDir, wid) @@ -565,7 +566,7 @@ func TestHandleCommitPreservesPreauthorizedSecrets(t *testing.T) { } res := &reservation{ leaseID: "lease-1", - wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}, + wid: models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"}, realEngine: inner, slot: slot, wf: &models.Workflow{Name: "build", Steps: []models.Step{fakeStep{}}}, @@ -649,7 +650,7 @@ func TestConnectHandshakesWhilePendingArtifactRecoveryIsBlocked(t *testing.T) { logDir := t.TempDir() e.cfg.Server.LogDir = logDir wid := models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "3abc"}, + PipelineId: models.PipelineId("3abc"), Name: "build", } logPath := models.LogFilePath(logDir, wid) @@ -813,7 +814,7 @@ func TestFinishJobReportsCancelledReservationAsCancelled(t *testing.T) { d := testDB(t) res := &reservation{ leaseID: "lease-1", - wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}, + wid: models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"}, cancelled: true, } e := &Executor{ @@ -827,7 +828,7 @@ func TestFinishJobReportsCancelledReservationAsCancelled(t *testing.T) { } e.finishJob(res, &db.StatusRow{ - Pipeline: res.wid.PipelineId.Rkey, + Pipeline: string(res.wid.PipelineId), Workflow: res.wid.Name, Status: string(models.StatusKindFailed), }) @@ -855,7 +856,7 @@ func TestFinishJobWaitsForEngineCleanup(t *testing.T) { res := &reservation{ leaseID: "lease-1", wid: models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, + PipelineId: models.PipelineId("r"), Name: "build", }, runDone: make(chan struct{}), @@ -873,7 +874,7 @@ func TestFinishJobWaitsForEngineCleanup(t *testing.T) { done := make(chan error, 1) go func() { done <- e.finishJob(res, &db.StatusRow{ - Pipeline: res.wid.PipelineId.Rkey, + Pipeline: string(res.wid.PipelineId), Workflow: res.wid.Name, Status: string(models.StatusKindSuccess), }) @@ -982,7 +983,7 @@ func TestSocketCancellationIndependence(t *testing.T) { } res := &reservation{ leaseID: "lease-1", - wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}, + wid: models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"}, realEngine: inner, slot: slot, wf: &models.Workflow{Name: "build", Steps: []models.Step{fakeStep{}}}, @@ -1345,6 +1346,7 @@ func TestTimerRace(t *testing.T) { TargetEngine: "microvm", RawWorkflowJson: string(twf), RawPipelineJson: string(tpl), + PipelineId: "pipeline", TtlSeconds: 1, }) @@ -1397,7 +1399,7 @@ func TestStructuredShutdown(t *testing.T) { } res := &reservation{ leaseID: "lease-1", - wid: models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}, + wid: models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"}, realEngine: inner, slot: slot, wf: &models.Workflow{Name: "build", Steps: []models.Step{fakeStep{}}}, diff --git a/spindle/mill/integration_test.go b/spindle/mill/integration_test.go index beb3049eb..41f2f762c 100644 --- a/spindle/mill/integration_test.go +++ b/spindle/mill/integration_test.go @@ -77,7 +77,7 @@ func TestEndToEndDummyJob(t *testing.T) { if err != nil { t.Fatalf("InitWorkflow: %v", err) } - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} if err := bdb.StatusPending(wid, &bn); err != nil { t.Fatal(err) } @@ -304,7 +304,7 @@ func TestEndToEndDummyJobUsesRequiredLabelsAcrossExecutors(t *testing.T) { if err != nil { t.Fatalf("InitWorkflow: %v", err) } - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey-arm"}, Name: "build-arm"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey-arm"), Name: "build-arm"} placeCtx, placeCancel := context.WithTimeout(ctx, 10*time.Second) defer placeCancel() @@ -356,7 +356,7 @@ func waitForStatus(t *testing.T, d *db.DB, wid models.WorkflowId, want string) b t.Fatalf("GetEvents: %v", err) } for _, ev := range evs { - if ev.Pipeline == wid.PipelineId.Rkey && ev.Workflow == wid.Name && ev.Status == want { + if ev.Pipeline == string(wid.PipelineId) && ev.Workflow == wid.Name && ev.Status == want { return true } } diff --git a/spindle/mill/mill.go b/spindle/mill/mill.go index 299f47744..7d051c622 100644 --- a/spindle/mill/mill.go +++ b/spindle/mill/mill.go @@ -560,7 +560,7 @@ func (m *Mill) quotaRequest(wid models.WorkflowId, wf *models.Workflow, resource m.l.Warn("placing workflow without quota because the pipeline has no repo identity", "workflow", wid.String()) return quota.ReserveRequest{}, false } - resID := quota.WorkflowReservationID(wf.RunID, id.OwnerDID, id.RepoDID, wid.Knot, wid.Rkey, wid.Name) + resID := quota.WorkflowReservationID(wf.RunID, id.OwnerDID, id.RepoDID, string(wid.PipelineId), wid.Name) return quota.ReserveRequest{ ID: resID, Kind: quota.KindWorkflow, @@ -571,11 +571,14 @@ func (m *Mill) quotaRequest(wid models.WorkflowId, wf *models.Workflow, resource } func (m *Mill) quotaIdentity(wf *models.Workflow) (quota.Identity, bool) { - st, ok := wf.Data.(*millWorkflowState) - if !ok || st == nil || st.RawPipeline.TriggerMetadata == nil { + if wf == nil { + return quota.Identity{}, false + } + state, ok := wf.Data.(*millWorkflowState) + if !ok || state == nil || state.RawPipeline.TriggerMetadata == nil { return quota.Identity{}, false } - repo := st.RawPipeline.TriggerMetadata.Repo + repo := state.RawPipeline.TriggerMetadata.Repo if repo == nil { return quota.Identity{}, false } @@ -643,7 +646,7 @@ func (m *Mill) bid(ctx context.Context, engineName string, wid models.WorkflowId attribute.String(observability.WorkflowIDKey, wid.String()), attribute.String(observability.LeaseIDKey, leaseID), attribute.String(observability.ExecutorNodeIDKey, sess.nodeID), - attribute.String(observability.PipelineIDKey, wid.PipelineId.AtUri().String()), + attribute.String(observability.PipelineIDKey, wid.PipelineId.String()), } if ownerDID != "" { attrs = append(attrs, attribute.String(observability.OwnerDIDKey, ownerDID)) @@ -670,8 +673,7 @@ func (m *Mill) bid(ctx context.Context, engineName string, wid models.WorkflowId TargetEngine: engineName, RawPipelineJson: rawPipeline, RawWorkflowJson: rawWorkflow, - Knot: wid.PipelineId.Knot, - Rkey: wid.PipelineId.Rkey, + PipelineId: string(wid.PipelineId), TtlSeconds: uint32(m.cfg.ReconnectGrace / time.Second), Traceparent: traceparent, Tracestate: tracestate, @@ -894,7 +896,7 @@ func (m *Mill) commitAndWait(ctx context.Context, wf *models.Workflow, unlocked attribute.String(observability.WorkflowIDKey, lease.wid.String()), attribute.String(observability.LeaseIDKey, lease.id), attribute.String(observability.ExecutorNodeIDKey, lease.nodeID), - attribute.String(observability.PipelineIDKey, lease.wid.PipelineId.AtUri().String()), + attribute.String(observability.PipelineIDKey, lease.wid.PipelineId.String()), } if lease.ownerDID != "" { attrs = append(attrs, attribute.String(observability.OwnerDIDKey, lease.ownerDID)) @@ -1467,7 +1469,7 @@ func (m *Mill) onEventBatch(sess *millSession, batch *millv1.EventBatch) error { if !strings.HasPrefix(a.GetHash(), "sha256:") { return protoErrf("invalid log artifact hash %q", a.GetHash()) } - if lease.wid.Knot == "" || lease.wid.Rkey == "" || lease.wid.Name == "" { + if lease.wid.PipelineId == "" || lease.wid.Name == "" { m.l.Error("skipping log artifact with incomplete workflow identity", "lease", lease.id, "wid", lease.wid) } else { if tx != nil { diff --git a/spindle/mill/mill_test.go b/spindle/mill/mill_test.go index 4132eceda..c9f644ba1 100644 --- a/spindle/mill/mill_test.go +++ b/spindle/mill/mill_test.go @@ -154,7 +154,7 @@ func TestCommitRetriesAfterSessionCloseBeforeCommitted(t *testing.T) { l := slog.New(slog.NewTextHandler(io.Discard, nil)) m := New(l, Config{BidTimeout: 25 * time.Millisecond, ReconnectGrace: time.Second}) wf := testWorkflow("build") - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease := newLease("lease-1", "node-1", "inc-1", "dummy") lease.wid = wid wf.Data.(*millWorkflowState).Lease = lease @@ -224,7 +224,7 @@ func TestCommitRetriesAfterSessionCloseBeforeCommitted(t *testing.T) { func TestDestroyRunningLeaseDoesNotDropCancelledTerminal(t *testing.T) { l := slog.New(slog.NewTextHandler(io.Discard, nil)) m := New(l, Config{}) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease := newLease("lease-1", "node-1", "inc-1", "dummy") lease.wid = wid lease.setState(leaseRunning) @@ -251,7 +251,7 @@ func TestPlaceBlocksWhenNoCapacity(t *testing.T) { l := slog.New(slog.NewTextHandler(io.Discard, nil)) m := New(l, Config{}) wf := testWorkflow("build") - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} // with no executors at all, place must block until ctx expires and the user sees pending ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) @@ -322,7 +322,7 @@ func TestPlaceWithMissingRequiredLabelsStaysPendingWithoutReserve(t *testing.T) return nil })) wf := testWorkflowWithRunsOn("build", []string{"linux", "arm64"}) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond) defer cancel() @@ -354,7 +354,7 @@ func TestMaxPendingRejects(t *testing.T) { func TestCancelledRunningLeaseSurvivesReleaseForReconnectReplay(t *testing.T) { m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease := newLease("lease-1", "node-1", "inc-1", "dummy") lease.wid = wid lease.setState(leaseRunning) @@ -496,7 +496,7 @@ func TestSilentSessionReplacementWithoutSnapshotFailsLeaseAtDeadline(t *testing. t.Fatal("first attach rejected") } lease := newLease("lease-1", old.nodeID, old.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persistLease: %v", err) @@ -555,7 +555,7 @@ func TestPlaceReleasesRemoteReservationWhenInitialPersistenceFails(t *testing.T) slot, err := m.place( ctx, "dummy", - models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"}, + models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"}, testWorkflow("build"), ) if err == nil { @@ -586,7 +586,7 @@ func TestGapsAndDuplicates(t *testing.T) { m.attachSession(sess) owned := newLease("lease-1", "node-1", "inc-1", "dummy") - owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + owned.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} m.mu.Lock() m.leases[owned.id] = owned m.mu.Unlock() @@ -630,7 +630,7 @@ func TestAtomicBatchRollback(t *testing.T) { m.attachSession(sess) owned := newLease("lease-1", "node-1", "inc-1", "dummy") - owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + owned.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} m.mu.Lock() m.leases[owned.id] = owned m.mu.Unlock() @@ -676,7 +676,7 @@ func TestTerminalBeforeACK(t *testing.T) { ackSent := make(chan struct{}) sess := newSession("node-1", "inc-1", nil, scriptedEncoder(func(msg *millproto.Message) error { if msg.GetAck() != nil { - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} st, err := bdb.GetStatus(wid) if err != nil || st != "success" { t.Errorf("expected terminal status success at ACK time, got status: %v, err: %v", st, err) @@ -688,7 +688,7 @@ func TestTerminalBeforeACK(t *testing.T) { m.attachSession(sess) owned := newLease("lease-1", "node-1", "inc-1", "dummy") - owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + owned.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} m.mu.Lock() m.leases[owned.id] = owned m.mu.Unlock() @@ -722,7 +722,7 @@ func TestTerminalWithIncompleteIdentityAdvancesCursor(t *testing.T) { m.attachSession(sess) lease := newLease("lease-1", sess.nodeID, sess.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{Name: "build"} m.mu.Lock() m.leases[lease.id] = lease m.mu.Unlock() @@ -764,7 +764,7 @@ func TestExecutorRestartEmptySnapshot(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) lease := newLease("lease-1", "node-1", "inc-old", "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} m.mu.Lock() m.leases[lease.id] = lease m.mu.Unlock() @@ -794,7 +794,7 @@ func TestExecutorRestartEmptySnapshot(t *testing.T) { func TestReplacementLostBeforeSnapshotFailsOldEpochLease(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) lease := newLease("lease-1", "node-1", "inc-old", "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persistLease: %v", err) @@ -842,7 +842,7 @@ func TestClaimedSweep(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) lease := newLease("lease-1", "node-1", "inc-1", "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.orphaned = true lease.claimed = false m.mu.Lock() @@ -884,7 +884,7 @@ func TestUnacknowledgedCancelClosesSession(t *testing.T) { m.attachSession(sess) lease := newLease("lease-1", "node-1", "inc-1", "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) m.mu.Lock() m.leases[lease.id] = lease @@ -906,7 +906,7 @@ func TestPendingCancelBlocksPlacementUntilAcknowledged(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute, CancelAckTimeout: time.Minute}) sess := addCandidateSession(t, m, "node-1", nil, 0, nil) lease := newLease("lease-1", "node-1", sess.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) m.mu.Lock() m.leases[lease.id] = lease @@ -924,7 +924,7 @@ func TestTerminalSettlesPendingCancelPlacementGate(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute, CancelAckTimeout: time.Minute}) sess := addCandidateSession(t, m, "node-1", nil, 0, nil) lease := newLease("lease-1", sess.nodeID, sess.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) m.mu.Lock() m.leases[lease.id] = lease @@ -951,7 +951,7 @@ func TestCancelSentAfterTerminalDoesNotLeakPlacementGate(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute, CancelAckTimeout: time.Minute}) sess := addCandidateSession(t, m, "node-1", nil, 0, nil) lease := newLease("lease-1", sess.nodeID, sess.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persistLease: %v", err) @@ -979,7 +979,7 @@ func TestValidSnapshotRecoversAfterProtocolError(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) first := addCandidateSession(t, m, "node-1", nil, 0, nil) lease := newLease("lease-1", first.nodeID, first.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) m.mu.Lock() m.leases[lease.id] = lease @@ -1015,7 +1015,7 @@ func TestReconnectRetriesKeepOriginalFailureDeadline(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) first := addCandidateSession(t, m, "node-1", nil, 0, nil) lease := newLease("lease-1", first.nodeID, first.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persistLease: %v", err) @@ -1052,7 +1052,7 @@ func TestUnacknowledgedCancelReconnectsReachBoundedTerminal(t *testing.T) { m, _ := restoreTestMill(t, Config{ReconnectGrace: time.Minute, CancelAckTimeout: time.Minute}) first := addCandidateSession(t, m, "node-1", nil, 0, nil) lease := newLease("lease-1", first.nodeID, first.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persistLease: %v", err) @@ -1123,7 +1123,7 @@ func TestAckedCancelFinishesOnlyItsLeaseAfterTeardownDeadline(t *testing.T) { m.attachSession(sess) lease := newLease("lease-1", "node-1", "inc-1", "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persistLease: %v", err) @@ -1172,7 +1172,7 @@ func TestLateCancelAckTimerKeepsReconnectGrace(t *testing.T) { } lease := newLease("lease-1", "node-1", "inc-1", "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persistLease: %v", err) @@ -1212,7 +1212,7 @@ func TestCancelAckDeadlineDoesNotCloseReplacementSession(t *testing.T) { oldSession := newSession("node-1", "inc-1", nil, nopEncoder(), discardLogger()) m.attachSession(oldSession) lease := newLease("lease-1", "node-1", oldSession.epoch, "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} lease.setState(leaseRunning) m.mu.Lock() m.leases[lease.id] = lease @@ -1250,7 +1250,7 @@ func TestCleanupRetry(t *testing.T) { m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) lease := newLease("lease-1", "node-1", "inc-1", "dummy") - lease.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + lease.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} if err := m.persistLease(lease, leaseRowRunning); err != nil { t.Fatalf("persist lease: %v", err) } @@ -1307,7 +1307,7 @@ func TestBidPreservesConsistentTypedIncompatibility(t *testing.T) { }) wid := models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "pipeline"}, + PipelineId: models.PipelineId("pipeline"), Name: "build", } _, err := m.bid(context.Background(), "dummy", wid, testWorkflow("build")) diff --git a/spindle/mill/proto/gen/mill.pb.go b/spindle/mill/proto/gen/mill.pb.go index 30904ff63..e154cd951 100644 --- a/spindle/mill/proto/gen/mill.pb.go +++ b/spindle/mill/proto/gen/mill.pb.go @@ -474,14 +474,12 @@ type ReserveSeat struct { TargetEngine string `protobuf:"bytes,2,opt,name=target_engine,json=targetEngine,proto3" json:"target_engine,omitempty"` RawPipelineJson string `protobuf:"bytes,3,opt,name=raw_pipeline_json,json=rawPipelineJson,proto3" json:"raw_pipeline_json,omitempty"` RawWorkflowJson string `protobuf:"bytes,4,opt,name=raw_workflow_json,json=rawWorkflowJson,proto3" json:"raw_workflow_json,omitempty"` - // pipeline id, the executor reconstructs the exact WorkflowId from it - Knot string `protobuf:"bytes,5,opt,name=knot,proto3" json:"knot,omitempty"` - Rkey string `protobuf:"bytes,6,opt,name=rkey,proto3" json:"rkey,omitempty"` - TtlSeconds uint32 `protobuf:"varint,7,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` - Traceparent string `protobuf:"bytes,8,opt,name=traceparent,proto3" json:"traceparent,omitempty"` - Tracestate string `protobuf:"bytes,9,opt,name=tracestate,proto3" json:"tracestate,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + PipelineId string `protobuf:"bytes,5,opt,name=pipeline_id,json=pipelineId,proto3" json:"pipeline_id,omitempty"` + TtlSeconds uint32 `protobuf:"varint,6,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` + Traceparent string `protobuf:"bytes,7,opt,name=traceparent,proto3" json:"traceparent,omitempty"` + Tracestate string `protobuf:"bytes,8,opt,name=tracestate,proto3" json:"tracestate,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReserveSeat) Reset() { @@ -542,16 +540,9 @@ func (x *ReserveSeat) GetRawWorkflowJson() string { return "" } -func (x *ReserveSeat) GetKnot() string { - if x != nil { - return x.Knot - } - return "" -} - -func (x *ReserveSeat) GetRkey() string { +func (x *ReserveSeat) GetPipelineId() string { if x != nil { - return x.Rkey + return x.PipelineId } return "" } @@ -1821,19 +1812,19 @@ const file_spindle_mill_v1_mill_proto_rawDesc = "" + "\x10active_lease_ids\x18\x03 \x03(\tR\x0eactiveLeaseIds\x1a_\n" + "\fEnginesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x129\n" + - "\x05value\x18\x02 \x01(\v2#.spindle.mill.v1.EngineAvailabilityR\x05value:\x028\x01\"\xc2\x02\n" + + "\x05value\x18\x02 \x01(\v2#.spindle.mill.v1.EngineAvailabilityR\x05value:\x028\x01\"\xbb\x02\n" + "\vReserveSeat\x12\"\n" + "\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x12,\n" + "\rtarget_engine\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\ftargetEngine\x12*\n" + "\x11raw_pipeline_json\x18\x03 \x01(\tR\x0frawPipelineJson\x12*\n" + - "\x11raw_workflow_json\x18\x04 \x01(\tR\x0frawWorkflowJson\x12\x12\n" + - "\x04knot\x18\x05 \x01(\tR\x04knot\x12\x12\n" + - "\x04rkey\x18\x06 \x01(\tR\x04rkey\x12\x1f\n" + - "\vttl_seconds\x18\a \x01(\rR\n" + + "\x11raw_workflow_json\x18\x04 \x01(\tR\x0frawWorkflowJson\x12\x1f\n" + + "\vpipeline_id\x18\x05 \x01(\tR\n" + + "pipelineId\x12\x1f\n" + + "\vttl_seconds\x18\x06 \x01(\rR\n" + "ttlSeconds\x12 \n" + - "\vtraceparent\x18\b \x01(\tR\vtraceparent\x12\x1e\n" + + "\vtraceparent\x18\a \x01(\tR\vtraceparent\x12\x1e\n" + "\n" + - "tracestate\x18\t \x01(\tR\n" + + "tracestate\x18\b \x01(\tR\n" + "tracestate\"\xa4\x04\n" + "\rReserveResult\x12\"\n" + "\blease_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aleaseId\x12\x1a\n" + diff --git a/spindle/mill/proto/protocol.go b/spindle/mill/proto/protocol.go index b9db8cbbd..0a739f8e5 100644 --- a/spindle/mill/proto/protocol.go +++ b/spindle/mill/proto/protocol.go @@ -17,7 +17,7 @@ import ( ) const ( - ProtocolVersion = 3 + ProtocolVersion = 4 // generous vs agentproto's 1 MiB. a ReserveSeat carries the raw pipeline and // workflow JSON, and streamed log lines can be chunky MaxMessageBytes = 8 * 1024 * 1024 diff --git a/spindle/mill/proto/protocol_test.go b/spindle/mill/proto/protocol_test.go index b1d860e6c..90488bc92 100644 --- a/spindle/mill/proto/protocol_test.go +++ b/spindle/mill/proto/protocol_test.go @@ -19,8 +19,7 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { LeaseId: "lease-1", TargetEngine: "microvm", RawWorkflowJson: `{"name":"build"}`, - Knot: "knot.example", - Rkey: "abc123", + PipelineId: "abc123", TtlSeconds: 30, }, } @@ -36,7 +35,7 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { if rs == nil { t.Fatal("decoded message missing reserve_seat") } - if rs.LeaseId != "lease-1" || rs.TargetEngine != "microvm" || rs.TtlSeconds != 30 { + if rs.LeaseId != "lease-1" || rs.TargetEngine != "microvm" || rs.PipelineId != "abc123" || rs.TtlSeconds != 30 { t.Fatalf("round-trip mismatch: %+v", rs) } } @@ -384,3 +383,9 @@ func TestQuotaResourceMapBounds(t *testing.T) { } } } + +func TestProtocolVersion(t *testing.T) { + if ProtocolVersion != 4 { + t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion) + } +} diff --git a/spindle/mill/proto/spindle/mill/v1/mill.proto b/spindle/mill/proto/spindle/mill/v1/mill.proto index 0ff2e217f..1793d64ed 100644 --- a/spindle/mill/proto/spindle/mill/v1/mill.proto +++ b/spindle/mill/proto/spindle/mill/v1/mill.proto @@ -43,12 +43,10 @@ message ReserveSeat { string target_engine = 2 [(buf.validate.field).string.min_len = 1]; string raw_pipeline_json = 3; string raw_workflow_json = 4; - // pipeline id, the executor reconstructs the exact WorkflowId from it - string knot = 5; - string rkey = 6; - uint32 ttl_seconds = 7; - string traceparent = 8; - string tracestate = 9; + string pipeline_id = 5; + uint32 ttl_seconds = 6; + string traceparent = 7; + string tracestate = 8; } enum RejectClass { diff --git a/spindle/mill/quota_admission_test.go b/spindle/mill/quota_admission_test.go index 093757f0e..9d7e06460 100644 --- a/spindle/mill/quota_admission_test.go +++ b/spindle/mill/quota_admission_test.go @@ -180,7 +180,7 @@ func TestPlaceChargesReportedResourcesToPipelineIdentity(t *testing.T) { m, _ := quotaMill(t, store) addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -222,7 +222,7 @@ func TestPlaceReleasesSeatBeforeWaitingOnQuota(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -259,7 +259,7 @@ func TestPlaceFailsAndReleasesSeatOnPermanentDenial(t *testing.T) { m.RegisterMetrics(metrics) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() slot, err := m.place(ctx, "dummy", wid, testWorkflow("build")) @@ -319,7 +319,7 @@ func TestPlaceRejectsRebidWithDifferentResources(t *testing.T) { return map[string]int64{"workflows": 1, "vcpus": 2, "memory_mib": 2048} }) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() slot, err := m.place(ctx, "dummy", wid, testWorkflow("build")) @@ -347,7 +347,7 @@ func TestSlotReleaseReleasesQuotaExactlyOnce(t *testing.T) { m, _ := quotaMill(t, store) addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -369,7 +369,7 @@ func TestFailedLeaseAfterDisconnectReleasesQuotaOnce(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -406,7 +406,7 @@ func TestCacheReserveChargesTheLeaseSubject(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -508,7 +508,7 @@ func TestCacheReserveReplayRequiresSameResources(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -580,7 +580,7 @@ func TestCacheReserveDuplicateQueuesUntilFirstSettles(t *testing.T) { return candB.sess } placeLease := func(name string) *RemoteLease { - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: name} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: name} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow(name)) if err != nil { t.Fatalf("place %s: %v", name, err) @@ -649,7 +649,7 @@ func TestBidRejectsOutOfRangeReportedResources(t *testing.T) { return map[string]int64{"workflows": -1, "vcpus": 2} }) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() slot, err := m.place(ctx, "dummy", wid, testWorkflow("build")) @@ -675,7 +675,7 @@ func TestLiveQuotaReservationIDsSurviveRestart(t *testing.T) { m, bdb := quotaMill(t, store) addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} if _, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")); err != nil { t.Fatalf("place: %v", err) } @@ -728,7 +728,7 @@ func TestPersistedLeaseCarriesQuotaReservation(t *testing.T) { m, bdb := quotaMill(t, store) addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -757,7 +757,7 @@ func TestUnchargeablePlacementStillRuns(t *testing.T) { // place its work addQuotaCandidate(t, m, "node-a", func(int) map[string]int64 { return nil }) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -783,7 +783,7 @@ func TestCacheReservationDuplicateIDRace(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -860,7 +860,7 @@ func TestCacheReservationCommitAfterTeardown(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -951,7 +951,7 @@ func TestCacheReservationFailedReleaseRetry(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -1017,7 +1017,7 @@ func TestCacheReservationStaleEpochRejected(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -1094,7 +1094,7 @@ func TestCacheReservationTeardownCommitRetry(t *testing.T) { m, _ := quotaMill(t, store) cand := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} slot, err := m.place(context.Background(), "dummy", wid, testWorkflow("build")) if err != nil { t.Fatalf("place: %v", err) @@ -1179,7 +1179,7 @@ func TestBlockedSendDoesNotFreezeMill(t *testing.T) { m, _ := quotaMill(t, store) candA := addQuotaCandidate(t, m, "node-a", fixedReport(2, 2048)) - widA := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkeyA"}, Name: "buildA"} + widA := models.WorkflowId{PipelineId: models.PipelineId("rkeyA"), Name: "buildA"} slotA, err := m.place(context.Background(), "dummy", widA, testWorkflow("buildA")) if err != nil { t.Fatalf("place A: %v", err) @@ -1197,7 +1197,7 @@ func TestBlockedSendDoesNotFreezeMill(t *testing.T) { m.mu.Unlock() candB := addQuotaCandidate(t, m, "node-b", fixedReport(2, 2048)) - widB := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkeyB"}, Name: "buildB"} + widB := models.WorkflowId{PipelineId: models.PipelineId("rkeyB"), Name: "buildB"} slotB, err := m.place(context.Background(), "dummy", widB, testWorkflow("buildB")) if err != nil { t.Fatalf("place B: %v", err) diff --git a/spindle/mill/restore.go b/spindle/mill/restore.go index b895d8105..c29486970 100644 --- a/spindle/mill/restore.go +++ b/spindle/mill/restore.go @@ -30,8 +30,7 @@ func (m *Mill) persistLease(lease *RemoteLease, state string) error { NodeID: lease.nodeID, Epoch: lease.epoch, Engine: lease.engine, - Knot: lease.wid.PipelineId.Knot, - Rkey: lease.wid.PipelineId.Rkey, + PipelineID: string(lease.wid.PipelineId), Workflow: lease.wid.Name, State: state, QuotaReservationID: quotaID, @@ -80,7 +79,7 @@ func (m *Mill) RestoreState() error { for _, r := range rows { lease := newLease(r.LeaseID, r.NodeID, r.Epoch, r.Engine) lease.wid = models.WorkflowId{ - PipelineId: models.PipelineId{Knot: r.Knot, Rkey: r.Rkey}, + PipelineId: models.PipelineId(r.PipelineID), Name: r.Workflow, } // the charge outlives the mill process. only the id survives, so diff --git a/spindle/mill/restore_test.go b/spindle/mill/restore_test.go index fe023bd5d..003893907 100644 --- a/spindle/mill/restore_test.go +++ b/spindle/mill/restore_test.go @@ -43,7 +43,7 @@ func TestRestoreStateRebuildsLeasesAndCursors(t *testing.T) { if err := bdb.SaveMillLease(db.MillLease{ LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", - Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: leaseRowRunning, + PipelineID: "rkey1", Workflow: "build", State: leaseRowRunning, }); err != nil { t.Fatalf("SaveMillLease: %v", err) } @@ -67,7 +67,7 @@ func TestRestoreStateRebuildsLeasesAndCursors(t *testing.T) { if lease.getState() != leaseRunning { t.Fatalf("restored lease state = %v, want leaseRunning", lease.getState()) } - wantWid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "rkey1"}, Name: "build"} + wantWid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} if lease.wid != wantWid { t.Fatalf("restored lease wid = %+v, want %+v", lease.wid, wantWid) } @@ -80,7 +80,7 @@ func TestOrphanTerminalAuthorsStatusRow(t *testing.T) { _, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) if err := bdb.SaveMillLease(db.MillLease{ LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", - Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: leaseRowRunning, + PipelineID: "rkey1", Workflow: "build", State: leaseRowRunning, }); err != nil { t.Fatalf("SaveMillLease: %v", err) } @@ -114,7 +114,7 @@ func TestOrphanTerminalAuthorsStatusRow(t *testing.T) { }, }) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example", Rkey: "rkey1"}, Name: "build"} + wid := models.WorkflowId{PipelineId: models.PipelineId("rkey1"), Name: "build"} st, err := bdb.GetStatus(wid) if err != nil { t.Fatalf("GetStatus after orphan terminal: %v", err) @@ -137,8 +137,8 @@ func TestOrphanTerminalAuthorsStatusRow(t *testing.T) { func TestSnapshotReconciliationFailsDroppedOrphans(t *testing.T) { _, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) for _, l := range []db.MillLease{ - {LeaseID: "lease-kept", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", Knot: "k", Rkey: "r1", Workflow: "w", State: leaseRowRunning}, - {LeaseID: "lease-gone", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", Knot: "k", Rkey: "r2", Workflow: "w", State: leaseRowRunning}, + {LeaseID: "lease-kept", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", PipelineID: "r1", Workflow: "w", State: leaseRowRunning}, + {LeaseID: "lease-gone", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", PipelineID: "r2", Workflow: "w", State: leaseRowRunning}, } { if err := bdb.SaveMillLease(l); err != nil { t.Fatalf("SaveMillLease(%s): %v", l.LeaseID, err) @@ -166,7 +166,7 @@ func TestSnapshotReconciliationFailsDroppedOrphans(t *testing.T) { t.Fatal("reconciliation kept a lease the executor no longer holds") } - st, err := bdb.GetStatus(models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r2"}, Name: "w"}) + st, err := bdb.GetStatus(models.WorkflowId{PipelineId: models.PipelineId("r2"), Name: "w"}) if err != nil { t.Fatalf("GetStatus for dropped orphan: %v", err) } @@ -178,7 +178,7 @@ func TestSnapshotReconciliationPreservesRequestedCancellation(t *testing.T) { m, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) lease := newLease("lease-1", "node-1", "inc-old", "dummy") lease.wid = models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "k", Rkey: "r1"}, + PipelineId: models.PipelineId("r1"), Name: "w", } lease.setState(leaseRunning) @@ -253,7 +253,7 @@ func TestSweepFailsOrphansOfAbsentExecutors(t *testing.T) { _, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) if err := bdb.SaveMillLease(db.MillLease{ LeaseID: "lease-1", NodeID: "node-absent", Epoch: "inc-absent", Engine: "dummy", - Knot: "k", Rkey: "r1", Workflow: "w", State: leaseRowReserved, + PipelineID: "r1", Workflow: "w", State: leaseRowReserved, }); err != nil { t.Fatalf("SaveMillLease: %v", err) } @@ -267,7 +267,7 @@ func TestSweepFailsOrphansOfAbsentExecutors(t *testing.T) { if still { t.Fatal("sweep kept an orphan whose executor never reconnected") } - st, err := bdb.GetStatus(models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r1"}, Name: "w"}) + st, err := bdb.GetStatus(models.WorkflowId{PipelineId: models.PipelineId("r1"), Name: "w"}) if err != nil { t.Fatalf("GetStatus after sweep: %v", err) } @@ -285,7 +285,7 @@ func TestAckSeqnoPersistsCursor(t *testing.T) { m.attachSession(sess) owned := newLease("lease-1", "node-1", "inc-1", "dummy") - owned.wid = models.WorkflowId{PipelineId: models.PipelineId{Knot: "k", Rkey: "r"}, Name: "build"} + owned.wid = models.WorkflowId{PipelineId: models.PipelineId("r"), Name: "build"} m.mu.Lock() m.leases[owned.id] = owned m.mu.Unlock() @@ -321,7 +321,7 @@ func TestOrphanTerminalFailureKeepsLeaseAndSeqnoRetryable(t *testing.T) { _, bdb := restoreTestMill(t, Config{ReconnectGrace: time.Minute}) if err := bdb.SaveMillLease(db.MillLease{ LeaseID: "lease-1", NodeID: "node-1", Epoch: "inc-1", Engine: "dummy", - Knot: "knot.example", Rkey: "rkey1", Workflow: "build", State: leaseRowRunning, + PipelineID: "rkey1", Workflow: "build", State: leaseRowRunning, }); err != nil { t.Fatalf("SaveMillLease: %v", err) } @@ -410,7 +410,7 @@ func TestSyntheticTerminalRespectsPersistedMetricAuthority(t *testing.T) { lease := newLease("lease-local-metrics", "node-old", "epoch-old", "dummy") lease.wid = models.WorkflowId{ - PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "pipeline"}, + PipelineId: models.PipelineId("pipeline"), Name: "build", } lease.millRecordsTerminalMetrics = false diff --git a/spindle/models/logger.go b/spindle/models/logger.go index 5406c2387..51f6ce727 100644 --- a/spindle/models/logger.go +++ b/spindle/models/logger.go @@ -49,6 +49,10 @@ func LogFilePath(baseDir string, workflowID WorkflowId) string { return logFilePath } +func LegacyLogFilePath(baseDir, knot string, pipelineID PipelineId, workflow string) string { + return filepath.Join(baseDir, fmt.Sprintf("%s-%s-%s.log", normalize(knot), pipelineID, normalize(workflow))) +} + func (l *FileWorkflowLogger) Close() error { for _, w := range l.dataWriters { if err := w.flush(); err != nil { diff --git a/spindle/models/logger_test.go b/spindle/models/logger_test.go index 79cec1ef6..933320183 100644 --- a/spindle/models/logger_test.go +++ b/spindle/models/logger_test.go @@ -9,7 +9,7 @@ import ( ) func testWorkflowId(name string) WorkflowId { - return WorkflowId{PipelineId: PipelineId{Knot: "knot1", Rkey: "rkey1"}, Name: name} + return WorkflowId{PipelineId: PipelineId("rkey1"), Name: name} } func readDataContents(t *testing.T, path string) []string { diff --git a/spindle/models/models.go b/spindle/models/models.go index 77bbbb025..d5b27925f 100644 --- a/spindle/models/models.go +++ b/spindle/models/models.go @@ -6,8 +6,6 @@ import ( "slices" "time" - "tangled.org/core/api/tangled" - "github.com/bluesky-social/indigo/atproto/syntax" ) @@ -15,14 +13,9 @@ var ( re = regexp.MustCompile(`[^a-zA-Z0-9_.-]`) ) -type PipelineId struct { - Knot string - Rkey string -} +type PipelineId syntax.RecordKey -func (p *PipelineId) AtUri() syntax.ATURI { - return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey)) -} +func (p PipelineId) String() string { return string(p) } type WorkflowId struct { PipelineId @@ -30,7 +23,7 @@ type WorkflowId struct { } func (wid WorkflowId) String() string { - return fmt.Sprintf("%s-%s-%s", normalize(wid.PipelineId.Knot), wid.PipelineId.Rkey, normalize(wid.Name)) + return fmt.Sprintf("%s-%s", normalize(string(wid.PipelineId)), normalize(wid.Name)) } func normalize(name string) string { diff --git a/spindle/models/pipeline_env.go b/spindle/models/pipeline_env.go index 235343959..9935f54d2 100644 --- a/spindle/models/pipeline_env.go +++ b/spindle/models/pipeline_env.go @@ -23,7 +23,7 @@ func PipelineEnvVarsForSource(tr *tangled.Pipeline_TriggerMetadata, pipelineId P // standard CI env vars env["CI"] = "true" - env["TANGLED_PIPELINE_ID"] = pipelineId.AtUri().String() + env["TANGLED_PIPELINE_ID"] = pipelineId.String() env["TANGLED_PIPELINE_KIND"] = tr.Kind if tr.SourceRepo != nil && *tr.SourceRepo != "" { diff --git a/spindle/models/pipeline_env_test.go b/spindle/models/pipeline_env_test.go index 54fc5f5ea..fd57b69df 100644 --- a/spindle/models/pipeline_env_test.go +++ b/spindle/models/pipeline_env_test.go @@ -23,10 +23,7 @@ func TestPipelineEnvVars_PushBranch(t *testing.T) { DefaultBranch: "main", }, } - id := PipelineId{ - Knot: "example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVars(tr, id) // Check standard CI variable @@ -86,10 +83,7 @@ func TestPipelineEnvVars_PushTag(t *testing.T) { RepoDid: sp("did:plc:boltless"), }, } - id := PipelineId{ - Knot: "example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVars(tr, id) if env["TANGLED_REF"] != "refs/tags/v1.2.3" { @@ -118,10 +112,7 @@ func TestPipelineEnvVars_PullRequest(t *testing.T) { RepoDid: sp("did:plc:boltless"), }, } - id := PipelineId{ - Knot: "example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVars(tr, id) // Check ref variables for PR @@ -185,10 +176,7 @@ func TestPipelineEnvVars_SourceRepo(t *testing.T) { RepoDid: &sourceRepoDid, DefaultBranch: "feature-branch", } - id := PipelineId{ - Knot: "target.example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVarsForSource(tr, id, sourceRepo) @@ -220,10 +208,7 @@ func TestPipelineEnvVars_ManualWithInputs(t *testing.T) { RepoDid: sp("did:plc:boltless"), }, } - id := PipelineId{ - Knot: "example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVars(tr, id) // Check manual input variables @@ -261,10 +246,7 @@ func TestPipelineEnvVars_DevMode(t *testing.T) { RepoDid: sp("did:plc:boltless"), }, } - id := PipelineId{ - Knot: "example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVars(tr, id) expectedURL := "http://localhost:3000/did:plc:boltless" @@ -274,10 +256,7 @@ func TestPipelineEnvVars_DevMode(t *testing.T) { } func TestPipelineEnvVars_NilTrigger(t *testing.T) { - id := PipelineId{ - Knot: "example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVars(nil, id) if env != nil { @@ -296,10 +275,7 @@ func TestPipelineEnvVars_NilPushData(t *testing.T) { RepoDid: sp("did:plc:boltless"), }, } - id := PipelineId{ - Knot: "example.com", - Rkey: "123123", - } + id := PipelineId("123123") env := PipelineEnvVars(tr, id) // Should still have repo variables diff --git a/spindle/observability/metrics.go b/spindle/observability/metrics.go index 9bb2d8e1a..5dddbf44c 100644 --- a/spindle/observability/metrics.go +++ b/spindle/observability/metrics.go @@ -1062,7 +1062,7 @@ func (c *dbCollector) refresh(ctx context.Context) { SELECT status, row_number() OVER ( - PARTITION BY rkey, workflow + PARTITION BY pipeline_id, workflow ORDER BY id DESC ) AS rank FROM workflow_statuses diff --git a/spindle/observability/metrics_test.go b/spindle/observability/metrics_test.go index c230a2a26..06ae550a3 100644 --- a/spindle/observability/metrics_test.go +++ b/spindle/observability/metrics_test.go @@ -925,7 +925,7 @@ func TestDBCollectorUsesLatestNormalizedWorkflowStatus(t *testing.T) { {"p2", "test", "failed"}, {"p1", "build", "success"}, } { - if _, err := database.Exec(`insert into workflow_statuses (rkey, workflow, status, created_at) values (?, ?, ?, ?)`, row.pipeline, row.workflow, row.status, time.Now().Format(time.RFC3339)); err != nil { + if _, err := database.Exec(`insert into workflow_statuses (pipeline_id, workflow, status, created_at) values (?, ?, ?, ?)`, row.pipeline, row.workflow, row.status, time.Now().Format(time.RFC3339)); err != nil { t.Fatal(err) } } diff --git a/spindle/quota/manager_test.go b/spindle/quota/manager_test.go index 61714c9ac..1fb01591b 100644 --- a/spindle/quota/manager_test.go +++ b/spindle/quota/manager_test.go @@ -687,8 +687,8 @@ func TestExternalLimitWake(t *testing.T) { } func TestWorkflowReservationID(t *testing.T) { - id1 := quota.WorkflowReservationID("run-1", "owner", "repo", "knot", "rkey", "name") - id2 := quota.WorkflowReservationID("run-1", "owner", "repo", "knot", "rkey", "name") + id1 := quota.WorkflowReservationID("run-1", "owner", "repo", "rkey", "name") + id2 := quota.WorkflowReservationID("run-1", "owner", "repo", "rkey", "name") if id1 != id2 { t.Errorf("expected deterministic IDs, got %q and %q", id1, id2) } @@ -698,18 +698,18 @@ func TestWorkflowReservationID(t *testing.T) { } // length-prefixing keeps shifted field boundaries distinct - idShift1 := quota.WorkflowReservationID("run-1", "ab", "c", "knot", "rkey", "name") - idShift2 := quota.WorkflowReservationID("run-1", "a", "bc", "knot", "rkey", "name") + idShift1 := quota.WorkflowReservationID("run-1", "ab", "c", "rkey", "name") + idShift2 := quota.WorkflowReservationID("run-1", "a", "bc", "rkey", "name") if idShift1 == idShift2 { t.Errorf("expected different IDs for shifted boundaries, both got %q", idShift1) } - idDiff := quota.WorkflowReservationID("run-1", "owner", "repo", "knot", "rkey", "name-changed") + idDiff := quota.WorkflowReservationID("run-1", "owner", "repo", "rkey", "name-changed") if id1 == idDiff { t.Errorf("expected different IDs on changed argument, both got %q", id1) } - idOtherRun := quota.WorkflowReservationID("run-2", "owner", "repo", "knot", "rkey", "name") + idOtherRun := quota.WorkflowReservationID("run-2", "owner", "repo", "rkey", "name") if id1 == idOtherRun { t.Errorf("expected different IDs for distinct runs, both got %q", id1) } diff --git a/spindle/quota/quota.go b/spindle/quota/quota.go index c76ca7025..1f29ca476 100644 --- a/spindle/quota/quota.go +++ b/spindle/quota/quota.go @@ -189,12 +189,11 @@ func validateResourceName(resource string) error { return nil } -func WorkflowReservationID(runID, owner, repoDid, knot, rkey, name string) string { - raw := fmt.Sprintf("%d:%s:%d:%s:%d:%s:%d:%s:%d:%s:%d:%s", +func WorkflowReservationID(runID, owner, repoDid, rkey, name string) string { + raw := fmt.Sprintf("%d:%s:%d:%s:%d:%s:%d:%s:%d:%s", len(runID), runID, len(owner), owner, len(repoDid), repoDid, - len(knot), knot, len(rkey), rkey, len(name), name, ) diff --git a/spindle/server.go b/spindle/server.go index 4cc5f65e5..9ef5f961f 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -568,6 +568,9 @@ func Run(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to setup db: %w", err) } + if err := d.MigratePipelineLogFiles(cfg.Server.LogDir); err != nil { + return fmt.Errorf("failed to migrate pipeline log files: %w", err) + } var qs *db.QuotaStore var qm *quota.Manager @@ -835,10 +838,10 @@ func (s *Spindle) runPipeline(ctx context.Context, repoDid syntax.DID, trigger t rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, rev) if err != nil { - return models.PipelineId{}, fmt.Errorf("loading pipeline: %w", err) + return "", fmt.Errorf("loading pipeline: %w", err) } if len(rawPipeline) == 0 { - return models.PipelineId{}, nil + return "", nil } tpl := compiler.Compile(compiler.Parse(rawPipeline)) @@ -854,15 +857,12 @@ func (s *Spindle) runPipeline(ctx context.Context, repoDid syntax.DID, trigger t tpl.Workflows = filterWorkflows(tpl.Workflows, only) } if len(tpl.Workflows) == 0 { - return models.PipelineId{}, nil + return "", nil } - pipelineId := models.PipelineId{ - Knot: trigger.Repo.Knot, - Rkey: tid.TID(), - } + pipelineId := models.PipelineId(tid.TID()) if err := s.db.CreatePipeline(pipelineId, tpl); err != nil { - return models.PipelineId{}, fmt.Errorf("creating pipeline: %w", err) + return "", fmt.Errorf("creating pipeline: %w", err) } err = s.processPipeline(ctx, repoDid, tpl, pipelineId, sourceRepo) return pipelineId, err @@ -887,7 +887,7 @@ func filterWorkflows(workflows []*tangled.Pipeline_Workflow, only []string) []*t // TriggerManual dispatches a pipeline at sha, authorized against and recorded // under repoDid. sourceRepo, pull, and inputs are optional trigger payload. -func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull xrpc.PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) { +func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull xrpc.PullContext, inputs []*tangled.Pipeline_Pair) (models.PipelineId, error) { repo, err := s.db.GetRepoByDid(repoDid) if err != nil { return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err) @@ -953,10 +953,10 @@ func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, re if err != nil { return "", err } - if pipelineId.Rkey == "" { + if pipelineId == "" { return "", xrpc.ErrNoMatchingWorkflows } - return pipelineId.AtUri(), nil + return pipelineId, nil } // sourceInfo is nil when the checkout comes from the target repo. @@ -1145,10 +1145,7 @@ func (s *Spindle) StartJobWorkers(ctx context.Context) { } func (s *Spindle) runJob(ctx context.Context, job *db.JobRow) { - pipelineId := models.PipelineId{ - Knot: job.PipelineIdKnot, - Rkey: job.PipelineIdRkey, - } + pipelineId := job.PipelineId repoDID := job.RepoDid if job.SourceRepo != nil && job.SourceRepo.RepoDid != nil && *job.SourceRepo.RepoDid != "" { repoDID = *job.SourceRepo.RepoDid @@ -1167,7 +1164,7 @@ func (s *Spindle) runJob(ctx context.Context, job *db.JobRow) { if span.IsRecording() { attrs := []attribute.KeyValue{ attribute.Int64(observability.JobIDKey, job.Id), - attribute.String(observability.PipelineIDKey, pipelineId.AtUri().String()), + attribute.String(observability.PipelineIDKey, pipelineId.String()), } if repoDID != "" { attrs = append(attrs, attribute.String(observability.RepoDIDKey, repoDID)) diff --git a/spindle/tapclient.go b/spindle/tapclient.go index c356961ac..9166b9ce2 100644 --- a/spindle/tapclient.go +++ b/spindle/tapclient.go @@ -624,10 +624,7 @@ func (s *Spindle) triggerPullRequestPipeline(ctx context.Context, l *slog.Logger return nil } - pipelineId := models.PipelineId{ - Knot: tpl.TriggerMetadata.Repo.Knot, - Rkey: tid.TID(), - } + pipelineId := models.PipelineId(tid.TID()) if err := s.db.CreatePipeline(pipelineId, tpl); err != nil { l.Error("failed to create pipeline event", "err", err) return nil diff --git a/spindle/tapclient_test.go b/spindle/tapclient_test.go index 63dab97a6..88a223ea7 100644 --- a/spindle/tapclient_test.go +++ b/spindle/tapclient_test.go @@ -545,7 +545,7 @@ func TestTeardownRepo_RBAC(t *testing.T) { if err := vault.AddSecret(context.Background(), secrets.UnlockedSecret{Key: "api_key", Value: "v", Repo: secrets.RepoIdentifier(repoDid.String()), CreatedBy: ownerDid}); err != nil { t.Fatalf("AddSecret: %v", err) } - if err := d.EnqueueJob(context.Background(), repoDid.String(), models.PipelineId{Knot: "knot.test", Rkey: "p1"}, nil, tangled.Pipeline{}, "", ""); err != nil { + if err := d.EnqueueJob(context.Background(), repoDid.String(), models.PipelineId("p1"), nil, tangled.Pipeline{}, "", ""); err != nil { t.Fatalf("EnqueueJob: %v", err) } diff --git a/spindle/wipe.go b/spindle/wipe.go index b8168032e..ef43a20e4 100644 --- a/spindle/wipe.go +++ b/spindle/wipe.go @@ -34,7 +34,7 @@ func (s *Spindle) WipeRepo(ctx context.Context, repoDid syntax.DID, reason strin } // cancel work before deleting the rows it references - wids, err := s.cancelRepoWorkflows(ctx, repoDid, repos, reason) + wids, err := s.cancelRepoWorkflows(ctx, repoDid, reason) if err != nil { return fmt.Errorf("cancel workflows: %w", err) } @@ -77,17 +77,11 @@ func (s *Spindle) WipeRepo(ctx context.Context, repoDid syntax.DID, reason strin fail("remove repo acl", s.e.RemoveRepo(r.Owner.String(), rbac.ThisServer, repoDid.String())) } - // use the recorded knot and rkey for status events - seen := map[db.PipelineKey]bool{} - var keys []db.PipelineKey + pipelineIDs := make([]models.PipelineId, 0, len(wids)) for _, wid := range wids { - k := db.PipelineKey{Knot: wid.PipelineId.Knot, Rkey: wid.PipelineId.Rkey} - if !seen[k] { - seen[k] = true - keys = append(keys, k) - } + pipelineIDs = append(pipelineIDs, wid.PipelineId) } - fail("delete events", s.db.DeleteEventsByRepo(repoDid.String(), keys)) + fail("delete pipelines", s.db.DeletePipelinesByRepo(repoDid.String(), pipelineIDs)) // keep the repo row when cleanup needs a retry if len(errs) > 0 { @@ -136,11 +130,11 @@ func (s *Spindle) WipeOwner(ctx context.Context, ownerDid syntax.DID, reason str return errors.Join(errs...) } -func (s *Spindle) cancelRepoWorkflows(ctx context.Context, repoDid syntax.DID, repos []db.Repo, reason string) ([]models.WorkflowId, error) { +func (s *Spindle) cancelRepoWorkflows(ctx context.Context, repoDid syntax.DID, reason string) ([]models.WorkflowId, error) { var wids []models.WorkflowId seen := map[models.WorkflowId]bool{} - add := func(knot, rkey, name string) { - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: knot, Rkey: rkey}, Name: name} + add := func(rkey, name string) { + wid := models.WorkflowId{PipelineId: models.PipelineId(rkey), Name: name} if !seen[wid] { seen[wid] = true wids = append(wids, wid) @@ -152,13 +146,7 @@ func (s *Spindle) cancelRepoWorkflows(ctx context.Context, repoDid syntax.DID, r return nil, fmt.Errorf("list pipeline workflows: %w", err) } for _, p := range pipes { - if p.Knot != "" { - add(p.Knot, p.Rkey, p.Name) - continue - } - for _, r := range repos { - add(r.Knot, p.Rkey, p.Name) - } + add(string(p.PipelineID), p.Name) } leases, err := s.db.ListMillLeases() @@ -167,27 +155,26 @@ func (s *Spindle) cancelRepoWorkflows(ctx context.Context, repoDid syntax.DID, r } for _, lease := range leases { if lease.RepoDID == repoDid.String() { - add(lease.Knot, lease.Rkey, lease.Workflow) + add(lease.PipelineID, lease.Workflow) } } var errs []error for _, wid := range wids { st, err := s.db.GetStatus(wid) - if err == nil && st.IsFinish() { - continue - } - if err := s.db.StatusCancelled(wid, reason, -1, s.n); err != nil { - errs = append(errs, err) - } - engine.CancelWorkflow(wid) - for _, eng := range s.engs { - if err := eng.DestroyWorkflow(ctx, wid); err != nil { + if err != nil || !st.IsFinish() { + if err := s.db.StatusCancelled(wid, reason, -1, s.n); err != nil { errs = append(errs, err) } + engine.CancelWorkflow(wid) + for _, eng := range s.engs { + if err := eng.DestroyWorkflow(ctx, wid); err != nil { + errs = append(errs, err) + } + } } if err := os.Remove(models.LogFilePath(s.cfg.Server.LogDir, wid)); err != nil && !os.IsNotExist(err) { - errs = append(errs, fmt.Errorf("remove live log for %s: %w", wid, err)) + errs = append(errs, fmt.Errorf("remove log for %s: %w", wid, err)) } } return wids, errors.Join(errs...) diff --git a/spindle/wipe_test.go b/spindle/wipe_test.go index 289bcf8d3..1074a6ec2 100644 --- a/spindle/wipe_test.go +++ b/spindle/wipe_test.go @@ -89,13 +89,13 @@ func seedWipeRepo(t *testing.T, s *Spindle, repoDid, owner syntax.DID) { if err := s.db.AddRepoCollaborator(db.RepoCollaborator{OwnerDid: owner, Rkey: syntax.RecordKey("c" + sfx), Subject: "did:plc:collab", RepoDid: repoDid}); err != nil { t.Fatal(err) } - if err := s.db.EnqueueJob(ctx, repoDid.String(), models.PipelineId{Knot: "knot.example.com", Rkey: "p" + sfx}, nil, tangled.Pipeline{}, "", ""); err != nil { + if err := s.db.EnqueueJob(ctx, repoDid.String(), models.PipelineId("p"+sfx), nil, tangled.Pipeline{}, "", ""); err != nil { t.Fatal(err) } - if err := s.db.SaveMillLease(db.MillLease{LeaseID: "l" + sfx, NodeID: "n1", Epoch: "e1", Engine: "microvm", Knot: "knot.example.com", Rkey: "p" + sfx, Workflow: "w" + sfx, State: "active", RepoDID: repoDid.String()}); err != nil { + if err := s.db.SaveMillLease(db.MillLease{LeaseID: "l" + sfx, NodeID: "n1", Epoch: "e1", Engine: "microvm", PipelineID: "p" + sfx, Workflow: "w" + sfx, State: "active", RepoDID: repoDid.String()}); err != nil { t.Fatal(err) } - if _, err := s.db.Exec(`insert into mill_artifacts (lease_id, repo_did, knot, rkey, workflow, ref, hash) values (?, ?, 'knot.example.com', ?, ?, ?, 'h')`, "l"+sfx, repoDid.String(), "p"+sfx, "w"+sfx, "out/l"+sfx+".bin"); err != nil { + if _, err := s.db.Exec(`insert into mill_artifacts (lease_id, repo_did, pipeline_id, workflow, ref, hash) values (?, ?, ?, ?, ?, 'h')`, "l"+sfx, repoDid.String(), "p"+sfx, "w"+sfx, "out/l"+sfx+".bin"); err != nil { t.Fatal(err) } if _, err := s.db.Exec(`insert into quota_allocations (repo_did, resource, kind, key, amount) values (?, 'compute', 'generic', 'k', 1)`, repoDid.String()); err != nil { @@ -113,10 +113,10 @@ func seedWipeRepo(t *testing.T, s *Spindle, repoDid, owner syntax.DID) { if err != nil { t.Fatal(err) } - if _, err := s.db.Exec(`insert into pipelines (rkey, knot, repo_did, commit_sha, kind, payload) values (?, 'knot.example.com', ?, '', '', ?)`, pipeline.Id, repoDid.String(), string(payload)); err != nil { + if _, err := s.db.Exec(`insert into pipelines (pipeline_id, repo_did, commit_sha, kind, payload) values (?, ?, '', '', ?)`, pipeline.Id, repoDid.String(), string(payload)); err != nil { t.Fatal(err) } - if _, err := s.db.Exec(`insert into workflow_statuses (rkey, workflow, status, created_at) values (?, ?, 'pending', 'now')`, pipeline.Id, "w"+sfx); err != nil { + if _, err := s.db.Exec(`insert into workflow_statuses (pipeline_id, workflow, status, created_at) values (?, ?, 'pending', 'now')`, pipeline.Id, "w"+sfx); err != nil { t.Fatal(err) } @@ -253,7 +253,7 @@ func TestWipeRepoRemovesLocalEngineArtifacts(t *testing.T) { seedWipeRepo(t, s, repoDid, owner) - wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.example.com", Rkey: "p1"}, Name: "w1"} + wid := models.WorkflowId{PipelineId: models.PipelineId("p1"), Name: "w1"} localRef := "logs/" + wid.String() + ".log" if err := s.db.SaveArtifactRef(wid.String(), repoDid.String(), wid, localRef, "h"); err != nil { t.Fatal(err) diff --git a/spindle/xrpc/ci_pipeline_subscribe_logs.go b/spindle/xrpc/ci_pipeline_subscribe_logs.go index b7c7b3126..23cb60ad8 100644 --- a/spindle/xrpc/ci_pipeline_subscribe_logs.go +++ b/spindle/xrpc/ci_pipeline_subscribe_logs.go @@ -11,7 +11,6 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/atclient" - "github.com/bluesky-social/indigo/atproto/syntax" "github.com/gorilla/websocket" "tangled.org/core/api/tangled" "tangled.org/core/spindle/logview" @@ -24,13 +23,14 @@ func (x *Xrpc) HandleCiSubscribePipelineLogs(w http.ResponseWriter, r *http.Requ workflows = r.URL.Query()["workflows"] ) - pipeline, err := syntax.ParseTID(pipelineQuery) - if err != nil { - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("pipeline parameter invalid: %s", pipelineQuery)}) + // pipeline ids are opaque, any format. WorkflowId.String() normalizes before + // it reaches a filesystem path, so only emptiness is rejected here. + if pipelineQuery == "" { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "pipeline parameter is required"}) return } - x.handleSubscribeLogs(w, r, pipeline, workflows) + x.handleSubscribeLogs(w, r, models.PipelineId(pipelineQuery), workflows) } var wsUpgrader = websocket.Upgrader{ @@ -38,11 +38,11 @@ var wsUpgrader = websocket.Upgrader{ WriteBufferSize: 10_000, } -func (x *Xrpc) handleSubscribeLogs(w http.ResponseWriter, r *http.Request, pipeline syntax.TID, workflows []string) { +func (x *Xrpc) handleSubscribeLogs(w http.ResponseWriter, r *http.Request, pipeline models.PipelineId, workflows []string) { l := x.Logger.With("pipeline", pipeline, "workflows", workflows) - // 1. query the pipeline from database to get the knot. knot is used to locate the workflow log files. - tpl, knot, err := x.Db.GetPipelineWithKnot(r.Context(), pipeline.String()) + // 1. the pipeline supplies the workflow names to stream + tpl, err := x.Db.GetPipeline(r.Context(), pipeline) if err != nil { l.ErrorContext(r.Context(), "failed to find pipeline event", "err", err) writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "NotFound", Message: fmt.Sprintf("pipeline not found: %s", pipeline.String())}) @@ -134,10 +134,7 @@ func (x *Xrpc) handleSubscribeLogs(w http.ResponseWriter, r *http.Request, pipel defer wg.Done() wid := models.WorkflowId{ - PipelineId: models.PipelineId{ - Knot: knot, - Rkey: pipeline.String(), - }, + PipelineId: pipeline, Name: wfName, } diff --git a/spindle/xrpc/ci_pipeline_trigger_pipeline.go b/spindle/xrpc/ci_pipeline_trigger_pipeline.go index d2a8fc389..207875c7a 100644 --- a/spindle/xrpc/ci_pipeline_trigger_pipeline.go +++ b/spindle/xrpc/ci_pipeline_trigger_pipeline.go @@ -110,7 +110,7 @@ func (x *Xrpc) TriggerPipeline(w http.ResponseWriter, r *http.Request) { return } - pipelineAt, err := x.Trigger.TriggerManual(r.Context(), repoDid, sha, ref, input.Workflows, sourceRepo, pull, inputs) + pipelineId, err := x.Trigger.TriggerManual(r.Context(), repoDid, sha, ref, input.Workflows, sourceRepo, pull, inputs) if errors.Is(err, ErrNoMatchingWorkflows) { fail(xrpcerr.GenericError(err)) return @@ -121,7 +121,7 @@ func (x *Xrpc) TriggerPipeline(w http.ResponseWriter, r *http.Request) { } if err := writeJson(w, http.StatusOK, tangled.CiTriggerPipeline_Output{ - Pipeline: pipelineAt.String(), + Pipeline: pipelineId.String(), }); err != nil { l.ErrorContext(r.Context(), "failed to write response", "err", err) } diff --git a/spindle/xrpc/ci_query_pipelines.go b/spindle/xrpc/ci_query_pipelines.go index a7a7a8010..c738a5bc4 100644 --- a/spindle/xrpc/ci_query_pipelines.go +++ b/spindle/xrpc/ci_query_pipelines.go @@ -6,6 +6,7 @@ import ( "strconv" "tangled.org/core/api/tangled" + "tangled.org/core/spindle/models" xrpcerr "tangled.org/core/xrpc/errors" ) @@ -66,7 +67,7 @@ func (x *Xrpc) HandleCiGetPipeline(w http.ResponseWriter, r *http.Request) { return } - p, err := x.Db.GetPipeline(r.Context(), pipeline) + p, err := x.Db.GetPipeline(r.Context(), models.PipelineId(pipeline)) if err != nil { fail(xrpcerr.GenericError(err), http.StatusInternalServerError) return diff --git a/spindle/xrpc/pipeline_cancel_pipeline.go b/spindle/xrpc/pipeline_cancel_pipeline.go index 2a8f6844f..83b662f51 100644 --- a/spindle/xrpc/pipeline_cancel_pipeline.go +++ b/spindle/xrpc/pipeline_cancel_pipeline.go @@ -32,9 +32,10 @@ func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { return } - pipelineTid, err := syntax.ParseTID(input.Pipeline) - if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("invalid pipeline TID %q: %w", input.Pipeline, err))) + // pipeline ids are opaque, any format. the GetPipeline + repo-ownership check + // below is the real gate; it also covers existence. + if input.Pipeline == "" { + fail(xrpcerr.GenericError(fmt.Errorf("pipeline is required"))) return } @@ -43,15 +44,9 @@ func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { fail(xerr) return } - repo, err := x.Db.GetRepoByDid(repoDid) - if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("failed to get repo: %w", err))) - return - } - // the actor is only authorized against input.Repo, so make sure the // pipeline actually belongs to it before cancelling anything - p, err := x.Db.GetPipeline(r.Context(), pipelineTid.String()) + p, err := x.Db.GetPipeline(r.Context(), models.PipelineId(input.Pipeline)) if err != nil { fail(xrpcerr.GenericError(fmt.Errorf("failed to get pipeline: %w", err))) return @@ -61,11 +56,8 @@ func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { return } - pipelineId := models.PipelineId{ - Knot: repo.Knot, - Rkey: pipelineTid.String(), - } - l = l.With("input.pipeline", pipelineTid, "input.workflows", input.Workflows) + pipelineId := models.PipelineId(input.Pipeline) + l = l.With("input.pipeline", input.Pipeline, "input.workflows", input.Workflows) workflows := input.Workflows if len(workflows) == 0 { @@ -83,7 +75,7 @@ func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { for _, wName := range workflows { wid := models.WorkflowId{ PipelineId: pipelineId, - Name: wName, + Name: wName, } l.DebugContext(r.Context(), "cancel pipeline", "wid", wid) diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index b39de81c8..a24aafa20 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -45,7 +45,7 @@ func requireSha(sha string) error { // this is to break an import cycle. spindle imports this package for Xrpc, // so this package can't import *spindle.Spindle back. type PipelineTrigger interface { - TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) + TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (models.PipelineId, error) DescribeWorkflowDefinition(ctx context.Context, repoDid syntax.DID, sha string, sourceRepo syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) } diff --git a/spindle/xrpc/xrpc_test.go b/spindle/xrpc/xrpc_test.go index 8255ff66a..7778b9464 100644 --- a/spindle/xrpc/xrpc_test.go +++ b/spindle/xrpc/xrpc_test.go @@ -27,9 +27,9 @@ type mockTrigger struct { triggered bool } -func (m *mockTrigger) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) { +func (m *mockTrigger) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (models.PipelineId, error) { m.triggered = true - return syntax.ParseATURI("at://did:plc:repoowner/sh.tangled.ci.pipeline/testrkey") + return models.PipelineId("testrkey"), nil } func (m *mockTrigger) DescribeWorkflowDefinition(context.Context, syntax.DID, string, syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) { @@ -191,7 +191,7 @@ func TestCancelPipeline_RBAC(t *testing.T) { {Name: "test-workflow"}, }, } - err = d.CreatePipeline(models.PipelineId{Knot: "knot.test", Rkey: pipelineTid}, tpl) + err = d.CreatePipeline(models.PipelineId(pipelineTid), tpl) if err != nil { t.Fatalf("CreatePipeline: %v", err) }