#!/usr/bin/env python3 """Small offline state model for teaching container lifecycle; not a container engine.""" import argparse import hashlib import json import shutil from pathlib import Path def load(path: Path): return json.loads(path.read_text()) if path.exists() else {"images": {}, "containers": {}, "volumes": {}} def save(path: Path, state): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n") def build(state_path: Path, image: str, context: Path): dockerfile = context / "Dockerfile" lines = [line.strip() for line in dockerfile.read_text().splitlines() if line.strip() and not line.lstrip().startswith("#")] instructions = [line.split(None, 1)[0].upper() for line in lines] allowed = {"FROM", "LABEL", "COPY", "CMD", "ENTRYPOINT", "ENV", "WORKDIR", "USER"} if not instructions or instructions[0] != "FROM" or "CMD" not in instructions or any(item not in allowed for item in instructions): raise SystemExit("invalid Dockerfile for offline lesson model") digest = hashlib.sha256() for file_path in sorted(path for path in context.rglob("*") if path.is_file()): digest.update(str(file_path.relative_to(context)).encode()); digest.update(b"\0"); digest.update(file_path.read_bytes()) value = "sha256:" + digest.hexdigest() state = load(state_path); state["images"][image] = {"digest": value, "instructions": instructions}; save(state_path, state) print(f"{value} instructions={len(instructions)}") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("state", type=Path) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("build"); p.add_argument("image"); p.add_argument("context", type=Path) p = sub.add_parser("image-inspect"); p.add_argument("image") p = sub.add_parser("run"); p.add_argument("name"); p.add_argument("image"); p.add_argument("--volume"); p.add_argument("--bind", type=Path) p = sub.add_parser("inspect"); p.add_argument("name") p = sub.add_parser("stop"); p.add_argument("name") p = sub.add_parser("rm"); p.add_argument("name") p = sub.add_parser("volume-create"); p.add_argument("name") p = sub.add_parser("volume-path"); p.add_argument("name") p = sub.add_parser("volume-rm"); p.add_argument("name") args = parser.parse_args(); state = load(args.state) if args.command == "build": build(args.state, args.image, args.context); return if args.command == "image-inspect": print(json.dumps(state["images"][args.image], sort_keys=True)); return if args.command == "volume-create": path = args.state.parent / "volumes" / args.name; path.mkdir(parents=True, exist_ok=True); state["volumes"][args.name] = str(path); save(args.state, state); print(path); return if args.command == "volume-path": print(state["volumes"][args.name]); return if args.command == "volume-rm": path = Path(state["volumes"].pop(args.name)); shutil.rmtree(path); save(args.state, state); print("removed"); return if args.command == "run": if args.image not in state["images"] or args.name in state["containers"]: raise SystemExit("invalid image or duplicate container") if args.volume and args.volume not in state["volumes"]: raise SystemExit("unknown volume") state["containers"][args.name] = {"image": args.image, "status": "running", "volume": args.volume, "bind": str(args.bind) if args.bind else None}; save(args.state, state); print("running"); return container = state["containers"].get(args.name) if not container: raise SystemExit("unknown container") if args.command == "inspect": print(container["status"]); return if args.command == "stop": container["status"] = "exited"; save(args.state, state); print("exited"); return if args.command == "rm": if container["status"] == "running": raise SystemExit("stop container before rm") del state["containers"][args.name]; save(args.state, state); print("removed"); return if __name__ == "__main__": main()