···8080| `TACK_JETSTREAM_URL` | Tangled Jetstream WebSocket URL |
8181| `TACK_DEV` | Use `ws://` for knot event-streams (any non-empty value) |
82828383-When no provider is configured, tack runs an in-process fake provider
8484-that's useful for exercising the jetstream → knot → `/events` flow
8585-locally without a real CI account.
8383+All configured providers are active simultaneously. Each workflow
8484+chooses its provider via the first key under its top-level `tack:`
8585+block. e.g. `tack: { buildkite: { ... } }` runs on Buildkite,
8686+`tack: { fake: {} }` runs on the in-process fake provider.
86878788## Providers
8889
+14-13
main.go
···159159 // them to publish synthetic status events at startup.
160160 br := newBroker(st)
161161162162- // Provider that turns Tangled pipeline triggers into
163163- // pipeline.status events. The Buildkite provider is the real
164164- // integration; the fake one stands in when no Buildkite token is
165165- // configured so the full jetstream → knot → /events flow is
166166- // still exercisable locally without a Buildkite account.
162162+ // Providers turn Tangled pipeline triggers into pipeline.status
163163+ // events. We always wire the fake provider in so workflows can
164164+ // opt into it (via `tack: { fake: ... }`) for end-to-end testing
165165+ // even when Buildkite credentials are present; the Buildkite
166166+ // provider is added on top when configured. Routing is per-
167167+ // workflow and driven by the workflow YAML — see providerRouter.
167168 //
168169 // bkProvider is kept as a typed pointer separately because the
169170 // /webhooks/buildkite handler needs the concrete *buildkiteProvider
170171 // (for HandleWebhook + signature verification), not the abstract
171172 // Provider surface.
172172- var (
173173- provider Provider
174174- bkProvider *buildkiteProvider
175175- )
173173+ providers := map[string]Provider{
174174+ "fake": newFakeProvider(br, logger),
175175+ }
176176+ logger.Info("fake provider enabled (workflow opt-in via `tack.fake:`)")
177177+178178+ var bkProvider *buildkiteProvider
176179 if cfg.BuildkiteToken != "" {
177180 bkProvider = newBuildkiteProvider(
178181 br, st,
···182185 cfg.BuildkiteWebhookMode,
183186 logger,
184187 )
185185- provider = bkProvider
188188+ providers["buildkite"] = bkProvider
186189 logger.Info("buildkite provider enabled",
187190 "default_org", cfg.BuildkiteOrg,
188191 "webhook_mode", cfg.BuildkiteWebhookMode,
189192 )
190190- } else {
191191- provider = newFakeProvider(br, logger)
192192- logger.Info("fake provider enabled (set TACK_BUILDKITE_TOKEN to use buildkite)")
193193 }
194194+ provider := newProviderRouter(logger, providers)
194195195196 // Start the knot event-stream consumer first so the jetstream
196197 // loop has somewhere to register newly-observed knots into. It
+149
provider_router.go
···11+package main
22+33+// providerRouter dispatches each incoming workflow to whichever
44+// configured Provider matches the workflow's YAML body. Selection
55+// happens per-workflow: we decode the top-level `tack:` map and pick
66+// the first child key that names one of the registered providers.
77+// This keeps the trigger plumbing oblivious to which backend any
88+// given workflow will run on, and lets a single tack instance host
99+// multiple providers concurrently — the workflow YAML is the source
1010+// of truth for routing, not the operator's env.
1111+//
1212+// Providers are registered as a (key → Provider) map. When a
1313+// workflow names more than one provider key under `tack:` (a config
1414+// mistake in practice) the router walks the YAML's child keys in
1515+// document order and picks the first one that has a registered
1616+// provider — Go's map iteration randomness never enters into it,
1717+// because the YAML's MapSlice preserves order.
1818+1919+import (
2020+ "context"
2121+ "errors"
2222+ "fmt"
2323+ "log/slog"
2424+ "strings"
2525+2626+ "go.yaml.in/yaml/v2"
2727+ "tangled.org/core/api/tangled"
2828+)
2929+3030+// providerRouter is itself a Provider so the rest of tack (knot
3131+// consumer, HTTP handlers) keeps talking to a single Provider value
3232+// regardless of how many real backends are wired in.
3333+type providerRouter struct {
3434+ log *slog.Logger
3535+ providers map[string]Provider
3636+}
3737+3838+// Compile-time interface conformance check.
3939+var _ Provider = (*providerRouter)(nil)
4040+4141+// newProviderRouter wires a router from a (key → Provider) map.
4242+func newProviderRouter(
4343+ log *slog.Logger,
4444+ providers map[string]Provider,
4545+) *providerRouter {
4646+ return &providerRouter{
4747+ log: log.With("component", "provider", "kind", "router"),
4848+ providers: providers,
4949+ }
5050+}
5151+5252+// Spawn satisfies Provider. Each workflow is routed independently —
5353+// a single pipeline can mix workflows that target different
5454+// providers. Workflows whose YAML doesn't name a known provider are
5555+// logged loudly and skipped, since silently dropping them would
5656+// hide a config error from the operator.
5757+func (r *providerRouter) Spawn(
5858+ ctx context.Context,
5959+ knot string,
6060+ pipelineRkey string,
6161+ trigger *tangled.Pipeline_TriggerMetadata,
6262+ workflows []*tangled.Pipeline_Workflow,
6363+) {
6464+ for _, wf := range workflows {
6565+ // Defensive: the lexicon allows nil entries and doesn't
6666+ // require a name. We can't route a workflow that has no
6767+ // body to inspect.
6868+ if wf == nil || wf.Name == "" {
6969+ continue
7070+ }
7171+ p, err := r.pick(wf.Raw)
7272+ if err != nil {
7373+ r.log.Error("route workflow",
7474+ "err", err,
7575+ "knot", knot,
7676+ "pipeline_rkey", pipelineRkey,
7777+ "workflow", wf.Name,
7878+ )
7979+ continue
8080+ }
8181+ // Hand the workflow off as a single-element slice so the
8282+ // downstream provider's existing Spawn loop runs unchanged.
8383+ p.Spawn(ctx, knot, pipelineRkey, trigger,
8484+ []*tangled.Pipeline_Workflow{wf},
8585+ )
8686+ }
8787+}
8888+8989+// Logs satisfies Provider. The (knot, rkey, workflow) tuple alone
9090+// doesn't tell us which backend ran the workflow — the YAML body
9191+// isn't carried on the request — so we ask each provider and
9292+// surface the first one that has a stream. ErrLogsNotFound from a
9393+// provider just means "not mine"; we keep walking. Any other error
9494+// is the answer (a real backend failure should surface to the HTTP
9595+// caller, not be masked by the next provider).
9696+//
9797+// Map iteration order is undefined, but in practice exactly one
9898+// provider should know about any given (knot, rkey, workflow) so the
9999+// order is moot.
100100+func (r *providerRouter) Logs(
101101+ ctx context.Context,
102102+ knot string,
103103+ pipelineRkey string,
104104+ workflow string,
105105+) (<-chan LogLine, error) {
106106+ for _, p := range r.providers {
107107+ ch, err := p.Logs(ctx, knot, pipelineRkey, workflow)
108108+ if errors.Is(err, ErrLogsNotFound) {
109109+ continue
110110+ }
111111+ return ch, err
112112+ }
113113+ return nil, ErrLogsNotFound
114114+}
115115+116116+// pick decodes raw and returns the first registered provider whose
117117+// key appears as a child of the top-level `tack:` map, walking the
118118+// YAML's children in document order. An empty body or YAML with no
119119+// `tack:` block — or one whose children name no registered provider
120120+// — is a structural error; no routing decision is possible.
121121+func (r *providerRouter) pick(raw string) (Provider, error) {
122122+ if strings.TrimSpace(raw) == "" {
123123+ return nil, errors.New("workflow body is empty")
124124+ }
125125+ // MapSlice preserves the on-the-wire ordering of the children
126126+ // of `tack:` so "first match" is deterministic w.r.t. the YAML
127127+ // document, not Go's randomised map iteration.
128128+ var doc struct {
129129+ Tack yaml.MapSlice `yaml:"tack"`
130130+ }
131131+ if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
132132+ return nil, fmt.Errorf("parse workflow yaml: %w", err)
133133+ }
134134+ for _, item := range doc.Tack {
135135+ // YAML map keys are usually strings, but the lexicon
136136+ // doesn't enforce that — guard so a stray int/bool key
137137+ // doesn't panic the type assertion.
138138+ key, ok := item.Key.(string)
139139+ if !ok {
140140+ continue
141141+ }
142142+ if p, ok := r.providers[key]; ok {
143143+ return p, nil
144144+ }
145145+ }
146146+ return nil, fmt.Errorf(
147147+ "workflow yaml has no `tack:` key matching a registered provider",
148148+ )
149149+}
+229
provider_router_test.go
···11+package main
22+33+// Tests for providerRouter. We use a tiny in-test stub Provider —
44+// stubProvider — that records every Spawn call and serves canned
55+// Logs responses, so the tests stay focused on routing behaviour
66+// (which provider got called for which workflow YAML, and how
77+// ErrLogsNotFound is fanned out) without dragging in either the
88+// fake or Buildkite providers' end-to-end machinery.
99+1010+import (
1111+ "context"
1212+ "errors"
1313+ "log/slog"
1414+ "sync"
1515+ "testing"
1616+1717+ "tangled.org/core/api/tangled"
1818+)
1919+2020+// stubProvider is a minimal Provider for routing tests. spawnCalls
2121+// captures the workflow names handed to Spawn (single-element slices
2222+// per the router's contract) so a test can assert which provider got
2323+// which workflow. logsErr / logsCh govern what Logs returns; the
2424+// default (zero value) is ErrLogsNotFound + nil channel, which makes
2525+// fan-out tests easy to express by overriding only the provider that
2626+// should "claim" the request.
2727+type stubProvider struct {
2828+ mu sync.Mutex
2929+ spawnCalls []string
3030+3131+ logsErr error
3232+ logsCh chan LogLine
3333+}
3434+3535+var _ Provider = (*stubProvider)(nil)
3636+3737+func (s *stubProvider) Spawn(
3838+ _ context.Context,
3939+ _ string,
4040+ _ string,
4141+ _ *tangled.Pipeline_TriggerMetadata,
4242+ workflows []*tangled.Pipeline_Workflow,
4343+) {
4444+ s.mu.Lock()
4545+ defer s.mu.Unlock()
4646+ for _, wf := range workflows {
4747+ if wf == nil {
4848+ continue
4949+ }
5050+ s.spawnCalls = append(s.spawnCalls, wf.Name)
5151+ }
5252+}
5353+5454+func (s *stubProvider) Logs(
5555+ _ context.Context,
5656+ _ string,
5757+ _ string,
5858+ _ string,
5959+) (<-chan LogLine, error) {
6060+ if s.logsErr != nil {
6161+ return nil, s.logsErr
6262+ }
6363+ if s.logsCh != nil {
6464+ return s.logsCh, nil
6565+ }
6666+ return nil, ErrLogsNotFound
6767+}
6868+6969+// names returns a defensive copy of spawnCalls so the test can read
7070+// it without racing the router's per-workflow loop. The router calls
7171+// Spawn synchronously in the test process, but a copy is the safer
7272+// pattern if that ever changes.
7373+func (s *stubProvider) names() []string {
7474+ s.mu.Lock()
7575+ defer s.mu.Unlock()
7676+ out := make([]string, len(s.spawnCalls))
7777+ copy(out, s.spawnCalls)
7878+ return out
7979+}
8080+8181+// newRouterTest wires a router with a fixed pair of stubs ("a", "b")
8282+// so tests can focus on the YAML → provider mapping. The stubs are
8383+// returned alongside so each case can inspect what it received.
8484+func newRouterTest() (*providerRouter, *stubProvider, *stubProvider) {
8585+ a := &stubProvider{}
8686+ b := &stubProvider{}
8787+ r := newProviderRouter(slog.Default(), map[string]Provider{
8888+ "a": a,
8989+ "b": b,
9090+ })
9191+ return r, a, b
9292+}
9393+9494+// TestProviderRouterSpawnRoutesByYAMLKey exercises the basic happy
9595+// path: each workflow's `tack:` block names exactly one provider key,
9696+// and the router hands that workflow to the matching provider only.
9797+func TestProviderRouterSpawnRoutesByYAMLKey(t *testing.T) {
9898+ r, a, b := newRouterTest()
9999+100100+ r.Spawn(context.Background(), "knot", "rkey", nil,
101101+ []*tangled.Pipeline_Workflow{
102102+ {Name: "wf-a.yml", Raw: "tack:\n a: {}\n"},
103103+ {Name: "wf-b.yml", Raw: "tack:\n b: {}\n"},
104104+ },
105105+ )
106106+107107+ if got, want := a.names(), []string{"wf-a.yml"}; !equalStrings(got, want) {
108108+ t.Fatalf("provider a got %v; want %v", got, want)
109109+ }
110110+ if got, want := b.names(), []string{"wf-b.yml"}; !equalStrings(got, want) {
111111+ t.Fatalf("provider b got %v; want %v", got, want)
112112+ }
113113+}
114114+115115+// TestProviderRouterSpawnFirstYAMLKeyWins pins tie-breaking when a
116116+// workflow lists multiple provider keys: the YAML's document-order
117117+// child of `tack:` wins, regardless of the Go map's iteration order.
118118+func TestProviderRouterSpawnFirstYAMLKeyWins(t *testing.T) {
119119+ r, a, b := newRouterTest()
120120+121121+ // `b` is listed first under `tack:` so it should claim the
122122+ // workflow even though both keys are registered.
123123+ r.Spawn(context.Background(), "knot", "rkey", nil,
124124+ []*tangled.Pipeline_Workflow{
125125+ {Name: "both.yml", Raw: "tack:\n b: {}\n a: {}\n"},
126126+ },
127127+ )
128128+129129+ if got := a.names(); len(got) != 0 {
130130+ t.Fatalf("provider a should not have been called; got %v", got)
131131+ }
132132+ if got, want := b.names(), []string{"both.yml"}; !equalStrings(got, want) {
133133+ t.Fatalf("provider b got %v; want %v", got, want)
134134+ }
135135+}
136136+137137+// TestProviderRouterSpawnSkipsUnroutable confirms that workflows
138138+// whose YAML has no matching provider key are skipped (logged but
139139+// not dispatched) and don't poison the rest of the batch.
140140+func TestProviderRouterSpawnSkipsUnroutable(t *testing.T) {
141141+ r, a, b := newRouterTest()
142142+143143+ r.Spawn(context.Background(), "knot", "rkey", nil,
144144+ []*tangled.Pipeline_Workflow{
145145+ // No `tack:` key at all.
146146+ {Name: "bare.yml", Raw: "steps: []\n"},
147147+ // `tack:` present but with an unknown sub-key.
148148+ {Name: "unknown.yml", Raw: "tack:\n nope: {}\n"},
149149+ // Empty body — also unroutable.
150150+ {Name: "empty.yml", Raw: ""},
151151+ // And one good one to prove the loop kept going.
152152+ {Name: "good.yml", Raw: "tack:\n a: {}\n"},
153153+ },
154154+ )
155155+156156+ if got, want := a.names(), []string{"good.yml"}; !equalStrings(got, want) {
157157+ t.Fatalf("provider a got %v; want %v", got, want)
158158+ }
159159+ if got := b.names(); len(got) != 0 {
160160+ t.Fatalf("provider b should not have been called; got %v", got)
161161+ }
162162+}
163163+164164+// TestProviderRouterLogsFanOut verifies that Logs walks the
165165+// providers and returns the channel from the first one that doesn't
166166+// say ErrLogsNotFound. We seed exactly one provider with a real
167167+// channel; map iteration order is unspecified but only one provider
168168+// can possibly answer, so the test is deterministic.
169169+func TestProviderRouterLogsFanOut(t *testing.T) {
170170+ r, _, b := newRouterTest()
171171+172172+ want := make(chan LogLine)
173173+ b.logsCh = want
174174+175175+ got, err := r.Logs(context.Background(), "k", "p", "w")
176176+ if err != nil {
177177+ t.Fatalf("Logs: %v", err)
178178+ }
179179+ if got != (<-chan LogLine)(want) {
180180+ t.Fatalf("got channel %v; want %v", got, want)
181181+ }
182182+}
183183+184184+// TestProviderRouterLogsAllNotFound makes sure that when no provider
185185+// claims the tuple, the router surfaces ErrLogsNotFound itself —
186186+// this is what the HTTP handler maps to a 404.
187187+func TestProviderRouterLogsAllNotFound(t *testing.T) {
188188+ r, _, _ := newRouterTest()
189189+190190+ ch, err := r.Logs(context.Background(), "k", "p", "w")
191191+ if !errors.Is(err, ErrLogsNotFound) {
192192+ t.Fatalf("err = %v; want ErrLogsNotFound", err)
193193+ }
194194+ if ch != nil {
195195+ t.Fatalf("channel should be nil on not-found")
196196+ }
197197+}
198198+199199+// TestProviderRouterLogsBackendError confirms that a non-NotFound
200200+// error from a provider is returned verbatim instead of being
201201+// masked by the fan-out — backend failures must reach the HTTP
202202+// caller as 5xx, not be silently retried elsewhere.
203203+func TestProviderRouterLogsBackendError(t *testing.T) {
204204+ a := &stubProvider{logsErr: errors.New("boom")}
205205+ r := newProviderRouter(slog.Default(), map[string]Provider{"a": a})
206206+207207+ ch, err := r.Logs(context.Background(), "k", "p", "w")
208208+ if err == nil || err.Error() != "boom" {
209209+ t.Fatalf("err = %v; want boom", err)
210210+ }
211211+ if ch != nil {
212212+ t.Fatalf("channel should be nil on backend error")
213213+ }
214214+}
215215+216216+// equalStrings is a small helper to compare ordered string slices —
217217+// the router preserves workflow order within Spawn, so the tests
218218+// assert against ordered slices rather than sets.
219219+func equalStrings(a, b []string) bool {
220220+ if len(a) != len(b) {
221221+ return false
222222+ }
223223+ for i := range a {
224224+ if a[i] != b[i] {
225225+ return false
226226+ }
227227+ }
228228+ return true
229229+}