From 57960c9fcacef9e53dcf4d18f8d5f4ede2b72fde Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 3 Oct 2026 05:30:56 +0800 Subject: [PATCH] Install native Asterisk as a verified user systemd service --- AGENTS.md | 2 +- deploys/README.md | 2 +- deploys/cell/README.md | 25 +++--- deploys/cell/asterisk.service | 22 ----- deploys/cell/build-asterisk-native.sh | 16 ++-- deploys/cell/install-asterisk-native.sh | 37 -------- deploys/cell/install-asterisk-user.sh | 85 +++++++++++++++++++ .../asterisk-22.10.1-native/build-platform | 1 + deploys/physical-deployment.md | 13 +-- docs/evidence/sip-user-service-nonprod.md | 19 +++++ internal/config/deployment_layout_test.go | 4 +- 11 files changed, 142 insertions(+), 84 deletions(-) delete mode 100644 deploys/cell/asterisk.service delete mode 100644 deploys/cell/install-asterisk-native.sh create mode 100755 deploys/cell/install-asterisk-user.sh create mode 100644 deploys/packages/asterisk-22.10.1-native/build-platform create mode 100644 docs/evidence/sip-user-service-nonprod.md diff --git a/AGENTS.md b/AGENTS.md index 53a21e2..deaa4b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ ## 运行环境、诊断与开发门禁 - 项目是独立 Go module,工具链 Go **1.27.1**;普通构建、测试、运行不读取父项目业务模块、数据库、env 或夹具。标准库和成熟官方 SDK 优先,Cobra 显式 `agent`/`dispatcher`,单制品分角色/权限/目录。禁止自行重写 SIP/ARI、RTP/RTCP/G.711、WebSocket、AMQP、SQLite 驱动、OSS 签名及 SDK 已覆盖的 AI 协议;核验现有依赖能力后再新增库。生产原生 Asterisk 仍由独立 Cell 的 systemd 统一管理,不声称当前 Mock 已完成真实媒体或 1000 路容量验收。 -- 生产 ECS 优先 Debian 13(Trixie)minimal;只有阿里云北京无可用镜像时允许 Ubuntu 24.04 LTS。Debian 12 仅供明确标记的非生产测试:必须在 Debian 12 原生构建 Asterisk,不得部署 Debian 13 编译的制品,也不得据此批准生产发布。Asterisk 直接安装在承载 ECS 主机,由 systemd 启用自启动并核对 `enabled+active`;不得以前台进程或容器入口代替。 +- 生产 ECS 优先 Debian 13(Trixie)minimal;只有阿里云北京无可用镜像时允许 Ubuntu 24.04 LTS。Debian 12 仅供明确标记的非生产测试:必须在 Debian 12 原生构建 Asterisk,不得部署 Debian 13 编译的制品,也不得据此批准生产发布。Asterisk 直接安装在承载 ECS 主机,以 `rogee` 的 `systemd --user` 服务管理,核对 `enabled+active`;生产环境还须启用 `loginctl enable-linger rogee` 并验证重启后持续运行。非生产若未启用 lingering,必须标记重启自启动未验收。不得以前台进程或容器入口代替。 - 开发、Mock、mixed、real 的**非生产主机**部署与诊断步骤默认强制,不因时间/旧环境/调用方参数跳过或静默降级;显式关闭即失败。每次新主机/版本/Cell 至少留存脱敏 ECS/EIP/网络只读核验、Debian/架构/磁盘/权限、`rogee` SSH 与加固、发布包/依赖 SHA-256、Asterisk/systemd `enabled+active`、ARI/PJSIP endpoint/contact、媒体 profile/端口及运行版本。 - 非生产 mixed/real 呼叫必须先通过上述真实时间门禁;**拨号前**启动受限 SIP/RTP 抓包和 Asterisk PJSIP logger,结束后采集 SIP 响应/INVITE–BYE 时间线、SDP codec/媒体地址端口、RTP 包/字节、录音与 ASR/LLM/TTS 事实及 SHA-256。失败通话也保存状态和抓包;tcpdump/CAP_NET_RAW、PJSIP logger 或 ARI/PJSIP 状态任一不可用须失败关闭。原始抓包/日志/录音只写受限证据目录,聊天、提交与长期证据只存脱敏摘要/计数/状态码/hash,不含完整用户音频/对话或凭据。统一入口见 [`deploys/test/nonprod-call-evidence.sh`](deploys/test/nonprod-call-evidence.sh);本地 `make check` 与 `make release-check-local` **不能代签主机诊断或生产门禁**。 - 当前完成前至少检查格式、当前合同和 Proto 来源/hash、`go vet ./...`、`go test -race ./...`、构建、确实运行的隔离 RabbitMQ/HTTPS/双向 TLS 端到端测试、业务单元覆盖率 ≥65% 及 A01–A12/K01–K16 对照。真实 SaaS/management/OSS/AI/Asterisk/ECS、第二节点/Cell/租户、多 D 额度、容量/N+1及生产切换必须另有事实与授权,任何本机 Mock 通过不得写成其签收。 diff --git a/deploys/README.md b/deploys/README.md index 6ee5651..3503ef7 100644 --- a/deploys/README.md +++ b/deploys/README.md @@ -7,7 +7,7 @@ by this project are: - `sip-go-agent-dispatcher.service` - `sip-go-agent-agent.service` -- the separately managed native `asterisk.service` +- the separately managed native `go-sip-asterisk.service` under `rogee`'s `systemd --user` (production requires lingering) RabbitMQ, OSS, AI providers and SaaS are external endpoints. They are not installed by the production package and are not started by systemd or Docker. diff --git a/deploys/cell/README.md b/deploys/cell/README.md index 6187fcb..2986919 100644 --- a/deploys/cell/README.md +++ b/deploys/cell/README.md @@ -12,16 +12,21 @@ The pinned source input is: - Archive `../packages/asterisk-22.10.1-source.tar.gz` - SHA-256 `373c98f4d4a1b923b42def0aee03f4e36aca9d1c244a8eeda646da8a97f89663` -`build-asterisk-native.sh` can reproduce a native stage from the local pinned -source/dependency archives using the Debian package list in -`debian-build-packages.lock`; `install-asterisk-native.sh` installs that stage -and the systemd unit without overwriting `/etc/asterisk`. Before production use, -the Cell owner must verify its dependencies/licence/security review, install the -management-approved static `pjsip.conf`/ARI/RTP configuration, and review/start -the systemd unit explicitly. Do not silently substitute another Asterisk version or a -container image. The Go Agent package only consumes the resulting approved static Cell artifact -and reports its applied revision. Local validation may use isolated MQ/OSS/AI -fixtures, but those are not production deployments. +`build-asterisk-native.sh` reproduces the stage from the pinned source and +local dependency cache; the build selects no downloaded core sounds/MOH. +The native package includes its `build-platform` marker and +`install-asterisk-user.sh`. Run that installer as `rogee`, never as root; +it verifies the stage hash and required libraries, installs into `~/.local/opt/`, +sets up `~/.config/go-sip-asterisk/` without overwriting existing files, and +installs `go-sip-asterisk.service` under `systemd --user`. Production requires +`loginctl enable-linger rogee` and a verified reboot-persistent `enabled+active` +service; `--nonprod` allows a session-scoped Debian 12 native build but does +not certify reboot persistence. The management-approved static `pjsip.conf`, +ARI and RTP configuration must be supplied separately. The bundled stage has +no live SIP trunk or dialing authorization; verify loaded endpoints and contacts +before any call. Do not substitute another Asterisk version or a container image. +The Go Agent package only consumes the resulting approved Cell artifact and +reports its applied revision. Local Mock fixtures do not prove real services. Before every real outbound attempt, the operator must obtain a fresh user confirmation in the current conversation that names the SIP channel, raw target diff --git a/deploys/cell/asterisk.service b/deploys/cell/asterisk.service deleted file mode 100644 index 1101f5b..0000000 --- a/deploys/cell/asterisk.service +++ /dev/null @@ -1,22 +0,0 @@ -[Unit] -Description=Asterisk SIP Cell 22.10.1 (physical host) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=asterisk -Group=asterisk -WorkingDirectory=/var/lib/asterisk -ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk -vvvg -ExecStop=/usr/sbin/asterisk -rx "core stop now" -Restart=on-failure -RestartSec=5s -UMask=0077 -LimitNOFILE=65536 -PrivateTmp=yes -ProtectHome=yes -ReadWritePaths=/etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk - -[Install] -WantedBy=multi-user.target diff --git a/deploys/cell/build-asterisk-native.sh b/deploys/cell/build-asterisk-native.sh index f3703df..c614669 100644 --- a/deploys/cell/build-asterisk-native.sh +++ b/deploys/cell/build-asterisk-native.sh @@ -11,9 +11,10 @@ OUT=${OUT_DIR:-"$PKG/asterisk-$VERSION-native"} JOBS=${JOBS:-1} WORK=${WORK_DIR:-"$ROOT/.local/asterisk-build-$VERSION"} NONPROD=${NONPROD:-0} -. /etc/os-release +OS_ID=$(. /etc/os-release; printf '%s' "$ID") +OS_VERSION=$(. /etc/os-release; printf '%s' "$VERSION_ID") source "$ROOT/deploys/cell/native-platform.sh" -require_native_platform "$ID" "$VERSION_ID" "$(uname -m)" "debian:$VERSION_ID:x86_64" "$NONPROD" +require_native_platform "$OS_ID" "$OS_VERSION" "$(uname -m)" "debian:$OS_VERSION:x86_64" "$NONPROD" [[ -f "$SRC_ARCHIVE" && -f "$SRC_SHA" ]] || { echo 'Asterisk source archive/checksum missing' >&2; exit 1; } [[ -d "$DEPS" ]] || { echo 'Asterisk dependency cache missing' >&2; exit 1; } @@ -26,6 +27,10 @@ tar -xzf "$SRC_ARCHIVE" -C "$WORK" SRC="$WORK/asterisk-$VERSION" cd "$SRC" EXTERNALS_CACHE_DIR="$OUT/cache" ./configure --with-pjproject-bundled --with-jansson-bundled +# Sound archives are not part of the pinned offline inputs; never fetch +# unverified downloads during an otherwise reproducible native build. +EXTERNALS_CACHE_DIR="$OUT/cache" make menuselect.makeopts +./menuselect/menuselect --disable CORE-SOUNDS-EN-GSM --disable MOH-OPSOUND-WAV menuselect.makeopts EXTERNALS_CACHE_DIR="$OUT/cache" make -j"$JOBS" STAGE="$WORK/stage" rm -rf -- "$STAGE" @@ -35,11 +40,10 @@ EXTERNALS_CACHE_DIR="$OUT/cache" make install DESTDIR="$STAGE" # binary package. rm -rf -- "$STAGE/etc/asterisk" tar -C "$STAGE" -cpf "$OUT/asterisk-$VERSION-native-stage.tar" . -cp "$ROOT/deploys/cell/asterisk.service" "$OUT/asterisk.service" -cp "$ROOT/deploys/cell/install-asterisk-native.sh" "$OUT/install-asterisk-native.sh" +cp "$ROOT/deploys/cell/install-asterisk-user.sh" "$OUT/install-asterisk-user.sh" cp "$ROOT/deploys/cell/native-platform.sh" "$OUT/native-platform.sh" -printf 'debian:%s:x86_64\n' "$VERSION_ID" > "$OUT/build-platform" -chmod 0755 "$OUT/install-asterisk-native.sh" +printf 'debian:%s:x86_64\n' "$OS_VERSION" > "$OUT/build-platform" +chmod 0755 "$OUT/install-asterisk-user.sh" ( cd "$OUT" sha256sum "asterisk-$VERSION-native-stage.tar" > "asterisk-$VERSION-native-stage.tar.sha256" diff --git a/deploys/cell/install-asterisk-native.sh b/deploys/cell/install-asterisk-native.sh deleted file mode 100644 index 9c06da5..0000000 --- a/deploys/cell/install-asterisk-native.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -[[ ${EUID} -eq 0 ]] || { echo 'install-asterisk-native.sh must run as root' >&2; exit 1; } -START=false -NONPROD=0 -for arg in "$@"; do - case "$arg" in - --start) START=true ;; - --nonprod) NONPROD=1 ;; - *) echo "unknown option: $arg" >&2; exit 2 ;; - esac -done - -PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -cd -- "$PACKAGE_DIR" -. /etc/os-release -[[ -f native-platform.sh && -f build-platform ]] || { echo 'native build platform metadata is missing' >&2; exit 1; } -source ./native-platform.sh -require_native_platform "$ID" "$VERSION_ID" "$(uname -m)" "$(&2; exit 1; } -getent group asterisk >/dev/null || groupadd --system asterisk -id -u asterisk >/dev/null 2>&1 || useradd --system --home-dir /var/lib/asterisk --shell /usr/sbin/nologin --gid asterisk asterisk -# Keep management-owned /etc/asterisk configuration intact. -tar --exclude='etc/asterisk/*' -xpf "$STAGE" -C / -ldconfig -install -d -o asterisk -g asterisk -m 0750 /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk -install -o root -g root -m 0644 asterisk.service /etc/systemd/system/asterisk.service -systemctl daemon-reload -systemctl enable asterisk.service -if [[ "$START" == true ]]; then - systemctl restart asterisk.service -fi -/usr/sbin/asterisk -V -printf 'installed asterisk=22.10.1 start=%s config_preserved=true\n' "$START" diff --git a/deploys/cell/install-asterisk-user.sh b/deploys/cell/install-asterisk-user.sh new file mode 100755 index 0000000..8fb57b1 --- /dev/null +++ b/deploys/cell/install-asterisk-user.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Native, unprivileged Asterisk installation for the approved rogee user. +set -euo pipefail +nonprod=0 +if [[ ${1:-} == --nonprod ]]; then nonprod=1; shift; fi +[[ $# == 1 ]] || { echo 'usage: install-asterisk-user.sh [--nonprod] ' >&2; exit 2; } +[[ $(id -u) != 0 ]] || { echo 'do not run the user service installer as root' >&2; exit 1; } +package=$(cd "$1" && pwd) +. /etc/os-release +source "$(dirname "$0")/native-platform.sh" +[[ -f $package/build-platform ]] || { echo 'native build platform metadata is missing' >&2; exit 1; } +require_native_platform "$ID" "$VERSION_ID" "$(uname -m)" "$(<"$package/build-platform")" "$nonprod" +archive=asterisk-22.10.1-native-stage.tar +(cd "$package" && sha256sum -c "$archive.sha256") +if [[ $nonprod == 0 && $(loginctl show-user "$(id -un)" -p Linger --value) != yes ]]; then + echo 'enable user lingering before a production user-service installation' >&2 + exit 1 +fi + +prefix="$HOME/.local/opt/go-sip-asterisk/22.10.1" +config="$HOME/.config/go-sip-asterisk" +state="$HOME/.local/state/go-sip-asterisk" +data="$HOME/.local/share/go-sip-asterisk" +cache="$HOME/.cache/go-sip-asterisk" +unit="$HOME/.config/systemd/user/go-sip-asterisk.service" +runtime="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/go-sip-asterisk" +[[ ! -e $prefix && ! -e $unit && ! -e $config/asterisk.conf && ! -e $config/modules.conf ]] || { echo 'existing Asterisk installation or user configuration; refuse to overwrite' >&2; exit 1; } +[[ $HOME != *[[:space:]]* ]] || { echo 'home path with whitespace is unsupported by the unit' >&2; exit 1; } + +mkdir -p "$(dirname "$prefix")" +staged=$(mktemp -d "$(dirname "$prefix")/.staged.XXXXXXXX") +trap 'rm -rf "$staged"' EXIT +tar -xpf "$package/$archive" -C "$staged" +[[ -x $staged/usr/sbin/asterisk ]] || { echo 'native package has no Asterisk executable' >&2; exit 1; } +if LD_LIBRARY_PATH="$staged/usr/lib" ldd "$staged/usr/sbin/asterisk" | grep -q 'not found'; then + echo 'native Asterisk runtime libraries are missing' >&2; exit 1 +fi +LD_LIBRARY_PATH="$staged/usr/lib" "$staged/usr/sbin/asterisk" -V +mv "$staged" "$prefix" +trap - EXIT + +mkdir -p "$config" "$state/log" "$state/spool" "$data/db" "$data/keys" "$data/agi-bin" "$cache" "$(dirname "$unit")" +cat > "$config/asterisk.conf" < $cache +astetcdir => $config +astmoddir => $prefix/usr/lib/asterisk/modules +astvarlibdir => $data +astdbdir => $data/db +astkeydir => $data/keys +astdatadir => $prefix/var/lib/asterisk +astagidir => $data/agi-bin +astspooldir => $state/spool +astrundir => $runtime +astlogdir => $state/log +EOF +printf '[modules]\nautoload=yes\n' > "$config/modules.conf" +cat > "$unit" <&2; exit 1; } +LD_LIBRARY_PATH="$prefix/usr/lib" "$prefix/usr/sbin/asterisk" -C "$config/asterisk.conf" -rx 'core show version' | grep -q 'Asterisk 22.10.1' || { echo 'Asterisk CLI did not report the expected live version' >&2; exit 1; } +if [[ $(loginctl show-user "$(id -un)" -p Linger --value) != yes ]]; then + echo 'nonproduction only: user lingering disabled; reboot persistence NOT verified' >&2 +fi diff --git a/deploys/packages/asterisk-22.10.1-native/build-platform b/deploys/packages/asterisk-22.10.1-native/build-platform new file mode 100644 index 0000000..3671b42 --- /dev/null +++ b/deploys/packages/asterisk-22.10.1-native/build-platform @@ -0,0 +1 @@ +debian:13:x86_64 diff --git a/deploys/physical-deployment.md b/deploys/physical-deployment.md index fc5097d..86c082c 100644 --- a/deploys/physical-deployment.md +++ b/deploys/physical-deployment.md @@ -10,8 +10,8 @@ to Debian 12. This exception does not approve production deployment or real call Production remains a small systemd installation on Debian 13 amd64: -1. native Asterisk Cell (`asterisk.service`), owned by the approved SIP - management release; +1. native Asterisk Cell (`go-sip-asterisk.service`) under `rogee`'s `systemd --user`, + with lingering enabled and reboot-persistent `enabled+active` verified; 2. `sip-go-agent-agent.service`; 3. `sip-go-agent-dispatcher.service`. @@ -23,9 +23,12 @@ The pinned versions are in [`versions.lock.json`](versions.lock.json). Build a release candidate with `build-package.sh`; it writes the archive to `dist/packages/` and is intentionally not production-approved. Production installation requires a clean, externally approved manifest. Build/install -Asterisk separately with the scripts in [`cell/`](cell/). The Asterisk -installer preserves `/etc/asterisk`; the management-approved static Cell -configuration is installed separately. +Asterisk separately with the scripts in [`cell/`](cell/). The Asterisk user installer preserves existing files and places the Cell +configuration under `~rogee/.config/go-sip-asterisk/`; management approves and +updates `pjsip.conf` separately. Run `install-asterisk-user.sh --nonprod ` +for a Debian 12 test build; without `--nonprod`, user lingering is required +before installation. Without lingering, non-production start is session-scoped +and reboot persistence must be reported as unverified. The Go installer creates `/opt/sip-go-agent`, `/etc/sip-go-agent` and `/var/lib/sip-go-agent`, installs the two Go units, and enables them. It does diff --git a/docs/evidence/sip-user-service-nonprod.md b/docs/evidence/sip-user-service-nonprod.md new file mode 100644 index 0000000..6df0b82 --- /dev/null +++ b/docs/evidence/sip-user-service-nonprod.md @@ -0,0 +1,19 @@ +# Nonproduction native Asterisk user service — 2026-10-03 + +This is **host-only evidence**, not SIP trunk, outbound-call, SaaS, or production acceptance. +The test host's address and raw logs are omitted from committed evidence. + +- Fresh Debian 13 amd64 host, root directory mode `0755 root:root` (read-only inspection). +- Native Asterisk 22.10.1 stage SHA-256: `68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d`. +- Installed under `rogee`'s home without sudo; `systemctl --user` reported `go-sip-asterisk.service` **enabled and active**, and the live CLI returned Asterisk 22.10.1. +- `systemctl --user reload` succeeded and `res_pjsip.so` reported running. **No PJSIP endpoints were configured or loaded**; this does not verify adding/changing a real trunk or reloading it during an active call. +- User lingering was **disabled** by user choice: reboot persistence is **not verified**. The production installer now refuses installation without lingering; `--nonprod` is explicitly session-scoped. +- An earlier user-install draft generated a template-only `[directories](!)` stanza and missed a startup crash. Corrected to `[directories]` and a live CLI readiness check; the corrected installer was checked for platform/lingering and no-overwrite behavior, but a fresh-install run of that final revision remains **unverified**. +- Prior Debian 12 nonproduction host: a native, same-OS Asterisk 22.10.1 stage was built and its checksum checked. The default installer rejected Debian 12; `--nonprod` installed the stage. Its system service could not be accepted and the host was subsequently reinstalled; do not count that as a successful Cell deployment. + +## Still required before a real call or production acceptance + +1. Implement and prove real Dispatcher→Agent SIP apply/reload and Agent-observed loaded state (current Go call runtime is still Mock-only). Validate endpoint add/edit against Asterisk while a call is active; explicitly reject unsupported authentication/REGISTER and transport changes. +2. Provide approved real line configuration and arrange each whitelist trial (trunk, original number, time, attempt count). The fixed Asia/Shanghai 09:00–20:00 gate and per-number daily cap remain mandatory. +3. Establish RabbitMQ/OSS/AI real integrations and nonproduction call-evidence capture. `deploys/test/nonprod-call-evidence.sh` requires root or the required capture capabilities; this host's `rogee` currently has no sudo. If tcpdump, logger, or ARI/PJSIP state is unavailable, do not dial. +4. Enable `rogee` user lingering and verify reboot-persistent `enabled+active` before claiming production readiness. Complete the host/network/dependency diagnostics and external signoffs separately; local `make check` cannot replace them. diff --git a/internal/config/deployment_layout_test.go b/internal/config/deployment_layout_test.go index bd8a625..a448bea 100644 --- a/internal/config/deployment_layout_test.go +++ b/internal/config/deployment_layout_test.go @@ -12,7 +12,7 @@ func TestDeploymentHasOneCanonicalDirectory(t *testing.T) { if _, err := os.Stat(filepath.Join(root, "deploy")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("obsolete deploy directory must not remain: %v", err) } - for _, path := range []string{"README.md", "build-package.sh", "install.sh", "versions.lock.json", "cell/install-asterisk-native.sh", "test/nonprod-call-evidence.sh", "test/README.md", "systemd/sip-go-agent-agent.service", "systemd/sip-go-agent-dispatcher.service", "config/agent-endpoints.example.json"} { + for _, path := range []string{"README.md", "build-package.sh", "install.sh", "versions.lock.json", "cell/install-asterisk-user.sh", "test/nonprod-call-evidence.sh", "test/README.md", "systemd/sip-go-agent-agent.service", "systemd/sip-go-agent-dispatcher.service", "config/agent-endpoints.example.json"} { info, err := os.Stat(filepath.Join(root, "deploys", path)) if err != nil { t.Errorf("canonical deployment entry %s: %v", path, err) @@ -22,7 +22,7 @@ func TestDeploymentHasOneCanonicalDirectory(t *testing.T) { t.Errorf("deployment entry %s is not a regular file", path) } } - for _, path := range []string{"config/dispatcher.json.example", "config/static-cell-artifact-v2.json", "config/static-cell-runtime-v1.json", "test/ai-dental-meiba-v1.json"} { + for _, path := range []string{"config/dispatcher.json.example", "config/static-cell-artifact-v2.json", "config/static-cell-runtime-v1.json", "test/ai-dental-meiba-v1.json", "cell/install-asterisk-native.sh", "cell/asterisk.service"} { if _, err := os.Lstat(filepath.Join(root, "deploys", path)); !errors.Is(err, os.ErrNotExist) { t.Errorf("retired deployment example %s still active: %v", path, err) }