@celilo/e2e 0.7.16 → 0.7.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +117 -0
- package/COVERAGE.md +60 -0
- package/bin/e2e-build +20 -0
- package/bin/e2e-cache-images +13 -6
- package/config/dns/iamtheinternet.org.zone +16 -0
- package/package.json +6 -3
- package/scripts/stage-apt-repo.ts +14 -2
- package/src/cli/build.ts +29 -0
- package/src/cli/command-registry.ts +3 -1
- package/src/cli/completion.ts +2 -0
- package/src/cli/index.ts +16 -3
- package/src/docker-compose-generator.ts +11 -0
- package/src/runner.ts +63 -6
- package/src/shared-infra.ts +34 -4
package/AGENTS.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# AGENTS.md — `@celilo/e2e`
|
|
2
|
+
|
|
3
|
+
`@celilo/e2e` is a **simulated internet in Docker** for end-to-end testing of
|
|
4
|
+
[celilo](https://celilo.computer)-deployed apps. It stands up a DNS hierarchy
|
|
5
|
+
(root → TLD → registrar → recursive resolver), an ACME server (Pebble, standing
|
|
6
|
+
in for Let's Encrypt), an ISP router + customer firewall with real iptables NAT,
|
|
7
|
+
zoned LAN networks, and systemd target machines — then runs celilo *inside* it as
|
|
8
|
+
if it were deploying to a real home lab. This file orients an AI agent to write
|
|
9
|
+
and run those tests. **Read the in-package docs below before searching the web** —
|
|
10
|
+
they are the source of truth.
|
|
11
|
+
|
|
12
|
+
This file ships **inside the npm package**: the `./`-prefixed docs in the doc map
|
|
13
|
+
are installed alongside it (grep them offline). Repo-relative entries point at the
|
|
14
|
+
source repository, which the tarball does not carry.
|
|
15
|
+
|
|
16
|
+
## Mental model (learn these five things)
|
|
17
|
+
|
|
18
|
+
- **The sim is a faithful internet, not a convenience harness.** Public DNS only
|
|
19
|
+
ever answers with the customer firewall's *external* IP; the firewall DNATs
|
|
20
|
+
inbound to the right internal host. ACME challenges validate over the public
|
|
21
|
+
path. A test that "passes" by short-circuiting any of this is testing a placebo
|
|
22
|
+
— see **Inviolable rules** below.
|
|
23
|
+
- **`network()` builder** — declare zones and the machines in them, `.start()` the
|
|
24
|
+
topology, drive celilo with `.celilo(...)`, assert, then `.stop()`. This is the
|
|
25
|
+
whole library surface (`import { network } from '@celilo/e2e'`).
|
|
26
|
+
- **Zones** — `internal` (home LAN), `dmz`, `app`, `secure` are the customer-side
|
|
27
|
+
networks; `isp-external` / `internet-external` model the public internet. Public
|
|
28
|
+
IPs live only on the public networks; the firewall containers are the only place
|
|
29
|
+
the two address spaces meet (via NAT/DNAT).
|
|
30
|
+
- **Two consumption modes** — as a **library** (`network()` in a `bun test` file)
|
|
31
|
+
or via the **`cele2e` CLI** (`cele2e run <test>`, `build-infra`, `list`). The
|
|
32
|
+
shared infra (`celilo-e2e-shared`) is a *global* Docker compose project,
|
|
33
|
+
independent of which checkout started it.
|
|
34
|
+
- **`build-infra` gate** — anything baked into a Docker image (`docker/`,
|
|
35
|
+
`config/`, `simulators/`, installed package tarballs) needs `cele2e build-infra`
|
|
36
|
+
before it takes effect. Pure `src/*.ts` changes do not (bun runs TS directly).
|
|
37
|
+
|
|
38
|
+
## Writing a test (the happy path)
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { test, expect } from 'bun:test';
|
|
42
|
+
import { network } from '@celilo/e2e';
|
|
43
|
+
|
|
44
|
+
test('caddy serves TLS in the dmz', async () => {
|
|
45
|
+
const net = await network()
|
|
46
|
+
.dmz({ caddy: '10.0.10.10' }) // machine name → zone-side IP
|
|
47
|
+
.start();
|
|
48
|
+
try {
|
|
49
|
+
await net.celilo('module import caddy');
|
|
50
|
+
await net.celilo('module deploy caddy --no-interactive');
|
|
51
|
+
// assert against the deployed service via the public path, not the container IP
|
|
52
|
+
} finally {
|
|
53
|
+
await net.stop();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Prefer the **fixtures** in `./src/fixtures.ts` (`CADDY_DEPLOYMENT`,
|
|
59
|
+
`FULL_STACK`, `TECHNITIUM_STACK`, …) over hand-rolling topology.
|
|
60
|
+
- Drive **everything through the celilo CLI** or the module's documented surface —
|
|
61
|
+
never `docker exec` to hand-edit a deployed container's config (that's the bug
|
|
62
|
+
you're trying to catch).
|
|
63
|
+
- Poll on the **observable condition** the deploy is meant to satisfy
|
|
64
|
+
(`VantageProbe`, DNS resolution, an HTTP header) — never `setTimeout` to mask a
|
|
65
|
+
race.
|
|
66
|
+
|
|
67
|
+
## CLI you'll use
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
cele2e list # enumerate every test (module suites + top-level)
|
|
71
|
+
cele2e build-infra # rebuild Docker images (required after image-baked changes)
|
|
72
|
+
cele2e run <test> [--keep] # run one test; --keep leaves the network up for debugging
|
|
73
|
+
cele2e run --all # full regression (every suite); --all-modules = modules only
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For Claude-driven / detached runs, fire via the repo wrapper
|
|
77
|
+
`infra/scripts/cele2e-run.sh <test> [--keep|--build]` (fire-and-forget RUNID/LOG
|
|
78
|
+
contract) rather than calling `cele2e run` from a long-lived watcher.
|
|
79
|
+
|
|
80
|
+
## Inviolable rules (breaking any turns the suite into a placebo)
|
|
81
|
+
|
|
82
|
+
1. **No RFC 1918 leaks past the public boundary.** Public DNS never serves an A
|
|
83
|
+
record pointing at `10/8`, `172.16/12`, or `192.168/16` — only the firewall's
|
|
84
|
+
external IP.
|
|
85
|
+
2. **Don't collapse the topology to fix a timing/connectivity bug.** Fix the
|
|
86
|
+
*deployment ordering* (DNS record / DNAT rule / firewall opening in place
|
|
87
|
+
before the dependent step), not the simulation.
|
|
88
|
+
3. **Public IPs only on the public networks.** Firewall containers bridge public
|
|
89
|
+
and private — the only place those spaces meet, and only via NAT/DNAT.
|
|
90
|
+
4. **ACME challenges go through the public path** to the firewall's external IP.
|
|
91
|
+
5. **DDNS updates modify *public* DNS** with the firewall's external IP, never an
|
|
92
|
+
internal target IP.
|
|
93
|
+
|
|
94
|
+
Cheats (`/etc/hosts` overrides, `iptables -F`, `--insecure` in module code,
|
|
95
|
+
hardcoded internal IPs, `|| true` on a celilo command, sleeping past a race) are
|
|
96
|
+
fine for **bisecting** a failure but are never a **final solution** — confirm the
|
|
97
|
+
hypothesis, then ship the real fix. Full list + rationale in `./README.md` and the
|
|
98
|
+
repo `CLAUDE.md`.
|
|
99
|
+
|
|
100
|
+
## Doc map (read in this order)
|
|
101
|
+
|
|
102
|
+
Shipped **inside this package** (offline, grep-friendly):
|
|
103
|
+
|
|
104
|
+
- `./README.md` — network architecture, machines, routing, DNS, simulators,
|
|
105
|
+
manual usage. Start here.
|
|
106
|
+
- `./COVERAGE.md` — the array/object/map coverage audit (where N>1 paths are or
|
|
107
|
+
aren't exercised). Consult before adding a multi-element feature test.
|
|
108
|
+
- `./src/index.ts` — the public library exports (`network`, types, probes).
|
|
109
|
+
- `./src/fixtures.ts` — ready-made topology presets to build tests on.
|
|
110
|
+
- `./src/types.ts` — `NetworkHandle`, `Zone`, `MachineSpec`, `Vantage`, and the
|
|
111
|
+
zone subnet/gateway constants.
|
|
112
|
+
|
|
113
|
+
In the **source repo** (not carried in the tarball):
|
|
114
|
+
|
|
115
|
+
- `infra/docs/RUNNING_CELE2E_TESTS.md` — the full operator guide; read before
|
|
116
|
+
running the suite for real.
|
|
117
|
+
- `https://celilo.computer/docs` — hosted docs (LAN; the repo docs lead).
|
package/COVERAGE.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# E2E Test Coverage Audit
|
|
2
|
+
|
|
3
|
+
This document tracks how well the e2e suite exercises capability and module
|
|
4
|
+
fields whose type is `array`, `object`, or `map` — anywhere the framework's
|
|
5
|
+
public API admits "more than one of X."
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
A test that only ever uses N=1 will pass even when the multi-element path is
|
|
10
|
+
broken. The bug fixed by `dns_registrar` 4.0.0 (see
|
|
11
|
+
`apps/celilo/designs/DNS_REGISTRAR_MULTI_DOMAIN.md`) escaped the existing e2e
|
|
12
|
+
suite for exactly this reason: the website-deploy-simple test put caddy, namecheap,
|
|
13
|
+
and the website all on a single domain, and the multi-domain code path was
|
|
14
|
+
never exercised.
|
|
15
|
+
|
|
16
|
+
## Audit checklist
|
|
17
|
+
|
|
18
|
+
For every capability and every module manifest, identify each field whose
|
|
19
|
+
type is `array`, `object`, or `map`. For each one, ask:
|
|
20
|
+
|
|
21
|
+
1. Does at least one e2e test exercise this field with **N > 1** distinct
|
|
22
|
+
values?
|
|
23
|
+
2. Are those values **meaningfully different** (different domains, different
|
|
24
|
+
zones, different ports — not just "two of the same kind of thing")?
|
|
25
|
+
3. Does the test assert behavior that would be **wrong if only the first
|
|
26
|
+
element worked**?
|
|
27
|
+
|
|
28
|
+
Anywhere all three answers aren't "yes," that's a gap to either close (write
|
|
29
|
+
the test) or document why it's intentionally deferred.
|
|
30
|
+
|
|
31
|
+
## Coverage table
|
|
32
|
+
|
|
33
|
+
| Field | N>1? | Meaningfully different? | Asserts non-first works? | Status |
|
|
34
|
+
|---|---|---|---|---|
|
|
35
|
+
| `namecheap.domains` | yes (`website-deploy-cross-domain`) | yes (different TLDs: `iamtheinternet.org` + `example.net`) | yes (verifies example.net DNS+TLS while iamtheinternet.org is canonical) | covered |
|
|
36
|
+
| `namecheap.ddns_passwords` (per-domain map) | yes (same test) | yes (distinct passwords per domain) | yes (uses example.net's own password to update its A record) | covered |
|
|
37
|
+
| `caddy.hostnames` | yes (`website-deploy-simple`) | partial (same domain in `website-deploy-simple.test.ts`); yes (cross-TLD in cross-domain test) | yes (cross-domain test serves a hostname that isn't `hostnames[0]`) | covered |
|
|
38
|
+
| `iptables.exposed_services` | unclear — needs audit | — | — | **GAP** |
|
|
39
|
+
| `public_web` registrations across modules | partial (`website-deploy-simple.test.ts` registers auth + website on the same domain) | needs cross-domain variant | partial | **GAP** |
|
|
40
|
+
| `<every secret: object field>` | typically only one key | usually no | — | **AUDIT EACH** |
|
|
41
|
+
|
|
42
|
+
## Closing the gaps
|
|
43
|
+
|
|
44
|
+
- **`iptables.exposed_services`**: write an e2e that exposes two services
|
|
45
|
+
on the same machine via different ports and verifies both are reachable
|
|
46
|
+
from outside.
|
|
47
|
+
- **`public_web` cross-domain**: extend `website-deploy-simple.test.ts` (or a new
|
|
48
|
+
test) to register routes on two different domains and verify each is
|
|
49
|
+
served by the right backend.
|
|
50
|
+
- **secret object fields**: as modules using these grow (e.g. third-party
|
|
51
|
+
API integrations with multiple keys), add per-key tests.
|
|
52
|
+
|
|
53
|
+
## New-module checklist
|
|
54
|
+
|
|
55
|
+
When authoring a module, list every field whose type permits multiple
|
|
56
|
+
values. For each, write at least one e2e or integration test that uses
|
|
57
|
+
**N ≥ 2** with meaningfully different values. Add a row to the table
|
|
58
|
+
above (or document why you've deferred it).
|
|
59
|
+
|
|
60
|
+
See `design/MODULE_DEVELOPMENT_GUIDE.md` for the full new-module checklist.
|
package/bin/e2e-build
CHANGED
|
@@ -22,6 +22,7 @@ NETAPPS_DIR="$E2E_DIR/netapps"
|
|
|
22
22
|
cd "$E2E_DIR"
|
|
23
23
|
|
|
24
24
|
GREEN='\033[0;32m'
|
|
25
|
+
YELLOW='\033[0;33m'
|
|
25
26
|
DIM='\033[2m'
|
|
26
27
|
BOLD='\033[1m'
|
|
27
28
|
NC='\033[0m'
|
|
@@ -160,6 +161,25 @@ BAKE_END=$(date +%s)
|
|
|
160
161
|
echo -e "${GREEN}Baked in $((BAKE_END - BAKE_START))s${NC}"
|
|
161
162
|
echo ""
|
|
162
163
|
|
|
164
|
+
# ── Step 2d: Pre-seed heavy app images into the app-zone preload cache ─────────
|
|
165
|
+
#
|
|
166
|
+
# Heavy application images (authentik server, postgres, redis) are pulled ONCE
|
|
167
|
+
# on the host here and saved to docker-image-cache/, which app-zone target
|
|
168
|
+
# machines bind-mount and load at startup via docker-image-preload.service.
|
|
169
|
+
# Without this the cache is empty on a fresh checkout / the builder, so every
|
|
170
|
+
# app-module deploy pulls GB through the simulated internet — slow enough to
|
|
171
|
+
# time the authentik deploy out (exit 124) and take down forgejo-deploy +
|
|
172
|
+
# full-stack-pipeline (ISS-0154). Best-effort: e2e-cache-images is per-image
|
|
173
|
+
# resilient and the preload service skips a missing tarball, so a transient
|
|
174
|
+
# registry hiccup degrades to a slow pull rather than failing build-infra.
|
|
175
|
+
echo -e "${BOLD}Pre-seeding app-zone image cache...${NC}"
|
|
176
|
+
if bash "$E2E_DIR/bin/e2e-cache-images"; then
|
|
177
|
+
:
|
|
178
|
+
else
|
|
179
|
+
echo -e "${YELLOW}⚠ image cache step failed — app-zone deploys may pull over the sim${NC}" >&2
|
|
180
|
+
fi
|
|
181
|
+
echo ""
|
|
182
|
+
|
|
163
183
|
# ── Step 3: Optionally save images to tarball ─────────────────────────────────
|
|
164
184
|
|
|
165
185
|
if [ "$1" = "--save" ]; then
|
package/bin/e2e-cache-images
CHANGED
|
@@ -23,6 +23,7 @@ mkdir -p "$CACHE_DIR"
|
|
|
23
23
|
|
|
24
24
|
BOLD='\033[1m'
|
|
25
25
|
GREEN='\033[0;32m'
|
|
26
|
+
YELLOW='\033[0;33m'
|
|
26
27
|
DIM='\033[2m'
|
|
27
28
|
NC='\033[0m'
|
|
28
29
|
|
|
@@ -44,12 +45,18 @@ for filename in "${!IMAGES[@]}"; do
|
|
|
44
45
|
printf " %-50s " "$image"
|
|
45
46
|
START=$(date +%s)
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
# Per-image best-effort: a transient registry failure for one image must not
|
|
49
|
+
# abort caching the others (this runs inside build-infra now). A guarded
|
|
50
|
+
# command does not trip `set -e`. The preload service skips a missing tarball
|
|
51
|
+
# gracefully — that module just pulls over the sim at deploy time.
|
|
52
|
+
if docker pull "$image" > /dev/null 2>&1 && docker save "$image" -o "$tarball"; then
|
|
53
|
+
END=$(date +%s)
|
|
54
|
+
SIZE=$(du -h "$tarball" | cut -f1)
|
|
55
|
+
echo -e "${GREEN}✔${NC} ${DIM}$((END - START))s ${SIZE}${NC}"
|
|
56
|
+
else
|
|
57
|
+
rm -f "$tarball"
|
|
58
|
+
echo -e "${YELLOW}✘ failed — will pull over the sim at deploy time${NC}"
|
|
59
|
+
fi
|
|
53
60
|
done
|
|
54
61
|
|
|
55
62
|
echo ""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
$ORIGIN iamtheinternet.org.
|
|
2
|
+
$TTL 300
|
|
3
|
+
|
|
4
|
+
@ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
|
|
5
|
+
2024020247 300 60 604800 300
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
|
|
9
|
+
; the e2e simulation is to model real internet connectivity, where
|
|
10
|
+
; private IPs are unreachable from outside. All public-facing names
|
|
11
|
+
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
|
+
; inbound to the right internal host.
|
|
13
|
+
@ IN NS ns1.iamtheinternet.org.
|
|
14
|
+
ns1 IN A 100.64.0.55
|
|
15
|
+
@ IN A 0.0.0.0
|
|
16
|
+
www IN A 100.100.0.100
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.17",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "bun test tests/",
|
|
22
|
-
"test:completion": "bun test tests/completion"
|
|
22
|
+
"test:completion": "bun test tests/completion",
|
|
23
|
+
"test:integration": "bun test tests-integration/"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"src/",
|
|
@@ -33,7 +34,9 @@
|
|
|
33
34
|
"npm-registry-server/tsconfig.json",
|
|
34
35
|
"npm-registry-server/src/",
|
|
35
36
|
"scripts/",
|
|
36
|
-
"README.md"
|
|
37
|
+
"README.md",
|
|
38
|
+
"AGENTS.md",
|
|
39
|
+
"COVERAGE.md"
|
|
37
40
|
],
|
|
38
41
|
"dependencies": {
|
|
39
42
|
"@celilo/cli-display": "^0.1.9",
|
|
@@ -65,11 +65,24 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
|
|
|
65
65
|
process.stdout.write(` ${'apt-repo (build debs)'.padEnd(28)} `);
|
|
66
66
|
const t0 = Date.now();
|
|
67
67
|
|
|
68
|
+
// The deb must install the EXACT version the npm-registry-sim serves locally
|
|
69
|
+
// (= apps/celilo/package.json). Pin CELILO_VERSION so the postinst runs
|
|
70
|
+
// `bun add @celilo/cli@<version>` rather than `@celilo/cli@alpha` (build-deb.sh's
|
|
71
|
+
// default): the sim has no local `alpha` dist-tag, so an `@alpha` install leaks
|
|
72
|
+
// to the real npm uplink and pulls a stale published alpha (ISS-0153). Pinning
|
|
73
|
+
// makes the deb version, the installed npm version, and the test expectation a
|
|
74
|
+
// single source of truth.
|
|
75
|
+
const version = currentCeliloVersion(repoRoot);
|
|
76
|
+
|
|
68
77
|
// Reuse the canonical build-deb scripts so versioning/contents match a
|
|
69
78
|
// real release exactly. build:deb builds both arches; build:deb:bootstrap
|
|
70
79
|
// builds the arch-all meta-package.
|
|
71
80
|
for (const script of ['build:deb', 'build:deb:bootstrap']) {
|
|
72
|
-
const result = spawnSync('bun', ['run', script], {
|
|
81
|
+
const result = spawnSync('bun', ['run', script], {
|
|
82
|
+
cwd: repoRoot,
|
|
83
|
+
stdio: 'pipe',
|
|
84
|
+
env: { ...process.env, CELILO_VERSION: version },
|
|
85
|
+
});
|
|
73
86
|
if (result.status !== 0) {
|
|
74
87
|
console.log(`${red}✗${reset}`);
|
|
75
88
|
console.error(result.stderr?.toString());
|
|
@@ -81,7 +94,6 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
|
|
|
81
94
|
// Stage every freshly-built .deb into the pool. dist/ may also hold older
|
|
82
95
|
// versions; copy only the current ones so the repo doesn't advertise
|
|
83
96
|
// stale celilo versions the npm-registry-sim no longer serves.
|
|
84
|
-
const version = currentCeliloVersion(repoRoot);
|
|
85
97
|
const distDir = join(repoRoot, 'dist');
|
|
86
98
|
|
|
87
99
|
const debs = readdirSync(distDir).filter((f) => f.endsWith('.deb') && f.includes(version));
|
package/src/cli/build.ts
CHANGED
|
@@ -48,6 +48,7 @@ const bold = '\x1b[1m';
|
|
|
48
48
|
const dim = '\x1b[2m';
|
|
49
49
|
const green = '\x1b[32m';
|
|
50
50
|
const red = '\x1b[31m';
|
|
51
|
+
const yellow = '\x1b[33m';
|
|
51
52
|
const reset = '\x1b[0m';
|
|
52
53
|
|
|
53
54
|
interface BuildOptions {
|
|
@@ -320,6 +321,30 @@ function bakeManagement(pkgDir: string): void {
|
|
|
320
321
|
console.log(`\n${green}Baked in ${elapsed}s${reset}\n`);
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Pre-seed heavy application images (authentik server, postgres, redis) into
|
|
326
|
+
* the app-zone preload cache. Without this the `docker-image-cache` is empty on
|
|
327
|
+
* a fresh checkout / the builder, so every app-module deploy pulls GB through
|
|
328
|
+
* the simulated internet — slow enough to time the authentik deploy out
|
|
329
|
+
* (exit 124) and take down forgejo-deploy + full-stack-pipeline (ISS-0154).
|
|
330
|
+
* Best-effort: `e2e-cache-images` is per-image resilient and the preload
|
|
331
|
+
* service skips a missing tarball, so a transient registry hiccup degrades to a
|
|
332
|
+
* slow pull rather than failing build-infra.
|
|
333
|
+
*/
|
|
334
|
+
function cacheAppImages(pkgDir: string): void {
|
|
335
|
+
const cacheScript = join(pkgDir, 'bin', 'e2e-cache-images');
|
|
336
|
+
if (!existsSync(cacheScript)) return;
|
|
337
|
+
|
|
338
|
+
console.log(`${bold}Pre-seeding app-zone image cache...${reset}\n`);
|
|
339
|
+
const result = spawnSync('bash', [cacheScript], { stdio: 'inherit' });
|
|
340
|
+
if (result.status !== 0) {
|
|
341
|
+
console.error(
|
|
342
|
+
`${yellow}⚠ image cache step failed — app-zone deploys may pull over the sim${reset}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
console.log('');
|
|
346
|
+
}
|
|
347
|
+
|
|
323
348
|
function saveImages(pkgDir: string): void {
|
|
324
349
|
const cacheDir = join(pkgDir, '.docker-cache');
|
|
325
350
|
mkdirSync(cacheDir, { recursive: true });
|
|
@@ -406,6 +431,10 @@ export function runBuild(options: BuildOptions): void {
|
|
|
406
431
|
// install.sh, rather than a parallel `bun add -g` pinning.
|
|
407
432
|
bakeManagement(pkgDir);
|
|
408
433
|
|
|
434
|
+
// Pre-seed heavy app images so app-zone deploys load them from the cache
|
|
435
|
+
// instead of pulling GB through the sim (ISS-0154).
|
|
436
|
+
cacheAppImages(pkgDir);
|
|
437
|
+
|
|
409
438
|
if (save) {
|
|
410
439
|
saveImages(pkgDir);
|
|
411
440
|
}
|
|
@@ -26,6 +26,8 @@ export const COMMANDS: CommandDef[] = [
|
|
|
26
26
|
description: 'Run e2e tests — pass a module path or pattern, or --all / --all-modules',
|
|
27
27
|
flags: [
|
|
28
28
|
{ name: '--all', description: 'Run the full regression: every module suite + top-level e2e/tests/' },
|
|
29
|
+
{ name: '--complete', description: 'Alias of --all: run every test, including ci-unsafe (quarantined) ones' },
|
|
30
|
+
{ name: '--ci-safe', description: 'Full regression EXCEPT tests marked cele2e-ci-unsafe (used by the nightly)' },
|
|
29
31
|
{ name: '--all-modules', description: 'Run e2e tests for all modules with e2e/ directories' },
|
|
30
32
|
{ name: '--keep', description: 'Keep network running after tests' },
|
|
31
33
|
{ name: '--reuse', description: 'Reuse existing network if running' },
|
|
@@ -99,7 +101,7 @@ export const COMMANDS: CommandDef[] = [
|
|
|
99
101
|
|
|
100
102
|
export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
|
|
101
103
|
|
|
102
|
-
export const RUN_FLAGS = ['--all', '--all-modules', '--keep', '--reuse', '--live', '--published'];
|
|
104
|
+
export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published'];
|
|
103
105
|
|
|
104
106
|
export const DOWN_FLAGS = ['--keep', '--all'];
|
|
105
107
|
|
package/src/cli/completion.ts
CHANGED
|
@@ -119,6 +119,8 @@ _cele2e() {
|
|
|
119
119
|
'--reuse[Reuse existing network if running]' \\
|
|
120
120
|
'--live[Use live (non-simulated) internet]' \\
|
|
121
121
|
'--published[Use published .netapp packages]' \\
|
|
122
|
+
'--ci[Plain log output: one line per step, no spinner]' \\
|
|
123
|
+
'--no-ci[Force spinner even when a CI env var is set]' \\
|
|
122
124
|
'*::test name or module path:_cele2e_run_args'
|
|
123
125
|
;;
|
|
124
126
|
list)
|
package/src/cli/index.ts
CHANGED
|
@@ -139,6 +139,8 @@ Options for \`run\`:
|
|
|
139
139
|
--reuse Reuse existing network if running
|
|
140
140
|
--live Use live (non-simulated) internet
|
|
141
141
|
--published Use published .netapp packages
|
|
142
|
+
--ci Plain log output: one ✔/✗ line per step, no spinner
|
|
143
|
+
(auto-on when a CI env var is set; --no-ci forces off)
|
|
142
144
|
|
|
143
145
|
Options for \`up\`:
|
|
144
146
|
--caddy Start with caddy machine in DMZ
|
|
@@ -187,8 +189,17 @@ switch (command) {
|
|
|
187
189
|
// repo's top-level e2e/tests/ in one invocation; `--all-modules` runs just
|
|
188
190
|
// the module suites. Both resolve the repo root via resolveSuiteRoot() so
|
|
189
191
|
// they work through the `./cele2e` wrapper's cd into packages/e2e.
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
+
if (
|
|
193
|
+
args.includes('--all') ||
|
|
194
|
+
args.includes('--all-modules') ||
|
|
195
|
+
args.includes('--complete') ||
|
|
196
|
+
args.includes('--ci-safe')
|
|
197
|
+
) {
|
|
198
|
+
// --complete and --ci-safe both run the full discovery (every module +
|
|
199
|
+
// the repo's top-level e2e/tests/). --ci-safe additionally has the runner
|
|
200
|
+
// skip tests marked `cele2e-ci-unsafe`. --all-modules stays modules-only.
|
|
201
|
+
const wantTopLevel =
|
|
202
|
+
args.includes('--all') || args.includes('--complete') || args.includes('--ci-safe');
|
|
192
203
|
const suiteRoot = resolveSuiteRoot();
|
|
193
204
|
const allModules = findAllModules(suiteRoot);
|
|
194
205
|
const env: Record<string, string> = {
|
|
@@ -206,7 +217,9 @@ switch (command) {
|
|
|
206
217
|
}
|
|
207
218
|
runScript(
|
|
208
219
|
'e2e-run',
|
|
209
|
-
|
|
220
|
+
// Strip the discovery aliases; KEEP --ci-safe so the runner applies the
|
|
221
|
+
// `cele2e-ci-unsafe` quarantine exclusion.
|
|
222
|
+
args.filter((a) => a !== '--all' && a !== '--all-modules' && a !== '--complete'),
|
|
210
223
|
env,
|
|
211
224
|
);
|
|
212
225
|
}
|
|
@@ -396,6 +396,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
396
396
|
variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
|
|
397
397
|
networks: { internal: { ipv4_address: ip } },
|
|
398
398
|
cap_add: ['NET_ADMIN'],
|
|
399
|
+
// ISS-0157: `module import`'s `bun install` hard-links packages from the
|
|
400
|
+
// bun cache into node_modules, which on overlayfs forces a copy-up + fsync
|
|
401
|
+
// per file. On a slow/contended builder disk that storm blows past the
|
|
402
|
+
// 120s import timeout (intermittently). Put the celilo data dir (the
|
|
403
|
+
// node_modules install target) on tmpfs (RAM): bun then copies cache→tmpfs
|
|
404
|
+
// (cross-fs, no overlay copy-up) at RAM speed, immune to host disk. NOT
|
|
405
|
+
// /root/.bun itself — that holds the baked `celilo` CLI (`bun add -g`).
|
|
406
|
+
// Tmpfs the bun *cache* subdir too: the copy-up + fsync storm is the
|
|
407
|
+
// overlay upper layer under /root/.bun/install/cache. RAM-backing it (cold
|
|
408
|
+
// → re-fetch over the fast sim/net) keeps the CLI intact. Ephemeral e2e.
|
|
409
|
+
tmpfs: ['/root/.local/share/celilo', '/root/.bun/install/cache'],
|
|
399
410
|
volumes: [
|
|
400
411
|
...(celiloRoot ? [`${celiloRoot}:/celilo`] : []),
|
|
401
412
|
'ssh-keys:/ssh-keys',
|
package/src/runner.ts
CHANGED
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
22
|
-
import { basename, join, resolve } from 'node:path';
|
|
22
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
23
23
|
import { parse as parseYaml } from 'yaml';
|
|
24
|
-
import { ProgressDisplay } from '@celilo/cli-display';
|
|
24
|
+
import { type DisplayMode, ProgressDisplay } from '@celilo/cli-display';
|
|
25
25
|
import {
|
|
26
26
|
emitRunCompleted,
|
|
27
27
|
emitRunFailed,
|
|
@@ -70,7 +70,23 @@ const flagReuse = rawArgs.includes('--reuse');
|
|
|
70
70
|
const flagLive = rawArgs.includes('--live');
|
|
71
71
|
const flagPublished = rawArgs.includes('--published');
|
|
72
72
|
const flagVerbose = rawArgs.includes('--verbose');
|
|
73
|
+
// CI mode: no animated spinner, no [progress:*] markers — just one clean
|
|
74
|
+
// ✔/✗ line per step (plus sub-events) written straight to the log. Forgejo's
|
|
75
|
+
// runner allocates a PTY, so process.stdout.isTTY is truthy there and the
|
|
76
|
+
// interactive footer (redrawn via `\r` on every spinner tick) gets linearized
|
|
77
|
+
// into one log line per tick — dozens of identical braille lines per step.
|
|
78
|
+
// `--ci` (or any standard `CI` env var, e.g. Forgejo/GitHub Actions) forces
|
|
79
|
+
// the non-interactive render path that sidesteps that entirely. `--no-ci`
|
|
80
|
+
// opts back out when a CI env var is set but an operator wants the spinner.
|
|
81
|
+
const flagCi = rawArgs.includes('--ci');
|
|
82
|
+
const flagNoCi = rawArgs.includes('--no-ci');
|
|
83
|
+
const ci = flagCi || (!flagNoCi && !!process.env.CI);
|
|
73
84
|
const patterns = rawArgs.filter((a) => !a.startsWith('--'));
|
|
85
|
+
// --ci-safe: skip tests marked `cele2e-ci-unsafe` in their file (quarantined —
|
|
86
|
+
// known-failing or builder-flaky, each tracked by an ISS). `--complete`/`--all`
|
|
87
|
+
// run everything. Passed through from the CLI (not stripped) so the runner can
|
|
88
|
+
// apply the exclusion.
|
|
89
|
+
const flagCiSafe = rawArgs.includes('--ci-safe');
|
|
74
90
|
|
|
75
91
|
// ─── Colors ──────────────────────────────────────────────────────────
|
|
76
92
|
|
|
@@ -81,7 +97,8 @@ const red = '\x1b[31m';
|
|
|
81
97
|
const cyan = '\x1b[36m';
|
|
82
98
|
const reset = '\x1b[0m';
|
|
83
99
|
|
|
84
|
-
const interactive =
|
|
100
|
+
const interactive =
|
|
101
|
+
process.stdout.isTTY && !process.argv.includes('--no-interactive') && !flagVerbose && !ci;
|
|
85
102
|
|
|
86
103
|
// ─── Timing ──────────────────────────────────────────────────────────
|
|
87
104
|
|
|
@@ -454,12 +471,45 @@ async function main() {
|
|
|
454
471
|
.filter((f) => f.endsWith('.test.ts'))
|
|
455
472
|
.sort()
|
|
456
473
|
.map((f) => resolve(testsPath, f));
|
|
474
|
+
// Docker-backed integration tests live in a sibling `tests-integration/`
|
|
475
|
+
// so they stay OUT of the hermetic `bun test tests/` gate. cele2e is the
|
|
476
|
+
// heavy runner (Docker + build-infra present), so it covers BOTH tiers —
|
|
477
|
+
// this is what keeps the documented `cele2e run ansible-output` working
|
|
478
|
+
// after the test moved out of tests/.
|
|
479
|
+
const integrationPath = join(dirname(testsPath), 'tests-integration');
|
|
480
|
+
if (existsSync(integrationPath)) {
|
|
481
|
+
testFiles.push(
|
|
482
|
+
...readdirSync(integrationPath)
|
|
483
|
+
.filter((f) => f.endsWith('.test.ts'))
|
|
484
|
+
.sort()
|
|
485
|
+
.map((f) => resolve(integrationPath, f)),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
457
488
|
}
|
|
458
489
|
|
|
459
490
|
if (patterns.length > 0) {
|
|
460
491
|
testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
|
|
461
492
|
}
|
|
462
493
|
|
|
494
|
+
// --ci-safe: drop quarantined tests — those whose file contains the marker
|
|
495
|
+
// `cele2e-ci-unsafe` (a comment naming the reason + ISS). Keeps the nightly
|
|
496
|
+
// green on the CI-safe subset while the quarantined tests are tracked + fixed
|
|
497
|
+
// separately. `--complete`/`--all` run everything (no exclusion).
|
|
498
|
+
if (flagCiSafe) {
|
|
499
|
+
const before = testFiles.length;
|
|
500
|
+
testFiles = testFiles.filter((f) => {
|
|
501
|
+
try {
|
|
502
|
+
return !readFileSync(f, 'utf-8').includes('cele2e-ci-unsafe');
|
|
503
|
+
} catch {
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
const skipped = before - testFiles.length;
|
|
508
|
+
if (skipped > 0) {
|
|
509
|
+
console.log(`${dim}--ci-safe: skipped ${skipped} quarantined (cele2e-ci-unsafe) test(s)${reset}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
463
513
|
if (testFiles.length === 0) {
|
|
464
514
|
console.error(`${red}No test files matched${reset}`);
|
|
465
515
|
process.exit(1);
|
|
@@ -524,14 +574,21 @@ async function main() {
|
|
|
524
574
|
|
|
525
575
|
// 'auto' resolves to 'render' when isTTY is true (operator at a
|
|
526
576
|
// terminal sees animated spinners) and 'protocol' when isTTY is
|
|
527
|
-
// false (
|
|
528
|
-
//
|
|
577
|
+
// false (cele2e-run.sh wrapper / any redirected stdout gets ASCII
|
|
578
|
+
// `[progress:*]` markers — grep-friendly, no braille noise).
|
|
529
579
|
// Prior to this change the mode was hardcoded 'render', so non-TTY
|
|
530
580
|
// callers got `⣾ phase started` / `✔ phase done` lines per phase
|
|
531
581
|
// (e.g. ~29 braille codepoints in a typical test log) — cosmetic
|
|
532
582
|
// but actively unhelpful for log scanning and tooling.
|
|
583
|
+
//
|
|
584
|
+
// CI mode forces 'render' with isTTY:false (non-interactive render):
|
|
585
|
+
// one ✔/✗ line per step, sub-events emitted inline, NO spinner and
|
|
586
|
+
// NO `[progress:*]` markers. We can't rely on 'auto' here because
|
|
587
|
+
// Forgejo's PTY makes isTTY truthy — which would pick the animated
|
|
588
|
+
// footer and spam the log with one braille line per spinner tick.
|
|
589
|
+
const displayMode: DisplayMode = ci ? 'render' : 'auto';
|
|
533
590
|
const display = new ProgressDisplay({
|
|
534
|
-
mode:
|
|
591
|
+
mode: displayMode,
|
|
535
592
|
out: { write: process.stdout.write.bind(process.stdout), isTTY: interactive },
|
|
536
593
|
});
|
|
537
594
|
let passed = 0;
|
package/src/shared-infra.ts
CHANGED
|
@@ -172,11 +172,16 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
172
172
|
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`,
|
|
173
173
|
{ cwd: e2eDir, timeout: 120_000 });
|
|
174
174
|
|
|
175
|
-
// Wait for DNS convergence
|
|
175
|
+
// Wait for DNS convergence — robustly. The old single 60s window with no
|
|
176
|
+
// recovery was an intermittent build-infra failure on the builder (#319:
|
|
177
|
+
// namecheap-dns hadn't served its zone within 60s, and the bare throw gave
|
|
178
|
+
// no clue why). Now: a longer window, RESTART the DNS container if it's
|
|
179
|
+
// stuck (re-triggers the zone load), and DUMP diagnostics before giving up.
|
|
176
180
|
console.log('[progress:start] waiting for DNS convergence | DNS converged');
|
|
181
|
+
const dnsTimeout = 180_000;
|
|
177
182
|
const start = Date.now();
|
|
178
|
-
|
|
179
|
-
while (Date.now() - start <
|
|
183
|
+
let lastRestart = start;
|
|
184
|
+
while (Date.now() - start < dnsTimeout) {
|
|
180
185
|
try {
|
|
181
186
|
const result = run(
|
|
182
187
|
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2`,
|
|
@@ -190,9 +195,34 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
190
195
|
} catch {
|
|
191
196
|
// retry
|
|
192
197
|
}
|
|
198
|
+
// Recovery: if ~45s have passed since the last (re)start without
|
|
199
|
+
// converging, the DNS container likely didn't load its zone — restart
|
|
200
|
+
// it to re-trigger the load instead of waiting out the whole window.
|
|
201
|
+
if (Date.now() - lastRestart > 45_000) {
|
|
202
|
+
console.log('[progress:sub] DNS not converged yet — restarting namecheap-dns');
|
|
203
|
+
try {
|
|
204
|
+
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} restart namecheap-dns`,
|
|
205
|
+
{ cwd: e2eDir, timeout: 30_000 });
|
|
206
|
+
} catch {}
|
|
207
|
+
lastRestart = Date.now();
|
|
208
|
+
}
|
|
193
209
|
await new Promise(r => setTimeout(r, 2000));
|
|
194
210
|
}
|
|
195
|
-
|
|
211
|
+
// Diagnostics before failing, so the log shows WHY it didn't converge.
|
|
212
|
+
let psOut = '';
|
|
213
|
+
try {
|
|
214
|
+
psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`,
|
|
215
|
+
{ cwd: e2eDir, timeout: 10_000 });
|
|
216
|
+
} catch {}
|
|
217
|
+
let dnsLog = '';
|
|
218
|
+
try {
|
|
219
|
+
dnsLog = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} logs --tail 40 namecheap-dns`,
|
|
220
|
+
{ cwd: e2eDir, timeout: 10_000 });
|
|
221
|
+
} catch {}
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Timeout waiting for shared infrastructure DNS convergence (${dnsTimeout / 1000}s)\n` +
|
|
224
|
+
`--- compose ps ---\n${psOut}\n--- namecheap-dns logs (tail 40) ---\n${dnsLog}`,
|
|
225
|
+
);
|
|
196
226
|
}
|
|
197
227
|
|
|
198
228
|
/**
|