Homelab Academy / Modules
Guided practice + break/fix + lab gate.
Module: ARR Data Model & Compose
Activity type: Lab (Practice)
Lab risk: low
Objective: Explain why a configuration archive alone is not a complete recovery strategy.
Create and test a configuration backup lifecycle without writing outside /opt/lab-classroom/class39/.
Every filesystem mutation in this lab is explicitly located beneath /opt/lab-classroom/class39/. The attempt1 directory is created without a forceful cleanup operation; if it already exists, stop and choose a new attempt directory under the same classroom root after reviewing the previous work.
Create a fresh workspace, sample configuration, and semantic validator:
BASE=/opt/lab-classroom/class39/attempt1
mkdir -p /opt/lab-classroom/class39
mkdir "$BASE"
mkdir "$BASE/config" "$BASE/backups" "$BASE/staging" "$BASE/restore" "$BASE/rollback"
cat > "$BASE/config/app.json" <<'EOF'
{
"service_name": "inventory",
"listen_port": 8080,
"feature_enabled": true
}
EOF
cat > "$BASE/config/service.ini" <<'EOF'
[service]
workers = 4
log_level = INFO
EOF
cat > "$BASE/validate.py" <<'PY'
import configparser
import json
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
with (root / "app.json").open(encoding="utf-8") as handle:
app = json.load(handle)
required = {"service_name", "listen_port", "feature_enabled"}
missing = required.difference(app)
if missing:
raise SystemExit(f"missing JSON keys: {sorted(missing)}")
if app["service_name"] != "inventory":
raise SystemExit("unexpected service_name")
if not isinstance(app["listen_port"], int) or isinstance(app["listen_port"], bool):
raise SystemExit("listen_port must be an integer")
if not 1024 <= app["listen_port"] <= 65535:
raise SystemExit("listen_port is outside the approved range")
if not isinstance(app["feature_enabled"], bool):
raise SystemExit("feature_enabled must be Boolean")
parser = configparser.ConfigParser()
loaded = parser.read(root / "service.ini", encoding="utf-8")
if len(loaded) != 1 or not parser.has_section("service"):
raise SystemExit("service.ini is missing the service section")
workers = parser.getint("service", "workers")
if not 1 <= workers <= 32:
raise SystemExit("workers is outside the approved range")
if parser.get("service", "log_level") not in {"DEBUG", "INFO", "WARNING", "ERROR"}:
raise SystemExit("unsupported log_level")
print(f"validated configuration at {root}")
PY
python3 "$BASE/validate.py" "$BASE/config"
Assemble the package, generate a manifest, record metadata, create the archive, and generate a checksum for the archive:
BASE=/opt/lab-classroom/class39/attempt1
mkdir "$BASE/staging/package"
cp -a "$BASE/config" "$BASE/staging/package/config"
printf '%s\n' 'class_id=39' 'backup_version=1' 'source=config' 'format=tar-gzip' > "$BASE/staging/package/BACKUP_INFO.txt"
(
cd "$BASE/staging/package"
find config -type f -print0 | sort -z | xargs -0 sha256sum > MANIFEST.sha256
)
tar -C "$BASE/staging/package" -czf "$BASE/backups/config-backup-v1.tar.gz" .
sha256sum "$BASE/backups/config-backup-v1.tar.gz" > "$BASE/backups/config-backup-v1.tar.gz.sha256"
Verify the backup before simulating any failure:
BASE=/opt/lab-classroom/class39/attempt1
sha256sum -c "$BASE/backups/config-backup-v1.tar.gz.sha256"
tar -tzf "$BASE/backups/config-backup-v1.tar.gz"
(
cd "$BASE/staging/package"
sha256sum -c MANIFEST.sha256
)
python3 "$BASE/validate.py" "$BASE/staging/package/config"
Introduce configuration drift that remains syntactically and semantically valid, then demonstrate that only comparison with the trusted backup manifest detects it:
BASE=/opt/lab-classroom/class39/attempt1
cat > "$BASE/config/app.json" <<'EOF'
{
"service_name": "inventory",
"listen_port": 9090,
"feature_enabled": false
}
EOF
python3 "$BASE/validate.py" "$BASE/config"
mkdir "$BASE/restore/candidate"
tar -C "$BASE/restore/candidate" -xzf "$BASE/backups/config-backup-v1.tar.gz"
if (
cd "$BASE"
sha256sum -c restore/candidate/MANIFEST.sha256
); then
echo 'ERROR: drift was not detected'
exit 1
else
echo 'Expected result: active configuration differs from the backup manifest'
fi
Validate the isolated recovery candidate before promotion:
BASE=/opt/lab-classroom/class39/attempt1
sha256sum -c "$BASE/backups/config-backup-v1.tar.gz.sha256"
(
cd "$BASE/restore/candidate"
sha256sum -c MANIFEST.sha256
)
python3 "$BASE/validate.py" "$BASE/restore/candidate/config"
cat "$BASE/restore/candidate/BACKUP_INFO.txt"
Preserve the pre-recovery state and promote the validated candidate using directory renames within the same filesystem:
BASE=/opt/lab-classroom/class39/attempt1
cp -a "$BASE/config" "$BASE/rollback/pre-recovery-config"
mv "$BASE/config" "$BASE/config.drifted"
mv "$BASE/restore/candidate/config" "$BASE/config"
python3 "$BASE/validate.py" "$BASE/config"
(
cd "$BASE/restore/candidate"
sha256sum -c MANIFEST.sha256
)
Compare the promoted configuration with the retained drifted state and inspect the final recovery artifacts:
BASE=/opt/lab-classroom/class39/attempt1
printf '%s\n' 'Promoted port:'
python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["listen_port"])' "$BASE/config/app.json"
printf '%s\n' 'Retained pre-recovery port:'
python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["listen_port"])' "$BASE/config.drifted/app.json"
find "$BASE" -maxdepth 3 -type f -print | sort
sha256sum -c /opt/lab-classroom/class39/attempt1/backups/config-backup-v1.tar.gz.sha256; the archive must report OK.tar -tzf /opt/lab-classroom/class39/attempt1/backups/config-backup-v1.tar.gz; confirm that the two configuration files, metadata file, and manifest are listed.cd /opt/lab-classroom/class39/attempt1 && sha256sum -c restore/candidate/MANIFEST.sha256; both active configuration files must report OK after promotion.python3 /opt/lab-classroom/class39/attempt1/validate.py /opt/lab-classroom/class39/attempt1/config; it must print a successful validation message.python3 -c 'import json; print(json.load(open("/opt/lab-classroom/class39/attempt1/config/app.json", encoding="utf-8"))["listen_port"])'; the result must be 8080.python3 -c 'import json; print(json.load(open("/opt/lab-classroom/class39/attempt1/config.drifted/app.json", encoding="utf-8"))["listen_port"])'; the result must be 9090.find /opt/lab-classroom/class39/attempt1 -maxdepth 3 -type f -print | sort and account for the active configuration, archive, checksum, staging package, candidate metadata, validator, and rollback copies.| Symptom | Likely cause | Fix |
|---|---|---|
| Creating attempt1 reports that the file or directory already exists. | A previous lab attempt is still present. | Do not overwrite it blindly. Inspect the previous artifacts, then use a new directory such as /opt/lab-classroom/class39/attempt2 and consistently substitute that path throughout the lab. |
| The shell reports permission denied while creating the classroom directory. | The current account lacks write permission under /opt/lab-classroom/. | Ask the homelab administrator to provision the classroom root with appropriate ownership. Do not redirect the exercise into an unrelated system directory. |
| The archive checksum reports FAILED or cannot find the archive. | The archive changed, the sidecar references a different absolute path, or the archive creation step did not complete. | Confirm both files exist under the attempt's backups directory. Treat a real mismatch as an integrity failure and rebuild the backup from a known-good staged package rather than proceeding with recovery. |
| Manifest verification cannot find config/app.json. | The command was run from the wrong working directory because manifest paths are relative to the package root. | Run the check from staging/package when testing the source package, from restore/candidate when testing the candidate, or from the attempt root when intentionally comparing the active config with the candidate manifest. |
| The drift comparison exits unsuccessfully and prints a checksum warning. | The lab intentionally changed app.json after the backup. | This is the expected drift-detection result. Continue only if the mismatch is limited to the intentionally changed file. |
| Python reports a JSON parsing error. | app.json has malformed punctuation, quoting, or value syntax. | Compare the file with the lesson's intended content. Recover the archived copy into the candidate directory and validate that copy before promotion. |
| The validator rejects workers, log_level, or listen_port even though parsing succeeds. | The file is syntactically valid but violates the lab's semantic policy. | Review the validator's allowed ranges and values. Use a backup known to match the intended application policy rather than bypassing the check. |
| Promotion reports that config.drifted already exists. | The promotion step was already run or a previous partial attempt used the same workspace. | Stop and inspect config, config.drifted, and rollback/pre-recovery-config. Determine which tree is active and validated before selecting a fresh attempt directory for another run. |
Treat configuration backups as sensitive because they may contain credentials, tokens, private endpoints, account names, and topology information.
Grant backup creation, retrieval, and restoration rights separately when operational roles permit.
Protect archive checksums or manifests independently; integrity data stored beside a writable archive does not prevent coordinated replacement.
Use signed manifests, independently controlled storage, or immutable retention for higher-assurance recovery.
Do not place real secrets in this classroom exercise.
Record who created, approved, retrieved, validated, and promoted production backups.
Test recovered configuration against a compatible application version before activation.
Compression is not encryption. A compressed configuration archive requires an additional approved encryption and key-management design when it contains confidential material.
A checksum detects changes relative to the checksum value supplied to the verifier. It does not identify the author or prove that the checksum itself is trustworthy.
Maintain multiple recovery generations and at least one independently controlled copy so that corruption, operator mistakes, or compromise of the primary host does not eliminate every recovery point.
Use rollback if post-promotion validation fails, if an application rejects the recovered configuration, or if the recovered version does not match the deployed application.
Confirm that /opt/lab-classroom/class39/attempt1/config.drifted exists.
Confirm that the currently active config directory is the recovered version.
Record the reason for rollback and any observed validation failure.
BASE=/opt/lab-classroom/class39/attempt1
mv "$BASE/config" "$BASE/config.recovered-v1"
mv "$BASE/config.drifted" "$BASE/config"
python3 "$BASE/validate.py" "$BASE/config"
The drifted pre-recovery configuration is active again under config/, while the recovered version remains available under config.recovered-v1 for investigation.
This classroom rollback restores files only. A real service may require a separately approved reload, restart, health check, and dependency validation procedure.