From 1d3d94cdde7731ecd633026bb78775591e0cf320 Mon Sep 17 00:00:00 2001 From: dawn Date: Sat, 18 Jul 2026 11:39:50 +0300 Subject: [PATCH] spindle,lexicons: add sh.tangled.ci.describeWorkflowDefinition query Signed-off-by: dawn --- api/tangled/cidescribeWorkflowDefinition.go | 46 ++++++++++ lexicons/ci/describeWorkflowDefinition.json | 62 ++++++++++++++ spindle/fingerprint_test.go | 34 ++++++++ spindle/server.go | 84 ++++++++++++++++--- ...i_pipeline_describe_workflow_definition.go | 45 ++++++++++ spindle/xrpc/ci_pipeline_trigger_pipeline.go | 4 +- spindle/xrpc/xrpc.go | 10 +++ spindle/xrpc/xrpc_test.go | 4 + 8 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 api/tangled/cidescribeWorkflowDefinition.go create mode 100644 lexicons/ci/describeWorkflowDefinition.json create mode 100644 spindle/fingerprint_test.go create mode 100644 spindle/xrpc/ci_pipeline_describe_workflow_definition.go diff --git a/api/tangled/cidescribeWorkflowDefinition.go b/api/tangled/cidescribeWorkflowDefinition.go new file mode 100644 index 00000000..6a79446d --- /dev/null +++ b/api/tangled/cidescribeWorkflowDefinition.go @@ -0,0 +1,46 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.ci.describeWorkflowDefinition + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + CiDescribeWorkflowDefinitionNSID = "sh.tangled.ci.describeWorkflowDefinition" +) + +// CiDescribeWorkflowDefinition_Output is the output of a sh.tangled.ci.describeWorkflowDefinition call. +type CiDescribeWorkflowDefinition_Output struct { + // derived: Whether the workflow definition is derived from this repository at all. When false, no commit-to-commit comparison is meaningful (e.g. definitions managed externally), and callers should not surface change warnings. + Derived bool `json:"derived" cborgen:"derived"` + // hash: Opaque fingerprint of the workflow definition as resolved by the spindle; for git-derived spindles this covers the workflow files at the commit, not their post-compilation expansion. Only equality is defined: identical resolved definitions MUST produce identical hashes for a given spindle deployment, and differing definitions SHOULD produce differing hashes. Absent when derived is false. + Hash *string `json:"hash,omitempty" cborgen:"hash,omitempty"` + // workflows: Names or paths of the effective workflow files that produced the hash, for display purposes. + Workflows []string `json:"workflows,omitempty" cborgen:"workflows,omitempty"` +} + +// CiDescribeWorkflowDefinition calls the XRPC method "sh.tangled.ci.describeWorkflowDefinition". +// +// repo: Target repository DID the workflow definition belongs to. +// sha: Commit SHA to resolve the workflow definition at +// sourceRepo: Repository DID to resolve workflow definitions from, if different from the target repo (e.g. a fork for a fork-based pull request). +func CiDescribeWorkflowDefinition(ctx context.Context, c util.LexClient, repo string, sha string, sourceRepo string) (*CiDescribeWorkflowDefinition_Output, error) { + var out CiDescribeWorkflowDefinition_Output + + params := map[string]interface{}{} + params["repo"] = repo + params["sha"] = sha + if sourceRepo != "" { + params["sourceRepo"] = sourceRepo + } + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.ci.describeWorkflowDefinition", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/lexicons/ci/describeWorkflowDefinition.json b/lexicons/ci/describeWorkflowDefinition.json new file mode 100644 index 00000000..8f18bf25 --- /dev/null +++ b/lexicons/ci/describeWorkflowDefinition.json @@ -0,0 +1,62 @@ +{ + "lexicon": 1, + "id": "sh.tangled.ci.describeWorkflowDefinition", + "defs": { + "main": { + "type": "query", + "description": "Resolve the workflow definition a pipeline would use at a given commit and return a fingerprint of it.", + "parameters": { + "type": "params", + "required": ["repo", "sha"], + "properties": { + "repo": { + "type": "string", + "format": "did", + "description": "Target repository DID the workflow definition belongs to." + }, + "sha": { + "type": "string", + "minLength": 40, + "maxLength": 40, + "description": "Commit SHA to resolve the workflow definition at" + }, + "sourceRepo": { + "type": "string", + "format": "did", + "description": "Repository DID to resolve workflow definitions from, if different from the target repo" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["derived"], + "properties": { + "derived": { + "type": "boolean", + "description": "Whether the workflow definition is derived from this repository at all. When false, no commit-to-commit comparison is meaningful (e.g. definitions managed externally), and callers should not surface change warnings." + }, + "hash": { + "type": "string", + "description": "Fingerprint of the workflow definition as resolved by the spindle. Absent when derived is false." + }, + "workflows": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names or paths of the effective workflow files that produced the hash." + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "Invalid request parameters" + } + ] + } + } +} diff --git a/spindle/fingerprint_test.go b/spindle/fingerprint_test.go new file mode 100644 index 00000000..efa393c3 --- /dev/null +++ b/spindle/fingerprint_test.go @@ -0,0 +1,34 @@ +package spindle + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "tangled.org/core/workflow" +) + +func TestFingerprintWorkflowDefinition(t *testing.T) { + a := workflow.RawWorkflow{Name: "a.yml", Contents: []byte("engine: dummy\n")} + b := workflow.RawWorkflow{Name: "b.yml", Contents: []byte("engine: nixery\n")} + + // iteration order must not affect the fingerprint + assert.Equal(t, + fingerprintWorkflowDefinition(workflow.RawPipeline{a, b}), + fingerprintWorkflowDefinition(workflow.RawPipeline{b, a}), + ) + + // content and name changes must both change the fingerprint + base := fingerprintWorkflowDefinition(workflow.RawPipeline{a, b}) + assert.NotEqual(t, base, fingerprintWorkflowDefinition(workflow.RawPipeline{ + {Name: "a.yml", Contents: []byte("engine: nixery\n")}, b, + })) + assert.NotEqual(t, base, fingerprintWorkflowDefinition(workflow.RawPipeline{ + {Name: "c.yml", Contents: a.Contents}, b, + })) + + assert.NotEqual(t, + fingerprintWorkflowDefinition(nil), + fingerprintWorkflowDefinition(workflow.RawPipeline{a}), + ) +} diff --git a/spindle/server.go b/spindle/server.go index f3136d87..cecd2abd 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -2,8 +2,10 @@ package spindle import ( "context" + "crypto/sha256" "database/sql" _ "embed" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -11,6 +13,7 @@ import ( "maps" "net/http" "path/filepath" + "sort" "sync" "time" @@ -692,18 +695,15 @@ func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, re } } - repoCloneUri := s.newRepoCloneUrl(repo.Knot, repoDid) - repoPath := s.newRepoPath(repoDid) - sourceInfo := triggerRepo // default: code comes from the repo itself - if sourceRepo != "" && sourceRepo != repoDid { - sourceInfo, err = s.resolveSourceRepoInfo(ctx, sourceRepo) - if err != nil { - return "", err - } + repoCloneUri, repoPath, sourceInfo, err := s.resolveCheckout(ctx, repoDid, sourceRepo) + if err != nil { + return "", err + } + if sourceInfo == nil { + sourceInfo = triggerRepo + } else { sourceRepoStr := sourceRepo.String() trigger.SourceRepo = &sourceRepoStr - repoCloneUri = models.BuildRepoURL(sourceInfo) - repoPath = s.newRepoPath(sourceRepo) } pipelineId, err := s.runPipeline(ctx, repoDid, trigger, nil, repoCloneUri, repoPath, sha, workflows, sourceInfo) @@ -716,6 +716,70 @@ func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, re return pipelineId.AtUri(), nil } +// sourceInfo is nil when the checkout comes from the target repo. +func (s *Spindle) resolveCheckout(ctx context.Context, repoDid syntax.DID, sourceRepo syntax.DID) (cloneUri, repoPath string, sourceInfo *tangled.Pipeline_TriggerRepo, err error) { + repo, err := s.db.GetRepoByDid(repoDid) + if err != nil { + return "", "", nil, fmt.Errorf("unknown repoDid %s: %w", repoDid, err) + } + + cloneUri = s.newRepoCloneUrl(repo.Knot, repoDid) + repoPath = s.newRepoPath(repoDid) + if sourceRepo != "" && sourceRepo != repoDid { + sourceInfo, err = s.resolveSourceRepoInfo(ctx, sourceRepo) + if err != nil { + return "", "", nil, err + } + cloneUri = models.BuildRepoURL(sourceInfo) + repoPath = s.newRepoPath(sourceRepo) + } + return cloneUri, repoPath, sourceInfo, nil +} + +// resolves the workflow definition at sha without executing it +// returns a deterministic fingerprint over the resolved files. +func (s *Spindle) DescribeWorkflowDefinition(ctx context.Context, repoDid syntax.DID, sha string, sourceRepo syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) { + repoCloneUri, repoPath, _, err := s.resolveCheckout(ctx, repoDid, sourceRepo) + if err != nil { + return nil, err + } + + rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, sha) + if err != nil { + return nil, fmt.Errorf("loading pipeline: %w", err) + } + + hash := fingerprintWorkflowDefinition(rawPipeline) + workflows := make([]string, 0, len(rawPipeline)) + for _, w := range rawPipeline { + workflows = append(workflows, w.Name) + } + + return &tangled.CiDescribeWorkflowDefinition_Output{ + Derived: true, + Hash: &hash, + Workflows: workflows, + }, nil +} + +func fingerprintWorkflowDefinition(rawPipeline workflow.RawPipeline) string { + sorted := make([]workflow.RawWorkflow, len(rawPipeline)) + copy(sorted, rawPipeline) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + + h := sha256.New() + var lenBuf [8]byte + for _, w := range sorted { + binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(w.Contents))) + h.Write([]byte(w.Name)) + // terminate name to avoid ["fo", "o"] == ["f", "oo"] + h.Write([]byte{0}) + h.Write(lenBuf[:]) + h.Write(w.Contents) + } + return fmt.Sprintf("sha256:%x", h.Sum(nil)) +} + func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { return nil, fmt.Errorf("syncing git repo: %w", err) diff --git a/spindle/xrpc/ci_pipeline_describe_workflow_definition.go b/spindle/xrpc/ci_pipeline_describe_workflow_definition.go new file mode 100644 index 00000000..8b7450ad --- /dev/null +++ b/spindle/xrpc/ci_pipeline_describe_workflow_definition.go @@ -0,0 +1,45 @@ +package xrpc + +import ( + "net/http" + + xrpcerr "tangled.org/core/xrpc/errors" +) + +func (x *Xrpc) DescribeWorkflowDefinition(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + q := r.URL.Query() + repoDid, xerr, ok := x.resolveKnownRepoDid(q.Get("repo")) + if !ok { + fail(xerr) + return + } + + sha := q.Get("sha") + if err := requireSha(sha); err != nil { + fail(xrpcerr.NewXrpcError(xrpcerr.WithTag("InvalidRequest"), xrpcerr.WithError(err))) + return + } + + sourceRepoParam := q.Get("sourceRepo") + sourceRepo, err := parseOptionalDID("sourceRepo", &sourceRepoParam) + if err != nil { + fail(xrpcerr.NewXrpcError(xrpcerr.WithTag("InvalidRequest"), xrpcerr.WithError(err))) + return + } + + out, err := x.Trigger.DescribeWorkflowDefinition(r.Context(), repoDid, sha, sourceRepo) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + if err := writeJson(w, http.StatusOK, out); err != nil { + l.Error("failed to write response", "err", err) + } +} diff --git a/spindle/xrpc/ci_pipeline_trigger_pipeline.go b/spindle/xrpc/ci_pipeline_trigger_pipeline.go index c31f4d59..7cf1a74e 100644 --- a/spindle/xrpc/ci_pipeline_trigger_pipeline.go +++ b/spindle/xrpc/ci_pipeline_trigger_pipeline.go @@ -105,8 +105,8 @@ func (x *Xrpc) TriggerPipeline(w http.ResponseWriter, r *http.Request) { return } - if len(sha) != 40 { - fail(xrpcerr.GenericError(fmt.Errorf("sha must be a 40-character commit hash"))) + if err := requireSha(sha); err != nil { + fail(xrpcerr.GenericError(err)) return } diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index d8232be4..a59c98cc 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -5,6 +5,7 @@ import ( _ "embed" "encoding/json" "errors" + "fmt" "log/slog" "net/http" @@ -27,10 +28,18 @@ const ActorDid = serviceauth.ActorDid var ErrNoMatchingWorkflows = errors.New("no workflows to run") +func requireSha(sha string) error { + if len(sha) != 40 { + return fmt.Errorf("sha must be a 40-character commit hash") + } + return nil +} + // 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) + DescribeWorkflowDefinition(ctx context.Context, repoDid syntax.DID, sha string, sourceRepo syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) } type PullContext struct { @@ -68,6 +77,7 @@ func (x *Xrpc) Router() http.Handler { // service query endpoints (no auth required) r.Get("/"+tangled.OwnerNSID, x.Owner) + r.Get("/"+tangled.CiDescribeWorkflowDefinitionNSID, x.DescribeWorkflowDefinition) r.Get("/"+tangled.CiSubscribePipelineLogsNSID, x.HandleCiSubscribePipelineLogs) r.Get("/"+tangled.CiQueryPipelinesNSID, x.HandleCiQueryPipelines) r.Get("/"+tangled.CiGetPipelineNSID, x.HandleCiGetPipeline) diff --git a/spindle/xrpc/xrpc_test.go b/spindle/xrpc/xrpc_test.go index fc679c40..fa2004e7 100644 --- a/spindle/xrpc/xrpc_test.go +++ b/spindle/xrpc/xrpc_test.go @@ -32,6 +32,10 @@ func (m *mockTrigger) TriggerManual(ctx context.Context, repoDid syntax.DID, sha return syntax.ParseATURI("at://did:plc:repoowner/sh.tangled.ci.pipeline/testrkey") } +func (m *mockTrigger) DescribeWorkflowDefinition(context.Context, syntax.DID, string, syntax.DID) (*tangled.CiDescribeWorkflowDefinition_Output, error) { + return &tangled.CiDescribeWorkflowDefinition_Output{}, nil +} + func newTestXrpcDB(t *testing.T) (*db.DB, *rbac.Enforcer) { t.Helper() p := filepath.Join(t.TempDir(), "spindle_xrpc.db") -- 2.51.2