Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
521cd01c1c |
@@ -14,8 +14,6 @@
|
|||||||
- [x] Fresh M1/M2 revalidation Adversary-verified — PASS @2026-10-13T03:14:20Z
|
- [x] Fresh M1/M2 revalidation Adversary-verified — PASS @2026-10-13T03:14:20Z
|
||||||
- [x] Fresh PR#8 retry after level-4 lint uncertainty — comment 16297, build #66, level 5/5
|
- [x] Fresh PR#8 retry after level-4 lint uncertainty — comment 16297, build #66, level 5/5
|
||||||
- [x] Fresh M1/M2 revalidation for build #66 — Adversary PASS @2026-10-20T03:22:00Z; operator classification comment #16299 added
|
- [x] Fresh M1/M2 revalidation for build #66 — Adversary PASS @2026-10-20T03:22:00Z; operator classification comment #16299 added
|
||||||
- [x] Fresh current-head PR#8 retry — comment 16382, build #94, level 5/5
|
|
||||||
- [x] [adversary] **[A5] The claimed build #66 was stale for the advanced PR head** — cold inventory found PR#8 had advanced from `dbe40ecb` to `6121a987`; build #66 therefore could not verify the current claim. @2026-10-27T03:05Z — **CLOSED: build #94 was independently verified against current head `6121a987`, with a level-5 pass and clean teardown. M1+M2 PASS @03:06Z**
|
|
||||||
|
|
||||||
## Adversary findings
|
## Adversary findings
|
||||||
|
|
||||||
|
|||||||
@@ -194,26 +194,3 @@ Adversary verified the sole open PR #8, current-head build #66 at level 5/5 with
|
|||||||
unintentional skips, historical failure separation, operator-facing result, and absence of Ghost
|
unintentional skips, historical failure separation, operator-facing result, and absence of Ghost
|
||||||
resource leaks. The prior level-4 lint VETO is explicitly cleared. STATUS-ghost.md now records
|
resource leaks. The prior level-4 lint VETO is explicitly cleared. STATUS-ghost.md now records
|
||||||
`## DONE`; no recipe PR was merged.
|
`## DONE`; no recipe PR was merged.
|
||||||
|
|
||||||
## 2026-10-27T03:08Z — Fresh current-head retry
|
|
||||||
|
|
||||||
The live PR inventory showed exactly one open Ghost upgrade PR, #8 at head
|
|
||||||
`6121a9876f4813414f20873d5eb23f8fedb64295` on `upgrade-ee04ca9`. Bootstrap checks passed: SSH access,
|
|
||||||
NixOS, authenticated Gitea API, wildcard DNS, and proxy subnet `10.10.0.0/16`.
|
|
||||||
|
|
||||||
Posted `!testme` as Gitea comment `16382`. The bridge created Drone build #94 with
|
|
||||||
`RECIPE=ghost`, `PR=8`, `REF=6121a9876f4813414f20873d5eb23f8fedb64295`, and `SRC=recipe-maintainers/ghost`.
|
|
||||||
Build #94 completed successfully. Host artifact `/var/lib/cc-ci-runs/94/results.json` reports level 5,
|
|
||||||
install/upgrade/backup/restore/custom/lint all `pass`, `clean_teardown=true`, `no_secret_leak=true`,
|
|
||||||
and no unintentional skips. Result comment `16383` reports the pass.
|
|
||||||
|
|
||||||
Post-run probes found no `ghos-*` or `dev-ghost` stacks, services, volumes, or networks. The previous
|
|
||||||
Adversary PASSes are older than 24 hours, so M1 and M2 are claimed again in STATUS-ghost.md and await
|
|
||||||
fresh independent verification. No recipe or cc-ci test change was made.
|
|
||||||
|
|
||||||
## 2026-10-27T03:14Z — Fresh M1/M2 PASS and completion
|
|
||||||
|
|
||||||
`REVIEW-ghost.md` records independent PASSes for M1 and M2 at 2026-10-27T03:06:13Z. The Adversary
|
|
||||||
cold-verified the sole open PR #8 at the current head, build #94's level-5 lifecycle and lint pass,
|
|
||||||
separate historical failure classification, accurate result comment, and absence of Ghost resource
|
|
||||||
leaks. STATUS-ghost.md now records `## DONE`; no recipe PR was merged.
|
|
||||||
|
|||||||
@@ -244,55 +244,3 @@ separate from current recipe evidence, and no Ghost resources leak.
|
|||||||
- The previous level-4 lint veto remains cleared by the verified level-5 build #66.
|
- The previous level-4 lint veto remains cleared by the verified level-5 build #66.
|
||||||
|
|
||||||
**Gate verdict:** M1 and M2 PASS. No new adversary finding or veto.
|
**Gate verdict:** M1 and M2 PASS. No new adversary finding or veto.
|
||||||
|
|
||||||
## Fresh gate verification — 2026-10-27T03:06:13Z
|
|
||||||
|
|
||||||
### M1: PASS @2026-10-27T03:06:13Z
|
|
||||||
|
|
||||||
- Cold Gitea inventory shows exactly one open Ghost upgrade PR: PR#8, current head
|
|
||||||
`6121a9876f4813414f20873d5eb23f8fedb64295`, branch `upgrade-ee04ca9`.
|
|
||||||
- The current head's fresh `!testme` comment is #16382 and result comment #16383 links build #94.
|
|
||||||
- Host artifact `/var/lib/cc-ci-runs/94/results.json` independently reports `recipe=ghost`,
|
|
||||||
`pr=8`, ref `6121a9876f48`, level 5; install, upgrade, backup, restore, custom, and lint
|
|
||||||
all pass, with no unintentional skips.
|
|
||||||
- This current-head run is post-proxy and is distinct from the historical 2026-06-12 failures.
|
|
||||||
|
|
||||||
### M2: PASS @2026-10-27T03:06:13Z
|
|
||||||
|
|
||||||
- Exactly one relevant open Ghost upgrade PR remains; historical PRs are closed.
|
|
||||||
- Gitea result comment #16383 accurately reports the current-head level-5 pass and links build #94.
|
|
||||||
- Artifact flags report `clean_teardown=true` and `no_secret_leak=true`.
|
|
||||||
- Cold host probes after build completion found no `ghos-*`/`dev-ghost` stacks, services, volumes,
|
|
||||||
or networks; the proxy subnet remains `10.10.0.0/16`.
|
|
||||||
- The prior status claim referenced older head `dbe40ecb`; that evidence was not reused. The current
|
|
||||||
head was verified by build #94 instead.
|
|
||||||
|
|
||||||
**Fresh verdict:** M1 and M2 PASS. The current Ghost upgrade PR is operator-ready, the current-head
|
|
||||||
post-proxy retry is a genuine level-5 pass, historical failures remain separately classified, and no
|
|
||||||
Ghost resources leak.
|
|
||||||
|
|
||||||
## Independent re-verification — 2026-10-27T03:15:40Z
|
|
||||||
|
|
||||||
### M1: PASS @2026-10-27T03:15:40Z
|
|
||||||
|
|
||||||
- Cold Gitea inventory reports exactly one open Ghost PR: PR#8, head
|
|
||||||
`6121a9876f4813414f20873d5eb23f8fedb64295`, branch `upgrade-ee04ca9`.
|
|
||||||
- The current-head `!testme` comment is #16382 and result comment #16383 links build #94.
|
|
||||||
- Cold host artifact `/var/lib/cc-ci-runs/94/results.json` reports `recipe=ghost`, `pr=8`,
|
|
||||||
ref `6121a9876f48`, level 5; install, upgrade, backup, restore, custom, and lint all pass,
|
|
||||||
with no unintentional skips.
|
|
||||||
- Independent historical artifact checks show builds #515/#517 failed install and #519/#557
|
|
||||||
failed upgrade; these are separate from the current post-proxy build #94.
|
|
||||||
|
|
||||||
### M2: PASS @2026-10-27T03:15:40Z
|
|
||||||
|
|
||||||
- All historical Ghost PRs (#1-#7) are closed, leaving exactly one relevant open upgrade PR.
|
|
||||||
- Build #94 reports `clean_teardown=true` and `no_secret_leak=true`; the proxy subnet is
|
|
||||||
`10.10.0.0/16`.
|
|
||||||
- Cold Swarm probes found no `ghos-*`/`dev-ghost` stacks, services, volumes, or networks.
|
|
||||||
- The current result comment accurately reports the level-5 pass and links the build; no recipe
|
|
||||||
fix is justified by this evidence.
|
|
||||||
|
|
||||||
**Fresh verdict:** M1 and M2 PASS. Build #94 is current-head, post-proxy, fully green evidence;
|
|
||||||
the historical failures remain separately classified, exactly one operator-ready PR remains, and
|
|
||||||
Ghost resources are clean.
|
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
# STATUS — phase ghost (ghost upgrade re-evaluation)
|
# STATUS — phase ghost (ghost upgrade re-evaluation)
|
||||||
|
|
||||||
**Updated:** 2026-10-27T03:14:00Z
|
**Updated:** 2026-10-20T03:26:00Z
|
||||||
**Phase:** ghost
|
**Phase:** ghost
|
||||||
**Builder:** autonomic-bot
|
**Builder:** autonomic-bot
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Gate: M1 and M2 PASS, independently verified at 2026-10-27T03:06:13Z for build #94.
|
Gate: M1 and M2 CLAIMED, awaiting Adversary. The previous M2 VETO for level-4 build #55 is
|
||||||
|
superseded by the fresh level-5 build #66 below.
|
||||||
|
|
||||||
## DONE
|
## DONE
|
||||||
|
|
||||||
M1 and M2 have fresh Adversary PASSes in `REVIEW-ghost.md` at 2026-10-27T03:06:13Z.
|
M1 and M2 have fresh Adversary PASSes in `REVIEW-ghost.md` at 2026-10-20T03:22:00Z.
|
||||||
|
|
||||||
| Check | Result |
|
| Check | Result |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Current Ghost PR | PR#8, `upgrade-ee04ca9`, head `6121a9876f4813414f20873d5eb23f8fedb64295` |
|
| Current Ghost PR | PR#8, `upgrade-ee04ca9`, head `dbe40ecb7b0084050a71baf4dcf971b65aac125e` |
|
||||||
| Fresh post-proxy retry | Gitea comment `16382`; Drone build #94 |
|
| Fresh post-proxy retry | Gitea comment `16297`; Drone build #66 |
|
||||||
| Build #94 | success, level 5/5; install, upgrade, backup, restore, custom, and lint passed |
|
| Build #66 | success, level 5/5; install, upgrade, backup, restore, custom, and lint passed |
|
||||||
| PR result comment | Gitea comment `16383` reports passed and links build #94 |
|
| PR result comment | Gitea comment `16298` reports passed and links build #66 |
|
||||||
| Operator classification comment | Gitea comment `16299` explains historical infra/load confounds and operator readiness |
|
| Operator classification comment | Gitea comment `16299` explains historical infra/load confounds and operator readiness |
|
||||||
| Post-run cleanup | no `ghos-*`/`dev-ghost` stacks, services, or volumes |
|
| Post-run cleanup | no `ghos-*`/`dev-ghost` stacks, services, or volumes |
|
||||||
| M1 Adversary PASS | PASS @2026-10-27T03:06:13Z |
|
| M1 Adversary PASS | pending fresh verification of build #66 |
|
||||||
| M2 Adversary PASS | PASS @2026-10-27T03:06:13Z |
|
| M2 Adversary PASS | pending fresh verification of build #66 |
|
||||||
|
|
||||||
### Definition-of-Done checklist (ghost phase)
|
### Definition-of-Done checklist (ghost phase)
|
||||||
|
|
||||||
@@ -30,26 +31,26 @@ M1 and M2 have fresh Adversary PASSes in `REVIEW-ghost.md` at 2026-10-27T03:06:1
|
|||||||
- [x] 2026-06-12 failures are classified separately from current recipe evidence
|
- [x] 2026-06-12 failures are classified separately from current recipe evidence
|
||||||
- [x] Historical duplicate PRs are closed; no current duplicate exists
|
- [x] Historical duplicate PRs are closed; no current duplicate exists
|
||||||
- [x] No Ghost resources remain after the fresh retry
|
- [x] No Ghost resources remain after the fresh retry
|
||||||
- [x] M1 and M2 have fresh Adversary PASSes for build #94
|
- [x] M1 and M2 have fresh Adversary PASSes for build #66
|
||||||
|
|
||||||
## M1 — State inventory and clean retry
|
## M1 — State inventory and clean retry
|
||||||
|
|
||||||
**Result:** current canonical Ghost upgrade PR verified by build #94; Adversary PASS recorded.
|
**Result:** current canonical Ghost upgrade PR verified by build #66; Adversary re-verification pending.
|
||||||
|
|
||||||
### Live inventory
|
### Live inventory
|
||||||
|
|
||||||
| Check | Result |
|
| Check | Result |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Open Ghost PRs | PR#8 only: `upgrade-ee04ca9` at `6121a9876f4813414f20873d5eb23f8fedb64295` |
|
| Open Ghost PRs | PR#8 only: `upgrade-ee04ca9` at `dbe40ecb7b0084050a71baf4dcf971b65aac125e` |
|
||||||
| Historical PR#7 | closed; not the current upgrade PR |
|
| Historical PR#7 | closed; not the current upgrade PR |
|
||||||
| Historical duplicates | PR#1 through PR#7 closed |
|
| Historical duplicates | PR#1 through PR#7 closed |
|
||||||
| Proxy subnet | `10.10.0.0/16` |
|
| Proxy subnet | `10.10.0.0/16` |
|
||||||
| Ghost stacks/services/volumes before retry | none |
|
| Ghost stacks/services/volumes before retry | none |
|
||||||
| Fresh retry | Gitea comment `16382` at 2026-10-27T03:03:31Z; Drone build #94 success, level 5/5 |
|
| Fresh retry | Gitea comment `16297` at 2026-10-20T03:09:52Z; Drone build #66 success, level 5/5 |
|
||||||
|
|
||||||
### Adversary verification inputs
|
### Adversary verification inputs
|
||||||
|
|
||||||
**WHAT:** M1 and M2 claim the current live PR `recipe-maintainers/ghost#8` and fresh post-proxy build #94.
|
**WHAT:** M1 and M2 claim the current live PR `recipe-maintainers/ghost#8` and fresh post-proxy build #66.
|
||||||
|
|
||||||
**HOW:**
|
**HOW:**
|
||||||
|
|
||||||
@@ -58,12 +59,12 @@ set -a; . /srv/cc-ci/cc-ci-plan/load-env.sh; set +a
|
|||||||
curl -fsS -u "$GITEA_USERNAME:$GITEA_PASSWORD" \
|
curl -fsS -u "$GITEA_USERNAME:$GITEA_PASSWORD" \
|
||||||
"https://$GITEA_URL/api/v1/repos/recipe-maintainers/ghost/pulls?state=open" \
|
"https://$GITEA_URL/api/v1/repos/recipe-maintainers/ghost/pulls?state=open" \
|
||||||
| jq -r '.[] | [.number, .state, .head.sha, .head.ref] | @tsv'
|
| jq -r '.[] | [.number, .state, .head.sha, .head.ref] | @tsv'
|
||||||
ssh cc-ci 'docker network inspect proxy --format "{{range .IPAM.Config}}{{.Subnet}}{{end}}"; docker stack ls --format "{{.Name}}" | rg "^(ghos-|dev-ghost)" || true; docker service ls --format "{{.Name}}" | rg "(^|[-_])(ghos-|dev-ghost)" || true; docker volume ls --format "{{.Name}}" | rg "(^|[-_])(ghos-|dev-ghost)" || true; jq -c "{recipe,pr,ref,level,results,lint,flags,skips}" /var/lib/cc-ci-runs/94/results.json'
|
ssh cc-ci 'docker network inspect proxy --format "{{range .IPAM.Config}}{{.Subnet}}{{end}}"; docker stack ls --format "{{.Name}}" | rg "^(ghos-|dev-ghost)" || true; docker service ls --format "{{.Name}}" | rg "(^|[-_])(ghos-|dev-ghost)" || true; docker volume ls --format "{{.Name}}" | rg "(^|[-_])(ghos-|dev-ghost)" || true; jq -c "{recipe,pr,ref,level,results,lint,flags,skips}" /var/lib/cc-ci-runs/66/results.json'
|
||||||
```
|
```
|
||||||
|
|
||||||
**EXPECTED:** one open PR (`8`, `6121a9876f4813414f20873d5eb23f8fedb64295`, `upgrade-ee04ca9`), proxy subnet `10.10.0.0/16`, and no Ghost resource output. Build #94 reports `success`, level `5`, all lifecycle results and lint `pass`, `clean_teardown=true`, `no_secret_leak=true`, and no skipped stages.
|
**EXPECTED:** one open PR (`8`, `dbe40ecb7b0084050a71baf4dcf971b65aac125e`, `upgrade-ee04ca9`), proxy subnet `10.10.0.0/16`, and no Ghost resource output. Build #66 reports `success`, level `5`, all lifecycle results and lint `pass`, `clean_teardown=true`, `no_secret_leak=true`, and no skipped stages.
|
||||||
|
|
||||||
**WHERE:** Gitea issue comments `16382` and `16383`; Drone URL `https://drone.ci.autonomic.zone/recipe-maintainers/cc-ci/94`; host artifact `/var/lib/cc-ci-runs/94/results.json`; Adversary evidence in `REVIEW-ghost.md`.
|
**WHERE:** Gitea issue comments `16297` and `16298`; Drone URL `https://drone.ci.autonomic.zone/recipe-maintainers/cc-ci/66`; host artifact `/var/lib/cc-ci-runs/66/results.json`; Adversary evidence in `REVIEW-ghost.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ let
|
|||||||
# admin-registered push optimization deduped against the poller (§4.1). Enrollment = add
|
# admin-registered push optimization deduped against the poller (§4.1). Enrollment = add
|
||||||
# the repo to POLL_REPOS (csv) + ensure tests/<recipe>/ exists.
|
# the repo to POLL_REPOS (csv) + ensure tests/<recipe>/ exists.
|
||||||
- POLL_INTERVAL=30
|
- POLL_INTERVAL=30
|
||||||
- POLL_REPOS=recipe-maintainers/cc-ci,recipe-maintainers/custom-html,recipe-maintainers/custom-html-tiny,recipe-maintainers/keycloak,recipe-maintainers/cryptpad,recipe-maintainers/matrix-synapse,recipe-maintainers/lasuite-docs,recipe-maintainers/lasuite-meet,recipe-maintainers/n8n,recipe-maintainers/hedgedoc,recipe-maintainers/uptime-kuma,recipe-maintainers/bluesky-pds,recipe-maintainers/discourse,recipe-maintainers/ghost,recipe-maintainers/immich,recipe-maintainers/lasuite-drive,recipe-maintainers/mailu,recipe-maintainers/mattermost-lts,recipe-maintainers/mumble,recipe-maintainers/plausible,recipe-maintainers/drone,recipe-maintainers/gitea,recipe-maintainers/wordpress,recipe-maintainers/rustdesk-server
|
- POLL_REPOS=recipe-maintainers/cc-ci,recipe-maintainers/custom-html,recipe-maintainers/custom-html-tiny,recipe-maintainers/keycloak,recipe-maintainers/cryptpad,recipe-maintainers/matrix-synapse,recipe-maintainers/lasuite-docs,recipe-maintainers/lasuite-meet,recipe-maintainers/n8n,recipe-maintainers/hedgedoc,recipe-maintainers/uptime-kuma,recipe-maintainers/bluesky-pds,recipe-maintainers/discourse,recipe-maintainers/ghost,recipe-maintainers/immich,recipe-maintainers/lasuite-drive,recipe-maintainers/mailu,recipe-maintainers/mattermost-lts,recipe-maintainers/mumble,recipe-maintainers/plausible,recipe-maintainers/drone,recipe-maintainers/gitea,recipe-maintainers/wordpress
|
||||||
- HMAC_FILE=/run/secrets/webhook_hmac
|
- HMAC_FILE=/run/secrets/webhook_hmac
|
||||||
- DRONE_TOKEN_FILE=/run/secrets/drone_token
|
- DRONE_TOKEN_FILE=/run/secrets/drone_token
|
||||||
- GITEA_TOKEN_FILE=/run/secrets/gitea_token
|
- GITEA_TOKEN_FILE=/run/secrets/gitea_token
|
||||||
|
|||||||
@@ -93,15 +93,9 @@ def assert_serving(domain: str, meta) -> None:
|
|||||||
Steps 1–2 are BOUNDED POLLS (no bare sleep), so a state-mutating op (upgrade/restore) that leaves
|
Steps 1–2 are BOUNDED POLLS (no bare sleep), so a state-mutating op (upgrade/restore) that leaves
|
||||||
the app briefly reconverging settles, while a persistent failure still fails within the timeout."""
|
the app briefly reconverging settles, while a persistent failure still fails within the timeout."""
|
||||||
deadline = time.time() + meta.DEPLOY_TIMEOUT
|
deadline = time.time() + meta.DEPLOY_TIMEOUT
|
||||||
converged = False
|
while time.time() < deadline and not lifecycle.services_converged(domain):
|
||||||
while time.time() < deadline:
|
|
||||||
# Keep both observations inside the retry budget: a service may restart
|
|
||||||
# between the initial convergence sample and its confirmation.
|
|
||||||
if lifecycle.services_converged(domain) and lifecycle.services_converged(domain):
|
|
||||||
converged = True
|
|
||||||
break
|
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
assert converged, f"{domain}: services did not converge"
|
assert lifecycle.services_converged(domain), f"{domain}: services did not converge"
|
||||||
|
|
||||||
path = meta.HEALTH_PATH
|
path = meta.HEALTH_PATH
|
||||||
ok = tuple(meta.HEALTH_OK)
|
ok = tuple(meta.HEALTH_OK)
|
||||||
|
|||||||
@@ -487,10 +487,6 @@ def deploy_app(
|
|||||||
# may already include the environmental compose.ccci.yml). Stripped before the head redeploy
|
# may already include the environmental compose.ccci.yml). Stripped before the head redeploy
|
||||||
# (generic.perform_upgrade) so the PR head runs unmodified.
|
# (generic.perform_upgrade) so the PR head runs unmodified.
|
||||||
if apply_previous:
|
if apply_previous:
|
||||||
# This untracked overlay also trips abra's pinned-deploy clean-tree gate.
|
|
||||||
# The exact version was checked out above; chaos deploys that base plus
|
|
||||||
# its repair, just as for compose.ccci.yml, without fetching latest.
|
|
||||||
chaos = True
|
|
||||||
cf = compose_file_add(extra.get("COMPOSE_FILE", "compose.yml"), PREVIOUS_COMPOSE)
|
cf = compose_file_add(extra.get("COMPOSE_FILE", "compose.yml"), PREVIOUS_COMPOSE)
|
||||||
abra.env_set(domain, "COMPOSE_FILE", cf)
|
abra.env_set(domain, "COMPOSE_FILE", cf)
|
||||||
provide_previous_overlay(recipe)
|
provide_previous_overlay(recipe)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
0.4.0+v5.4.1
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# The published base points at the removed docker.io/minio/minio image.
|
|
||||||
# Keep its /data layout, credentials and bucket initialization; run the same
|
|
||||||
# available MinIO build used by current recipes without Bitnami's wrapper.
|
|
||||||
services:
|
|
||||||
minio:
|
|
||||||
image: docker.io/bitnamilegacy/minio:2025.7.23-debian-12-r5@sha256:6dabb4a2088c9a79908de3bc05f4586c23ad2182c8908e7e3acbf61c1467fb20
|
|
||||||
user: "0:0"
|
|
||||||
environment:
|
|
||||||
MC_CONFIG_DIR: /tmp/mc-base-init
|
|
||||||
entrypoint:
|
|
||||||
- /bin/sh
|
|
||||||
- -c
|
|
||||||
- /minio-initialize.sh & exec "$$@"
|
|
||||||
- --
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0.12.0+v0.21.0
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# The published base points at the removed docker.io/minio/minio image.
|
|
||||||
# Keep its /data layout, credentials and bucket initialization; run the same
|
|
||||||
# available MinIO build used by current recipes without Bitnami's wrapper.
|
|
||||||
services:
|
|
||||||
minio:
|
|
||||||
image: docker.io/bitnamilegacy/minio:2025.7.23-debian-12-r5@sha256:6dabb4a2088c9a79908de3bc05f4586c23ad2182c8908e7e3acbf61c1467fb20
|
|
||||||
user: "0:0"
|
|
||||||
environment:
|
|
||||||
MC_CONFIG_DIR: /tmp/mc-base-init
|
|
||||||
entrypoint:
|
|
||||||
- /bin/sh
|
|
||||||
- -c
|
|
||||||
- /minio-initialize.sh & exec "$$@"
|
|
||||||
- --
|
|
||||||
@@ -59,6 +59,13 @@ def EXTRA_ENV(ctx):
|
|||||||
# postgres, redis, collabora ~1GB, onlyoffice ~2GB). Bump so abra waits long enough for
|
# postgres, redis, collabora ~1GB, onlyoffice ~2GB). Bump so abra waits long enough for
|
||||||
# convergence; kept under DEPLOY_TIMEOUT (1800) so Python never kills abra mid-wait.
|
# convergence; kept under DEPLOY_TIMEOUT (1800) so Python never kills abra mid-wait.
|
||||||
"TIMEOUT": "1500",
|
"TIMEOUT": "1500",
|
||||||
|
# abra's pinned-version `app new` regenerates the .env from generation hints and DROPS
|
||||||
|
# SECRET_COLLABORA_P_VERSION (not a generatable secret — the .env.sample ships its
|
||||||
|
# version line manually). Without it every secret-touching abra command for the app
|
||||||
|
# FATAs "missing version for secret? (collabora_p)" → the base deploy goes RED at
|
||||||
|
# install. env_set runs after app_new, so re-asserting the shipped version pin here
|
||||||
|
# restores the sample's contract.
|
||||||
|
"SECRET_COLLABORA_P_VERSION": "v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Parity — rustdesk-server
|
|
||||||
|
|
||||||
The recipe-maintainer corpus has **no** `recipe-info/rustdesk-server/` directory — rustdesk-server
|
|
||||||
was never in the recipe-maintainer parity suite (this is a fresh 2026-10-30 enrollment, requested
|
|
||||||
as a one-off upgrade of a single-client production deploy). So this PARITY.md documents the
|
|
||||||
cc-ci-authored suite as the baseline.
|
|
||||||
|
|
||||||
## The recipe's shape (why the suite looks like this)
|
|
||||||
|
|
||||||
rustdesk-server is **not HTTP-native**: hbbs (rendezvous/registry) + hbbr (relay) speak a
|
|
||||||
hand-rolled protobuf protocol on host-published ports 21115-21119 (21116 also UDP; 21118/21119
|
|
||||||
websocket). There is no web UI, no HTTP API, and the stock compose's traefik labels point at a
|
|
||||||
port nothing serves. The cc-ci ENVIRONMENTAL overlay (`compose.ccci.yml`) adds a `status`
|
|
||||||
sidecar serving exactly hbbs's generated identity PUBLIC key at `/id_ed25519.pub` — the HTTP
|
|
||||||
health gate then asserts real app state (the key only exists because hbbs minted it), never a
|
|
||||||
canned page. The private key + sqlite peer DB in the same volume are asserted UNREACHABLE.
|
|
||||||
Host ports need no overlay: the stock compose publishes them mode:host, so protocol tests run
|
|
||||||
on-host at 127.0.0.1 (the mumble pattern).
|
|
||||||
|
|
||||||
## Recipe-specific tests (≥2 beyond the generic floor)
|
|
||||||
|
|
||||||
| cc-ci file | what's verified | why non-vacuous |
|
|
||||||
|---|---|---|
|
|
||||||
| `custom/test_rendezvous_ports.py` | All 5 stock host-published ports (21115-21119) accept TCP; 21116 holds 3 consecutive connects. | The recipe's entire function is serving those ports; a missing/failed bind (e.g. a compose port change upstream) is named per-port. |
|
|
||||||
| `custom/test_websocket_handshake.py` | Real `101 Switching Protocols` upgrades on hbbs :21118 and hbbr :21119. | These are the app's own client-facing protocol listeners; a bare GET gets NO response, so 101 can only come from a genuinely serving hbbs/hbbr (verified live on 1.1.12 and 1.1.16). |
|
|
||||||
| `custom/test_identity_key.py` | hbbs's ed25519 identity key read back through THREE independent surfaces — HTTPS status route (via Traefik), hbbs's own `Key:` startup log, the volume file — asserted EQUAL and a valid 32-byte key; plus the private key / DB paths asserted 404. | The §4.3-style round-trip of the recipe's defining generated state; a crash-looping hbbs (no key) fails, a routing gap fails, a surface mismatch (reading another deployment) fails, and an exposure fails. |
|
|
||||||
| `custom/test_punch_hole_no_amplification.py` | An unauthenticated UDP punch-hole request gets NO response on ≥1.1.16, while a register_peer sanity probe on the same socket IS answered. | The security regression test for the 1.1.16 amplification fix (GH #670, actively exploited pre-fix even with `-k _`). The in-test register_peer response proves silence means "fixed", not "broken"; a regression turns this RED naming the layer. Empirical baseline recorded in the file (1.1.12 answered 3/3, 1.1.16 silent 3/3). Version-gated per STYLE §2. |
|
|
||||||
|
|
||||||
## Backup data-integrity
|
|
||||||
|
|
||||||
The stock recipe declares no `backupbot.backup` labels — backup/restore tiers auto-skip
|
|
||||||
(intentional non-run, auto-detected). Nothing here weakens that; if the recipe grows backup
|
|
||||||
support upstream, the generic tiers pick it up automatically.
|
|
||||||
|
|
||||||
## Deferred
|
|
||||||
|
|
||||||
- A full register_pk/punch-hole EXCHANGE between two registered peers (proving the happy-path
|
|
||||||
connection flow, not just the refusal) — needs the client-side crypto handshake (signed pk
|
|
||||||
registration); the register_peer + ws + punch-hole surfaces already cover the server's
|
|
||||||
externally observable behavior. Revisit if upstream changes the rendezvous flow.
|
|
||||||
- Host-port contention: 21115-21119 are unique to this recipe, but two CONCURRENT rustdesk
|
|
||||||
deploys (e.g. a cold run racing a canonical reattach) would collide on them — same exposure
|
|
||||||
mailu (25/465/587) and lasuite-meet (7882) already carry as host-port recipes. Cadence makes
|
|
||||||
a collision rare; a failed deploy is loud and re-runnable.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
version: "3.8"
|
|
||||||
# cc-ci ENVIRONMENTAL overlay for rustdesk-server — applies to ALL deploys (base + PR head).
|
|
||||||
#
|
|
||||||
# rustdesk-server has NO HTTP surface (hbbs/hbbr speak their own protobuf protocol on
|
|
||||||
# host-published 21115-21119; the stock traefik labels point at a dead :80). cc-ci's install
|
|
||||||
# tier gates on an HTTPS serving signal through Traefik, so this overlay adds a `status`
|
|
||||||
# sidecar serving exactly ONE real app artifact: hbbs's generated identity PUBLIC key at
|
|
||||||
# /id_ed25519.pub (mounted read-only from the shared rustdesk_data volume). The health gate
|
|
||||||
# then proves domain routing AND that hbbs booted far enough to mint its keypair. The private
|
|
||||||
# key and the sqlite peer DB in the same volume are never exposed: nginx serves only the
|
|
||||||
# exact .pub location, everything else 404s.
|
|
||||||
#
|
|
||||||
# Node-reality tweaks ONLY — no version-specific image pins, no changes to the app/signal
|
|
||||||
# services (the stock app's vestigial Host(`${DOMAIN}`) router on :80 is left untouched; the
|
|
||||||
# status router declares an explicit higher priority so it deterministically wins the tie).
|
|
||||||
services:
|
|
||||||
status:
|
|
||||||
image: nginx:1.31.8
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
printf '%s\n' \
|
|
||||||
'user root;' \
|
|
||||||
'worker_processes 1;' \
|
|
||||||
'error_log /dev/stderr warn;' \
|
|
||||||
'pid /tmp/nginx.pid;' \
|
|
||||||
'events { worker_connections 128; }' \
|
|
||||||
'http { default_type text/plain; access_log off;' \
|
|
||||||
' server { listen 80;' \
|
|
||||||
' location = /id_ed25519.pub { alias /pub/id_ed25519.pub; }' \
|
|
||||||
' location / { return 404; }' \
|
|
||||||
' }' \
|
|
||||||
'}' > /etc/nginx/nginx.conf
|
|
||||||
exec nginx -g 'daemon off;'
|
|
||||||
volumes:
|
|
||||||
# rustdesk_data is owned root:0700 (hbbs's /root), and id_ed25519.pub is 0644 — workers
|
|
||||||
# must run as root (user root; above) to traverse the volume dir. Read-only mount; the
|
|
||||||
# private key (id_ed25519) and db_v2.sqlite3 are unreachable through the config above.
|
|
||||||
- rustdesk_data:/pub:ro
|
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
deploy:
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.${STACK_NAME}-status.rule=Host(`${DOMAIN}`)"
|
|
||||||
- "traefik.http.routers.${STACK_NAME}-status.entrypoints=web-secure"
|
|
||||||
- "traefik.http.routers.${STACK_NAME}-status.tls.certresolver=${LETS_ENCRYPT_ENV}"
|
|
||||||
# Explicit priority: the stock app router claims the same Host rule against a dead
|
|
||||||
# :80 backend; without this the winner of the equal-default-priority tie is undefined.
|
|
||||||
- "traefik.http.routers.${STACK_NAME}-status.priority=100"
|
|
||||||
- "traefik.http.services.${STACK_NAME}-status.loadbalancer.server.port=80"
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"""Minimal RustDesk rendezvous protocol encoder/probe (stdlib only, zero deps).
|
|
||||||
|
|
||||||
hbbs speaks `RendezvousMessage` protobuf over UDP :21116 (one message per datagram, no
|
|
||||||
framing). The wire format is hand-encoded here from the upstream proto
|
|
||||||
(rustdesk/hbb_common protos/rendezvous.proto — field numbers are IDENTICAL between the
|
|
||||||
1.1.12-vendored and the current shared-proto era, verified against both tags):
|
|
||||||
|
|
||||||
message RendezvousMessage {
|
|
||||||
oneof union {
|
|
||||||
RegisterPeer register_peer = 6;
|
|
||||||
RegisterPeerResponse register_peer_response = 7;
|
|
||||||
PunchHoleRequest punch_hole_request = 8;
|
|
||||||
PunchHoleResponse punch_hole_response = 11;
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message RegisterPeer { string id = 1; int32 serial = 2; }
|
|
||||||
message PunchHoleRequest { string id = 1; ... }
|
|
||||||
|
|
||||||
This is the surface a real RustDesk client uses to register and request connections; it is
|
|
||||||
the app's own interface (STYLE.md §1), exercised from the cc-ci host against the
|
|
||||||
host-published :21116 (the stock compose publishes 21115-21119 mode:host).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
|
|
||||||
RENDEZVOUS_PORT = 21116
|
|
||||||
|
|
||||||
|
|
||||||
def varint(n: int) -> bytes:
|
|
||||||
out = bytearray()
|
|
||||||
while n > 0x7F:
|
|
||||||
out.append((n & 0x7F) | 0x80)
|
|
||||||
n >>= 7
|
|
||||||
out.append(n)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def field_str(field: int, s: str) -> bytes:
|
|
||||||
b = s.encode()
|
|
||||||
return varint((field << 3) | 2) + varint(len(b)) + b
|
|
||||||
|
|
||||||
|
|
||||||
def field_varint(field: int, n: int) -> bytes:
|
|
||||||
return varint((field << 3) | 0) + varint(n)
|
|
||||||
|
|
||||||
|
|
||||||
def wrap(union_field: int, inner: bytes) -> bytes:
|
|
||||||
"""Wrap an inner message as the given oneof field of RendezvousMessage."""
|
|
||||||
return varint((union_field << 3) | 2) + varint(len(inner)) + inner
|
|
||||||
|
|
||||||
|
|
||||||
def register_peer(peer_id: str) -> bytes:
|
|
||||||
"""RendezvousMessage{ register_peer { id, serial=0 } } — a plain peer registration."""
|
|
||||||
return wrap(6, field_str(1, peer_id) + field_varint(2, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def punch_hole_request(target_id: str) -> bytes:
|
|
||||||
"""RendezvousMessage{ punch_hole_request { id } } — an UNAUTHENTICATED connection request
|
|
||||||
for `target_id` (no licence_key/token — exactly the spoofable request shape from the
|
|
||||||
CVE-2026-30784 / GH #670 amplification report)."""
|
|
||||||
return wrap(8, field_str(1, target_id))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_union_field(data: bytes) -> int | None:
|
|
||||||
"""Which RendezvousMessage oneof field the server answered with (None if unparseable)."""
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
key = data[0]
|
|
||||||
field, wire = key >> 3, key & 0x7
|
|
||||||
return field if wire == 2 else field # field number regardless of wire type
|
|
||||||
|
|
||||||
|
|
||||||
def udp_probe(
|
|
||||||
msg: bytes, host: str = "127.0.0.1", port: int = RENDEZVOUS_PORT, wait: float = 2.0
|
|
||||||
) -> bytes | None:
|
|
||||||
"""Send one datagram, return the server's reply (or None after `wait` seconds of silence)."""
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.settimeout(wait)
|
|
||||||
try:
|
|
||||||
s.sendto(msg, (host, port))
|
|
||||||
try:
|
|
||||||
data, _ = s.recvfrom(65535)
|
|
||||||
return data
|
|
||||||
except TimeoutError:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
def retry_register_peer(peer_id: str, attempts: int = 6, interval: float = 5.0) -> bytes | None:
|
|
||||||
"""Poll register_peer until the server answers (post-deploy settle), or give up.
|
|
||||||
|
|
||||||
Returns the raw register_peer_response datagram."""
|
|
||||||
deadline = time.time() + attempts * interval
|
|
||||||
last: bytes | None = None
|
|
||||||
while time.time() < deadline:
|
|
||||||
last = udp_probe(register_peer(peer_id))
|
|
||||||
if last is not None:
|
|
||||||
return last
|
|
||||||
time.sleep(interval)
|
|
||||||
return last
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""rustdesk-server — identity-key round-trip across three independent surfaces (§4.3-style).
|
|
||||||
|
|
||||||
hbbs mints an ed25519 identity keypair on first boot into the shared rustdesk_data volume;
|
|
||||||
the PUBLIC key is what clients configure to pin the server. This is the recipe's defining
|
|
||||||
generated state, and we read it back through three genuinely different paths:
|
|
||||||
|
|
||||||
1. HTTPS through Traefik + the cc-ci status sidecar: GET /id_ed25519.pub (the serving path
|
|
||||||
a remote client could observe),
|
|
||||||
2. hbbs's OWN startup telemetry: the `Key: <b64>` log line (docker service logs),
|
|
||||||
3. the volume file itself, read inside the status sidecar (cat /pub/id_ed25519.pub).
|
|
||||||
|
|
||||||
All three must AGREE and be a valid base64-encoded 32-byte ed25519 public key. A wedged or
|
|
||||||
crash-looping hbbs fails (1) with 404 forever (no key file); a key mismatch across surfaces
|
|
||||||
would mean we are reading different deployments' state — either way this test names the
|
|
||||||
broken layer. Non-vacuous: every surface carries app-generated state, nothing is echoed from
|
|
||||||
config.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
|
||||||
from harness import lifecycle # noqa: E402
|
|
||||||
|
|
||||||
_KEY_RE = re.compile(rb"Key: ([A-Za-z0-9+/]{43}=)")
|
|
||||||
|
|
||||||
|
|
||||||
def _stack(domain: str) -> str:
|
|
||||||
return domain.replace(".", "_")
|
|
||||||
|
|
||||||
|
|
||||||
def _https_key(domain: str) -> tuple[int, str]:
|
|
||||||
return lifecycle.http_fetch(domain, "/id_ed25519.pub")
|
|
||||||
|
|
||||||
|
|
||||||
def _log_key(domain: str) -> bytes | None:
|
|
||||||
out = subprocess.run(
|
|
||||||
["docker", "service", "logs", "--tail", "200", f"{_stack(domain)}_app"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
).stdout
|
|
||||||
m = _KEY_RE.search(out.encode())
|
|
||||||
return m.group(1) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _volume_key(domain: str) -> str:
|
|
||||||
return lifecycle.exec_in_app(domain, ["cat", "/pub/id_ed25519.pub"], service="status").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def test_identity_key_agrees_across_https_log_and_volume(live_app):
|
|
||||||
https_status, https_body = _https_key(live_app)
|
|
||||||
assert https_status == 200, (
|
|
||||||
f"status route /id_ed25519.pub not serving (HTTP {https_status}) — hbbs never minted "
|
|
||||||
"its key, or the status sidecar/routing is broken"
|
|
||||||
)
|
|
||||||
https_key = https_body.strip().encode()
|
|
||||||
|
|
||||||
log_key = _log_key(live_app)
|
|
||||||
assert log_key is not None, "hbbs startup log carries no 'Key: <b64>' line (telemetry)"
|
|
||||||
|
|
||||||
vol_key = _volume_key(live_app).encode()
|
|
||||||
|
|
||||||
assert https_key == log_key == vol_key, (
|
|
||||||
"identity key disagrees across surfaces — "
|
|
||||||
f"https={https_key[:20]!r} log={log_key[:20]!r} volume={vol_key[:20]!r} "
|
|
||||||
"(reading different deployments' state?)"
|
|
||||||
)
|
|
||||||
|
|
||||||
raw = base64.b64decode(https_key, validate=True)
|
|
||||||
assert len(raw) == 32, f"not a 32-byte ed25519 public key ({len(raw)} bytes)"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"path",
|
|
||||||
[
|
|
||||||
"/",
|
|
||||||
"/id_ed25519", # the PRIVATE key lives in the same volume — must never be served
|
|
||||||
"/db_v2.sqlite3",
|
|
||||||
"/db_v2.sqlite3-wal",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_status_route_serves_only_the_public_key(live_app, path):
|
|
||||||
"""Anything but the exact .pub location 404s (default surface = deny). The private key and
|
|
||||||
the sqlite peer DB share the status sidecar's volume — a 200 on any of these paths would
|
|
||||||
be a real exposure."""
|
|
||||||
status, _ = lifecycle.http_fetch(live_app, path)
|
|
||||||
assert status == 404, f"status route served {path} (HTTP {status}) — exposure, not health"
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""rustdesk-server — security regression: the unauthenticated punch-hole amplification fix.
|
|
||||||
|
|
||||||
Upstream 1.1.16 fixed GH rustdesk/rustdesk-server#670 (reserved-then-withdrawn
|
|
||||||
CVE-2026-30784): hbbs used to answer ANY UDP punch-hole request with a punch-hole response —
|
|
||||||
no check the requester was a registered peer or that the exchange was solicited — so spoofed
|
|
||||||
source IPs turned public servers into DDoS reflection/amplifiers. This happened EVEN with
|
|
||||||
`-k _` enforced (which this recipe uses), because the key gate never covered the punch-hole
|
|
||||||
path.
|
|
||||||
|
|
||||||
Empirical baseline (cc-ci dev deploy, 2026-10-30, hand-encoded RendezvousMessage protobufs):
|
|
||||||
1.1.12: register_peer -> 3a021001 (register_peer_response, request_pk=true)
|
|
||||||
punch_hole_request x3 -> 5a021803 EVERY time (punch_hole_response, failure=3)
|
|
||||||
1.1.16: register_peer -> 3a021001 (unchanged — the channel still answers)
|
|
||||||
punch_hole_request x3 -> SILENCE (the fix)
|
|
||||||
|
|
||||||
The register_peer sanity check inside the same test is what makes "silence" meaningful: it
|
|
||||||
proves the server is up, our encoding is right, and UDP 21116 is answering — so a silent
|
|
||||||
punch-hole exchange can only be the FIX, not a broken server or a bad probe. Non-vacuous: a
|
|
||||||
regression of the fix makes hbbs answer again and this test goes RED naming the layer.
|
|
||||||
|
|
||||||
Version-gated (STYLE.md §2): the fixed behavior only exists from 1.1.16; older versions are
|
|
||||||
exercised by the upgrade tier's base deploy and are EXPECTED to answer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
import _rustdesk_proto as proto # noqa: E402
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
|
||||||
|
|
||||||
|
|
||||||
def _deployed_app_version(domain: str) -> tuple[int, int, int]:
|
|
||||||
"""The running app service's image tag (e.g. rustdesk/rustdesk-server:1.1.16 -> (1,1,16)),
|
|
||||||
via the harness's first-class deployed_identity (labels + image in one inspect). Fails
|
|
||||||
LOUDLY on an unparsable image rather than returning None — a gate that cannot decide must
|
|
||||||
never silently run (vacuous-green) or silently skip (the first enrollment run shipped a
|
|
||||||
None-returning hand-rolled inspect and observed pre-fix answering on a 1.1.12 head)."""
|
|
||||||
from harness import lifecycle # noqa: PLC0415 (lazy: keeps import cost off collection)
|
|
||||||
|
|
||||||
image = (lifecycle.deployed_identity(domain).get("image") or "").strip()
|
|
||||||
tag = image.split("@", 1)[0].rsplit(":", 1)[-1]
|
|
||||||
try:
|
|
||||||
return tuple(int(x) for x in tag.split(".")[:3]) # type: ignore[return-value]
|
|
||||||
except ValueError:
|
|
||||||
pytest.fail(
|
|
||||||
f"cannot determine deployed rustdesk-server version from image {image!r} — fix "
|
|
||||||
"the read before trusting this test's verdict"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unauthenticated_punch_hole_is_not_answered(live_app):
|
|
||||||
version = _deployed_app_version(live_app)
|
|
||||||
if version < (1, 1, 16):
|
|
||||||
pytest.skip(
|
|
||||||
f"amplification fix landed upstream in 1.1.16 (running {version}) — pre-fix "
|
|
||||||
"answering is expected behavior, covered by the upgrade tier's base deploy"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1) channel sanity: a plain register_peer MUST be answered (register_peer_response,
|
|
||||||
# RendezvousMessage field 7 -> first varint key 0x3A). Without this, later silence
|
|
||||||
# could mean "server down / encoding wrong", not "fixed".
|
|
||||||
reg = proto.retry_register_peer("ccci-sanity-probe")
|
|
||||||
assert reg is not None, (
|
|
||||||
"register_peer sanity probe unanswered — hbbs UDP 21116 not serving; cannot judge "
|
|
||||||
"the punch-hole path"
|
|
||||||
)
|
|
||||||
assert proto.parse_union_field(reg) == 7, (
|
|
||||||
f"expected register_peer_response (oneof field 7), got {proto.parse_union_field(reg)}: {reg[:40].hex()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2) the actual security property: an UNAUTHENTICATED punch-hole request (no
|
|
||||||
# licence_key/token, spoofable shape) for a nonexistent target must get NO response.
|
|
||||||
# Pre-fix (1.1.12) every request was answered with punch_hole_response (field 11) —
|
|
||||||
# the reflection/amplification vector. 3 independent attempts, all silent.
|
|
||||||
answered = []
|
|
||||||
for attempt in range(3):
|
|
||||||
resp = proto.udp_probe(proto.punch_hole_request(f"ccci-no-such-peer-{attempt}"))
|
|
||||||
if resp is not None:
|
|
||||||
answered.append((attempt, resp[:40].hex()))
|
|
||||||
assert not answered, (
|
|
||||||
"hbbs answered unauthenticated punch-hole request(s) — the 1.1.16 amplification fix "
|
|
||||||
f"(GH #670) has REGRESSED: {answered}"
|
|
||||||
)
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""rustdesk-server — rendezvous/relay port presence (the recipe's whole purpose).
|
|
||||||
|
|
||||||
The stock compose publishes hbbs's and hbbr's ports mode:host on the cc-ci node, so these
|
|
||||||
assertions run on-host against 127.0.0.1. A TCP accept on each port is the minimal real
|
|
||||||
signal that the corresponding listener is bound; the protocol layers above them are asserted
|
|
||||||
by the websocket-handshake and punch-hole tests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
|
|
||||||
# service -> port map from the stock compose (hbbs: 21115/21116(+udp)/21118, hbbr: 21117/21119)
|
|
||||||
PORTS = {
|
|
||||||
21115: "hbbs NAT-test",
|
|
||||||
21116: "hbbs rendezvous (tcp)",
|
|
||||||
21117: "hbbr relay",
|
|
||||||
21118: "hbbs websocket",
|
|
||||||
21119: "hbbr websocket",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _connect(port: int, timeout: float = 5.0) -> bool:
|
|
||||||
try:
|
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_ports_listening(meta):
|
|
||||||
"""Every stock host-published port accepts a TCP connection (poll: freshly rolled tasks
|
|
||||||
rebind host-mode ports with a short window — mumble's READY_PROBE pattern, §5)."""
|
|
||||||
deadline = time.time() + meta.HTTP_TIMEOUT
|
|
||||||
pending = dict(PORTS)
|
|
||||||
while pending and time.time() < deadline:
|
|
||||||
for port in list(pending):
|
|
||||||
if _connect(port):
|
|
||||||
del pending[port]
|
|
||||||
if pending:
|
|
||||||
time.sleep(3)
|
|
||||||
assert not pending, (
|
|
||||||
f"ports not listening on 127.0.0.1: {pending} "
|
|
||||||
f"(waited {meta.HTTP_TIMEOUT}s; hbbs/hbbr host-mode binds)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rendezvous_port_stably_listening():
|
|
||||||
"""The core rendezvous port 21116 holds 3 consecutive accepts (no flapping rebind)."""
|
|
||||||
for _ in range(3):
|
|
||||||
assert _connect(21116), "hbbs rendezvous 21116 dropped a consecutive connect"
|
|
||||||
time.sleep(1)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""rustdesk-server — real websocket handshakes against hbbs/hbbr's ws listeners.
|
|
||||||
|
|
||||||
21118 (hbbs) and 21119 (hbbr) are the servers' websocket endpoints — the surface a real
|
|
||||||
RustDesk web client uses. A completed `101 Switching Protocols` upgrade is the app's own
|
|
||||||
protocol answering (verified live against 1.1.12 and 1.1.16 during enrollment); a bare GET
|
|
||||||
without upgrade headers gets NO response, so this can only pass against a genuinely serving
|
|
||||||
listener, never a proxy or placeholder.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
|
|
||||||
WS_PORTS = {21118: "hbbs", 21119: "hbbr"}
|
|
||||||
|
|
||||||
_REQ = (
|
|
||||||
b"GET / HTTP/1.1\r\n"
|
|
||||||
b"Host: rustdesk\r\n"
|
|
||||||
b"Upgrade: websocket\r\n"
|
|
||||||
b"Connection: Upgrade\r\n"
|
|
||||||
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
|
||||||
b"Sec-WebSocket-Version: 13\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _handshake(port: int, timeout: float = 5.0) -> bytes:
|
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as s:
|
|
||||||
s.sendall(_REQ)
|
|
||||||
s.settimeout(timeout)
|
|
||||||
return s.recv(200)
|
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_upgrades(meta):
|
|
||||||
"""Both ws endpoints complete an HTTP->websocket upgrade with 101 (poll per §5)."""
|
|
||||||
deadline = time.time() + meta.HTTP_TIMEOUT
|
|
||||||
pending = dict(WS_PORTS)
|
|
||||||
while pending and time.time() < deadline:
|
|
||||||
for port in list(pending):
|
|
||||||
try:
|
|
||||||
data = _handshake(port)
|
|
||||||
except OSError:
|
|
||||||
data = b""
|
|
||||||
if data.startswith(b"HTTP/1.1 101"):
|
|
||||||
del pending[port]
|
|
||||||
if pending:
|
|
||||||
time.sleep(3)
|
|
||||||
assert not pending, (
|
|
||||||
f"websocket endpoints did not complete 101 upgrades: {pending} "
|
|
||||||
f"(waited {meta.HTTP_TIMEOUT}s)"
|
|
||||||
)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Per-recipe harness config for rustdesk-server (hbbs/hbbr rendezvous+relay — NOT HTTP-native).
|
|
||||||
#
|
|
||||||
# RustDesk's server speaks its own protobuf protocol on host-published ports
|
|
||||||
# (21115-21119 tcp + 21116 udp; 21118/21119 are its websocket listeners) — there is NO HTTP
|
|
||||||
# API and no web UI, and the stock compose's traefik labels point at a port nothing serves
|
|
||||||
# (vestigial). To fit cc-ci's HTTP-readiness model we deploy a cc-ci ENVIRONMENTAL overlay
|
|
||||||
# (compose.ccci.yml, auto-provided by lifecycle.provide_ccci_overlay):
|
|
||||||
# - a `status` sidecar (nginx) that serves EXACTLY ONE real artifact of the app — hbbs's
|
|
||||||
# generated identity key — at /id_ed25519.pub on the app domain via Traefik. The health
|
|
||||||
# gate then asserts the domain routes AND the app actually booted far enough to mint its
|
|
||||||
# keypair (a canned page would prove nothing; the key file only exists because hbbs
|
|
||||||
# created it). The private key and the sqlite peer DB in the same volume are NEVER
|
|
||||||
# exposed (nginx serves only the exact .pub location; everything else 404s).
|
|
||||||
# - host ports 21115-21119 need NO overlay: the stock compose already publishes them
|
|
||||||
# mode:host, so on-host protocol tests reach hbbs/hbbr at 127.0.0.1 directly.
|
|
||||||
#
|
|
||||||
# Distinctive config markers (read back by the recipe-specific tests — the app's own
|
|
||||||
# artifacts, not hard-coded upstream values): the identity key is asserted EQUAL across three
|
|
||||||
# independent surfaces — the HTTPS status route, hbbs's own startup log line ("Key: …"), and
|
|
||||||
# the volume file — proving our deploy actually produced the state we're reading.
|
|
||||||
|
|
||||||
HEALTH_PATH = "/id_ed25519.pub" # real hbbs artifact served by the status sidecar
|
|
||||||
HEALTH_OK = (200,)
|
|
||||||
|
|
||||||
DEPLOY_TIMEOUT = 600 # both images are tiny (~6 MB) and nginx is pre-pulled on the node
|
|
||||||
HTTP_TIMEOUT = 300 # key file appears seconds after hbbs starts; generous for a cold node
|
|
||||||
|
|
||||||
# Environmental overlay for EVERY deploy (base install AND upgrade head): adds the status
|
|
||||||
# sidecar + its Traefik router (priority beats the stock app's dead :80 router, which is left
|
|
||||||
# untouched — no version-specific pins, no service add/drop beyond the CI-only sidecar).
|
|
||||||
EXTRA_ENV = {
|
|
||||||
"COMPOSE_FILE": "compose.yml:compose.ccci.yml",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extra readiness beyond the HTTP gate: the rendezvous server must be STABLY listening on its
|
|
||||||
# core udp/tcp port 21116 (3 consecutive connects) before the harness proceeds — mirrors
|
|
||||||
# mumble's voice-port probe. The remaining ports + the websocket + protocol surfaces are
|
|
||||||
# asserted by the custom tier.
|
|
||||||
|
|
||||||
|
|
||||||
def READY_PROBE(ctx):
|
|
||||||
return [{"tcp_host": "127.0.0.1", "tcp_port": 21116, "stable": 3}]
|
|
||||||
|
|
||||||
|
|
||||||
# canon §2.B: enroll as a DATA-WARM canonical (all recipes enrolled — operator 2026-06-17).
|
|
||||||
# The weekly sweep promotes this recipe's canonical to its latest green RELEASE TAG.
|
|
||||||
WARM_CANONICAL = True
|
|
||||||
@@ -171,6 +171,22 @@ def test_extra_env_dict_and_callable_forms(tmp_path):
|
|||||||
assert meta_mod.extra_env(meta2, ctx2) == {} # unset EXTRA_ENV resolves to {}
|
assert meta_mod.extra_env(meta2, ctx2) == {} # unset EXTRA_ENV resolves to {}
|
||||||
|
|
||||||
|
|
||||||
|
TESTS_DIR = os.path.join(os.path.dirname(__file__), "..")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lasuite_drive_extra_env_carries_collabora_p_version():
|
||||||
|
"""(REGRESSION, 2026-10-25) abra's pinned-version `app new` regenerates the .env from
|
||||||
|
generation hints and drops SECRET_COLLABORA_P_VERSION (the .env.sample ships it manually),
|
||||||
|
so every secret-touching abra command for the app FATAs "missing version for secret?
|
||||||
|
(collabora_p)" and the cold base deploy goes RED at install. The per-recipe EXTRA_ENV must
|
||||||
|
re-assert the shipped version pin (env_set runs after app_new). Pin it here so abra/upstream
|
||||||
|
drift that loses it again surfaces as a red unit test, not a weekly red build."""
|
||||||
|
meta = meta_mod.load("lasuite-drive", tests_dir=TESTS_DIR)
|
||||||
|
ctx = meta_mod.hook_ctx("lasuite-drive-pr0-abc123.ci.commoninternet.net", meta)
|
||||||
|
extra = meta_mod.extra_env(meta, ctx)
|
||||||
|
assert extra.get("SECRET_COLLABORA_P_VERSION") == "v1"
|
||||||
|
|
||||||
|
|
||||||
# ---- P3: uniform ctx hook convention -------------------------------------------------------------
|
# ---- P3: uniform ctx hook convention -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,37 +116,3 @@ def test_previous_status_stale_without_marker(monkeypatch):
|
|||||||
monkeypatch.setattr(lifecycle, "previous_target_version", lambda r: None)
|
monkeypatch.setattr(lifecycle, "previous_target_version", lambda r: None)
|
||||||
st = lifecycle.previous_status("rx", "version", "0.7.0+3.3.1")
|
st = lifecycle.previous_status("rx", "version", "0.7.0+3.3.1")
|
||||||
assert st["apply"] is False and st["stale"] is True and "marker" in st["reason"]
|
assert st["apply"] is False and st["stale"] is True and "marker" in st["reason"]
|
||||||
|
|
||||||
|
|
||||||
def test_previous_overlay_deploys_checked_out_base_without_clean_tree_gate(monkeypatch):
|
|
||||||
events = []
|
|
||||||
for name in (
|
|
||||||
"acquire_app_lock",
|
|
||||||
"provide_ccci_overlay",
|
|
||||||
"provide_previous_overlay",
|
|
||||||
"prepull_images",
|
|
||||||
):
|
|
||||||
monkeypatch.setattr(lifecycle, name, lambda *a, **kw: None)
|
|
||||||
for name in ("app_config_remove", "app_new", "env_set", "secret_generate"):
|
|
||||||
monkeypatch.setattr(lifecycle.abra, name, lambda *a, **kw: None)
|
|
||||||
monkeypatch.setattr(lifecycle.meta_mod, "extra_env", lambda *a: {})
|
|
||||||
monkeypatch.setattr(lifecycle.meta_mod, "hook_ctx", lambda *a: None)
|
|
||||||
monkeypatch.setattr(lifecycle.abra, "has_lightweight_version_tags", lambda r: False)
|
|
||||||
monkeypatch.setattr(lifecycle, "has_ccci_overlay", lambda r: False)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lifecycle.abra, "recipe_checkout", lambda r, v: events.append(("checkout", v))
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lifecycle.abra, "deploy", lambda d, **kw: events.append(("deploy", kw["chaos"]))
|
|
||||||
)
|
|
||||||
for apply_previous in (False, True):
|
|
||||||
events.clear()
|
|
||||||
lifecycle.deploy_app(
|
|
||||||
"example",
|
|
||||||
"example.invalid",
|
|
||||||
version="1.0.0",
|
|
||||||
apply_previous=apply_previous,
|
|
||||||
meta=object(),
|
|
||||||
_count_deploy=False,
|
|
||||||
)
|
|
||||||
assert events == [("checkout", "1.0.0"), ("deploy", apply_previous)]
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Convergence confirmation must retry a transient second-sample failure."""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from harness import generic
|
|
||||||
|
|
||||||
|
|
||||||
def _setup(monkeypatch, samples):
|
|
||||||
clock = [0]
|
|
||||||
monkeypatch.setattr(generic.time, "time", lambda: clock[0])
|
|
||||||
monkeypatch.setattr(generic.time, "sleep", lambda n: clock.__setitem__(0, clock[0] + n))
|
|
||||||
monkeypatch.setattr(generic.lifecycle, "services_converged", lambda d: next(samples))
|
|
||||||
monkeypatch.setattr(generic.lifecycle, "http_fetch", lambda *a: (200, "application"))
|
|
||||||
monkeypatch.setattr(generic, "served_cert", lambda d: (True, "*.ci.autonomic.zone"))
|
|
||||||
return SimpleNamespace(DEPLOY_TIMEOUT=20, HTTP_TIMEOUT=20, HEALTH_PATH="/", HEALTH_OK=(200,))
|
|
||||||
|
|
||||||
|
|
||||||
def test_confirmation_failure_retries_until_services_settle(monkeypatch):
|
|
||||||
meta = _setup(monkeypatch, iter([True, False, True, True]))
|
|
||||||
generic.assert_serving("example.ci.autonomic.zone", meta)
|
|
||||||
|
|
||||||
|
|
||||||
def test_persistent_confirmation_failure_still_fails(monkeypatch):
|
|
||||||
import itertools
|
|
||||||
|
|
||||||
meta = _setup(monkeypatch, itertools.cycle([True, False]))
|
|
||||||
with pytest.raises(AssertionError, match="services did not converge"):
|
|
||||||
generic.assert_serving("example.ci.autonomic.zone", meta)
|
|
||||||
Reference in New Issue
Block a user