-
Notifications
You must be signed in to change notification settings - Fork 10
ci: deploy + seed a test stand after the umbrella chart publish on main #2404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
64 commits
Select commit
Hold shift + click to select a range
7a3ada9
chore(studio): sync Constructor Studio config
1257fbe
feat(gitops): add the test-stand environment, CI emulation and deploy…
6071508
ci(stand): deploy, seed and smoke the published chart on the test stand
410427a
test(stand): add the post-deploy smoke gate for a deployed stand
6d043a0
ci(stand): do not inherit every repository secret into the deploy
0aca4e7
fix(stand): redact underscore-joined secret keys, and grant the cross…
ddfb903
ci(stand): let a dispatch run deploy and seed without the smoke crede…
edbcaef
docs(stand): say why the branch allow-list is the control, and that t…
78d1225
feat(gitops): re-sync the test-stand environment to the redeployed stand
54f3a14
fix(stand): recover the seed manifest from an interleaved log, and re…
f281958
fix(gitops): stop the persons-seed CronJob competing with the seeded …
6758a88
ci(stand): allow a dispatched reseed to override the foreign-rows guard
267538d
fix(seed): read the dev-lead address from the realm the stand applies
128ef21
ci(stand): drop the rehearsal harness, and let a dispatch take the ne…
4ffaf74
fix(stand): resolve the newest chart from the registry, not from a br…
25d41aa
fix(stand): seed a window the API can query, and let the caller rebui…
f894136
fix(tests/stand): skip an optional fixture instead of aborting, and s…
9240ddf
ci(stand): stop publishing cluster output, tell the truth in the summ…
fd6d7ae
ci(stand): deploy through `make deploy`, and let the environment decl…
0b39add
feat(gitops): an executable rebuild for the disposable stand, that dr…
5c483e7
fix(gitops): give each database client its own image, and stop a stra…
fa15e1d
fix(gitops): hand make the context the rebuild already validated
8ed5bfe
fix(gitops): ask the deploy's guards before the wipe, not between the…
39a1f5d
refactor(stand-ci): fold the stand tooling into gitops, one spelling …
32173d7
ci(stand): downgrades have no override — recovery is recreate, never …
4233607
ci(stand): let a manual dispatch omit the version and take the regist…
439df8c
feat(seed): let a stand set the persona password via INSIGHT_SEED_PER…
5c856e8
ci(stand): TEMPORARY debug trigger for the deploy pipeline — delete b…
86097a9
ci(stand): TEMPORARY branch exception in the main-only guard for the …
9db43bd
ci(stand): debug run marker 3
2f69c07
ci(stand): TEMPORARY manifest-capture fallback for the pre-merge debu…
8daa2de
ci(stand): fix the TEMPORARY manifest fallback — stop at the document…
dbdedfc
ci(stand): TEMPORARY fallback reads the completed Job's log at rest
b0b3ecd
ci(stand): TEMPORARY fallback filters interleaved stderr log lines
51ba45c
ci(stand): debug run marker 8
6a6bf38
test(stand): xfail the metric non-null check — seeded observations bi…
9250bc7
ci(stand): debug run marker 9 — full rerun after rebase on main
1601d77
docs(stand): comment diet — constraints stay, narration goes
4cfbe04
ci(stand): debug run marker 10 — seed + smoke the recreated stand
be32a18
docs+seed(stand): retire the dead login-mode machinery from the docs;…
47dc28d
ci(stand): the deploy leaves the build's concurrency slot; TEMPORARY …
717bd6b
security(stand): enumerate the CI deployer's grants instead of bindin…
0ac7cb9
Merge remote-tracking branch 'origin/main' into test-stand-ci
ee14b43
test(stand): give the /auth/me module its quality vector — main's gat…
4209972
Merge remote-tracking branch 'origin/main' into test-stand-ci
3593ad0
ci(stand): TEMPORARY debug trigger returns for a pre-merge CI run — d…
2738b62
ci(stand): TEMPORARY — debug run 12 executes the full stand suite
7fe2860
fix(stand): the blob rule no longer eats file paths
44fadc3
Revert "ci(stand): TEMPORARY — debug run 12 executes the full stand s…
8ec2e27
Revert "ci(stand): TEMPORARY debug trigger returns for a pre-merge CI…
c376a1e
docs(stand): move the CI credentials runbook next to the stand it doc…
6eac4bd
docs(stand): condense INFRA.md to a scannable reference (1193 -> 291 …
39aacec
docs(stand): condense README to the deploy walkthrough (484 -> 285 li…
81da0de
docs(stand): tighten the RBAC manifest comments
0475793
Merge remote-tracking branch 'origin/main' into test-stand-ci
fce64e1
chore(stand): clear the validation-sweep warnings
bc764b8
fix(stand): seed to a resolved stand — run the identity projection an…
8da7f87
ci(stand): TEMPORARY debug trigger for a pre-merge CI run — delete be…
51ad3b8
test(stand): un-xfail test_metric_results_200 — #2447 verified on the…
063e677
Revert "ci(stand): TEMPORARY debug trigger for a pre-merge CI run — d…
91346c3
Merge remote-tracking branch 'origin/main' into test-stand-ci
9adffd0
Reapply "ci(stand): TEMPORARY debug trigger for a pre-merge CI run — …
e6b7262
Revert "Reapply "ci(stand): TEMPORARY debug trigger for a pre-merge C…
f28d1f2
Merge remote-tracking branch 'origin/main' into test-stand-ci
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Fires after a main build finishes, OUTSIDE its concurrency slot: a slow | ||
| # stand deploy must never queue the next merge's image build or chart | ||
| # publish behind it (deploy-test-stand.yml has its own coalescing group). | ||
| # The version is resolved from the OCI registry's newest published tag — | ||
| # which IS the chart that run just published; the downgrade guard refuses | ||
| # anything older than what is deployed. | ||
| name: Deploy test stand after publish | ||
| on: | ||
| workflow_run: | ||
| workflows: ["Build & Push Container Images"] | ||
| types: [completed] | ||
| branches: [main] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| deploy: | ||
| # The whole run must be green: publish-chart sits at its end, so a red | ||
| # run either never published or published amid other failures — neither | ||
| # is a state to install. | ||
| if: github.event.workflow_run.conclusion == 'success' | ||
| permissions: | ||
| contents: read | ||
| uses: ./.github/workflows/deploy-test-stand.yml |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| #!/usr/bin/env python3 | ||
| """Mask credential-shaped text out of stand output before it reaches a public log. | ||
|
|
||
| This repository is PUBLIC, and so is every line a workflow prints; cluster and | ||
| seeder output goes through here rather than straight to the console. | ||
|
|
||
| **Fail closed, per line.** A line this filter cannot prove it cleaned becomes | ||
| `LINE_MARKER`, and the pass at the end of `_clean` re-scans the *result* rather | ||
| than trusting the substitutions; an exception aborts the stream, because a | ||
| truncated diagnostic is recoverable and a published secret is not. | ||
|
|
||
| No structure awareness: a secret split across lines, or shaped like an ordinary | ||
| word, survives — the workflow's own no-print discipline covers the rest. | ||
|
|
||
| Usage: | ||
| <producer> | redact-stand-log.py # stdin -> stdout, line at a time | ||
| redact-stand-log.py FILE [FILE ...] # named files -> stdout | ||
| redact-stand-log.py --max-line 400 FILE # tighter truncation | ||
|
|
||
| Exit 0 when the stream was cleaned, 1 when it aborted — pipe under `pipefail`. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import re | ||
| import sys | ||
| from collections.abc import Iterable, Iterator | ||
| from pathlib import Path | ||
|
|
||
| #: What an unrecoverable line becomes. Deliberately loud: a reader must be able | ||
| #: to tell "this line was removed" from "nothing was logged here". | ||
| LINE_MARKER = "[line withheld by CI — redaction could not be verified]" | ||
|
|
||
| #: Default ceiling on a single emitted line. Long enough for a helm error or a | ||
| #: Rust panic with a backtrace frame, short enough that a blob cannot ride out. | ||
| DEFAULT_MAX_LINE = 1000 | ||
|
|
||
| #: Placeholder standing in for an image digest while the long-blob rule runs. | ||
| #: Restored verbatim afterwards — see `_clean`. | ||
| _DIGEST_SLOT = "\x00digest{}\x00" | ||
|
|
||
| _URL_CREDENTIALS = re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.\-]*://)[^\s/@:]+:[^\s/@]+@") | ||
| _EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") | ||
| _JWT = re.compile(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}(?:\.[A-Za-z0-9_\-]+)?") | ||
| _BEARER = re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=\-]{8,}") | ||
| _SESSION_COOKIE = re.compile(r"(?i)(__Host-sid|__Secure-sid|sid)=[^\s;,\"']{8,}") | ||
| _PRIVATE_KEY_HEADER = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----") | ||
|
|
||
| #: Matched case-insensitively against the token left of a `:`/`=`. Boundary is | ||
| #: `(?<![A-Za-z0-9])`, not `\b`, which misses `MARIADB_PASSWORD=` — load-bearing. | ||
| _SECRET_KEY = re.compile( | ||
| r"(?i)(?<![A-Za-z0-9])([A-Za-z0-9_.\-]*(?:" | ||
| r"password|passwd|pwd|secret|secret[_\-]?key|client[_\-]?secret|" | ||
| r"token|access[_\-]?token|refresh[_\-]?token|id[_\-]?token|bearer[_\-]?token|" | ||
| r"api[_\-]?key|private[_\-]?key|signing[_\-]?key|" | ||
| r"client-key-data|client-certificate-data|certificate-authority-data" | ||
| r"))([\"']?\s*[:=]\s*)(?!\s*$)\S+" | ||
| ) | ||
|
|
||
| #: An image digest: exempt, because it answers "which image failed to pull". | ||
| _DIGEST = re.compile(r"\bsha(?:256|512):[0-9a-fA-F]{32,128}\b") | ||
|
|
||
| #: The catch-all. 40 chars is above every object name and image tag in this | ||
| #: stack, below every key and token; alphabet covers base64 and base64url. | ||
| _LONG_BLOB = re.compile(r"(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_-]{40,}={0,2}(?![A-Za-z0-9+/_=-])") | ||
|
|
||
| _IPV4 = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])") | ||
|
|
||
|
|
||
| def _blob_mask(match: re.Match[str]) -> str: | ||
| """Mask unless the token provably is not base64 of either variant. | ||
|
|
||
| Standard base64 never contains `_`/`-`; base64url never contains `/`/`+`. | ||
| A token mixing the two exclusive sets (a pytest path like | ||
| tests/stand/ui/test_logged_out_access_refused) is therefore not a blob. | ||
| """ | ||
| token = match.group(0) | ||
| if ("/" in token or "+" in token) and ("-" in token or "_" in token): | ||
| return token | ||
| return "[blob redacted]" | ||
|
|
||
|
|
||
| def _blob_survivor(text: str) -> bool: | ||
| """True when a maskable (non-exempt) blob is still present in `text`.""" | ||
| return any(_blob_mask(m) != m.group(0) for m in _LONG_BLOB.finditer(_DIGEST.sub("", text))) | ||
|
|
||
|
|
||
| def _email_slot(match: re.Match[str]) -> str: | ||
| """A stable one-way handle for one address. | ||
|
|
||
| Six hex characters: enough that two personas in the same log are told apart, | ||
| far too few to attack, and short enough to keep a table readable. | ||
| """ | ||
| digest = hashlib.sha256(match.group(0).lower().encode("utf-8")).hexdigest()[:6] | ||
| return f"[email:{digest}]" | ||
|
|
||
|
|
||
| def _clean(line: str, *, max_line: int) -> str: | ||
| """Return `line` with every credential-shaped run replaced. | ||
|
|
||
| Order is load-bearing. URLs go first so a DSN's password is masked as a URL | ||
| credential rather than half-caught by the email rule; digests are parked | ||
| before the long-blob sweep and restored after it; the verification pass runs | ||
| last, over the bytes that are actually about to be printed. | ||
| """ | ||
| if _PRIVATE_KEY_HEADER.search(line): | ||
| # The header names the key type and nothing else useful, and the body | ||
| # is caught by the blob rule. A PEM in a CI log is never a diagnostic. | ||
| return "[private key material withheld by CI]" | ||
|
|
||
| out = _URL_CREDENTIALS.sub(r"\g<scheme>[credentials redacted]@", line) | ||
| out = _JWT.sub("[jwt redacted]", out) | ||
| out = _BEARER.sub(r"\1 [redacted]", out) | ||
| out = _SESSION_COOKIE.sub(r"\1=[redacted]", out) | ||
| out = _SECRET_KEY.sub(r"\1\2[redacted]", out) | ||
| out = _EMAIL.sub(_email_slot, out) | ||
|
|
||
| digests: list[str] = [] | ||
|
|
||
| def _park(match: re.Match[str]) -> str: | ||
| digests.append(match.group(0)) | ||
| return _DIGEST_SLOT.format(len(digests) - 1) | ||
|
|
||
| out = _DIGEST.sub(_park, out) | ||
| out = _LONG_BLOB.sub(_blob_mask, out) | ||
| for index, digest in enumerate(digests): | ||
| out = out.replace(_DIGEST_SLOT.format(index), digest) | ||
|
|
||
| out = _IPV4.sub("[ip redacted]", out) | ||
|
|
||
| if len(out) > max_line: | ||
| out = out[:max_line] + f" …[truncated at {max_line} chars by CI]" | ||
|
|
||
| # The result, not the input: anything credential-shaped that survived means | ||
| # a rule has a hole. Digests stripped first so an exempt one cannot trip it. | ||
| if _EMAIL.search(out) or _JWT.search(out) or _PRIVATE_KEY_HEADER.search(out) or _blob_survivor(out): | ||
| return LINE_MARKER | ||
| return out | ||
|
|
||
|
|
||
| def _stream(lines: Iterable[str], *, max_line: int) -> Iterator[str]: | ||
| for line in lines: | ||
| yield _clean(line.rstrip("\n"), max_line=max_line) | ||
|
|
||
|
|
||
| def main(argv: list[str]) -> int: | ||
| args = argv[1:] | ||
| max_line = DEFAULT_MAX_LINE | ||
| if args[:1] == ["--max-line"]: | ||
| if len(args) < 2 or not args[1].isdigit() or int(args[1]) < 80: | ||
| print("--max-line needs a whole number of at least 80", file=sys.stderr) # noqa: T201 | ||
| return 2 | ||
| max_line = int(args[1]) | ||
| args = args[2:] | ||
|
|
||
| try: | ||
| if not args: | ||
| for cleaned in _stream(sys.stdin, max_line=max_line): | ||
| print(cleaned, flush=True) # noqa: T201 | ||
| return 0 | ||
| for path in args: | ||
| with Path(path).open(encoding="utf-8", errors="replace") as handle: | ||
| for cleaned in _stream(handle, max_line=max_line): | ||
| print(cleaned, flush=True) # noqa: T201 | ||
| except BrokenPipeError: | ||
| # The consumer went away (a `head`, or a cancelled step). Not a | ||
| # redaction failure, and not worth a red X. | ||
| return 0 | ||
| except Exception as exc: # noqa: BLE001 — any failure means "stop printing" | ||
| # Whatever was emitted before this point was cleaned; what follows was | ||
| # not, so nothing follows. | ||
| print(f"::error::redaction aborted, output truncated: {type(exc).__name__}: {exc}", file=sys.stderr) # noqa: T201 | ||
| return 1 | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main(sys.argv)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,9 @@ | ||
| <!-- @cf:root-agents --> | ||
| ```toml | ||
| cf-studio-path = ".cf-studio" | ||
| ``` | ||
|
|
||
| ALWAYS resolve and enforce prerequisites of skills/workflows/commands BEFORE applying user intent. | ||
| <!-- /@cf:root-agents --> | ||
|
|
||
| @AGENTS.md |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
Truncate the input before the rules run, not after.
Line 132 applies
max_lineto the output. Every regex at lines 107-130 therefore scans the full input line first. A pod log or a helm error can carry a single line of many megabytes, and_SECRET_KEYat lines 52-59 backtracks over[A-Za-z0-9_.\-]*at each start position, so scan cost grows faster than line length. The step then stalls inside the redactor with no output.Cap the input first. The masking result stays the same for anything that fits, and the truncation notice still tells the reader that content was dropped.
♻️ Proposed change
if _PRIVATE_KEY_HEADER.search(line): # The header names the key type and nothing else useful, and the body # is caught by the blob rule. A PEM in a CI log is never a diagnostic. return "[private key material withheld by CI]" - out = _URL_CREDENTIALS.sub(r"\g<scheme>[credentials redacted]@", line) + truncated = len(line) > max_line + out = _URL_CREDENTIALS.sub(r"\g<scheme>[credentials redacted]@", line[:max_line]) @@ - if len(out) > max_line: + if truncated or len(out) > max_line: out = out[:max_line] + f" …[truncated at {max_line} chars by CI]"🤖 Prompt for AI Agents