Homelab Academy / Modules
Guided practice + break/fix + lab gate.
Module: Compose Operations & IaC
Activity type: Lab (Practice)
Lab risk: low
Objective: Explain why a mutable tag is unsuitable as the final deployment identity.
/opt/lab-classroom/class81/
Do not create, edit, move, or delete files outside /opt/lab-classroom/class81/ during this lab.
mkdir -p /opt/lab-classroom/class81/blobs && cd /opt/lab-classroom/class81 && python3 - <<'PY'
import json
from pathlib import Path
base = Path('/opt/lab-classroom/class81')
old = 'sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
candidate = 'sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
empty = 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
registry = {
'repository': 'registry.lab/academy/demo',
'channels': {'stable': 'index-v2', 'previous': 'index-v1'},
'indexes': {
'index-v1': {'manifests': [
{'platform': {'os': 'linux', 'architecture': 'amd64'}, 'digest': old, 'blob': 'blobs/previous-amd64.manifest'}
]},
'index-v2': {'manifests': [
{'platform': {'os': 'linux', 'architecture': 'amd64'}, 'digest': candidate, 'blob': 'blobs/candidate-amd64.manifest'},
{'platform': {'os': 'linux', 'architecture': 'arm64'}, 'digest': empty, 'blob': 'blobs/candidate-arm64.manifest'}
]}
}
}
policy = {'allowed': [
{'channel': 'stable', 'platform': 'linux/amd64', 'digest': candidate},
{'channel': 'stable', 'platform': 'linux/amd64', 'digest': old}
]}
deployment = {
'channel': 'stable',
'platform': 'linux/amd64',
'image': 'registry.lab/academy/demo@' + old
}
(base / 'blobs/previous-amd64.manifest').write_bytes(b'hello')
(base / 'blobs/candidate-amd64.manifest').write_bytes(b'abc')
(base / 'blobs/candidate-arm64.manifest').write_bytes(b'')
for name, value in [('registry.json', registry), ('policy.json', policy), ('deployment.json', deployment)]:
(base / name).write_text(json.dumps(value, indent=2, sort_keys=True) + '\n', encoding='utf-8')
(base / 'audit.log').write_text('', encoding='utf-8')
PY
cat > /opt/lab-classroom/class81/update.py <<'PY'
import argparse
import hashlib
import json
import os
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
BASE = Path('/opt/lab-classroom/class81')
DIGEST_RE = re.compile(r'^sha256:[0-9a-f]{64}$')
def load_json(name):
with (BASE / name).open('r', encoding='utf-8') as handle:
return json.load(handle)
def digest_bytes(data):
return 'sha256:' + hashlib.sha256(data).hexdigest()
def image_digest(image):
if '@' not in image:
raise ValueError('deployment image is not digest pinned')
value = image.rsplit('@', 1)[1]
if not DIGEST_RE.fullmatch(value):
raise ValueError('deployment image has an invalid digest')
return value
def append_audit(event):
with (BASE / 'audit.log').open('a', encoding='utf-8') as handle:
handle.write(json.dumps(event, sort_keys=True) + '\n')
handle.flush()
os.fsync(handle.fileno())
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--channel', required=True)
parser.add_argument('--platform', required=True)
parser.add_argument('--expected-digest', required=True)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
if not DIGEST_RE.fullmatch(args.expected_digest):
raise SystemExit('REJECT: malformed expected digest')
if args.platform.count('/') != 1:
raise SystemExit('REJECT: platform must be os/architecture')
wanted_os, wanted_arch = args.platform.split('/', 1)
registry = load_json('registry.json')
policy = load_json('policy.json')
deployment = load_json('deployment.json')
index_name = registry.get('channels', {}).get(args.channel)
if index_name is None:
raise SystemExit('REJECT: unknown channel')
index = registry.get('indexes', {}).get(index_name)
if not isinstance(index, dict):
raise SystemExit('REJECT: channel does not resolve to an index')
matches = [d for d in index.get('manifests', []) if d.get('platform') == {'os': wanted_os, 'architecture': wanted_arch}]
if len(matches) != 1:
raise SystemExit('REJECT: platform selection must produce exactly one manifest')
descriptor = matches[0]
selected_digest = descriptor.get('digest', '')
if not DIGEST_RE.fullmatch(selected_digest):
raise SystemExit('REJECT: selected descriptor digest is malformed')
blob_path = (BASE / descriptor.get('blob', '')).resolve()
if BASE.resolve() not in blob_path.parents:
raise SystemExit('REJECT: blob path escapes lab directory')
actual_digest = digest_bytes(blob_path.read_bytes())
if actual_digest != selected_digest:
raise SystemExit('REJECT: selected manifest bytes do not match the index descriptor')
if selected_digest != args.expected_digest:
raise SystemExit('REJECT: selected manifest does not match the out-of-band expected digest')
approved = any(item == {'channel': args.channel, 'platform': args.platform, 'digest': selected_digest} for item in policy.get('allowed', []))
if not approved:
raise SystemExit('REJECT: selected channel, platform, and digest tuple is not locally approved')
prior_digest = image_digest(deployment.get('image', ''))
new_deployment = {
'channel': args.channel,
'platform': args.platform,
'image': registry['repository'] + '@' + selected_digest
}
if args.dry_run:
print(json.dumps({'action': 'dry-run', 'index': index_name, 'selected_platform_manifest': selected_digest, 'from_digest': prior_digest, 'to_digest': selected_digest}, sort_keys=True))
return
target = BASE / 'deployment.json'
backup = BASE / 'deployment.backup.json'
temporary = BASE / 'deployment.json.tmp'
shutil.copy2(target, backup)
temporary.write_text(json.dumps(new_deployment, indent=2, sort_keys=True) + '\n', encoding='utf-8')
os.replace(temporary, target)
append_audit({
'action': 'update',
'channel': args.channel,
'platform': args.platform,
'from_digest': prior_digest,
'to_digest': selected_digest,
'time': datetime.now(timezone.utc).isoformat()
})
print('PROMOTED ' + new_deployment['image'])
if name == 'main':
main()
PY
cat > /opt/lab-classroom/class81/rollback.py <<'PY'
import argparse
import json
import os
import re
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
BASE = Path('/opt/lab-classroom/class81')
DIGEST_RE = re.compile(r'^sha256:[0-9a-f]{64}$')
def load(path):
with path.open('r', encoding='utf-8') as handle:
return json.load(handle)
def get_digest(document):
image = document.get('image', '')
if '@' not in image:
raise SystemExit('REJECT: image is not digest pinned')
digest = image.rsplit('@', 1)[1]
if not DIGEST_RE.fullmatch(digest):
raise SystemExit('REJECT: malformed image digest')
return digest
def append_audit(event):
with (BASE / 'audit.log').open('a', encoding='utf-8') as handle:
handle.write(json.dumps(event, sort_keys=True) + '\n')
handle.flush()
os.fsync(handle.fileno())
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--channel', required=True)
parser.add_argument('--platform', required=True)
parser.add_argument('--expected-digest', required=True)
args = parser.parse_args()
if not DIGEST_RE.fullmatch(args.expected_digest):
raise SystemExit('REJECT: malformed expected rollback digest')
target = BASE / 'deployment.json'
backup = BASE / 'deployment.backup.json'
if not backup.is_file():
raise SystemExit('REJECT: retained backup is unavailable')
current = load(target)
prior = load(backup)
current_digest = get_digest(current)
restore_digest = get_digest(prior)
if prior.get('channel') != args.channel or prior.get('platform') != args.platform:
raise SystemExit('REJECT: backup channel or platform does not match rollback approval')
if restore_digest != args.expected_digest:
raise SystemExit('REJECT: backup digest does not match the out-of-band rollback digest')
policy = load(BASE / 'policy.json')
approved = any(item == {'channel': args.channel, 'platform': args.platform, 'digest': restore_digest} for item in policy.get('allowed', []))
if not approved:
raise SystemExit('REJECT: rollback tuple is not locally approved')
failed_copy = BASE / ('deployment.failed.' + str(time.time_ns()) + '.json')
shutil.copy2(target, failed_copy)
temporary = BASE / 'deployment.rollback.tmp'
shutil.copy2(backup, temporary)
if get_digest(load(temporary)) != args.expected_digest:
raise SystemExit('REJECT: copied rollback candidate failed validation')
os.replace(temporary, target)
restored = load(target)
if get_digest(restored) != args.expected_digest:
raise SystemExit('REJECT: restored deployment failed post-replace validation')
append_audit({
'action': 'rollback',
'channel': args.channel,
'platform': args.platform,
'from_digest': current_digest,
'to_digest': restore_digest,
'failed_state_copy': failed_copy.name,
'time': datetime.now(timezone.utc).isoformat()
})
print('RESTORED ' + restored['image'])
if name == 'main':
main()
PY
Create the isolated registry model and programs
Run the setup_command.
Run the update_program_command.
Run the rollback_program_command.
cd /opt/lab-classroom/class81
Inspect the index and platform descriptors
python3 -m json.tool /opt/lab-classroom/class81/registry.json
python3 -m json.tool /opt/lab-classroom/class81/deployment.json
stable resolves to index-v2. That index points separately to linux/amd64 and linux/arm64 manifests. The current deployment is pinned to the previous linux/amd64 digest rather than to stable.
Verify the published golden vectors independently
cd /opt/lab-classroom/class81 && test "$(sha256sum blobs/previous-amd64.manifest | awk '{print $1}')" = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
cd /opt/lab-classroom/class81 && test "$(sha256sum blobs/candidate-amd64.manifest | awk '{print $1}')" = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
cd /opt/lab-classroom/class81 && test "$(sha256sum blobs/candidate-arm64.manifest | awk '{print $1}')" = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
Prove dry-run does not mutate deployment or audit state
cd /opt/lab-classroom/class81 && cp deployment.json deployment.before-dry-run.json && cp audit.log audit.before-dry-run.log
cd /opt/lab-classroom/class81 && python3 update.py --channel stable --platform linux/amd64 --expected-digest sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad --dry-run
cd /opt/lab-classroom/class81 && cmp deployment.json deployment.before-dry-run.json && cmp audit.log audit.before-dry-run.log && test ! -e deployment.backup.json
Prove a simultaneous catalog, content, and policy substitution is rejected
cd /opt/lab-classroom/class81 && cp registry.json registry.before-tamper.json && cp policy.json policy.before-tamper.json && cp blobs/candidate-amd64.manifest candidate.before-tamper.manifest && cp deployment.json deployment.before-rejection.json && cp audit.log audit.before-rejection.log
cd /opt/lab-classroom/class81 && python3 - <<'PY'
import json
from pathlib import Path
base = Path('/opt/lab-classroom/class81')
old = 'sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
new = 'sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'
(base / 'blobs/candidate-amd64.manifest').write_bytes(b'test')
for name in ('registry.json', 'policy.json'):
path = base / name
path.write_text(path.read_text(encoding='utf-8').replace(old, new), encoding='utf-8')
PY
cd /opt/lab-classroom/class81 && if python3 update.py --channel stable --platform linux/amd64 --expected-digest sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad; then echo 'ERROR: substitution was accepted'; exit 1; else echo 'Expected rejection observed'; fi
cd /opt/lab-classroom/class81 && cmp deployment.json deployment.before-rejection.json && cmp audit.log audit.before-rejection.log && test ! -e deployment.backup.json
cd /opt/lab-classroom/class81 && cp registry.before-tamper.json registry.json && cp policy.before-tamper.json policy.json && cp candidate.before-tamper.manifest blobs/candidate-amd64.manifest
The substituted bytes and descriptor are internally consistent, and local policy was changed to approve them. Promotion still fails because the selected digest differs from the independently supplied expected digest.
Perform the controlled update
cd /opt/lab-classroom/class81 && python3 update.py --channel stable --platform linux/amd64 --expected-digest sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
cd /opt/lab-classroom/class81 && python3 -m json.tool deployment.json
cd /opt/lab-classroom/class81 && python3 -m json.tool deployment.backup.json
cd /opt/lab-classroom/class81 && cat audit.log
| Symptom | Likely cause | Fix |
|---|---|---|
| The golden digest test fails for candidate-amd64.manifest. | The file contains a newline, different capitalization, or bytes other than the exact three-byte sequence abc. | Run the setup command again or use Python to write b'abc' exactly to /opt/lab-classroom/class81/blobs/candidate-amd64.manifest. |
| update.py reports that the selected manifest bytes do not match the index descriptor. | A blob or its descriptor was changed without restoring the original test files. | Copy registry.before-tamper.json to registry.json and candidate.before-tamper.manifest to blobs/candidate-amd64.manifest, then rerun the golden digest checks. |
| update.py reports that the selected manifest does not match the out-of-band expected digest. | The command supplied a digest for another release or platform, or the catalog was intentionally substituted. | Do not weaken the check. Confirm the reviewed release digest and requested platform, restore the intended catalog for the lab, and rerun with the literal approved digest. |
| The platform selection does not produce exactly one manifest. | The requested platform is absent or the index contains duplicate descriptors for the same operating system and architecture. | Inspect registry.json and require one unambiguous descriptor for the requested platform. Do not silently select the first duplicate. |
| The rejection test unexpectedly creates deployment.backup.json. | The update program was modified so validation occurs after mutation, or a backup from an earlier successful update already exists. | Recreate the lab from the supplied setup and update program before the rejection test. Confirm every validation occurs before the backup and replacement operations. |
| Rollback reports that the retained backup is unavailable. | No successful update has occurred or the backup was manually moved. | Complete the controlled update first. Rollback must fail closed when the expected retained backup is absent. |
| An audit assertion reports more than one update event. | The successful update was executed multiple times without recreating the lab. | Inspect audit.log to understand the history. For the exact lesson assertion, rerun setup to begin with an empty audit log and perform one successful update. |
Deployment by immutable repository@sha256 reference instead of a mutable channel or tag.
Explicit index-to-platform-manifest resolution with rejection of missing or ambiguous platform matches.
Hash verification of selected manifest bytes against the selected index descriptor.
Required out-of-band expected channel, platform, and digest.
Exact local policy matching of the channel, platform, and selected manifest digest tuple.
Validation before mutation, atomic deployment replacement, retained backup state, and digest-linked audit events.
Independent golden values for known byte sequences and explicit unchanged-state assertions.
The update rejects an unknown channel.
The update rejects a missing, malformed, or ambiguous platform selection.
The update rejects malformed digests and blob paths that escape the lab directory.
The update rejects when selected bytes disagree with the index descriptor.
The update rejects when the selected manifest differs from the out-of-band expected digest.
The update rejects when the exact channel, platform, and digest tuple is absent from local policy.
Rollback rejects a missing backup, an unpinned image, a tuple mismatch, an out-of-band digest mismatch, or a locally unapproved restoration.
A self-consistent catalog digest is not publisher trust. An attacker controlling both content and its catalog digest can produce a valid hash relationship.
A self-consistent catalog hash is not a digital signature, does not identify the publisher, does not prove build provenance, and does not by itself enforce an approved digest.
The out-of-band expected digest detects substitutions that change the selected digest while the approved invocation remains trustworthy. It cannot detect malicious replacement of content with different bytes having the same SHA-256 digest, compromise of the approval source, or an operator intentionally supplying an attacker-approved digest.
The local policy check detects tuples absent from policy, but it cannot provide independent approval if policy.json and the invoking authority are both compromised.
This lab does not verify registry transport security, signatures, attestations, transparency logs, vulnerability status, image configuration, runtime behavior, or the authenticity of update.py itself.
The miniature manifest blobs are SHA-256 teaching vectors rather than runnable OCI JSON manifests. The index-selection and digest-pinning logic model the identity decisions without claiming to implement a complete registry client.
Production systems should combine digest pinning with protected change control, authenticated registries, signature or attestation verification, least privilege, monitored audit storage, and tested recovery procedures.
Complete the successful controlled update so deployment.json contains the candidate digest and deployment.backup.json contains the previous approved digest.
cd /opt/lab-classroom/class81 && python3 rollback.py --channel stable --platform linux/amd64 --expected-digest sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
cd /opt/lab-classroom/class81 && python3 - <<'PY'
import json
from pathlib import Path
base = Path('/opt/lab-classroom/class81')
old = 'sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
new = 'sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
deployment = json.loads((base / 'deployment.json').read_text())
backup = json.loads((base / 'deployment.backup.json').read_text())
failed = list(base.glob('deployment.failed.*.json'))
events = [json.loads(line) for line in (base / 'audit.log').read_text().splitlines() if line]
rollbacks = [event for event in events if event.get('action') == 'rollback']
assert deployment['image'] == 'registry.lab/academy/demo@' + old
assert backup['image'] == 'registry.lab/academy/demo@' + old
assert failed
assert any(json.loads(path.read_text())['image'] == 'registry.lab/academy/demo@' + new for path in failed)
assert len(rollbacks) == 1
assert rollbacks[0]['from_digest'] == new
assert rollbacks[0]['to_digest'] == old
assert (base / rollbacks[0]['failed_state_copy']).is_file()
print('rollback, retained backup, failed-state evidence, and audit linkage verified')
PY
The currently deployed candidate document is copied to a timestamped deployment.failed file before restoration.
The backup's channel, platform, pin format, out-of-band expected digest, and local approval are validated before restoration.
The backup is copied to a temporary restoration file rather than moved, so deployment.backup.json remains available.
The temporary copy is validated before atomic replacement, and deployment.json is validated again after replacement.
The rollback audit event is appended only after the restored deployment has passed post-replacement validation.
The rollback audit event links the failed candidate digest to the restored prior digest.