-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTaskfile.yml
More file actions
419 lines (382 loc) · 20.7 KB
/
Copy pathTaskfile.yml
File metadata and controls
419 lines (382 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
version: "3"
# Everything in this repository is driven by `task`.
# `task` with no argument lists what there is; `task verify` is the whole gate.
#
# # A cache that lies is worse than no cache
#
# Task skips work by CHECKSUMMING `sources`. That says nothing about whether the outputs still exist.
# Two real failures came out of that gap: `rm dist/krm-stream.js && task build-client` reported "up to
# date" and rebuilt nothing, and `npm install` silently pruned a devDependency when run from a branch
# that predated it, so a later build died on `sh: 1: esbuild: not found`. Both are the CI bug this all
# started from: a step reporting success about a thing it never looked at. Hence:
#
# - Every cache-worthy task delivers a file, named individually in `generates`, never a glob.
# - Every such task also carries a `status:` that probes the filesystem. That is the check a
# checksum cannot do, and it makes a deleted artifact or a pruned dependency self-heal.
# - `npm ci`, never `npm install`: the tree is then a pure function of two committed files.
# - One definition of a thing. CI runs the same `task pack-client` a developer does.
#
# Go's build/test cache is deliberately left alone. It is content-addressed and sound, so a cached
# `ok` means those exact inputs really did pass. `task clean` does not touch it.
run: once
vars:
CLIENT_DIR: packages/krm-stream
REPLAY_ADDR: 127.0.0.1:8099
# A throwaway cluster for proving the gateway against a real API server.
# (The unit suites need no cluster at all — see `task test`.)
SPIKE_CLUSTER: krm-stream-spike
# Kubernetes 1.35+ is a REQUIREMENT of this library, not a preference: from 1.35, resourceVersion
# orderability is a Certified Kubernetes conformance requirement, and the gateway relies on it to
# promise consumers per-object monotonicity. So the cluster we verify against must be one.
K3S_IMAGE: rancher/k3s:v1.36.2-k3s1
KUBECONFIG_PATH: '{{.HOME}}/.kube/config'
tasks:
default:
desc: "List the available tasks."
cmds:
- task --list
silent: true
# ---------------------------------------------------------------- fixtures --
fixtures:
desc: "Regenerate conformance/gen/ — the JSON both suites read, and the SSE both suites agree on."
sources:
- conformance/bodies/*.yaml
- conformance/fixtures/*.yaml
- conformance/scopes.yaml
- conformance/generate.sh
- gateway/*.go
# The generator's own command line lives in this file, so it is a source too: changing WHICH
# golden tests run, and then being told "up to date", is a trap I walked into writing this line.
- Taskfile.yml
generates:
- conformance/gen/*.json
- conformance/gen/sse/*.sse
cmds:
- bash conformance/generate.sh
# The SSE goldens: the bytes the gateway REALLY writes, through its real sink. They are what
# closes the seam — the TypeScript suite parses these exact files back and replays them into a
# store, so "the client can read what the gateway wrote" is a test and not a hope.
#
# They are generated by the gateway rather than by generate.sh precisely BECAUSE the gateway is
# the thing under test: a transcript hand-written next to the implementation would agree with it
# by construction and prove nothing.
# …and gen/protocol.json, written by the Go const that DEFINES the protocol version, so the
# TypeScript suite can assert it rather than keeping its own hand-copied number in step.
- cd gateway && go test -count=1 -run 'TestSSEGoldens|TestProtocolVersionIsPublished' -update ./...
fixtures-check:
desc: "Fail if conformance/gen/ is stale — the YAML is the source of truth, the JSON is built."
deps: [fixtures]
cmds:
- git diff --exit-code -- conformance/gen || (echo "conformance/gen is stale — run 'task fixtures' and commit" && exit 1)
# ------------------------------------------------------------------ verify --
verify:
desc: "The whole gate, in the order CI runs it. If this passes, CI passes."
cmds:
# Sequential on purpose: the first failure is the one you want to read, not the fourth.
- task: fixtures-check
- task: lint
- task: test
- task: e2e-wire
- task: e2e-browser
# Last, because nothing else proves it: what we upload contains what the exports map advertises.
- task: pack-client
# -------------------------------------------------------------------- test --
test:
desc: "Run both suites against the shared conformance fixtures."
deps: [test-gateway, test-kube, test-client]
test-gateway:
desc: "Go: the gateway + its half of the conformance suite."
deps: [fixtures]
dir: gateway
cmds:
# -race, and it stays: SharedBackend fans one upstream watch out to N subscribers, so the
# gateway now has a pump goroutine, a per-scope cache and N bounded queues. A data race in
# THAT is a browser being shown another tenant's object, not a flaky test.
- go test -race ./...
# A SEPARATE MODULE, so `go test ./...` in gateway/ does not reach it — which means that until it
# was listed here, nothing tested it at all. The Kubernetes adapter is opt-in for adopters; it is
# not optional for us.
test-kube:
desc: "Go: the Kubernetes adapter (no cluster — see test-cluster for the real thing)."
dir: gateway/kube
cmds:
- go test ./...
test-client:
desc: "TypeScript: the client + its half of the conformance suite (node --test, no deps)."
deps: [fixtures, _client-deps]
dir: "{{.CLIENT_DIR}}"
cmds:
- node --test
# --------------------------------------------------------------------- e2e --
replay:
desc: "Serve the conformance corpus over real SSE — a cluster you can point a browser at."
dir: gateway
cmds:
- go run ./cmd/replay --addr {{.REPLAY_ADDR}} {{.CLI_ARGS}}
e2e-wire:
desc: "End to end, no cluster: the real Go gateway over a real socket, into the real TS store."
deps: [fixtures]
cmds:
# Two processes, two languages, one contract. Everything else in the suite feeds the store bytes
# that never left the process — which cannot prove that the server flushes, that the frames
# survive arbitrary chunk boundaries, or that a terminal error really closes the connection.
- |
set -e
log="$(mktemp -t krm-replay-XXXX.log)"
# BUILD, then run the binary — do not `go run` it. `go run` execs the real program as a
# CHILD, so killing the pid we know about leaves the server holding the port, and the next
# run of this task fails with a confusing "address already in use".
#
# And redirect its output to a file rather than inheriting ours: a background process holding
# our stdout open makes `task` (and any pipe we are in) wait for it forever, which looks
# exactly like a hung test and is not one.
bin="$(mktemp -t krm-replay-XXXX)"
(cd gateway && go build -o "$bin" ./cmd/replay)
"$bin" --addr {{.REPLAY_ADDR}} --corpus conformance >"$log" 2>&1 &
pid=$!
trap 'kill $pid 2>/dev/null || true; rm -f "$bin"' EXIT
for _ in $(seq 50); do
curl -sf http://{{.REPLAY_ADDR}}/healthz >/dev/null && break || sleep 0.2
done
curl -sf http://{{.REPLAY_ADDR}}/healthz >/dev/null || { echo "replay server never came up:"; cat "$log"; exit 1; }
cd {{.CLIENT_DIR}} && REPLAY_URL=http://{{.REPLAY_ADDR}} node e2e/wire.ts
e2e-browser:
desc: "End to end in a REAL browser: native EventSource, unbundled ESM AND the bundle, no cluster."
# build-client, because playwright's webServer rebuilds the library from packages/krm-stream —
# and it cannot do that with no node_modules there. On a developer's machine that directory is
# already populated by `task test`, which is exactly why this gap survived until the first CI run
# on a clean checkout found it.
deps: [fixtures, build-client, _example-deps]
dir: examples/vanilla-browser
cmds:
# The only place the library's central promise is actually tested: that the published ESM
# imports in a browser with no bundler. Node importing it proves nothing — Node is not a browser.
# Playwright lives in the EXAMPLE's package, so the library keeps its four devDependencies.
# Runs the suite twice, once per entry point (per-module and ./bundle).
- npx --no-install playwright install chromium --with-deps
- npx --no-install playwright test {{.CLI_ARGS}}
demo:
desc: "Serve the browser demo — watch a status reconcile while you edit spec. No cluster."
deps: [build-client]
cmds:
- |
echo "→ http://127.0.0.1:8100/?fixture=status-follow-live&pace=800ms"
echo " (also: conflict-and-converge, secret-redaction, edit-vs-unrelated-change — /fixtures lists them)"
cd gateway && go run ./cmd/replay --addr 127.0.0.1:8100 --corpus ../conformance \
--static ../examples/vanilla-browser --dist ../packages/krm-stream/dist
# -------------------------------------------------------------------- lint --
lint:
desc: "Vet + lint everything: both languages, the workflows, and the Dockerfile."
deps: [lint-gateway, lint-kube, lint-client, lint-actions, lint-dockerfiles]
# A workflow YAML and a Dockerfile were the only files here with no linter, and a workflow is where
# the bundle-that-was-never-built bug lived. actionlint also shellchecks every `run:` block.
lint-actions:
desc: "Lint the GitHub Actions workflows (actionlint: expressions, needs/runs-on, shellcheck on every run: block)."
sources:
- .github/workflows/*.yml
- .github/workflows/*.yaml
cmds:
# No path argument: actionlint discovers every workflow itself, so a new one is linted the day it
# lands rather than the day someone remembers to list it.
- actionlint
lint-dockerfiles:
desc: "Lint the devcontainer Dockerfile (hadolint)."
sources:
- .devcontainer/Dockerfile
- .hadolint.yaml
cmds:
- hadolint .devcontainer/Dockerfile
lint-gateway:
dir: gateway
cmds:
- go vet ./...
- golangci-lint run ./...
lint-kube:
dir: gateway/kube
cmds:
# --build-tags e2e, or the real-cluster suite is the one Go file in the repo nobody vets.
- go vet -tags e2e ./...
- golangci-lint run --build-tags e2e ./...
lint-client:
deps: [_client-deps]
dir: "{{.CLIENT_DIR}}"
cmds:
# Two tsconfigs, on purpose. The first is the BUILD (src only — its rootDir is what ships);
# the second is the only thing that ever typechecks test/, because `node --test` STRIPS types
# rather than checking them. Without it the conformance suite — the file whose whole job is to
# be the contract check — would be the one piece of unverified TypeScript in the repo.
- npx --no-install tsc --noEmit
- npx --no-install tsc --noEmit -p tsconfig.test.json
# biome = the TypeScript half of gofmt + go vet + golangci-lint. `task fmt-client` fixes.
- npx --no-install biome check
fmt-client:
desc: "Format and auto-fix the TypeScript (the gofmt of this side)."
deps: [_client-deps]
dir: "{{.CLIENT_DIR}}"
cmds:
- npx --no-install biome check --write
# -------------------------------------------------------------------- deps --
# node_modules is shared mutable state across git branches and nothing in git tracks it.
# `npm install` reconciles it to the CURRENT branch's package.json, which means it prunes: run it
# from a branch predating a devDependency and that dependency is gone, leaving a later build to die
# on `sh: 1: esbuild: not found`. `npm ci` installs exactly the lockfile instead, so the tree cannot
# drift from the branch you are on. `generates` is npm's own record of what it installed.
_client-deps:
internal: true
dir: "{{.CLIENT_DIR}}"
sources: ["package.json", "package-lock.json"]
generates: ["node_modules/.package-lock.json"]
status:
# The checksum never looks at the filesystem, so without this an `rm -rf node_modules` is
# invisible to Task and the next build fails on a missing binary.
- test -x node_modules/.bin/tsc
- test -x node_modules/.bin/esbuild
- test -x node_modules/.bin/biome
cmds:
- npm ci --no-audit --no-fund
_example-deps:
internal: true
dir: examples/vanilla-browser
sources: ["package.json", "package-lock.json"]
generates: ["node_modules/.package-lock.json"]
status:
- test -x node_modules/.bin/playwright
cmds:
- npm ci --no-audit --no-fund
# ------------------------------------------------------------------- build --
build-client:
desc: "Emit the dependency-free ESM a browser can <script type=module> import — per-module, and flattened."
deps: [_client-deps]
dir: "{{.CLIENT_DIR}}"
sources: ["src/**/*.ts", "tsconfig.json", "package.json"]
# Named individually, not as a `dist/**/*.js` glob. A glob is only a checksum key, and Task never
# asks whether the files exist: `rm dist/krm-stream.js && task build-client` used to say "up to
# date" and rebuild nothing.
generates:
- dist/index.js
- dist/krm-stream.js
status:
# The existence check a checksum cannot do.
- test -f dist/index.js
- test -f dist/krm-stream.js
cmds:
# Two artifacts, both published: dist/index.js keeps its relative imports (tree-shakeable, for
# bundlers), and dist/krm-stream.js is the same API flattened into one file, for a host that
# vendors the library with no bundler.
#
# `npm run build`, not `npx tsc`: one definition of the build, shared with package.json and CI.
# A hand-copied `npx tsc` kept doing half of it once esbuild was added, and CI packed a tarball
# whose exports map pointed at a file that was not in it.
- npm run --silent build
pack-client:
desc: "Pack the npm tarball and PROVE it contains every entry point the exports map advertises."
deps: [build-client]
vars:
# CI passes PACK_DIR=/tmp/npm and uploads what lands there; release.yml publishes those bytes
# without rebuilding.
PACK_DIR: '{{.PACK_DIR | default "/tmp/krm-stream-pack"}}'
cmds:
# The gap between "the build works" and "the thing we upload contains what we said it does".
# Every other rung builds its own dist and never looks at the tarball, so a missing ./bundle
# packs green, publishes green, and fails for the first consumer who imports it. CI runs this
# same task, so the local gate and the release gate cannot drift.
- |
set -euo pipefail
mkdir -p "{{.PACK_DIR}}"
rm -f "{{.PACK_DIR}}"/*.tgz
npm pack --pack-destination "{{.PACK_DIR}}" ./{{.CLIENT_DIR}} >/dev/null
tarball="$(ls "{{.PACK_DIR}}"/*.tgz)"
# List once into a variable. `tar | grep -q` under pipefail reports failure on a MATCH: grep
# exits early, tar takes SIGPIPE, and the pipeline inherits tar's status. This guard failed a
# perfectly good tarball that way on its first run.
listing="$(tar -tzf "$tarball")"
for entry in package/dist/index.js package/dist/krm-stream.js; do
if ! grep -qxF "$entry" <<<"$listing"; then
echo "✗ ${entry#package/} is in the exports map but NOT in the tarball. Publishing this ships a broken import."
echo "$listing"
exit 1
fi
done
echo "✓ every entry point in the exports map is present in $(basename "$tarball")."
# ------------------------------------------------------------------- clean --
# The escape hatch for when a cache lies to you. It should be rare: the `status:` guards above exist
# so the two failures that used to need it (a pruned node_modules, a half-deleted dist/) self-heal.
clean:
desc: "Drop every local cache: Task's fingerprints, node_modules, dist. Next run is cold."
cmds:
# .task/ is Task's checksum store: what makes a task say "up to date". Remove it when one is wrong.
- rm -rf .task
- rm -rf {{.CLIENT_DIR}}/node_modules {{.CLIENT_DIR}}/dist
- rm -rf examples/vanilla-browser/node_modules
# Go's test cache is content-addressed and sound: a cached `ok` means those inputs really did
# pass. Use `go test -count=1` to watch a suite run; `go clean -cache` buys nothing but minutes.
- echo "clean. (Go's test cache is deliberately untouched.)"
# ---------------------------------------------------------------- cluster --
# The one rung a fake watch cannot reach. Everything the gateway BELIEVES about Kubernetes — that a
# streaming list ends with an `initial-events-end` bookmark, that a 410 arrives as a watch error,
# that resourceVersions are orderable decimals — is unverified until something asks a real API
# server. See docs/proposals/0002-real-cluster.md.
cluster-up:
desc: "A real Kubernetes (k3d, {{.K3S_IMAGE}}) — with real etcd, because that is what we are verifying."
status:
- k3d cluster list {{.SPIKE_CLUSTER}} >/dev/null 2>&1
cmds:
# --cluster-init gives k3s its EMBEDDED ETCD. Without it k3s stores in kine (SQLite), and then
# `resourceVersion` and compaction/410 semantics would be kine's rather than etcd's — which are
# exactly the two things this rung exists to check. Verifying them against a substitute storage
# layer would be worse than not verifying them at all.
- k3d cluster create {{.SPIKE_CLUSTER}} --image {{.K3S_IMAGE}} --k3s-arg "--cluster-init@server:0" --wait
- task: cluster-kubeconfig
- kubectl --context k3d-{{.SPIKE_CLUSTER}} get --raw /healthz
- kubectl --context k3d-{{.SPIKE_CLUSTER}} version -o json | jq -r '.serverVersion.gitVersion'
cluster-kubeconfig:
desc: "Point the kubeconfig at the cluster in a way that works from INSIDE the devcontainer."
cmds:
# Docker-outside-Docker. k3d publishes the API server on the HOST's docker daemon, and writes a
# kubeconfig saying `https://0.0.0.0:<port>` — which is the host's address, and means nothing in
# here. From this container the host is `host.docker.internal`.
#
# But the API server's certificate has no SAN for that name (it has `localhost`, `127.0.0.1`,
# `0.0.0.0` and the k3d container IPs), so simply rewriting the host fails TLS verification.
# `--tls-server-name` is the honest fix: connect to the host, verify the name the cert actually
# carries. The alternative — turning off certificate verification — would be trading a real check
# for a convenient one, in a repo whose entire point is not doing that.
- |
set -e
port="$(docker port k3d-{{.SPIKE_CLUSTER}}-serverlb 6443/tcp | head -1 | sed 's/.*://')"
kubectl config set-cluster k3d-{{.SPIKE_CLUSTER}} \
--server "https://host.docker.internal:${port}" --tls-server-name localhost >/dev/null
echo "kubeconfig → https://host.docker.internal:${port} (tls-server-name: localhost)"
cluster-down:
desc: "Delete the throwaway cluster."
cmds:
- k3d cluster delete {{.SPIKE_CLUSTER}}
cluster-aggregated-api:
desc: "Install Kubernetes' own sample-apiserver (wardle Flunders) as a REAL aggregated API."
deps: [cluster-up]
cmds:
# The one upstream whose behaviour Kubernetes' conformance requirements do NOT cover — so the only
# place OrderingLenient could be needed, and (as it turns out) the only place the gateway's
# streaming-list assumption actually breaks. See docs/facts/observed-*.md, F6.
- kubectl apply -f test/cluster/sample-apiserver/sample-apiserver.yaml
- kubectl -n wardle rollout status deploy/wardle-server --timeout=180s
- kubectl wait --for=condition=Available apiservice/v1alpha1.wardle.example.com --timeout=120s
cluster-facts:
desc: "Ask a REAL API server the questions the docs do not answer. Writes docs/facts/observed-*.md."
deps: [cluster-up, cluster-aggregated-api]
dir: gateway/kube
cmds:
- go run ./cmd/facts --kubeconfig {{.KUBECONFIG_PATH}} --out ../../docs/facts
# The backend, against the real thing. The unit suite stubs the API server — and a stub is a thing we
# wrote, so it agrees with us. This drives the REAL stream loop over the REAL backend against a REAL
# API server, on BOTH paths: the streaming list against kube-apiserver, and list-then-watch against
# the aggregated API that refuses it (F6). It does not run in per-PR CI: it needs Docker-in-Docker
# and it takes minutes, while the fixtures gate every PR in milliseconds.
test-cluster:
desc: "The kube backend against a real API server on both watch paths."
deps: [cluster-up, cluster-aggregated-api]
dir: gateway/kube
env:
KUBECONFIG: '{{.KUBECONFIG_PATH}}'
cmds:
- go test -tags e2e -v -count=1 -timeout 10m ./...