Demo images / Build & push demo images (push) Failing after 2m22s
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>
266 lines
8.5 KiB
Bash
Executable File
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)"
|