Stitch any CI into Tangled
1package main
2
3// Provider is the abstraction over "the thing that turns a Tangled
4// pipeline trigger into pipeline.status events". It exists so the rest
5// of tack can stay agnostic to whether a given trigger is dispatched to
6// Buildkite, run by a stub for testing, or anything else we plug in later.
7
8import (
9 "context"
10 "errors"
11 "time"
12
13 "tangled.org/core/api/tangled"
14)
15
16// LogLine is the on-the-wire shape of a single log frame emitted by a
17// Provider. It mirrors tangled.org/core/spindle/models.LogLine on the
18// JSON level without importing the upstream package — that package
19// transitively pulls in git, vault, redis and a few hundred other
20// modules just to expose a handful of types we don't otherwise need.
21//
22// The JSON tags here MUST stay byte-compatible with the upstream
23// struct: the appview's log proxy decodes against the upstream type,
24// so any tag drift breaks the appview's renderer.
25type LogLine struct {
26 Kind string `json:"kind"`
27 Content string `json:"content"`
28 Time time.Time `json:"time"`
29 StepId int `json:"step_id"`
30 Stream string `json:"stream,omitempty"`
31 StepStatus string `json:"step_status,omitempty"`
32 StepKind string `json:"step_kind,omitempty"`
33 StepCommand string `json:"step_command,omitempty"`
34}
35
36// LogKind / StepStatus enum values, matching the upstream constants
37// (LogKindData, LogKindControl, StepStatusStart, StepStatusEnd) on
38// the wire. Use these instead of bare strings so we don't drift.
39const (
40 LogKindData = "data"
41 LogKindControl = "control"
42 StepStatusStart = "start"
43 StepStatusEnd = "end"
44)
45
46// ErrLogsNotFound is returned by Provider.Logs when the requested
47// (knot, pipelineRkey, workflow) tuple has no recorded logs — either
48// because that workflow never ran on this spindle, or because the
49// provider has since dropped it. The HTTP /logs handler translates
50// this into a 404 *before* upgrading to WebSocket so the appview's
51// dialer sees a real HTTP error rather than an immediate close.
52var ErrLogsNotFound = errors.New("logs not found")
53
54// Provider dispatches a Tangled pipeline trigger to whatever backend
55// actually runs the workflows, and exposes per-workflow log retrieval.
56//
57// Implementations are responsible for publishing
58// sh.tangled.pipeline.status records back through whatever channel
59// they were constructed with.
60type Provider interface {
61 // Spawn kicks off a pipeline run for every workflow in workflows.
62 //
63 // It MUST be non-blocking: the caller is the eventconsumer worker
64 // that's shared across all knot subscriptions, so per-pipeline
65 // work has to live on its own goroutine. A typical implementation
66 // fans out into a goroutine per workflow and returns immediately.
67 //
68 // ctx is the consumer's app-scoped context (lives until shutdown,
69 // not just for the duration of one event). Implementations are
70 // expected to honour cancellation: in-flight runs should wind
71 // down without issuing further publishes once ctx is done.
72 //
73 // knot is the knot hostname the trigger arrived on; it's the
74 // authority half of the pipeline ATURI that pipeline.status
75 // records reference. pipelineRkey is the trigger record's rkey
76 // on that knot. actor is the DID that the knot consumer has
77 // already authorized to spawn this work; concretely, the
78 // publisher of the originating sh.tangled.repo record (i.e.
79 // the repo owner). It is guaranteed non-empty: the consumer
80 // never invokes Spawn for an unauthorized trigger, so providers
81 // can treat actor as "the identity to attribute this run to"
82 // without re-checking authorization. trigger is the decoded
83 // record's TriggerMetadata (may be nil; the lexicon doesn't
84 // enforce its presence) and carries the commit/branch/PR data
85 // a real CI provider needs to kick off a build. workflows is
86 // the unmodified slice from the decoded sh.tangled.pipeline
87 // record; implementations should tolerate nil entries and
88 // zero-length names defensively, since the lexicon doesn't
89 // enforce either.
90 Spawn(
91 ctx context.Context,
92 knot string,
93 pipelineRkey string,
94 actor string,
95 trigger *tangled.Pipeline_TriggerMetadata,
96 workflows []*tangled.Pipeline_Workflow,
97 )
98
99 // Logs returns a channel of log frames for a single workflow run,
100 // identified by the same (knot, pipelineRkey, workflow) tuple
101 // Spawn was invoked with.
102 //
103 // Each LogLine corresponds 1:1 to one frame written by the HTTP
104 // /logs WebSocket handler — the handler marshals the LogLine and
105 // emits it as a single TextMessage so appview clients see the
106 // exact same wire shape they would from the stock Tangled spindle
107 // (whose on-disk log file holds the same records, one per line).
108 //
109 // The returned channel is closed by the provider when the stream
110 // is complete; a closed channel is the only "end of stream"
111 // signal — there is no separate done channel. Implementations
112 // MUST also stop sending and close the channel when ctx is
113 // cancelled, so a disconnecting client doesn't leak a producer
114 // goroutine.
115 //
116 // Backend errors that occur *after* a successful return (e.g.
117 // the upstream Buildkite log socket dying mid-stream) are logged
118 // internally and surface to the consumer as an early channel
119 // close. The same observable behaviour the appview already
120 // handles for a websocket that closes mid-stream.
121 //
122 // Returns ErrLogsNotFound if no logs exist for the requested
123 // tuple. Any other returned error indicates a backend failure
124 // and should surface as a 5xx to the HTTP caller. On a non-nil
125 // error, the channel is nil and there is nothing to drain.
126 Logs(
127 ctx context.Context,
128 knot string,
129 pipelineRkey string,
130 workflow string,
131 ) (<-chan LogLine, error)
132}