Stitch any CI into Tangled
2

Configure Feed

Select the types of activity you want to include in your feed.

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}