Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,25 @@ Inputs are visible in GitHub, so none of them is secret.

1. Exports the Actions cache runtime (`crazy-max/ghaction-github-runtime`), so `ployz build` uses the GitHub Actions cache.
2. Turns on Docker's containerd image store if it is off. This restarts Docker and needs `sudo`.
3. Gets a GitHub OIDC token with audience `cloud` and checks in: `POST {cloud}/api/builds/{build}/check-in` with `Authorization: Bearer <token>`. Cloud answers:
3. Generates the Build Grant key, an ed25519 key that never leaves the runner. Gets a GitHub OIDC token with audience `cloud` and checks in: `POST {cloud}/api/builds/{build}/check-in` with `Authorization: Bearer <token>` and `{"holder": "<64 hex public key>"}`. Cloud answers:
```json
{"grant": "ployzgrant1:…", "commit": "<40 hex>", "fingerprint": "<64 hex>", "ployzVersion": "<x.y.z[-beta.n]>", "deployment": { "snapshots": [{ "resolvedEnv": {} }] }}
{"machine": "<64 hex>", "commit": "<40 hex>", "fingerprint": "<64 hex>", "ployzVersion": "<x.y.z[-beta.n]>", "deployment": { "snapshots": [{ "resolvedEnv": {} }] }}
```
The token, the grant, and every `resolvedEnv` value are masked (`::add-mask::`, per line) before anything else runs. Lines under 4 characters are not: masking `0` would star out every 0 in the log. A check-in that can't reach Cloud is retried; an HTTP error is not, and the job fails with Cloud's status and message.
The Action builds the grant from `machine` and its own key. Older Machines answer `grant` instead of `machine`. The token, the grant, and every `resolvedEnv` value are masked (`::add-mask::`, per line) before anything else runs. Lines under 4 characters are not: masking `0` would star out every 0 in the log. A check-in that can't reach Cloud is retried; an HTTP error is not, and the job fails with Cloud's status and message.
4. Installs exactly `ployzVersion` from `https://ployz.sh`: the version of the Cloud process that computed `fingerprint`, so they match even while Cloud rolls out a new version. The installer verifies the release checksum. The install is timed as the first Build Step, "Installing ployz". If it fails, the Action sends Cloud a final report with that step failed and naming the version (`installFailed`), so Cloud moves the build on to the next Builder.
5. Checks out `commit` without persisting credentials.
6. Runs `PLOYZ_BUILD_GRANT=… ployz build --deployment <file> --commit <commit> --fingerprint <fingerprint> --events <file>`. The deployment file lives in `$RUNNER_TEMP` and is deleted when the job ends, pass or fail.
6. Runs `PLOYZ_BUILD_GRANT=… ployz build --deployment <file> --commit <commit> --fingerprint <fingerprint> --events <file>`. The deployment file, the key, and the build's scratch (`TMPDIR`) live in `$RUNNER_TEMP` and are deleted when the job ends, pass or fail. A cancelled job stops `ployz build` first.
7. Reports the Build Steps while it builds, every few seconds, each time with a fresh OIDC token: `POST {cloud}/api/builds/{build}/steps` with `{"from": <line>, "events": [<new event lines>]}`, where `from` is the 0-based line the batch starts at. The lines are the "Installing ployz" step, then `ployz build --events`, which includes the push and its per-layer progress. Cloud files only lines it hasn't taken, so a retried batch is harmless. As soon as `ployz build` prints its result (the image is in the Machine), or when it fails, the last batch adds `"platforms": [...]`; empty means the build failed. That report settles the build, and Cloud takes no more. `ployz build` then uploads the build cache to the GitHub Actions cache; a failed upload is only a warning.

Output `digest` is the manifest digest the Machine received. Cloud does not trust it: it reads the pushed digest from the Machine when it ends the grant.

## Runner needs

Linux, Docker with Buildx, rootful Docker on the runner's network (the push goes to `127.0.0.1`), and outbound access to `relay.ployz.dev`. GitHub-hosted Ubuntu runners have all of these.
Linux, Node.js, Docker with Buildx, rootful Docker on the runner's network (the push goes to `127.0.0.1`), and outbound access to `relay.ployz.dev`. GitHub-hosted Ubuntu runners have all of these.

## Develop

`./test.sh` runs both scripts against stubbed `curl`, `docker`, and `ployz`. `shellcheck *.sh` must pass. `oidc.sh` holds the OIDC token helper both scripts source.
`./test.sh` runs both scripts against stubbed `curl`, `docker`, and `ployz`. `shellcheck *.sh` must pass. `oidc.sh` holds the OIDC token helper both scripts source. `grant.js` makes the Build Grant key and assembles the grant.

## Publish

Expand Down
20 changes: 18 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,24 @@ cloud=${PLOYZ_CLOUD%/}
events="$work/events.jsonl"
# How often, in seconds, new Build Steps go to Cloud while the build runs.
interval=${PLOYZ_STEPS_INTERVAL:-5}
PLOYZ_BUILD_GRANT=$(jq -r '.grant' "$work/check-in.json")
PLOYZ_BUILD_GRANT=$(<"$work/grant")
export PLOYZ_BUILD_GRANT
: >"$events"
sent=0

# A cancelled job signals this script. The build stops, and is waited for, before the work dir and
# the Build Grant key in it go.
stop() {
if [[ -n ${build:-} ]]; then
kill -TERM "$build" 2>/dev/null || true
wait "$build" || true
fi
rm -rf "$work"
}
trap stop EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

# Every event line so far: prepare.sh's "Installing ployz", then the build's own.
steps() {
[[ ! -f "$work/install.jsonl" ]] || cat "$work/install.jsonl"
Expand All @@ -40,9 +53,11 @@ report() {
fi
}

# Build scratch, opened Sealed values included, stays under the work dir, which goes on any exit.
install -d -m 0700 "$work/tmp"
# stdout is one JSON line, printed once the image is pushed; build logs go to stderr and, as
# events, to a file. After the push, ployz goes on uploading build cache, which Cloud doesn't wait for.
ployz build \
TMPDIR="$work/tmp" ployz build \
--deployment "$work/deployment.json" \
--commit "$(jq -r '.commit' "$work/check-in.json")" \
--fingerprint "$(jq -r '.fingerprint' "$work/check-in.json")" \
Expand All @@ -63,6 +78,7 @@ while [[ -z $ended ]] && kill -0 "$build" 2>/dev/null; do
done
status=0
wait "$build" || status=$?
build=

[[ -n $ended ]] || report "$(if [[ $status == 0 ]]; then jq -c '.platforms' "$work/result.json"; else echo '[]'; fi)"
[[ $status == 0 ]] || exit "$status"
Expand Down
63 changes: 63 additions & 0 deletions grant.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// The Build Grant key is this runner's own: Cloud gets its public half, the holder, and answers with
// the Machine. Neither the key nor the grant is ever printed, except to mask the grant.
// key <key file> writes a new ed25519 seed, prints the holder
// holder <key file> prints the holder of a seed
// assemble <check-in.json> <key> <grant file> writes the Build Grant and masks it
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');

// An ed25519 PKCS#8 key is this fixed prefix and the 32-byte seed.
const pkcs8 = Buffer.from('302e020100300506032b657004220420', 'hex');
const holder = (seed) => {
const key = crypto.createPrivateKey({ key: Buffer.concat([pkcs8, seed]), format: 'der', type: 'pkcs8' });
return Buffer.from(crypto.createPublicKey(key).export({ format: 'jwk' }).x, 'base64url').toString('hex');
};
// Each refusal is a fixed line that names no secret.
class Refusal extends Error {}
const readSeed = (path) => {
const seed = fs.readFileSync(path);
if (seed.length !== 32) throw new Refusal('The Build Grant key is not 32 bytes.');
return seed;
};
// A fresh file, so 0600 holds even when one was already there.
const writeSecret = (path, data) => {
fs.rmSync(path, { force: true });
fs.writeFileSync(path, data, { mode: 0o600, flag: 'wx' });
};

// A parse error can quote the response, which holds build secrets: print only fixed lines.
try {
const [command, ...args] = process.argv.slice(2);
if (command === 'key') {
const { privateKey } = crypto.generateKeyPairSync('ed25519');
const seed = Buffer.from(privateKey.export({ format: 'jwk' }).d, 'base64url');
writeSecret(args[0], seed);
console.log(holder(seed));
} else if (command === 'holder') {
console.log(holder(readSeed(args[0])));
} else if (command === 'assemble') {
let checkIn;
try {
checkIn = JSON.parse(fs.readFileSync(args[0], 'utf8'));
} catch {
throw new Refusal('Check-in response has no Build Grant.');
}
let grant = checkIn.grant;
// A Machine that mints for a holder answers with its own identity, never the grant. Older ones send the grant.
if (checkIn.machine !== undefined) {
if (!/^[0-9a-f]{64}$/.test(checkIn.machine)) throw new Refusal('Check-in response has no valid Machine.');
const seed = readSeed(args[1]);
grant = 'ployzgrant1:' + Buffer.concat([Buffer.from(checkIn.machine, 'hex'), seed]).toString('base64url');
}
if (typeof grant !== 'string' || !grant.startsWith('ployzgrant1:')) throw new Refusal('Check-in response has no Build Grant.');
console.log(`::add-mask::${grant}`);
writeSecret(args[2], grant);
} else {
process.exit(2);
}
} catch (error) {
// stderr, so a caller that captures stdout still shows it in the job log.
console.error(`::error::${error instanceof Refusal ? error.message : 'The Build Grant key could not be made or read.'}`);
process.exit(1);
}
19 changes: 15 additions & 4 deletions prepare.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ set -euo pipefail

# shellcheck source=oidc.sh
source "$(dirname "$0")/oidc.sh"
grant_js="$(dirname "$0")/grant.js"
work="$RUNNER_TEMP/ployz-build"
fail() {
echo "::error::$1"
exit 1
}

[[ "$(uname -s)" == Linux ]] || fail "Ployz builds need a Linux runner."
command -v node >/dev/null || fail "Ployz builds need Node.js on the runner."
# A URL path segment: never "." or "..".
[[ "$PLOYZ_BUILD_ID" =~ ^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$ ]] || fail "Invalid build id."
cloud=${PLOYZ_CLOUD%/}
Expand All @@ -34,21 +36,30 @@ if ! has_containerd_store; then
has_containerd_store || fail "Docker's containerd image store could not be enabled."
fi

# The Build Grant key never leaves the runner: Cloud mints the grant for its public half, the holder.
holder=$(node "$grant_js" key "$work/key")
jq -n --arg holder "$holder" '{holder: $holder}' >"$work/holder.json"

# GitHub's OIDC token proves this run to Cloud; its audience is the Cloud origin.
oidc_token "$cloud"

cloud_post "$cloud/api/builds/$PLOYZ_BUILD_ID/check-in" "$work/check-in.json" ||
cloud_post "$cloud/api/builds/$PLOYZ_BUILD_ID/check-in" "$work/check-in.json" \
-H "Content-Type: application/json" --data-binary "@$work/holder.json" ||
fail "Ployz Cloud refused the check-in for build $PLOYZ_BUILD_ID ($cloud_error)."

# Mask the grant and every build secret before anything can print them. Masks are per line.
# Lines under 4 characters stay visible: masking a value like `0` would star out every 0 in the log.
jq -r '.grant, (.deployment.snapshots[]?.resolvedEnv // {} | .[])' "$work/check-in.json" |
node "$grant_js" assemble "$work/check-in.json" "$work/key" "$work/grant" || exit 1
jq -r '.deployment.snapshots[]?.resolvedEnv // {} | .[]' "$work/check-in.json" |
while IFS= read -r line; do
if [[ ${#line} -ge 4 ]]; then echo "::add-mask::$line"; fi
done
# Cloud answered with the grant, not the Machine: one of them predates runner-held keys. Such a grant opens no Sealed
# values, so the Machine refuses `/ployz/sealed` and a build that reads one fails closed.
if ! jq -e 'has("machine")' "$work/check-in.json" >/dev/null; then
echo "::notice::This build used a Cloud-held Build Grant."
fi

jq -e '.grant | type == "string" and startswith("ployzgrant1:")' "$work/check-in.json" >/dev/null ||
fail "Check-in response has no Build Grant."
commit=$(jq -r '.commit' "$work/check-in.json")
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "Check-in response has no commit."
[[ "$(jq -r '.fingerprint' "$work/check-in.json")" =~ ^[0-9a-f]{64}$ ]] ||
Expand Down
Loading
Loading