Merge main into archy-hwconfig — reconcile probe/dedup/name work
Both sides independently fixed the serial-alias dedup and the ESP32 boot-reset races; kept the branch's defer-to-auto-detect for unpinned preferred paths (single probe pass per cycle) on top of main's advert-name threading, Reticulum name propagation and radio-first routing. Modal keeps main's 'Set Recommended' naming + probe progress bar alongside the branch's in-app firmware flasher step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ PATTERNS=(
|
||||
)
|
||||
|
||||
# Allowed files (config templates, docs, test fixtures)
|
||||
ALLOW_PATTERNS="test|mock|example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets"
|
||||
ALLOW_PATTERNS="test|e2e|mock|demo|example|Example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets|startsWith|should start with"
|
||||
|
||||
main() {
|
||||
log "=== Secrets Audit ==="
|
||||
@@ -34,7 +34,7 @@ main() {
|
||||
# 1. Check for .env files in version control
|
||||
log "1. Checking for .env files in git..."
|
||||
local env_files
|
||||
env_files=$(cd "$REPO_ROOT" && git ls-files '*.env' '.env*' 2>/dev/null || echo "")
|
||||
env_files=$(cd "$REPO_ROOT" && git ls-files | grep -E '(^|/)\.env($|[.])|(^|/)[^/]*\.env($|[.])' | grep -vE '(^|/)\.env\.example$|(^|/)[^/]*\.env\.example$' || echo "")
|
||||
if [ -z "$env_files" ]; then
|
||||
pass "No .env files tracked in git"
|
||||
else
|
||||
@@ -69,7 +69,7 @@ main() {
|
||||
if [ -n "$matches" ]; then
|
||||
# Filter out false positives (empty strings, variable declarations, etc.)
|
||||
local real_matches
|
||||
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<' || echo "")
|
||||
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<|\$\{[A-Z0-9_]+:-\}|\$[A-Z0-9_]+|TestPassword|password123|entertoexit' || echo "")
|
||||
if [ -n "$real_matches" ]; then
|
||||
echo " WARNING: Pattern '$pattern' found:"
|
||||
echo "$real_matches" | head -5 | sed 's/^/ /'
|
||||
@@ -96,7 +96,7 @@ main() {
|
||||
# 5. Check for credential files in repo
|
||||
log "5. Checking for credential files..."
|
||||
local cred_files
|
||||
cred_files=$(cd "$REPO_ROOT" && git ls-files '*.pem' '*.key' '*macaroon*' 2>/dev/null | grep -v '\.rs$' | grep -v '\.ts$' || echo "")
|
||||
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts)$' || echo "")
|
||||
if [ -z "$cred_files" ]; then
|
||||
pass "No credential files tracked in git"
|
||||
else
|
||||
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gated ISO release build — the single command that turns a signed release
|
||||
# on `main` into a tested installer ISO.
|
||||
#
|
||||
# Stages (fail-fast, each logged with timing):
|
||||
# 0. preflight — Linux, clean tree on main, version parity across
|
||||
# Cargo.toml / package.json / releases/manifest.json /
|
||||
# CHANGELOG / git tag, manifest signature present
|
||||
# 1. gates — tests/release/run.sh (static + frontend + backend
|
||||
# slice), strict catalog drift, FULL cargo test suite
|
||||
# 2. artifacts — release binary embeds the version, frontend dist
|
||||
# matches, AIUI present (OTA-strip regression guard)
|
||||
# 3. build — image-recipe/build-debian-iso.sh (unbundled by default)
|
||||
# 4. smoke — scripts/iso-smoke-test.sh (mount-level, version-checked)
|
||||
# 5. qemu — headless boot test (skippable with --no-qemu)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-iso-release.sh [--skip-gates] [--no-qemu] [--bundled] [--rc N]
|
||||
#
|
||||
# The ISO is NOT signed here — run scripts/sign-iso-checksums.sh with the
|
||||
# offline RELEASE_MASTER_MNEMONIC afterwards (publisher only).
|
||||
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
SKIP_GATES=0 NO_QEMU=0 UNBUNDLED=1 RC_OVERRIDE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-gates) SKIP_GATES=1 ;;
|
||||
--no-qemu) NO_QEMU=1 ;;
|
||||
--bundled) UNBUNDLED=0 ;;
|
||||
--rc) RC_OVERRIDE="${2:?--rc needs a number}"; shift ;;
|
||||
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
||||
PASS=() FAIL=()
|
||||
stage() { # stage <name> <cmd...>
|
||||
local name="$1"; shift
|
||||
local t0=$SECONDS
|
||||
echo
|
||||
echo "═══ [$name] $*"
|
||||
if "$@"; then
|
||||
echo "═══ [$name] PASS ($((SECONDS - t0))s)"
|
||||
PASS+=("$name")
|
||||
else
|
||||
local rc=$?
|
||||
echo "═══ [$name] FAIL exit=$rc ($((SECONDS - t0))s)"
|
||||
FAIL+=("$name")
|
||||
summary 1
|
||||
fi
|
||||
}
|
||||
summary() {
|
||||
echo
|
||||
echo "──────── ISO release build summary ────────"
|
||||
printf 'PASS: %s\n' "${PASS[@]:-none}"
|
||||
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# ── Stage 0: preflight ───────────────────────────────────────────────
|
||||
preflight() {
|
||||
[ "$(uname -s)" = "Linux" ] || { echo "ISO builds run on Linux only"; return 1; }
|
||||
|
||||
local branch; branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
[ "$branch" = "main" ] || { echo "must build from main (on: $branch)"; return 1; }
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "working tree is not clean — release ISOs build from committed state only:"
|
||||
git status --porcelain | head -20
|
||||
return 1
|
||||
fi
|
||||
|
||||
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
|
||||
local ui_ver manifest_ver
|
||||
ui_ver="$(python3 -c 'import json;print(json.load(open("neode-ui/package.json"))["version"])')"
|
||||
manifest_ver="$(python3 -c 'import json;print(json.load(open("releases/manifest.json"))["version"])')"
|
||||
echo " Cargo.toml: $VERSION"
|
||||
echo " package.json: $ui_ver"
|
||||
echo " releases/manifest: $manifest_ver"
|
||||
[ "$VERSION" = "$ui_ver" ] || { echo "version mismatch Cargo vs package.json"; return 1; }
|
||||
[ "$VERSION" = "$manifest_ver" ] || { echo "version mismatch Cargo vs releases/manifest.json"; return 1; }
|
||||
|
||||
head -5 CHANGELOG.md | grep -qF "v$VERSION" \
|
||||
|| { echo "CHANGELOG.md top entry is not v$VERSION"; return 1; }
|
||||
|
||||
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
|
||||
|| { echo "tag v$VERSION does not exist — cut the release first (scripts/create-release.sh)"; return 1; }
|
||||
|
||||
# The ISO must only ever be cut from a ceremony-signed manifest.
|
||||
python3 - <<'EOF' || return 1
|
||||
import json, sys
|
||||
m = json.load(open("releases/manifest.json"))
|
||||
sig, by = m.get("signature"), m.get("signed_by", "")
|
||||
if not sig or not by.startswith("did:key:"):
|
||||
print("releases/manifest.json is UNSIGNED — run the signing ceremony first")
|
||||
sys.exit(1)
|
||||
print(f" manifest signed by {by[:32]}…")
|
||||
EOF
|
||||
|
||||
echo " version: $VERSION @ $(git rev-parse --short HEAD), tree clean, manifest signed"
|
||||
}
|
||||
stage "preflight" preflight
|
||||
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
|
||||
|
||||
# ── Stage 1: gates ───────────────────────────────────────────────────
|
||||
if [ "$SKIP_GATES" = "0" ]; then
|
||||
stage "release-gate-harness" bash tests/release/run.sh
|
||||
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Full Rust suite — the release harness only runs a 6-module slice;
|
||||
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
|
||||
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
|
||||
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
|
||||
else
|
||||
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
|
||||
fi
|
||||
|
||||
# ── Stage 2: artifact verification ───────────────────────────────────
|
||||
verify_artifacts() {
|
||||
local bin="core/target/release/archipelago"
|
||||
[ -x "$bin" ] || { echo "missing release binary $bin — build it first"; return 1; }
|
||||
strings "$bin" | grep -qF "$VERSION" \
|
||||
|| { echo "release binary does not embed $VERSION — stale build"; return 1; }
|
||||
echo " backend binary embeds $VERSION ($(du -h "$bin" | cut -f1))"
|
||||
|
||||
[ -f web/dist/neode-ui/index.html ] || { echo "missing frontend dist"; return 1; }
|
||||
grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js \
|
||||
|| { echo "frontend dist does not contain $VERSION — stale build"; return 1; }
|
||||
echo " frontend dist contains $VERSION"
|
||||
|
||||
# AIUI must ride inside the dist BEFORE packaging or OTA upgrades
|
||||
# silently strip it from nodes in the field.
|
||||
[ -f web/dist/neode-ui/aiui/index.html ] \
|
||||
|| { echo "AIUI missing from web/dist/neode-ui/aiui — fold it in before building"; return 1; }
|
||||
echo " AIUI present in frontend dist"
|
||||
}
|
||||
stage "verify-artifacts" verify_artifacts
|
||||
|
||||
# ── Stage 3: build the ISO ───────────────────────────────────────────
|
||||
build_iso() {
|
||||
local env_args=(
|
||||
UNBUNDLED="$UNBUNDLED"
|
||||
BUILD_FROM_SOURCE=0
|
||||
DEV_SERVER=localhost
|
||||
ARCHIPELAGO_BIN="$REPO/core/target/release/archipelago"
|
||||
)
|
||||
[ -n "$RC_OVERRIDE" ] && env_args+=(RC="$RC_OVERRIDE")
|
||||
sudo -E env "${env_args[@]}" nice -n 5 bash image-recipe/build-debian-iso.sh
|
||||
}
|
||||
stage "build-iso" build_iso
|
||||
|
||||
find_iso() {
|
||||
ls -t "$REPO"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1
|
||||
}
|
||||
ISO="$(find_iso)"
|
||||
[ -n "$ISO" ] || { echo "FAIL: no ISO produced for $VERSION in image-recipe/results/"; FAIL+=("locate-iso"); summary 1; }
|
||||
|
||||
# ── Stage 4: mount-level smoke test ──────────────────────────────────
|
||||
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
|
||||
|
||||
# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
|
||||
# The ISO's kernel cmdline has no serial console, so the serial-log
|
||||
# sanity grep can miss a perfectly healthy boot. Run it, report it,
|
||||
# but don't fail an otherwise-green build on it.
|
||||
if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
|
||||
echo
|
||||
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
|
||||
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
|
||||
echo "═══ [qemu-boot] PASS"
|
||||
PASS+=("qemu-boot")
|
||||
else
|
||||
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
|
||||
PASS+=("qemu-boot(inconclusive)")
|
||||
fi
|
||||
else
|
||||
echo; echo "═══ [qemu-boot] SKIPPED"
|
||||
fi
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────
|
||||
SHA_FILE="$ISO.sha256"
|
||||
[ -f "$SHA_FILE" ] || (cd "$(dirname "$ISO")" && sha256sum "$(basename "$ISO")" > "$SHA_FILE")
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " ISO RELEASE BUILD COMPLETE — v$VERSION"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
|
||||
echo
|
||||
echo " Next steps (publisher, offline mnemonic required):"
|
||||
echo " 1. scripts/sign-iso-checksums.sh $ISO"
|
||||
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
|
||||
echo " v$VERSION Gitea release assets"
|
||||
summary 0
|
||||
@@ -142,7 +142,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
# SIGPIPE-safe: use awk to read only the first line and exit,
|
||||
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
|
||||
# don't kill the whole `set -euo pipefail` script.
|
||||
root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | awk 'NR==1{print $1; exit}')
|
||||
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
|
||||
case "$root_mode" in
|
||||
drwxr-xr-x|drwxr-x*x*)
|
||||
echo " Tarball root perms OK: $root_mode"
|
||||
|
||||
@@ -141,6 +141,32 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
|
||||
"""Rust constant of catalog launch ports for the fips0 firewall drop-in
|
||||
(core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
|
||||
APP_PORTS overrides (companions/aliases) that have no manifest of their own.
|
||||
"""
|
||||
distinct = sorted(set(list(ports.values()) + extra_ports))
|
||||
lines = [
|
||||
"//! Generated by scripts/generate-app-catalog.py. Do not edit manually.",
|
||||
"//!",
|
||||
"//! Catalog app launch ports (the web UIs the companion opens by direct",
|
||||
"//! port). Used to write the fips0 firewall allowance drop-in so app UIs",
|
||||
"//! are reachable over the mesh; ports of apps that aren\'t installed have",
|
||||
"//! no listener, so allowing them is inert.",
|
||||
"",
|
||||
"pub const APP_LAUNCH_PORTS: &[u16] = &[",
|
||||
]
|
||||
lines.extend(f" {port}," for port in distinct)
|
||||
lines.extend(["];", ""])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Keep in lockstep with APP_PORTS overrides in
|
||||
# neode-ui/src/views/appSession/appSessionConfig.ts.
|
||||
RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
|
||||
|
||||
|
||||
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
catalog = json.load(fh)
|
||||
@@ -178,6 +204,11 @@ def main() -> int:
|
||||
default=[],
|
||||
help="Catalog JSON path to update. May be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rust-app-ports",
|
||||
default="core/archipelago/src/fips/app_ports.rs",
|
||||
help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--app-session-config",
|
||||
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
|
||||
@@ -201,6 +232,20 @@ def main() -> int:
|
||||
print(f"{path}: updated")
|
||||
else:
|
||||
print(f"{path}: updated 0 fields")
|
||||
if args.rust_app_ports:
|
||||
ports = {
|
||||
app_id: port
|
||||
for app_id, app in manifests.items()
|
||||
if (port := manifest_launch_port(app))
|
||||
}
|
||||
rust_path = Path(args.rust_app_ports)
|
||||
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
|
||||
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
|
||||
if rust_old != rust_content:
|
||||
rust_path.write_text(rust_content, encoding="utf-8")
|
||||
print(f"{rust_path}: updated")
|
||||
else:
|
||||
print(f"{rust_path}: updated 0 fields")
|
||||
print(f"total_updated={total}")
|
||||
return 0
|
||||
|
||||
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mount-level smoke test for an Archipelago installer ISO.
|
||||
#
|
||||
# Verifies boot plumbing (BIOS + UEFI + live-boot), the auto-installer
|
||||
# payload, and — the check that has bitten before — that the backend
|
||||
# binary inside the ISO actually embeds the version the filename claims.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/iso-smoke-test.sh <path-to-iso> [expected-version]
|
||||
#
|
||||
# expected-version defaults to core/archipelago/Cargo.toml. Needs sudo
|
||||
# (loop mount). Exits non-zero on the first hard failure; prints a
|
||||
# PASS/FAIL table either way.
|
||||
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ISO="${1:-}"
|
||||
EXPECTED_VERSION="${2:-$(grep -m1 '^version' "$REPO/core/archipelago/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')}"
|
||||
|
||||
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
|
||||
echo "usage: $0 <path-to-iso> [expected-version]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAIL=0
|
||||
ok() { echo " OK: $*"; }
|
||||
bad() { echo " FAIL: $*"; FAIL=1; }
|
||||
warn() { echo " WARN: $*"; }
|
||||
|
||||
echo "ISO smoke test"
|
||||
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||
echo " Version: $EXPECTED_VERSION (expected)"
|
||||
|
||||
# ── Filename ↔ version parity (gap: ISO version can silently drift) ──
|
||||
case "$(basename "$ISO")" in
|
||||
*"$EXPECTED_VERSION"*) ok "filename contains $EXPECTED_VERSION" ;;
|
||||
*) bad "filename does not contain expected version $EXPECTED_VERSION" ;;
|
||||
esac
|
||||
|
||||
MNT="$(mktemp -d)"
|
||||
INITRD_DIR=""
|
||||
cleanup() {
|
||||
sudo umount "$MNT" 2>/dev/null || true
|
||||
rmdir "$MNT" 2>/dev/null || true
|
||||
[ -n "$INITRD_DIR" ] && sudo rm -rf "$INITRD_DIR" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! sudo mount -o loop,ro "$ISO" "$MNT"; then
|
||||
echo " FAIL: could not loop-mount ISO" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Required boot + installer files ──────────────────────────────────
|
||||
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
|
||||
isolinux/isolinux.bin isolinux/isolinux.cfg \
|
||||
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
|
||||
archipelago/auto-install.sh archipelago/rootfs.tar; do
|
||||
if [ -e "$MNT/$f" ]; then
|
||||
ok "$f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
|
||||
else
|
||||
bad "missing $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── GRUB must boot the live system ───────────────────────────────────
|
||||
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
||||
ok "grub.cfg has boot=live"
|
||||
else
|
||||
bad "grub.cfg missing boot=live"
|
||||
fi
|
||||
|
||||
# ── initrd must contain live-boot scripts ────────────────────────────
|
||||
if command -v unmkinitramfs >/dev/null 2>&1; then
|
||||
INITRD_DIR="$(mktemp -d)"
|
||||
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
|
||||
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
|
||||
ok "initrd has live-boot scripts"
|
||||
else
|
||||
bad "initrd missing live-boot scripts"
|
||||
fi
|
||||
else
|
||||
warn "unmkinitramfs not installed — skipping initrd live-boot check"
|
||||
fi
|
||||
|
||||
# ── Backend binary inside the ISO embeds the expected version ────────
|
||||
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
|
||||
# shipped and the fleet rejected its fips.yaml on Activate)
|
||||
BIN_IN_ISO=""
|
||||
if [ -f "$MNT/archipelago/bin/archipelago" ]; then
|
||||
BIN_IN_ISO="$MNT/archipelago/bin/archipelago"
|
||||
if sudo strings "$BIN_IN_ISO" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then
|
||||
ok "payload backend binary embeds $EXPECTED_VERSION"
|
||||
else
|
||||
bad "payload backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
|
||||
fi
|
||||
else
|
||||
# Fall back to the copy inside rootfs.tar
|
||||
TMPBIN="$(mktemp -d)"
|
||||
if sudo tar -xf "$MNT/archipelago/rootfs.tar" -C "$TMPBIN" \
|
||||
usr/local/bin/archipelago 2>/dev/null; then
|
||||
if sudo strings "$TMPBIN/usr/local/bin/archipelago" | grep -qF "$EXPECTED_VERSION"; then
|
||||
ok "rootfs backend binary embeds $EXPECTED_VERSION"
|
||||
else
|
||||
bad "rootfs backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
|
||||
fi
|
||||
else
|
||||
bad "no backend binary found at archipelago/bin/ or in rootfs.tar"
|
||||
fi
|
||||
sudo rm -rf "$TMPBIN"
|
||||
fi
|
||||
|
||||
# ── Frontend payload present ─────────────────────────────────────────
|
||||
if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
|
||||
ok "frontend payload (archipelago/web-ui/index.html)"
|
||||
if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
|
||||
ok "AIUI included in frontend payload"
|
||||
else
|
||||
warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
|
||||
fi
|
||||
else
|
||||
warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo "ISO SMOKE TEST: FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "ISO SMOKE TEST: PASSED"
|
||||
+28
-30
@@ -1,12 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run all Archipelago tests: frontend (local) + backend (dev server via SSH).
|
||||
# Exit 0 only if both pass.
|
||||
# Run Archipelago tests.
|
||||
#
|
||||
# By default this runs frontend tests and local backend Rust tests. Set
|
||||
# ARCHIPELAGO_SSH_HOST and ARCHIPELAGO_SSH_KEY to run backend tests on a Linux
|
||||
# target instead.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-archipelago@192.168.1.228}"
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
|
||||
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
@@ -29,34 +32,29 @@ fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Backend Tests (on dev server) ---
|
||||
echo "--- Backend Tests (dev server) ---"
|
||||
# --- Backend Tests ---
|
||||
if [[ -n "$SSH_HOST" && -n "$SSH_KEY" ]]; then
|
||||
echo "--- Backend Tests (Linux target: $SSH_HOST) ---"
|
||||
echo "Syncing source to target..."
|
||||
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
|
||||
-e "ssh -i $SSH_KEY" \
|
||||
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
|
||||
|
||||
# Sync source to server
|
||||
echo "Syncing source to dev server..."
|
||||
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
|
||||
-e "ssh -i $SSH_KEY" \
|
||||
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
|
||||
|
||||
# Run tests on server
|
||||
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||
"source ~/.cargo/env && cd ~/archy/core && cargo test -p archipelago 2>&1"; then
|
||||
echo "✅ Backend unit tests PASSED"
|
||||
BACKEND_OK=1
|
||||
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||
"source ~/.cargo/env && cd ~/archy/core && cargo test --all-features 2>&1"; then
|
||||
echo "✅ Backend tests PASSED"
|
||||
BACKEND_OK=1
|
||||
else
|
||||
echo "❌ Backend tests FAILED"
|
||||
fi
|
||||
else
|
||||
echo "❌ Backend unit tests FAILED"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Integration Tests ---
|
||||
echo "--- Integration Tests (dev server) ---"
|
||||
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||
"source ~/.cargo/env && cd ~/archy/core && cargo test --test rpc_integration 2>&1"; then
|
||||
echo "✅ Integration tests PASSED"
|
||||
else
|
||||
echo "❌ Integration tests FAILED"
|
||||
BACKEND_OK=0
|
||||
echo "--- Backend Tests (local) ---"
|
||||
if (cd "$PROJECT_DIR/core" && cargo test --all-features 2>&1); then
|
||||
echo "✅ Backend tests PASSED"
|
||||
BACKEND_OK=1
|
||||
else
|
||||
echo "❌ Backend tests FAILED"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -24,8 +24,9 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
|
||||
|
||||
# Anthropic API key — used by all servers for AIUI Claude chat
|
||||
ANTHROPIC_API_KEY="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"
|
||||
# Anthropic API key used by the AIUI Claude chat proxy. Keep this in the
|
||||
# caller's environment or scripts/deploy-config.sh; never commit live keys.
|
||||
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
|
||||
|
||||
TARGET_HOST="$1"
|
||||
if [ -z "$TARGET_HOST" ]; then
|
||||
@@ -34,6 +35,12 @@ if [ -z "$TARGET_HOST" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "ERROR: ANTHROPIC_API_KEY must be set in the environment."
|
||||
echo "Example: ANTHROPIC_API_KEY=<key> $0 $TARGET_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
|
||||
if [ ! -f "$AIUI_DIST/index.html" ]; then
|
||||
echo "ERROR: AIUI build not found at $AIUI_DIST"
|
||||
|
||||
+201
-103
@@ -1,25 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# validate-app-manifest.sh — Validate a community-submitted app manifest
|
||||
# validate-app-manifest.sh - validate an Archipelago app manifest.
|
||||
#
|
||||
# Usage: ./scripts/validate-app-manifest.sh <manifest.yml>
|
||||
# Usage:
|
||||
# ./scripts/validate-app-manifest.sh [--repo-audit] apps/my-app/manifest.yml
|
||||
#
|
||||
# Checks:
|
||||
# 1. Valid YAML syntax
|
||||
# 2. Required fields present (id, title, version, image, description)
|
||||
# 3. Image from trusted registry (docker.io, ghcr.io, quay.io)
|
||||
# 4. No :latest tag (must pin specific version)
|
||||
# 5. Resource limits specified (memory, cpu)
|
||||
# 6. Security: no privileged mode, no host networking
|
||||
# 7. No hardcoded secrets/passwords in environment
|
||||
# 8. Port conflicts with existing apps
|
||||
#
|
||||
# Exit 0 = valid, Exit 1 = issues found
|
||||
# This intentionally mirrors the public app contract documented in
|
||||
# docs/app-manifest-spec.md: manifests have a top-level `app:` block and are
|
||||
# ultimately validated by the Rust parser in core/container/src/manifest.rs.
|
||||
# This script is the contributor-friendly preflight; the Rust parser remains
|
||||
# canonical.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <manifest.yml>"
|
||||
REPO_AUDIT=0
|
||||
if [[ "${1:-}" == "--repo-audit" ]]; then
|
||||
REPO_AUDIT=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 [--repo-audit] <manifest.yml>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -30,138 +31,235 @@ WARN=0
|
||||
|
||||
check() {
|
||||
local desc="$1" result="$2"
|
||||
if [[ "$result" == "pass" ]]; then
|
||||
PASS=$((PASS + 1))
|
||||
echo " PASS: $desc"
|
||||
elif [[ "$result" == "warn" ]]; then
|
||||
WARN=$((WARN + 1))
|
||||
echo " WARN: $desc"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
echo " FAIL: $desc"
|
||||
fi
|
||||
case "$result" in
|
||||
pass)
|
||||
PASS=$((PASS + 1))
|
||||
echo " PASS: $desc"
|
||||
;;
|
||||
warn)
|
||||
WARN=$((WARN + 1))
|
||||
echo " WARN: $desc"
|
||||
;;
|
||||
*)
|
||||
FAIL=$((FAIL + 1))
|
||||
echo " FAIL: $desc"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
yaml_eval() {
|
||||
ruby -ryaml -e '
|
||||
path, expr = ARGV
|
||||
data = YAML.load_file(path)
|
||||
app = data.is_a?(Hash) ? data["app"] : nil
|
||||
abort "missing top-level app block" unless app.is_a?(Hash)
|
||||
value = eval(expr)
|
||||
case value
|
||||
when Array
|
||||
puts value.join("\n")
|
||||
when Hash
|
||||
puts value.to_a.map { |k, v| "#{k}=#{v}" }.join("\n")
|
||||
when NilClass
|
||||
puts ""
|
||||
else
|
||||
puts value
|
||||
end
|
||||
' "$MANIFEST" "$1"
|
||||
}
|
||||
|
||||
echo "Validating: $MANIFEST"
|
||||
echo ""
|
||||
|
||||
# 1. File exists and is readable
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
echo " FAIL: File not found: $MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
check "File exists" "pass"
|
||||
|
||||
# 2. Valid YAML
|
||||
if ! python3 -c "import yaml; yaml.safe_load(open('$MANIFEST'))" 2>/dev/null; then
|
||||
check "Valid YAML syntax" "fail"
|
||||
echo " Cannot continue with invalid YAML"
|
||||
if ! ruby -ryaml -e 'data = YAML.load_file(ARGV[0]); exit(data.is_a?(Hash) && data["app"].is_a?(Hash) ? 0 : 1)' "$MANIFEST" 2>/dev/null; then
|
||||
check "Valid YAML with top-level app block" "fail"
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
||||
echo "STATUS: REJECTED - fix failures before resubmitting"
|
||||
exit 1
|
||||
fi
|
||||
check "Valid YAML syntax" "pass"
|
||||
check "Valid YAML with top-level app block" "pass"
|
||||
|
||||
# 3. Required fields
|
||||
CONTENT=$(python3 -c "
|
||||
import yaml, json
|
||||
with open('$MANIFEST') as f:
|
||||
d = yaml.safe_load(f)
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null)
|
||||
APP_ID="$(yaml_eval 'app["id"]')"
|
||||
APP_NAME="$(yaml_eval 'app["name"]')"
|
||||
APP_VERSION="$(yaml_eval 'app["version"]')"
|
||||
APP_DESCRIPTION="$(yaml_eval 'app["description"]')"
|
||||
APP_INTERNAL="$(yaml_eval 'app["internal"]')"
|
||||
IMAGE="$(yaml_eval '(app["container"] || {})["image"]')"
|
||||
BUILD_CONTEXT="$(yaml_eval '(((app["container"] || {})["build"] || {})["context"])')"
|
||||
BUILD_TAG="$(yaml_eval '(((app["container"] || {})["build"] || {})["tag"])')"
|
||||
|
||||
for field in id title version description; do
|
||||
val=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$field',''))" 2>/dev/null)
|
||||
if [[ -n "$val" && "$val" != "None" ]]; then
|
||||
check "Required field '$field' present" "pass"
|
||||
else
|
||||
check "Required field '$field' present" "fail"
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Image reference
|
||||
IMAGE=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('image','') or d.get('docker_image','') or '')" 2>/dev/null)
|
||||
if [[ -z "$IMAGE" || "$IMAGE" == "None" ]]; then
|
||||
check "Container image specified" "fail"
|
||||
if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
check "app.id is lowercase kebab-case ($APP_ID)" "pass"
|
||||
else
|
||||
check "Container image specified" "pass"
|
||||
check "app.id is lowercase kebab-case" "fail"
|
||||
fi
|
||||
|
||||
# Check trusted registry
|
||||
if [[ -n "$APP_NAME" ]]; then
|
||||
check "app.name present" "pass"
|
||||
else
|
||||
check "app.name present" "fail"
|
||||
fi
|
||||
|
||||
if [[ "$APP_VERSION" =~ [0-9] ]]; then
|
||||
check "app.version present and contains a digit" "pass"
|
||||
else
|
||||
check "app.version present and contains a digit" "fail"
|
||||
fi
|
||||
|
||||
if [[ -n "$APP_DESCRIPTION" ]]; then
|
||||
check "app.description present" "pass"
|
||||
else
|
||||
check "app.description present" "warn"
|
||||
fi
|
||||
|
||||
HAS_IMAGE=0
|
||||
HAS_BUILD=0
|
||||
[[ -n "$IMAGE" ]] && HAS_IMAGE=1
|
||||
[[ -n "$BUILD_CONTEXT" || -n "$BUILD_TAG" ]] && HAS_BUILD=1
|
||||
|
||||
if [[ "$HAS_IMAGE" -eq 1 && "$HAS_BUILD" -eq 0 ]]; then
|
||||
check "container.image specified" "pass"
|
||||
elif [[ "$HAS_IMAGE" -eq 0 && "$HAS_BUILD" -eq 1 ]]; then
|
||||
if [[ -n "$BUILD_CONTEXT" && -n "$BUILD_TAG" ]]; then
|
||||
check "container.build specified with context and tag" "pass"
|
||||
else
|
||||
check "container.build requires context and tag" "fail"
|
||||
fi
|
||||
else
|
||||
check "exactly one of container.image or container.build specified" "fail"
|
||||
fi
|
||||
|
||||
if [[ -n "$IMAGE" ]]; then
|
||||
TRUSTED=false
|
||||
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000"; do
|
||||
if echo "$IMAGE" | grep -q "$reg"; then
|
||||
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000" "localhost/"; do
|
||||
if [[ "$IMAGE" == *"$reg"* ]]; then
|
||||
TRUSTED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Also allow short-form Docker Hub images (no registry prefix)
|
||||
if ! echo "$IMAGE" | grep -q "/"; then
|
||||
TRUSTED=true # single-name images are Docker Hub official
|
||||
fi
|
||||
if [[ "$TRUSTED" == "true" ]]; then
|
||||
check "Image from trusted registry" "pass"
|
||||
if [[ "$TRUSTED" == "true" || "$IMAGE" != */* ]]; then
|
||||
check "image registry is recognized" "pass"
|
||||
else
|
||||
check "Image from trusted registry ($IMAGE)" "warn"
|
||||
check "image registry is not in the reviewed list ($IMAGE)" "warn"
|
||||
fi
|
||||
|
||||
# Check no :latest
|
||||
if echo "$IMAGE" | grep -q ":latest$"; then
|
||||
check "No :latest tag (pin specific version)" "fail"
|
||||
elif ! echo "$IMAGE" | grep -q ":"; then
|
||||
check "No version tag specified (should pin version)" "warn"
|
||||
if [[ "$IMAGE" == *":latest" ]]; then
|
||||
if [[ "$APP_INTERNAL" == "true" || "$IMAGE" == localhost/* ]]; then
|
||||
check "internal/local build uses :latest ($IMAGE)" "warn"
|
||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
||||
check "existing manifest uses :latest and must be pinned before public app submission ($IMAGE)" "warn"
|
||||
else
|
||||
check "image tag is pinned and not :latest ($IMAGE)" "fail"
|
||||
fi
|
||||
elif [[ "$IMAGE" != *:* ]]; then
|
||||
check "image tag is explicit ($IMAGE)" "warn"
|
||||
else
|
||||
check "Version tag pinned" "pass"
|
||||
check "image tag is pinned" "pass"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Security checks
|
||||
PRIVILEGED=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('privileged', False))" 2>/dev/null)
|
||||
if [[ "$PRIVILEGED" == "True" ]]; then
|
||||
check "No privileged mode" "fail"
|
||||
MEMORY_LIMIT="$(yaml_eval '((app["resources"] || {})["memory_limit"] || (app["resources"] || {})["memory"])')"
|
||||
CPU_LIMIT="$(yaml_eval '((app["resources"] || {})["cpu_limit"] || (app["resources"] || {})["cpu"])')"
|
||||
[[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn"
|
||||
[[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn"
|
||||
|
||||
READONLY_ROOT="$(yaml_eval '((app["security"] || {})["readonly_root"])')"
|
||||
NO_NEW_PRIVS="$(yaml_eval '((app["security"] || {})["no_new_privileges"])')"
|
||||
NETWORK_POLICY="$(yaml_eval '((app["security"] || {})["network_policy"])')"
|
||||
CONTAINER_NETWORK="$(yaml_eval '((app["container"] || {})["network"])')"
|
||||
|
||||
if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then
|
||||
check "security.readonly_root true (explicit or Rust default)" "pass"
|
||||
else
|
||||
check "No privileged mode" "pass"
|
||||
check "security.readonly_root true or explicitly justified" "warn"
|
||||
fi
|
||||
|
||||
HOST_NET=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('host_network', d.get('network_mode','')))" 2>/dev/null)
|
||||
if [[ "$HOST_NET" == "host" ]]; then
|
||||
check "No host networking" "fail"
|
||||
if [[ "$NO_NEW_PRIVS" == "true" || -z "$NO_NEW_PRIVS" ]]; then
|
||||
check "security.no_new_privileges true (explicit or Rust default)" "pass"
|
||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
||||
check "existing manifest disables security.no_new_privileges and needs review" "warn"
|
||||
else
|
||||
check "No host networking" "pass"
|
||||
check "security.no_new_privileges true" "fail"
|
||||
fi
|
||||
|
||||
# 6. Check for hardcoded secrets in env vars
|
||||
ENV_VARS=$(echo "$CONTENT" | python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
env = d.get('environment', d.get('env', {}))
|
||||
if isinstance(env, dict):
|
||||
for k,v in env.items():
|
||||
print(f'{k}={v}')
|
||||
elif isinstance(env, list):
|
||||
for e in env:
|
||||
print(e)
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
SECRET_PATTERNS="password|secret|api_key|private_key|token"
|
||||
if echo "$ENV_VARS" | grep -iqE "$SECRET_PATTERNS"; then
|
||||
check "No hardcoded secrets in environment" "warn"
|
||||
if [[ "$NETWORK_POLICY" == "isolated" || "$NETWORK_POLICY" == "bridge" || "$NETWORK_POLICY" == "host" || -z "$NETWORK_POLICY" ]]; then
|
||||
check "security.network_policy valid" "pass"
|
||||
else
|
||||
check "No hardcoded secrets in environment" "pass"
|
||||
check "security.network_policy valid" "fail"
|
||||
fi
|
||||
|
||||
# 7. Memory limit
|
||||
MEM=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('memory', d.get('mem_limit', d.get('resources',{}).get('memory',''))))" 2>/dev/null)
|
||||
if [[ -n "$MEM" && "$MEM" != "None" && "$MEM" != "" ]]; then
|
||||
check "Memory limit specified ($MEM)" "pass"
|
||||
if [[ "$CONTAINER_NETWORK" == container:* || "$CONTAINER_NETWORK" == ns:* ]]; then
|
||||
check "container.network does not share another namespace" "fail"
|
||||
else
|
||||
check "Memory limit specified" "warn"
|
||||
check "container.network does not share another namespace" "pass"
|
||||
fi
|
||||
|
||||
SECRET_ENV="$(yaml_eval '(app["environment"] || [])')"
|
||||
if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then
|
||||
check "no hardcoded secret-like values in app.environment" "warn"
|
||||
else
|
||||
check "no hardcoded secret-like values in app.environment" "pass"
|
||||
fi
|
||||
|
||||
if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then
|
||||
EXPECTED_DIR="$(basename "$(dirname "$MANIFEST")")"
|
||||
if [[ "$EXPECTED_DIR" == "$APP_ID" ]]; then
|
||||
check "app.id matches directory name" "pass"
|
||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
||||
check "existing manifest app.id differs from directory name ($EXPECTED_DIR)" "warn"
|
||||
else
|
||||
check "app.id matches directory name ($EXPECTED_DIR)" "fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
PORT_CHECK="$(ruby -ryaml -e '
|
||||
current = ARGV[0]
|
||||
current_id = File.basename(File.dirname(current))
|
||||
ports = {}
|
||||
Dir.glob("apps/*/manifest.yml").sort.each do |path|
|
||||
data = YAML.load_file(path)
|
||||
app = data.is_a?(Hash) ? data["app"] : nil
|
||||
next unless app.is_a?(Hash)
|
||||
id = app["id"] || File.basename(File.dirname(path))
|
||||
next if id == current_id
|
||||
Array(app["ports"]).each do |p|
|
||||
next unless p.is_a?(Hash)
|
||||
proto = p["protocol"] || "tcp"
|
||||
bind = p["bind"] || ""
|
||||
host = p["host"]
|
||||
ports[[host, proto, bind]] = id if host
|
||||
end
|
||||
end
|
||||
data = YAML.load_file(current)
|
||||
app = data["app"]
|
||||
conflicts = []
|
||||
Array(app["ports"]).each do |p|
|
||||
next unless p.is_a?(Hash)
|
||||
key = [p["host"], p["protocol"] || "tcp", p["bind"] || ""]
|
||||
conflicts << "#{key[2].empty? ? "*" : key[2]}:#{key[0]}/#{key[1]} already used by #{ports[key]}" if ports.key?(key)
|
||||
end
|
||||
puts conflicts.join("\n")
|
||||
' "$MANIFEST")"
|
||||
if [[ -n "$PORT_CHECK" ]]; then
|
||||
while IFS= read -r conflict; do
|
||||
check "port conflict: $conflict" "warn"
|
||||
done <<< "$PORT_CHECK"
|
||||
else
|
||||
check "no duplicate host port bindings" "pass"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo "STATUS: REJECTED — fix failures before resubmitting"
|
||||
echo "STATUS: REJECTED - fix failures before resubmitting"
|
||||
exit 1
|
||||
else
|
||||
echo "STATUS: APPROVED (with $WARN warnings)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STATUS: APPROVED (with $WARN warnings)"
|
||||
|
||||
Reference in New Issue
Block a user