revert: drop runtime-image-en language variant

This commit is contained in:
sunjunzhao committed 2026-09-27 19:29:24 +08:00
1 parent 2ce3ab66e7
commit 5eb50bf3ba
4 files changed
+8 -324

No files matched your search

+8 -12
View File
@@ -10,11 +10,10 @@
npx skills add gitea@livebook:images/skills.git npx skills add gitea@livebook:images/skills.git
``` ```
按语言选择安装(中文版 `runtime-image` / 英文版 `runtime-image-en`,同一能力的两个语言版本,选一个即可): 只安装指定 skill:
```bash ```bash
npx skills add gitea@livebook:images/skills.git --skill runtime-image npx skills add gitea@livebook:images/skills.git --skill runtime-image
npx skills add gitea@livebook:images/skills.git --skill runtime-image-en
``` ```
## 卸载 ## 卸载
@@ -33,10 +32,9 @@ npx skills remove runtime-image -g
## Skills 列表 ## Skills 列表
| Skill | 语言 | 说明 | | Skill | 说明 |
| --- | --- | --- | | --- | --- |
| [runtime-image](skills/runtime-image/SKILL.md) | 中文 | 探测项目技术栈,按 runtime-base 镜像矩阵选型并生成/修正 Dockerfile | | [runtime-image](skills/runtime-image/SKILL.md) | 探测项目技术栈,按 runtime-base 镜像矩阵选型并生成/修正 Dockerfile |
| [runtime-image-en](skills/runtime-image-en/SKILL.md) | English | English version of runtime-image: detect the tech stack, pick a base image from the runtime-base matrix, generate/audit the Dockerfile |
## 目录结构 ## 目录结构
@@ -44,12 +42,10 @@ npx skills remove runtime-image -g
``` ```
skills/ skills/
├── runtime-image/ # 中文版 └── runtime-image/
│ ├── SKILL.md # skill 主体(必需) ├── SKILL.md # skill 主体(必需)
│ ├── references/ # 补充参考文档 ├── references/ # 补充参考文档
│ └── scripts/ # 辅助脚本 └── scripts/ # 辅助脚本
└── runtime-image-en/ # English version
└── ...
``` ```
## License ## License
-193
View File
@@ -1,193 +0,0 @@
---
name: runtime-image-en
description: Detects a project's tech stack and picks the correct base runtime image from the runtime-base image matrix, generating or fixing the project Dockerfile; can also audit an existing Dockerfile and repair it. Use whenever the user asks to "write a Dockerfile / containerize this project / pick a base image / which FROM image / package this into an image / deploy this project as a container / review my Dockerfile / optimize / audit / slim the image", or when symptoms appear such as container won't start / exec format error / image too large / pip compile failures / latest tag behavior changed, or when runtime-node / runtime-pm2 / runtime-base / livebook:8418 / code.innev.cn / registry mirror are mentioned — even if the user never says "runtime image". Scope boundaries — building/pushing app images belongs to project CI or xgent-image-push; modifying the runtime images themselves (adding images, changing versions) belongs to the runtime-base repo, not this skill. English version of the runtime-image skill.
---
# runtime-image — Pick a runtime image by project language and environment
Based on the runtime-base repo's image matrix (10 images: 7 Alpine-based Linux + 3 Wine), complete "detect stack → select image → write Dockerfile → verify on a real machine" inside the project. Two working modes:
- **Create mode**: the project has no Dockerfile; select from scratch and generate one
- **Audit-and-fix mode**: the project already has a Dockerfile (or the user asks to "look at this Dockerfile"); audit first, then fix
Core idea (the reasoning chain every constraint hangs on): **the runtime series is only responsible for running already-compiled code** — building/compiling is done by the project's own CI; images contain no compilers/SDKs (the only exception is the Node family: install+build happens at container start, with the node-gyp toolchain included by design). Therefore a project Dockerfile is always a single stage: `FROM runtime-<name>` → `COPY artifacts` → `CMD`.
## Overall flow
```
Pre-step registry detection (script auto-detects internal/external network)
→ Step 0 detect tech stack (also detect existing Dockerfile → switch to audit-and-fix mode if found)
→ Step 1 selection table → Step 2 pin the tag
→ Step 3 write/modify Dockerfile → Step 4 verify on a real machine → Step 5 report
```
## Pre-step: registry detection (do this FIRST on every task, never skip)
There are two registry sources, **decided by detection, never by guesswork**:
| Source | Prefix | When | Condition |
|---|---|---|---|
| Internal | `livebook:8418/images` | machines on the LAN; fastest pulls | ① daemon.json already lists `insecure-registries: ["livebook:8418"]` (plain HTTP; docker refuses to pull without it) ② `http://livebook:8418/v2/` reachable (200 or 401 both count) |
| External | `code.innev.cn/images` | external machines / distribution to colleagues | fallback when either internal condition fails |
Run `scripts/detect-registry.sh`; the last stdout line is the image prefix. Every Dockerfile's `FROM` is then written as `<prefix>/runtime-<name>:<tag>`. If both routes fail (exit 1) → die: stop and report troubleshooting directions (internal machine: confirm same subnet + registration + docker restart; external machine: confirm `https://code.innev.cn` is reachable). Do not hardcode either one.
→ Acceptance: the image prefix used in this task is backed by a detection conclusion (internal/external), recorded in the Step 5 report.
## Step 0: detect the tech stack (never skip)
Never pick an image from the user's one-liner — confirm with project files first, then listen to the user's deployment requirements.
| What you detect | Verdict |
|---|---|
| `package.json` (no build artifacts, not a pure static site) | Node backend → then ask: single process, or multi-process/cluster needed? |
| `package.json` + pure frontend (react/vue/vite static output) | **Not for this series** — CI produces static files hosted by the project's own nginx image |
| `requirements.txt` / `pyproject.toml` | Python |
| `go.mod` / `Cargo.toml` | Go/Rust → static binary → `runtime-base` |
| `pom.xml` / `build.gradle` | Java → jar |
| `*.csproj` / `*.sln` | .NET → **must ask one more question**: modern .NET (publish output) or Framework 4.x? They use completely different images |
| `CMakeLists` / `Makefile` (C/C++) | C/C++ → **must confirm musl dynamic linking** (constraint ⑤) |
| MQL4 / MT4 terminal requirement | `runtime-mt4` |
| Scripts / ops tooling | `runtime-base` |
**Also detect existing Dockerfiles**: glob all `Dockerfile*` in the project (including subdirectories and `.dockerignore`). If any exist, switch to "audit-and-fix mode" (after Step 2); otherwise continue in create mode. Audit each Dockerfile when there are several.
→ Acceptance: the verdict has file evidence (paths); anything uncertain is asked about before selection, never guessed; there is an explicit conclusion on whether a Dockerfile already exists.
## Step 1: selection table
| Project type | FROM | What the project provides |
|---|---|---|
| Node backend (single process) | `runtime-node` | source code (**without** node_modules); install+build at startup |
| Node backend (multi-process) | `runtime-pm2` | same + pm2 cluster (defaults to max cores) |
| Python | `runtime-python` | source + `venv/` (dependencies installed by CI) |
| Go / Rust static binary | `runtime-base` | compiled binary |
| C/C++ (musl dynamic linking) | `runtime-cpp` | compiled binary |
| Java jar | `runtime-java` | `target/*.jar` |
| Modern .NET publish output | `runtime-dotnet` | `dotnet publish` output directory |
| .NET Framework 4.x | `runtime-wine-dotnet` | `bin/Release/` directory |
| MT4 terminal | `runtime-mt4` | no COPY; mount a data directory at runtime |
| Scripts / tools | `runtime-base` | script files |
Full image name: `<prefix>/runtime-<name>:<tag>` — the prefix comes from the pre-step detection (internal `livebook:8418/images` / external `code.innev.cn/images`), and can be explicitly overridden by `INNEV_IMAGE_PREFIX` (CI scenarios). For matrix details (preinstalled content / environment variables / Wine notes) read `references/runtime-matrix.md` on demand.
die: the detected stack is outside the matrix (e.g. Ruby/PHP) → stop and report "no matching runtime image", giving the two options (project builds its own full image / add a new image in the runtime-base repo). **Do not force a near-fit image.**
→ Acceptance: the chosen image has an explicit row in the table, and every special precondition of that row has been confirmed (musl vs glibc / modern .NET vs 4.x / single vs multi-process).
## Step 2: pin the tag
Three tag classes: `latest` (rolling rebuild with upstream every Monday) / `short-sha` / `version tag` (e.g. v0.5.0).
- Production/delivery projects → pin a `version tag` or `short-sha` (warn: if the user insists on latest, remind them of the rolling-rebuild risk; do not block)
- Local trial runs → latest is acceptable
→ Acceptance: the FROM tag in the Dockerfile is the result of an explicit choice, not something typed in passing.
## Audit-and-fix mode (take this branch when Step 0 finds an existing Dockerfile)
Finish Steps 1 and 2 first to establish "what it should look like" (correct image + tag), then audit the current state item by item. **Read-only audit: change no files until the audit report is out.**
### Audit checklist (each item carries a hard-constraint number; severity defined below)
| # | Check | Violates | Severity |
|---|---|---|---|
| A1 | Multi-stage build / SDK installed / compiling inside the image (`RUN mvn package`, `dotnet build`, `pip install` from source) | ① | must-fix |
| A2 | Node family COPYs node_modules / missing `.dockerignore` exclusion / COPYs dist artifacts as a generic pattern (the Node model is install+build at startup) | ② | must-fix |
| A3 | Token passed via `ARG` / secrets COPYed into layers / `.npmrc` with a token landing in image layers | ③ | must-fix (security, handle first) |
| A4 | `FROM ...:latest` in a production/delivery project | ④ | should-fix |
| A5 | C/C++ artifacts built in a glibc environment (debian/ubuntu CI) FROM an Alpine-based image | ⑤ | must-fix |
| A6 | Go/Rust binary FROM a full `golang`/`rust` image or another language-bearing image | ⑥ | should-fix (switch to `runtime-base`; size often drops by an order of magnitude) |
| A7 | Frontend static output served by a node image with `serve`/`http-server` | ⑦ | should-fix (switch to nginx hosting) |
| A8 | Stack/FROM mismatch (Framework 4.x on `runtime-dotnet`, multi-process need running single-process on `runtime-node`, etc.) | table | must-fix |
| A9 | Missing `CMD`/`ENTRYPOINT`, `EXPOSE`; entry path doesn't match the CI artifact path | — | note |
| A10 | FROM prefix contradicts the pre-step detection (external machine FROM `livebook:8418` can't pull; internal machine FROM `code.innev.cn` takes a pointless detour) | ⑧ | must-fix |
Severity definitions: **must-fix** = will break (won't start / wrong behavior) or leaks secrets; **should-fix** = runs but size/stability/maintainability suffers; **note** = blemish. If only "should-fix/note" items are found and the user asked for an audit only, change no files — the report is the deliverable.
### Audit report format (fixed)
- One-line conclusion: currently FROM what → should be FROM what (if identical, say plainly "selection is correct")
- Problem table: severity | line number | problem | basis (constraint number) | fix
- Items with no problems also get a "passed" line (go through A1–A9 one by one; reporting only the bad and never the good is forbidden — omission is as harmful as false positives)
Lay out the report; once the user confirms fixes → go to Step 3 and rewrite per the template (show the diff), then Step 4 verification. If the user says "audit only, don't touch" → the report is the deliverable.
## Step 3: write/modify the Dockerfile
Default action: **lay out the selection conclusion plus the full Dockerfile text for user confirmation before writing to disk** (show a diff when modifying an existing Dockerfile).
Template (generic pattern; python/java/dotnet/cpp/base/wine are isomorphic):
```dockerfile
FROM livebook:8418/images/runtime-<name>:<tag>
COPY <CI-built artifacts> <target path>
CMD [...]
```
Node family pattern (note the difference from the generic pattern — COPY source, not artifacts):
```dockerfile
FROM livebook:8418/images/runtime-node:<tag>
COPY . . # .dockerignore must exclude node_modules
```
Node-family runtime parameters are injected via environment variables at `docker run` time (`APP_ENTRY`/`BUILD_CMD`/`NPM_REGISTRY`/`PM2_APP_NAME`/`PM2_INSTANCES`, etc.; full table in references) — **never baked into image layers**. If an existing Dockerfile uses multi-stage builds / installs SDKs / passes tokens via ARG, call it out per the red lines and rewrite to a single stage.
→ Acceptance: Dockerfile is single-stage, has no compile steps, contains no secrets; Node family comes with a `.dockerignore` excluding node_modules.
## Step 4: verify on a real machine
Done means actually building and smoke-running a container, not "the Dockerfile syntax is fine":
1. `docker build -t <app>:test .` succeeds
2. `docker run` starts a container and the process stays alive (Node family install+builds on first start — slow startup is normal; watch the logs until listening)
3. If it has a port, verify the port is reachable / the health check passes
If the environment doesn't allow build verification (host hasn't registered `livebook:8418` as an insecure registry, no docker) → explicitly declare "not verified" and list "left for manual follow-up" in the report.
## Step 5: report
Fixed structure:
- Mode: create / audit-and-fix
- Registry: internal / external (detection conclusion, with script output as evidence)
- Detection conclusion: language/stack + evidence file paths
- Selection: `runtime-<name>:<tag>` + one-line rationale
- [Audit-and-fix mode] audit results: must-fix N / should-fix N / notes N (with the problem table); whether the user authorized fixes
- What was written: Dockerfile (new/modified diff), `.dockerignore`
- What was skipped/not done: e.g. multi-process need unconfirmed, CI build step missing, user asked for audit only
- How far verification went: build/run results — **if not done, say not done**
- Left for manual follow-up: daemon.json registration, CI artifact steps, secret configuration
## Hard constraints
- ① **No compilation in runtime images**. Why: images carry no compilers/SDKs (dotnet has no SDK, java has no JDK/Maven, python has no musl compile chain); installing one defeats the "minimal runtime base" purpose. Symptom: build-time `mvn/dotnet SDK not found`, pip source-compile failures. Sole exception: the Node family install+builds at startup (node-gyp toolchain included — by design, not a loophole).
- ② **Node family never COPYs node_modules**. Why: the entrypoint runs `pnpm install` at startup (`--frozen-lockfile` with a lockfile); COPYing it in causes overwrite conflicts and image bloat; native modules must compile inside the container to match the runtime environment.
- ③ **Tokens never via ARG, never in image layers**. Why: ARG values persist in `docker history`; `NPM_TOKEN` via env shows up in `docker inspect` output. Correct posture: `NPM_TOKEN_FILE` pointing at a secret file (docker secret / k8s mounted volume); the token is written only into the in-container `~/.npmrc`.
- ④ **No latest in production**. Why: `latest` rolls with upstream every Monday 02:30 — the content under the same tag changes; "it ran last time" doesn't mean it runs next time. Pin a version tag or short sha.
- ⑤ **C/C++ only with musl dynamic linking on `runtime-cpp`**. Why: Alpine is musl-based; glibc artifacts won't run — symptom `exec format error` or `not found` (binary exists but the loader is missing). For glibc artifacts, switch base or compile statically.
- ⑥ **No dedicated Go/Rust image**. A static native binary needs no language environment at runtime — `FROM runtime-base` is enough; don't go looking for `runtime-go`.
- ⑦ **Frontend doesn't use this series**. React/Vue etc. are built by CI into static files and hosted by the project's own nginx image.
- ⑧ **Registry source is decided by pre-step detection — never guessed, never hardcoded**. Why: internal `livebook:8418` is LAN plain HTTP, unreachable from outside; external `code.innev.cn` is a detour from inside. Hardcode either route and pulling breaks when the network environment changes. Run `scripts/detect-registry.sh` first on every task; for scenarios needing a fixed source (CI), set `INNEV_IMAGE_PREFIX` explicitly and say so in the report.
## Troubleshooting table
| Symptom | Cause | What to do |
|---|---|---|
| `exec format error` | glibc binary in a musl image / architecture mismatch | switch the build stage to musl or static compilation; check buildx architecture |
| Pull fails with `http: server gave HTTP response to HTTPS client` | host hasn't registered `insecure-registries: ["livebook:8418"]` | edit `/etc/docker/daemon.json` and restart docker (manual); or switch to the external prefix per pre-step detection |
| External machine FROM `livebook:8418` times out / connection refused | internal prefix is only reachable on the LAN | change FROM to `code.innev.cn/images/...` (re-run pre-step detection to confirm) |
| External pull returns 401 unauthorized | code.innev.cn registry requires auth | `docker login code.innev.cn` (manual, with the Gitea account) |
| pip install compile failure | runtime-python has no compile chain | compile wheels/venv in the build stage (CI), then COPY |
| .NET program won't start on runtime-dotnet | Framework 4.x program on the modern .NET image | switch to `runtime-wine-dotnet`, start with `xvfb-run -a wine` |
| Token visible in `docker inspect` | NPM_TOKEN passed via env | switch to NPM_TOKEN_FILE secret mount |
| Worked last week, broken this week | used latest; Monday rolling rebuild | pin a version tag / short sha |
| Node container starts very slowly | normal: install+build at startup | watch logs until listening; don't kill it as hung |
## Red lines (never violated under any circumstance)
- Never select by guesswork — if Step 0 finds no evidence, ask; never force a near-fit image
- Never add compile stages/SDKs to a project Dockerfile (except the Node family's startup build)
- Never write any token/secret into a Dockerfile or image layer; a token the user pasted must not land in any file — and remind them to rotate it
- Never silently drop anything — skipped requirements (multi-process/private registry/health check) must appear in the report
- Before changing an existing Dockerfile, present the conclusion and the diff first; write only after confirmation
@@ -1,61 +0,0 @@
# runtime-base image matrix details (excerpted from the runtime-base repo README, 2026-09 edition)
> Read on demand: consult this file when Step 1 leaves you unsure about preinstalled content or environment variables.
> Full image name: `$INNEV_IMAGE_PREFIX/runtime-<name>:<tag>`, default prefix `livebook:8418/images/`.
## Linux side (all Alpine, linux/amd64 + linux/arm64 dual-arch)
| Image | Base | Preinstalled |
|---|---|---|
| `runtime-base` | alpine:3.22 | bash/git/curl/jq/make/CA certs/UTC+8 timezone; the runtime foundation for Go/Rust static binaries and scripts |
| `runtime-node` | node:24-alpine | Node backend: pnpm install + build at startup, launched with `node $APP_ENTRY` |
| `runtime-pm2` | node:24-alpine | same + pm2: `pm2-runtime` cluster startup |
| `runtime-python` | python:3.13-alpine | pip/setuptools/wheel + uv/pipx/virtualenv (no source compile chain — packages without musl wheels are compiled in the build stage) |
| `runtime-dotnet` | dotnet/aspnet:10.0-alpine | ASP.NET Core runtime, no SDK |
| `runtime-cpp` | alpine:3.22 | libstdc++/libgcc runtime libraries (musl dynamically linked artifacts only) |
| `runtime-java` | temurin:21-jre-alpine | JRE 21 LTS, no JDK/Maven/Gradle |
The Node family (node/pm2) additionally preinstalls: pnpm/yarn (versions controllable via ARG `PNPM_VERSION`/`YARN_VERSION`), python3/make/g++ (the node-gyp compile chain) — because they run `pnpm install` at container start, and native modules compile at install time right there.
## Windows side (Linux containers + Wine, x86_64 only)
| Image | Base | Preinstalled |
|---|---|---|
| `runtime-wine-base` | debian:bookworm-slim | Wine (win32+win64) + winetricks + Xvfb + CJK fonts + pre-initialized wine prefix `/opt/wineprefix` |
| `runtime-wine-dotnet` | runtime-wine-base | .NET Framework 4.8 preinstalled (ARG `DOTNET_FX` switches the verb) |
| `runtime-mt4` | runtime-wine-base | MetaTrader 4 + corefonts preinstalled, Xvfb headless startup |
Why Wine instead of Windows containers: Windows containers require a Windows Server host — high maintenance cost; .NET Framework 4.x and MT4 are mature and stable under Wine. Debian as base rather than Alpine: musl Wine has incomplete 32-bit (wow64) support.
## Node-family environment variables (inject at docker run, never bake into layers)
| Variable | Purpose | Default |
|---|---|---|
| `NPM_REGISTRY` | npm registry address (including private ones) | official registry |
| `NPM_TOKEN` | private registry token (via env it ends up in `docker inspect`; not recommended) | none |
| `NPM_TOKEN_FILE` | token file path (docker secret / k8s mounted volume; recommended, takes precedence over NPM_TOKEN) | none |
| `APP_DIR` | app directory | `/app` |
| `BUILD_CMD` | build script name (auto-skipped if absent from package.json) | `build` |
| `APP_ENTRY` | build artifact entry file | `dist/main.js` |
| `PM2_APP_NAME` | [pm2] app name | `app` |
| `PM2_INSTANCES` | [pm2] cluster instance count | `max` (core count) |
## Common CMD / COPY patterns
- Go: `COPY app /usr/local/bin/app` + `CMD ["app"]`
- Java: `COPY target/app.jar /app/app.jar` + `CMD ["java","-jar","/app/app.jar"]`
- .NET: `COPY out /app` + `CMD ["dotnet","/app/MyService.dll"]`
- wine-dotnet: `COPY bin/Release/ "$WINEPREFIX/drive_c/app/"` + `CMD ["xvfb-run","-a","wine","C:\\app\\MyService.exe"]`
- MT4: no COPY; `docker run -v /srv/mt4/data:/opt/wineprefix/drive_c/"Program Files (x86)"/"MetaTrader 4"`; drop EAs/indicators into the data directory's `MQL4/Experts`, `MQL4/Indicators` and restart the container
## Pre-step: host registration on the pulling side (manual)
Add `{ "insecure-registries": ["livebook:8418"] }` to `/etc/docker/daemon.json` and restart docker (the internal registry uses plain HTTP on the LAN).
## Tag policy
`latest` (rolling rebuild with upstream every Monday 02:30 Beijing time) / `short-sha` / `version tag` (v*). Production pins a version tag or short sha.
## Package-manager mirrors for China
Each runtime Dockerfile has comments showing how to enable them; you can also configure them in the project's build-stage Dockerfile (the README provides ready-made snippets for crates.io rsproxy and Maven aliyun).
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# detect-registry.sh — detect the reachable registry and print the image prefix
# Internal livebook:8418 preferred (plain HTTP on the LAN, fast); external code.innev.cn as fallback.
# Usage: scripts/detect-registry.sh
# Output: last stdout line is the image prefix (e.g. livebook:8418/images); exit 1 = both routes failed
set -u
IN_HOST="livebook:8418"
IN_PREFIX="livebook:8418/images"
OUT_HOST="code.innev.cn"
OUT_PREFIX="code.innev.cn/images"
say() { printf '%s\n' "$*" >&2; }
ok() { say "✔ $*"; }
no() { say "✘ $*"; }
step() { say "▸ $*"; }
# --- Check 1: is livebook:8418 registered in insecure-registries (internal is plain HTTP; docker refuses to pull otherwise) ---
insecure_ok=0
if command -v docker >/dev/null 2>&1; then
if docker info 2>/dev/null | grep -qi "$IN_HOST"; then
insecure_ok=1
fi
fi
if [ "$insecure_ok" -eq 0 ]; then
for f in /etc/docker/daemon.json "$HOME/.docker/daemon.json"; do
[ -f "$f" ] && grep -q "$IN_HOST" "$f" && insecure_ok=1 && break
done
fi
[ "$insecure_ok" -eq 1 ] && ok "insecure-registries has $IN_HOST registered" || no "insecure-registries missing $IN_HOST"
# --- Check 2: internal registry connectivity (/v2/ returning 200 or 401 both count) ---
in_code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 2 --max-time 4 "http://$IN_HOST/v2/" 2>/dev/null || echo 000)
if [ "$in_code" = 200 ] || [ "$in_code" = 401 ]; then
ok "internal $IN_HOST reachable (HTTP ${in_code})"
else
no "internal $IN_HOST unreachable (HTTP ${in_code})"
fi
if [ "$insecure_ok" -eq 1 ] && { [ "$in_code" = 200 ] || [ "$in_code" = 401 ]; }; then
step "conclusion: use internal registry (fastest build pulls)"
printf '%s\n' "$IN_PREFIX"
exit 0
fi
# --- Fallback: external ---
step "internal unavailable, trying external $OUT_HOST"
out_code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time 6 "https://$OUT_HOST/v2/" 2>/dev/null || echo 000)
if [ "$out_code" = 200 ] || [ "$out_code" = 401 ]; then
ok "external $OUT_HOST reachable (HTTP ${out_code})"
say "⚠ using external: slower pulls than internal; if the registry requires auth, run docker login $OUT_HOST first (manual)"
printf '%s\n' "$OUT_PREFIX"
exit 0
fi
no "external $OUT_HOST also unreachable (HTTP ${out_code})"
say "Both routes failed. Troubleshoot: internal machine — confirm same subnet as $IN_HOST, daemon.json registered, docker restarted; external machine — confirm https://$OUT_HOST is reachable"
exit 1