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. trigger is the decoded record's TriggerMetadata
77 // (may be nil — the lexicon doesn't enforce its presence) and
78 // carries the commit/branch/PR data a real CI provider needs to
79 // kick off a build. workflows is the unmodified slice from the
80 // decoded sh.tangled.pipeline record; implementations should
81 // tolerate nil entries and zero-length names defensively, since
82 // the lexicon doesn't enforce either.
83 Spawn(
84 ctx context.Context,
85 knot string,
86 pipelineRkey string,
87 trigger *tangled.Pipeline_TriggerMetadata,
88 workflows []*tangled.Pipeline_Workflow,
89 )
90
91 // Logs returns a channel of log frames for a single workflow run,
92 // identified by the same (knot, pipelineRkey, workflow) tuple
93 // Spawn was invoked with.
94 //
95 // Each LogLine corresponds 1:1 to one frame written by the HTTP
96 // /logs WebSocket handler — the handler marshals the LogLine and
97 // emits it as a single TextMessage so appview clients see the
98 // exact same wire shape they would from the stock Tangled spindle
99 // (whose on-disk log file holds the same records, one per line).
100 //
101 // The returned channel is closed by the provider when the stream
102 // is complete; a closed channel is the only "end of stream"
103 // signal — there is no separate done channel. Implementations
104 // MUST also stop sending and close the channel when ctx is
105 // cancelled, so a disconnecting client doesn't leak a producer
106 // goroutine.
107 //
108 // Backend errors that occur *after* a successful return (e.g.
109 // the upstream Buildkite log socket dying mid-stream) are logged
110 // internally and surface to the consumer as an early channel
111 // close. The same observable behaviour the appview already
112 // handles for a websocket that closes mid-stream.
113 //
114 // Returns ErrLogsNotFound if no logs exist for the requested
115 // tuple. Any other returned error indicates a backend failure
116 // and should surface as a 5xx to the HTTP caller. On a non-nil
117 // error, the channel is nil and there is nothing to drain.
118 Logs(
119 ctx context.Context,
120 knot string,
121 pipelineRkey string,
122 workflow string,
123 ) (<-chan LogLine, error)
124}