Files
archy/scripts/validate-app-manifest.sh
T
archipelagoandClaude Opus 5 8e814ca06a
Demo images / Build & push demo images (push) Failing after 2m22s
feat(registry): move image and OTA references to the public domain
Replaces the registry host across 86 files: 309 references, covering all 40
app manifests, the orchestrator and container crates, the release and catalog
scripts, both demo-images workflows, the ISO builder, demo-deploy, and the
frontend marketplace data.

Verified the domain actually serves the registry before rewriting anything,
rather than assuming the web host implies the registry:
- TLS verifies clean, HTTP/2 on the web root
- an anonymous token grants a manifest fetch (HTTP 200) with no credentials
- skopeo inspect --no-creds resolves an image and lists its tags

That last check is the one that matters: an outside developer with no account
can now pull, which was the functional blocker for publishing at all.

Plain-HTTP references become HTTPS in the same pass, so OTA downloads stop
crossing the network in the clear.

Deliberately NOT rewritten:
- The public FIPS anchor on port 8444. It is a functional network endpoint
  every node dials to bootstrap the mesh — closer to Bitcoin Core's hardcoded
  seeds than to leaked infrastructure. The domain does resolve to the same
  host, so it could become a hostname, but that adds a DNS dependency to the
  path used precisely when things are broken. Worth a deliberate decision,
  not a side effect of this change.
- The companion APK on port 2100. The domain returns 404 for that path, so
  rewriting it would swap a working URL for a broken one. The Releases page
  does serve (200), which is where the plan already wants those binaries.
- releases/app-catalog.json, releases/manifest.json and release-manifest.json.
  These carry `signature` and `signed_by`; editing their contents invalidates
  the signature and the fleet refuses artifacts that fail verification. They
  were rewritten in a first pass and reverted — they must be regenerated and
  re-signed through the signing ceremony instead, which needs the mnemonic.

So the catalog still advertises the old host until that ceremony runs. Nodes
resolve images through the signed catalog, not the on-disk manifests, so this
commit alone does not change what a node pulls.

Verified: archipelago-container 75/75; every manifest still parses with a
top-level app block; no signed artifact modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 11:31:20 -04:00

266 lines
8.5 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# validate-app-manifest.sh - validate an Archipelago app manifest.
#
# Usage:
# ./scripts/validate-app-manifest.sh [--repo-audit] apps/my-app/manifest.yml
#
# 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
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
MANIFEST="$1"
PASS=0
FAIL=0
WARN=0
check() {
local desc="$1" result="$2"
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 ""
if [[ ! -f "$MANIFEST" ]]; then
echo " FAIL: File not found: $MANIFEST"
exit 1
fi
check "File exists" "pass"
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 with top-level app block" "pass"
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"])')"
if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
check "app.id is lowercase kebab-case ($APP_ID)" "pass"
else
check "app.id is lowercase kebab-case" "fail"
fi
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" "source.archipelago-foundation.org" "localhost/"; do
if [[ "$IMAGE" == *"$reg"* ]]; then
TRUSTED=true
break
fi
done
if [[ "$TRUSTED" == "true" || "$IMAGE" != */* ]]; then
check "image registry is recognized" "pass"
else
check "image registry is not in the reviewed list ($IMAGE)" "warn"
fi
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 "image tag is pinned" "pass"
fi
fi
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 "security.readonly_root true or explicitly justified" "warn"
fi
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 "security.no_new_privileges true" "fail"
fi
if [[ "$NETWORK_POLICY" == "isolated" || "$NETWORK_POLICY" == "bridge" || "$NETWORK_POLICY" == "host" || -z "$NETWORK_POLICY" ]]; then
check "security.network_policy valid" "pass"
else
check "security.network_policy valid" "fail"
fi
if [[ "$CONTAINER_NETWORK" == container:* || "$CONTAINER_NETWORK" == ns:* ]]; then
check "container.network does not share another namespace" "fail"
else
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"
exit 1
fi
echo "STATUS: APPROVED (with $WARN warnings)"