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.
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
Code
#!/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 077TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXITexport 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" >&2set_source "$IMAGE"if deploy_and_wait; then echo "zaroz: $IMAGE is serving" >&2 exit 0fiif [ "$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" >&2fiexit 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:
Code
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 pendingzcurl -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 bodyzcurl "$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.