diff --git a/.env.example b/.env.example index fc98e4d..c88cd04 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,11 @@ TAG= # container that's driving the upgrade. Empty falls back to TAG. # UPDATER_TAG= +# Accept vX.Y.Z- PRERELEASE tags as explicit upgrade targets. +# Prerelease tags are still never auto-advertised as "latest". +# Leave unset in production. Set to "true" to enable. +# EXO_UPDATER_ALLOW_PRERELEASE_TARGET= + # ------------------------------------------------------------------ # Worker scale (optional) # ------------------------------------------------------------------ diff --git a/README.md b/README.md index ac196cf..5d61b9c 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,26 @@ a single host. A working install from a stock Ubuntu image (no Docker preinstalled): - **VM**: 4 GB / 2 Intel vCPUs / 120 GB / Ubuntu 24.04 (LTS) x64 -- **Install path**: `/opt/ghost-agent-docker` +- **Install path**: `/opt/exo` (required — see note below) Bootstrap once: ```bash apt-get update apt-get install -y docker.io docker-compose-v2 -git clone https://github.com/ghostsecurity/ghost-agent-docker.git /opt/ghost-agent-docker -cd /opt/ghost-agent-docker +git clone https://github.com/ghostsecurity/ghost-agent-docker.git /opt/exo +cd /opt/exo ``` +> **The deploy directory must be `/opt/exo`.** In-stack upgrades run +> `docker compose` from inside the updater container with the project +> directory fixed at `/opt/exo`, so the compose file's relative bind +> mounts (`./config.toml`, `./Caddyfile`, …) resolve to `/opt/exo/...` +> on the host. Deploying anywhere else works for the first +> `docker compose up` but breaks the first in-stack upgrade (the +> recreated services would bind nonexistent host paths). `setup.sh` +> refuses to run outside `/opt/exo`. + Then follow the **Install** section below: `docker login`, then `./setup.sh`, then `docker compose pull && up -d`. @@ -52,9 +61,12 @@ Then follow the **Install** section below: `docker login`, then ### 1. Clone this repo on the host +Clone into `/opt/exo` — the deploy directory is required to be there +(see the note under Quick start; `setup.sh` enforces it). + ```bash -git clone https://github.com/ghostsecurity/ghost-agent-docker.git -cd ghost-agent-docker +git clone https://github.com/ghostsecurity/ghost-agent-docker.git /opt/exo +cd /opt/exo ``` ### 2. Authenticate to Docker Hub @@ -85,13 +97,19 @@ It prompts for the per-deployment inputs (release tag, public domain - auto-detected as `nip.io`, admin email + password, Docker Hub OAT, TLS flavor), auto-generates `ENCRYPTION_KEY` and `jwt_secret`, and writes `.env`, `config.toml`, `config.proxy.toml`, -and `Caddyfile` from their `.example` templates. Refuses to -overwrite existing files - delete them and re-run to regenerate. +and `Caddyfile` from their `.example` templates. It then fetches +`docker-compose.yml` for the chosen release tag from the published +`exo-stack` bundle (the compose is not shipped in this repo - it's +versioned with each release and fetched here, and re-fetched by the +in-stack updater on every upgrade). Refuses to overwrite existing +config files - delete them and re-run to regenerate. Or, to edit by hand: copy each `*.example` to its target name, open the four files, replace every empty REQUIRED value and `TODO` comment. Inline comments document each one. For BYO-cert, also `mkdir -p certs/` and place `fullchain.pem` + `privkey.pem` there. +Then fetch the compose for your tag with `oras`: +`oras pull docker.io/ghostsecurityhq/exo-stack: -o .`. ### 4. Pull and start @@ -120,7 +138,12 @@ admin credentials from step 3 and rotate the password from the UI. The in-stack updater polls Docker Hub every 10 minutes for new release tags. When a newer `vX.Y.Z` is available, the "Upgrade" button in the UI's System view lights up. Click it to upgrade the -running stack in place. +running stack in place. Each upgrade fetches that release's +`exo-stack` bundle (its `docker-compose.yml`) and converges the +stack to it, so a release can add, remove, or reconfigure containers +- not just bump image tags. Your local `docker-compose.yml` is +overwritten by the release's on each upgrade (edit topology upstream, +not in place). To upgrade out of band (or to bump the updater image itself, which the in-UI upgrade deliberately doesn't touch): @@ -138,7 +161,7 @@ docker compose up -d | Scale worker replicas | `WORKER_REPLICAS` in `.env`, then `docker compose up -d worker` | | Bump the updater image only | `UPDATER_TAG` in `.env`, then `docker compose up -d exo-updater` | | Run behind an existing reverse proxy | Keep Caddy in the stack (it serves the static UI bundle as well as proxying the API). Switch its Caddyfile to plain HTTP on a different host port, then point your external proxy at that port | -| Use named volumes on a specific disk | Override the volume definitions at the bottom of `docker-compose.yml` with `driver_opts` pointing at the desired filesystem | +| Use named volumes on a specific disk | The `docker-compose.yml` is fetched from the release bundle and overwritten on every upgrade, so don't edit it in place. Configure the Docker volume's storage out of band (e.g. a `local` volume `driver_opts` device, or relocating `/var/lib/docker`) | | Switch the registry | `REGISTRY` in `.env` (must mirror the `ghostsecurityhq/exo-*` layout) | | Cap container log size + auto-prune old images | Optional final step in `setup.sh`. Caps each container's logs at 10MB x 3 rotation (`json-file` driver), installs a daily systemd timer running `docker image prune -a --filter until=168h`. Answer 'n' at the prompt to skip | diff --git a/config.toml.example b/config.toml.example index 3c53690..742ebb2 100644 --- a/config.toml.example +++ b/config.toml.example @@ -68,18 +68,36 @@ compose_file_path = "/opt/exo/docker-compose.yml" # Docker Hub polling backend. registry_type = "oci" -# Services the updater recreates during an in-place upgrade. Don't -# add `exo-updater` to this list; the updater can't recreate the -# container it's running inside. -managed_services = ["gateway", "credential-proxy", "worker", "ui-extract"] cert_dir = "/var/lib/exo/tls" cert_ttl = "1h" cert_renew_before = "15m" +# Stack delivery (topology-aware upgrades). The compose file is not +# shipped in this repo; it is published per release as the cosign- +# signable OCI artifact `${REGISTRY}/exo-stack:` and fetched both +# at bootstrap (by setup.sh) and on every upgrade (by the updater), +# which then converges the topology — so a release can add / remove / +# reconfigure containers, not just bump image tags. +stack_repository = "exo-stack" +# The updater's own compose service name, excluded from the converge so +# the upgrade doesn't recreate the container running it. +self_service = "exo-updater" +# Fallback registry host for the bundle ref when `.env` doesn't set +# REGISTRY (the compose interpolates `${REGISTRY:-docker.io/ghostsecurityhq}`, +# so REGISTRY is usually absent from .env). Matches that default. +stack_registry = "docker.io/ghostsecurityhq" +# No stack_signing_* policy here: Docker Hub images/bundles are not +# cosign-signed, so the bundle is pulled unverified (the updater logs a +# warning). The trust level matches the unsigned images it ships beside. + [updater.oci] registry = "registry-1.docker.io" namespace = "ghostsecurityhq" -repository = "exo-worker" +# Repo the poller watches for new release tags. Points at exo-stack — the +# compose bundle, published LAST in the release pipeline (after every +# image). Its tag appearing is what makes a release fully upgradeable: all +# images plus the bundle are then in the registry. +repository = "exo-stack" auth_username = "ghostsecurityhq" # auth_token is read from the EXO_UPDATER_OCI_AUTH_TOKEN env var. diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 18664df..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,379 +0,0 @@ -# Ghost Agent Platform - self-hosted docker-compose deployment. -# -# From this directory, run `docker compose pull && docker compose up -# -d`. Variables are read from `.env` (copy `.env.example` and fill -# in the required values). Configs (`config.toml`, -# `config.proxy.toml`, `Caddyfile`) are bind-mounted from this -# directory - copy the corresponding `.example` templates and edit -# before bringing the stack up. -# -# Host prereqs: -# -# 1. `docker login -u -p ` once on the host as the -# user that will run docker compose. This is what lets the -# initial `docker compose pull` (and subsequent updates from -# the in-stack updater) authenticate against Docker Hub. -# -# 2. The Caddyfile flavor you pick (auto-LE vs BYO-cert) must -# match your environment. See README.md. -# -# In-stack upgrades are driven by the exo-updater container, which -# polls Docker Hub for new release tags using the OAT in -# EXO_UPDATER_OCI_AUTH_TOKEN. The same OAT that authenticated the -# host's `docker login` above is fine here. - -# Pin the compose project name so host invocations (`docker compose` from -# whatever directory the operator cloned into) AND the in-stack updater's -# invocations resolve to the SAME project. -name: exo - -services: - # Oneshot that normalizes ownership on the shared TLS + artifacts - # volumes before any long-running service mounts them, then exits - # (`restart: "no"`). Docker seeds a named volume's ownership from the - # image mount-point only on first creation, so on an UPGRADE the - # volumes keep their old root ownership and the now-non-root gateway / - # proxy / worker (UID 65532) can't read or write them. This root - # oneshot chowns them on every `up -d`; a fresh install is a near - # no-op. Mirrors the ui-extract oneshot pattern - the long-running - # service images stay non-root. - # tls private keys, 0700, read by gateway + proxy + updater - # tls-public world-readable (0755) - worker reads ca.crt as its agent user - # artifacts 2775 setgid so files written by the gateway (65532) and - # the worker's agent user (supplementary group 65532) stay - # mutually readable - volume-init: - image: busybox - user: "0:0" - restart: "no" - command: - - /bin/sh - - -c - - | - set -e - chown -R 65532:65532 /tls /tls-public /artifacts - chmod 0700 /tls - chmod 0755 /tls-public - chmod 2775 /artifacts - volumes: - - tls:/tls - - tls-public:/tls-public - - artifacts:/artifacts - - gateway: - image: ${REGISTRY:-docker.io/ghostsecurityhq}/exo-server:${TAG} - restart: unless-stopped - command: [ "-config", "/etc/exo/config.toml" ] - environment: - # FIPS 140-3 mode. GOFIPS140 selects the validated crypto - # module at build time; GODEBUG=fips140=on activates it at - # runtime; EXO_REQUIRE_FIPS makes startup fatal if FIPS isn't - # active. - GOFIPS140: latest - GODEBUG: fips140=on - EXO_REQUIRE_FIPS: "true" - # Gateway secrets injected from .env rather than config.toml, so - # config.toml stays non-secret (world-readable) and the gateway can - # run as its non-root default user. config.Load prefers these over - # the blank values in config.toml. - EXO_JWT_SECRET: ${EXO_JWT_SECRET:?EXO_JWT_SECRET must be set in .env} - EXO_SEED_ADMIN_PASSWORD: ${EXO_SEED_ADMIN_PASSWORD:?EXO_SEED_ADMIN_PASSWORD must be set in .env} - # Channel connectors. Set in `.env` when wiring Slack; empty - # defaults keep compose substitution from blowing up. - SLACK_APP_TOKEN: ${SLACK_APP_TOKEN:-} - SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN:-} - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} - # In-stack updater so the gateway's per-run worker-recycle RPC - # has somewhere to land + the UI's "Upgrade" button works. - UPDATER_URL: https://exo-updater:8080 - SYSTEM_UPGRADE_ENABLED: "true" - volumes: - - ./config.toml:/etc/exo/config.toml:ro - - artifacts:/var/lib/exo/artifacts - - tls:/var/lib/exo/tls - networks: - internal: - ipv4_address: 172.28.0.11 - database: - external: - depends_on: - volume-init: - condition: service_completed_successfully - database: - condition: service_healthy - credential-proxy: - condition: service_started - - # Only component with ENCRYPTION_KEY. Holds the MITM CA private - # key and the service CA private key. NOT reachable from outside - # the host: the MITM (:443), control plane (:8444), and DNS (:53) - # listeners are bridge-only. - credential-proxy: - image: ${REGISTRY:-docker.io/ghostsecurityhq}/exo-credential-proxy:${TAG} - restart: unless-stopped - command: [ "-config", "/etc/exo/config.proxy.toml" ] - # The proxy runs as the non-root UID 65532 but binds the privileged - # ports :443 (MITM) and :53 (DNS). Lowering the unprivileged-port floor - # to 0 lets it bind them without CAP_NET_BIND_SERVICE or root. - sysctls: - - net.ipv4.ip_unprivileged_port_start=0 - environment: - ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY must be set in .env (generate with `openssl rand -base64 32`)} - GOFIPS140: latest - GODEBUG: fips140=on - EXO_REQUIRE_FIPS: "true" - volumes: - - ./config.proxy.toml:/etc/exo/config.proxy.toml:ro - - tls:/var/lib/exo/tls - - tls-public:/var/lib/exo/tls-public - networks: - # Fixed IP - workers' `dns:` directive depends on this and - # must match [proxy].dns_proxy_ip in config.proxy.toml. - internal: - ipv4_address: 172.28.0.10 - database: - external: - depends_on: - volume-init: - condition: service_completed_successfully - database: - condition: service_healthy - - database: - image: mongo:7 - restart: unless-stopped - # Single-node replica set (rs0) - required for the change - # streams the gateway tails on db.run_events.watch() for live - # UI updates. - command: [ "--replSet", "rs0", "--bind_ip_all" ] - volumes: - - mongo-data:/data/db - networks: - - database - # Healthcheck doubles as one-time rs.initiate() bootstrap - # (idempotent on subsequent boots). - healthcheck: - test: - - CMD-SHELL - - | - mongosh --quiet --eval ' - try { rs.status() } catch (e) { - rs.initiate({_id: "rs0", members: [{_id: 0, host: "database:27017"}]}); - } - ' && mongosh --quiet --eval 'db.hello().isWritablePrimary' - interval: 10s - timeout: 10s - retries: 10 - start_period: 20s - - worker: - image: ${REGISTRY:-docker.io/ghostsecurityhq}/exo-worker:${TAG} - restart: unless-stopped - cap_drop: - - ALL - deploy: - replicas: ${WORKER_REPLICAS:-2} - environment: - EXO_RUNNER_WS_URL: wss://gateway:8000/api/v1/runners/connect - EXO_RUNNER_WORKER_TYPES: general - EXO_RUNNER_CAPABILITIES: task - EXO_RUNNER_SESSION_ROOT: /run/exo - EXO_ARTIFACT_ROOT: /var/lib/exo/artifacts - EXO_AGENT_EXTRA_CA_CERTS: /var/lib/exo/tls/ca.crt - EXO_RUNNER_IDENTITY_DIR: /var/lib/exo/runner-identity - # ALL hostname lookups go through the credential proxy's DNS - # server. Public hostnames resolve to the proxy and land at the - # MITM listener; stack-internal names forward to Docker's - # embedded DNS. The primary mechanism by which arbitrary SDKs - # get their credential swapped without an HTTPS_PROXY escape - # hatch. - dns: - - 172.28.0.10 - volumes: - - artifacts:/var/lib/exo/artifacts - # Worker sees ONLY the public CA cert - never the signing - # key. tls-public mounts at /var/lib/exo/tls inside the - # container to match EXO_AGENT_EXTRA_CA_CERTS above. - - tls-public:/var/lib/exo/tls:ro - - runner-identity:/var/lib/exo/runner-identity - networks: - - internal - depends_on: - volume-init: - condition: service_completed_successfully - gateway: - condition: service_started - credential-proxy: - condition: service_started - - # UI-driven container upgrade dispatcher. Only component that - # holds the host's docker socket. Listens on the internal bridge - # only - the gateway is the only thing that dials it, and the - # network's `internal: true` plus the updater's mTLS gate (CN - # must match `gw_`) keep it unreachable from anything - # else. - # - # UPDATER_TAG is intentionally separate from TAG: every TAG bump - # via `docker compose up -d` skips the updater itself (so the - # container running the upgrade doesn't get recreated mid- - # flight). Bump the updater image out of band: - # - # sed -i 's/^UPDATER_TAG=.*/UPDATER_TAG=vX.Y.Z/' .env - # docker compose up -d exo-updater - exo-updater: - image: ${REGISTRY:-docker.io/ghostsecurityhq}/exo-updater:${UPDATER_TAG:-${TAG}} - restart: unless-stopped - # Entrypoint shim runs once per container start: `docker login` - # using the OAT so the in-container docker CLI has Docker Hub - # auth for the `docker compose pull` that fires during UI-driven - # upgrades. Doing it here rather than relying on a host - # ~/.docker/config.json bind-mount keeps the updater self- - # contained - no dependency on the host's credential helper - # situation. `${REGISTRY##*/}` strips everything up to the - # last `/` to extract the Docker Hub org (e.g. `ghostsecurityhq`). - entrypoint: [ "/bin/sh", "-c" ] - # The image ships a non-root default user, but this container holds the - # host docker socket — which is root-equivalent (it can launch a - # privileged container) — so running the process as non-root buys no - # real isolation while complicating the in-container `docker login` and - # the .env rewrite inside the bind-mounted /opt/exo. Run it as root - # explicitly. - user: "0:0" - command: - - | - set -e - DH_ORG="$${REGISTRY##*/}" - printf '%s' "$$EXO_UPDATER_OCI_AUTH_TOKEN" | docker login -u "$$DH_ORG" --password-stdin - exec /updater -config /etc/exo/config.toml - environment: - GOFIPS140: latest - GODEBUG: fips140=on - EXO_REQUIRE_FIPS: "true" - # REGISTRY is read by the entrypoint shim above to derive the - # Docker Hub org for `docker login`. - REGISTRY: ${REGISTRY:-docker.io/ghostsecurityhq} - # Docker Hub OAT - used both by the entrypoint shim for - # `docker login` and by the OCI registry-polling backend - # configured in config.toml ([updater] registry_type = "oci"). - EXO_UPDATER_OCI_AUTH_TOKEN: ${EXO_UPDATER_OCI_AUTH_TOKEN:?EXO_UPDATER_OCI_AUTH_TOKEN must be set in .env (Docker Hub OAT with read on the exo-* repos)} - volumes: - # The updater's own config lives at /etc/exo/config.toml. - # Mounting the parent directory (./) rather than the - # individual .env file is deliberate: the updater's atomic - # .env rewrite uses rename(2), and Linux refuses rename over - # a single-file bind mount (EBUSY). Mounting the parent - # directory keeps the rename inside the bind, which works. - - ./config.toml:/etc/exo/config.toml:ro - - ./:/opt/exo - - /var/run/docker.sock:/var/run/docker.sock - - tls:/var/lib/exo/tls - networks: - # Fixed IP on the internal bridge so the gateway's - # UPDATER_URL hostname resolves predictably. The `external` - # network is required for outbound HTTPS to Docker Hub - # (tags/list polling + image pulls) - `internal: true` - # blocks both. - internal: - ipv4_address: 172.28.0.12 - external: - depends_on: - # service_started (not service_healthy): the credential- - # proxy writes updater.crt + updater.key synchronously during - # its own startup, but the depends_on contract is "container - # exists", not "ready". A startup check inside the updater - # turns a missing cert into a clean fatal, which docker's - # restart policy resolves after ~1 cycle. - credential-proxy: - condition: service_started - - # Public TLS terminator + static UI server + /api reverse proxy. - # The only service in this compose that publishes host ports. - # Caddyfile flavor (auto-LE vs BYO-cert) is an operator choice: - # copy either Caddyfile.letsencrypt.example or Caddyfile.byo.example - # to Caddyfile before bringing the stack up. - caddy: - image: caddy:2-alpine - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - # Optional bind for BYO-cert deployments. Operators using - # Caddyfile.byo.example place their cert + key here. - # Harmless when empty - the auto-LE Caddyfile doesn't read - # it. - - ./certs:/etc/caddy/certs:ro - - caddy-data:/data - - caddy-config:/config - - caddy-ui:/srv/ui:ro - networks: - - internal - - external - depends_on: - gateway: - condition: service_started - - # Oneshot that copies the static UI bundle out of the exo-ui - # image into the shared volume Caddy reads from. Runs once on - # each `up -d`, then exits. `restart: "no"` keeps it from - # looping. - ui-extract: - image: ${REGISTRY:-docker.io/ghostsecurityhq}/exo-ui:${TAG} - # The exo-ui image now defaults to the non-root nginx-unprivileged user - # (UID 101). This throwaway oneshot writes the caddy-ui named volume, - # which Docker seeds root-owned, so run the copy as root. The non-root - # image default still satisfies Scout's policy; nothing long-running - # uses this image. - user: "0:0" - command: [ "/bin/sh", "-c", "cp -rT /usr/share/nginx/html /srv/ui" ] - restart: "no" - volumes: - - caddy-ui:/srv/ui - -networks: - # Stack-internal traffic. `internal: true` blocks external - # egress, forcing worker outbound HTTPS through the credential- - # proxy. gateway and credential-proxy hold fixed IPs for stable - # DNS and for the worker's `dns:` directive. - internal: - driver: bridge - internal: true - ipam: - config: - - subnet: 172.28.0.0/24 - database: - driver: bridge - external: - driver: bridge - -volumes: - # MongoDB data directory. - mongo-data: # Run artifacts (logs, outputs). - - artifacts: # Private TLS material: ca.{crt,key}, server.{crt,key}, - - # service-ca.{crt,key}, updater.{crt,key}. Mounted by the - # credential-proxy (which generates it on first boot), the - # gateway (reads server.* and service-ca.crt), and the updater - # (reads its own cert pair). NEVER mounted by the worker - no - # private key should ever reach a workload container. - tls: # Public CAs only: the credential-proxy publishes ca.crt and - - # service-ca.crt here on startup so downstream services can - # trust the cert chain without read access to the signing keys. - # Mounted read-only by the worker. - tls-public: # Runner identity pool (one subdir per runner_id, each gated by - - # its own advisory flock). Shared across scaled worker replicas - # - the lock discipline guarantees two workers never use the - # same identity concurrently. - runner-identity: # Caddy state - Let's Encrypt account + issued cert material - - # persists here across restarts so LE doesn't re-issue on every - # boot. Unused on the BYO-cert path. - caddy-data: - caddy-config: # Static UI bundle, populated by the ui-extract oneshot and - - # served by Caddy. - caddy-ui: diff --git a/setup.sh b/setup.sh index 875473f..b9cee6a 100755 --- a/setup.sh +++ b/setup.sh @@ -25,6 +25,20 @@ fi # --- preflight --- +# The deploy directory is locked to /opt/exo. In-stack upgrades run +# `docker compose` from inside the updater container with the project +# directory fixed at /opt/exo, so the compose file's relative bind +# mounts (./config.toml, ./Caddyfile, …) resolve to /opt/exo/... on the +# host. Deploying elsewhere works for the first `docker compose up` but +# breaks the first upgrade (recreated services would bind nonexistent +# host paths). Fail fast here rather than at upgrade time. +if [ "$PWD" != "/opt/exo" ]; then + echo "${R}error:${N} this stack must be deployed at /opt/exo (current: ${PWD})." + echo " Move the repo to /opt/exo and re-run, e.g.:" + echo " sudo mv \"$PWD\" /opt/exo && cd /opt/exo && ./setup.sh" + exit 1 +fi + # Required template files (sources for the runtime configs). for f in .env.example config.toml.example config.proxy.toml.example \ Caddyfile.letsencrypt.example Caddyfile.byo.example; do @@ -171,6 +185,30 @@ chmod 600 .env # holds secrets: encryption key, jwt secret, admin pw, OAT # can read them regardless of the operator's umask. chmod 644 config.toml config.proxy.toml +# --- fetch the stack compose --- + +# The docker-compose.yml is NOT shipped in this repo. It's published per +# release as the OCI "stack bundle" `${REGISTRY}/exo-stack:${TAG}` and +# fetched here for bootstrap; the in-stack updater fetches subsequent +# versions on each topology-aware upgrade (same source of truth). We pull +# it with a throwaway `oras` container (no host oras install needed), +# authenticating with the OAT already collected above. +REGISTRY_VALUE="${REGISTRY:-docker.io/ghostsecurityhq}" +DH_ORG="${REGISTRY_VALUE##*/}" +STACK_REF="${REGISTRY_VALUE}/exo-stack:${TAG}" +ORAS_IMAGE="ghcr.io/oras-project/oras:v1.2.0" + +echo +echo "${B}Fetching stack compose${N} ${STACK_REF}" +if docker run --rm -v "$PWD:/work" -w /work "$ORAS_IMAGE" \ + pull --username "$DH_ORG" --password "$DOCKER_OAT" "$STACK_REF" -o . ; then + echo " wrote docker-compose.yml" +else + echo "${R}error:${N} failed to fetch the stack bundle ${STACK_REF}." + echo " Confirm the tag exists in Docker Hub and the OAT has read access, then re-run." + exit 1 +fi + # --- summary --- echo