Stitch any CI into Tangled
1package main
2
3// providerRouter dispatches each incoming workflow to whichever
4// configured Provider matches the workflow's YAML body. Selection
5// happens per-workflow: we decode the top-level `tack:` map and pick
6// the first child key that names one of the registered providers.
7// This keeps the trigger plumbing oblivious to which backend any
8// given workflow will run on, and lets a single tack instance host
9// multiple providers concurrently — the workflow YAML is the source
10// of truth for routing, not the operator's env.
11//
12// Providers are registered as a (key → Provider) map. When a
13// workflow names more than one provider key under `tack:` (a config
14// mistake in practice) the router walks the YAML's child keys in
15// document order and picks the first one that has a registered
16// provider — Go's map iteration randomness never enters into it,
17// because the YAML's MapSlice preserves order.
18
19import (
20 "context"
21 "errors"
22 "fmt"
23 "log/slog"
24 "strings"
25
26 "go.yaml.in/yaml/v2"
27 "tangled.org/core/api/tangled"
28)
29
30// providerRouter is itself a Provider so the rest of tack (knot
31// consumer, HTTP handlers) keeps talking to a single Provider value
32// regardless of how many real backends are wired in.
33type providerRouter struct {
34 log *slog.Logger
35 providers map[string]Provider
36}
37
38// Compile-time interface conformance check.
39var _ Provider = (*providerRouter)(nil)
40
41// newProviderRouter wires a router from a (key → Provider) map.
42func newProviderRouter(
43 log *slog.Logger,
44 providers map[string]Provider,
45) *providerRouter {
46 return &providerRouter{
47 log: log.With("component", "provider", "kind", "router"),
48 providers: providers,
49 }
50}
51
52// Spawn satisfies Provider. Each workflow is routed independently —
53// a single pipeline can mix workflows that target different
54// providers. Workflows whose YAML doesn't name a known provider are
55// logged loudly and skipped, since silently dropping them would
56// hide a config error from the operator.
57func (r *providerRouter) Spawn(
58 ctx context.Context,
59 knot string,
60 pipelineRkey string,
61 trigger *tangled.Pipeline_TriggerMetadata,
62 workflows []*tangled.Pipeline_Workflow,
63) {
64 for _, wf := range workflows {
65 // Defensive: the lexicon allows nil entries and doesn't
66 // require a name. We can't route a workflow that has no
67 // body to inspect.
68 if wf == nil || wf.Name == "" {
69 continue
70 }
71 p, err := r.pick(wf.Raw)
72 if err != nil {
73 r.log.Error("route workflow",
74 "err", err,
75 "knot", knot,
76 "pipeline_rkey", pipelineRkey,
77 "workflow", wf.Name,
78 )
79 continue
80 }
81 // Hand the workflow off as a single-element slice so the
82 // downstream provider's existing Spawn loop runs unchanged.
83 p.Spawn(ctx, knot, pipelineRkey, trigger,
84 []*tangled.Pipeline_Workflow{wf},
85 )
86 }
87}
88
89// Logs satisfies Provider. The (knot, rkey, workflow) tuple alone
90// doesn't tell us which backend ran the workflow — the YAML body
91// isn't carried on the request — so we ask each provider and
92// surface the first one that has a stream. ErrLogsNotFound from a
93// provider just means "not mine"; we keep walking. Any other error
94// is the answer (a real backend failure should surface to the HTTP
95// caller, not be masked by the next provider).
96//
97// Map iteration order is undefined, but in practice exactly one
98// provider should know about any given (knot, rkey, workflow) so the
99// order is moot.
100func (r *providerRouter) Logs(
101 ctx context.Context,
102 knot string,
103 pipelineRkey string,
104 workflow string,
105) (<-chan LogLine, error) {
106 for _, p := range r.providers {
107 ch, err := p.Logs(ctx, knot, pipelineRkey, workflow)
108 if errors.Is(err, ErrLogsNotFound) {
109 continue
110 }
111 return ch, err
112 }
113 return nil, ErrLogsNotFound
114}
115
116// pick decodes raw and returns the first registered provider whose
117// key appears as a child of the top-level `tack:` map, walking the
118// YAML's children in document order. An empty body or YAML with no
119// `tack:` block — or one whose children name no registered provider
120// — is a structural error; no routing decision is possible.
121func (r *providerRouter) pick(raw string) (Provider, error) {
122 if strings.TrimSpace(raw) == "" {
123 return nil, errors.New("workflow body is empty")
124 }
125 // MapSlice preserves the on-the-wire ordering of the children
126 // of `tack:` so "first match" is deterministic w.r.t. the YAML
127 // document, not Go's randomised map iteration.
128 var doc struct {
129 Tack yaml.MapSlice `yaml:"tack"`
130 }
131 if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
132 return nil, fmt.Errorf("parse workflow yaml: %w", err)
133 }
134 for _, item := range doc.Tack {
135 // YAML map keys are usually strings, but the lexicon
136 // doesn't enforce that — guard so a stray int/bool key
137 // doesn't panic the type assertion.
138 key, ok := item.Key.(string)
139 if !ok {
140 continue
141 }
142 if p, ok := r.providers[key]; ok {
143 return p, nil
144 }
145 }
146 return nil, fmt.Errorf(
147 "workflow yaml has no `tack:` key matching a registered provider",
148 )
149}