fix(M7-27+capstone): apply AI-drives-git reframe, lesson=theory, de-slop course-wide
Phase 2 sweep — all modules are post-pivot, so the learner directs the AI agent
(Claude Code as the worked example) to do the git/setup work and verifies, instead
of typing commands by hand; no re-teaching basics. Lesson sections are theory with
example output; all execution lives in the labs. De-slopped ("prose" etc. gone
course-wide, em-dash density thinned). /path/to placeholders -> ~/ai-workflow-course.
Every deliberate teaching device verified intact: M10 ai-change.patch trap,
M12 bad-clear-snippet, M13/M27 planted pending_count bug, M15 secret+typosquat+MD5,
M18 BREAK=1, M21 absent-.gitignore, M22 poisoned skill, M24 no-op patch, M25 --simulate.
Labs compile/parse (py/sh/yaml/json); no junk.
Closes #83
Closes #86
Closes #89
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TfzV5QvtPDz8LJS3Pu5VLT
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Module 16 — Containers and Reproducible Environments
|
||||
|
||||
> **"Works on my machine" is a confession, not a defense.** A container ships the machine with the
|
||||
> code, so your app, your CI, and your deploy target all run the exact same environment — and gives
|
||||
> you a throwaway box to run an agent you don't fully trust.
|
||||
> code, so your app, your CI, and your deploy target all run the exact same environment. It also
|
||||
> gives you a throwaway box to run an agent you don't fully trust.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
module is what makes that clean machine *identical* to your laptop and to where you'll deploy.
|
||||
- **Module 15** — security scanning and dependency hygiene. Important here as a boundary: a
|
||||
container faithfully reproduces your dependencies, including the vulnerable ones. Containers are
|
||||
**not** a substitute for the hygiene Module 15 taught — they're downstream of it.
|
||||
**not** a substitute for the hygiene Module 15 taught; they're downstream of it.
|
||||
|
||||
You do **not** need Docker installed yet — that's the first step of the lab. This module looks
|
||||
You do **not** need Docker installed yet; that's the first step of the lab. This module looks
|
||||
forward to Module 18 (deployment: a container is *what* you ship) and, lightly, to Units 4–5, where
|
||||
that same throwaway box becomes the place you let an agent run.
|
||||
|
||||
@@ -49,8 +49,8 @@ written down."
|
||||
|
||||
Hand the code to a colleague, a CI runner (Module 14), or a server, and the invisible stack is
|
||||
different. The failures are maddeningly specific: a different Python patch version changes a default,
|
||||
a system library is missing, an env var you set six months ago and forgot is load-bearing. The bug
|
||||
isn't in the code. The bug is that the *environment* never traveled with it.
|
||||
a system library is missing, an env var you set six months ago and forgot turns out to be required.
|
||||
The bug isn't in the code. The bug is that the *environment* never traveled with it.
|
||||
|
||||
A container is the fix: it packages the code **and the invisible stack together** into one artifact
|
||||
that runs the same everywhere. You stop shipping just the code and start shipping the machine.
|
||||
@@ -67,7 +67,7 @@ distinction:
|
||||
- **Registry** — where images are stored and shared, the way a Git remote (Module 8) stores repos.
|
||||
You `push` an image to a registry and `pull` it elsewhere. (Most git hosts now bundle one.)
|
||||
- **Dockerfile** — the plain-text recipe that *builds* an image. This is the part you version. It is
|
||||
the executable, reviewable specification of the environment — the same instinct as committing the
|
||||
the executable, reviewable specification of the environment, the same instinct as committing the
|
||||
AI's config in Module 5, applied to the whole machine.
|
||||
|
||||
### It is not a virtual machine
|
||||
@@ -78,7 +78,7 @@ and isolates only the process and its filesystem view. It's much closer to a sou
|
||||
or a BSD jail with packaging and distribution bolted on than to a hypervisor. That's why containers
|
||||
start in milliseconds and weigh megabytes instead of gigabytes.
|
||||
|
||||
Hold onto "shares the host kernel" — it's also exactly why a container is not a strong security
|
||||
Hold onto "shares the host kernel." It's also exactly why a container is not a strong security
|
||||
boundary by default (more in *Where it breaks*).
|
||||
|
||||
### The Dockerfile, line by line
|
||||
@@ -101,7 +101,7 @@ Each instruction adds a **layer**. Layers are cached and reused: change only `cl
|
||||
rebuilds from the `COPY` step down, reusing the base image and everything above. Order your
|
||||
Dockerfile cheapest-to-most-volatile (base and dependencies first, your fast-changing code last) and
|
||||
rebuilds stay fast. This is the same reason you install dependencies *before* copying source in a
|
||||
real project — so a one-line code change doesn't reinstall the world.
|
||||
real project, so a one-line code change doesn't reinstall the world.
|
||||
|
||||
### The levers that make it actually reproducible
|
||||
|
||||
@@ -114,24 +114,24 @@ levers that close that gap:
|
||||
`FROM python:3.12-slim@sha256:…`. Choose your point on the spectrum deliberately — a moving tag
|
||||
picks up security patches automatically; a pinned digest never changes under you. Both are valid;
|
||||
silence is not.
|
||||
- **Pin your dependencies.** This is Module 15's lesson, now load-bearing. A Dockerfile that runs
|
||||
`pip install <pkg>` with no version reproduces *whatever was newest at build time* — which is not
|
||||
reproducible at all. Use a lockfile. The container is only as deterministic as what you install
|
||||
into it.
|
||||
- **Pin your dependencies.** This is Module 15's lesson, and the container is where it bites. A
|
||||
Dockerfile that runs `pip install <pkg>` with no version reproduces *whatever was newest at build
|
||||
time*, which is not reproducible at all. Use a lockfile. The container is only as deterministic as
|
||||
what you install into it.
|
||||
- **Use a `.dockerignore`.** See [`lab/dockerignore-starter`](lab/dockerignore-starter). What isn't
|
||||
copied into the build can't bloat the image or leak into it — the same instinct as `.gitignore`
|
||||
copied into the build can't bloat the image or leak into it, the same instinct as `.gitignore`
|
||||
from Module 2.
|
||||
|
||||
### Why this snaps CI and deploy into one line
|
||||
|
||||
Module 14 sold CI as "a clean machine that runs your checks." The unsolved half was that the clean
|
||||
machine still wasn't *your* machine — "passes locally, fails in CI" was a real, common, miserable
|
||||
bug. Containers dissolve it. When CI builds and runs the same image you build and run locally, the
|
||||
machine still wasn't *your* machine: "passes locally, fails in CI" was a real, common, miserable
|
||||
bug. Containers remove it. When CI builds and runs the same image you build and run locally, the
|
||||
environment is identical by construction. "Works in CI but not locally" stops being possible because
|
||||
there's only one environment now, not two that drift.
|
||||
|
||||
The same artifact carries forward: the image CI builds is the image Module 18 deploys. Build once,
|
||||
run identically — laptop, pipeline, production.
|
||||
run identically on laptop, pipeline, and production.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,12 +141,12 @@ Docker itself you may already know. What makes containers matter *more* in AI-as
|
||||
|
||||
- **AI writes code for an environment it can't see.** The model assumes packages are installed, a
|
||||
certain runtime version, paths that exist on *its* imagined machine. "Works on my machine"
|
||||
becomes "works on the machine the model pictured" — and that machine is no one's. A Dockerfile
|
||||
becomes "works on the machine the model pictured," and that machine is no one's. A Dockerfile
|
||||
forces the environment to be explicit, so the AI's assumptions either hold or fail loudly at build
|
||||
time instead of mysteriously at run time.
|
||||
- **The environment becomes reviewable.** AI-suggested setup ("just run these eight commands") drifts
|
||||
and rots and lives in a chat log. A Dockerfile turns that into one committed, diffable file. When
|
||||
the AI changes how the environment is built, it arrives as a diff in a PR (Module 10) — the same
|
||||
the AI changes how the environment is built, it arrives as a diff in a PR (Module 10), the same
|
||||
win as committing the AI's config in Module 5, extended to the whole machine.
|
||||
- **A container is a sandbox for an agent you don't fully trust.** This is the forward-looking one.
|
||||
As you let AI do bolder things — run commands, install packages, execute its own code, and
|
||||
@@ -155,7 +155,7 @@ Docker itself you may already know. What makes containers matter *more* in AI-as
|
||||
worst, then `docker rm` the whole thing. The host never saw it. This is the practical foundation
|
||||
for running less-trusted agents, and we'll build on it when MCP servers and skills (Unit 4) start
|
||||
executing third-party code.
|
||||
- **But a container does not make AI code safe.** It reproduces whatever the AI wrote — including a
|
||||
- **But a container does not make AI code safe.** It reproduces whatever the AI wrote, including a
|
||||
hallucinated dependency (Module 15) or a hardcoded secret (Module 17), now faithfully baked into an
|
||||
image and shipped everywhere. Containers are a *reproducibility and blast-radius* tool, not a
|
||||
correctness or security tool. They sit alongside Module 15, not on top of it.
|
||||
@@ -179,13 +179,16 @@ containerize and run the app you already have.
|
||||
is up with `docker info` (or `podman info`), which only succeeds when the engine is actually live.
|
||||
- The starter files from this module's `lab/`: [`Dockerfile`](lab/Dockerfile) and
|
||||
[`dockerignore-starter`](lab/dockerignore-starter).
|
||||
- Your AI assistant.
|
||||
- Your coding agent (Claude Code is the worked example; sub your own).
|
||||
|
||||
### Part A — Build the image
|
||||
|
||||
1. Copy this module's `lab/Dockerfile` into your `tasks-app` folder, and copy
|
||||
`lab/dockerignore-starter` to a file named exactly `.dockerignore` in the same folder. Read the
|
||||
Dockerfile top to bottom — every line is commented. Then build:
|
||||
1. Get the two starter files into your `tasks-app` folder. Direct your agent (Claude Code is the
|
||||
worked example; sub your own) to do the placement: *"Copy this module's lab/Dockerfile into
|
||||
`~/ai-workflow-course/tasks-app`, and create a file named exactly `.dockerignore` there from
|
||||
lab/dockerignore-starter."* Then read the Dockerfile top to bottom yourself before you build:
|
||||
every line is commented, and you want to know what you're about to run, not just that the file
|
||||
landed. The build is the lesson, so you run it by hand:
|
||||
|
||||
```bash
|
||||
cd ~/ai-workflow-course/tasks-app
|
||||
@@ -253,9 +256,10 @@ containerize and run the app you already have.
|
||||
### Part D — Use the container as a sandbox (the AI angle, hands-on)
|
||||
|
||||
4. Now use a disposable container as a blast-radius box for something you don't fully trust. Ask your
|
||||
AI for a one-line shell command that "inspects the system" — the kind of thing you'd hesitate to
|
||||
paste straight into your real terminal. Then run it where it can't touch your host: no network,
|
||||
read-only root filesystem, and nothing of yours mounted:
|
||||
agent (Claude Code is the worked example; sub your own) for a one-line shell command that
|
||||
"inspects the system," the kind of thing you'd hesitate to paste straight into your real terminal.
|
||||
Then run it where it can't touch your host: no network, read-only root filesystem, and nothing of
|
||||
yours mounted:
|
||||
|
||||
```bash
|
||||
docker run --rm --network none --read-only python:3.12-slim \
|
||||
@@ -265,16 +269,19 @@ containerize and run the app you already have.
|
||||
`--network none` cuts it off from the internet; `--read-only` stops it writing to the container
|
||||
filesystem; `--rm` destroys the container after. Whatever the command does, it does it to a box
|
||||
that exists for one second and touches nothing you care about. **This is the pattern** for running
|
||||
less-trusted commands and, later, less-trusted agents — the foundation Units 4–5 build on. (Read
|
||||
less-trusted commands and, later, less-trusted agents: the foundation Units 4–5 build on. (Read
|
||||
*Where it breaks* before you trust it with something genuinely hostile.)
|
||||
|
||||
5. Commit your work. The Dockerfile and `.dockerignore` are environment-as-code — version them like
|
||||
anything else:
|
||||
5. Commit your work. The Dockerfile and `.dockerignore` are environment-as-code, so version them
|
||||
like anything else. Direct your agent (Claude Code is the worked example; sub your own) to stage
|
||||
and commit them: *"Stage the Dockerfile and .dockerignore and commit them with a clear message
|
||||
about containerizing the tasks-app for a reproducible environment."*
|
||||
|
||||
```bash
|
||||
git add Dockerfile .dockerignore
|
||||
git commit -m "Containerize the tasks-app for a reproducible environment"
|
||||
```
|
||||
Then verify the result, because what got committed is the point. Have the agent show you the
|
||||
commit (`git show --stat HEAD`) and confirm it staged **only** those two files. `tasks.json`
|
||||
should be absent: your `.dockerignore` and `.gitignore` exclude it, and runtime state has no
|
||||
business in either the image or the repo. If the agent staged anything you didn't expect, that's
|
||||
the review gate (Module 10) doing its job before the environment-as-code ships.
|
||||
|
||||
---
|
||||
|
||||
@@ -290,13 +297,13 @@ Be honest about the limits — this audience will find them the hard way otherwi
|
||||
capabilities, seccomp/AppArmor profiles, and for genuinely hostile workloads a stronger sandbox
|
||||
with its own kernel (gVisor, Kata Containers, or a real VM). Treat the lab's `--network none
|
||||
--read-only` as raising the cost of mischief, not as a guarantee against a determined attacker.
|
||||
- **Reproducible ≠ small.** A naive image can be hundreds of megabytes to multiple gigabytes —
|
||||
- **Reproducible ≠ small.** A naive image can be hundreds of megabytes to multiple gigabytes:
|
||||
full base images, build toolchains left in the final layer, the `.git` directory copied in.
|
||||
Bloat is slow to pull, expensive to store, and a larger attack surface. The defenses: slim or
|
||||
distroless base images, multi-stage builds (build in a fat image, copy only the artifact into a
|
||||
thin one), and a real `.dockerignore`.
|
||||
- **It does not replace dependency hygiene (Module 15).** A container reproduces your dependencies
|
||||
*perfectly* — including the vulnerable and the hallucinated ones. Pinning a base image with a known
|
||||
*perfectly*, including the vulnerable and the hallucinated ones. Pinning a base image with a known
|
||||
CVE just reproduces that CVE on every machine, reliably. Containers are downstream of Module 15,
|
||||
not a substitute: you still scan dependencies, and you scan the *image itself* (its base layers
|
||||
carry their own vulnerabilities).
|
||||
@@ -327,7 +334,7 @@ Be honest about the limits — this audience will find them the hard way otherwi
|
||||
why the host was safe — *and* can name one case where it wouldn't have been.
|
||||
- You can state, without looking back: a container is not a VM, it's not a security boundary by
|
||||
default, and it doesn't replace dependency hygiene from Module 15.
|
||||
- Your `Dockerfile` and `.dockerignore` are committed — the environment is now version-controlled,
|
||||
- Your `Dockerfile` and `.dockerignore` are committed: the environment is now version-controlled,
|
||||
reviewable config.
|
||||
|
||||
When "works on my machine" stops being something you say and starts being something you build, you're
|
||||
|
||||
Reference in New Issue
Block a user