# Deploy from any shell

`zaroz-deploy.sh` is everything the CI-specific guides call. It needs `curl`, `jq` and a POSIX shell, so it runs in Jenkins, Drone, Woodpecker, Buildkite, a Makefile or a cron job.

```bash
IMAGE=ghcr.io/acme/api:3f2a1c9d8e7b \
ZAROZ_SERVICE=2d1a0c1e-0000-0000-0000-000000000000 \
ZAROZ_TOKEN=zaroz_... \
sh deploy/zaroz-deploy.sh
```

| Variable | Required | Meaning |
|---|---|---|
| `IMAGE` | yes | `host/repository:tag` to run |
| `ZAROZ_SERVICE` | yes | the service instance UUID |
| `ZAROZ_TOKEN` | yes | the [deploy token](/guides/ci/overview#create-a-token-that-can-only-deploy) |
| `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | no | a pull-only login for a private registry |
| `ROLLBACK` | no | `1` (default) puts the previous image back when the rollout fails; `0` disables |
| `DEPLOY_TIMEOUT_SECONDS` | no | how long to wait, default 600 |
| `ZAROZ_API` | no | defaults to `https://kernel.zaroz.cloud/api/v2` |

Exit code `0` means the new image is serving. Anything else means it is not; if rollback ran, the previous image is serving again.

## The script

```bash
#!/usr/bin/env sh
# Deploy an image to a Zaroz Workload and fail the job if the rollout fails.
#
#   IMAGE=ghcr.io/acme/api:3f2a1c ZAROZ_SERVICE=<uuid> ZAROZ_TOKEN=zaroz_... ./zaroz-deploy.sh
#
# Steps: remember what runs now, point the Workload's source at IMAGE, start a
# deployment, wait for it, check the result, and (ROLLBACK=1, the default) put
# the previous image back when the new one did not roll out.
#
# Optional: REGISTRY_USERNAME / REGISTRY_PASSWORD when the image is in a private
# registry (stored by Zaroz, never returned by the API), DEPLOY_TIMEOUT_SECONDS,
# ROLLBACK=0 to disable the automatic rollback, ZAROZ_API.
#
# Needs: curl, jq. POSIX sh. No other dependency.
set -eu

: "${ZAROZ_TOKEN:?set ZAROZ_TOKEN to the deploy token}"
: "${ZAROZ_SERVICE:?set ZAROZ_SERVICE to the service instance uuid}"
: "${IMAGE:?set IMAGE to host/repository:tag}"
API="${ZAROZ_API:-https://kernel.zaroz.cloud/api/v2}"
TIMEOUT="${DEPLOY_TIMEOUT_SECONDS:-600}"
ROLLBACK="${ROLLBACK:-1}"
BASE="$API/service-instances/$ZAROZ_SERVICE"

# Secrets never go in argv (visible to `ps` for every user on a shared runner):
# the bearer header travels in curl's config on stdin, the body in a 0600 temp file.
# The registry login is read by jq from the environment, not from its arguments.
umask 077
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
export REGISTRY_USERNAME="${REGISTRY_USERNAME:-}" REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-}"

call() { # method path [json body]; prints the body, fails on a non-2xx
  _out="$TMP/out"; : > "$_out"
  if [ -n "${3:-}" ]; then printf '%s' "$3" > "$TMP/body"; set -- "$1" "$2" --data "@$TMP/body"; fi
  _method="$1"; _path="$2"; shift 2
  _code="$(printf 'header = "Authorization: Bearer %s"\n' "$ZAROZ_TOKEN" \
    | curl -sS -K - -o "$_out" -w '%{http_code}' -X "$_method" "$BASE$_path" \
        -H 'Content-Type: application/json' "$@")"
  rm -f "$TMP/body"
  case "$_code" in
    2??) cat "$_out" ;;
    *) echo "zaroz: $_method $_path answered $_code: $(cat "$_out")" >&2; return 1 ;;
  esac
}

# host/repository:tag -> three parts (a tag after the last colon of the last path segment)
split_image() {
  _host="${1%%/*}"; _rest="${1#*/}"
  case "$_rest" in *:*) _repo="${_rest%:*}"; _tag="${_rest##*:}" ;; *) _repo="$_rest"; _tag=latest ;; esac
  printf '%s %s %s' "$_host" "$_repo" "$_tag"
}

set_source() { # image
  set -- $(split_image "$1")
  # Registry login is only sent when given; absent leaves the stored login alone.
  call PUT /source "$(jq -n --arg h "$1" --arg r "$2" --arg t "$3" \
    '{kind:"registry",host:$h,repository:$r,tag:$t}
     + (if (env.REGISTRY_USERNAME // "") != ""
        then {credentials:{username:env.REGISTRY_USERNAME,password:env.REGISTRY_PASSWORD}}
        else {} end)')" >/dev/null
}

deploy_and_wait() { # prints the deployment json; exit 0 when it rolled out
  _dep="$(call POST /deployments)"
  _id="$(printf '%s' "$_dep" | jq -r .id)"
  echo "zaroz: deployment #$(printf '%s' "$_dep" | jq -r .number) started" >&2
  _deadline=$(( $(date +%s) + TIMEOUT ))
  while :; do
    _list="$(call GET '/deployments?page_size=5')"
    _active="$(printf '%s' "$_list" | jq -r '.active_deployment_id // empty')"
    if [ -z "$_active" ]; then break; fi
    [ "$(date +%s)" -lt "$_deadline" ] || { echo "zaroz: timed out after ${TIMEOUT}s" >&2; return 1; }
    sleep 5
  done
  _outcome="$(printf '%s' "$_list" | jq -r --arg id "$_id" '[.data[]|select(.id==$id)][0].outcome // "unknown"')"
  _reason="$(printf '%s' "$_list" | jq -r --arg id "$_id" '[.data[]|select(.id==$id)][0].failure_reason // ""')"
  # "succeeded" only says the rollout was applied; the workloads say whether it is serving.
  _bad="$(printf '%s' "$_list" | jq -r '[.workloads[]|select(.status!="running")|.role+"="+.status]|join(",")')"
  if [ "$_outcome" != succeeded ]; then echo "zaroz: rollout $_outcome ${_reason:+($_reason)}" >&2; return 1; fi
  if [ -n "$_bad" ]; then echo "zaroz: not serving: $_bad" >&2; return 1; fi
}

PREVIOUS="$(call GET /source | jq -r '.running_image // empty')"
echo "zaroz: running now: ${PREVIOUS:-nothing}; deploying $IMAGE" >&2

set_source "$IMAGE"
if deploy_and_wait; then
  echo "zaroz: $IMAGE is serving" >&2
  exit 0
fi

if [ "$ROLLBACK" = 1 ] && [ -n "$PREVIOUS" ] && [ "$PREVIOUS" != "$IMAGE" ]; then
  echo "zaroz: rolling back to $PREVIOUS" >&2
  set_source "$PREVIOUS"
  deploy_and_wait && echo "zaroz: rolled back to $PREVIOUS" >&2 || echo "zaroz: rollback failed too" >&2
fi
exit 1
```


## Why the token is never on the command line

Arguments of a process are readable by every user on the machine (`ps`, `/proc/<pid>/cmdline`), and CI runners are often shared. So the script never writes the token or the registry password in an argument: the `Authorization` header goes to `curl -K -` as configuration on standard input (`printf` is a shell builtin, so it appears in no process list), the request body goes through a private temporary file, and `jq` reads the registry login from the environment. Copy the same habit into your own scripts: `curl -H "Authorization: Bearer $TOKEN"` is the pattern to avoid.

## What each request does

The script is five requests. You can run them yourself:

```bash
B="$ZAROZ_API/service-instances/$ZAROZ_SERVICE"
zcurl() { printf 'header = "Authorization: Bearer %s"\n' "$ZAROZ_TOKEN" | curl -sS -K - "$@"; }
zcurl "$B/source" | jq '{running_image, pending}'      # what runs now, and is a change pending
zcurl -X PUT "$B/source" -H 'Content-Type: application/json' \
  -d '{"kind":"registry","host":"ghcr.io","repository":"acme/api","tag":"3f2a1c9d8e7b"}'
zcurl -X POST "$B/deployments"                          # deploys what was saved; no body
zcurl "$B/deployments?page_size=5" \
  | jq '{active_deployment_id, newest: .data[0] | {number, status, outcome, failure_reason}, workloads}'
```

`failure_reason` is one of `not_configured`, `product_not_deployable`, `subscription_not_active`, `hostname_taken`, `invalid_hostname`, `network_unavailable`, `cluster_rejected`, `unknown`. Zaroz never repeats raw cluster messages, because they quote internal hostnames; the closed vocabulary is what you get.

## Testing the script without a Workload

The repository `unit-examples` ships `_shared/test/run.sh`, which runs this script against a small mock of those routes and checks the success path and the rollback path.
