diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 69b2eaa..8c74b30 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -10,7 +10,7 @@ on: jobs: call-docker-build: - uses: ycpss91255-docker/template/.github/workflows/build-worker.yaml@v0.9.9 + uses: ycpss91255-docker/template/.github/workflows/build-worker.yaml@v0.9.11 with: image_name: ros1_bridge build_runtime: true @@ -18,6 +18,6 @@ jobs: call-release: needs: call-docker-build if: startsWith(github.ref, 'refs/tags/') - uses: ycpss91255-docker/template/.github/workflows/release-worker.yaml@v0.9.9 + uses: ycpss91255-docker/template/.github/workflows/release-worker.yaml@v0.9.11 with: archive_name_prefix: ros1_bridge diff --git a/Dockerfile b/Dockerfile index 3b9dcec..3dee7b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,12 +68,13 @@ COPY --chmod=0755 script/entrypoint.sh /entrypoint.sh COPY --chmod=0755 script/ros_entrypoint.sh /ros_entrypoint.sh COPY --chmod=0644 "${BRIDGE_FILE}" /bridge.yaml -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/ros_entrypoint.sh"] CMD ["bash"] ############################## runtime ############################## FROM devel AS runtime +ENTRYPOINT ["/entrypoint.sh"] CMD ["ros2", "run", "ros1_bridge", "parameter_bridge"] ############################## test (ephemeral) ############################## @@ -87,6 +88,7 @@ COPY --from=lint-tools /usr/local/bin/hadolint /usr/local/bin/hadolint COPY .hadolint.yaml /lint/.hadolint.yaml COPY Dockerfile /lint/Dockerfile COPY template/script/docker/build.sh template/script/docker/run.sh template/script/docker/exec.sh template/script/docker/stop.sh /lint/ +COPY template/script/docker/_lib.sh template/script/docker/i18n.sh /lint/ COPY script/*.sh /lint/ RUN shellcheck -S warning /lint/*.sh RUN cd /lint && hadolint Dockerfile diff --git a/doc/changelog/CHANGELOG.md b/doc/changelog/CHANGELOG.md index 7432300..54c9c2a 100644 --- a/doc/changelog/CHANGELOG.md +++ b/doc/changelog/CHANGELOG.md @@ -8,14 +8,15 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Changed +- Upgrade `template/` subtree to [v0.9.11](https://github.com/ycpss91255-docker/template/releases/tag/v0.9.11). Brings arm64-native CI (multi-arch matrix on `ubuntu-24.04-arm`), Jetson auto-detect for `[deploy] runtime` / `[build] network` (no more manual `network = host` workaround on Jetson), `_sanitize_lang` i18n, `_lib.sh` / `i18n.sh` dedupe, and `upgrade.sh` destructive fast-forward guards. - Rebuild `devel` stage from `ros:foxy-ros-base-focal` (multi-arch) plus the ROS 1 snapshot apt repo instead of the amd64-only `osrf/ros:foxy-ros1-bridge`. Enables Jetson (arm64) support. - `ENV ROS1_DISTRO=noetic` / `ENV ROS2_DISTRO=foxy` now baked into the image so downstream scripts can reference the distro names without hardcoding. - Test stage lint target uses `COPY script/*.sh /lint/` (glob) to pick up new scripts automatically. - Bridge YAML examples now document the full `parameter_bridge` schema: `bridge.yaml` ships `services_1_to_2` / `services_2_to_1` entries and an inline QoS block on `/scan`; `config/scan_bridge.yaml` and `config/release_bridge.yaml` set sensor-data QoS (BEST_EFFORT for image streams, RELIABLE for `camera_info`). READMEs in all four languages note the topic (ROS 2) vs service (ROS 1) `type` format asymmetry. -- **Split `devel` and `runtime` into separate stages (USER-VISIBLE BEHAVIOR CHANGE).** `devel` CMD is now `bash` — `./run.sh` drops into an interactive shell instead of auto-launching `parameter_bridge`. The new `runtime` stage (`FROM devel`) keeps `CMD ["ros2", "run", "ros1_bridge", "parameter_bridge"]` for production-style auto-bridge deployments. CI builds both (`build_runtime: true` in `main.yaml`). Note: `./run.sh runtime` does not yet work because the auto-generated `compose.yaml` does not emit a `runtime` service (tracked upstream in template); invoke runtime via direct `docker build --target runtime && docker run` until template provides this. +- **Split `devel` and `runtime` into separate stages (USER-VISIBLE BEHAVIOR CHANGE).** `devel` CMD is now `bash` — `./run.sh` drops into an interactive shell instead of auto-launching `parameter_bridge`. `devel` ENTRYPOINT is `/ros_entrypoint.sh` (sources ROS1+ROS2 env only, no `rosparam load`) so the shell is usable immediately. The new `runtime` stage (`FROM devel`) keeps `CMD ["ros2", "run", "ros1_bridge", "parameter_bridge"]` and switches ENTRYPOINT back to `/entrypoint.sh` (which does `rosparam load /bridge.yaml` before launch) for production-style auto-bridge deployments. CI builds both (`build_runtime: true` in `main.yaml`). Note: `./run.sh runtime` does not yet work because the auto-generated `compose.yaml` does not emit a `runtime` service (tracked upstream in template); invoke runtime via direct `docker build --target runtime && docker run` until template provides this. ### Added -- `script/ros_entrypoint.sh` — osrf-compatible entrypoint that only sources both ROS distros (no `rosparam load`), available at `/ros_entrypoint.sh` in the image. The existing `/entrypoint.sh` remains the default `ENTRYPOINT`. +- `script/ros_entrypoint.sh` — osrf-compatible entrypoint that only sources both ROS distros (no `rosparam load`), available at `/ros_entrypoint.sh` in the image. `devel` stage uses this as its `ENTRYPOINT`; `runtime` stage keeps `/entrypoint.sh` (with `rosparam load`). - Smoke tests: `ROS1_DISTRO`/`ROS2_DISTRO` env vars, `/ros_entrypoint.sh` existence + ability to source both ROS envs + expose `ros2`. ### Removed diff --git a/template/.github/workflows/build-worker.yaml b/template/.github/workflows/build-worker.yaml index 0ab42a9..aa3a30c 100644 --- a/template/.github/workflows/build-worker.yaml +++ b/template/.github/workflows/build-worker.yaml @@ -17,10 +17,83 @@ on: type: boolean default: true description: "Whether to build runtime stage" + platforms: + required: false + type: string + default: "linux/amd64" + description: | + Comma-separated target platforms. Each runs as a parallel job on + its own native runner (linux/amd64 → ubuntu-latest, linux/arm64 + → ubuntu-24.04-arm), so arm64 builds avoid QEMU emulation + (which would take 30-60 min per run on an amd64 host) and stay + in the 5-15 min range. + + Each matrix shard runs the full pipeline (test-tools build, + test stage smoke tests, devel stage, runtime stage) natively + for its platform. + + Default "linux/amd64" preserves single-platform behavior — + existing downstream repos see no CI change until they opt in + by adding `platforms: linux/amd64,linux/arm64` in their + main.yaml call. + + Supported values: linux/amd64, linux/arm64. Other values are + rejected by the compute-matrix step. jobs: - docker-build: + # ──────────────────────────────────────────────────────────────── + # Parse the comma-separated `platforms` input into a matrix + # definition. Each supported platform gets the right runner label + # (native arm64 runners are available for public repos under the + # ycpss91255-docker org) and a canonical HARDWARE build-arg value. + # ──────────────────────────────────────────────────────────────── + compute-matrix: runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set.outputs.matrix }} + steps: + - id: set + env: + PLATFORMS: ${{ inputs.platforms }} + run: | + items="" + IFS=',' read -ra plats <<< "${PLATFORMS}" + for p in "${plats[@]}"; do + p="$(echo "${p}" | tr -d '[:space:]')" + case "${p}" in + linux/amd64) + items+='{"platform":"linux/amd64","runner":"ubuntu-latest","hardware":"x86_64"},' + ;; + linux/arm64) + items+='{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","hardware":"aarch64"},' + ;; + "") + continue + ;; + *) + echo "::error::Unsupported platform '${p}'. Supported: linux/amd64, linux/arm64" + exit 1 + ;; + esac + done + if [ -z "${items}" ]; then + echo "::error::No valid platforms found in '${PLATFORMS}'" + exit 1 + fi + json="{\"include\":[${items%,}]}" + echo "matrix=${json}" >> "${GITHUB_OUTPUT}" + + # ──────────────────────────────────────────────────────────────── + # Per-platform build. Each shard runs on its native runner so + # arm64 doesn't pay QEMU emulation cost. Full test + devel + + # runtime pipeline runs per platform. + # ──────────────────────────────────────────────────────────────── + build: + needs: compute-matrix + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} + runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@v4 @@ -43,9 +116,17 @@ jobs: fi - name: Set up Docker Buildx + # docker-container driver is required even on single-platform + # builds now: the downstream Dockerfile's `test` stage + # references `test-tools:local` via `COPY --from=`, and with + # docker-container the tag built in the previous buildx step + # stays in the same builder's internal store, which subsequent + # build-push-action steps can resolve. The legacy `docker` + # driver stores images in the host daemon, which isn't + # accessible from a matrix job's buildx container. uses: docker/setup-buildx-action@v3 with: - driver: docker + driver: docker-container - name: Generate .env run: | @@ -54,7 +135,7 @@ jobs: USER_GROUP=ci USER_UID=1000 USER_GID=1000 - HARDWARE=x86_64 + HARDWARE=${{ matrix.hardware }} DOCKER_HUB_USER=ci GPU_ENABLED=false IMAGE_NAME=${{ inputs.image_name }} @@ -63,24 +144,32 @@ jobs: mkdir -p /tmp/workspace - name: Build test-tools image - run: | - if [ -f template/dockerfile/Dockerfile.test-tools ]; then - docker build -t test-tools:local \ - -f template/dockerfile/Dockerfile.test-tools . - fi + # Must use buildx (not plain `docker build`) so `test-tools:local` + # lands in buildx's internal image store — subsequent build + # steps on the same builder resolve `COPY --from=test-tools:local` + # from there. docker-container's builder container doesn't see + # host daemon images. + uses: docker/build-push-action@v6 + with: + context: . + file: template/dockerfile/Dockerfile.test-tools + tags: test-tools:local + platforms: ${{ matrix.platform }} + push: false - name: Build test stage (includes smoke tests) uses: docker/build-push-action@v6 with: context: . target: test + platforms: ${{ matrix.platform }} push: false build-args: | USER=ci GROUP=ci UID=1000 GID=1000 - HARDWARE=x86_64 + HARDWARE=${{ matrix.hardware }} ${{ inputs.build_args }} - name: Build devel stage @@ -88,13 +177,14 @@ jobs: with: context: . target: devel + platforms: ${{ matrix.platform }} push: false build-args: | USER=ci GROUP=ci UID=1000 GID=1000 - HARDWARE=x86_64 + HARDWARE=${{ matrix.hardware }} ${{ inputs.build_args }} - name: Build runtime stage @@ -103,6 +193,7 @@ jobs: with: context: . target: runtime + platforms: ${{ matrix.platform }} push: false build-args: | USER=ci @@ -110,3 +201,25 @@ jobs: UID=1000 GID=1000 ${{ inputs.build_args }} + + # ──────────────────────────────────────────────────────────────── + # Stable-name aggregator so downstream branch-protection rules + # keep working. Keeping this job's name as `docker-build` + # preserves the status-check context that public-ycpss91255-docker + # container repos currently require on their `main` branch + # (`call-docker-build / docker-build`). Matrix shards publish as + # `call-docker-build / build (linux/amd64)` etc. — those are the + # actual build runs; this one just gates on them. + # ──────────────────────────────────────────────────────────────── + docker-build: + needs: build + if: always() + runs-on: ubuntu-latest + steps: + - name: Aggregate matrix result + run: | + result="${{ needs.build.result }}" + echo "Matrix build result: ${result}" + if [ "${result}" != "success" ]; then + exit 1 + fi diff --git a/template/.version b/template/.version index 8031930..9781ff2 100644 --- a/template/.version +++ b/template/.version @@ -1 +1 @@ -v0.9.9 +v0.9.11 diff --git a/template/doc/changelog/CHANGELOG.md b/template/doc/changelog/CHANGELOG.md index ebc3e72..96587b0 100644 --- a/template/doc/changelog/CHANGELOG.md +++ b/template/doc/changelog/CHANGELOG.md @@ -7,6 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v0.9.11] - 2026-04-24 + +### Fixed +- **`_lib.sh` fallback `_detect_lang` returned `"zh"` for `zh_TW` (issue + #103)** — a copy-paste typo in the fallback used when `i18n.sh` was + absent (the Dockerfile `/lint` stage). Fixed to `"zh-TW"`. The + follow-up `#104` dedupe below then REMOVED the fallback entirely; the + only remaining `_detect_lang` is in `i18n.sh`. + +### Changed +- **`[build] network` now defaults to `auto` (issue #102)**. On Jetson + (detected via `/etc/nv_tegra_release`) setup.sh resolves `auto` to + `host`, so first-time `./build.sh` succeeds without the DNS failures + that Jetson's broken bridge NAT used to cause. Desktop hosts stay on + Docker's default bridge. Explicit `host` / `bridge` / `none` / + `default` still pass through unchanged; new `off` value for explicit + opt-out. New `_resolve_build_network` helper mirrors + `_resolve_runtime`'s Jetson-aware pattern. +- **`_detect_lang` deduplicated: single canonical definition in + `i18n.sh` (issue #104)**. Previously `build.sh` / `run.sh` / + `exec.sh` / `stop.sh` / `_lib.sh` each shipped an inline fallback + `_detect_lang` for when `i18n.sh` wasn't reachable (Dockerfile + `/lint` stage). That invited drift — see #103 where `_lib.sh`'s + copy had silently returned `zh` instead of `zh-TW` for months. + `Dockerfile.example`'s test stage now COPYs `_lib.sh` + `i18n.sh` + + `_tui_conf.sh` alongside `*.sh`; scripts look up `_lib.sh` in the + template layout OR as a sibling, with a clear error when neither + exists. Downstream repos using a custom Dockerfile (not based on + `Dockerfile.example`) need to mirror this COPY in their test stage. +- **`_sanitize_lang` warning now localises to the system `$LANG`**. v0.9.7 + Agent A scoped this helper out of i18n; a user with `LANG=zh_TW.UTF-8` + who typed `--lang xxx` still saw an English WARNING. Now we re-detect + from the system env (can't trust `_LANG` — it holds the invalid input + the user just passed) and print the warning in zh-TW / zh-CN / ja + where applicable, falling back to English for other locales. + +### Added +- **Coverage audit follow-up (+9 unit tests)**. Kcov run flagged four + small untested branches in `_lib.sh` and `_tui_conf.sh`; filling them + raised non-TUI coverage from 94.4% → 95.7%. New tests: + - `_lib_msg count` / `caps` translation keys exercised in all four + languages (previously only Files / Identity / etc. were asserted). + - `_mount_container_path` helper — four cases (plain / + with-mode / env-var-interpolated / no-colon fallback). The symmetric + `_mount_host_path` was already covered; the container-side parser + had zero unit tests. + - `_upsert_conf_value` "section not found" branch — appends a fresh + `[section]` header + key when called against a conf that doesn't + yet have that section. + - `_upsert_conf_value` "section present, key absent at EOF" branch — + appends the key to the last section when target key isn't there. + - `_write_setup_conf` final-section override flush — an override key + whose target is the LAST section in the template gets emitted + via the EOF-flush path (previously only the mid-file append branch + was asserted). + - `_write_setup_conf` removed_keys + flush interplay — ensures a key + listed in `removed_keys` does NOT reappear via the EOF flush. + + TUI interactive flows (`_edit_section_*`) in `setup_tui.sh` remain + at ~17% — they require a dialog/whiptail stub framework to drive, + cost doesn't justify coverage-for-its-own-sake. `setup_tui.sh` + validators / I/O helpers are covered at unit level via `tui_spec`. + +## [v0.9.10] - 2026-04-24 + +### Added +- **Multi-arch support in `build-worker.yaml`** — new `platforms` input + (default `"linux/amd64"`, accepts `"linux/amd64,linux/arm64"`). Each + requested platform runs as a parallel matrix shard on its own native + runner (amd64 → `ubuntu-latest`, arm64 → `ubuntu-24.04-arm`), so arm64 + builds avoid QEMU emulation and stay in the 5-15 min range instead of + 30-60 min. Full pipeline (test-tools → test stage smoke → devel → + runtime) runs natively per platform. Covers Jetson (Nano / Xavier / + Orin, all aarch64) and modern Raspberry Pi (4 / 5 on 64-bit OS) and + standard x86 hosts. 32-bit ARM (armv7/v6) intentionally unsupported — + no native runner exists and QEMU emulation would balloon CI time; + modern Pi defaults to 64-bit OS. + +### Changed +- **`build-worker.yaml` now uses the `docker-container` buildx driver** + (was `docker`). Required for multi-arch builds. Side effect: + `test-tools:local` is built via `docker/build-push-action@v6` (not + plain `docker build`) so the tag lands in buildx's internal image + store, visible to the subsequent test-stage build's + `COPY --from=test-tools:local` on the same builder. +- **Matrix job names**: per-platform shards are called + `call-docker-build / build (linux/amd64)` etc. A stable-name + aggregator job `call-docker-build / docker-build` gates on all + shards — downstream `main` branch protection rules that require + `call-docker-build / docker-build` keep working without changes. + ## [v0.9.9] - 2026-04-24 ### Added diff --git a/template/doc/test/TEST.md b/template/doc/test/TEST.md index ca86eda..d3efe4a 100644 --- a/template/doc/test/TEST.md +++ b/template/doc/test/TEST.md @@ -1,10 +1,10 @@ # TEST.md -Template self-tests: **632 tests** total (589 unit + 43 integration). +Template self-tests: **654 tests** total (611 unit + 43 integration). ## Test Files -### test/unit/lib_spec.bats (34) +### test/unit/lib_spec.bats (38) | Test | Description | |------|-------------| @@ -34,7 +34,7 @@ Template self-tests: **632 tests** total (589 unit + 43 integration). | `_print_config_summary hides sections that are empty in setup.conf` | Empty-section skip | | `_print_config_summary warns when setup.conf is missing` | Missing-conf hint | -### test/unit/setup_spec.bats (105) +### test/unit/setup_spec.bats (111) Covers core detection (user/hardware/docker/GPU/GUI), the INI parser (`_parse_ini_section`), setup.conf section merging (`_load_setup_conf` @@ -61,7 +61,7 @@ writeback (first-time bootstrap / user-edit respect / opt-out). | `[build]` apt_mirror (empty fallback, override) | 2 | | Workspace writeback (first-time, respect user edit, opt-out) | 3 | -### test/unit/tui_spec.bats (73) +### test/unit/tui_spec.bats (82) Pure-logic unit tests for the TUI support libraries (`_tui_conf.sh`). No dialog/whiptail invocations here — strictly validators, mount-string @@ -181,7 +181,7 @@ conditional GPU deploy block + GUI env/volumes + extra volumes from | `empty extras => no extra mount lines` | empty list | | `with GUI+GPU+extras => all sections present` | fully loaded | -### test/unit/template_spec.bats (102) +### test/unit/template_spec.bats (105) | Test | Description | |------|-------------| diff --git a/template/dockerfile/Dockerfile.example b/template/dockerfile/Dockerfile.example index 6b3f0dc..9de4ca5 100644 --- a/template/dockerfile/Dockerfile.example +++ b/template/dockerfile/Dockerfile.example @@ -133,6 +133,16 @@ COPY .hadolint.yaml /lint/.hadolint.yaml COPY Dockerfile /lint/Dockerfile COPY compose.yaml /lint/compose.yaml COPY *.sh /lint/ +# Helpers sourced by the root-level scripts. Must sit next to them so +# build.sh / run.sh / exec.sh / stop.sh / setup.sh can source _lib.sh +# (which in turn sources i18n.sh); setup.sh also sources _tui_conf.sh. +# Issue #104: removing these used to be compensated by inline +# `_detect_lang` fallbacks in every script — now the canonical +# definition lives once in i18n.sh. +COPY template/script/docker/_lib.sh \ + template/script/docker/i18n.sh \ + template/script/docker/_tui_conf.sh \ + /lint/ RUN shellcheck -S warning /lint/*.sh RUN cd /lint && hadolint Dockerfile diff --git a/template/script/docker/_lib.sh b/template/script/docker/_lib.sh index 1746c3d..fe7cc64 100644 --- a/template/script/docker/_lib.sh +++ b/template/script/docker/_lib.sh @@ -16,24 +16,14 @@ if [[ -n "${_DOCKER_LIB_SOURCED:-}" ]]; then fi _DOCKER_LIB_SOURCED=1 -# _detect_lang prints the language code derived from $LANG. -_detect_lang() { - case "${LANG:-}" in - zh_TW*) echo "zh" ;; - zh_CN*|zh_SG*) echo "zh-CN" ;; - ja*) echo "ja" ;; - *) echo "en" ;; - esac -} - -# Load i18n.sh if present, otherwise fall back to a minimal _LANG. +# i18n.sh lives next to _lib.sh in every deployment surface (consumer +# repo's template/script/docker/, and the /lint/ stage where the +# Dockerfile COPYs both). Issue #104 removed the duplicate fallback +# `_detect_lang` definitions that had already drifted (#103) — the +# one canonical _detect_lang + _LANG assignment lives in i18n.sh. _lib_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -if [[ -f "${_lib_dir}/i18n.sh" ]]; then - # shellcheck disable=SC1091 - source "${_lib_dir}/i18n.sh" -else - _LANG="${SETUP_LANG:-$(_detect_lang)}" -fi +# shellcheck disable=SC1091 +source "${_lib_dir}/i18n.sh" unset _lib_dir # _load_env sources the given .env file with allexport so every assignment diff --git a/template/script/docker/_tui_conf.sh b/template/script/docker/_tui_conf.sh index a3c5749..3ec552d 100644 --- a/template/script/docker/_tui_conf.sh +++ b/template/script/docker/_tui_conf.sh @@ -165,7 +165,7 @@ _validate_build_network() { local _v="${1-}" [[ -z "${_v}" ]] && return 0 case "${_v}" in - host|bridge|none|default) return 0 ;; + auto|host|bridge|none|default|off) return 0 ;; *) return 1 ;; esac } diff --git a/template/script/docker/build.sh b/template/script/docker/build.sh index 2892883..347484a 100755 --- a/template/script/docker/build.sh +++ b/template/script/docker/build.sh @@ -5,22 +5,22 @@ set -euo pipefail FILE_PATH="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" readonly FILE_PATH +# _lib.sh lives at template/script/docker/_lib.sh in normal consumer +# repos, OR alongside build.sh when the Dockerfile `test` stage COPYs +# scripts + helpers into /lint/. Issue #104 deduplicated the previously +# inlined fallback `_detect_lang`; we now always have i18n.sh via +# _lib.sh's sibling load. if [[ -f "${FILE_PATH}/template/script/docker/_lib.sh" ]]; then # shellcheck disable=SC1091 source "${FILE_PATH}/template/script/docker/_lib.sh" +elif [[ -f "${FILE_PATH}/_lib.sh" ]]; then + # shellcheck disable=SC1091 + source "${FILE_PATH}/_lib.sh" else - # Fallback for /lint stage which COPYs only *.sh from repo root and has - # no template/ tree. Only _LANG is needed for `usage()`; other helpers - # are unused in this stage. - _detect_lang() { - case "${LANG:-}" in - zh_TW*) echo "zh-TW" ;; - zh_CN*|zh_SG*) echo "zh-CN" ;; - ja*) echo "ja" ;; - *) echo "en" ;; - esac - } - _LANG="${SETUP_LANG:-$(_detect_lang)}" + printf "[build] ERROR: cannot find _lib.sh — expected one of:\n" >&2 + printf " %s\n" "${FILE_PATH}/template/script/docker/_lib.sh" >&2 + printf " %s\n" "${FILE_PATH}/_lib.sh" >&2 + exit 1 fi _msg() { diff --git a/template/script/docker/exec.sh b/template/script/docker/exec.sh index e298526..3005fe7 100755 --- a/template/script/docker/exec.sh +++ b/template/script/docker/exec.sh @@ -5,20 +5,19 @@ set -euo pipefail FILE_PATH="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" readonly FILE_PATH +# _lib.sh lookup: template/script/docker/_lib.sh in consumer repos, or +# sibling _lib.sh in /lint/ (Dockerfile test stage). See build.sh. if [[ -f "${FILE_PATH}/template/script/docker/_lib.sh" ]]; then # shellcheck disable=SC1091 source "${FILE_PATH}/template/script/docker/_lib.sh" +elif [[ -f "${FILE_PATH}/_lib.sh" ]]; then + # shellcheck disable=SC1091 + source "${FILE_PATH}/_lib.sh" else - # Fallback for /lint stage. See build.sh for rationale. - _detect_lang() { - case "${LANG:-}" in - zh_TW*) echo "zh-TW" ;; - zh_CN*|zh_SG*) echo "zh-CN" ;; - ja*) echo "ja" ;; - *) echo "en" ;; - esac - } - _LANG="${SETUP_LANG:-$(_detect_lang)}" + printf "[exec] ERROR: cannot find _lib.sh — expected one of:\n" >&2 + printf " %s\n" "${FILE_PATH}/template/script/docker/_lib.sh" >&2 + printf " %s\n" "${FILE_PATH}/_lib.sh" >&2 + exit 1 fi _msg() { diff --git a/template/script/docker/i18n.sh b/template/script/docker/i18n.sh index 0bb76c8..c085226 100644 --- a/template/script/docker/i18n.sh +++ b/template/script/docker/i18n.sh @@ -25,19 +25,41 @@ _detect_lang() { # _sanitize_lang [] # # Reads the current value of the nameref, and if it's not in -# {en, zh-TW, zh-CN, ja} prints a WARNING to stderr and rewrites +# {en, zh-TW, zh-CN, ja} prints a warning to stderr and rewrites # the nameref to "en". Callers invoke this right after parsing # --lang so typos don't silently fall through to English at message # lookup time (visible warning, safe default, non-fatal). +# +# Warning language: the user just demonstrated they don't know the +# right --lang value, so we can't trust _LANG (it holds the invalid +# input). Instead we re-detect from the system's $LANG env var so +# the warning appears in the user's actual locale. _sanitize_lang() { local -n _sl_ref="${1:?"${FUNCNAME[0]}: missing outvar name"}" local _who="${2:-tui}" case "${_sl_ref}" in en|zh-TW|zh-CN|ja) return 0 ;; esac - printf "[%s] WARNING: unsupported --lang value %q, falling back to 'en'\n" \ - "${_who}" "${_sl_ref}" >&2 - printf "[%s] allowed: en | zh-TW | zh-CN | ja\n" "${_who}" >&2 + local _sys_lang + _sys_lang="$(_detect_lang)" + case "${_sys_lang}" in + zh-TW) + printf "[%s] 警告:不支援的 --lang 值 %q,改用 'en'\n" "${_who}" "${_sl_ref}" >&2 + printf "[%s] 可用值:en | zh-TW | zh-CN | ja\n" "${_who}" >&2 + ;; + zh-CN) + printf "[%s] 警告:不支持的 --lang 值 %q,改用 'en'\n" "${_who}" "${_sl_ref}" >&2 + printf "[%s] 可用值:en | zh-TW | zh-CN | ja\n" "${_who}" >&2 + ;; + ja) + printf "[%s] 警告: サポート外の --lang 値 %q, 'en' にフォールバックします\n" "${_who}" "${_sl_ref}" >&2 + printf "[%s] 利用可能: en | zh-TW | zh-CN | ja\n" "${_who}" >&2 + ;; + *) + printf "[%s] WARNING: unsupported --lang value %q, falling back to 'en'\n" "${_who}" "${_sl_ref}" >&2 + printf "[%s] allowed: en | zh-TW | zh-CN | ja\n" "${_who}" >&2 + ;; + esac _sl_ref="en" } diff --git a/template/script/docker/run.sh b/template/script/docker/run.sh index 1d5c634..007fe14 100755 --- a/template/script/docker/run.sh +++ b/template/script/docker/run.sh @@ -5,20 +5,19 @@ set -euo pipefail FILE_PATH="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" readonly FILE_PATH +# _lib.sh lookup: template/script/docker/_lib.sh in consumer repos, or +# sibling _lib.sh in /lint/ (Dockerfile test stage). See build.sh. if [[ -f "${FILE_PATH}/template/script/docker/_lib.sh" ]]; then # shellcheck disable=SC1091 source "${FILE_PATH}/template/script/docker/_lib.sh" +elif [[ -f "${FILE_PATH}/_lib.sh" ]]; then + # shellcheck disable=SC1091 + source "${FILE_PATH}/_lib.sh" else - # Fallback for /lint stage. See build.sh for rationale. - _detect_lang() { - case "${LANG:-}" in - zh_TW*) echo "zh-TW" ;; - zh_CN*|zh_SG*) echo "zh-CN" ;; - ja*) echo "ja" ;; - *) echo "en" ;; - esac - } - _LANG="${SETUP_LANG:-$(_detect_lang)}" + printf "[run] ERROR: cannot find _lib.sh — expected one of:\n" >&2 + printf " %s\n" "${FILE_PATH}/template/script/docker/_lib.sh" >&2 + printf " %s\n" "${FILE_PATH}/_lib.sh" >&2 + exit 1 fi _msg() { diff --git a/template/script/docker/setup.sh b/template/script/docker/setup.sh index eb3613c..a67478c 100755 --- a/template/script/docker/setup.sh +++ b/template/script/docker/setup.sh @@ -576,6 +576,27 @@ _resolve_runtime() { esac } +# _resolve_build_network +# mode=host / bridge / none / default → pass through +# mode=auto → "host" iff _detect_jetson, else "" (issue #102) +# mode=off | "" → "" (no network key emitted; Docker defaults to bridge) +# +# Jetson L4T kernels commonly lack the iptables modules docker's bridge +# NAT needs, so first-time `docker build` on Jetson dies with DNS +# resolution failures before the apt step. Auto-promoting to host-net +# on Jetson removes the trap door; desktop hosts keep default bridge. +_resolve_build_network() { + local _mode="${1:-}" + local -n _rbn_out="${2:?}" + case "${_mode}" in + host|bridge|none|default) _rbn_out="${_mode}" ;; + auto) + if _detect_jetson; then _rbn_out="host"; else _rbn_out=""; fi + ;; + off|""|*) _rbn_out="" ;; + esac +} + # ════════════════════════════════════════════════════════════════════ # _compute_conf_hash # @@ -1199,8 +1220,10 @@ main() { # compose.yaml and `--network ` to the auxiliary test-tools # docker build. Typical value: `host`, for hosts whose docker bridge # NAT is unusable (stripped embedded kernels, iptables:false). + local build_network_mode="" + _get_conf_value _build_k _build_v "network" "auto" build_network_mode local build_network="" - _get_conf_value _build_k _build_v "network" "" build_network + _resolve_build_network "${build_network_mode}" build_network local gpu_mode="" gpu_count="" gpu_caps="" runtime_mode="" local gui_mode="" diff --git a/template/script/docker/setup_tui.sh b/template/script/docker/setup_tui.sh index 802191c..cfa8043 100755 --- a/template/script/docker/setup_tui.sh +++ b/template/script/docker/setup_tui.sh @@ -95,11 +95,11 @@ declare -gA _TUI_MSG_EN=( [build.target_arch.prompt]=$'Docker TARGETARCH override\n - Empty = let BuildKit auto-fill from host / --platform (default)\n - amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64\n - Applies to the main image + the test-tools image\n - Pin when you need cross-builds or explicit control' [build.target_arch.auto]="(auto)" [build.network.label]="Build network" - [build.network.prompt]=$'Docker build-time network (only the build stage; runtime is separate)\n - Empty = Docker default (bridge + NAT)\n - host = use the host network stack. Required when the host\'s bridge\n NAT is unusable: stripped embedded kernels (e.g. Jetson L4T\n missing iptable_raw), hosts with iptables: false, or firewall-\n locked CI runners.\n - bridge / none / default = explicit variants (rarely needed)' - [build.network.default]="(default: bridge)" + [build.network.prompt]=$'Docker build-time network (only the build stage; runtime is separate)\n - auto = detect Jetson (/etc/nv_tegra_release) → host; desktop → Docker default (default)\n - host = force host network stack. Required when bridge NAT is\n unusable (stripped kernels, iptables: false, firewall-locked CI)\n - bridge / none / default = explicit Docker modes\n - off (or empty) = explicit opt-out; stay on Docker default bridge' + [build.network.default]="(default: auto)" [build.args.label]="Extra build args" [err.invalid_target_arch]="Invalid TARGETARCH. Use empty or amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64." - [err.invalid_build_network]="Invalid build network. Use empty or host / bridge / none / default." + [err.invalid_build_network]="Invalid build network. Use auto / host / bridge / none / default / off (or empty)." [network.title]="Network" [network.mode.prompt]="Network mode" [network.mode.host]="host (share host network stack)" @@ -248,11 +248,11 @@ declare -gA _TUI_MSG_ZH_TW=( [build.target_arch.prompt]=$'Docker TARGETARCH 覆寫\n - 留空 = 交給 BuildKit 依 host / --platform 自動填(預設)\n - amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64\n - 同時套用主 image 與 test-tools image\n - 需要跨架構編譯或明確指定時才填' [build.target_arch.auto]="(自動)" [build.network.label]="Build 網路" - [build.network.prompt]=$'Docker build 階段使用的網路(不影響 runtime 容器的網路)\n - 留空 = Docker 預設(bridge + NAT)\n - host = 改用 host 網路 stack。當主機的 bridge NAT 無法用時需要:\n 例如 kernel 缺 iptable_raw(Jetson L4T)、\n daemon.json 有 iptables: false、或 CI runner 防火牆限制。\n - bridge / none / default = 其他明確選項(很少用到)' - [build.network.default]="(預設:bridge)" + [build.network.prompt]=$'Docker build 階段使用的網路(不影響 runtime 容器的網路)\n - auto = 自動偵測 Jetson(/etc/nv_tegra_release)→ host;桌機 → Docker 預設(預設)\n - host = 強制 host 網路 stack。當主機的 bridge NAT 無法用時需要:\n 例如 kernel 缺 iptable_raw(Jetson L4T)、\n daemon.json 有 iptables: false、或 CI runner 防火牆限制\n - bridge / none / default = 其他明確 Docker 選項\n - off(或留空)= 明確關閉;用 Docker 預設的 bridge' + [build.network.default]="(預設:auto)" [build.args.label]="額外 build args" [err.invalid_target_arch]="TARGETARCH 無效,請填空值或 amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64。" - [err.invalid_build_network]="Build 網路無效,請填空值或 host / bridge / none / default。" + [err.invalid_build_network]="Build 網路無效,請填 auto / host / bridge / none / default / off(或留空)。" [network.title]="Network" [network.mode.prompt]="網路模式" [network.mode.host]="host(共用主機網路堆疊)" @@ -399,11 +399,11 @@ declare -gA _TUI_MSG_ZH_CN=( [build.target_arch.prompt]=$'Docker TARGETARCH 覆盖\n - 留空 = 交给 BuildKit 依 host / --platform 自动填(默认)\n - amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64\n - 同时应用于主 image 与 test-tools image\n - 需要跨架构构建或明确指定时才填' [build.target_arch.auto]="(自动)" [build.network.label]="Build 网络" - [build.network.prompt]=$'Docker build 阶段使用的网络(不影响 runtime 容器的网络)\n - 留空 = Docker 默认(bridge + NAT)\n - host = 改用 host 网络 stack。当主机的 bridge NAT 无法用时需要:\n 例如 kernel 缺 iptable_raw(Jetson L4T)、\n daemon.json 有 iptables: false、或 CI runner 防火墙限制。\n - bridge / none / default = 其他明确选项(很少用到)' - [build.network.default]="(默认:bridge)" + [build.network.prompt]=$'Docker build 阶段使用的网络(不影响 runtime 容器的网络)\n - auto = 自动检测 Jetson(/etc/nv_tegra_release)→ host;桌机 → Docker 默认(默认)\n - host = 强制 host 网络 stack。当主机的 bridge NAT 无法用时需要:\n 例如 kernel 缺 iptable_raw(Jetson L4T)、\n daemon.json 有 iptables: false、或 CI runner 防火墙限制\n - bridge / none / default = 其他明确 Docker 选项\n - off(或留空)= 明确关闭;用 Docker 默认的 bridge' + [build.network.default]="(默认:auto)" [build.args.label]="额外 build args" [err.invalid_target_arch]="TARGETARCH 无效,请填空值或 amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64。" - [err.invalid_build_network]="Build 网络无效,请填空值或 host / bridge / none / default。" + [err.invalid_build_network]="Build 网络无效,请填 auto / host / bridge / none / default / off(或留空)。" [network.title]="Network" [network.mode.prompt]="网络模式" [network.mode.host]="host(共用主机网络栈)" @@ -545,11 +545,11 @@ declare -gA _TUI_MSG_JA=( [build.target_arch.prompt]=$'Docker TARGETARCH 上書き\n - 空 = BuildKit が host / --platform から自動補完(デフォルト)\n - amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64\n - メイン image と test-tools image の両方に適用\n - クロスビルドや明示指定が必要なときのみ設定' [build.target_arch.auto]="(自動)" [build.network.label]="Build ネットワーク" - [build.network.prompt]=$'Docker build 時のネットワーク(runtime コンテナは別管理)\n - 空 = Docker デフォルト(bridge + NAT)\n - host = ホストのネットワークスタックを利用。ホストの bridge NAT が\n 使えない場合に必要:kernel が iptable_raw 欠落(Jetson L4T)、\n daemon.json に iptables: false、CI runner のファイアウォール制限など。\n - bridge / none / default = 明示指定(めったに使用しない)' - [build.network.default]="(デフォルト:bridge)" + [build.network.prompt]=$'Docker build 時のネットワーク(runtime コンテナは別管理)\n - auto = Jetson(/etc/nv_tegra_release)検出時は host、デスクトップは Docker 既定(既定)\n - host = 強制的にホストネットワーク stack を使用。ホストの bridge NAT が\n 使えない場合に必要:kernel が iptable_raw 欠落(Jetson L4T)、\n daemon.json に iptables: false、CI runner のファイアウォール制限など\n - bridge / none / default = 明示指定(Docker の既知モード)\n - off(または空)= 明示的にオプトアウト。Docker 既定の bridge を使用' + [build.network.default]="(デフォルト:auto)" [build.args.label]="追加 build args" [err.invalid_target_arch]="TARGETARCH が不正です。空、または amd64 / arm64 / arm / 386 / ppc64le / s390x / riscv64 を指定してください。" - [err.invalid_build_network]="Build ネットワークが不正です。空、または host / bridge / none / default を指定してください。" + [err.invalid_build_network]="Build ネットワークが不正です。auto / host / bridge / none / default / off(または空)を指定してください。" [network.title]="Network" [network.mode.prompt]="ネットワークモード" [network.mode.host]="host(ホストネットワークスタックを共有)" diff --git a/template/script/docker/stop.sh b/template/script/docker/stop.sh index 5a9db15..de74a4d 100755 --- a/template/script/docker/stop.sh +++ b/template/script/docker/stop.sh @@ -5,20 +5,19 @@ set -euo pipefail FILE_PATH="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" readonly FILE_PATH +# _lib.sh lookup: template/script/docker/_lib.sh in consumer repos, or +# sibling _lib.sh in /lint/ (Dockerfile test stage). See build.sh. if [[ -f "${FILE_PATH}/template/script/docker/_lib.sh" ]]; then # shellcheck disable=SC1091 source "${FILE_PATH}/template/script/docker/_lib.sh" +elif [[ -f "${FILE_PATH}/_lib.sh" ]]; then + # shellcheck disable=SC1091 + source "${FILE_PATH}/_lib.sh" else - # Fallback for /lint stage. See build.sh for rationale. - _detect_lang() { - case "${LANG:-}" in - zh_TW*) echo "zh-TW" ;; - zh_CN*|zh_SG*) echo "zh-CN" ;; - ja*) echo "ja" ;; - *) echo "en" ;; - esac - } - _LANG="${SETUP_LANG:-$(_detect_lang)}" + printf "[stop] ERROR: cannot find _lib.sh — expected one of:\n" >&2 + printf " %s\n" "${FILE_PATH}/template/script/docker/_lib.sh" >&2 + printf " %s\n" "${FILE_PATH}/_lib.sh" >&2 + exit 1 fi _msg() { diff --git a/template/setup.conf b/template/setup.conf index 9006b70..b4b0be2 100644 --- a/template/setup.conf +++ b/template/setup.conf @@ -75,20 +75,23 @@ rule_3 = @basename # network: Docker build-time network mode. Only the user's own # Dockerfile RUN stages (apk/apt/curl/git clone) care — runtime is # controlled separately via [network] mode. -# → Docker default (bridge + NAT via iptables) -# host → build uses the host's network stack. Necessary -# on environments where Docker's default bridge -# NAT is unusable: stripped embedded kernels (e.g. -# Jetson L4T missing iptable_raw), firewall-locked -# CI runners, or hosts with `iptables: false` in -# daemon.json. -# When non-empty, setup.sh writes BUILD_NETWORK to .env and emits -# `build.network: ` under each service in compose.yaml; -# build.sh forwards `--network ` to the auxiliary `docker build` -# call for test-tools. +# auto → auto-detect Jetson (/etc/nv_tegra_release) and +# emit host-net only there; desktop hosts stay on +# Docker's default bridge. Default. +# host → force host-net for all hosts. Necessary when +# Docker's default bridge NAT is unusable: +# stripped embedded kernels (Jetson L4T missing +# iptable_raw), firewall-locked CI runners, hosts +# with `iptables: false` in daemon.json. +# bridge|none|default → explicit Docker-known network modes. +# off → never emit (explicitly opt out of auto). +# When non-empty (after resolve), setup.sh writes BUILD_NETWORK to +# .env and emits `build.network: ` under each service in +# compose.yaml; build.sh forwards `--network ` to the +# auxiliary `docker build` call for test-tools. [build] target_arch = -network = +network = auto arg_1 = APT_MIRROR_UBUNTU=tw.archive.ubuntu.com arg_2 = APT_MIRROR_DEBIAN=mirror.twds.com.tw arg_3 = TZ=Asia/Taipei diff --git a/template/test/unit/build_sh_spec.bats b/template/test/unit/build_sh_spec.bats index e7066e0..513ccad 100644 --- a/template/test/unit/build_sh_spec.bats +++ b/template/test/unit/build_sh_spec.bats @@ -352,34 +352,41 @@ EOS assert_output --partial "使用法" } -# ── Fallback _detect_lang (no template/ tree) ────────────────────────────── -# Exercises lines 17-19 of build.sh where _lib.sh is missing and _detect_lang -# maps LANG → {zh, zh-CN, ja}. Symlink (not copy) so kcov attributes runs. +# ── /lint/-layout _detect_lang (flat dir: build.sh + _lib.sh + i18n.sh) ──── +# After #104 the inline fallback is gone; scripts in the Dockerfile test +# stage rely on _lib.sh + i18n.sh copied alongside. These tests exercise +# that layout by symlinking build.sh (for kcov) and copying the helpers. -@test "build.sh fallback _detect_lang maps zh_TW.UTF-8 to zh-TW" { +@test "build.sh in /lint/ layout maps zh_TW.UTF-8 to zh-TW" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/build.sh "${_tmp}/build.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_TW.UTF-8 run bash "${_tmp}/build.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "build.sh fallback _detect_lang maps zh_CN.UTF-8 to zh-CN" { +@test "build.sh in /lint/ layout maps zh_CN.UTF-8 to zh-CN" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/build.sh "${_tmp}/build.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_CN.UTF-8 run bash "${_tmp}/build.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "build.sh fallback _detect_lang maps ja_JP.UTF-8 to ja" { +@test "build.sh in /lint/ layout maps ja_JP.UTF-8 to ja" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/build.sh "${_tmp}/build.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=ja_JP.UTF-8 run bash "${_tmp}/build.sh" -h assert_success assert_output --partial "使用法" diff --git a/template/test/unit/exec_sh_spec.bats b/template/test/unit/exec_sh_spec.bats index e57977e..45005f1 100644 --- a/template/test/unit/exec_sh_spec.bats +++ b/template/test/unit/exec_sh_spec.bats @@ -146,32 +146,38 @@ teardown() { assert_output --partial "exec" } -# ── Fallback _detect_lang (no template/ tree) ────────────────────────────── +# ── /lint/-layout _detect_lang (flat dir with _lib.sh + i18n.sh, #104) ───── -@test "exec.sh fallback _detect_lang maps zh_TW.UTF-8 to zh-TW" { +@test "exec.sh in /lint/ layout maps zh_TW.UTF-8 to zh-TW" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/exec.sh "${_tmp}/exec.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_TW.UTF-8 run bash "${_tmp}/exec.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "exec.sh fallback _detect_lang maps zh_CN.UTF-8 to zh-CN" { +@test "exec.sh in /lint/ layout maps zh_CN.UTF-8 to zh-CN" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/exec.sh "${_tmp}/exec.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_CN.UTF-8 run bash "${_tmp}/exec.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "exec.sh fallback _detect_lang maps ja_JP.UTF-8 to ja" { +@test "exec.sh in /lint/ layout maps ja_JP.UTF-8 to ja" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/exec.sh "${_tmp}/exec.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=ja_JP.UTF-8 run bash "${_tmp}/exec.sh" -h assert_success assert_output --partial "使用法" diff --git a/template/test/unit/lib_spec.bats b/template/test/unit/lib_spec.bats index 433c5c7..664e4e3 100644 --- a/template/test/unit/lib_spec.bats +++ b/template/test/unit/lib_spec.bats @@ -166,8 +166,9 @@ EOF assert_output "ja" } -@test "_sanitize_lang warns and falls back to 'en' for unsupported values" { - run bash -c "source ${LIB}; v=foo; _sanitize_lang v test 2>&1; echo \"--VALUE=\${v}\"" +@test "_sanitize_lang warns and falls back to 'en' for unsupported values (English default)" { + # Locale-agnostic / English system: English WARNING is emitted. + run bash -c "unset LANG; source ${LIB}; v=foo; _sanitize_lang v test 2>&1; echo \"--VALUE=\${v}\"" assert_success assert_output --partial "WARNING" assert_output --partial "foo" @@ -175,12 +176,38 @@ EOF } @test "_sanitize_lang warns for the old bare 'zh' code (post zh→zh-TW rename)" { - run bash -c "source ${LIB}; v=zh; _sanitize_lang v tui 2>&1; echo \"--VALUE=\${v}\"" + run bash -c "unset LANG; source ${LIB}; v=zh; _sanitize_lang v tui 2>&1; echo \"--VALUE=\${v}\"" assert_success assert_output --partial "WARNING" assert_output --partial "--VALUE=en" } +@test "_sanitize_lang warning is localized to system LANG (zh-TW)" { + # Regression: v0.9.7 Agent A scoped this helper out of i18n coverage. + # v0.9.11 localizes the warning using the SYSTEM LANG (not _LANG, + # which holds the invalid input), so a user whose shell is zh-TW sees + # the warning in Traditional Chinese rather than English. + run env LANG=zh_TW.UTF-8 bash -c "source ${LIB}; v=foo; _sanitize_lang v test 2>&1" + assert_success + assert_output --partial "警告" + assert_output --partial "foo" + refute_output --partial "WARNING" +} + +@test "_sanitize_lang warning is localized to system LANG (zh-CN)" { + run env LANG=zh_CN.UTF-8 bash -c "source ${LIB}; v=foo; _sanitize_lang v test 2>&1" + assert_success + assert_output --partial "警告" + refute_output --partial "WARNING" +} + +@test "_sanitize_lang warning is localized to system LANG (ja)" { + run env LANG=ja_JP.UTF-8 bash -c "source ${LIB}; v=foo; _sanitize_lang v test 2>&1" + assert_success + assert_output --partial "警告: サポート外" + refute_output --partial "WARNING" +} + # ── _dump_conf_section / _print_config_summary ───────────────────────────── _write_sample_conf() { @@ -358,6 +385,27 @@ EOF assert_output "ファイル|ID|ユーザー|ハードウェア" } +@test "_lib_msg returns count / caps across all languages" { + # Regression: these two keys are only invoked inline in the + # "Resolved" block of _print_config_summary and were missed by + # spot-check assertions; kcov flagged both branches as uncovered. + run bash -c "source ${LIB}; _LANG=en; echo \"\$(_lib_msg count)|\$(_lib_msg caps)\"" + assert_success + assert_output "count|caps" + + run bash -c "source ${LIB}; _LANG=zh-TW; echo \"\$(_lib_msg count)|\$(_lib_msg caps)\"" + assert_success + assert_output "數量|能力" + + run bash -c "source ${LIB}; _LANG=zh-CN; echo \"\$(_lib_msg count)|\$(_lib_msg caps)\"" + assert_success + assert_output "数量|能力" + + run bash -c "source ${LIB}; _LANG=ja; echo \"\$(_lib_msg count)|\$(_lib_msg caps)\"" + assert_success + assert_output "数量|ケーパビリティ" +} + @test "_lib_msg falls back to English for unknown _LANG value" { # unknown locale should not silently output empty — falls through to *:. run bash -c "source ${LIB}; _LANG=de; echo \"\$(_lib_msg files)|\$(_lib_msg identity)\"" diff --git a/template/test/unit/run_sh_spec.bats b/template/test/unit/run_sh_spec.bats index 7c6493a..521714f 100644 --- a/template/test/unit/run_sh_spec.bats +++ b/template/test/unit/run_sh_spec.bats @@ -299,32 +299,38 @@ EOS assert_success } -# ── Fallback _detect_lang (no template/ tree) ────────────────────────────── +# ── /lint/-layout _detect_lang (flat dir with _lib.sh + i18n.sh, #104) ───── -@test "run.sh fallback _detect_lang maps zh_TW.UTF-8 to zh-TW" { +@test "run.sh in /lint/ layout maps zh_TW.UTF-8 to zh-TW" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/run.sh "${_tmp}/run.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_TW.UTF-8 run bash "${_tmp}/run.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "run.sh fallback _detect_lang maps zh_CN.UTF-8 to zh-CN" { +@test "run.sh in /lint/ layout maps zh_CN.UTF-8 to zh-CN" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/run.sh "${_tmp}/run.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_CN.UTF-8 run bash "${_tmp}/run.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "run.sh fallback _detect_lang maps ja_JP.UTF-8 to ja" { +@test "run.sh in /lint/ layout maps ja_JP.UTF-8 to ja" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/run.sh "${_tmp}/run.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=ja_JP.UTF-8 run bash "${_tmp}/run.sh" -h assert_success assert_output --partial "使用法" diff --git a/template/test/unit/setup_spec.bats b/template/test/unit/setup_spec.bats index 5c1fb7b..3bc35ec 100644 --- a/template/test/unit/setup_spec.bats +++ b/template/test/unit/setup_spec.bats @@ -535,6 +535,52 @@ EOF assert_equal "${_out}" "" } +# ════════════════════════════════════════════════════════════════════ +# _resolve_build_network (Jetson build-net auto-detect, issue #102) +# ════════════════════════════════════════════════════════════════════ + +@test "_resolve_build_network auto on Jetson => host" { + local _out + SETUP_DETECT_JETSON=true _resolve_build_network "auto" _out + assert_equal "${_out}" "host" +} + +@test "_resolve_build_network auto off Jetson => empty" { + local _out + SETUP_DETECT_JETSON=false _resolve_build_network "auto" _out + assert_equal "${_out}" "" +} + +@test "_resolve_build_network host => always host (explicit override wins)" { + local _out + SETUP_DETECT_JETSON=false _resolve_build_network "host" _out + assert_equal "${_out}" "host" +} + +@test "_resolve_build_network bridge / none / default pass through" { + local _out + _resolve_build_network "bridge" _out + assert_equal "${_out}" "bridge" + _resolve_build_network "none" _out + assert_equal "${_out}" "none" + _resolve_build_network "default" _out + assert_equal "${_out}" "default" +} + +@test "_resolve_build_network off / empty => empty (explicitly suppressed)" { + local _out + SETUP_DETECT_JETSON=true _resolve_build_network "off" _out + assert_equal "${_out}" "" + SETUP_DETECT_JETSON=true _resolve_build_network "" _out + assert_equal "${_out}" "" +} + +@test "_resolve_build_network unknown mode falls through to empty" { + local _out + SETUP_DETECT_JETSON=true _resolve_build_network "garbage" _out + assert_equal "${_out}" "" +} + # ════════════════════════════════════════════════════════════════════ # detect_image_name (now reads [image] rules from setup.conf) # ════════════════════════════════════════════════════════════════════ diff --git a/template/test/unit/stop_sh_spec.bats b/template/test/unit/stop_sh_spec.bats index 6beb255..f33ffb3 100644 --- a/template/test/unit/stop_sh_spec.bats +++ b/template/test/unit/stop_sh_spec.bats @@ -143,32 +143,38 @@ teardown() { assert_output --partial "mockuser-mockimg-bar" } -# ── Fallback _detect_lang (no template/ tree) ────────────────────────────── +# ── /lint/-layout _detect_lang (flat dir with _lib.sh + i18n.sh, #104) ───── -@test "stop.sh fallback _detect_lang maps zh_TW.UTF-8 to zh-TW" { +@test "stop.sh in /lint/ layout maps zh_TW.UTF-8 to zh-TW" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/stop.sh "${_tmp}/stop.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_TW.UTF-8 run bash "${_tmp}/stop.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "stop.sh fallback _detect_lang maps zh_CN.UTF-8 to zh-CN" { +@test "stop.sh in /lint/ layout maps zh_CN.UTF-8 to zh-CN" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/stop.sh "${_tmp}/stop.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=zh_CN.UTF-8 run bash "${_tmp}/stop.sh" -h assert_success assert_output --partial "用法" rm -rf "${_tmp}" } -@test "stop.sh fallback _detect_lang maps ja_JP.UTF-8 to ja" { +@test "stop.sh in /lint/ layout maps ja_JP.UTF-8 to ja" { local _tmp _tmp="$(mktemp -d)" ln -s /source/script/docker/stop.sh "${_tmp}/stop.sh" + cp /source/script/docker/_lib.sh "${_tmp}/_lib.sh" + cp /source/script/docker/i18n.sh "${_tmp}/i18n.sh" LANG=ja_JP.UTF-8 run bash "${_tmp}/stop.sh" -h assert_success assert_output --partial "使用法" diff --git a/template/test/unit/template_spec.bats b/template/test/unit/template_spec.bats index 59e9856..b18d16c 100644 --- a/template/test/unit/template_spec.bats +++ b/template/test/unit/template_spec.bats @@ -521,46 +521,101 @@ EOF assert_success } -@test "build.sh -h works when i18n.sh is missing (consumer Dockerfile /lint scenario)" { - # Regression: consumer Dockerfile copies *.sh into /lint without template/ - # tree, so sourcing template/script/docker/i18n.sh fails. build.sh must - # gracefully fall back to inline _detect_lang so smoke tests pass. +_stage_lint_layout() { + # Simulate Dockerfile.example's /lint/ stage: script + helpers in one + # flat directory. Callers pass the script file under test. + local _dest="${1:?}" _script="${2:?}" + cp "/source/script/docker/${_script}" "${_dest}/${_script}" + cp /source/script/docker/_lib.sh "${_dest}/_lib.sh" + cp /source/script/docker/i18n.sh "${_dest}/i18n.sh" +} + +@test "build.sh -h works in /lint/ layout (flat dir with _lib.sh + i18n.sh, issue #104)" { + # After #104 we no longer carry inline _detect_lang fallbacks; the + # /lint/ stage COPY must include _lib.sh and i18n.sh alongside. local _tmp _tmp="$(mktemp -d)" - cp /source/script/docker/build.sh "${_tmp}/build.sh" + _stage_lint_layout "${_tmp}" build.sh run bash "${_tmp}/build.sh" -h assert_success assert_output --partial "Usage" rm -rf "${_tmp}" } -@test "run.sh -h works when i18n.sh is missing" { +@test "run.sh -h works in /lint/ layout" { local _tmp _tmp="$(mktemp -d)" - cp /source/script/docker/run.sh "${_tmp}/run.sh" + _stage_lint_layout "${_tmp}" run.sh run bash "${_tmp}/run.sh" -h assert_success rm -rf "${_tmp}" } -@test "exec.sh -h works when i18n.sh is missing" { +@test "exec.sh -h works in /lint/ layout" { local _tmp _tmp="$(mktemp -d)" - cp /source/script/docker/exec.sh "${_tmp}/exec.sh" + _stage_lint_layout "${_tmp}" exec.sh run bash "${_tmp}/exec.sh" -h assert_success rm -rf "${_tmp}" } -@test "stop.sh -h works when i18n.sh is missing" { +@test "stop.sh -h works in /lint/ layout" { local _tmp _tmp="$(mktemp -d)" - cp /source/script/docker/stop.sh "${_tmp}/stop.sh" + _stage_lint_layout "${_tmp}" stop.sh run bash "${_tmp}/stop.sh" -h assert_success rm -rf "${_tmp}" } +@test "build.sh errors with a clear diagnostic when _lib.sh missing from both paths (issue #104)" { + # No template/script/docker/_lib.sh nor sibling _lib.sh → explicit + # non-zero exit + error message pointing the user at the two + # expected paths. Better UX than the old silent inline fallback + # that hid the absence. + local _tmp + _tmp="$(mktemp -d)" + cp /source/script/docker/build.sh "${_tmp}/build.sh" + run bash "${_tmp}/build.sh" -h + assert_failure + assert_output --partial "cannot find _lib.sh" + rm -rf "${_tmp}" +} + +@test "Dockerfile.example copies _lib.sh + i18n.sh + _tui_conf.sh into /lint/ (issue #104)" { + # Structural guard: if the Dockerfile COPY is dropped, the /lint/ + # smoke test (script_help.bats) would break silently for new + # downstream repos. Pin it here. + run grep -F 'template/script/docker/_lib.sh' /source/dockerfile/Dockerfile.example + assert_success + run grep -F 'template/script/docker/i18n.sh' /source/dockerfile/Dockerfile.example + assert_success + run grep -F 'template/script/docker/_tui_conf.sh' /source/dockerfile/Dockerfile.example + assert_success +} + +@test "no inline _detect_lang fallbacks remain after dedupe (issue #104)" { + # Lock in: only i18n.sh defines _detect_lang. build.sh / run.sh / + # exec.sh / stop.sh / _lib.sh previously shipped their own copies, + # which drifted (see #103's zh→zh-TW typo) — a single-source + # definition prevents further drift. + local _count + _count="$(grep -cE '^_detect_lang\(\)' \ + /source/script/docker/build.sh \ + /source/script/docker/run.sh \ + /source/script/docker/exec.sh \ + /source/script/docker/stop.sh \ + /source/script/docker/_lib.sh \ + /source/script/docker/setup.sh \ + | awk -F: '{sum += $2} END {print sum}')" + [ "${_count}" = "0" ] + + # i18n.sh must still have exactly one definition. + run grep -cE '^_detect_lang\(\)' /source/script/docker/i18n.sh + assert_output "1" +} + @test "setup.sh does not redefine _detect_lang" { # setup.sh is not COPY'd into consumer /lint stage, so no fallback needed run grep -cE '^_detect_lang\(\)' /source/script/docker/setup.sh diff --git a/template/test/unit/tui_spec.bats b/template/test/unit/tui_spec.bats index 4abda88..905f40b 100644 --- a/template/test/unit/tui_spec.bats +++ b/template/test/unit/tui_spec.bats @@ -238,6 +238,11 @@ teardown() { _validate_build_network default } +@test "_validate_build_network accepts auto / off (issue #102)" { + _validate_build_network auto + _validate_build_network off +} + @test "_validate_build_network rejects unknown values" { run _validate_build_network "HOST" # case-sensitive [ "${status}" -ne 0 ] @@ -386,6 +391,34 @@ teardown() { assert_equal "${_host}" '${WS_PATH}' } +# ════════════════════════════════════════════════════════════════════ +# _mount_container_path +# ════════════════════════════════════════════════════════════════════ + +@test "_mount_container_path extracts plain container path" { + local _cont="" + _mount_container_path "/data:/data" _cont + assert_equal "${_cont}" "/data" +} + +@test "_mount_container_path extracts container path with mode" { + local _cont="" + _mount_container_path "/data:/data:ro" _cont + assert_equal "${_cont}" "/data" +} + +@test "_mount_container_path extracts container path with env var" { + local _cont="" + _mount_container_path '${WS_PATH}:/home/${USER_NAME}/work' _cont + assert_equal "${_cont}" '/home/${USER_NAME}/work' +} + +@test "_mount_container_path empty when input has no colon" { + local _cont="sentinel" + _mount_container_path "/standalone" _cont + assert_equal "${_cont}" "" +} + # ════════════════════════════════════════════════════════════════════ # _load_setup_conf_full + _write_setup_conf (INI round-trip) # ════════════════════════════════════════════════════════════════════ @@ -573,6 +606,84 @@ EOF [[ "${output}" == "rules = @default:foo" ]] } +@test "_upsert_conf_value creates section + key when section absent" { + # Coverage gap: `Section not found at all → append new section + key` + # branch at the tail of _upsert_conf_value was never exercised. + cat > "${TEMP_DIR}/setup.conf" <<'EOF' +[image] +rule_1 = @default:foo +EOF + _upsert_conf_value "${TEMP_DIR}/setup.conf" "volumes" "mount_1" "/a:/b" + + run cat "${TEMP_DIR}/setup.conf" + assert_output --partial "[volumes]" + assert_output --partial "mount_1 = /a:/b" + # Pre-existing section untouched + assert_output --partial "rule_1 = @default:foo" +} + +@test "_upsert_conf_value appends key at EOF when section is the last one without target key" { + # Coverage gap: "Still in target section at EOF and key not matched → + # append" branch of _upsert_conf_value. + cat > "${TEMP_DIR}/setup.conf" <<'EOF' +[image] +rule_1 = @default:foo + +[volumes] +mount_1 = /a:/a +EOF + _upsert_conf_value "${TEMP_DIR}/setup.conf" "volumes" "mount_2" "/b:/b" + + run cat "${TEMP_DIR}/setup.conf" + assert_output --partial "mount_1 = /a:/a" + assert_output --partial "mount_2 = /b:/b" +} + +@test "_write_setup_conf flushes unknown override keys that belong to the final template section" { + # Coverage gap: the "Flush leftovers belonging to the final section" + # block fires when an override's key doesn't match any template line + # AND the override's section is the LAST one in the template. Without + # that terminal flush, the unknown key would be dropped. + cat > "${TEMP_DIR}/template.conf" <<'EOF' +[image] +rule_1 = prefix:docker_ + +[volumes] +mount_1 = /a:/a +EOF + # volumes is the last section; mount_9 is a new key not in template. + local -a _sections=(volumes) \ + _keys=(volumes.mount_9) \ + _values=("/extra:/extra") + _write_setup_conf "${TEMP_DIR}/out.conf" "${TEMP_DIR}/template.conf" \ + _sections _keys _values + + run cat "${TEMP_DIR}/out.conf" + assert_output --partial "mount_1 = /a:/a" + assert_output --partial "mount_9 = /extra:/extra" +} + +@test "_write_setup_conf removed_keys + final-section flush interplay" { + # Regression: ensure removed_keys suppresses the EOF flush so a key + # the user explicitly cleared doesn't reappear as a trailing override. + cat > "${TEMP_DIR}/template.conf" <<'EOF' +[image] +rule_1 = prefix:docker_ + +[volumes] +mount_1 = /a:/a +EOF + local -a _sections=(volumes) \ + _keys=(volumes.mount_9) \ + _values=("/extra:/extra") + _write_setup_conf "${TEMP_DIR}/out.conf" "${TEMP_DIR}/template.conf" \ + _sections _keys _values "volumes.mount_9" + + run cat "${TEMP_DIR}/out.conf" + refute_output --partial "mount_9" + assert_output --partial "mount_1 = /a:/a" +} + # ════════════════════════════════════════════════════════════════════ # _edit_list_section — regression tests for B5 (volumes mount_1) # ════════════════════════════════════════════════════════════════════