Stitch any CI into Tangled
2

Configure Feed

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

adding tekton first shot

+1306 -2
+1
README.md
··· 110 110 its own doc per provider: 111 111 112 112 * [Buildkite](docs/buildkite.md) 113 + * [Tekton](docs/tekton.md)
+128
docs/tekton.md
··· 1 + # Tekton 2 + 3 + The Tekton provider runs only in Kubernetes. Tack receives Tangled 4 + pipeline triggers, creates a Tekton `PipelineRun` for an existing 5 + in-cluster `Pipeline`, watches that `PipelineRun`, and publishes 6 + `sh.tangled.pipeline.status` records back to Tangled. 7 + 8 + Tekton Triggers are intentionally not used. Tack already performs the 9 + event-to-run translation, and Tekton's native execution object is the 10 + `PipelineRun`. 11 + 12 + ## Required cluster setup 13 + 14 + * Tekton Pipelines is installed in the cluster. 15 + * Tack is deployed inside the same cluster. 16 + * The target Tekton `Pipeline` objects already exist in the namespace 17 + tack is configured to use. 18 + * Tack's Kubernetes service account has RBAC to: 19 + * create, get, list, and watch `tekton.dev` `pipelineruns` 20 + * get, list, and watch `tekton.dev` `taskruns` 21 + * get and list pods 22 + * get pod logs via `pods/log` 23 + 24 + Example RBAC: 25 + 26 + ```yaml 27 + apiVersion: rbac.authorization.k8s.io/v1 28 + kind: Role 29 + metadata: 30 + name: tack-tekton 31 + namespace: ci 32 + rules: 33 + - apiGroups: ["tekton.dev"] 34 + resources: ["pipelineruns"] 35 + verbs: ["create", "get", "list", "watch"] 36 + - apiGroups: ["tekton.dev"] 37 + resources: ["taskruns"] 38 + verbs: ["get", "list", "watch"] 39 + - apiGroups: [""] 40 + resources: ["pods"] 41 + verbs: ["get", "list"] 42 + - apiGroups: [""] 43 + resources: ["pods/log"] 44 + verbs: ["get"] 45 + ``` 46 + 47 + ## Configure Tack 48 + 49 + | Env var | Description | 50 + | ------------------------ | --------------------------------------------------------- | 51 + | `TACK_TEKTON_ENABLED` | Set to `1` to enable the Tekton provider | 52 + | `TACK_TEKTON_NAMESPACE` | Namespace for created `PipelineRun`s (default `default`) | 53 + 54 + The provider uses Kubernetes in-cluster service account credentials. 55 + It will not run from a local kubeconfig. 56 + 57 + ## Naming 58 + 59 + There are three separate names: 60 + 61 + * Tack workflow name: the Tangled workflow filename/name, e.g. `ci.yml`. 62 + This remains the Tangled-facing workflow identity in status records. 63 + * Tekton `Pipeline` name: the existing in-cluster pipeline definition, 64 + e.g. `repo-ci`. This is written to `spec.pipelineRef.name`. 65 + * Tekton `PipelineRun` name: generated by tack per trigger/workflow, 66 + e.g. `tack-ci-yml-<short-hash>`. This is the concrete execution 67 + object tack watches and stores. 68 + 69 + ## Workflow YAML 70 + 71 + Only the provider and target pipeline are required: 72 + 73 + ```yaml 74 + tack: 75 + tekton: 76 + pipeline: repo-ci 77 + ``` 78 + 79 + Optional fields: 80 + 81 + ```yaml 82 + tack: 83 + tekton: 84 + pipeline: repo-ci 85 + service_account: pipeline-runner 86 + params: 87 + image: example/app 88 + ``` 89 + 90 + `params` are forwarded as string Tekton params. Tack also stores the 91 + knot, pipeline rkey, workflow name, actor DID, commit, and branch as 92 + `PipelineRun` annotations, so operators can inspect the Kubernetes 93 + object and connect it back to the Tangled trigger. 94 + 95 + ## Example Pipeline 96 + 97 + ```yaml 98 + apiVersion: tekton.dev/v1 99 + kind: Pipeline 100 + metadata: 101 + name: repo-ci 102 + namespace: ci 103 + spec: 104 + params: 105 + - name: image 106 + type: string 107 + tasks: 108 + - name: test 109 + taskSpec: 110 + params: 111 + - name: image 112 + type: string 113 + steps: 114 + - name: test 115 + image: golang:1.25 116 + script: | 117 + set -eu 118 + echo "building $(params.image)" 119 + go test ./... 120 + workspaces: [] 121 + params: 122 + - name: image 123 + value: $(params.image) 124 + ``` 125 + 126 + Detailed CI behavior belongs in the in-cluster `Pipeline`. The Tangled 127 + workflow YAML should stay small: select `tekton`, pick the target 128 + pipeline, and pass only the small set of params that pipeline expects.
+28
go.mod
··· 8 8 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 9 9 github.com/mattn/go-sqlite3 v1.14.44 10 10 go.yaml.in/yaml/v2 v2.4.3 11 + k8s.io/api v0.35.3 12 + k8s.io/apimachinery v0.35.3 13 + k8s.io/client-go v0.35.3 11 14 tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b 12 15 ) 13 16 ··· 29 32 github.com/charmbracelet/x/term v0.2.1 // indirect 30 33 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 31 34 github.com/cyphar/filepath-securejoin v0.4.1 // indirect 35 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 32 36 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 33 37 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 38 + github.com/emicklei/go-restful/v3 v3.12.2 // indirect 34 39 github.com/emirpasic/gods v1.18.1 // indirect 35 40 github.com/felixge/httpsnoop v1.0.4 // indirect 41 + github.com/fxamacker/cbor/v2 v2.9.0 // indirect 36 42 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 37 43 github.com/go-git/go-billy/v5 v5.6.2 // indirect 38 44 github.com/go-git/go-git/v5 v5.14.0 // indirect ··· 40 46 github.com/go-logfmt/logfmt v0.6.1 // indirect 41 47 github.com/go-logr/logr v1.4.3 // indirect 42 48 github.com/go-logr/stdr v1.2.2 // indirect 49 + github.com/go-openapi/jsonpointer v0.21.0 // indirect 50 + github.com/go-openapi/jsonreference v0.20.2 // indirect 51 + github.com/go-openapi/swag v0.23.0 // indirect 43 52 github.com/go-redis/cache/v9 v9.0.0 // indirect 44 53 github.com/goccy/go-json v0.10.5 // indirect 45 54 github.com/gogo/protobuf v1.3.2 // indirect 55 + github.com/google/gnostic-models v0.7.0 // indirect 46 56 github.com/google/uuid v1.6.0 // indirect 47 57 github.com/hashicorp/errwrap v1.1.0 // indirect 48 58 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect ··· 66 76 github.com/ipfs/go-log v1.0.5 // indirect 67 77 github.com/ipfs/go-log/v2 v2.9.1 // indirect 68 78 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 79 + github.com/josharian/intern v1.0.0 // indirect 80 + github.com/json-iterator/go v1.1.12 // indirect 69 81 github.com/klauspost/compress v1.18.0 // indirect 70 82 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 71 83 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 84 + github.com/mailru/easyjson v0.7.7 // indirect 72 85 github.com/mattn/go-isatty v0.0.20 // indirect 73 86 github.com/mattn/go-runewidth v0.0.16 // indirect 74 87 github.com/minio/sha256-simd v1.0.1 // indirect 75 88 github.com/mitchellh/mapstructure v1.5.0 // indirect 89 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 90 + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 76 91 github.com/mr-tron/base58 v1.2.0 // indirect 77 92 github.com/muesli/termenv v0.16.0 // indirect 78 93 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 98 113 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 99 114 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 100 115 github.com/whyrusleeping/cbor-gen v0.3.1 // indirect 116 + github.com/x448/float16 v0.8.4 // indirect 101 117 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 102 118 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 103 119 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect ··· 109 125 go.uber.org/atomic v1.11.0 // indirect 110 126 go.uber.org/multierr v1.11.0 // indirect 111 127 go.uber.org/zap v1.27.1 // indirect 128 + go.yaml.in/yaml/v3 v3.0.4 // indirect 112 129 golang.org/x/crypto v0.48.0 // indirect 113 130 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect 114 131 golang.org/x/net v0.50.0 // indirect 132 + golang.org/x/oauth2 v0.34.0 // indirect 115 133 golang.org/x/sync v0.19.0 // indirect 116 134 golang.org/x/sys v0.42.0 // indirect 135 + golang.org/x/term v0.40.0 // indirect 117 136 golang.org/x/text v0.34.0 // indirect 118 137 golang.org/x/time v0.12.0 // indirect 119 138 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 120 139 google.golang.org/protobuf v1.36.11 // indirect 140 + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect 141 + gopkg.in/inf.v0 v0.9.1 // indirect 121 142 gopkg.in/warnings.v0 v0.1.2 // indirect 122 143 gopkg.in/yaml.v3 v3.0.1 // indirect 144 + k8s.io/klog/v2 v2.130.1 // indirect 145 + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect 146 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 123 147 lukechampine.com/blake3 v1.4.1 // indirect 148 + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 149 + sigs.k8s.io/randfill v1.0.0 // indirect 150 + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 151 + sigs.k8s.io/yaml v1.6.0 // indirect 124 152 ) 125 153 126 154 // tangled.org/core uses these forks for extra commit/patch metadata fields.
+79 -2
go.sum
··· 1 1 dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 2 dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 3 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 + github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 5 + github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 4 6 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 7 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 8 github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= ··· 62 64 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 63 65 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 64 66 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 67 + github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= 68 + github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 65 69 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 66 70 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 67 71 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= ··· 72 76 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 73 77 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 74 78 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 79 + github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 80 + github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 75 81 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 76 82 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 77 83 github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= ··· 88 94 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 89 95 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 90 96 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 97 + github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 98 + github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 99 + github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 100 + github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 101 + github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 102 + github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 103 + github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 104 + github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 91 105 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 92 106 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 107 + github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= 93 108 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 109 + github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 110 + github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 94 111 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 95 112 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 96 113 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= ··· 109 126 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 110 127 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 111 128 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 129 + github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= 130 + github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 112 131 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 113 132 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 114 133 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 117 136 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 118 137 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 119 138 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 139 + github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 120 140 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 141 + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 142 + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 121 143 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 122 144 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 123 145 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= ··· 182 204 github.com/ipfs/go-log/v2 v2.9.1/go.mod h1:evFx7sBiohUN3AG12mXlZBw5hacBQld3ZPHrowlJYoo= 183 205 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 184 206 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 207 + github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 208 + github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 209 + github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 210 + github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 185 211 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 186 212 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 187 213 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 194 220 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 195 221 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 196 222 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 223 + github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 197 224 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 198 225 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 199 226 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= ··· 203 230 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 204 231 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 205 232 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 233 + github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 234 + github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 206 235 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 207 236 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 208 237 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= ··· 215 244 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 216 245 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 217 246 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 247 + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 248 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 249 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 250 + github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 251 + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 252 + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 218 253 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 219 254 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 220 255 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= ··· 246 281 github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= 247 282 github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 248 283 github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= 284 + github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= 285 + github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= 249 286 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 250 287 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 251 288 github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= ··· 256 293 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 257 294 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 258 295 github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 259 - github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 260 - github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 296 + github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= 297 + github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= 261 298 github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 262 299 github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 263 300 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= ··· 305 342 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 306 343 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 307 344 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 345 + github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 346 + github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 308 347 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 309 348 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 310 349 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 350 + github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 351 + github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 311 352 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 312 353 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 313 354 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= ··· 330 371 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 331 372 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 332 373 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 374 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 375 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 333 376 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 334 377 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 335 378 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 370 413 go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 371 414 go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 372 415 go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 416 + go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 417 + go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 373 418 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 374 419 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 375 420 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= ··· 390 435 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 391 436 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 392 437 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 438 + golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= 439 + golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= 393 440 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 394 441 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 395 442 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 411 458 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 412 459 golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= 413 460 golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 461 + golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 462 + golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 414 463 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 415 464 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 416 465 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 458 507 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 459 508 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 460 509 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 510 + golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= 511 + golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 461 512 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 462 513 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 463 514 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 487 538 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 488 539 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 489 540 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 541 + golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= 542 + golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 490 543 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 491 544 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 492 545 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= ··· 510 563 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 511 564 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 512 565 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 566 + gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= 567 + gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 513 568 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 569 + gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 570 + gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 514 571 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 515 572 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 516 573 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= ··· 523 580 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 524 581 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 525 582 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 583 + k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= 584 + k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= 585 + k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= 586 + k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= 587 + k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= 588 + k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= 589 + k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 590 + k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 591 + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= 592 + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= 593 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= 594 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 526 595 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 527 596 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 597 + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= 598 + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 599 + sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 600 + sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 601 + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= 602 + sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= 603 + sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= 604 + sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= 528 605 tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b h1:5g3kGPrs6IGoHuFAb3kqrL5EwgGTl4ulbJTYsX1WfTM= 529 606 tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b/go.mod h1:abXVlFoPAeM9pZier/WF1Cnn1ZoO9YE5h59WLEUy+Hk= 530 607 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
+26
main.go
··· 46 46 BuildkiteOrg string 47 47 BuildkiteWebhookSecret string 48 48 BuildkiteWebhookMode buildkite.WebhookMode 49 + 50 + // Tekton mode is explicit because it only works from inside a 51 + // Kubernetes cluster. When enabled, tack creates PipelineRuns in 52 + // TektonNamespace using its pod's service account credentials. 53 + TektonEnabled bool 54 + TektonNamespace string 49 55 } 50 56 51 57 func loadConfig() (config, error) { ··· 62 68 BuildkiteWebhookMode: buildkite.WebhookMode( 63 69 envOr("TACK_BUILDKITE_WEBHOOK_MODE", string(buildkite.WebhookModeToken)), 64 70 ), 71 + TektonEnabled: os.Getenv("TACK_TEKTON_ENABLED") == "1", 72 + TektonNamespace: envOr("TACK_TEKTON_NAMESPACE", "default"), 65 73 } 66 74 addrFlag := flag.String("addr", cfg.Addr, "HTTP listen address (overrides TACK_LISTEN_ADDR)") 67 75 flag.Parse() ··· 99 107 cfg.BuildkiteWebhookMode, 100 108 ) 101 109 } 110 + } 111 + if cfg.TektonEnabled && cfg.TektonNamespace == "" { 112 + return cfg, errors.New("TACK_TEKTON_NAMESPACE is required when TACK_TEKTON_ENABLED=1") 102 113 } 103 114 104 115 return cfg, nil ··· 189 200 logger.Info("buildkite provider enabled", 190 201 "default_org", cfg.BuildkiteOrg, 191 202 "webhook_mode", cfg.BuildkiteWebhookMode, 203 + ) 204 + } 205 + if cfg.TektonEnabled { 206 + tkProvider, err := newInClusterTektonProvider( 207 + br, st, 208 + cfg.TektonNamespace, 209 + logger, 210 + ) 211 + if err != nil { 212 + logger.Error("failed to configure tekton provider", "err", err) 213 + os.Exit(1) 214 + } 215 + providers["tekton"] = tkProvider 216 + logger.Info("tekton provider enabled", 217 + "namespace", cfg.TektonNamespace, 192 218 ) 193 219 } 194 220 provider := newProviderRouter(logger, providers)
+663
provider_tekton.go
··· 1 + package main 2 + 3 + // tektonProvider implements Provider by creating Tekton PipelineRuns 4 + // directly inside the Kubernetes cluster. Tack already receives and 5 + // authorizes Tangled pipeline triggers, so adding Tekton Triggers would 6 + // duplicate the event-to-run translation layer instead of simplifying it. 7 + 8 + import ( 9 + "bufio" 10 + "context" 11 + "crypto/sha256" 12 + "encoding/hex" 13 + "encoding/json" 14 + "errors" 15 + "fmt" 16 + "io" 17 + "log/slog" 18 + "sort" 19 + "strings" 20 + "time" 21 + "unicode" 22 + 23 + corev1 "k8s.io/api/core/v1" 24 + apierrors "k8s.io/apimachinery/pkg/api/errors" 25 + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 26 + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 27 + "k8s.io/apimachinery/pkg/fields" 28 + "k8s.io/apimachinery/pkg/labels" 29 + runtimeschema "k8s.io/apimachinery/pkg/runtime/schema" 30 + "k8s.io/client-go/dynamic" 31 + "k8s.io/client-go/kubernetes" 32 + "k8s.io/client-go/rest" 33 + "tangled.org/core/api/tangled" 34 + 35 + "go.yaml.in/yaml/v2" 36 + ) 37 + 38 + const ( 39 + tektonAPIVersion = "tekton.dev/v1" 40 + tektonRunKind = "PipelineRun" 41 + 42 + tektonLabelManagedBy = "tack.mitchellh.com/managed-by" 43 + tektonLabelPipelineRkey = "tack.mitchellh.com/pipeline-rkey" 44 + tektonLabelWorkflow = "tack.mitchellh.com/workflow" 45 + 46 + tektonAnnotationKnot = "tack.mitchellh.com/knot" 47 + tektonAnnotationPipelineRkey = "tack.mitchellh.com/pipeline-rkey" 48 + tektonAnnotationWorkflow = "tack.mitchellh.com/workflow" 49 + tektonAnnotationActor = "tack.mitchellh.com/actor" 50 + tektonAnnotationCommit = "tack.mitchellh.com/commit" 51 + tektonAnnotationBranch = "tack.mitchellh.com/branch" 52 + ) 53 + 54 + var ( 55 + pipelineRunsGVR = runtimeschema.GroupVersionResource{ 56 + Group: "tekton.dev", Version: "v1", Resource: "pipelineruns", 57 + } 58 + taskRunsGVR = runtimeschema.GroupVersionResource{ 59 + Group: "tekton.dev", Version: "v1", Resource: "taskruns", 60 + } 61 + ) 62 + 63 + // tektonWorkflowConfig is the Tekton-specific subset of workflow YAML. 64 + // `pipeline` names an existing in-cluster Tekton Pipeline. Params are 65 + // deliberately string-only in v1: tack is meant to select an existing 66 + // runner and pass a small amount of routing data, not mirror Tekton's 67 + // entire PipelineRun API. 68 + type tektonWorkflowConfig struct { 69 + Pipeline string `yaml:"pipeline"` 70 + ServiceAccount string `yaml:"service_account"` 71 + Params map[string]string `yaml:"params"` 72 + } 73 + 74 + type tektonWorkflowDoc struct { 75 + Tack struct { 76 + Tekton tektonWorkflowConfig `yaml:"tekton"` 77 + } `yaml:"tack"` 78 + } 79 + 80 + // parseTektonWorkflowConfig decodes `tack.tekton` from a workflow body. 81 + func parseTektonWorkflowConfig(raw string) (*tektonWorkflowConfig, error) { 82 + if strings.TrimSpace(raw) == "" { 83 + return nil, errors.New("workflow body is empty") 84 + } 85 + var doc tektonWorkflowDoc 86 + if err := yaml.Unmarshal([]byte(raw), &doc); err != nil { 87 + return nil, fmt.Errorf("parse workflow yaml: %w", err) 88 + } 89 + cfg := doc.Tack.Tekton 90 + if cfg.Pipeline == "" { 91 + return nil, errors.New("workflow yaml: `tack.tekton.pipeline` is required") 92 + } 93 + return &cfg, nil 94 + } 95 + 96 + type tektonProvider struct { 97 + br *broker 98 + st *store 99 + log *slog.Logger 100 + dyn dynamic.Interface 101 + kube kubernetes.Interface 102 + namespace string 103 + } 104 + 105 + var _ Provider = (*tektonProvider)(nil) 106 + 107 + func newTektonProvider( 108 + br *broker, 109 + st *store, 110 + dyn dynamic.Interface, 111 + kube kubernetes.Interface, 112 + namespace string, 113 + log *slog.Logger, 114 + ) *tektonProvider { 115 + return &tektonProvider{ 116 + br: br, 117 + st: st, 118 + log: log.With("component", "provider", "kind", "tekton"), 119 + dyn: dyn, 120 + kube: kube, 121 + namespace: namespace, 122 + } 123 + } 124 + 125 + func newInClusterTektonProvider( 126 + br *broker, 127 + st *store, 128 + namespace string, 129 + log *slog.Logger, 130 + ) (*tektonProvider, error) { 131 + cfg, err := rest.InClusterConfig() 132 + if err != nil { 133 + return nil, fmt.Errorf("load in-cluster kubernetes config: %w", err) 134 + } 135 + dyn, err := dynamic.NewForConfig(cfg) 136 + if err != nil { 137 + return nil, fmt.Errorf("create dynamic kubernetes client: %w", err) 138 + } 139 + kube, err := kubernetes.NewForConfig(cfg) 140 + if err != nil { 141 + return nil, fmt.Errorf("create kubernetes client: %w", err) 142 + } 143 + return newTektonProvider(br, st, dyn, kube, namespace, log), nil 144 + } 145 + 146 + func (p *tektonProvider) Spawn( 147 + ctx context.Context, 148 + knot string, 149 + pipelineRkey string, 150 + actor string, 151 + trigger *tangled.Pipeline_TriggerMetadata, 152 + workflows []*tangled.Pipeline_Workflow, 153 + ) { 154 + if len(workflows) == 0 { 155 + p.log.Warn("pipeline has no workflows; nothing to spawn", 156 + "knot", knot, "rkey", pipelineRkey, 157 + ) 158 + return 159 + } 160 + for _, wf := range workflows { 161 + if wf == nil || wf.Name == "" { 162 + continue 163 + } 164 + wf := wf 165 + go p.spawnWorkflow(ctx, knot, pipelineRkey, actor, trigger, wf) 166 + } 167 + } 168 + 169 + func (p *tektonProvider) spawnWorkflow( 170 + ctx context.Context, 171 + knot string, 172 + pipelineRkey string, 173 + actor string, 174 + trigger *tangled.Pipeline_TriggerMetadata, 175 + wf *tangled.Pipeline_Workflow, 176 + ) { 177 + logger := p.log.With( 178 + "knot", knot, 179 + "pipeline_rkey", pipelineRkey, 180 + "workflow", wf.Name, 181 + "actor", actor, 182 + ) 183 + 184 + cfg, err := parseTektonWorkflowConfig(wf.Raw) 185 + if err != nil { 186 + logger.Error("invalid workflow config; refusing to spawn", "err", err) 187 + return 188 + } 189 + commit, branch := triggerCommitAndBranch(trigger) 190 + name := tektonPipelineRunName(knot, pipelineRkey, wf.Name, commit, branch) 191 + pr := buildTektonPipelineRun( 192 + p.namespace, name, cfg, knot, pipelineRkey, actor, commit, branch, wf, 193 + ) 194 + 195 + runs := p.dyn.Resource(pipelineRunsGVR).Namespace(p.namespace) 196 + created, err := runs.Create(ctx, pr, metav1.CreateOptions{}) 197 + if apierrors.IsAlreadyExists(err) { 198 + created, err = runs.Get(ctx, name, metav1.GetOptions{}) 199 + } 200 + if err != nil { 201 + logger.Error("create tekton PipelineRun", "err", err, 202 + "namespace", p.namespace, "pipeline_run", name, 203 + "pipeline", cfg.Pipeline, 204 + ) 205 + return 206 + } 207 + 208 + ref := TektonRunRef{ 209 + Knot: knot, 210 + PipelineRkey: pipelineRkey, 211 + Workflow: wf.Name, 212 + Namespace: p.namespace, 213 + PipelineRunName: name, 214 + PipelineRunUID: string(created.GetUID()), 215 + PipelineName: cfg.Pipeline, 216 + PipelineURI: pipelineATURI(knot, pipelineRkey), 217 + } 218 + if err := p.st.InsertTektonRun(ctx, ref); err != nil { 219 + logger.Error("persist tekton run mapping", "err", err, 220 + "pipeline_run", name, 221 + ) 222 + return 223 + } 224 + 225 + if err := p.publishStatus(ctx, ref.PipelineURI, wf.Name, 226 + "pending", name, nil, nil); err != nil { 227 + logger.Error("publish initial pending status", "err", err) 228 + } 229 + 230 + logger.Info("tekton PipelineRun created", 231 + "namespace", p.namespace, 232 + "pipeline", cfg.Pipeline, 233 + "pipeline_run", name, 234 + "uid", ref.PipelineRunUID, 235 + ) 236 + go p.watchPipelineRun(ctx, ref) 237 + } 238 + 239 + func buildTektonPipelineRun( 240 + namespace, name string, 241 + cfg *tektonWorkflowConfig, 242 + knot, pipelineRkey, actor, commit, branch string, 243 + wf *tangled.Pipeline_Workflow, 244 + ) *unstructured.Unstructured { 245 + obj := &unstructured.Unstructured{ 246 + Object: map[string]interface{}{ 247 + "apiVersion": tektonAPIVersion, 248 + "kind": tektonRunKind, 249 + "metadata": map[string]interface{}{ 250 + "name": name, 251 + "namespace": namespace, 252 + "labels": map[string]interface{}{ 253 + tektonLabelManagedBy: "tack", 254 + tektonLabelPipelineRkey: labelValue(pipelineRkey), 255 + tektonLabelWorkflow: labelValue(wf.Name), 256 + }, 257 + "annotations": map[string]interface{}{ 258 + tektonAnnotationKnot: knot, 259 + tektonAnnotationPipelineRkey: pipelineRkey, 260 + tektonAnnotationWorkflow: wf.Name, 261 + tektonAnnotationActor: actor, 262 + tektonAnnotationCommit: commit, 263 + tektonAnnotationBranch: branch, 264 + }, 265 + }, 266 + "spec": map[string]interface{}{ 267 + "pipelineRef": map[string]interface{}{ 268 + "name": cfg.Pipeline, 269 + }, 270 + }, 271 + }, 272 + } 273 + spec := obj.Object["spec"].(map[string]interface{}) 274 + if cfg.ServiceAccount != "" { 275 + spec["serviceAccountName"] = cfg.ServiceAccount 276 + } 277 + if len(cfg.Params) > 0 { 278 + keys := make([]string, 0, len(cfg.Params)) 279 + for key := range cfg.Params { 280 + keys = append(keys, key) 281 + } 282 + sort.Strings(keys) 283 + params := make([]interface{}, 0, len(keys)) 284 + for _, key := range keys { 285 + params = append(params, map[string]interface{}{ 286 + "name": key, 287 + "value": cfg.Params[key], 288 + }) 289 + } 290 + spec["params"] = params 291 + } 292 + return obj 293 + } 294 + 295 + func (p *tektonProvider) watchPipelineRun(ctx context.Context, ref TektonRunRef) { 296 + logger := p.log.With( 297 + "knot", ref.Knot, 298 + "pipeline_rkey", ref.PipelineRkey, 299 + "workflow", ref.Workflow, 300 + "namespace", ref.Namespace, 301 + "pipeline_run", ref.PipelineRunName, 302 + ) 303 + 304 + last := "" 305 + if obj, err := p.dyn.Resource(pipelineRunsGVR).Namespace(ref.Namespace). 306 + Get(ctx, ref.PipelineRunName, metav1.GetOptions{}); err == nil { 307 + status, terminal, ok := mapTektonPipelineRunStatus(obj) 308 + if ok { 309 + last = status 310 + if err := p.publishStatus(ctx, ref.PipelineURI, ref.Workflow, 311 + status, ref.PipelineRunName, nil, nil); err != nil { 312 + logger.Error("publish tekton status", "err", err, "status", status) 313 + } 314 + if terminal { 315 + return 316 + } 317 + } 318 + } else if apierrors.IsNotFound(err) { 319 + logger.Warn("PipelineRun disappeared while watching") 320 + return 321 + } else { 322 + logger.Debug("initial PipelineRun status read", "err", err) 323 + } 324 + 325 + w, err := p.dyn.Resource(pipelineRunsGVR).Namespace(ref.Namespace). 326 + Watch(ctx, metav1.ListOptions{ 327 + FieldSelector: fields.OneTermEqualSelector( 328 + "metadata.name", ref.PipelineRunName, 329 + ).String(), 330 + }) 331 + if err != nil { 332 + logger.Debug("watch PipelineRun status; falling back to polling", "err", err) 333 + p.pollPipelineRun(ctx, ref, logger, last) 334 + return 335 + } 336 + defer w.Stop() 337 + 338 + for { 339 + select { 340 + case <-ctx.Done(): 341 + return 342 + case ev, ok := <-w.ResultChan(): 343 + if !ok { 344 + p.pollPipelineRun(ctx, ref, logger, last) 345 + return 346 + } 347 + obj, ok := ev.Object.(*unstructured.Unstructured) 348 + if !ok { 349 + continue 350 + } 351 + status, terminal, ok := mapTektonPipelineRunStatus(obj) 352 + if !ok || status == last { 353 + if terminal { 354 + return 355 + } 356 + continue 357 + } 358 + last = status 359 + if err := p.publishStatus(ctx, ref.PipelineURI, ref.Workflow, 360 + status, ref.PipelineRunName, nil, nil); err != nil { 361 + logger.Error("publish tekton status", "err", err, "status", status) 362 + continue 363 + } 364 + if terminal { 365 + return 366 + } 367 + } 368 + } 369 + } 370 + 371 + func (p *tektonProvider) pollPipelineRun( 372 + ctx context.Context, 373 + ref TektonRunRef, 374 + logger *slog.Logger, 375 + last string, 376 + ) { 377 + ticker := time.NewTicker(5 * time.Second) 378 + defer ticker.Stop() 379 + for { 380 + select { 381 + case <-ctx.Done(): 382 + return 383 + case <-ticker.C: 384 + obj, err := p.dyn.Resource(pipelineRunsGVR).Namespace(ref.Namespace). 385 + Get(ctx, ref.PipelineRunName, metav1.GetOptions{}) 386 + if apierrors.IsNotFound(err) { 387 + logger.Warn("PipelineRun disappeared while watching") 388 + return 389 + } 390 + if err != nil { 391 + logger.Debug("get PipelineRun status", "err", err) 392 + continue 393 + } 394 + status, terminal, ok := mapTektonPipelineRunStatus(obj) 395 + if !ok || status == last { 396 + if terminal { 397 + return 398 + } 399 + continue 400 + } 401 + last = status 402 + if err := p.publishStatus(ctx, ref.PipelineURI, ref.Workflow, 403 + status, ref.PipelineRunName, nil, nil); err != nil { 404 + logger.Error("publish tekton status", "err", err, "status", status) 405 + continue 406 + } 407 + if terminal { 408 + return 409 + } 410 + } 411 + } 412 + } 413 + 414 + // mapTektonPipelineRunStatus translates Tekton's Succeeded condition 415 + // into the Tangled status strings consumed by the appview. 416 + func mapTektonPipelineRunStatus(obj *unstructured.Unstructured) (status string, terminal bool, ok bool) { 417 + conditions, ok, _ := unstructured.NestedSlice(obj.Object, "status", "conditions") 418 + if !ok || len(conditions) == 0 { 419 + return "", false, false 420 + } 421 + for _, raw := range conditions { 422 + cond, _ := raw.(map[string]interface{}) 423 + if cond["type"] != "Succeeded" { 424 + continue 425 + } 426 + condStatus, _ := cond["status"].(string) 427 + reason, _ := cond["reason"].(string) 428 + switch condStatus { 429 + case "True": 430 + return "success", true, true 431 + case "False": 432 + if tektonReasonCancelled(reason) { 433 + return "cancelled", true, true 434 + } 435 + return "failed", true, true 436 + case "Unknown": 437 + return "running", false, true 438 + default: 439 + return "", false, false 440 + } 441 + } 442 + return "", false, false 443 + } 444 + 445 + func tektonReasonCancelled(reason string) bool { 446 + r := strings.ToLower(reason) 447 + return strings.Contains(r, "cancel") || strings.Contains(r, "stop") 448 + } 449 + 450 + func (p *tektonProvider) Logs( 451 + ctx context.Context, 452 + knot string, 453 + pipelineRkey string, 454 + workflow string, 455 + ) (<-chan LogLine, error) { 456 + ref, err := p.st.LookupTektonRunByTuple(ctx, knot, pipelineRkey, workflow) 457 + if err != nil { 458 + return nil, fmt.Errorf("lookup tekton run mapping: %w", err) 459 + } 460 + if ref == nil { 461 + return nil, ErrLogsNotFound 462 + } 463 + 464 + taskRuns, err := p.taskRunsForPipelineRun(ctx, *ref) 465 + if err != nil { 466 + return nil, err 467 + } 468 + if len(taskRuns) == 0 { 469 + return nil, ErrLogsNotFound 470 + } 471 + 472 + out := make(chan LogLine, 32) 473 + go func() { 474 + defer close(out) 475 + stepID := 0 476 + for _, tr := range taskRuns { 477 + taskName := tr.GetName() 478 + if taskName == "" { 479 + taskName = fmt.Sprintf("task %d", stepID) 480 + } 481 + if !sendLine(ctx, out, LogLine{ 482 + Kind: LogKindControl, 483 + Time: time.Now(), 484 + Content: taskName, 485 + StepId: stepID, 486 + StepStatus: StepStatusStart, 487 + }) { 488 + return 489 + } 490 + 491 + p.streamTaskRunLogs(ctx, out, *ref, tr, stepID) 492 + 493 + if !sendLine(ctx, out, LogLine{ 494 + Kind: LogKindControl, 495 + Time: time.Now(), 496 + Content: taskName, 497 + StepId: stepID, 498 + StepStatus: StepStatusEnd, 499 + }) { 500 + return 501 + } 502 + stepID++ 503 + } 504 + }() 505 + return out, nil 506 + } 507 + 508 + func (p *tektonProvider) taskRunsForPipelineRun(ctx context.Context, ref TektonRunRef) ([]unstructured.Unstructured, error) { 509 + sel := labels.Set{"tekton.dev/pipelineRun": ref.PipelineRunName}.String() 510 + list, err := p.dyn.Resource(taskRunsGVR).Namespace(ref.Namespace). 511 + List(ctx, metav1.ListOptions{LabelSelector: sel}) 512 + if err != nil { 513 + return nil, fmt.Errorf("list Tekton TaskRuns: %w", err) 514 + } 515 + items := append([]unstructured.Unstructured(nil), list.Items...) 516 + sort.Slice(items, func(i, j int) bool { 517 + ti := items[i].GetCreationTimestamp() 518 + tj := items[j].GetCreationTimestamp() 519 + return ti.Before(&tj) 520 + }) 521 + return items, nil 522 + } 523 + 524 + func (p *tektonProvider) streamTaskRunLogs( 525 + ctx context.Context, 526 + out chan<- LogLine, 527 + ref TektonRunRef, 528 + tr unstructured.Unstructured, 529 + stepID int, 530 + ) { 531 + pods, err := p.podsForTaskRun(ctx, ref.Namespace, tr.GetName()) 532 + if err != nil { 533 + p.log.Debug("list pods for TaskRun", "err", err, 534 + "task_run", tr.GetName(), "pipeline_run", ref.PipelineRunName, 535 + ) 536 + return 537 + } 538 + for _, pod := range pods { 539 + for _, c := range append(pod.Spec.InitContainers, pod.Spec.Containers...) { 540 + req := p.kube.CoreV1().Pods(ref.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{ 541 + Container: c.Name, 542 + }) 543 + rc, err := req.Stream(ctx) 544 + if err != nil { 545 + p.log.Debug("stream pod logs", "err", err, 546 + "pod", pod.Name, "container", c.Name, 547 + ) 548 + continue 549 + } 550 + p.sendReaderLines(ctx, out, rc, stepID) 551 + _ = rc.Close() 552 + } 553 + } 554 + } 555 + 556 + func (p *tektonProvider) podsForTaskRun(ctx context.Context, namespace, taskRun string) ([]corev1.Pod, error) { 557 + sel := labels.Set{"tekton.dev/taskRun": taskRun}.String() 558 + list, err := p.kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ 559 + LabelSelector: sel, 560 + }) 561 + if err != nil { 562 + return nil, fmt.Errorf("list pods: %w", err) 563 + } 564 + pods := append([]corev1.Pod(nil), list.Items...) 565 + sort.Slice(pods, func(i, j int) bool { 566 + return pods[i].CreationTimestamp.Before(&pods[j].CreationTimestamp) 567 + }) 568 + return pods, nil 569 + } 570 + 571 + func (p *tektonProvider) sendReaderLines( 572 + ctx context.Context, 573 + out chan<- LogLine, 574 + rc io.Reader, 575 + stepID int, 576 + ) { 577 + scanner := bufio.NewScanner(rc) 578 + for scanner.Scan() { 579 + if !sendLine(ctx, out, LogLine{ 580 + Kind: LogKindData, 581 + Time: time.Now(), 582 + Content: scanner.Text() + "\n", 583 + StepId: stepID, 584 + Stream: "stdout", 585 + }) { 586 + return 587 + } 588 + } 589 + if err := scanner.Err(); err != nil { 590 + p.log.Debug("scan pod log", "err", err) 591 + } 592 + } 593 + 594 + func (p *tektonProvider) publishStatus( 595 + ctx context.Context, 596 + pipelineURI, workflow, status, runName string, 597 + errMsg *string, 598 + exitCode *int64, 599 + ) error { 600 + rec := tangled.PipelineStatus{ 601 + LexiconTypeID: tangled.PipelineStatusNSID, 602 + Pipeline: pipelineURI, 603 + Workflow: workflow, 604 + Status: status, 605 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 606 + Error: errMsg, 607 + ExitCode: exitCode, 608 + } 609 + body, err := json.Marshal(rec) 610 + if err != nil { 611 + return fmt.Errorf("marshal pipeline.status: %w", err) 612 + } 613 + rkey := fmt.Sprintf("tk-%s-%s-%d", runName, status, time.Now().UnixNano()) 614 + if _, err := p.br.Publish(ctx, rkey, tangled.PipelineStatusNSID, body); err != nil { 615 + return fmt.Errorf("publish pipeline.status: %w", err) 616 + } 617 + return nil 618 + } 619 + 620 + func tektonPipelineRunName(knot, pipelineRkey, workflow, commit, branch string) string { 621 + h := sha256.Sum256([]byte(strings.Join( 622 + []string{knot, pipelineRkey, workflow, commit, branch}, "\x00", 623 + ))) 624 + suffix := hex.EncodeToString(h[:])[:12] 625 + base := dnsLabel("tack-" + workflow) 626 + maxBase := 63 - len(suffix) - 1 627 + if len(base) > maxBase { 628 + base = strings.TrimRight(base[:maxBase], "-") 629 + } 630 + if base == "" { 631 + base = "tack" 632 + } 633 + return base + "-" + suffix 634 + } 635 + 636 + func dnsLabel(s string) string { 637 + var b strings.Builder 638 + lastDash := false 639 + for _, r := range strings.ToLower(s) { 640 + ok := unicode.IsLetter(r) || unicode.IsDigit(r) 641 + if ok { 642 + b.WriteRune(r) 643 + lastDash = false 644 + continue 645 + } 646 + if !lastDash { 647 + b.WriteByte('-') 648 + lastDash = true 649 + } 650 + } 651 + return strings.Trim(b.String(), "-") 652 + } 653 + 654 + func labelValue(s string) string { 655 + v := dnsLabel(s) 656 + if len(v) > 63 { 657 + v = strings.TrimRight(v[:63], "-") 658 + } 659 + if v == "" { 660 + return "unknown" 661 + } 662 + return v 663 + }
+291
provider_tekton_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "log/slog" 8 + "testing" 9 + "time" 10 + 11 + corev1 "k8s.io/api/core/v1" 12 + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 + "k8s.io/apimachinery/pkg/runtime" 15 + runtimeschema "k8s.io/apimachinery/pkg/runtime/schema" 16 + dynamicfake "k8s.io/client-go/dynamic/fake" 17 + kubefake "k8s.io/client-go/kubernetes/fake" 18 + "tangled.org/core/api/tangled" 19 + ) 20 + 21 + func newTektonTestProvider(t *testing.T, objs ...runtime.Object) (*tektonProvider, *store, *broker) { 22 + t.Helper() 23 + st := newTestStore(t) 24 + br := newBroker(st) 25 + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( 26 + runtime.NewScheme(), 27 + map[runtimeschema.GroupVersionResource]string{ 28 + pipelineRunsGVR: "PipelineRunList", 29 + taskRunsGVR: "TaskRunList", 30 + }, 31 + objs..., 32 + ) 33 + kube := kubefake.NewSimpleClientset() 34 + p := newTektonProvider(br, st, dyn, kube, "ci", slog.Default()) 35 + return p, st, br 36 + } 37 + 38 + func TestTektonWorkflowConfig(t *testing.T) { 39 + raw := "tack:\n tekton:\n pipeline: repo-ci\n service_account: runner\n params:\n image: example/app\n" 40 + cfg, err := parseTektonWorkflowConfig(raw) 41 + if err != nil { 42 + t.Fatalf("parse: %v", err) 43 + } 44 + if cfg.Pipeline != "repo-ci" || cfg.ServiceAccount != "runner" { 45 + t.Fatalf("cfg mismatch: %+v", cfg) 46 + } 47 + if got := cfg.Params["image"]; got != "example/app" { 48 + t.Fatalf("params[image] = %q", got) 49 + } 50 + 51 + if _, err := parseTektonWorkflowConfig("tack:\n tekton: {}\n"); err == nil { 52 + t.Fatal("missing pipeline should fail") 53 + } 54 + } 55 + 56 + func TestTektonBuildPipelineRun(t *testing.T) { 57 + cfg := &tektonWorkflowConfig{ 58 + Pipeline: "repo-ci", 59 + ServiceAccount: "runner", 60 + Params: map[string]string{ 61 + "image": "example/app", 62 + }, 63 + } 64 + name := tektonPipelineRunName("knot.example.com", "rkey-1", "ci.yml", "abcdef", "main") 65 + if len(name) > 63 || name == "" { 66 + t.Fatalf("bad generated name: %q", name) 67 + } 68 + 69 + obj := buildTektonPipelineRun("ci", name, cfg, 70 + "knot.example.com", "rkey-1", "did:plc:actor", "abcdef", "main", 71 + &tangled.Pipeline_Workflow{Name: "ci.yml"}, 72 + ) 73 + if obj.GetAPIVersion() != tektonAPIVersion || obj.GetKind() != tektonRunKind { 74 + t.Fatalf("type meta mismatch: %s %s", obj.GetAPIVersion(), obj.GetKind()) 75 + } 76 + pipeline, _, _ := unstructured.NestedString(obj.Object, "spec", "pipelineRef", "name") 77 + if pipeline != "repo-ci" { 78 + t.Fatalf("pipelineRef.name = %q", pipeline) 79 + } 80 + sa, _, _ := unstructured.NestedString(obj.Object, "spec", "serviceAccountName") 81 + if sa != "runner" { 82 + t.Fatalf("serviceAccountName = %q", sa) 83 + } 84 + params, _, _ := unstructured.NestedSlice(obj.Object, "spec", "params") 85 + if len(params) != 1 { 86 + t.Fatalf("params = %+v", params) 87 + } 88 + if obj.GetAnnotations()[tektonAnnotationActor] != "did:plc:actor" || 89 + obj.GetAnnotations()[tektonAnnotationCommit] != "abcdef" { 90 + t.Fatalf("annotations missing identity: %+v", obj.GetAnnotations()) 91 + } 92 + } 93 + 94 + func TestTektonStatusMapping(t *testing.T) { 95 + tests := []struct { 96 + name string 97 + cond string 98 + reason string 99 + status string 100 + terminal bool 101 + ok bool 102 + }{ 103 + {name: "unknown", cond: "Unknown", status: "running", ok: true}, 104 + {name: "success", cond: "True", status: "success", terminal: true, ok: true}, 105 + {name: "failed", cond: "False", reason: "Failed", status: "failed", terminal: true, ok: true}, 106 + {name: "cancelled", cond: "False", reason: "PipelineRunCancelled", status: "cancelled", terminal: true, ok: true}, 107 + {name: "stopped", cond: "False", reason: "PipelineRunStopped", status: "cancelled", terminal: true, ok: true}, 108 + } 109 + for _, tt := range tests { 110 + t.Run(tt.name, func(t *testing.T) { 111 + obj := tektonStatusObject(tt.cond, tt.reason) 112 + status, terminal, ok := mapTektonPipelineRunStatus(obj) 113 + if status != tt.status || terminal != tt.terminal || ok != tt.ok { 114 + t.Fatalf("got %q/%v/%v; want %q/%v/%v", 115 + status, terminal, ok, tt.status, tt.terminal, tt.ok) 116 + } 117 + }) 118 + } 119 + } 120 + 121 + func TestTektonSpawnCreatesPipelineRun(t *testing.T) { 122 + p, st, _ := newTektonTestProvider(t) 123 + ctx, cancel := context.WithCancel(context.Background()) 124 + defer cancel() 125 + 126 + trigger := &tangled.Pipeline_TriggerMetadata{ 127 + Push: &tangled.Pipeline_PushTriggerData{ 128 + NewSha: "abcdef0123", 129 + Ref: "refs/heads/main", 130 + }, 131 + } 132 + p.Spawn(ctx, "knot.example.com", "rkey-1", "did:plc:actor", trigger, 133 + []*tangled.Pipeline_Workflow{{Name: "ci.yml", 134 + Raw: "tack:\n tekton:\n pipeline: repo-ci\n"}}, 135 + ) 136 + 137 + ref := waitTektonRef(t, st, "knot.example.com", "rkey-1", "ci.yml") 138 + if ref.Namespace != "ci" || ref.PipelineName != "repo-ci" { 139 + t.Fatalf("ref mismatch: %+v", ref) 140 + } 141 + 142 + obj, err := p.dyn.Resource(pipelineRunsGVR).Namespace("ci"). 143 + Get(context.Background(), ref.PipelineRunName, metav1.GetOptions{}) 144 + if err != nil { 145 + t.Fatalf("get PipelineRun: %v", err) 146 + } 147 + pipeline, _, _ := unstructured.NestedString(obj.Object, "spec", "pipelineRef", "name") 148 + if pipeline != "repo-ci" { 149 + t.Fatalf("pipelineRef.name = %q", pipeline) 150 + } 151 + 152 + rows, err := st.EventsAfter(context.Background(), 0) 153 + if err != nil { 154 + t.Fatalf("EventsAfter: %v", err) 155 + } 156 + if len(rows) != 1 { 157 + t.Fatalf("got %d events, want 1", len(rows)) 158 + } 159 + var rec tangled.PipelineStatus 160 + if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil { 161 + t.Fatalf("decode status: %v", err) 162 + } 163 + if rec.Status != "pending" || rec.Workflow != "ci.yml" { 164 + t.Fatalf("bad pending status: %+v", rec) 165 + } 166 + } 167 + 168 + func TestTektonSpawnAlreadyExists(t *testing.T) { 169 + name := tektonPipelineRunName("knot.example.com", "rkey-1", "ci.yml", "abcdef0123", "main") 170 + existing := buildTektonPipelineRun("ci", name, 171 + &tektonWorkflowConfig{Pipeline: "repo-ci"}, 172 + "knot.example.com", "rkey-1", "did:plc:actor", "abcdef0123", "main", 173 + &tangled.Pipeline_Workflow{Name: "ci.yml"}, 174 + ) 175 + existing.SetUID("uid-1") 176 + p, st, _ := newTektonTestProvider(t, existing) 177 + ctx, cancel := context.WithCancel(context.Background()) 178 + defer cancel() 179 + 180 + p.Spawn(ctx, "knot.example.com", "rkey-1", "did:plc:actor", 181 + &tangled.Pipeline_TriggerMetadata{Push: &tangled.Pipeline_PushTriggerData{ 182 + NewSha: "abcdef0123", 183 + Ref: "refs/heads/main", 184 + }}, 185 + []*tangled.Pipeline_Workflow{{Name: "ci.yml", 186 + Raw: "tack:\n tekton:\n pipeline: repo-ci\n"}}, 187 + ) 188 + 189 + ref := waitTektonRef(t, st, "knot.example.com", "rkey-1", "ci.yml") 190 + if ref.PipelineRunName != name || ref.PipelineRunUID != "uid-1" { 191 + t.Fatalf("ref mismatch: %+v", ref) 192 + } 193 + } 194 + 195 + func TestTektonLogsLookup(t *testing.T) { 196 + p, st, _ := newTektonTestProvider(t) 197 + ctx := context.Background() 198 + if _, err := p.Logs(ctx, "knot.example.com", "rkey-1", "ci.yml"); !errors.Is(err, ErrLogsNotFound) { 199 + t.Fatalf("logs before mapping err = %v; want ErrLogsNotFound", err) 200 + } 201 + ref := TektonRunRef{ 202 + Knot: "knot.example.com", 203 + PipelineRkey: "rkey-1", 204 + Workflow: "ci.yml", 205 + Namespace: "ci", 206 + PipelineRunName: "run-1", 207 + PipelineRunUID: "uid-1", 208 + PipelineName: "repo-ci", 209 + PipelineURI: pipelineATURI("knot.example.com", "rkey-1"), 210 + } 211 + if err := st.InsertTektonRun(ctx, ref); err != nil { 212 + t.Fatalf("insert ref: %v", err) 213 + } 214 + if _, err := p.Logs(ctx, "knot.example.com", "rkey-1", "ci.yml"); !errors.Is(err, ErrLogsNotFound) { 215 + t.Fatalf("logs before TaskRuns err = %v; want ErrLogsNotFound", err) 216 + } 217 + 218 + taskRun := &unstructured.Unstructured{Object: map[string]interface{}{ 219 + "apiVersion": "tekton.dev/v1", 220 + "kind": "TaskRun", 221 + "metadata": map[string]interface{}{ 222 + "name": "task-1", 223 + "namespace": "ci", 224 + "labels": map[string]interface{}{ 225 + "tekton.dev/pipelineRun": "run-1", 226 + }, 227 + }, 228 + }} 229 + _, err := p.dyn.Resource(taskRunsGVR).Namespace("ci"). 230 + Create(ctx, taskRun, metav1.CreateOptions{}) 231 + if err != nil { 232 + t.Fatalf("create TaskRun: %v", err) 233 + } 234 + _, err = p.kube.CoreV1().Pods("ci").Create(ctx, &corev1.Pod{ 235 + ObjectMeta: metav1.ObjectMeta{ 236 + Name: "pod-1", 237 + Namespace: "ci", 238 + Labels: map[string]string{ 239 + "tekton.dev/taskRun": "task-1", 240 + }, 241 + }, 242 + Spec: corev1.PodSpec{ 243 + Containers: []corev1.Container{{Name: "step-test", Image: "busybox"}}, 244 + }, 245 + }, metav1.CreateOptions{}) 246 + if err != nil { 247 + t.Fatalf("create pod: %v", err) 248 + } 249 + 250 + ch, err := p.Logs(ctx, "knot.example.com", "rkey-1", "ci.yml") 251 + if err != nil { 252 + t.Fatalf("Logs after pods: %v", err) 253 + } 254 + var got []LogLine 255 + for line := range ch { 256 + got = append(got, line) 257 + } 258 + if len(got) < 2 || got[0].StepStatus != StepStatusStart || 259 + got[len(got)-1].StepStatus != StepStatusEnd { 260 + t.Fatalf("log frames = %+v", got) 261 + } 262 + } 263 + 264 + func tektonStatusObject(condStatus, reason string) *unstructured.Unstructured { 265 + return &unstructured.Unstructured{Object: map[string]interface{}{ 266 + "status": map[string]interface{}{ 267 + "conditions": []interface{}{map[string]interface{}{ 268 + "type": "Succeeded", 269 + "status": condStatus, 270 + "reason": reason, 271 + }}, 272 + }, 273 + }} 274 + } 275 + 276 + func waitTektonRef(t *testing.T, st *store, knot, rkey, workflow string) *TektonRunRef { 277 + t.Helper() 278 + deadline := time.Now().Add(2 * time.Second) 279 + for time.Now().Before(deadline) { 280 + ref, err := st.LookupTektonRunByTuple(context.Background(), knot, rkey, workflow) 281 + if err != nil { 282 + t.Fatalf("lookup: %v", err) 283 + } 284 + if ref != nil { 285 + return ref 286 + } 287 + time.Sleep(20 * time.Millisecond) 288 + } 289 + t.Fatal("tekton run row not persisted within deadline") 290 + return nil 291 + }
+69
store.go
··· 526 526 PipelineURI string 527 527 } 528 528 529 + // TektonRunRef is the persisted link from a Tangled workflow tuple 530 + // to the in-cluster PipelineRun tack created for it. The tuple is the 531 + // user-facing identity the appview knows; namespace/name/uid are the 532 + // Kubernetes identity needed for status watching and log lookup. 533 + type TektonRunRef struct { 534 + Knot string 535 + PipelineRkey string 536 + Workflow string 537 + Namespace string 538 + PipelineRunName string 539 + PipelineRunUID string 540 + PipelineName string 541 + PipelineURI string 542 + } 543 + 529 544 // InsertBuildkiteBuild records that a Buildkite build was created on 530 545 // behalf of the given (knot, pipelineRkey, workflow) tuple. Uses 531 546 // INSERT OR REPLACE so that an unlikely build-uuid collision (or a ··· 624 639 } 625 640 if err != nil { 626 641 return nil, fmt.Errorf("lookup buildkite_build by tuple: %w", err) 642 + } 643 + return &ref, nil 644 + } 645 + 646 + // InsertTektonRun records the latest PipelineRun created for a Tangled 647 + // workflow tuple. Reusing the tuple as the primary key intentionally 648 + // makes /logs resolve to the newest run for that workflow identity. 649 + func (s *store) InsertTektonRun(ctx context.Context, ref TektonRunRef) error { 650 + now := time.Now().UTC() 651 + _, err := s.db.ExecContext(ctx, 652 + `INSERT INTO tekton_runs ( 653 + knot, pipeline_rkey, workflow, 654 + namespace, pipeline_run_name, pipeline_run_uid, 655 + pipeline_name, pipeline_uri, created_at, created_unix_ns 656 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 657 + ON CONFLICT(knot, pipeline_rkey, workflow) DO UPDATE SET 658 + namespace = excluded.namespace, 659 + pipeline_run_name = excluded.pipeline_run_name, 660 + pipeline_run_uid = excluded.pipeline_run_uid, 661 + pipeline_name = excluded.pipeline_name, 662 + pipeline_uri = excluded.pipeline_uri, 663 + created_at = excluded.created_at, 664 + created_unix_ns = excluded.created_unix_ns`, 665 + ref.Knot, ref.PipelineRkey, ref.Workflow, 666 + ref.Namespace, ref.PipelineRunName, ref.PipelineRunUID, 667 + ref.PipelineName, ref.PipelineURI, now.Format(time.RFC3339Nano), now.UnixNano(), 668 + ) 669 + if err != nil { 670 + return fmt.Errorf("insert tekton_run: %w", err) 671 + } 672 + return nil 673 + } 674 + 675 + // LookupTektonRunByTuple resolves the appview's path-based identity to 676 + // the concrete PipelineRun tack created in Kubernetes. 677 + func (s *store) LookupTektonRunByTuple(ctx context.Context, knot, pipelineRkey, workflow string) (*TektonRunRef, error) { 678 + var ref TektonRunRef 679 + err := s.db.QueryRowContext(ctx, 680 + `SELECT knot, pipeline_rkey, workflow, 681 + namespace, pipeline_run_name, pipeline_run_uid, 682 + pipeline_name, pipeline_uri 683 + FROM tekton_runs 684 + WHERE knot = ? AND pipeline_rkey = ? AND workflow = ?`, 685 + knot, pipelineRkey, workflow, 686 + ).Scan( 687 + &ref.Knot, &ref.PipelineRkey, &ref.Workflow, 688 + &ref.Namespace, &ref.PipelineRunName, &ref.PipelineRunUID, 689 + &ref.PipelineName, &ref.PipelineURI, 690 + ) 691 + if errors.Is(err, sql.ErrNoRows) { 692 + return nil, nil 693 + } 694 + if err != nil { 695 + return nil, fmt.Errorf("lookup tekton_run by tuple: %w", err) 627 696 } 628 697 return &ref, nil 629 698 }
+21
store_migrate.go
··· 121 121 ); 122 122 CREATE INDEX IF NOT EXISTS buildkite_builds_lookup 123 123 ON buildkite_builds (knot, pipeline_rkey, workflow); 124 + 125 + -- Mapping from a Tangled workflow tuple to the latest Tekton 126 + -- PipelineRun tack created for it. Unlike Buildkite webhooks, Tekton 127 + -- status observation happens in-process, so the primary read path is 128 + -- /logs resolving (knot, pipeline_rkey, workflow) back to the concrete 129 + -- PipelineRun whose TaskRuns and pods hold output. 130 + CREATE TABLE IF NOT EXISTS tekton_runs ( 131 + knot TEXT NOT NULL, 132 + pipeline_rkey TEXT NOT NULL, 133 + workflow TEXT NOT NULL, 134 + namespace TEXT NOT NULL, 135 + pipeline_run_name TEXT NOT NULL, 136 + pipeline_run_uid TEXT NOT NULL, 137 + pipeline_name TEXT NOT NULL, 138 + pipeline_uri TEXT NOT NULL, 139 + created_at TEXT NOT NULL, 140 + created_unix_ns INTEGER NOT NULL, 141 + PRIMARY KEY (knot, pipeline_rkey, workflow) 142 + ); 143 + CREATE INDEX IF NOT EXISTS tekton_runs_uid 144 + ON tekton_runs (pipeline_run_uid); 124 145 ` 125 146 126 147 // migrate applies the schema. Safe to call repeatedly.