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
31 changes: 29 additions & 2 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ on:
required: true
type: boolean
default: true
named_keys_v1:
description: 'Turn on named keys (TC-1777): sets NAMED_KEYS_V1 in the sealed Phala env. false keeps the routes off.'
required: false
type: boolean
default: false
image_version:
description: 'Image version without leading v. Builds must match the selected crate; digest promotions must match the recorded image version (rollback_image_version when supplied).'
required: false
Expand Down Expand Up @@ -686,9 +691,10 @@ jobs:
reviewed = native + '@sha256:4bdae4fc232d886bb56bfbce4ec62ed4b8fb4e1369a01f6afb73d06dd68c188f'
# TC-959: main's N0 compose (#349 c33c2302…) with the 95m ingress body
# cap (raw d95bfb70…), the TC-1274 + TC-1317 + TC-1482 + c_index + TC-1840 + TC-1843 Vector config and the
# optional TC-1482 NODE_STATS_READ_TOKEN env line and the TC-950 pool of 10 (raw 192dfb7e…),
# optional TC-1482 NODE_STATS_READ_TOKEN env line, the TC-950 pool of 10 and the TC-1777 named-keys gate
# env lines + deletion-ledger volume (raw 298ea356…; 192dfb7e… before TC-1777),
# its single node digest normalized to 64 zeros.
n0_template_hash = '8df7e0f258f6e343d7683b1bd538291a64f6365d8174e7bfe2bf25dfa68160b3'
n0_template_hash = 'c9bcbea9dac90fc2b74d8439146596ba802796a211d1b6d65aeda2286d7ed028'
node_pin = re.compile(rb'(?m)^ image: ghcr\.io/tinycloudlabs/tinycloud-node@sha256:([0-9a-f]{64})$')
def n0_template(raw):
pins = list(node_pin.finditer(raw))
Expand Down Expand Up @@ -802,13 +808,22 @@ jobs:
TINYCLOUD_SHARE_EMAIL__TRUST_BUNDLE_BASE64: ${{ secrets.PROD_TINYCLOUD_SHARE_TRUST_BUNDLE_BASE64 }}
TINYCLOUD_SHARE_EMAIL__POSTGRES_TLS__SSLMODE: verify-full
TINYCLOUD_KEYS__TYPE: Dstack
TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1: ${{ inputs.named_keys_v1 }}
run: |
set -euo pipefail
IMAGE="$(sed -n -E 's/^ image: (ghcr.io\/tinycloudlabs\/tinycloud-node(:[^[:space:]]+|@sha256:[0-9a-f]{64}))$/\1/p' docker-compose.dstack-postgres.yaml)"
if [ "$(printf '%s\n' "${IMAGE}" | awk 'NF { count += 1 } END { print count + 0 }')" != "1" ]; then
echo "::error::Expected exactly one resolved TinyCloud Node image for runtime preflight"
exit 1
fi
# TC-1777: validate the same named-keys gate and ledger path the sealed
# deployment will carry. The path is read from the compose being deployed
# (absent from the pre-N0 rollback compose, where it is not passed).
LEDGER_PATH="$(sed -n -E 's/^ TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH: ([^[:space:]]+)$/\1/p' docker-compose.dstack-postgres.yaml)"
ledger_args=()
if [ -n "${LEDGER_PATH}" ]; then
ledger_args=(-e "TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH=${LEDGER_PATH}")
fi
docker run --rm --network none --read-only --cap-drop ALL \
--security-opt no-new-privileges \
-e TINYCLOUD_STORAGE__DATABASE \
Expand All @@ -817,6 +832,8 @@ jobs:
-e TINYCLOUD_SHARE_EMAIL__TRUST_BUNDLE_BASE64 \
-e TINYCLOUD_SHARE_EMAIL__POSTGRES_TLS__SSLMODE \
-e TINYCLOUD_KEYS__TYPE \
-e TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1 \
${ledger_args[@]+"${ledger_args[@]}"} \
"${IMAGE}" --validate-config

# TC-959: only env_keys/encrypted_env may change here. Never copy the
Expand Down Expand Up @@ -953,6 +970,7 @@ jobs:
PHALA_ENV_CERTBOT_EMAIL: ${{ secrets.PROD_TINYCLOUD_CERTBOT_EMAIL }}
PHALA_ENV_BETTERSTACK_SOURCE_TOKEN: ${{ secrets.PROD_TINYCLOUD_BETTERSTACK_SOURCE_TOKEN }}
PHALA_ENV_SQL_IDENTITY_FENCE: ${{ inputs.sql_identity_fence }}
PHALA_ENV_NAMED_KEYS_V1: ${{ inputs.named_keys_v1 }}
# Update the existing prod CVM (name: tinycloud-node) in the
# Tiny Cloud workspace. The PHALA_CLOUD_API_KEY secret is
# workspace-scoped and must be issued from that workspace.
Expand All @@ -978,6 +996,7 @@ jobs:
PHALA_ENV_CERTBOT_EMAIL
PHALA_ENV_BETTERSTACK_SOURCE_TOKEN
PHALA_ENV_SQL_IDENTITY_FENCE
PHALA_ENV_NAMED_KEYS_V1
)

for var in "${required_vars[@]}"; do
Expand All @@ -991,6 +1010,13 @@ jobs:
fi
done

# TC-1777: the named-keys gate is written into the sealed env as-is,
# so anything but a literal true/false is refused before it is written.
if [ "${PHALA_ENV_NAMED_KEYS_V1}" != "true" ] && [ "${PHALA_ENV_NAMED_KEYS_V1}" != "false" ]; then
echo "::error::named_keys_v1 must be exactly true or false"
exit 1
fi

# Optional (TC-1482): the same single-line rule, but absence is allowed.
if [[ "${PHALA_ENV_NODE_STATS_READ_TOKEN:-}" == *$'\n'* || "${PHALA_ENV_NODE_STATS_READ_TOKEN:-}" == *$'\r'* ]]; then
echo "::error::Phala deploy inputs must be single-line values"
Expand Down Expand Up @@ -1035,6 +1061,7 @@ jobs:
printf 'CERTBOT_EMAIL=%s\n' "$PHALA_ENV_CERTBOT_EMAIL"
printf 'BETTERSTACK_SOURCE_TOKEN=%s\n' "$PHALA_ENV_BETTERSTACK_SOURCE_TOKEN"
printf 'SQL_IDENTITY_FENCE=%s\n' "$PHALA_ENV_SQL_IDENTITY_FENCE"
printf 'NAMED_KEYS_V1=%s\n' "$PHALA_ENV_NAMED_KEYS_V1"
# TC-1482: optional. Unset keeps the node starting with
# GET /admin/stats disabled (404); never written empty.
if [ -n "${PHALA_ENV_NODE_STATS_READ_TOKEN:-}" ]; then
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.dstack-postgres.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ services:
- "8000:8000"
volumes:
- /var/run/dstack.sock:/var/run/dstack.sock
- deletion-ledger:/var/lib/tinycloud/ledger
environment:
TINYCLOUD_KEYS_TYPE: Dstack
TINYCLOUD_STORAGE_DATABASE: "${DATABASE_URL}"
Expand Down Expand Up @@ -91,6 +92,12 @@ services:
TINYCLOUD_QUOTA_URL: "${TINYCLOUD_QUOTA_URL}"
TINYCLOUD_ADMIN_SECRET: "${TINYCLOUD_ADMIN_SECRET}"
NODE_STATS_READ_TOKEN: "${NODE_STATS_READ_TOKEN:-}"
# TC-1777: named-keys release gate. Set by the docker.yml named_keys_v1
# workflow input (default false); never hand-edit the CVM env.
TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1: "${NAMED_KEYS_V1:-false}"
# Append-only record of deleted keys, on the deletion-ledger volume so it
# survives container replacement. Required by the node while the gate is on.
TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH: /var/lib/tinycloud/ledger/deletions.jsonl
ROCKET_ADDRESS: "0.0.0.0"
ROCKET_MAX_BLOCKING: "1024"

Expand Down Expand Up @@ -180,6 +187,7 @@ services:

volumes:
cert-data:
deletion-ledger:

networks:
logship:
Expand Down
8 changes: 4 additions & 4 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,8 +499,8 @@ Before any CVM mutation the workflow requires:

- the main compose, with its one node digest replaced by 64 zeros, to hash
to the reviewed N0 template
**`8df7e0f258f6e343d7683b1bd538291a64f6365d8174e7bfe2bf25dfa68160b3`**
(raw `192dfb7e407fa591d4f0232b95e43a41a85dd18add1d4976e70ebdbff5ddebca` normalized; before the TC-1843 per-minute caps on 4xx and 5xx request events and on the 5xx diagnostic, and the `errors_dropped` event (the dropped count), in the Vector config the template was `7c3456dc…`, raw `d0e72bde…`; before the TC-1840 `db_failure_5xx` event (the node's 5xx diagnostic line shipped as an individual event) in the Vector config the template was `c91e5b7a…`, raw `99013c40…`; before the TC-950 pool of 10 (`TINYCLOUD_DATABASE__MAX_CONNECTIONS` 15 to 10) the template was `79469f97…`, raw `32a002ef…`; before the three `current_kv_c_index_*` warning codes in the Vector config (TC-1274 `current_kv` C-index migration) the template was `87b5d12a…`, raw `d1fcd665…`; before the optional TC-1482 `NODE_STATS_READ_TOKEN` env line and the TC-1482 route in the Vector config the template was `7fabba70…`, raw `1228d81927fc0a3fbba85745103e77b3112606f5d314d15974d400b222409350`: the 1.21.0 compose
**`c9bcbea9dac90fc2b74d8439146596ba802796a211d1b6d65aeda2286d7ed028`**
(raw `298ea356adf8d5e562557ab18bd3f5d8cdb816de82b1eaf6e6e50bf888e22765` normalized; before the TC-1777 named-keys gate env lines (`TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1`, `…__DELETION_LEDGER_PATH`) and the `deletion-ledger` volume the template was `8df7e0f2…`, raw `192dfb7e407fa591d4f0232b95e43a41a85dd18add1d4976e70ebdbff5ddebca`; before the TC-1843 per-minute caps on 4xx and 5xx request events and on the 5xx diagnostic, and the `errors_dropped` event (the dropped count), in the Vector config the template was `7c3456dc…`, raw `d0e72bde…`; before the TC-1840 `db_failure_5xx` event (the node's 5xx diagnostic line shipped as an individual event) in the Vector config the template was `c91e5b7a…`, raw `99013c40…`; before the TC-950 pool of 10 (`TINYCLOUD_DATABASE__MAX_CONNECTIONS` 15 to 10) the template was `79469f97…`, raw `32a002ef…`; before the three `current_kv_c_index_*` warning codes in the Vector config (TC-1274 `current_kv` C-index migration) the template was `87b5d12a…`, raw `d1fcd665…`; before the optional TC-1482 `NODE_STATS_READ_TOKEN` env line and the TC-1482 route in the Vector config the template was `7fabba70…`, raw `1228d81927fc0a3fbba85745103e77b3112606f5d314d15974d400b222409350`: the 1.21.0 compose
`d95bfb707871c1d626c4a8f2b8d32d354103edbb5077847c6a21fe5f7f6cfa50`, template
`51f8e9e9…`, with only the embedded Vector config regenerated for TC-1274's,
TC-1317's and TC-1482's route and warning codes (TC-1274 and TC-1317 were template `7fabba70…`, raw `1228d819…`); TC-1274 alone was template `00938226…`, raw
Expand Down Expand Up @@ -594,8 +594,8 @@ gh workflow run docker.yml -R TinyCloudLabs/tinycloud-node --ref main \

The served/OCI identity remains **1.20.6-dstack / e18578ff9162986ddd813259c60f73866e3e60d1**.
With the template lock on main this redeploys exactly main's raw compose
`192dfb7e407fa591d4f0232b95e43a41a85dd18add1d4976e70ebdbff5ddebca`
(the 1.21.0 compose `d95bfb70…` with the TC-1274, TC-1317, TC-1482, `current_kv_c_index_*`, `db_failure_5xx`, the 4xx and 5xx caps and `errors_dropped` Vector config, the optional TC-1482 `NODE_STATS_READ_TOKEN` env line and the TC-950 pool of 10), whatever digest
`298ea356adf8d5e562557ab18bd3f5d8cdb816de82b1eaf6e6e50bf888e22765`
(the 1.21.0 compose `d95bfb70…` with the TC-1274, TC-1317, TC-1482, `current_kv_c_index_*`, `db_failure_5xx`, the 4xx and 5xx caps and `errors_dropped` Vector config, the optional TC-1482 `NODE_STATS_READ_TOKEN` env line, the TC-950 pool of 10, and the TC-1777 `NAMED_KEYS_V1` gate env lines with the `deletion-ledger` volume), whatever digest
main's template currently pins. It is not a rollback of a
later release across a schema migration. Current pre-N0 tag/pinned
equivalents are separately hash-checked; arbitrary same-image config
Expand Down
8 changes: 8 additions & 0 deletions scripts/ci/fixtures/tc959-n0-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ services:
- "8000:8000"
volumes:
- /var/run/dstack.sock:/var/run/dstack.sock
- deletion-ledger:/var/lib/tinycloud/ledger
environment:
TINYCLOUD_KEYS_TYPE: Dstack
TINYCLOUD_STORAGE_DATABASE: "${DATABASE_URL}"
Expand Down Expand Up @@ -91,6 +92,12 @@ services:
TINYCLOUD_QUOTA_URL: "${TINYCLOUD_QUOTA_URL}"
TINYCLOUD_ADMIN_SECRET: "${TINYCLOUD_ADMIN_SECRET}"
NODE_STATS_READ_TOKEN: "${NODE_STATS_READ_TOKEN:-}"
# TC-1777: named-keys release gate. Set by the docker.yml named_keys_v1
# workflow input (default false); never hand-edit the CVM env.
TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1: "${NAMED_KEYS_V1:-false}"
# Append-only record of deleted keys, on the deletion-ledger volume so it
# survives container replacement. Required by the node while the gate is on.
TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH: /var/lib/tinycloud/ledger/deletions.jsonl
ROCKET_ADDRESS: "0.0.0.0"
ROCKET_MAX_BLOCKING: "1024"

Expand Down Expand Up @@ -180,6 +187,7 @@ services:

volumes:
cert-data:
deletion-ledger:

networks:
logship:
Expand Down
64 changes: 64 additions & 0 deletions scripts/ci/test-deploy-recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ def event(value):
print(os.environ['OCI_REVISION'] if 'revision' in args[-1] else os.environ['OCI_LABEL'])
elif args[0]=='run':
assert args[-1]=='--validate-config' and 'none' in args and '--read-only' in args; event('sealed-preflight')
passed = dict(a.split('=', 1) if '=' in a else (a, os.environ.get(a, '')) for a in (args[i + 1] for i, x in enumerate(args) if x == '-e'))
event('preflight-named-keys=' + passed.get('TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1', '<absent>') + ' ledger=' + passed.get('TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH', '<absent>'))
# Stands for the node's startup rule: named keys on needs an absolute deletion ledger path.
if passed.get('TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1') == 'true' and not passed.get('TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH', '').startswith('/'):
print('encryption_network.deletion_ledger_path is required when named_keys_v1 is on', file=sys.stderr); sys.exit(1)
elif args[0]=='compose':
assert args[-2:]==['config','--quiet']; assert Path('.phala-prod.env').stat().st_mode & 0o777==0o600
else: raise SystemExit('Unexpected Docker boundary')
Expand All @@ -125,6 +130,7 @@ def event(value):
names=[line.split('=',1)[0] for line in Path('.phala-prod.env').read_text().splitlines()]
assert 'BETTERSTACK_SOURCE_TOKEN' in names and 'QUOTA_BILLING_ADMIN_SECRET' not in names
data=json.loads(state.read_text()); data['values']['env_names']=names; state.write_text(json.dumps(data))
sealed=dict(row.split('=',1) for row in Path('.phala-prod.env').read_text().splitlines()); event('sealed-named-keys-v1='+sealed.get('NAMED_KEYS_V1','<absent>'))
if args[-1]=='--check':
event('whitelist-check')
# What sync.mjs does when the CVM's allowed_envs names a variable the sealed file lacks.
Expand Down Expand Up @@ -279,6 +285,8 @@ def run_deploy(self, compose=CURRENT, changes=None, *, fail_at="", artifact_erro
env["PHALA_ENV_NODE_STATS_READ_TOKEN"] = stats_token
if stats_token and stats_token != canary: CANARIES.append(stats_token)
if missing_billing: env.pop("QUOTA_BILLING_ADMIN_SECRET")
env["PHALA_ENV_NAMED_KEYS_V1"] = inputs.get("named_keys_v1", "false")
env["TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1"] = inputs.get("named_keys_v1", "false")
context = {"github.event_name": event, "github.ref": ref, "job.success": True,
**{"inputs." + key: (value == "true" if value in ("true", "false") else value) for key, value in inputs.items()}}
logs = ""
Expand Down Expand Up @@ -559,6 +567,62 @@ def test_unreleased_digest_and_captured_rollback_are_not_substitutable(self):
self.assertNotIn("cvm-deploy", result["events"])
self.assertEqual(result["compose"], N0)

def test_named_keys_gate_is_sealed_as_a_literal_flag_beside_the_ssh_keys(self):
# TC-1777: the flip is a workflow input, so the sealed file (the only CVM env source)
# carries the flag and keeps DSTACK_AUTHORIZED_KEYS on every dispatch.
for value in ("false", "true"):
with self.subTest(value=value):
result = self.run_deploy(changes={"named_keys_v1": value})
self.assertTrue(result["success"])
self.assertEqual(result["env_names"].count("NAMED_KEYS_V1"), 1)
self.assertEqual(result["env_names"].count("DSTACK_AUTHORIZED_KEYS"), 1)
sealed = {event for event in result["events"] if event.startswith("sealed-named-keys-v1=")}
self.assertEqual(sealed, {"sealed-named-keys-v1=" + value})

def test_named_keys_gate_other_than_true_or_false_is_refused_before_the_file(self):
for value in ("", "TRUE", "1", "yes", "maybe", "true "):
with self.subTest(value=value):
refused = self.run_deploy(changes={"named_keys_v1": value})
self.assertFalse(refused["success"])
self.assertEqual(refused["events"].count("cvm-deploy"), 0, "A bad flag must fail before the CVM mutation")
self.assertNotIn("whitelist-check", refused["events"], "The sealed file must not be written first")
if value:
self.assertIn("::error::named_keys_v1 must be exactly true or false", refused["logs"])

def run_preflight(self, compose, named_keys):
# Only the preflight step, against the fake docker: the compose it reads is the test input.
with tempfile.TemporaryDirectory() as directory:
root = Path(directory); binaries = root / "bin"; binaries.mkdir()
binary = binaries / "docker"; binary.write_text(CLI); binary.chmod(0o700)
(root / "docker-compose.dstack-postgres.yaml").write_bytes(compose)
state = root / "state"; state.write_text(json.dumps({"events": [], "values": {}}))
env = {"PATH": str(binaries) + ":" + str(Path(sys.executable).parent) + ":/usr/bin:/bin", "TEST_STATE": str(state),
"TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1": named_keys}
result = subprocess.run(["bash", "--noprofile", "--norc", "-e", "-c", shell_for_step("Preflight sealed runtime configuration")],
cwd=root, env=env, capture_output=True, text=True)
return result.returncode, json.loads(state.read_text())["events"], result.stdout + result.stderr

def test_preflight_validates_the_named_keys_gate_with_the_composed_ledger_path(self):
ledger = "/var/lib/tinycloud/ledger/deletions.jsonl"
for value in ("false", "true"):
with self.subTest(value=value):
result = self.run_deploy(changes={"named_keys_v1": value})
self.assertTrue(result["success"])
self.assertIn(f"preflight-named-keys={value} ledger={ledger}", result["events"])

def test_preflight_refuses_the_gate_without_a_ledger_path_only_when_it_is_on(self):
no_path = CURRENT.replace(b" TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH: /var/lib/tinycloud/ledger/deletions.jsonl\n", b"")
self.assertNotEqual(no_path, CURRENT)
code, events, logs = self.run_preflight(no_path, "false")
self.assertEqual(code, 0, "Gate off without a path must keep passing")
self.assertIn("preflight-named-keys=false ledger=<absent>", events)
code, events, logs = self.run_preflight(no_path, "true")
self.assertNotEqual(code, 0, "Gate on without a ledger path must fail the preflight")
self.assertIn("preflight-named-keys=true ledger=<absent>", events)
self.assertIn("deletion_ledger_path is required", logs)
code, events, logs = self.run_preflight(CURRENT, "true")
self.assertEqual(code, 0, "Gate on with the composed ledger path must pass")

def test_last_restart_precedes_health_inventory_and_quota(self):
result = self.run_deploy(N0, {"rollback_pre_n0": "true", "rollback_privacy_mode": "keep-json-file"}, approved_privacy="keep-json-file")
self.assertTrue(result["success"])
Expand Down
2 changes: 1 addition & 1 deletion scripts/tc959-docker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ export async function main(args = process.argv.slice(2)) {
assert.equal(service.ports[0].host_ip ?? '0.0.0.0', '0.0.0.0');
}
phase = 'compose_mounts';
assert.deepEqual(production.tinycloud.volumes.map(value => [value.type, value.source, value.target]), [['bind', '/var/run/dstack.sock', '/var/run/dstack.sock']]);
assert.deepEqual(production.tinycloud.volumes.map(value => [value.type, value.source, value.target]), [['bind', '/var/run/dstack.sock', '/var/run/dstack.sock'], ['volume', 'deletion-ledger', '/var/lib/tinycloud/ledger']]);
assert.deepEqual(production['dstack-ingress'].volumes.map(value => [value.type, value.source, value.target]).sort((a, b) => a[2].localeCompare(b[2])), [['volume', 'cert-data', '/etc/letsencrypt'], ['bind', '/var/run/dstack.sock', '/var/run/dstack.sock']]);
phase = 'compose_networks';
assert.deepEqual(Object.keys(production.tinycloud.networks), ['default']);
Expand Down
Loading