diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1f7436b..8e438c8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // Dev container for the Forge SWEXP starter repo. // Open in GitHub Codespaces (Code -> Codespaces -> Create) or VS Code Dev Containers // for a zero-setup environment with Git, the right runtime, and the gh CLI preinstalled. -// Then follow README.md / Lesson_00.md to pick up your first ticket. +// Then follow README.md to pick up your first lab. { "name": "SWEXP 08 Platform Engineering Containerization", "image": "mcr.microsoft.com/devcontainers/base:ubuntu", @@ -9,10 +9,13 @@ "ghcr.io/devcontainers/features/node:1": { "version": "20" }, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + }, "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, - "postCreateCommand": "echo '\\n=== Forge SWEXP environment ready ==='; git --version; node --version 2>/dev/null; echo 'Open README.md, then Lesson_00.md to begin.'", + "postCreateCommand": "npm install; python3 -c 'import yaml' 2>/dev/null || pip install --quiet pyyaml; echo '\\n=== Forge SWEXP environment ready ==='; git --version; node --version 2>/dev/null; python3 --version 2>/dev/null; docker --version 2>/dev/null; echo 'Open README.md, then start with labs/lab-00-setup.'", "customizations": { "vscode": { "extensions": [ diff --git a/.github/workflows/autograde.yml b/.github/workflows/autograde.yml new file mode 100644 index 0000000..66548ae --- /dev/null +++ b/.github/workflows/autograde.yml @@ -0,0 +1,63 @@ +name: Autograde + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + grade: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies (bats) + run: npm ci + + - name: Ensure PyYAML (compose/CI/k8s YAML graders parse with python3) + run: python3 -c "import yaml" || pip install --quiet pyyaml + + - name: Run autograder + id: grade + continue-on-error: true + run: npm run grade + + - name: Publish score to job summary + if: always() + run: cat grade-report.md >> "$GITHUB_STEP_SUMMARY" || true + + - name: Comment score on pull request + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + let body = marker + '\n'; + try { body += fs.readFileSync('grade-report.md', 'utf8'); } + catch { body += 'Autograder did not produce a report.'; } + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + + - name: Fail the check if incomplete + if: steps.grade.outcome != 'success' + run: | + echo "Exercises are not yet complete — see the autograde summary above." + exit 1 diff --git a/.gitignore b/.gitignore index 646ac51..bd41f50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .DS_Store node_modules/ +.grade/ +grade-report.md +errors.log diff --git a/LEARNER_GUIDE.md b/LEARNER_GUIDE.md deleted file mode 100644 index 0df44c5..0000000 --- a/LEARNER_GUIDE.md +++ /dev/null @@ -1,39 +0,0 @@ -# Learner Guide — Platform Engineering & Containerization - -## You are a platform engineer -Every lesson is an **engineering ticket** turning *Project Forge* into a reproducible platform. Approach each as real work: write the infrastructure-as-code, validate it, and document your reasoning. The goal isn't memorizing Docker flags — it's the reproducibility, security, and operability judgment to own a platform other engineers build on and that ships safely without heroics. - -## The ideas that matter most -- **Build once, run anywhere.** One immutable image per service runs identically everywhere; configuration is injected, never baked. -- **Infrastructure as code.** Dockerfiles, compose, pipelines, manifests are declarative, versioned, reviewable text — validate them like code. -- **Reproducible, not "works on my machine."** Pin versions, commit lockfiles, deterministic installs, dev containers. -- **Least privilege / minimal surface.** Non-root, minimal bases, no secrets in images, expose the minimum, segment the network. -- **Automate the path to production.** CI/CD is the only way to ship; the gates are unskippable; promote the tested image. -- **Design for failure / operability.** Health probes, resource limits, zero-downtime rollouts (Modules 03/06 carry). -- **Developer experience is a feature.** One command to run the whole platform locally. - -## How each lesson works -1. **Read the ticket and the deep dive.** -2. **Do the lab.** Write the artifact, **predict** the lint findings / policy result, then verify by parsing/linting. -3. **Investigate** — push from "it parses" to "I can show the naive version failing and mine passing, and explain what each check prevents." -4. **Run the AI exercise** — draft → verify → log, deliberately. -5. **Submit the assignment** and **update your notebook.** -6. **Check the solution** to validate your reasoning — after you've done the work. - -Track progress in `dashboard.html`. - -## What every assignment must include -- **What you built** and *why this design* — what's pinned, multi-stage, health-gated, segmented; what gates the pipeline; what's injected vs baked. -- **Evidence:** the Dockerfile lint (naive → clean), the parsed YAML structure/policy, the logic-check results (boundaries, reachability, gate order, reproducibility). -- **The fix at the cause** — a pinned base, a multi-stage split, a health gate, a network tier, a deploy gate, injected secrets. -- **AI-usage log:** draft → verify → log. -- **Clean commits** (Module 02 habits). - -## Using AI responsibly -AI drafts infra fast and confidently, and is often wrong in ways that are *expensive* on a platform — `:latest`, root containers, baked secrets, a published database, a pipeline whose gates don't gate. You have concrete verifiers: lint the Dockerfile, parse the YAML, run the policy logic. `resources/ai-workflow-guide.md` maps the failure modes. - -## The standard -A Dockerfile you didn't lint isn't trusted; a manifest you didn't parse may not declare what you think; a pipeline you didn't policy-check may have a gate that doesn't gate. The linters, parsers, and policy checks are the arbiters — not confidence. Build once, run anywhere only holds if the artifact and its config are actually what you verified. - -## How you're graded -Against `ASSESSMENT_RUBRIC.md` — on repository architecture, developer experience, the container platform, build automation, production readiness, operability, and judgment, with evidence. An image that "runs" but is root, `:latest`, and ships the toolchain, or a manifest with one replica and no limits, scores poorly regardless of the happy path. diff --git a/Lesson_00.md b/Lesson_00.md deleted file mode 100644 index abc1550..0000000 --- a/Lesson_00.md +++ /dev/null @@ -1,105 +0,0 @@ -# Lesson 00 — Welcome to the Platform Engineering Team - -> **Role:** Platform Engineer · **Competency:** Platform Engineering Orientation · **Track:** PLAT · **Est. time:** 2–3 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: PLAT-1000 -TITLE: Onboard to the Project Forge platform -PRIORITY: P1 — blocks all platform work -TYPE: Onboarding -ASSIGNEE: You (Platform Engineer) -DESCRIPTION: Forge is three apps built across earlier modules — a web frontend - (M05), an API (M06), and a data layer (M07) — that currently run only - on the original authors' laptops, set up by hand. Your job is to turn - Forge into a reproducible platform: one repo, containerized services, a - one-command dev environment, automated builds, and production-ready - infrastructure. Set up the toolchain and understand the lifecycle of a - build artifact from source to production. - -ACCEPTANCE CRITERIA: - - The platform toolchain runs; you can validate a config file and a Dockerfile - - You can explain "build once, run anywhere" and why it matters - - You can describe an artifact's path: source → image → environments - - Your engineering notebook has a dated first entry -``` - -## 🏢 Business Context - -A product that only runs where it was written isn't a product — it's a demo. Every new hire who spends two days getting Forge running, every "works on my machine" bug, every manual deploy that goes wrong at 2am is the same root problem: the way software is built and run isn't *reproducible*. Platform engineering fixes that. You make one artifact that runs identically on a laptop, in CI, and in production, shipped by automation instead of by hand. That reproducibility is what lets a team move fast without breaking things. - -## 🎯 Learning Objectives - -- Set up the platform toolchain and validate a config file and a Dockerfile -- Explain "build once, run anywhere" and the cost of non-reproducibility -- Trace a build artifact's lifecycle: source → image → environments -- Map the module's arc (monorepo → containers → compose → reproducibility → images → networking → CI/CD → production) - -## 📚 Technical Deep Dive - -**Build once, run anywhere.** The core idea of containerization: package an application and everything it needs (runtime, libraries, config defaults) into one immutable **image**, then run that same image everywhere. The image you tested is the image that runs in production — bit for bit. No "but it worked in staging." - -**The artifact lifecycle.** -``` -source code → build → image (immutable, tagged) → registry → run in any environment - (laptop / CI / staging / prod) -``` -The image is built once and promoted through environments unchanged; only *configuration* (env vars, secrets) differs per environment. - -**Infrastructure as code.** Platform work is *declarative and versioned*: Dockerfiles, compose files, CI pipelines, and Kubernetes manifests are text files in the repo, reviewed in pull requests, applied by automation. Nothing is hand-clicked in a console where it can't be reviewed or reproduced. - -**Configuration is reviewable text.** Most of what you'll write this module is config — YAML and Dockerfiles. So the first skill is *validating* it: does it parse, and does it declare what you intended? You'll lint Dockerfiles and parse/validate compose and Kubernetes YAML, the same way you'd type-check code. - -```dockerfile -# a Dockerfile is a recipe for an image — declarative, versioned, reviewable -FROM node:22.13-bookworm-slim -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -CMD ["node", "server.js"] -``` - -**The module arc.** Reorganize into a monorepo → containerize a service → wire a one-command dev environment with compose → make it reproducible (pinned versions, dev containers) → engineer lean production images → build the container network → automate builds with CI/CD → run a production container platform → ship it. - -### Common gotchas -- Treating infrastructure as something you click together once, not code you version. -- Confusing the image (the immutable artifact) with configuration (what varies per environment). -- "It runs on my machine" — the exact problem this module eliminates. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-00-setup.md`**: set up the toolchain, validate a YAML config file (it parses and has the keys you intended) and a Dockerfile (it passes a basic best-practice lint), and trace one artifact's path from source to a running environment. - -## 🔍 Engineering Investigation - -Take Forge's three apps and, in your notebook, sketch the artifact lifecycle for one of them: what gets built, what the image contains, what's configuration vs baked-in, and which environments the same image will run in. Note one thing that's currently "works on my machine" and how an immutable image fixes it. - -## 🤖 AI Engineering Exercise - -Ask an AI to "write a Dockerfile for a Node app." **Draft** it, then **verify**: does it parse/lint cleanly, pin its base image, and avoid baking in configuration? **Log** what you kept and corrected. The loop all module: **draft → verify (parse/lint the config, run the build logic, check the policy) → log.** - -## 📝 Assignment - -1. Set up the toolchain; paste `node --version` and a validated config + Dockerfile lint result. -2. Complete the lab; include the artifact-lifecycle sketch for one Forge app. -3. Write a 5–8 sentence explainer: "what does 'build once, run anywhere' buy a team, and what breaks without it?" -4. Commit your notebook. - -## 🚀 Stretch Goal - -Find a real "works on my machine" story (yours or a well-known postmortem) and write a paragraph on which reproducibility practice from this module's arc would have prevented it. - -## ✅ Definition of Done - -- [ ] Toolchain runs; a config file validates and a Dockerfile lints -- [ ] Artifact lifecycle sketched for one Forge app -- [ ] "Build once, run anywhere" explainer written -- [ ] Notebook committed - -## 🪞 Reflection - -Where has "works on my machine" cost you time before? What does making the build artifact immutable change about how a team ships software? diff --git a/Lesson_01.md b/Lesson_01.md deleted file mode 100644 index f9dbda8..0000000 --- a/Lesson_01.md +++ /dev/null @@ -1,102 +0,0 @@ -# Lesson 01 — Reorganize Project Forge - -> **Role:** Platform Engineer · **Competency:** Monorepo Architecture · **Track:** REPO · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: REPO-1010 -TITLE: Forge's apps live in three drifting repos that share copy-pasted code -PRIORITY: P1 -TYPE: Architecture -DESCRIPTION: The web app (M05), the API (M06), and shared types/utilities live in - separate repositories. Shared code is copy-pasted and drifts; a change - to a shared type means three PRs; there's no single place to build and - version the platform. Reorganize Forge into a monorepo with clear - package boundaries, workspace tooling, and shared packages consumed by - the apps — without creating a tangled big ball of mud. - -ACCEPTANCE CRITERIA: - - One repository with a clear, documented layout (apps vs shared packages) - - Workspace tooling manages dependencies across packages - - Shared code lives in a package the apps depend on (no copy-paste) - - Dependency boundaries are explicit (apps may depend on packages, not vice versa) -``` - -## 🏢 Business Context - -How you organize the repository is an architecture decision, not bookkeeping. When the web app, the API, and their shared types live in separate repos, a single change to a shared contract becomes a multi-repo, multi-PR coordination problem, and copy-pasted code silently drifts until the frontend and backend disagree about what an `Order` is. A monorepo puts the whole platform in one versioned place with shared packages as the single source of truth — so a contract change is one atomic commit, and the boundaries between pieces are explicit and enforceable. - -## 🎯 Learning Objectives - -- Structure a monorepo with clear apps-vs-packages boundaries -- Use workspace tooling to manage cross-package dependencies -- Extract shared code into a package consumed by the apps (no copy-paste) -- Make dependency direction explicit (apps → packages, never the reverse) - -## 📚 Technical Deep Dive - -**A monorepo is one repo, many packages.** Not one giant tangle — a structured set of independently-defined packages with explicit dependencies: - -``` -forge/ -├── package.json # workspace root -├── apps/ -│ ├── web/ # M05 frontend (depends on @forge/types, @forge/ui) -│ └── api/ # M06 backend (depends on @forge/types) -├── packages/ -│ ├── types/ # shared domain types (the Order contract) -│ └── ui/ # shared UI components -└── ... -``` - -**Workspaces manage cross-package dependencies.** The workspace root declares where packages live; the tool (npm/pnpm/yarn workspaces, or a build system like Turborepo/Nx) links them so `apps/web` can `import { Order } from '@forge/types'` and get the local package, not a copy. - -```json -{ "name": "forge", "private": true, "workspaces": ["apps/*", "packages/*"] } -``` - -**Shared code is a package, not a copy.** The `Order` type from Module 04 lives once in `@forge/types`; the web and api both depend on it. Change it once, and both apps see the change (and the type-checker flags every mismatch) — the drift problem is gone structurally. - -**Dependency direction is a rule.** Apps depend on packages; packages don't depend on apps; shared packages don't depend on each other circularly. This is the same "dependencies point inward" discipline from Module 06's layering, now at the repository scale. A dependency-boundary check (a lint rule or a CI step) can enforce it so the architecture can't silently rot. - -**Why not just separate repos?** Separate repos give independent versioning but make cross-cutting changes painful and let shared code drift. A monorepo trades that for atomic cross-package changes and one source of truth — the right call when the apps ship together as one platform. - -### Common gotchas -- A "monorepo" that's just everything dumped in one folder with no boundaries (a big ball of mud). -- Copy-pasting shared code instead of extracting a package (drift returns). -- Circular dependencies between packages, or a package depending on an app. -- No workspace tooling, so packages can't reference each other cleanly. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-01-monorepo.md`**. You'll define the Forge monorepo layout (a workspace root + `apps/*` and `packages/*`), declare the workspace config, and extract a shared `@forge/types` package the apps depend on. A validator parses the workspace config and the package manifests and checks the **dependency boundaries** — apps may depend on packages, packages must not depend on apps, and there are no cycles — failing a deliberately-wrong layout and passing the correct one. - -## 🔍 Engineering Investigation - -Map Forge's current code to a monorepo layout: what's an app, what's a shared package, what was copy-pasted that should be extracted. After reorganizing, run the dependency-boundary check and record that the apps depend on `@forge/types` (not a copy) and that no package depends on an app. Note one bug the old copy-paste drift could have caused. - -## 🤖 AI Engineering Exercise - -Ask an AI to "set up a monorepo for these apps." **Verify** it creates real package boundaries (not one folder), wires workspaces so apps can import shared packages, and keeps dependency direction sane (no package → app, no cycles). **Log** where it produced a tangle or left copy-pasted code and how you fixed it. - -## 📝 Assignment - -Submit the monorepo layout: the workspace config, the `apps/*` and `packages/*` structure, the extracted shared package, and the passing dependency-boundary check (apps → packages, no app dependency, no cycles) — plus a note on the drift the old structure caused. - -## 🚀 Stretch Goal - -Add a build-graph tool (Turborepo/Nx) or a task pipeline that builds packages before the apps that depend on them, and explain how the dependency graph drives correct, cacheable build order. - -## ✅ Definition of Done - -- [ ] One repo with a clear apps-vs-packages layout -- [ ] Workspace tooling manages cross-package dependencies -- [ ] Shared code extracted into a package (no copy-paste) -- [ ] Dependency boundaries explicit and checked (apps → packages, no cycles) - -## 🪞 Reflection - -What did copy-pasting shared code cost (or risk) before? Why is dependency *direction* a rule worth enforcing in CI rather than trusting to discipline? diff --git a/Lesson_02.md b/Lesson_02.md deleted file mode 100644 index 4694a9d..0000000 --- a/Lesson_02.md +++ /dev/null @@ -1,107 +0,0 @@ -# Lesson 02 — Containerize the Platform - -> **Role:** Platform Engineer · **Competency:** Docker Fundamentals · **Track:** DOCK · **Est. time:** 4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: DOCK-2001 -TITLE: The API only runs after a page of manual setup; containerize it -PRIORITY: P1 -TYPE: Feature -DESCRIPTION: Running the Forge API requires installing the right Node version, - system libraries, and environment by hand — different on every - machine. Containerize it: write a Dockerfile that packages the API and - its runtime into one immutable image that builds reproducibly and runs - the same everywhere. Follow image hygiene: pinned base, good layer - order, a build context that excludes junk, and a non-root runtime. - -ACCEPTANCE CRITERIA: - - A Dockerfile builds the API into a single runnable image - - Base image is pinned (no :latest); instructions are ordered for layer caching - - A .dockerignore excludes node_modules, secrets, and build junk from the context - - The container runs as a non-root user; configuration comes from the environment -``` - -## 🏢 Business Context - -A container turns "install these twelve things in this order" into "run this image." It's the unit of reproducibility: the same image runs on a laptop, in CI, and in production, so the environment stops being a variable. But a careless image is slow to build, huge, insecure, or leaks secrets — so containerization is a craft with real best practices. Getting the Dockerfile right is the foundation everything else in this module stands on. - -## 🎯 Learning Objectives - -- Write a Dockerfile that packages a service into a reproducible image -- Pin the base image and order instructions for layer caching -- Use a `.dockerignore` to keep the build context clean -- Run as a non-root user and take configuration from the environment - -## 📚 Technical Deep Dive - -**Images, layers, and the build cache.** A Dockerfile is a sequence of instructions; each creates a **layer**. Docker caches layers and only rebuilds from the first changed instruction onward — so *order matters*. Copy and install dependencies *before* copying source, so a code change doesn't bust the dependency-install cache: - -```dockerfile -FROM node:22.13-bookworm-slim # pinned base — reproducible, not :latest -WORKDIR /app -COPY package*.json ./ # deps layer: changes rarely -RUN npm ci # cached unless package*.json changes -COPY . . # source layer: changes often -RUN npm run build -USER node # drop root for the runtime -CMD ["node", "dist/server.js"] -``` -If `COPY . .` came before `npm ci`, every code edit would reinstall all dependencies — slow builds. - -**Pin the base image.** `node:latest` is a moving target — today's build and tomorrow's differ. Pin a specific tag (`node:22.13-bookworm-slim`) so the build is reproducible. Prefer slim/minimal bases to shrink size and attack surface. - -**`.dockerignore` keeps the context clean.** The build context is everything sent to the builder. Without a `.dockerignore`, you ship `node_modules`, `.git`, `.env` files, and logs into the build — slow, bloated, and a secret-leak risk: - -``` -node_modules -.git -.env -*.log -dist -``` - -**Run as non-root.** By default a container runs as root; a compromise then has root in the container. Create/҂use a non-root user (`USER node`) so the runtime has least privilege. - -**Configuration from the environment.** Don't bake environment-specific config or secrets into the image (it's the *same image* everywhere). Read config from environment variables at runtime; pass secrets in at run time, never `COPY` them in. This is the 12-factor "config in the environment" rule, and it's what keeps one image promotable across environments. - -### Common gotchas -- `FROM node:latest` (non-reproducible builds). -- `COPY . .` before installing dependencies (busts the cache on every code change). -- No `.dockerignore` (bloated context; leaked `.env`/`.git`). -- Running as root; baking secrets or env-specific config into the image. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-02-dockerfile.md`**. You'll write a Dockerfile for the Forge API and run it through a **Dockerfile linter** that checks the best practices: a pinned (non-`latest`) base, dependency layers before source (cache-friendly order), a non-root `USER`, and a `.dockerignore` excluding `node_modules`/`.env`/`.git`. A deliberately-bad Dockerfile fails the lint with specific findings; your corrected one passes. - -## 🔍 Engineering Investigation - -Lint a "naive" Dockerfile (latest base, `COPY . .` first, root, no `.dockerignore`) and record every finding. Fix each and re-lint to a clean pass. In your notebook, explain for one finding what concretely goes wrong in production if it ships (e.g. a leaked `.env`, a non-reproducible build, a root compromise). - -## 🤖 AI Engineering Exercise - -Ask an AI to "containerize this API." **Verify** the Dockerfile pins its base, orders layers for caching, runs non-root, ships a `.dockerignore`, and takes config from the environment (no baked secrets). **Log** each best practice the AI missed and your fix — these are exactly the lint findings. - -## 📝 Assignment - -Submit the API Dockerfile + `.dockerignore`, the before/after lint results (naive → clean), and a note on what each fixed finding prevents in production. - -## 🚀 Stretch Goal - -Measure (or reason precisely about) the image-size and build-time difference between the naive and improved Dockerfile, and attribute the difference to specific instructions (base image choice, layer order, `.dockerignore`). - -## ✅ Definition of Done - -- [ ] A Dockerfile builds the API into one runnable image -- [ ] Base pinned; instructions ordered for layer caching -- [ ] `.dockerignore` excludes node_modules, secrets, build junk -- [ ] Runs non-root; config from the environment (no baked secrets) -- [ ] Lint passes clean (naive version's findings all addressed) - -## 🪞 Reflection - -Which best practice would you have skipped under time pressure, and what would it have cost later? Why is layer *order* a performance decision, not a cosmetic one? diff --git a/Lesson_03.md b/Lesson_03.md deleted file mode 100644 index 9e2b683..0000000 --- a/Lesson_03.md +++ /dev/null @@ -1,114 +0,0 @@ -# Lesson 03 — Build a One-Command Development Environment - -> **Role:** Platform Engineer · **Competency:** Docker Compose · **Track:** COMPOSE · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: COMPOSE-2010 -TITLE: New engineers spend a day wiring web + api + database by hand -PRIORITY: P1 — developer experience -TYPE: Feature -DESCRIPTION: Running Forge locally means starting the database, then the API, then - the web app, in the right order, with the right connection settings — - by hand, differently on every machine. Define the whole stack in Docker - Compose so `one command` brings up web + api + database, correctly - wired, with health-gated startup order, persistent data, and the ports - a developer needs. - -ACCEPTANCE CRITERIA: - - A single compose file defines web, api, and database services - - Services are wired by name; startup waits for dependencies to be healthy - - Data persists across restarts (a volume); needed ports are published - - `one command` brings the whole stack up (and down) reproducibly -``` - -## 🏢 Business Context - -Developer experience is a feature. Every hour a new engineer spends fighting local setup is an hour not building Forge, and every subtly-different local environment is a "works on my machine" bug waiting to happen. A one-command dev environment — `docker compose up` and the whole platform is running, wired, and seeded — is one of the highest-leverage things a platform team ships. It also makes the local stack *match* the way services talk in production: by name, over a network, with health-gated dependencies. - -## 🎯 Learning Objectives - -- Define a multi-service stack in a single Docker Compose file -- Wire services by name and gate startup on dependency health -- Persist data with volumes and publish the ports developers need -- Bring the whole platform up and down with one command - -## 📚 Technical Deep Dive - -**Compose declares the whole stack.** One YAML file describes every service, how they connect, and what they need: - -```yaml -services: - db: - image: postgres:16.2 - environment: { POSTGRES_PASSWORD: devpass } - volumes: [ "forge-data:/var/lib/postgresql/data" ] # data persists across restarts - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] - interval: 5s - api: - build: ./apps/api - environment: { DATABASE_URL: "postgres://postgres:devpass@db:5432/forge" } # 'db' = service name - depends_on: - db: { condition: service_healthy } # wait for db to be ready - ports: [ "8080:8080" ] - web: - build: ./apps/web - environment: { API_URL: "http://api:8080" } # 'api' = service name - depends_on: - api: { condition: service_started } - ports: [ "3000:3000" ] -volumes: - forge-data: -``` - -**Service discovery by name.** Compose puts services on a shared network where each is reachable by its service name (`db`, `api`). The API connects to `db:5432`, the web app to `api:8080` — no IP addresses, no hardcoded hosts. This is exactly how services find each other in production orchestration (Lesson 6). - -**Health-gated startup.** `depends_on` alone only orders *start*, not *readiness* — the API can start before the database accepts connections and crash. `condition: service_healthy` (backed by the db's `healthcheck`) makes the API wait until the database is actually ready. (This is the readiness idea from Module 06, now at the orchestration layer.) - -**Volumes persist data.** Containers are ephemeral — their filesystem vanishes on removal. A named **volume** keeps the database's data across restarts so `compose down && compose up` doesn't wipe your local data. - -**Ports: published vs internal.** `ports: ["8080:8080"]` publishes a port to the host (so you can hit it from your browser). Services that only other services need can stay unpublished — reachable on the internal network but not exposed. (Foreshadows network isolation in Lesson 6.) - -**One command.** `docker compose up` builds/pulls, creates the network and volumes, and starts everything in dependency order; `docker compose down` tears it all down. Reproducible, for every developer. - -### Common gotchas -- `depends_on` without a health condition (the API races the database and crashes). -- No volume for the database (data lost on every `down`). -- Hardcoding IPs/hosts instead of using service names. -- Publishing every port to the host (including internal-only services). - -## 🧪 Hands-on Labs - -Work through **`labs/lab-03-compose.md`**. You'll write the Forge `docker-compose.yml` (web + api + db) and a validator will parse it and assert the **wiring**: the api and web reference dependencies by service name, `api` waits for `db` with `condition: service_healthy`, the db has a named volume, and only the intended ports are published. A broken compose file (racey `depends_on`, no volume) fails the checks; the correct one passes. - -## 🔍 Engineering Investigation - -Bring the stack up conceptually and trace startup order: which service must be healthy before which starts, and what happens without the health gate (the API crash loop). Confirm the validator reports service-name wiring, the health-gated dependency, the persistent volume, and the published ports. Record the one command that replaces the old manual sequence. - -## 🤖 AI Engineering Exercise - -Ask an AI to "write a compose file for web, api, and a database." **Verify** services are wired by name, `depends_on` uses a health condition (not bare ordering), the database has a volume, and only necessary ports are published. **Log** where it raced startup or dropped the volume and your fix. - -## 📝 Assignment - -Submit the `docker-compose.yml`, the passing validation (service-name wiring, health-gated `depends_on`, persistent volume, published ports), and a before/after of the developer setup (manual steps → one command). - -## 🚀 Stretch Goal - -Add a seed/migration step (run the database migrations once on startup) or a compose `profiles` setup so a developer can bring up just the API + db without the web app, and explain the DX win. - -## ✅ Definition of Done - -- [ ] One compose file defines web, api, and database -- [ ] Services wired by name; startup health-gated (`service_healthy`) -- [ ] Database data persists via a named volume; needed ports published -- [ ] One command brings the stack up and down -- [ ] Validation passes (wiring, health gate, volume, ports) - -## 🪞 Reflection - -How many manual steps did one command replace? Why does gating startup on *health* (not just order) matter, and where have you seen a service race its database before? diff --git a/Lesson_04.md b/Lesson_04.md deleted file mode 100644 index 670ee62..0000000 --- a/Lesson_04.md +++ /dev/null @@ -1,103 +0,0 @@ -# Lesson 04 — Eliminate "Works on My Machine" - -> **Role:** Platform Engineer · **Competency:** Development Containers · **Track:** DEVENV · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: DEVENV-3001 -TITLE: A bug reproduces for one engineer and not another — environments differ -PRIORITY: P1 -TYPE: Reliability / Developer Experience -DESCRIPTION: Even with Compose, engineers have different host toolchains — Node - versions, global packages, OS libraries — so builds and bugs differ by - machine. Make the development environment itself reproducible: pin - versions and lockfiles, define a development container so everyone codes - in an identical environment, and ensure a clean checkout builds the same - way for everyone and in CI. - -ACCEPTANCE CRITERIA: - - Toolchain versions are pinned (language, package manager) and lockfiles committed - - A development container defines an identical environment for every engineer - - A clean checkout builds deterministically (same inputs → same result) - - The dev environment matches what CI uses (no host-specific drift) -``` - -## 🏢 Business Context - -"Works on my machine" is a reproducibility failure, and it's expensive: bugs that appear for one person and not another, builds that pass locally and fail in CI, hours lost to environment archaeology. The fix is to stop treating the developer's host as the environment. Pin everything, commit lockfiles, and define the environment as code — a development container — so every engineer (and CI) runs in a byte-identical setup. When the environment is reproducible, a bug reproduces everywhere, and "it builds" means it builds for everyone. - -## 🎯 Learning Objectives - -- Pin toolchain versions and commit lockfiles for deterministic installs -- Define a development container so every engineer's environment is identical -- Ensure a clean checkout builds deterministically -- Align the dev environment with CI (eliminate host drift) - -## 📚 Technical Deep Dive - -**Determinism = same inputs → same outputs.** A reproducible build depends only on committed inputs (source, pinned versions, lockfiles), never on whatever happens to be installed on the host. - -**Pin versions; commit lockfiles.** A loose dependency (`"express": "^4"`) resolves to different versions over time and across machines. A **lockfile** (`package-lock.json`, `pnpm-lock.yaml`) records the exact resolved versions; committing it and installing with `npm ci` (not `npm install`) gives every machine the same dependency tree. Pin the toolchain too — the Node version (`.nvmrc`/`engines`), the package manager version — so the *tools* don't drift either. - -```jsonc -// package.json -{ "engines": { "node": "22.13.x" }, "packageManager": "pnpm@9.7.0" } -``` - -**The development container defines the environment as code.** A dev container (e.g. a `.devcontainer/devcontainer.json` plus an image) specifies the exact OS, language runtime, tools, and extensions everyone codes in. Open the repo and you're in the same environment as every teammate — and as CI — regardless of your host OS. - -```jsonc -// .devcontainer/devcontainer.json -{ - "image": "mcr.microsoft.com/devcontainers/javascript-node:22", - "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, - "postCreateCommand": "pnpm install --frozen-lockfile" -} -``` - -**Pin the base for the dev image too.** Same rule as Lesson 2: a pinned base (`...node:22`, ideally a digest) keeps the dev environment from drifting under everyone's feet. - -**Dev matches CI.** The whole point: the environment you develop in is the environment that builds in CI (Lesson 7) and the base your production image builds from (Lesson 5). One reproducible toolchain, everywhere — so "passes locally" predicts "passes in CI." - -**Reproducibility is layered.** Lockfiles pin *dependencies*; the dev container pins the *environment*; the image (Lesson 2) pins the *runtime*. Together they close the "works on my machine" gap at every level. - -### Common gotchas -- `npm install` (re-resolves) instead of `npm ci` (installs the lockfile exactly). -- Lockfile not committed, or `.gitignore`-d — determinism lost. -- Unpinned toolchain (Node/PM version) so tools drift even if deps are pinned. -- A dev container that drifts from what CI/production actually use. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-04-devcontainer.md`**. You'll pin the toolchain (`engines`, `packageManager`), ensure a committed lockfile + `npm ci`-style install, and define a dev container config. A reproducibility validator parses the manifests and checks: dependencies are pinned via a lockfile (no floating ranges relied on at install), the toolchain version is pinned, the dev container specifies a pinned image, and the install command is the deterministic one — failing a "drifty" setup and passing the pinned one. - -## 🔍 Engineering Investigation - -Take a "drifty" setup (floating deps, no committed lockfile, `npm install`, unpinned Node) and identify each source of non-determinism. Fix each (lockfile committed, `npm ci`, pinned `engines`, pinned dev-container image) and re-run the validator to a clean pass. In your notebook, describe a bug that host drift could cause and how the dev container eliminates it. - -## 🤖 AI Engineering Exercise - -Ask an AI to "set up a reproducible dev environment." **Verify** it commits/uses a lockfile with `npm ci`, pins the toolchain, and defines a pinned dev-container image that matches CI. **Log** where it left floating versions or used `npm install` and your fix. - -## 📝 Assignment - -Submit: the pinned toolchain + committed lockfile + deterministic install, the dev-container config, the passing reproducibility validation, and a note on one host-drift bug the dev container eliminates. - -## 🚀 Stretch Goal - -Pin a base image by **digest** (`@sha256:...`) instead of a tag and explain the additional guarantee it gives, plus the maintenance trade-off (you must bump it deliberately). - -## ✅ Definition of Done - -- [ ] Toolchain versions pinned; lockfile committed; deterministic install (`npm ci`) -- [ ] Dev container defines an identical environment (pinned image) -- [ ] A clean checkout builds deterministically -- [ ] Dev environment matches CI -- [ ] Reproducibility validation passes - -## 🪞 Reflection - -Which source of non-determinism would have been hardest to track down as a bug? Why is `npm ci` + a committed lockfile a correctness practice, not just a convenience? diff --git a/Lesson_05.md b/Lesson_05.md deleted file mode 100644 index 34aacd7..0000000 --- a/Lesson_05.md +++ /dev/null @@ -1,108 +0,0 @@ -# Lesson 05 — Engineer Production Images - -> **Role:** Platform Engineer · **Competency:** Image Engineering · **Track:** IMG · **Est. time:** 4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: IMG-3010 -TITLE: The API image is 1.2 GB, runs as root, and ships the whole toolchain -PRIORITY: P1 — security & cost -TYPE: Optimization / Security -DESCRIPTION: The current image bundles the full build toolchain, dev dependencies, - and source into a fat, root-running image — slow to ship, expensive to - store, and a large attack surface. Engineer a production image: a - multi-stage build that compiles in one stage and ships only the runtime - artifact in a minimal, non-root final image with a healthcheck and no - secrets or build tooling. - -ACCEPTANCE CRITERIA: - - Multi-stage build: build tooling stays in the build stage, not the final image - - Final image is minimal (slim/distroless), non-root, with only the runtime artifact - - A HEALTHCHECK is defined; no secrets or dev dependencies in the final image - - The image is meaningfully smaller and has a smaller attack surface than the naive one -``` - -## 🏢 Business Context - -A production image is shipped thousands of times — pulled to every node, on every deploy, every scale-up. A fat image is slow to deploy, expensive to store and transfer, and dangerous: every compiler, dev dependency, and shell it carries is attack surface. Engineering a lean, minimal, non-root image is where containerization pays off in production — faster rollouts, lower cost, smaller blast radius. This is "least privilege / minimal surface" applied to the artifact itself. - -## 🎯 Learning Objectives - -- Use a multi-stage build to keep build tooling out of the final image -- Ship a minimal (slim/distroless), non-root final image with only the runtime artifact -- Add a healthcheck; exclude secrets and dev dependencies -- Reason about image size and attack surface as production concerns - -## 📚 Technical Deep Dive - -**Multi-stage builds separate building from running.** Build in a stage that has the toolchain; copy *only the artifact* into a clean, minimal final stage. The compilers and dev dependencies never reach production: - -```dockerfile -# --- build stage: has the full toolchain --- -FROM node:22.13-bookworm-slim AS build -WORKDIR /app -COPY package*.json ./ -RUN npm ci # all deps, incl. dev -COPY . . -RUN npm run build # produce dist/ - -# --- runtime stage: minimal, only what runs --- -FROM node:22.13-bookworm-slim AS runtime -WORKDIR /app -ENV NODE_ENV=production -COPY package*.json ./ -RUN npm ci --omit=dev # production deps only -COPY --from=build /app/dist ./dist # copy ONLY the build artifact -USER node # non-root runtime -HEALTHCHECK --interval=30s CMD node healthcheck.js -CMD ["node", "dist/server.js"] -``` - -**Minimal base = smaller and safer.** A `-slim` base drops hundreds of MB of OS packages; a **distroless** base goes further — no shell, no package manager, just the runtime — shrinking both size and attack surface (no shell for an attacker to use). The trade-off: harder to `exec` in and debug, so choose per service. - -**Only the artifact ships.** The final image contains the runtime, production dependencies, and the built output — not the source, not dev dependencies, not the build cache. Smaller image, fewer things that can have a CVE. - -**Non-root, healthcheck, no secrets.** Same disciplines as Lesson 2, now non-negotiable for production: a non-root `USER`, a `HEALTHCHECK` so the orchestrator (Lesson 8) knows the container's health, and absolutely no secrets baked in (they belong in the environment / a secret store at runtime). - -**Size and surface are measurable.** Image size (pull time, storage, deploy speed) and the count of packages/CVEs are real production metrics. Multi-stage + minimal base typically turns a >1 GB image into tens-to-low-hundreds of MB — a measurable win you should be able to attribute to specific choices. - -### Common gotchas -- Single-stage build that ships the compiler and dev dependencies to production. -- A fat base (`node` instead of `-slim`/distroless) with hundreds of unused packages. -- Copying the whole tree (`COPY . .`) into the final image instead of just the artifact. -- Running as root in production; baking secrets in; no healthcheck. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-05-prod-image.md`**. You'll engineer a multi-stage production Dockerfile for the Forge API and run it through the production-image linter: it must be **multi-stage** (≥2 `FROM`s), use a **minimal** base, run **non-root**, define a **HEALTHCHECK**, install **production-only** deps in the runtime stage, copy **only the artifact** (no `COPY . .` in the final stage), and bake **no secrets**. The naive single-stage/root image fails with specific findings; your engineered one passes. - -## 🔍 Engineering Investigation - -Lint the naive image (single-stage, fat base, root, dev deps) and record every finding. Engineer the multi-stage version and re-lint to a clean pass. Reason about (or measure) the size reduction and attribute it: how much from multi-stage (no toolchain), how much from the minimal base, how much from `--omit=dev`. Note the attack-surface reduction from dropping the shell (distroless). - -## 🤖 AI Engineering Exercise - -Ask an AI to "optimize this Dockerfile for production." **Verify** it goes multi-stage (build tooling out of the final image), uses a minimal non-root base, copies only the artifact, adds a healthcheck, and keeps secrets out. **Log** where it left a single stage, a fat base, or root and your fix. - -## 📝 Assignment - -Submit the multi-stage production Dockerfile, the before/after lint (naive → clean), an analysis of the size/attack-surface reduction attributed to specific choices, and confirmation of non-root + healthcheck + no secrets. - -## 🚀 Stretch Goal - -Convert the final stage to a **distroless** base, get the image building without a shell, and explain both the security gain and what you lose for debugging (and how you'd debug it anyway). - -## ✅ Definition of Done - -- [ ] Multi-stage build; build tooling stays out of the final image -- [ ] Minimal, non-root final image with only the runtime artifact -- [ ] HEALTHCHECK defined; production-only dependencies -- [ ] No secrets or dev dependencies in the final image -- [ ] Production-image lint passes; size/surface reduction attributed - -## 🪞 Reflection - -Which single change shrank the image most, and why? Why is a smaller image a *security* win and not only a speed/cost one? diff --git a/Lesson_06.md b/Lesson_06.md deleted file mode 100644 index 9d97462..0000000 --- a/Lesson_06.md +++ /dev/null @@ -1,103 +0,0 @@ -# Lesson 06 — Build the Container Network - -> **Role:** Platform Engineer · **Competency:** Container Networking · **Track:** NET · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: NET-4001 -TITLE: The database is reachable from the public internet -PRIORITY: P0 — security -TYPE: Architecture / Security -DESCRIPTION: Every service currently publishes its port to the host, so the - database and internal services are exposed far more widely than they - should be. Design the container network: services discover each other - by name on internal networks, only the edge (web/gateway) is exposed - publicly, the database sits on an internal-only network, and traffic is - segmented so a compromised service can't reach everything. - -ACCEPTANCE CRITERIA: - - Services communicate by name over defined networks (not host IPs) - - Only intended edge services publish ports to the host; the database does not - - The database is on an internal-only network, reachable solely by the API - - Network segmentation limits blast radius (front/back tiers separated) -``` - -## 🏢 Business Context - -A database exposed to the internet is a breach waiting to happen — and "publish every port" is how it happens by default. How containers network determines both whether services can find each other and who can reach what. Good network design gives services clean name-based discovery while keeping the attack surface tiny: only the edge is public, internal services talk on private networks, and segmentation means a compromised front-end service can't pivot straight to the database. This is least privilege applied to traffic. - -## 🎯 Learning Objectives - -- Connect services on defined networks and discover them by name -- Expose only intended edge services; keep internal services unpublished -- Place the database on an internal-only network reachable by just its consumer -- Segment networks (front/back tiers) to limit blast radius - -## 📚 Technical Deep Dive - -**Name-based service discovery.** On a container network, each service is reachable by its name — the API reaches the database at `db:5432`, the web app reaches the API at `api:8080`. No IP addresses, no hardcoded hosts; the network resolves names. (Same model as Compose in Lesson 3, now designed deliberately.) - -**Published vs internal ports.** Publishing a port (`ports: ["8080:8080"]`) maps it to the host, making it reachable from outside. A service that only *other services* call needs **no** published port — it's reachable on the internal network but invisible to the host and the internet. The database should never be published. - -**Segment the network into tiers.** Put services on separate networks so only the right ones can talk: - -```yaml -services: - web: - networks: [ frontend ] # edge: talks to api; published to host - ports: [ "3000:3000" ] - api: - networks: [ frontend, backend ] # bridges the tiers - db: - networks: [ backend ] # internal only — NO ports published, NOT on frontend -networks: - frontend: - backend: - internal: true # backend has no external connectivity -``` -Here the web app can reach the API (shared `frontend` network) but **cannot** reach the database — only the API, which is on both networks, can. A compromised web container can't touch the database directly. That's segmentation limiting blast radius. - -**Least privilege for traffic.** The default should be *deny*: a service can reach only what it must. Expose the minimum (just the edge), publish the minimum (only host-facing ports), and connect services to only the networks they need. An `internal: true` network has no route to the outside world at all. - -**This is the same model in production.** Kubernetes does this with Services (name-based discovery), ClusterIP (internal-only) vs LoadBalancer (exposed), and NetworkPolicies (segmentation) — Lesson 8. The concepts you design here carry straight up. - -### Common gotchas -- Publishing every service's port to the host (database on the internet). -- One flat network where every service can reach every other (no segmentation). -- Hardcoding IPs instead of using service names. -- Forgetting `internal: true` on the back tier, so it still has outbound/inbound routes. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-06-networking.md`**. You'll design the Forge network topology in compose form (frontend/backend tiers) and a validator will parse it and assert the **security properties**: the database publishes **no** host ports and is **not** on the frontend network; the web app is on the frontend (and published) but **cannot** reach the db; the api bridges both tiers; and the backend network is `internal: true`. A flat "everything exposed" topology fails the checks; the segmented one passes. - -## 🔍 Engineering Investigation - -Start from the "everything published, one network" topology and list exactly who can reach the database (everyone). Redesign with tiers and record the new reachability: web → api (yes), web → db (no), api → db (yes), host → db (no). Confirm the validator reports the database unpublished, off the frontend, and the backend internal. Note the breach the old topology invited. - -## 🤖 AI Engineering Exercise - -Ask an AI to "set up networking for these services." **Verify** only the edge publishes ports, the database is internal-only and unreachable from the front tier, services use names, and tiers are segmented. **Log** where it published the database or used one flat network and your fix. - -## 📝 Assignment - -Submit the segmented network topology, the passing validation (db unpublished + off frontend, backend `internal`, edge-only exposure, name-based discovery), a reachability table (who can reach what, before vs after), and a note on the blast-radius reduction. - -## 🚀 Stretch Goal - -Express the same segmentation as Kubernetes NetworkPolicies (default-deny + explicit allows) and explain how it maps to the compose tiers you designed. - -## ✅ Definition of Done - -- [ ] Services discover each other by name over defined networks -- [ ] Only edge services publish ports; the database does not -- [ ] Database on an internal-only network, reachable only by the API -- [ ] Tiers segmented; backend `internal: true`; blast radius limited -- [ ] Validation passes; reachability table documented - -## 🪞 Reflection - -Who could reach the database before, and who can now? Why is "deny by default, expose the minimum" the right posture for container traffic? diff --git a/Lesson_07.md b/Lesson_07.md deleted file mode 100644 index bc09a6b..0000000 --- a/Lesson_07.md +++ /dev/null @@ -1,111 +0,0 @@ -# Lesson 07 — Automate the Build Platform - -> **Role:** Platform Engineer · **Competency:** Build Automation · **Track:** CI · **Est. time:** 4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: CI-4010 -TITLE: Builds and deploys are run by hand and skip steps under pressure -PRIORITY: P1 -TYPE: Automation -DESCRIPTION: Today someone builds the images and pushes them manually; under - pressure, tests or scans get skipped, and a bad build reaches users. - Build the CI/CD pipeline: on every change, automatically install, - lint, test, build the image, scan it, and push it — with deploys gated - on the whole pipeline passing. The pipeline becomes the only path to - production, so quality gates can't be skipped. - -ACCEPTANCE CRITERIA: - - A pipeline runs on every push/PR: install → lint → test → build → scan → push - - Stages run in order; a failing stage stops the pipeline (no skipping gates) - - Deploy is gated on all prior stages passing (and uses the built image) - - Builds are cached/reproducible; the pipeline is defined as versioned config -``` - -## 🏢 Business Context - -If shipping depends on a human remembering every step, steps get skipped — especially under pressure, which is exactly when skipping is most dangerous. CI/CD makes the pipeline the only path to production: every change automatically runs the same install, lint, test, build, scan, and push, and nothing deploys unless all of it passes. The quality gates become unskippable, the build becomes reproducible, and "did someone run the tests?" stops being a question. Automating the path to production is what lets a team ship often *and* safely. - -## 🎯 Learning Objectives - -- Define a CI/CD pipeline as versioned configuration -- Order stages so a failure stops the pipeline (unskippable gates) -- Gate deployment on all prior stages passing, using the built artifact -- Make builds cached and reproducible - -## 📚 Technical Deep Dive - -**The pipeline is versioned config.** Like everything else this module, CI is declarative text in the repo, reviewed in PRs: - -```yaml -# .github/workflows/ci.yml (shape is similar across CI systems) -on: [push, pull_request] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: npm ci # deterministic install (Lesson 4) - - run: npm run lint - - run: npm test # gate: tests must pass - - run: docker build -t forge-api:${{ github.sha }} . # build the image - - run: trivy image forge-api:${{ github.sha }} # scan for vulnerabilities - - run: docker push forge-api:${{ github.sha }} # push to registry - deploy: - needs: build # gate: only if build job fully passed - if: github.ref == 'refs/heads/main' - steps: - - run: kubectl set image deploy/forge-api api=forge-api:${{ github.sha }} -``` - -**Stages run in order; failure stops the line.** install → lint → test → build → scan → push. If tests fail, the pipeline stops *before* building and pushing — a broken build never reaches the registry, let alone production. The gates are unskippable because they're not optional steps a human chooses to run. - -**Deploy is gated on the whole pipeline.** `deploy needs build`: deployment only happens if every prior stage passed, and it deploys the **exact image** that was built, tested, and scanned (tagged by commit SHA) — the build-once-run-anywhere artifact, promoted, not rebuilt. - -**Scan as a gate.** Building the image isn't enough — scan it for known vulnerabilities (CVEs in base image / dependencies) and fail the pipeline on serious findings, so a vulnerable image doesn't ship. Security becomes part of the unskippable path. - -**Reproducible, cached builds.** Use the deterministic install (`npm ci`, Lesson 4) and cache dependency/layer caches between runs so the pipeline is both reproducible and fast. The image built in CI is the artifact that runs in production. - -**The pipeline is the only path to production.** No manual `docker push` to prod, no hand-deploys. If it didn't go through the pipeline, it doesn't ship — that's what makes the gates meaningful. - -### Common gotchas -- Stages that don't actually gate (tests run but the pipeline continues on failure). -- Rebuilding the image for deploy instead of promoting the tested one (different artifact!). -- No vulnerability scan, so known-CVE images ship. -- Manual deploys that bypass the pipeline (gates become theater). -- Non-deterministic install (`npm install`) making CI flaky. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-07-ci.md`**. You'll define the Forge CI/CD pipeline as YAML and a validator will parse it and assert the **pipeline policy**: the stages exist and are in order (install → lint → test → build → scan → push), `deploy` declares `needs: build` (gated), the deploy uses the **built image tag** (not a rebuild or `latest`), and the install is the deterministic one. A pipeline that lets tests fail without stopping, or rebuilds for deploy, fails the checks; the correct one passes. - -## 🔍 Engineering Investigation - -Trace a change through the pipeline: where does a failing test stop it, and what never happens as a result (no build, no push, no deploy)? Confirm the validator reports the ordered stages, the `deploy needs build` gate, the scan stage, and that deploy uses the SHA-tagged built image. Note one incident that "the pipeline is the only path to prod" would have prevented. - -## 🤖 AI Engineering Exercise - -Ask an AI to "write a CI pipeline for this app." **Verify** stages are ordered and actually gate (failure stops the line), there's a vulnerability scan, deploy is gated on prior stages and promotes the built image (not a rebuild/`latest`), and the install is deterministic. **Log** where it let a stage fail silently or rebuilt for deploy and your fix. - -## 📝 Assignment - -Submit the pipeline config, the passing validation (ordered gating stages, scan, `deploy needs build`, promotes the built image, deterministic install), and a note on a release incident the unskippable pipeline prevents. - -## 🚀 Stretch Goal - -Add image signing / provenance (sign the built image, verify the signature before deploy) or a staged rollout gate (deploy to staging, run smoke tests, then promote to prod), and explain the supply-chain or safety guarantee it adds. - -## ✅ Definition of Done - -- [ ] Pipeline runs on push/PR: install → lint → test → build → scan → push -- [ ] Stages ordered; a failure stops the pipeline (unskippable gates) -- [ ] Deploy gated on all prior stages; promotes the built (SHA-tagged) image -- [ ] Deterministic, cached builds; pipeline defined as versioned config -- [ ] Validation passes - -## 🪞 Reflection - -Which gate would have been skipped under deadline pressure if a human ran it? Why must deploy promote the *tested* image rather than rebuild — what could differ if it rebuilt? diff --git a/Lesson_08.md b/Lesson_08.md deleted file mode 100644 index 1bc6151..0000000 --- a/Lesson_08.md +++ /dev/null @@ -1,117 +0,0 @@ -# Lesson 08 — Build the Production Container Platform - -> **Role:** Platform Engineer · **Competency:** Production Containers · **Track:** PROD · **Est. time:** 4–5 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: PROD-5001 -TITLE: A single container on one host is the whole production "platform" -PRIORITY: P1 -TYPE: Architecture / Operations -DESCRIPTION: Production runs one container on one machine: no redundancy, no health - recovery, no resource limits, and a deploy means downtime. Stand up a - production container platform with an orchestrator: multiple replicas - behind a service, health/readiness probes, resource requests and - limits, configuration via env/secret references, and zero-downtime - rolling updates. - -ACCEPTANCE CRITERIA: - - The service runs multiple replicas behind a stable service endpoint - - Liveness and readiness probes drive restart and traffic decisions - - Resource requests and limits are set; config/secrets injected (not baked in) - - Deploys are zero-downtime rolling updates with a rollback path -``` - -## 🏢 Business Context - -One container on one host is a demo, not a platform: a crash is an outage, a deploy is downtime, and a traffic spike has no headroom. A production container platform — an orchestrator like Kubernetes — runs multiple replicas, restarts unhealthy ones, routes traffic only to ready ones, enforces resource limits so one service can't starve others, injects configuration per environment, and rolls out new versions without downtime. This is where everything in the module comes together into something you can actually operate. It's "design for failure and operability" (Modules 03, 06) at the platform level. - -## 🎯 Learning Objectives - -- Run a service as multiple replicas behind a stable endpoint -- Configure liveness and readiness probes to drive restart/traffic decisions -- Set resource requests and limits; inject config/secrets (not baked in) -- Perform zero-downtime rolling updates with a rollback path - -## 📚 Technical Deep Dive - -**The orchestrator runs the desired state.** You declare what you want (N replicas of this image, with these resources and probes); the orchestrator makes reality match and keeps it there — restarting crashed containers, replacing unhealthy ones, rescheduling on node failure. Declarative, versioned manifests: - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: { name: forge-api } -spec: - replicas: 3 # redundancy + headroom - strategy: - type: RollingUpdate - rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } # zero-downtime - template: - spec: - containers: - - name: api - image: forge-api:1.4.2 # the pinned, tested artifact (Lesson 7) - resources: - requests: { cpu: "100m", memory: "128Mi" } # scheduler guarantees - limits: { cpu: "500m", memory: "256Mi" } # caps — can't starve neighbors - readinessProbe: { httpGet: { path: /readyz, port: 8080 }, initialDelaySeconds: 5 } - livenessProbe: { httpGet: { path: /healthz, port: 8080 }, periodSeconds: 10 } - envFrom: - - secretRef: { name: forge-api-secrets } # config/secrets injected -``` - -**Replicas + a stable service.** Multiple replicas give redundancy and capacity; a **Service** gives a stable name/endpoint that load-balances across the healthy replicas (name-based discovery from Lesson 6, in production). A pod dying doesn't take the service down. - -**Probes drive decisions** (the Module 06 health endpoints, now consumed): -- **Liveness** → *is it alive?* Fails → the orchestrator **restarts** it. -- **Readiness** → *can it serve?* Fails → it's pulled from the **load-balancer** (no traffic) but not restarted. -Keep liveness trivial and readiness dependency-aware (Module 06), or you get restart loops. - -**Resource requests and limits.** *Requests* are what the scheduler guarantees (and uses to place pods); *limits* are hard caps so a runaway service can't starve its neighbors. Without limits, one memory leak takes down the node. - -**Config and secrets are injected, not baked.** The image is the same everywhere (Lesson 2); per-environment config comes from ConfigMaps/Secrets mounted as env vars at runtime. Secrets never live in the image or the manifest in plaintext. - -**Zero-downtime rolling updates.** A rolling update replaces replicas gradually (`maxUnavailable: 0` keeps full capacity, `maxSurge: 1` adds one new pod at a time), routing traffic only to ready new pods and keeping old ones until the new are healthy — so a deploy causes no downtime, and a bad rollout can be rolled back to the previous version. - -### Common gotchas -- One replica (no redundancy; a crash or deploy is an outage). -- No resource limits (one service starves the node). -- Liveness that checks dependencies (restart loops) or no readiness (traffic to not-ready pods). -- `image: ...:latest` instead of the pinned tested tag (you don't know what's running). -- Secrets baked into the image or committed in the manifest. -- A recreate strategy (kill-all-then-start) instead of a rolling update → downtime. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-08-production.md`**. You'll write the Forge production manifests (a Deployment + Service) and a validator will parse the YAML and assert the **production properties**: `replicas >= 2`, a `RollingUpdate` strategy with `maxUnavailable: 0`, resource **requests and limits** set, **both** liveness and readiness probes, a **pinned** image (not `latest`), and config/secrets via references (not inline plaintext). A naive manifest (1 replica, no limits, `latest`, no probes) fails with specific findings; the production one passes. - -## 🔍 Engineering Investigation - -Lint the naive manifest (single replica, `latest`, no probes, no limits, recreate) and record every finding. Author the production manifest and re-validate to a clean pass. Trace a rolling update: what happens to traffic and old pods as new ones become ready, and how a failed rollout is rolled back. Note what each missing property would cost in a real incident (outage on crash, node starvation, traffic to a not-ready pod). - -## 🤖 AI Engineering Exercise - -Ask an AI to "write Kubernetes manifests for this service." **Verify** multiple replicas, requests+limits, both probes (liveness trivial / readiness dependency-aware), a pinned image, injected secrets, and a rolling-update strategy. **Log** where it shipped one replica, `latest`, no limits, or no probes and your fix. - -## 📝 Assignment - -Submit the production Deployment + Service manifests, the passing validation (replicas ≥ 2, rolling update with `maxUnavailable: 0`, requests+limits, liveness+readiness, pinned image, referenced secrets), a trace of a zero-downtime rollout + rollback, and a note on what each property prevents in an incident. - -## 🚀 Stretch Goal - -Add a HorizontalPodAutoscaler (scale on CPU/memory) or a PodDisruptionBudget, and explain how it keeps the service available under load or during node maintenance. - -## ✅ Definition of Done - -- [ ] Multiple replicas behind a stable service -- [ ] Liveness + readiness probes drive restart/traffic decisions -- [ ] Resource requests and limits set; config/secrets injected (not baked) -- [ ] Zero-downtime rolling update with a rollback path -- [ ] Pinned image (not `latest`); validation passes - -## 🪞 Reflection - -Which missing property (replicas, limits, probes, rolling update) would have caused the worst incident, and why? How do the Module 06 health endpoints become the orchestrator's restart-and-traffic decisions here? diff --git a/Lesson_09.md b/Lesson_09.md deleted file mode 100644 index d20280b..0000000 --- a/Lesson_09.md +++ /dev/null @@ -1,91 +0,0 @@ -# Lesson 09 — Project Forge Engineering Platform - -> **Role:** Platform Engineer · **Competency:** Engineering Platform Release · **Track:** CAP · **Est. time:** 16–20 hours - ---- - -## 🎫 Engineering Ticket - -``` -EPIC: FORGE-9600 -TITLE: Ship the Project Forge engineering platform -PRIORITY: P1 — module capstone -TYPE: Epic (integrative) -DESCRIPTION: You own turning Forge into a reproducible engineering platform. - Integrate everything: a monorepo with shared packages, containerized - services, a one-command dev environment, a reproducible toolchain, lean - production images, a segmented container network, an automated CI/CD - pipeline, and a production orchestration setup with health, limits, and - zero-downtime rollouts. Ship a coherent platform and a report that - proves each quality bar with evidence. - -ACCEPTANCE CRITERIA: (full mapping in assignments/capstone-brief.md) - - Monorepo: clear apps/packages boundaries, shared packages, enforced dependency direction - - Containerized services with hygienic, pinned, non-root images - - One-command dev environment (compose) with health-gated startup and persistent data - - Reproducible toolchain: pinned versions, committed lockfile, dev container matching CI - - Lean multi-stage production images (minimal, non-root, healthchecked, no secrets) - - Segmented network: edge exposed, database internal-only, blast radius limited - - CI/CD: ordered unskippable gates (install→lint→test→build→scan→push), deploy promotes the built image - - Production orchestration: replicas, probes, resource limits, injected config, zero-downtime rollouts - - An engineering-platform report proves each bar with reproducible evidence -``` - -## 🏢 Business Context - -This is the job: take Forge from "runs on the authors' laptops" to a platform a team can develop, build, ship, and operate reproducibly. Shipping a platform is an exercise in integration and judgment — the monorepo, the images, the network, the pipeline, and the orchestration all interact, and reproducibility, security, and operability can't be bolted on at the end. Any one piece is straightforward; composing them into a platform other engineers build on, and that ships safely without heroics, is the skill. - -## 🎯 Learning Objectives - -Integrate every module competency into a shippable platform: a monorepo with shared packages; containerized services; a one-command dev environment; a reproducible toolchain; lean production images; a segmented network; an automated CI/CD pipeline; and production orchestration with health, limits, and zero-downtime rollouts — all as versioned infrastructure-as-code with evidence. - -## 📚 Technical Deep Dive - -No new concepts — the capstone tests **integration, reproducibility, and judgment.** The full specification, the platform scope, the recommended build order, and the acceptance-criteria → rubric mapping live in **`assignments/capstone-brief.md`**; read it first and trace each criterion to the evidence you'll produce. - -A sound build order (detailed in the brief): - -1. **Repository** — the monorepo with apps/packages boundaries and enforced dependency direction (Lesson 1). -2. **Containers + dev environment** — Dockerfiles and a one-command compose stack (Lessons 2, 3). -3. **Reproducibility** — pinned toolchain, committed lockfile, dev container matching CI (Lesson 4). -4. **Production images** — lean multi-stage, minimal, non-root, healthchecked (Lesson 5). -5. **Network** — segmented tiers, edge-only exposure, internal-only database (Lesson 6). -6. **CI/CD + production** — the unskippable pipeline and the orchestrated production setup; assemble the report (Lessons 7, 8). - -Keep every artifact validating (lint/parse/policy-check green) throughout; build in small, verified increments. - -## 🧪 Hands-on Labs - -The capstone *is* the lab. The Dockerfiles, compose files, network topology, pipeline, and manifests reuse the earlier lab generators and validators, so you ship real, checkable infrastructure-as-code rather than prose, and the evidence (lints, parses, policy checks) is reproducible. - -## 🔍 Engineering Investigation - -Investigation is the deliverable. The engineering-platform report must show, with evidence: the monorepo's enforced dependency boundaries; the container images passing the hygiene lint; the one-command dev environment with health-gated startup; the reproducibility checks (pinned + lockfile + dev container); the production images passing the production lint (multi-stage, minimal, non-root, healthcheck, no secrets); the segmented network proving the database is unreachable from the edge; the CI/CD pipeline's ordered unskippable gates and built-image promotion; and the production manifests' replicas, probes, limits, and zero-downtime rollout. End with a "reproducibility, security & operability" summary: what each bar guarantees and how you verified it. - -## 🤖 AI Engineering Exercise - -Use AI throughout as a professional would — to draft a Dockerfile, a compose file, a pipeline, a manifest — **but every use follows draft → verify (parse/lint the config, run the build/policy logic, check the property) → log.** Maintain an AI-usage log. The recurring failures to catch: `:latest` and single-stage images, root containers, baked secrets, published databases, flat networks, non-gating pipelines, deploys that rebuild instead of promote, single replicas, and missing probes/limits. The linters, the parsers, and the policy checks are the arbiters. - -## 📝 Assignment - -Ship the Forge engineering platform per `assignments/capstone-brief.md`, using `assignments/capstone-submission-template.md`. Your submission is the working, validating infrastructure-as-code plus an **engineering-platform report** proving each quality bar with evidence, and the engineering notebook (including the AI-usage log). - -## 🚀 Stretch Goal - -Go beyond the brief in one production-grade way a real team would value — e.g. autoscaling (HPA), image signing/provenance in the pipeline, a staged rollout with smoke tests and automatic rollback, secret management with a real secret store, or observability (metrics/dashboards) wired into the platform — and justify it with evidence. - -## ✅ Definition of Done - -- [ ] Monorepo with apps/packages boundaries and enforced dependency direction -- [ ] Containerized services; hygienic, pinned, non-root images -- [ ] One-command dev environment (compose) with health-gated startup + persistent data -- [ ] Reproducible toolchain: pinned versions, committed lockfile, dev container matching CI -- [ ] Lean multi-stage production images (minimal, non-root, healthchecked, no secrets) -- [ ] Segmented network: edge exposed, database internal-only, blast radius limited -- [ ] CI/CD: ordered unskippable gates; deploy promotes the built image -- [ ] Production orchestration: replicas, probes, limits, injected config, zero-downtime rollouts -- [ ] Engineering-platform report + notebook + AI log complete and reproducible - -## 🪞 Reflection - -Which integration decision had the widest blast radius across the platform? Where did a reproducibility, security, or operability bar force a change you'd have skipped under time pressure — and why was building it in cheaper than the outage or the "works on my machine" hunt it prevents? diff --git a/MODULE_SYLLABUS.md b/MODULE_SYLLABUS.md deleted file mode 100644 index ca2f127..0000000 --- a/MODULE_SYLLABUS.md +++ /dev/null @@ -1,53 +0,0 @@ -# Module Syllabus — Platform Engineering & Containerization - -## Description -A ticket-driven module that turns *Project Forge* from "runs on the authors' laptops" into a reproducible **engineering platform**. Across 10 lessons and a capstone, you operate as a Platform Engineer closing tickets that move from reorganizing the code into a monorepo, through containerizing services and a one-command dev environment, making the toolchain reproducible, engineering lean production images, segmenting the network, automating an unskippable CI/CD pipeline, and operating a production container platform — culminating in shipping the platform. The emphasis is on **reproducibility, security, and operability**: build once and run anywhere, the artifact is immutable and configuration is injected, infrastructure is code, and the path to production is automated. - -## Prerequisites -- The Forge apps from earlier modules (M05 web, M06 API, M07 data) as the thing being packaged. -- Comfort at a command line and solid Git (Modules 01–02). -- Basic TypeScript/Node (Module 04) — the apps and the validators run on Node. -- **Node.js** and **npm**; a YAML parser for the validators (`npm i js-yaml`). **No Docker or Kubernetes is required** — the artifacts are declarative and verified by parsing/linting. - -## Pacing Options - -| Track | Cadence | Duration | -|-------|---------|----------| -| Intensive (bootcamp) | ~1 lesson/day; capstone over the last 4–5 days | ~2–3 weeks | -| Part-time (cohort) | 2 lessons/week | ~6 weeks | -| Self-paced | 1 lesson per sitting; capstone when ready | flexible | - -Most lessons are 3–4 hours including the lab; the capstone is 16–20 hours. - -## Module Arc - -| Phase | Lessons | Focus | -|-------|---------|-------| -| Foundations | 0 | the toolchain; build once, run anywhere | -| Repository & Local Dev | 1–3 | monorepo; containerize; one-command compose | -| Reproducibility | 4–5 | dev containers; production image engineering | -| Networking & Automation | 6–7 | container network segmentation; CI/CD | -| Production | 8 | production orchestration (replicas, probes, limits, rollouts) | -| Capstone | 9 | ship the full Forge engineering platform with evidence | - -## Lesson Structure -Every lesson follows the same shape: **Engineering Ticket → Business Context → Learning Objectives → Technical Deep Dive → Hands-on Labs → Engineering Investigation → AI Engineering Exercise → Assignment → Stretch Goal → Definition of Done → Reflection.** - -## Labs -Every lab carries the Forge platform forward and is **verified** by parsing and policy-checking the real artifacts: a Dockerfile **linter** (pinned base, multi-stage, non-root, healthcheck, layer order, `.dockerignore`), a real **YAML parser** (`js-yaml`) for compose/CI/Kubernetes (asserting the graph, the pipeline policy, the manifest properties), and **Node logic tests** (dependency boundaries, reproducibility, network reachability, gate order). There is **no container runtime** — the artifacts are declarative, so correctness is whether they declare the right thing; the same files run unchanged where Docker/Kubernetes exist. - -## Deliverables -- **Per lesson:** a completed lab, an assignment via `assignments/submission-template.md`, and an engineering-notebook entry (what you built → evidence → fixes → AI log). -- **Capstone:** the working, validating infrastructure-as-code platform, an engineering-platform report proving each quality bar with evidence (enforced monorepo boundaries, image hygiene lint, a health-gated one-command dev environment, the reproducibility checks, the production-image lint, a segmented network proving the db is unreachable from the edge, the pipeline's ordered unskippable gates and built-image promotion, and the production manifests' replicas/probes/limits/zero-downtime rollout), and the notebook — per `assignments/capstone-brief.md`. - -## Final Assessment -Graded against `ASSESSMENT_RUBRIC.md`: Repository Architecture (15%), Developer Experience (15%), Container Platform (15%), Build Automation (10%), Production Readiness (10%), Documentation (10%), Operational Quality (10%), Engineering Judgment (10%), AI Workflow (5%). - -## Support Materials -- `resources/` — platform setup; monorepo; Dockerfile; Docker Compose; reproducibility; production images; container networking; CI/CD; Kubernetes production; Dockerfile-lint reference; infrastructure-as-code; AI-workflow; notebook template. -- `dashboard.html` — an interactive progress tracker. -- `solutions/` — worked solutions (lint findings, parsed structure, policy results reproducible) to check against. -- `instructor-notes/` — per-lesson facilitation guidance. - -## Academic & Professional Integrity -AI assistance is **encouraged**, used as a professional would: every use follows **draft → verify (parse/lint the config, run the build/policy logic, check the property) → log.** The recurring failures to catch — `:latest`/single-stage images, root containers, baked secrets, published databases, flat networks, non-gating pipelines, rebuild-for-deploy, single replicas, missing probes/limits — are exactly what the linters and policy checks exist to surface. Unverified AI output in deliverables counts against you, and security/reproducibility shortcuts especially: a platform mistake ships to every environment. diff --git a/README.md b/README.md index be16eb6..cc0d463 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,83 @@ -# SWEXP Module 08 — Platform Engineering & Containerization - -**Theme:** Build Once. Run Anywhere — transform Project Forge into a reproducible engineering platform with a monorepo, containers, automated builds, and production-ready infrastructure. - -You are a **Platform Engineer**. Forge is three apps built across earlier modules — a web frontend (M05), an API (M06), and a data layer (M07) — that currently run only on the original authors' laptops, set up by hand. Across 10 ticket-driven lessons you reorganize Forge into a monorepo, containerize its services, wire a one-command dev environment, make the toolchain reproducible, engineer lean production images, segment the container network, automate an unskippable CI/CD pipeline, stand up a production orchestration platform, and ship it. - -The ethos, in every lesson: **build once, run anywhere**; **the artifact is immutable, configuration is injected**; **infrastructure as code**; **reproducible, not "works on my machine"**; **least privilege / minimal surface**; **automate the path to production**; **design for failure / operability**; and **developer experience is a feature**. AI is used as **draft → verify (parse/lint the config, run the build/policy logic, check the property) → log**. - -## How You Work Here - -| Step | What it means | -|------|---------------| -| Pick up a ticket | Each lesson is an engineering ticket (`DOCK-2001`, `CI-4010`, …) with acceptance criteria | -| Write infrastructure as code | Dockerfiles, compose, pipelines, manifests — declarative, versioned, reviewable | -| Validate the artifact | Lint Dockerfiles; parse and policy-check YAML; test the logic | -| Build once, inject config | One immutable image per service; configuration comes from the environment | -| Least privilege | Non-root images, minimal bases, no secrets baked in, the database off the edge | -| Automate the path | The CI/CD pipeline is the only way to ship; its gates are unskippable | -| Verify AI | Draft → verify (lint / parse / policy-check) → log | - -## Learning Outcomes - -By the end you will be able to: -- Organize a monorepo with shared packages and an enforced dependency direction. -- Containerize a service with a hygienic, pinned, non-root, cache-friendly Dockerfile. -- Stand up a one-command dev environment with Docker Compose (health-gated, persistent). -- Make the toolchain reproducible: pinned versions, committed lockfiles, dev containers. -- Engineer lean multi-stage production images (minimal, non-root, healthchecked, no secrets). -- Design a segmented container network where the database is unreachable from the edge. -- Automate an unskippable CI/CD pipeline that promotes the tested image. -- Operate a production container platform: replicas, probes, limits, zero-downtime rollouts. -- Ship a coherent engineering platform with evidence for each quality bar. - -## Lesson Index - -| # | Lesson | Competency | Ticket | -|---|--------|-----------|--------| -| 0 | Welcome to the Platform Engineering Team | Platform Engineering Orientation | PLAT-1000 | -| 1 | Reorganize Project Forge | Monorepo Architecture | REPO-1010 | -| 2 | Containerize the Platform | Docker Fundamentals | DOCK-2001 | -| 3 | Build a One-Command Development Environment | Docker Compose | COMPOSE-2010 | -| 4 | Eliminate "Works on My Machine" | Development Containers | DEVENV-3001 | -| 5 | Engineer Production Images | Image Engineering | IMG-3010 | -| 6 | Build the Container Network | Container Networking | NET-4001 | -| 7 | Automate the Build Platform | Build Automation | CI-4010 | -| 8 | Build the Production Container Platform | Production Containers | PROD-5001 | -| 9 | Project Forge Engineering Platform | Engineering Platform Release | FORGE-9600 | - -Phases: **Foundations** (0) → **Repository & Local Dev** (1–3) → **Reproducibility** (4–5) → **Networking & Automation** (6–7) → **Production** (8) → **Capstone** (9). - -## Repository Layout +# SWEXP Module 08 — Platform Engineering & Containerization (Interactive Workspace) +This is a **work-along starter workspace**, not a set of lessons to read. You learn by +shipping infrastructure-as-code: you author Dockerfiles, a `docker-compose.yml`, a CI +pipeline, and Kubernetes manifests, then an **autograder** checks each one against the +quality bar (lint clean, parses, policy-checks green). + +> The conceptual lessons, deep-dive guides, and the engineering-notebook template live in +> the LMS and in [`resources/`](resources/). This repo is the hands-on half. + +## How it works + +Every exercise is a self-contained folder under [`labs/`](labs/) (and the capstone under +[`assignments/`](assignments/)). Each folder contains: + +- A `README.md` — the ticket: goal, what to do, and the definition of done. +- A **starter artifact with `# TODO`s** — the file *you* edit. Depending on the lab this is a + `Dockerfile`, a `docker-compose.yml`, a `ci.yml`, a `deployment.yml`, a `package.json`, + and/or a `solution.sh`. +- `tests/*.bats` — the **spec**. These are the executable acceptance criteria. Read them. +- `fixtures/` — supporting files some labs need. + +You finish a lab when its tests are green. + +## Grading model (fast, deterministic, mostly Docker-free) + +The autograder grades your **files**, not a live cluster: + +- **Dockerfiles** are graded with static checks (pinned base, non-root `USER`, + `HEALTHCHECK`, multi-stage, no baked secrets, a complete `.dockerignore`). +- **compose / CI / k8s YAML** is graded by **parsing** it with `python3` and asserting the + required services, networks, gates, probes, limits, and image pinning. +- **shell scripts** (e.g. the monorepo boundary check) are run directly under `bats`. +- Exactly **one** lab does a real `docker build` (a tiny `alpine` image) to prove your + Dockerfile actually builds. Everything else is build-free, so grading is fast and reliable. + +## Quick start + +```bash +npm install # installs bats (the test runner) + +# work one lab: +npx bats labs/lab-00-setup/tests +# or grade everything (what CI runs): +npm run grade ``` -. -├── README.md # this file -├── MODULE_SYLLABUS.md # pacing, structure, deliverables -├── LEARNER_GUIDE.md # how to operate as a platform engineer here -├── INSTRUCTOR_GUIDE.md # facilitation and assessment -├── COMPETENCY_MATRIX.md # lesson → competency → skills -├── ASSESSMENT_RUBRIC.md # grading weights and performance levels -├── dashboard.html # interactive progress dashboard (open in a browser) -├── Lesson_00.md … Lesson_09.md # the 10 lessons -├── labs/ # hands-on labs (lint Dockerfiles; parse/policy-check YAML; test logic) -├── solutions/ # worked solutions / answer keys -├── resources/ # monorepo, Dockerfile, compose, reproducibility, images, networking, CI/CD, k8s + more -├── assignments/ # submission templates + capstone brief -└── instructor-notes/ # per-lesson facilitation notes -``` -## Getting Started +`npm run grade` prints a per-exercise scoreboard and writes `grade-report.md`. It exits +non-zero until **every** test passes and every shell script parses cleanly. Push your branch +and the **Autograde** GitHub Action runs the same grader and comments your score on the PR. + +## The labs + +| # | Folder | You author | Graded by | +|---|--------|-----------|-----------| +| 00 | `labs/lab-00-setup` | `config.yml` + a first `Dockerfile` | YAML parse + Dockerfile lint | +| 01 | `labs/lab-01-monorepo` | `solution.sh` (boundary check) | bats (runs your script) | +| 02 | `labs/lab-02-dockerfile` | `Dockerfile` + `.dockerignore` | static lint **+ one real `docker build`** | +| 03 | `labs/lab-03-compose` | `docker-compose.yml` | YAML parse (services, health-gate, volume) | +| 04 | `labs/lab-04-devcontainer` | `package.json` + `.devcontainer/devcontainer.json` | reproducibility checks | +| 05 | `labs/lab-05-prod-image` | multi-stage `Dockerfile` | production-image lint | +| 06 | `labs/lab-06-networking` | segmented `docker-compose.yml` | YAML reachability checks | +| 07 | `labs/lab-07-ci` | `ci.yml` (a GitHub Actions workflow) | pipeline policy parse | +| 08 | `labs/lab-08-production` | `deployment.yml` (Deployment + Service) | manifest policy parse | +| — | `assignments/capstone` | integrate all of the above | static + parse checks | + +## Requirements + +- **Node 20+** (for `bats` and the grader). +- **python3** (for YAML/JSON parsing in the graders) — preinstalled in the dev container. +- **Docker** — only `lab-02` does a real build; if Docker is unavailable that one build test + will fail but every other test still runs. The CI runner (and the dev container) have Docker. + +The fastest way in: open this repo in **GitHub Codespaces** (Code → Codespaces → Create) or +in VS Code Dev Containers — the [`.devcontainer`](.devcontainer/) gives you Node, python3, +Docker, and the `gh` CLI with zero setup. -1. Read `resources/platform-setup-guide.md`; set up the toolchain (Lesson 0 / `labs/lab-00-setup.md`). -2. Start your engineering notebook from `resources/engineering-notebook-template.md`. -3. Open `dashboard.html` in your browser to track progress through the lessons and phases. -4. Open `Lesson_00.md` and pick up your first ticket. Keep the relevant `resources/` references open as you build. +## Submitting -**Verification.** The deliverables are infrastructure-as-code, so verification means **parsing and policy-checking the real artifacts**: a Dockerfile **linter** (pinned base, multi-stage, non-root, healthcheck, layer order, `.dockerignore`), a real **YAML parser** for compose/CI/Kubernetes (asserting the graph, the pipeline policy, the manifest properties), and **Node logic tests** (dependency boundaries, reproducibility, network reachability, gate order). There is **no container runtime** here — the artifacts are declarative, and the same files run unchanged where Docker/Kubernetes exist. +Commit and push your branch. The autograder scores it automatically and comments on your PR. +Record your reasoning, trade-offs, and AI-usage log in the engineering notebook +(`resources/engineering-notebook-template.md`) and the submission templates under +[`assignments/`](assignments/). diff --git a/assignments/README.md b/assignments/README.md deleted file mode 100644 index 36eabd8..0000000 --- a/assignments/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Assignments — Platform Engineering & Containerization - -Each lesson has an assignment described in its `Lesson_NN.md`. Submit every one using `submission-template.md`, and back every claim with evidence — lint output, parsed structure, and policy-check results. - -| File | Purpose | -|------|---------| -| `submission-template.md` | per-lesson submission format | -| `capstone-brief.md` | the full FORGE-9600 engineering-platform specification | -| `capstone-submission-template.md` | the capstone platform-report format | - -## What every submission must include -- **What you built** and *why this design* — what's pinned, multi-stage, health-gated, segmented; what gates the pipeline; what's injected vs baked. -- **Evidence:** the Dockerfile lint (naive → clean), the parsed YAML structure/policy, the logic-check results (boundaries, reachability, gate order, reproducibility). -- **The fix at the cause** — a pinned base, a multi-stage split, a health gate, a network tier, a deploy gate, injected secrets. -- **AI-usage log:** draft → verify (parse/lint the config, run the policy logic) → log. -- **Clean commits** (your Module 02 Git skills apply). - -## Grading -Against `../ASSESSMENT_RUBRIC.md`. The recurring standard: **build once, run anywhere; the artifact is immutable, configuration is injected; infrastructure as code; reproducible, not "works on my machine"; least privilege / minimal surface; automate the path to production; design for failure / operability; DX is a feature.** diff --git a/assignments/capstone-brief.md b/assignments/capstone-brief.md deleted file mode 100644 index 88a100b..0000000 --- a/assignments/capstone-brief.md +++ /dev/null @@ -1,68 +0,0 @@ -# Capstone Brief — FORGE-9600: Ship the Project Forge Engineering Platform - -> **Epic:** FORGE-9600 · **Role:** Platform Engineer (owner) · **Est. time:** 16–20 hours (staged) · **Submission:** `capstone-submission-template.md` - -## The situation -*Project Forge* — a web frontend (M05), an API (M06), and a data layer (M07) — runs only on the original authors' laptops, set up by hand. You own turning it into a reproducible **engineering platform**: one repo, containerized services, a one-command dev environment, a reproducible toolchain, lean production images, a segmented network, automated CI/CD, and a production orchestration setup. You integrate everything from this module into one coherent platform and prove each quality bar with evidence. - -The capstone introduces **no new concepts.** It tests **integration, reproducibility, and judgment**: the monorepo, the images, the network, the pipeline, and the orchestration all interact, and reproducibility, security, and operability can't be bolted on at the end. - -## Platform scope -A working Forge platform (as infrastructure-as-code) with at least: -- **Monorepo** — apps/packages boundaries, shared packages, enforced dependency direction (Lesson 1). -- **Containerized services** — hygienic, pinned, non-root Dockerfiles with `.dockerignore` (Lesson 2). -- **One-command dev environment** — a compose stack wiring web + api + db, health-gated, with a volume (Lesson 3). -- **Reproducible toolchain** — pinned versions, committed lockfile, dev container matching CI (Lesson 4). -- **Production images** — lean multi-stage, minimal, non-root, healthchecked, no secrets (Lesson 5). -- **Segmented network** — edge exposed, database internal-only, blast radius limited (Lesson 6). -- **CI/CD** — ordered unskippable gates (install→lint→test→build→scan→push), deploy promotes the built image (Lesson 7). -- **Production orchestration** — replicas, probes, resource limits, injected config, zero-downtime rollouts (Lesson 8). - -## Build order (follow it) -1. **Repository** — the monorepo with boundaries + enforced dependency direction. (Lesson 1) -2. **Containers + dev environment** — Dockerfiles and the one-command compose stack. (Lessons 2, 3) -3. **Reproducibility** — pinned toolchain, committed lockfile, dev container matching CI. (Lesson 4) -4. **Production images** — lean multi-stage, minimal, non-root, healthchecked. (Lesson 5) -5. **Network** — segmented tiers, edge-only exposure, internal-only database. (Lesson 6) -6. **CI/CD + production** — the unskippable pipeline and the orchestrated production setup; assemble the report. (Lessons 7, 8) - -Keep every artifact validating (lint/parse/policy-check green) throughout; build in small, verified increments. - -## Phases (stage the work) -- **Phase A — Repository & local dev (monorepo + containers + compose).** -- **Phase B — Reproducibility (pinned toolchain + dev container + production images).** -- **Phase C — Network & automation (segmentation + CI/CD pipeline).** -- **Phase D — Production + the platform report (orchestration manifests + report).** - -## Acceptance criteria → rubric mapping -| Acceptance criterion | Rubric category | -|----------------------|-----------------| -| Monorepo: clear boundaries, shared packages, enforced dependency direction | Repository Architecture (15%) | -| One-command dev environment; compose health-gated; fast onboarding | Developer Experience (15%) | -| Containerized services + segmented network: hygienic images, internal-only db | Container Platform (15%) | -| CI/CD with ordered unskippable gates; deploy promotes the built image | Build Automation (10%) | -| Production orchestration: replicas, probes, limits, zero-downtime rollout | Production Readiness (10%) | -| Platform report documents each bar with reproducible evidence | Documentation (10%) | -| Reproducible toolchain, healthchecks, operable rollouts/rollback | Operational Quality (10%) | -| Sound, justified design; right trade-offs; no over-engineering; least privilege | Engineering Judgment (10%) | -| AI used as draft → verify → log | AI Workflow (5%) | - -## Deliverables -1. **The working platform** — validating infrastructure-as-code (Dockerfiles lint clean, compose/CI/k8s YAML parses + policy-checks green, monorepo boundaries + reproducibility enforced), reproducible. Reuse the lab generators and validators so the build is checkable. -2. **An engineering-platform report** proving each quality bar with evidence: the enforced monorepo boundaries; the image hygiene lint; the one-command health-gated dev environment; the reproducibility checks; the production-image lint; the segmented network proving the database is unreachable from the edge; the pipeline's ordered unskippable gates and built-image promotion; and the production manifests' replicas/probes/limits/zero-downtime rollout. -3. **The engineering notebook**, including the **AI-usage log**. -4. **A "reproducibility, security & operability" summary** — what each bar guarantees and how you verified it. - -## Definition of done -- [ ] Monorepo with boundaries and enforced dependency direction -- [ ] Containerized services; hygienic, pinned, non-root images -- [ ] One-command dev environment (compose) with health-gated startup + persistent data -- [ ] Reproducible toolchain: pinned versions, committed lockfile, dev container matching CI -- [ ] Lean multi-stage production images (minimal, non-root, healthchecked, no secrets) -- [ ] Segmented network: edge exposed, database internal-only, blast radius limited -- [ ] CI/CD: ordered unskippable gates; deploy promotes the built image -- [ ] Production orchestration: replicas, probes, limits, injected config, zero-downtime rollouts -- [ ] Platform report + notebook + AI log complete and reproducible - -## The standard -Build once, run anywhere; the artifact is immutable and configuration is injected; infrastructure as code; reproducible, not "works on my machine"; least privilege / minimal surface; automate the path to production; design for failure / operability; DX is a feature. A Dockerfile that lints clean, a compose graph that health-gates the database, a network where the db is unreachable from the edge, a pipeline whose gates actually gate, and a manifest with replicas/probes/limits/zero-downtime rollout are how "engineering platform" becomes true rather than asserted. diff --git a/assignments/capstone/README.md b/assignments/capstone/README.md new file mode 100644 index 0000000..78e28fc --- /dev/null +++ b/assignments/capstone/README.md @@ -0,0 +1,59 @@ +# Capstone — FORGE-9600: Ship the Project Forge Engineering Platform + +> **Epic:** FORGE-9600 · **Role:** Platform Engineer (owner) · **Submission:** +> `../capstone-submission-template.md` + +## The situation + +*Project Forge* — a web frontend, an API, and a data layer — runs only on the original +authors' laptops, set up by hand. You own turning it into a reproducible **engineering +platform**: one repo, containerized services, a segmented network, automated CI/CD, and a +production orchestration setup. The capstone introduces **no new concepts** — it tests +**integration, reproducibility, and judgment** by making every bar from Labs 01–08 hold at +once, in one place. + +You assemble the platform under [`platform/`](platform/) and prove each bar with the same +kinds of checks the labs used. The autograder grades your files — it never starts a cluster. + +## What you build (in `platform/`) + +1. **Monorepo boundaries** — [`platform/forge.deps`](platform/forge.deps): the dependency + edge list (`consumer -> dependency`). Must respect apps→packages, **no** package→app, **no** + cycles. (Lab 01) +2. **Production image** — [`platform/Dockerfile`](platform/Dockerfile): multi-stage, pinned, + non-root, healthchecked, production-only deps, no baked secrets. (Labs 02 & 05) +3. **Segmented dev/runtime topology** — + [`platform/docker-compose.yml`](platform/docker-compose.yml): web + api + db, the db + internal-only (publishes nothing, not reachable from `web`), the edge published. (Labs 03 & 06) +4. **CI/CD** — [`platform/.github/workflows/ci.yml`](platform/.github/workflows/ci.yml): + ordered unskippable gates (npm ci → lint → test → docker build → trivy → docker push), + `deploy` gated on `build`, promotes the SHA-tagged image, never `:latest`. (Lab 07) +5. **Production orchestration** — [`platform/k8s/deployment.yml`](platform/k8s/deployment.yml): + Deployment + Service; replicas ≥ 2, zero-downtime rolling update, requests+limits, both + probes, pinned image, secrets by reference. (Lab 08) + +## The integration script + +Complete [`solution.sh`](solution.sh): it runs the **monorepo boundary check** against +`platform/forge.deps` and prints every violation (a clean platform prints nothing and exits +0). This is the same skill as Lab 01, now wired against your capstone repo — it's the one +gate that's a real script, so the shell-syntax gate covers your platform too. + +## How it's graded + +```bash +npx bats assignments/capstone/tests +# or grade the whole module: npm run grade +``` + +Static checks on the Dockerfile; `python3` parses the compose, CI, and k8s YAML; your +`solution.sh` runs the boundary check. Every bar must hold simultaneously. + +## Deliverables + +1. The working platform under `platform/` (all checks green). +2. An engineering-platform report proving each bar with evidence (see + `../capstone-submission-template.md`). +3. The engineering notebook + the AI-usage log. +4. A "reproducibility, security & operability" summary — what each bar guarantees and how you + verified it. diff --git a/assignments/capstone/platform/.github/workflows/ci.yml b/assignments/capstone/platform/.github/workflows/ci.yml new file mode 100644 index 0000000..272d3e4 --- /dev/null +++ b/assignments/capstone/platform/.github/workflows/ci.yml @@ -0,0 +1,9 @@ +# Capstone — CI/CD pipeline. See README.md. +# +# TODO: jobs.build steps in order (npm ci -> lint -> test -> docker build -> trivy -> docker push), +# tag with ${{ github.sha }}; jobs.deploy: needs build, promotes the SHA-tagged image (no :latest). +# +# Replace everything below. + +on: [push, pull_request] +jobs: {} diff --git a/assignments/capstone/platform/Dockerfile b/assignments/capstone/platform/Dockerfile new file mode 100644 index 0000000..1562a71 --- /dev/null +++ b/assignments/capstone/platform/Dockerfile @@ -0,0 +1,7 @@ +# Capstone — production image (multi-stage, pinned, non-root, healthchecked). See README.md. +# +# TODO: a build stage (FROM AS build) and a runtime stage (FROM AS runtime): +# - pinned, slim base (not :latest) +# - npm ci (build) / npm ci --omit=dev or NODE_ENV=production (runtime) +# - COPY --from=build the artifact only +# - USER node, HEALTHCHECK, no baked secrets diff --git a/assignments/capstone/platform/docker-compose.yml b/assignments/capstone/platform/docker-compose.yml new file mode 100644 index 0000000..52e157e --- /dev/null +++ b/assignments/capstone/platform/docker-compose.yml @@ -0,0 +1,11 @@ +# Capstone — segmented web + api + db stack. See README.md. +# +# TODO: services web, api, db on frontend/backend networks (backend: internal: true): +# - web: frontend only, publishes 3000 +# - api: frontend + backend (the bridge) +# - db: backend only, publishes NOTHING +# +# Replace everything below. + +services: {} +networks: {} diff --git a/assignments/capstone/platform/forge.deps b/assignments/capstone/platform/forge.deps new file mode 100644 index 0000000..9fecb10 --- /dev/null +++ b/assignments/capstone/platform/forge.deps @@ -0,0 +1,3 @@ +# Capstone monorepo dependency edge list (consumer -> dependency). See README.md. +# TODO: declare the Forge graph. apps may depend on packages; packages must NOT +# depend on apps; no cycles. (A node with nothing after -> has no deps.) diff --git a/assignments/capstone/platform/k8s/deployment.yml b/assignments/capstone/platform/k8s/deployment.yml new file mode 100644 index 0000000..2b3c535 --- /dev/null +++ b/assignments/capstone/platform/k8s/deployment.yml @@ -0,0 +1,10 @@ +# Capstone — production manifests (Deployment + Service). See README.md. +# +# TODO: two documents separated by ---: +# Deployment: replicas >= 2; RollingUpdate maxUnavailable 0; requests+limits; +# readiness+liveness probes; pinned image (not :latest); envFrom secretRef. +# Service: selects the Deployment's pods. +# +# Replace this placeholder. + +placeholder: true diff --git a/assignments/capstone/solution.sh b/assignments/capstone/solution.sh new file mode 100755 index 0000000..32459d7 --- /dev/null +++ b/assignments/capstone/solution.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Capstone — monorepo boundary check for the assembled platform. See README.md. +# Usage: ./solution.sh [edge-list-file] (defaults to platform/forge.deps) +# Print each boundary/cycle violation on its own line (and exit 0). +# A clean platform prints nothing. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +graph="${1:-$here/platform/forge.deps}" + +# Reuse your Lab 01 skill against platform/forge.deps: +# TODO 1: print a "boundary" line for every packages/* -> apps/* edge. +# TODO 2: print a "cycle" line for every direct two-node cycle (A->B and B->A). diff --git a/assignments/capstone/tests/capstone.bats b/assignments/capstone/tests/capstone.bats new file mode 100644 index 0000000..a56e8ce --- /dev/null +++ b/assignments/capstone/tests/capstone.bats @@ -0,0 +1,100 @@ +setup() { + CAP_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + SOL="$CAP_DIR/solution.sh" + P="$CAP_DIR/platform" + export DF="$P/Dockerfile" + export COMPOSE="$P/docker-compose.yml" + export WF="$P/.github/workflows/ci.yml" + export MANIFEST="$P/k8s/deployment.yml" + export DEPS="$P/forge.deps" +} + +py() { python3 -c "$1"; } + +# ---- Bar 1: monorepo boundaries (via solution.sh) ---- + +@test "monorepo: the platform's dependency graph has no boundary/cycle violations" { + run bash "$SOL" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "monorepo: solution.sh still flags a bad graph (package -> app)" { + bad="$(mktemp)"; printf 'packages/types -> apps/web\napps/web ->\n' > "$bad" + run bash "$SOL" "$bad"; rm -f "$bad" + [ "$status" -eq 0 ] + echo "$output" | grep -qi 'boundary' +} + +# ---- Bar 2: production image ---- + +@test "image: multi-stage, pinned, non-root, healthchecked, no secrets" { + run bash -c "grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+[[:space:]]+AS[[:space:]]+\S+' '$DF'"; [ "$status" -eq 0 ] + [ "$(grep -Eci '^[[:space:]]*FROM[[:space:]]' "$DF")" -ge 2 ] + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+:latest' "$DF"; [ "$status" -ne 0 ] + run grep -Eiq '^[[:space:]]*COPY[[:space:]]+--from=build' "$DF"; [ "$status" -eq 0 ] + run bash -c "grep -Eiq '^[[:space:]]*USER[[:space:]]+\S+' '$DF' && ! grep -Eiq '^[[:space:]]*USER[[:space:]]+root([[:space:]]|\$)' '$DF'"; [ "$status" -eq 0 ] + run grep -Eiq '^[[:space:]]*HEALTHCHECK[[:space:]]' "$DF"; [ "$status" -eq 0 ] + run grep -Eiq '^[[:space:]]*(ENV|ARG)[[:space:]].*(PASSWORD|SECRET|TOKEN|API_?KEY)' "$DF"; [ "$status" -ne 0 ] +} + +# ---- Bar 3: segmented network ---- + +@test "network: db is internal-only and unreachable from the edge" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +nets=lambda s:set(c['services'][s].get('networks') or []) +assert (c.get('networks') or {}).get('backend',{}).get('internal') is True, 'backend must be internal' +assert not (c['services']['db'].get('ports') or []), 'db must publish nothing' +assert not (nets('web') & nets('db')), 'web must not reach db' +assert nets('api') & nets('db'), 'api must reach db' +assert any('3000' in str(p) for p in (c['services']['web'].get('ports') or [])), 'web (edge) must publish 3000' +print('ok') +" + [ "$status" -eq 0 ] +} + +# ---- Bar 4: CI/CD ---- + +@test "ci: ordered gates, deploy needs build, promotes SHA image (no :latest)" { + run py " +import yaml,os +wf=yaml.safe_load(open(os.environ['WF'])) +steps='\n'.join((s.get('run') or '') for s in wf['jobs']['build']['steps']) +last=-1 +for stage in ['npm ci','lint','test','docker build','trivy','docker push']: + i=steps.find(stage); assert i>last,'stage order/missing: '+stage; last=i +assert 'npm install' not in steps, 'use npm ci' +needs=wf['jobs']['deploy'].get('needs') +assert needs=='build' or (isinstance(needs,list) and 'build' in needs), 'deploy needs build' +dep='\n'.join((s.get('run') or '') for s in wf['jobs']['deploy']['steps']) +assert 'github.sha' in dep and ':latest' not in dep, 'deploy must promote SHA image, not :latest' +print('ok') +" + [ "$status" -eq 0 ] +} + +# ---- Bar 5: production orchestration ---- + +@test "k8s: replicas>=2, zero-downtime, limits, both probes, pinned image, secretRef" { + run py " +import yaml,os,json +docs=[d for d in yaml.safe_load_all(open(os.environ['MANIFEST'])) if isinstance(d,dict)] +dep=next((d for d in docs if d.get('kind')=='Deployment'),None) +svc=next((d for d in docs if d.get('kind')=='Service'),None) +assert dep and svc, 'need a Deployment and a Service' +assert (dep['spec'].get('replicas') or 0)>=2, 'replicas >= 2' +st=dep['spec'].get('strategy') or {} +assert st.get('type')=='RollingUpdate' and (st.get('rollingUpdate') or {}).get('maxUnavailable')==0, 'zero-downtime rollout' +c=dep['spec']['template']['spec']['containers'][0] +lim=(c.get('resources') or {}).get('limits') or {} +assert (c.get('resources') or {}).get('requests') and lim.get('cpu') and lim.get('memory'), 'requests+limits' +assert c.get('readinessProbe') and c.get('livenessProbe'), 'both probes' +img=c.get('image','') +assert ':' in img and not img.endswith(':latest'), 'pinned image' +assert 'secretRef' in json.dumps(c) or 'secretKeyRef' in json.dumps(c), 'secrets by reference' +print('ok') +" + [ "$status" -eq 0 ] +} diff --git a/labs/README.md b/labs/README.md deleted file mode 100644 index 7a68834..0000000 --- a/labs/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Labs — Platform Engineering & Containerization - -Hands-on labs for each lesson. You turn Forge into a reproducible platform, carried forward lesson to lesson. Because the deliverables are **infrastructure-as-code** (Dockerfiles, compose/CI/Kubernetes YAML), verification is done by **parsing and policy-checking the real artifacts**, not by hand-waving: - -- **Parse the real config.** Compose, CI, and Kubernetes YAML are parsed with a real YAML parser (`js-yaml`) and asserted against the structure/policy you intended — a Deployment really has replicas+limits+probes, a compose graph really health-gates the database, a pipeline really orders its gates. -- **Lint the Dockerfiles.** A Dockerfile linter parses the instructions and checks best practices — pinned (non-`latest`) base, multi-stage, non-root `USER`, `HEALTHCHECK`, cache-friendly layer order, `.dockerignore` hygiene, no baked secrets. A deliberately-bad file fails with specific findings; the good one passes. -- **Test the policy logic in Node.** Dependency-boundary rules, reproducibility checks, network reachability, and pipeline-gate logic are pure functions asserted with `node`. - -There is **no container runtime** here (no `docker`/`kubectl`), and that's fine: the point of these lessons is the *declarative artifact* and whether it declares what you intended. The same files run unchanged on a machine with Docker/Kubernetes. - -## How to use a lab -1. Read the matching `Lesson_NN.md` first. -2. Run the **Setup** (a generator writes the artifacts under `/tmp/forge-platform`). -3. Work the **Tasks**, parsing/linting the artifacts as you go. -4. Produce the **Deliverable** for your engineering notebook (include the lint output, the parse/policy results, the before/after). -5. Check your reasoning against `solutions/lab-NN-solution.md`. - -## Ground rules -- **Build once, run anywhere.** One immutable image per service; configuration injected, never baked. -- **Infrastructure as code.** Everything is versioned, reviewable text — validate it like you'd type-check code. -- **Reproducible, not "works on my machine."** Pin versions, commit lockfiles, deterministic installs. -- **Least privilege / minimal surface.** Non-root, minimal bases, no secrets in images, expose the minimum. -- **Evidence, not assertion.** Paste the real lint findings, the parsed structure, the policy-check results. - -## Prerequisites -- **Node.js** and **npm**. A YAML parser for the validators: `npm i js-yaml` (used in CommonJS: `const yaml = require('js-yaml')`). -- The validators are plain Node scripts — no Docker or Kubernetes required. - -## Lab index -| # | Lab | Focus | -|---|-----|-------| -| 0 | `lab-00-setup.md` | toolchain; validate a config + lint a Dockerfile | -| 1 | `lab-01-monorepo.md` | monorepo layout + dependency-boundary check | -| 2 | `lab-02-dockerfile.md` | Dockerfile + `.dockerignore` hygiene lint | -| 3 | `lab-03-compose.md` | one-command dev env; health-gated compose graph | -| 4 | `lab-04-devcontainer.md` | pinned toolchain + lockfile + dev container (reproducibility) | -| 5 | `lab-05-prod-image.md` | multi-stage minimal non-root production image | -| 6 | `lab-06-networking.md` | segmented network; internal-only database | -| 7 | `lab-07-ci.md` | CI/CD pipeline with ordered, unskippable gates | -| 8 | `lab-08-production.md` | production manifests: replicas, probes, limits, rollout | - -The Lesson 09 capstone reuses these to ship the full Forge engineering platform. diff --git a/labs/lab-00-setup.md b/labs/lab-00-setup.md deleted file mode 100644 index c5b60fe..0000000 --- a/labs/lab-00-setup.md +++ /dev/null @@ -1,68 +0,0 @@ -# Lab 00 — Toolchain, Config Validation & a Dockerfile Lint - -**Lesson:** 00 · **Goal:** set up the platform toolchain and validate the two artifact types you'll write all module — a YAML config and a Dockerfile. - -## Goal -Confirm you can parse/validate a config file and lint a Dockerfile, and trace one artifact from source to a running environment. - -## Setup -```bash -mkdir -p /tmp/forge-platform && cd /tmp/forge-platform -npm init -y >/dev/null -npm i js-yaml -``` -A tiny YAML config (`config.yml`) and a first Dockerfile (`Dockerfile`): -```yaml -# config.yml -service: forge-api -port: 8080 -replicas: 2 -``` -```dockerfile -# Dockerfile -FROM node:22.13-bookworm-slim -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -USER node -HEALTHCHECK CMD node healthcheck.js -CMD ["node", "server.js"] -``` - -## Tasks -1. **Validate the config.** Parse `config.yml` with `js-yaml` and assert it has the keys you intended (`service`, `port`, `replicas`) with sane values — the way you'd type-check code. -2. **Lint the Dockerfile.** Parse the instructions and check basic hygiene: pinned (non-`latest`) base, a non-root `USER`, a `HEALTHCHECK`. (You'll deepen this in Lesson 2.) -3. **Trace an artifact.** In your notebook, follow one Forge app: source → built image → which environments run the same image, and what's configuration vs baked-in. -4. **Build-once explainer.** Write 5–8 sentences on what "build once, run anywhere" buys a team. - -## Verify (example) -```js -// verify.cjs -const yaml = require('js-yaml'); -const fs = require('node:fs'); -const assert = require('node:assert'); -const cfg = yaml.load(fs.readFileSync('config.yml', 'utf8')); -assert.strictEqual(cfg.service, 'forge-api'); -assert.ok(cfg.port > 0 && cfg.replicas >= 1, 'sane config values'); -// minimal Dockerfile lint -const df = fs.readFileSync('Dockerfile', 'utf8'); -assert.ok(!/FROM\s+\S+:latest/i.test(df), 'base image not :latest'); -assert.ok(/^USER (?!root)/im.test(df), 'runs non-root'); -assert.ok(/HEALTHCHECK/i.test(df), 'has healthcheck'); -console.log('SETUP VERIFIED: config parses + Dockerfile passes basic lint'); -``` -```bash -node verify.cjs -``` - -## Deliverable -`node --version`; the validated config; the Dockerfile lint result; and the artifact-lifecycle sketch for one Forge app. - -## Cleanup -```bash -rm -f /tmp/forge-platform/verify.cjs # keep the project; later labs build on it -``` - -## Check -`../solutions/lab-00-solution.md`. diff --git a/labs/lab-00-setup/Dockerfile b/labs/lab-00-setup/Dockerfile new file mode 100644 index 0000000..15fe0a0 --- /dev/null +++ b/labs/lab-00-setup/Dockerfile @@ -0,0 +1,12 @@ +# Lab 00 — a first hygienic Dockerfile. See README.md. +# +# TODO 1: pin the base image (a real tag, NOT :latest, NOT untagged). +# e.g. FROM node:22.13-bookworm-slim +# +# TODO 2: set up the app (WORKDIR, copy package*.json, install, copy source). +# +# TODO 3: run as a NON-root user (e.g. USER node). +# +# TODO 4: declare a HEALTHCHECK. +# +# TODO 5: set the CMD that starts the service. diff --git a/labs/lab-00-setup/README.md b/labs/lab-00-setup/README.md new file mode 100644 index 0000000..696ee66 --- /dev/null +++ b/labs/lab-00-setup/README.md @@ -0,0 +1,38 @@ +# Lab 00 — Toolchain, Config Validation & a Dockerfile Lint + +**Goal:** validate the two artifact types you'll write all module — a YAML **config** and a +**Dockerfile** — the way you'd type-check code. + +## What you do + +Edit the two starter files in this folder: + +1. **[`config.yml`](config.yml)** — a tiny service config. It must declare: + - `service: forge-api` + - `port:` a positive integer (use `8080`) + - `replicas:` an integer `>= 1` (use `2`) + +2. **[`Dockerfile`](Dockerfile)** — a first, hygienic Dockerfile. It must: + - **Pin** its base image (a real tag, **not** `:latest` and not untagged) — + e.g. `FROM node:22.13-bookworm-slim`. + - Run as a **non-root** user (`USER node`, not `USER root`). + - Declare a `HEALTHCHECK`. + +Run the tests: + +```bash +npx bats labs/lab-00-setup/tests +# or everything: npm run grade +``` + +## How it's graded + +`config.yml` is parsed with `python3` (PyYAML if present, else a tiny fallback parser) and +its keys/values are asserted. The `Dockerfile` is statically linted with `grep`/`awk` — the +same checks `hadolint` would start with. + +## Definition of done + +- `npx bats labs/lab-00-setup/tests` is green. +- In your LMS notebook: explain "build once, run anywhere", and why a pinned base + non-root + + healthcheck matter before you ever run the image. diff --git a/labs/lab-00-setup/config.yml b/labs/lab-00-setup/config.yml new file mode 100644 index 0000000..20d16b0 --- /dev/null +++ b/labs/lab-00-setup/config.yml @@ -0,0 +1,5 @@ +# Lab 00 — service config. See README.md. +# TODO: declare the three keys the tests expect: +# service: forge-api +# port: a positive integer (e.g. 8080) +# replicas: an integer >= 1 (e.g. 2) diff --git a/labs/lab-00-setup/tests/setup.bats b/labs/lab-00-setup/tests/setup.bats new file mode 100644 index 0000000..427d2ff --- /dev/null +++ b/labs/lab-00-setup/tests/setup.bats @@ -0,0 +1,60 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + CFG="$LAB_DIR/config.yml" + DOCKERFILE="$LAB_DIR/Dockerfile" +} + +# ---- config.yml ---- + +@test "config.yml parses as YAML and declares service: forge-api" { + run python3 -c " +import yaml,sys +c=yaml.safe_load(open('$CFG')) +assert isinstance(c,dict), 'config is not a mapping' +assert c.get('service')=='forge-api', 'service must be forge-api, got %r'%c.get('service') +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "config.yml port is a positive integer" { + run python3 -c " +import yaml +c=yaml.safe_load(open('$CFG')) +p=c.get('port') +assert isinstance(p,int) and p>0, 'port must be a positive integer, got %r'%p +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "config.yml replicas is an integer >= 1" { + run python3 -c " +import yaml +c=yaml.safe_load(open('$CFG')) +r=c.get('replicas') +assert isinstance(r,int) and r>=1, 'replicas must be an integer >= 1, got %r'%r +print('ok') +" + [ "$status" -eq 0 ] +} + +# ---- Dockerfile lint ---- + +@test "Dockerfile has a FROM with a pinned (non-:latest, non-untagged) base" { + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+[^[:space:]]+:[^[:space:]]+' "$DOCKERFILE" + [ "$status" -eq 0 ] + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+:latest' "$DOCKERFILE" + [ "$status" -ne 0 ] +} + +@test "Dockerfile runs as a non-root USER" { + # must declare a USER, and that USER must not be root + run bash -c "grep -Eiq '^[[:space:]]*USER[[:space:]]+\S+' '$DOCKERFILE' && ! grep -Eiq '^[[:space:]]*USER[[:space:]]+root([[:space:]]|\$)' '$DOCKERFILE'" + [ "$status" -eq 0 ] +} + +@test "Dockerfile declares a HEALTHCHECK" { + run grep -Eiq '^[[:space:]]*HEALTHCHECK[[:space:]]' "$DOCKERFILE" + [ "$status" -eq 0 ] +} diff --git a/labs/lab-01-monorepo.md b/labs/lab-01-monorepo.md deleted file mode 100644 index 2ffed50..0000000 --- a/labs/lab-01-monorepo.md +++ /dev/null @@ -1,58 +0,0 @@ -# Lab 01 — Reorganize Project Forge into a Monorepo - -**Lesson:** 01 · **Goal:** a monorepo layout with shared packages and an enforced dependency direction (apps → packages, no cycles). - -## Goal -Define the Forge monorepo and prove the dependency boundaries with a check that fails a bad layout and passes the correct one. - -## Setup -The layout and workspace config: -``` -forge/ -├── package.json # { "private": true, "workspaces": ["apps/*","packages/*"] } -├── apps/ -│ ├── web/ # depends on @forge/types, @forge/ui -│ └── api/ # depends on @forge/types -└── packages/ - ├── types/ # the shared Order contract (no deps) - └── ui/ # depends on @forge/types -``` -Express the dependency graph as data (from each package's manifest `dependencies`): -```js -const graph = { - 'apps/web': ['packages/types', 'packages/ui'], - 'apps/api': ['packages/types'], - 'packages/ui': ['packages/types'], - 'packages/types': [], -}; -``` - -## Tasks -1. **Lay out the workspace.** Root `package.json` with `workspaces: ["apps/*","packages/*"]`; `apps/web`, `apps/api`, `packages/types`, `packages/ui`. -2. **Extract shared code.** Move the duplicated `Order` type into `@forge/types`; the apps depend on it instead of copy-pasting. -3. **Enforce dependency direction.** Run a boundary check: apps may depend on packages; **a package must not depend on an app**; **no cycles**. -4. **Prove it.** A deliberately-wrong graph (a package depending on an app, or a cycle) must fail; the correct graph passes. - -## Verify (example — using the shared validators) -```js -const { checkMonorepoBoundaries } = require('/tmp/pscaffold/validators.cjs'); -const assert = require('node:assert'); -const good = { 'apps/web':['packages/types','packages/ui'], 'apps/api':['packages/types'], 'packages/ui':['packages/types'], 'packages/types':[] }; -assert.deepStrictEqual(checkMonorepoBoundaries(good), [], 'valid: apps→packages, no cycles'); -const badDir = { 'packages/types':['apps/web'], 'apps/web':[] }; // package → app -assert.ok(checkMonorepoBoundaries(badDir).length >= 1, 'package depending on app is rejected'); -const cycle = { 'packages/a':['packages/b'], 'packages/b':['packages/a'] }; // cycle -assert.ok(checkMonorepoBoundaries(cycle).some(i => /cycle/.test(i)), 'cycle detected'); -console.log('MONOREPO VERIFIED: apps→packages enforced; package→app and cycles rejected'); -``` - -## Deliverable -The monorepo layout + workspace config, the extracted shared package, and the passing boundary check (with the bad-layout failures shown) — plus a note on the drift the old copy-paste caused. - -## Cleanup -```bash -rm -f /tmp/forge-platform/monorepo.cjs -``` - -## Check -`../solutions/lab-01-solution.md`. diff --git a/labs/lab-01-monorepo/README.md b/labs/lab-01-monorepo/README.md new file mode 100644 index 0000000..6ffb2e0 --- /dev/null +++ b/labs/lab-01-monorepo/README.md @@ -0,0 +1,51 @@ +# Lab 01 — Monorepo Boundary Check + +**Goal:** enforce the dependency direction of the Forge monorepo — **apps may depend on +packages, but a package must never depend on an app, and there must be no cycles.** + +## The model + +A monorepo's dependency graph is just data. We express it as a flat edge list, one edge +per line, `consumer -> dependency`: + +``` +apps/web -> packages/types +apps/web -> packages/ui +apps/api -> packages/types +packages/ui -> packages/types +packages/types -> +``` + +(A node with nothing after the arrow has no dependencies.) + +## What you do + +Complete [`solution.sh`](solution.sh). It takes one argument — a path to an edge-list file — +and **prints each boundary violation, one per line** (and exits `0`). A clean graph prints +**nothing**. You must catch two kinds of violation: + +1. **Wrong direction** — a `packages/*` node depending on an `apps/*` node. Print a line + containing the word `boundary` and the offending edge. +2. **Cycle** — a direct two-node cycle: `A -> B` *and* `B -> A`. Print a line containing the + word `cycle`. + +Hints: split each line on `->` with `awk` or parameter expansion; trim whitespace; a +`packages/* -> apps/*` edge is a `boundary` violation; for cycles, for every edge `A -> B` +check whether the reverse edge `B -> A` also exists. + +Run the tests: + +```bash +npx bats labs/lab-01-monorepo/tests +``` + +## Definition of done + +- The **correct** graph (`fixtures/good.deps`) prints nothing and exits 0. +- A `packages/* -> apps/*` graph prints a `boundary` line. +- A cyclic graph prints a `cycle` line. +- `npm run grade` shell-syntax gate stays clean. + +## Submit + +Commit and push. The autograder scores it. diff --git a/labs/lab-01-monorepo/fixtures/bad-direction.deps b/labs/lab-01-monorepo/fixtures/bad-direction.deps new file mode 100644 index 0000000..ab40cf3 --- /dev/null +++ b/labs/lab-01-monorepo/fixtures/bad-direction.deps @@ -0,0 +1,2 @@ +packages/types -> apps/web +apps/web -> diff --git a/labs/lab-01-monorepo/fixtures/cycle.deps b/labs/lab-01-monorepo/fixtures/cycle.deps new file mode 100644 index 0000000..394b572 --- /dev/null +++ b/labs/lab-01-monorepo/fixtures/cycle.deps @@ -0,0 +1,2 @@ +packages/a -> packages/b +packages/b -> packages/a diff --git a/labs/lab-01-monorepo/fixtures/good.deps b/labs/lab-01-monorepo/fixtures/good.deps new file mode 100644 index 0000000..c8ed6f4 --- /dev/null +++ b/labs/lab-01-monorepo/fixtures/good.deps @@ -0,0 +1,5 @@ +apps/web -> packages/types +apps/web -> packages/ui +apps/api -> packages/types +packages/ui -> packages/types +packages/types -> diff --git a/labs/lab-01-monorepo/solution.sh b/labs/lab-01-monorepo/solution.sh new file mode 100755 index 0000000..2710c0e --- /dev/null +++ b/labs/lab-01-monorepo/solution.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Lab 01 — Monorepo boundary check. See README.md. +# Usage: ./solution.sh +# Print each boundary violation on its own line (and exit 0). +# A clean graph prints nothing. +set -euo pipefail +graph="${1:?usage: solution.sh }" + +# Each line is "consumer -> dependency" (dependency may be empty). + +# TODO 1: print a line containing the word "boundary" for every edge where a +# packages/* node depends on an apps/* node (wrong direction). + +# TODO 2: print a line containing the word "cycle" for every direct two-node +# cycle: an edge "A -> B" whose reverse edge "B -> A" also exists. diff --git a/labs/lab-01-monorepo/tests/monorepo.bats b/labs/lab-01-monorepo/tests/monorepo.bats new file mode 100644 index 0000000..d97daf3 --- /dev/null +++ b/labs/lab-01-monorepo/tests/monorepo.bats @@ -0,0 +1,33 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + SOL="$LAB_DIR/solution.sh" + FIX="$LAB_DIR/fixtures" +} + +@test "the correct graph reports no violations" { + run bash "$SOL" "$FIX/good.deps" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "a package depending on an app is flagged as a boundary violation" { + run bash "$SOL" "$FIX/bad-direction.deps" + [ "$status" -eq 0 ] + echo "$output" | grep -qi 'boundary' +} + +@test "the bad-direction graph is not silently accepted" { + run bash "$SOL" "$FIX/bad-direction.deps" + [ -n "$output" ] +} + +@test "a two-node cycle is detected" { + run bash "$SOL" "$FIX/cycle.deps" + [ "$status" -eq 0 ] + echo "$output" | grep -qi 'cycle' +} + +@test "the correct graph is not falsely flagged with a cycle" { + run bash "$SOL" "$FIX/good.deps" + ! echo "$output" | grep -qi 'cycle' +} diff --git a/labs/lab-02-dockerfile.md b/labs/lab-02-dockerfile.md deleted file mode 100644 index ff75fc1..0000000 --- a/labs/lab-02-dockerfile.md +++ /dev/null @@ -1,76 +0,0 @@ -# Lab 02 — Containerize the Platform - -**Lesson:** 02 · **Goal:** a hygienic Dockerfile + `.dockerignore` for the Forge API, proven by a best-practice linter. - -## Goal -Write a Dockerfile that pins its base, orders layers for caching, runs non-root, has a healthcheck, and a `.dockerignore` that keeps the context clean — and prove it against a linter that fails a naive version. - -## Setup -A **naive** Dockerfile (what to fix): -```dockerfile -FROM node:latest -COPY . . -RUN npm install -CMD ["node", "server.js"] -``` -Your **target** Dockerfile: -```dockerfile -FROM node:22.13-bookworm-slim -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -USER node -HEALTHCHECK CMD node healthcheck.js -CMD ["node", "dist/server.js"] -``` -And `.dockerignore`: -``` -node_modules -.git -.env -*.log -dist -``` - -## Tasks -1. **Pin the base** (`node:22.13-bookworm-slim`, not `:latest`). -2. **Order for caching:** copy `package*.json` and `npm ci` **before** `COPY . .`, so code changes don't reinstall deps. -3. **Run non-root** (`USER node`). -4. **Add a `HEALTHCHECK`.** -5. **Ship a `.dockerignore`** excluding `node_modules`, `.git`, `.env`, logs. -6. **Lint both** — the naive Dockerfile must fail with specific findings; yours must pass clean. - -## Verify (example — using the shared validators) -```js -const { lintDockerfile, lintDockerignore } = require('/tmp/pscaffold/validators.cjs'); -const assert = require('node:assert'); -const naive = `FROM node:latest -COPY . . -RUN npm install -CMD ["node","server.js"]`; -const good = `FROM node:22.13-bookworm-slim -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -USER node -HEALTHCHECK CMD node healthcheck.js -CMD ["node","dist/server.js"]`; -const naiveIssues = lintDockerfile(naive); -assert.ok(naiveIssues.length >= 3, 'naive flagged: ' + naiveIssues.join('; ')); -assert.strictEqual(lintDockerfile(good).length, 0, 'target clean'); -assert.strictEqual(lintDockerignore('node_modules\n.git\n.env\n*.log\ndist').length, 0, '.dockerignore complete'); -console.log('DOCKERFILE VERIFIED: naive flagged [' + naiveIssues.join('; ') + ']; target + .dockerignore clean'); -``` - -## Deliverable -The Dockerfile + `.dockerignore`, the before/after lint (naive findings → clean), and a note on what each fixed finding prevents in production. - -## Cleanup -```bash -rm -f /tmp/forge-platform/dockerfile-lint.cjs -``` - -## Check -`../solutions/lab-02-solution.md`. diff --git a/labs/lab-02-dockerfile/.dockerignore b/labs/lab-02-dockerfile/.dockerignore new file mode 100644 index 0000000..684e4dd --- /dev/null +++ b/labs/lab-02-dockerfile/.dockerignore @@ -0,0 +1,6 @@ +# Lab 02 — keep the build context clean. See README.md. +# TODO: list the paths the build does NOT need. At minimum exclude: +# node_modules +# .git +# .env +# *.log diff --git a/labs/lab-02-dockerfile/Dockerfile b/labs/lab-02-dockerfile/Dockerfile new file mode 100644 index 0000000..efef6cd --- /dev/null +++ b/labs/lab-02-dockerfile/Dockerfile @@ -0,0 +1,11 @@ +# Lab 02 — hygienic Dockerfile for the Forge API. See README.md. +# +# TODO 1: pin the base image (NOT :latest). e.g. FROM node:22.13-bookworm-slim +# TODO 2: WORKDIR /app +# TODO 3: COPY package*.json ./ then RUN npm ci (BEFORE copying the source) +# TODO 4: COPY . . +# TODO 5: USER node (run as non-root) +# TODO 6: HEALTHCHECK ... +# TODO 7: CMD ["node", "server.js"] +# +# Do NOT bake secrets in via ENV/ARG (no *PASSWORD* / *SECRET* / *TOKEN* / *API_KEY*). diff --git a/labs/lab-02-dockerfile/README.md b/labs/lab-02-dockerfile/README.md new file mode 100644 index 0000000..941d544 --- /dev/null +++ b/labs/lab-02-dockerfile/README.md @@ -0,0 +1,43 @@ +# Lab 02 — Containerize the Platform + +**Goal:** a hygienic `Dockerfile` + `.dockerignore` for the Forge API, plus a tiny image you +actually **build and run** to prove the toolchain works end to end. + +## What you do + +### 1. Author the hygienic `Dockerfile` (static lint) + +Edit [`Dockerfile`](Dockerfile) so it passes the linter. It must: + +1. **Pin the base** — a real tag, **not** `:latest` (e.g. `FROM node:22.13-bookworm-slim`). +2. **Order layers for caching** — `COPY package*.json ./` and the install (`RUN npm ci`) + **before** `COPY . .`, so a source change doesn't reinstall dependencies. +3. **Run non-root** — `USER node` (not root). +4. **Add a `HEALTHCHECK`.** +5. Use a deterministic install (`npm ci`, **not** `npm install`). +6. **No baked secrets** — no `ENV`/`ARG` named like a secret (`*PASSWORD*`, `*SECRET*`, + `*TOKEN*`, `*API_KEY*`). + +### 2. Author the `.dockerignore` (static lint) + +Edit [`.dockerignore`](.dockerignore) so it excludes at least: `node_modules`, `.git`, +`.env`, and logs (`*.log`). + +### 3. Build a tiny image for real + +Edit [`greeting/Dockerfile`](greeting/Dockerfile) — a minimal **alpine/busybox** image whose +`CMD` prints exactly `forge-up`. The grader runs `docker build` then `docker run` on it and +checks the output. Keep it tiny (no installs) so the build is fast. + +```bash +npx bats labs/lab-02-dockerfile/tests +``` + +> The real-build test needs Docker. If Docker is unavailable locally, that single test will +> fail but the static-lint tests still run; CI has Docker. + +## Definition of done + +- `Dockerfile` and `.dockerignore` pass the lint tests. +- `greeting/Dockerfile` builds and `docker run` prints `forge-up`. +- A note on what each fixed finding prevents in production. diff --git a/labs/lab-02-dockerfile/greeting/Dockerfile b/labs/lab-02-dockerfile/greeting/Dockerfile new file mode 100644 index 0000000..6aea153 --- /dev/null +++ b/labs/lab-02-dockerfile/greeting/Dockerfile @@ -0,0 +1,7 @@ +# Lab 02 — a tiny image that is actually built and run by the grader. +# It must print exactly: forge-up +# +# TODO: base it on a small image (alpine or busybox) and set a CMD that prints forge-up. +# e.g. +# FROM alpine:3.20 +# CMD ["echo", "forge-up"] diff --git a/labs/lab-02-dockerfile/tests/dockerfile.bats b/labs/lab-02-dockerfile/tests/dockerfile.bats new file mode 100644 index 0000000..7421292 --- /dev/null +++ b/labs/lab-02-dockerfile/tests/dockerfile.bats @@ -0,0 +1,68 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + DF="$LAB_DIR/Dockerfile" + DI="$LAB_DIR/.dockerignore" + GREET="$LAB_DIR/greeting" +} + +# ---- Dockerfile static lint ---- + +@test "base image is pinned (not :latest, not untagged)" { + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+:\S+' "$DF" + [ "$status" -eq 0 ] + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+:latest' "$DF" + [ "$status" -ne 0 ] +} + +@test "deps are installed deterministically with npm ci (not npm install)" { + run grep -Eiq 'npm[[:space:]]+ci' "$DF" + [ "$status" -eq 0 ] + run grep -Eiq 'npm[[:space:]]+install' "$DF" + [ "$status" -ne 0 ] +} + +@test "layers are cache-ordered: package*.json copied before the full source COPY" { + # line number of `COPY package*.json` must be < line number of `COPY . .` + pkgln="$(grep -nEi '^[[:space:]]*COPY[[:space:]]+package\*?\.json' "$DF" | head -1 | cut -d: -f1)" + srcln="$(grep -nEi '^[[:space:]]*COPY[[:space:]]+\.[[:space:]]+\.' "$DF" | head -1 | cut -d: -f1)" + [ -n "$pkgln" ] + [ -n "$srcln" ] + [ "$pkgln" -lt "$srcln" ] +} + +@test "runs as a non-root USER" { + run bash -c "grep -Eiq '^[[:space:]]*USER[[:space:]]+\S+' '$DF' && ! grep -Eiq '^[[:space:]]*USER[[:space:]]+root([[:space:]]|\$)' '$DF'" + [ "$status" -eq 0 ] +} + +@test "declares a HEALTHCHECK" { + run grep -Eiq '^[[:space:]]*HEALTHCHECK[[:space:]]' "$DF" + [ "$status" -eq 0 ] +} + +@test "no secrets baked in via ENV/ARG" { + run grep -Eiq '^[[:space:]]*(ENV|ARG)[[:space:]].*(PASSWORD|SECRET|TOKEN|API_?KEY)' "$DF" + [ "$status" -ne 0 ] +} + +# ---- .dockerignore static lint ---- + +@test ".dockerignore excludes node_modules, .git, .env and logs" { + run grep -Eq '(^|/)node_modules/?[[:space:]]*$' "$DI"; [ "$status" -eq 0 ] + run grep -Eq '(^|/)\.git/?[[:space:]]*$' "$DI"; [ "$status" -eq 0 ] + run grep -Eq '(^|/)\.env[[:space:]]*$' "$DI"; [ "$status" -eq 0 ] + run grep -Eq '\*\.log[[:space:]]*$' "$DI"; [ "$status" -eq 0 ] +} + +# ---- ONE real build: the tiny greeting image must build and print forge-up ---- + +@test "greeting image builds and prints forge-up" { + command -v docker >/dev/null 2>&1 || { echo "docker not available"; return 1; } + tag="forge-lab02-$$-$RANDOM" + run docker build -q -t "$tag" "$GREET" + if [ "$status" -ne 0 ]; then echo "build failed: $output"; return 1; fi + run docker run --rm "$tag" + docker rmi -f "$tag" >/dev/null 2>&1 || true + [ "$status" -eq 0 ] + echo "$output" | grep -qx 'forge-up' +} diff --git a/labs/lab-03-compose.md b/labs/lab-03-compose.md deleted file mode 100644 index 174c8f5..0000000 --- a/labs/lab-03-compose.md +++ /dev/null @@ -1,66 +0,0 @@ -# Lab 03 — Build a One-Command Development Environment - -**Lesson:** 03 · **Goal:** a `docker-compose.yml` that brings up web + api + db, wired by name, health-gated, with a persistent volume — proven by parsing the compose graph. - -## Goal -Define the whole Forge stack in one compose file and validate the wiring: name-based connections, health-gated startup, a persistent DB volume, and only the intended published ports. - -## Setup -`docker-compose.yml`: -```yaml -services: - db: - image: postgres:16.2 - environment: { POSTGRES_PASSWORD: devpass } - volumes: [ "forge-data:/var/lib/postgresql/data" ] - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] - interval: 5s - api: - build: ./apps/api - environment: { DATABASE_URL: "postgres://postgres:devpass@db:5432/forge" } - depends_on: - db: { condition: service_healthy } - ports: [ "8080:8080" ] - web: - build: ./apps/web - environment: { API_URL: "http://api:8080" } - depends_on: - api: { condition: service_started } - ports: [ "3000:3000" ] -volumes: - forge-data: -``` - -## Tasks -1. **Define all three services** (web, api, db) in one file. -2. **Wire by name:** the api's `DATABASE_URL` points at `db`, the web's `API_URL` at `api` — service names, not IPs. -3. **Health-gate startup:** `api depends_on db: { condition: service_healthy }`, backed by the db's `healthcheck`. -4. **Persist data:** a named volume on the db. -5. **Publish only what developers need** (web 3000, api 8080). -6. **Validate the graph** by parsing the YAML and asserting the wiring. - -## Verify (example) -```js -const { loadYaml } = require('/tmp/pscaffold/validators.cjs'); -const fs = require('node:fs'); const assert = require('node:assert'); -const c = loadYaml(fs.readFileSync('docker-compose.yml', 'utf8')); -assert.ok(c.services.db && c.services.api && c.services.web, 'three services'); -assert.strictEqual(c.services.api.depends_on.db.condition, 'service_healthy', 'api waits for healthy db'); -assert.ok(c.services.db.healthcheck, 'db has a healthcheck'); -assert.ok((c.services.db.volumes || []).some(v => /forge-data/.test(v)), 'db has a persistent volume'); -assert.ok(/@db:/.test(c.services.api.environment.DATABASE_URL), 'api connects to db by name'); -assert.ok(/api:/.test(c.services.web.environment.API_URL), 'web connects to api by name'); -console.log('COMPOSE VERIFIED: 3 services, name-wired, health-gated, volume + ports'); -``` - -## Deliverable -The `docker-compose.yml`, the passing validation (name wiring, health-gated `depends_on`, persistent volume, published ports), and the before/after of developer setup (manual steps → one command). - -## Cleanup -```bash -rm -f /tmp/forge-platform/compose-check.cjs -``` - -## Check -`../solutions/lab-03-solution.md`. diff --git a/labs/lab-03-compose/README.md b/labs/lab-03-compose/README.md new file mode 100644 index 0000000..920b647 --- /dev/null +++ b/labs/lab-03-compose/README.md @@ -0,0 +1,34 @@ +# Lab 03 — One-Command Development Environment + +**Goal:** a `docker-compose.yml` that brings up **web + api + db**, wired by name, +health-gated, with a persistent volume — proven by parsing the compose graph. + +## What you do + +Complete [`docker-compose.yml`](docker-compose.yml). It must: + +1. **Define all three services:** `web`, `api`, `db`. +2. **Wire by name (service names, not IPs):** + - `api`'s `DATABASE_URL` connects to the `db` host — it must contain `@db:` + (e.g. `postgres://postgres:devpass@db:5432/forge`). + - `web`'s `API_URL` connects to the `api` host — it must contain `api:` + (e.g. `http://api:8080`). +3. **Health-gate startup:** the `db` declares a `healthcheck`, and `api` declares + `depends_on: { db: { condition: service_healthy } }`. +4. **Persist data:** a **named volume** on `db` (e.g. `forge-data:/var/lib/postgresql/data`) + and a top-level `volumes:` entry for it. +5. **Publish only what developers need:** `web` publishes `3000`, `api` publishes `8080`. + +```bash +npx bats labs/lab-03-compose/tests +``` + +## How it's graded + +The grader parses your YAML with `python3` and asserts the wiring above. It does **not** +run `docker compose up`. + +## Definition of done + +- Tests green. +- A note on the before/after of developer setup (manual steps → one command). diff --git a/labs/lab-03-compose/docker-compose.yml b/labs/lab-03-compose/docker-compose.yml new file mode 100644 index 0000000..cb3633a --- /dev/null +++ b/labs/lab-03-compose/docker-compose.yml @@ -0,0 +1,12 @@ +# Lab 03 — one-command dev environment. See README.md. +# +# TODO: define services web, api, db with: +# - db: image postgres:16.2, a healthcheck, and a NAMED volume for its data +# - api: DATABASE_URL pointing at db by name (must contain "@db:"), +# depends_on db with condition: service_healthy, publishes 8080 +# - web: API_URL pointing at api by name (must contain "api:"), publishes 3000 +# - a top-level volumes: entry for the db's named volume +# +# Replace everything below with your compose file. + +services: {} diff --git a/labs/lab-03-compose/tests/compose.bats b/labs/lab-03-compose/tests/compose.bats new file mode 100644 index 0000000..f7904aa --- /dev/null +++ b/labs/lab-03-compose/tests/compose.bats @@ -0,0 +1,85 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + COMPOSE="$LAB_DIR/docker-compose.yml" + export COMPOSE +} + +py() { python3 -c "$1"; } + +@test "defines all three services: web, api, db" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) or {} +s=c.get('services') or {} +for name in ('web','api','db'): + assert name in s, 'missing service: '+name +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "api connects to db by name (@db:) and web connects to api by name (api:)" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +def env(svc): + e=c['services'][svc].get('environment') or {} + if isinstance(e,list): + e=dict(x.split('=',1) for x in e if '=' in x) + return e +api=env('api'); web=env('web') +assert '@db:' in (api.get('DATABASE_URL') or ''), 'api DATABASE_URL must reference @db:' +assert 'api:' in (web.get('API_URL') or ''), 'web API_URL must reference api:' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "db has a healthcheck" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +assert c['services']['db'].get('healthcheck'), 'db needs a healthcheck' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "api waits for a healthy db (depends_on condition service_healthy)" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +dep=c['services']['api'].get('depends_on') +assert isinstance(dep,dict), 'api depends_on must use the long form with a condition' +assert dep.get('db',{}).get('condition')=='service_healthy', 'api must wait for db service_healthy' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "db persists data on a named volume declared at the top level" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +vols=c['services']['db'].get('volumes') or [] +names=[(v.split(':')[0] if isinstance(v,str) else v.get('source')) for v in vols] +named=[n for n in names if n and not n.startswith('.') and not n.startswith('/')] +assert named, 'db needs a named volume (not a bind mount)' +top=c.get('volumes') or {} +assert any(n in top for n in named), 'the named volume must be declared under top-level volumes:' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "web publishes 3000 and api publishes 8080" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +def ports(s): return [str(p) for p in (c['services'][s].get('ports') or [])] +assert any('3000' in p for p in ports('web')), 'web must publish 3000' +assert any('8080' in p for p in ports('api')), 'api must publish 8080' +print('ok') +" + [ "$status" -eq 0 ] +} diff --git a/labs/lab-04-devcontainer.md b/labs/lab-04-devcontainer.md deleted file mode 100644 index 41a2eae..0000000 --- a/labs/lab-04-devcontainer.md +++ /dev/null @@ -1,60 +0,0 @@ -# Lab 04 — Eliminate "Works on My Machine" - -**Lesson:** 04 · **Goal:** a reproducible toolchain — pinned versions, committed lockfile, deterministic install, and a dev container — proven by a reproducibility check. - -## Goal -Make the dev environment deterministic and prove it: pinned toolchain, a lockfile installed with `npm ci`, and a pinned dev-container image matching CI. - -## Setup -`package.json` (pinned toolchain) and a dev container config: -```jsonc -// package.json -{ "name": "forge", "engines": { "node": "22.13.x" }, "packageManager": "pnpm@9.7.0", - "dependencies": { "express": "4.19.2" } } // pinned, not "^4" -``` -```jsonc -// .devcontainer/devcontainer.json -{ "image": "mcr.microsoft.com/devcontainers/javascript-node:22", - "postCreateCommand": "npm ci" } // deterministic install, not npm install -``` -A committed `package-lock.json` (present in the repo). - -## Tasks -1. **Pin the toolchain:** `engines.node` and `packageManager` to exact versions. -2. **Pin dependencies + commit the lockfile;** install with **`npm ci`** (installs the lockfile exactly), not `npm install` (re-resolves). -3. **Define a dev container** with a **pinned** image so every engineer codes in the same environment. -4. **Match CI:** the install command and base used in the dev container are what CI uses (Lesson 7). -5. **Validate reproducibility:** a "drifty" setup (floating deps, no lockfile, `npm install`, unpinned Node) fails; the pinned one passes. - -## Verify (example) -```js -const assert = require('node:assert'); -function checkReproducibility(pkg, devcontainer, hasLockfile) { - const issues = []; - if (!pkg.engines || !/^\d/.test((pkg.engines.node||'').replace(/[~^]/,''))) issues.push('node not pinned'); - if (!pkg.packageManager) issues.push('package manager not pinned'); - if (!hasLockfile) issues.push('lockfile not committed'); - if (!/npm ci|--frozen-lockfile/.test(devcontainer.postCreateCommand || '')) issues.push('non-deterministic install'); - if (!/:\S/.test(devcontainer.image || '')) issues.push('dev container image not pinned'); - for (const [, v] of Object.entries(pkg.dependencies || {})) if (/^[\^~]/.test(v)) issues.push('floating dependency: ' + v); - return issues; -} -const goodPkg = { engines:{node:'22.13.x'}, packageManager:'pnpm@9.7.0', dependencies:{express:'4.19.2'} }; -const goodDc = { image:'mcr.microsoft.com/devcontainers/javascript-node:22', postCreateCommand:'npm ci' }; -assert.deepStrictEqual(checkReproducibility(goodPkg, goodDc, true), [], 'pinned setup reproducible'); -const driftyPkg = { dependencies:{ express:'^4' } }; -const driftyDc = { image:'node', postCreateCommand:'npm install' }; -assert.ok(checkReproducibility(driftyPkg, driftyDc, false).length >= 4, 'drifty setup flagged'); -console.log('REPRODUCIBILITY VERIFIED: pinned+lockfile+ci+pinned-image clean; drifty flagged'); -``` - -## Deliverable -The pinned toolchain + committed lockfile + `npm ci` install, the dev-container config, the passing reproducibility check (with drifty failures shown), and a note on a host-drift bug the dev container eliminates. - -## Cleanup -```bash -rm -f /tmp/forge-platform/repro-check.cjs -``` - -## Check -`../solutions/lab-04-solution.md`. diff --git a/labs/lab-04-devcontainer/.devcontainer/devcontainer.json b/labs/lab-04-devcontainer/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6e45ec2 --- /dev/null +++ b/labs/lab-04-devcontainer/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "_TODO": "pin the image to a tag (not a bare 'node'), and make postCreateCommand deterministic with npm ci.", + "image": "node", + "postCreateCommand": "npm install" +} \ No newline at end of file diff --git a/labs/lab-04-devcontainer/README.md b/labs/lab-04-devcontainer/README.md new file mode 100644 index 0000000..2d6ec03 --- /dev/null +++ b/labs/lab-04-devcontainer/README.md @@ -0,0 +1,36 @@ +# Lab 04 — Eliminate "Works on My Machine" + +**Goal:** a reproducible toolchain — pinned versions, a committed lockfile, a deterministic +install, and a pinned dev-container image — proven by a reproducibility check. + +## What you do + +Edit the three starter files in this folder: + +1. **[`package.json`](package.json):** + - `engines.node` pinned to an exact version (starts with a digit, e.g. `22.13.x`). + - `packageManager` pinned (e.g. `pnpm@9.7.0`). + - every entry in `dependencies` pinned to an **exact** version — **no** `^` or `~` + (e.g. `"express": "4.19.2"`). + +2. **[`.devcontainer/devcontainer.json`](.devcontainer/devcontainer.json):** + - `image` **pinned** to a tag (contains `:` followed by a tag, not a bare `node`). + - `postCreateCommand` installs deterministically: it must use `npm ci` + (or `--frozen-lockfile`), **not** `npm install`. + +3. **[`package-lock.json`](package-lock.json):** commit a real lockfile (it must exist and be + valid JSON). A `npm install` in this folder will generate one for you. + +```bash +npx bats labs/lab-04-devcontainer/tests +``` + +## How it's graded + +`python3` reads the two JSON files and applies the reproducibility rules above; a "drifty" +setup (floating deps, no lockfile, `npm install`, unpinned image) would be flagged. + +## Definition of done + +- Tests green. +- A note on a host-drift bug a pinned dev container eliminates. diff --git a/labs/lab-04-devcontainer/package.json b/labs/lab-04-devcontainer/package.json new file mode 100644 index 0000000..f41381a --- /dev/null +++ b/labs/lab-04-devcontainer/package.json @@ -0,0 +1,9 @@ +{ + "name": "forge", + "private": true, + "_TODO": "pin engines.node to an exact version, add packageManager, and pin every dependency (no ^ or ~).", + "engines": {}, + "dependencies": { + "express": "^4" + } +} diff --git a/labs/lab-04-devcontainer/tests/reproducibility.bats b/labs/lab-04-devcontainer/tests/reproducibility.bats new file mode 100644 index 0000000..c0cea27 --- /dev/null +++ b/labs/lab-04-devcontainer/tests/reproducibility.bats @@ -0,0 +1,70 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + export PKG="$LAB_DIR/package.json" + export DC="$LAB_DIR/.devcontainer/devcontainer.json" + export LOCK="$LAB_DIR/package-lock.json" +} + +py() { python3 -c "$1"; } + +@test "engines.node is pinned to an exact version" { + run py " +import json,os,re +p=json.load(open(os.environ['PKG'])) +n=(p.get('engines') or {}).get('node','') +n=re.sub(r'^[~^]','',n) +assert re.match(r'^\d',n), 'engines.node must be pinned to an exact version, got %r'%n +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "packageManager is pinned" { + run py " +import json,os +p=json.load(open(os.environ['PKG'])) +assert p.get('packageManager'), 'packageManager must be pinned (e.g. pnpm@9.7.0)' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "no floating (^ or ~) dependencies" { + run py " +import json,os +p=json.load(open(os.environ['PKG'])) +bad=[k for k,v in (p.get('dependencies') or {}).items() if str(v)[:1] in '^~'] +assert not bad, 'floating dependencies: '+', '.join(bad) +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "a committed lockfile exists and is valid JSON" { + [ -f "$LOCK" ] + run py "import json,os;json.load(open(os.environ['LOCK']));print('ok')" + [ "$status" -eq 0 ] +} + +@test "dev container image is pinned to a tag" { + run py " +import json,os +d=json.load(open(os.environ['DC'])) +img=d.get('image','') +assert ':' in img and not img.endswith(':'), 'dev container image must be pinned to a tag, got %r'%img +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "dev container install is deterministic (npm ci / frozen lockfile, not npm install)" { + run py " +import json,os,re +d=json.load(open(os.environ['DC'])) +cmd=d.get('postCreateCommand','') or '' +assert re.search(r'npm ci|--frozen-lockfile',cmd), 'use npm ci (or --frozen-lockfile)' +assert not re.search(r'npm install',cmd), 'do not use npm install in the dev container' +print('ok') +" + [ "$status" -eq 0 ] +} diff --git a/labs/lab-05-prod-image.md b/labs/lab-05-prod-image.md deleted file mode 100644 index 51f70cc..0000000 --- a/labs/lab-05-prod-image.md +++ /dev/null @@ -1,82 +0,0 @@ -# Lab 05 — Engineer Production Images - -**Lesson:** 05 · **Goal:** a multi-stage, minimal, non-root, healthchecked production image with production-only deps and no secrets — proven by the production-image linter. - -## Goal -Engineer a production Dockerfile that keeps the build toolchain out of the final image and ships only the runtime artifact, and prove it against a stricter production lint. - -## Setup -A **naive** single-stage image (what to fix): -```dockerfile -FROM node -COPY . . -RUN npm install -CMD ["node", "server.js"] -``` -Your **production** Dockerfile (multi-stage): -```dockerfile -FROM node:22.13-bookworm-slim AS build -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npm run build - -FROM node:22.13-bookworm-slim AS runtime -WORKDIR /app -ENV NODE_ENV=production -COPY package*.json ./ -RUN npm ci --omit=dev -COPY --from=build /app/dist ./dist -USER node -HEALTHCHECK --interval=30s CMD node healthcheck.js -CMD ["node", "dist/server.js"] -``` - -## Tasks -1. **Multi-stage:** build in one stage, ship a clean runtime stage (toolchain/dev deps stay behind). -2. **Minimal, non-root base** (`-slim`; `USER node`). -3. **Production-only deps** in the runtime stage (`npm ci --omit=dev` / `NODE_ENV=production`). -4. **Copy only the artifact** (`COPY --from=build /app/dist ./dist`), not the whole tree. -5. **HEALTHCHECK**, and **no baked secrets**. -6. **Lint with the production linter** — the naive image fails with specific findings; yours passes. - -## Verify (example — using the shared validators) -```js -const { lintProdImage } = require('/tmp/pscaffold/validators.cjs'); -const assert = require('node:assert'); -const naive = `FROM node -COPY . . -RUN npm install -CMD ["node","server.js"]`; -const prod = `FROM node:22.13-bookworm-slim AS build -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npm run build -FROM node:22.13-bookworm-slim AS runtime -WORKDIR /app -ENV NODE_ENV=production -COPY package*.json ./ -RUN npm ci --omit=dev -COPY --from=build /app/dist ./dist -USER node -HEALTHCHECK CMD node healthcheck.js -CMD ["node","dist/server.js"]`; -const naiveIssues = lintProdImage(naive); -assert.ok(naiveIssues.length >= 4, 'naive flagged: ' + naiveIssues.join('; ')); -assert.strictEqual(lintProdImage(prod).length, 0, 'production image clean'); -console.log('PROD IMAGE VERIFIED: naive flagged [' + naiveIssues.join('; ') + ']; production multi-stage clean'); -``` - -## Deliverable -The multi-stage production Dockerfile, the before/after production lint, an analysis of the size/attack-surface reduction attributed to specific choices (multi-stage, minimal base, `--omit=dev`), and confirmation of non-root + healthcheck + no secrets. - -## Cleanup -```bash -rm -f /tmp/forge-platform/prod-lint.cjs -``` - -## Check -`../solutions/lab-05-solution.md`. diff --git a/labs/lab-05-prod-image/Dockerfile b/labs/lab-05-prod-image/Dockerfile new file mode 100644 index 0000000..8edaffe --- /dev/null +++ b/labs/lab-05-prod-image/Dockerfile @@ -0,0 +1,22 @@ +# Lab 05 — multi-stage production image. See README.md. +# +# TODO — build stage: +# FROM node:22.13-bookworm-slim AS build +# WORKDIR /app +# COPY package*.json ./ +# RUN npm ci +# COPY . . +# RUN npm run build +# +# TODO — runtime stage: +# FROM node:22.13-bookworm-slim AS runtime +# WORKDIR /app +# ENV NODE_ENV=production +# COPY package*.json ./ +# RUN npm ci --omit=dev +# COPY --from=build /app/dist ./dist +# USER node +# HEALTHCHECK ... +# CMD ["node", "dist/server.js"] +# +# Bake NO secrets via ENV/ARG. diff --git a/labs/lab-05-prod-image/README.md b/labs/lab-05-prod-image/README.md new file mode 100644 index 0000000..d1daccf --- /dev/null +++ b/labs/lab-05-prod-image/README.md @@ -0,0 +1,29 @@ +# Lab 05 — Engineer Production Images + +**Goal:** a **multi-stage**, minimal, non-root, healthchecked production image with +production-only dependencies and **no baked secrets** — proven by a production-image lint. + +## What you do + +Complete [`Dockerfile`](Dockerfile). It must: + +1. **Be multi-stage:** a build stage `FROM AS build`, then a separate runtime + stage `FROM AS runtime`. The toolchain/dev deps stay in the build stage. +2. **Use a minimal, pinned base** (e.g. `node:22.13-bookworm-slim`, not `:latest`). +3. **Install production-only deps** in the runtime stage — `npm ci --omit=dev` and/or + `ENV NODE_ENV=production`. +4. **Copy only the build artifact** into runtime — at least one `COPY --from=build ...` + (e.g. `COPY --from=build /app/dist ./dist`), not the whole tree. +5. **Run non-root** (`USER node`). +6. **Declare a `HEALTHCHECK`.** +7. **Bake no secrets** (no `ENV`/`ARG` named `*PASSWORD*`/`*SECRET*`/`*TOKEN*`/`*API_KEY*`). + +```bash +npx bats labs/lab-05-prod-image/tests +``` + +## Definition of done + +- Tests green. +- An analysis of the size/attack-surface reduction from multi-stage + minimal base + + `--omit=dev`, and confirmation of non-root + healthcheck + no secrets. diff --git a/labs/lab-05-prod-image/tests/prod_image.bats b/labs/lab-05-prod-image/tests/prod_image.bats new file mode 100644 index 0000000..07143d8 --- /dev/null +++ b/labs/lab-05-prod-image/tests/prod_image.bats @@ -0,0 +1,45 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + DF="$LAB_DIR/Dockerfile" +} + +@test "is multi-stage: a named build stage and a second FROM" { + # a build stage: FROM ... AS build + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+[[:space:]]+AS[[:space:]]+\S+' "$DF" + [ "$status" -eq 0 ] + # at least two FROM instructions + n="$(grep -Eci '^[[:space:]]*FROM[[:space:]]' "$DF")" + [ "$n" -ge 2 ] +} + +@test "uses a pinned, non-:latest base" { + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+:\S+' "$DF" + [ "$status" -eq 0 ] + run grep -Eiq '^[[:space:]]*FROM[[:space:]]+\S+:latest' "$DF" + [ "$status" -ne 0 ] +} + +@test "installs production-only deps (--omit=dev or NODE_ENV=production)" { + run grep -Eiq '(npm[[:space:]]+ci[[:space:]].*--omit=dev|--production|NODE_ENV[[:space:]]*=?[[:space:]]*production)' "$DF" + [ "$status" -eq 0 ] +} + +@test "copies only the built artifact from the build stage (COPY --from=build)" { + run grep -Eiq '^[[:space:]]*COPY[[:space:]]+--from=build' "$DF" + [ "$status" -eq 0 ] +} + +@test "runs as a non-root USER" { + run bash -c "grep -Eiq '^[[:space:]]*USER[[:space:]]+\S+' '$DF' && ! grep -Eiq '^[[:space:]]*USER[[:space:]]+root([[:space:]]|\$)' '$DF'" + [ "$status" -eq 0 ] +} + +@test "declares a HEALTHCHECK" { + run grep -Eiq '^[[:space:]]*HEALTHCHECK[[:space:]]' "$DF" + [ "$status" -eq 0 ] +} + +@test "bakes no secrets via ENV/ARG" { + run grep -Eiq '^[[:space:]]*(ENV|ARG)[[:space:]].*(PASSWORD|SECRET|TOKEN|API_?KEY)' "$DF" + [ "$status" -ne 0 ] +} diff --git a/labs/lab-06-networking.md b/labs/lab-06-networking.md deleted file mode 100644 index e4b9d27..0000000 --- a/labs/lab-06-networking.md +++ /dev/null @@ -1,62 +0,0 @@ -# Lab 06 — Build the Container Network - -**Lesson:** 06 · **Goal:** a segmented network topology where the edge is exposed, the database is internal-only, and the web app can't reach the db — proven by parsing the topology. - -## Goal -Design front/back network tiers and validate the security properties: the database publishes no host ports and is unreachable from the front tier; only the edge is exposed. - -## Setup -A segmented `docker-compose.yml`: -```yaml -services: - web: - image: forge-web:1.0 - networks: [ frontend ] - ports: [ "3000:3000" ] # edge: published - api: - image: forge-api:1.0 - networks: [ frontend, backend ] # bridges tiers - db: - image: postgres:16.2 - networks: [ backend ] # internal only — no ports published -networks: - frontend: - backend: - internal: true -``` - -## Tasks -1. **Two tiers:** `frontend` and `backend` networks; `backend` is `internal: true`. -2. **Edge only:** `web` publishes `3000`; `db` publishes **nothing**. -3. **Segment:** `web` on `frontend` only; `db` on `backend` only; `api` on **both** (the only bridge). -4. **Reachability:** prove web→api (shared frontend) yes; web→db **no** (no shared network); api→db yes; host→db **no** (unpublished). -5. **Validate** by parsing the topology and computing reachability. - -## Verify (example) -```js -const { loadYaml } = require('/tmp/pscaffold/validators.cjs'); -const fs = require('node:fs'); const assert = require('node:assert'); -const c = loadYaml(fs.readFileSync('docker-compose.yml', 'utf8')); -const nets = s => new Set(c.services[s].networks || []); -const canReach = (a, b) => [...nets(a)].some(n => nets(b).has(n)); // share a network? -const published = s => (c.services[s].ports || []).length > 0; -assert.ok(!published('db'), 'database publishes NO host ports'); -assert.ok(!nets('db').has('frontend'), 'database not on the frontend network'); -assert.strictEqual(c.networks.backend.internal, true, 'backend network is internal'); -assert.ok(canReach('web', 'api'), 'web can reach api'); -assert.ok(!canReach('web', 'db'), 'web CANNOT reach db'); -assert.ok(canReach('api', 'db'), 'api can reach db'); -assert.ok(published('web'), 'edge (web) is published'); -console.log('NETWORK VERIFIED: db internal-only & unreachable from edge; api bridges; least exposure'); -``` - -## Deliverable -The segmented topology, the passing validation, a reachability table (web→api yes, web→db no, api→db yes, host→db no) before vs after, and a note on the blast-radius reduction. - -## Cleanup -```bash -rm -f /tmp/forge-platform/net-check.cjs -``` - -## Check -`../solutions/lab-06-solution.md`. diff --git a/labs/lab-06-networking/README.md b/labs/lab-06-networking/README.md new file mode 100644 index 0000000..4c0e630 --- /dev/null +++ b/labs/lab-06-networking/README.md @@ -0,0 +1,33 @@ +# Lab 06 — Build the Container Network + +**Goal:** a segmented topology where the **edge is exposed, the database is internal-only, +and the web app cannot reach the db** — proven by parsing the topology and computing +reachability. + +## What you do + +Complete [`docker-compose.yml`](docker-compose.yml). It must define two networks and three +services: + +1. **Two tiers:** networks `frontend` and `backend`; `backend` is `internal: true`. +2. **Segment the services:** + - `web` → `frontend` only, and **publishes** `3000:3000` (the edge). + - `api` → **both** `frontend` and `backend` (the only bridge between tiers). + - `db` → `backend` only, and **publishes nothing**. +3. The resulting reachability must be: web→api **yes** (shared `frontend`), web→db **no** + (no shared network), api→db **yes**, host→db **no** (db publishes no ports). + +```bash +npx bats labs/lab-06-networking/tests +``` + +## How it's graded + +`python3` parses the topology and computes reachability as "do these two services share a +network?" — then asserts the security properties. No containers are started. + +## Definition of done + +- Tests green. +- A reachability table (web→api yes, web→db no, api→db yes, host→db no) and a note on the + blast-radius reduction. diff --git a/labs/lab-06-networking/docker-compose.yml b/labs/lab-06-networking/docker-compose.yml new file mode 100644 index 0000000..f407aa3 --- /dev/null +++ b/labs/lab-06-networking/docker-compose.yml @@ -0,0 +1,11 @@ +# Lab 06 — segmented container network. See README.md. +# +# TODO: define networks frontend and backend (backend: internal: true), and services: +# - web: networks [frontend], ports ["3000:3000"] +# - api: networks [frontend, backend] (bridges the two tiers) +# - db: networks [backend] (no published ports) +# +# Replace everything below with your topology. + +services: {} +networks: {} diff --git a/labs/lab-06-networking/tests/networking.bats b/labs/lab-06-networking/tests/networking.bats new file mode 100644 index 0000000..52fefcd --- /dev/null +++ b/labs/lab-06-networking/tests/networking.bats @@ -0,0 +1,55 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + export COMPOSE="$LAB_DIR/docker-compose.yml" +} + +py() { python3 -c "$1"; } + +@test "backend network is internal: true" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +b=(c.get('networks') or {}).get('backend') or {} +assert b.get('internal') is True, 'backend network must be internal: true' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "db publishes no host ports and is not on the frontend network" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +db=c['services']['db'] +assert not (db.get('ports') or []), 'db must publish NO ports' +assert 'frontend' not in (db.get('networks') or []), 'db must not be on frontend' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "edge (web) is published on frontend only" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +web=c['services']['web'] +assert any('3000' in str(p) for p in (web.get('ports') or [])), 'web must publish 3000' +assert (web.get('networks') or [])==['frontend'] or set(web.get('networks') or [])=={'frontend'}, 'web must be on frontend only' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "reachability: web->api yes, web->db no, api->db yes" { + run py " +import yaml,os +c=yaml.safe_load(open(os.environ['COMPOSE'])) +def nets(s): return set(c['services'][s].get('networks') or []) +reach=lambda a,b: bool(nets(a) & nets(b)) +assert reach('web','api'), 'web must reach api (shared frontend)' +assert not reach('web','db'), 'web must NOT reach db' +assert reach('api','db'), 'api must reach db (shared backend)' +print('ok') +" + [ "$status" -eq 0 ] +} diff --git a/labs/lab-07-ci.md b/labs/lab-07-ci.md deleted file mode 100644 index 7dfc56c..0000000 --- a/labs/lab-07-ci.md +++ /dev/null @@ -1,63 +0,0 @@ -# Lab 07 — Automate the Build Platform - -**Lesson:** 07 · **Goal:** a CI/CD pipeline with ordered, unskippable gates that deploys the built image — proven by parsing the pipeline and checking the policy. - -## Goal -Define the Forge pipeline as YAML and validate the policy: ordered stages (install → lint → test → build → scan → push), a deploy gated on the build job, deploy promotes the **built (SHA-tagged)** image, and the install is deterministic. - -## Setup -`.github/workflows/ci.yml`: -```yaml -on: [push, pull_request] -jobs: - build: - runs-on: ubuntu-latest - steps: - - run: npm ci - - run: npm run lint - - run: npm test - - run: docker build -t forge-api:${{ github.sha }} . - - run: trivy image forge-api:${{ github.sha }} - - run: docker push forge-api:${{ github.sha }} - deploy: - needs: build - if: github.ref == 'refs/heads/main' - steps: - - run: kubectl set image deploy/forge-api api=forge-api:${{ github.sha }} -``` - -## Tasks -1. **Ordered stages** in the build job: `npm ci` → lint → test → build → scan → push. -2. **Gates are real:** the pipeline stops on any failing step (a failed test means no build/push/deploy). -3. **Deploy is gated:** `deploy` declares `needs: build` (only runs if build fully passed). -4. **Promote, don't rebuild:** deploy sets the image to the **SHA-tagged** artifact built+tested+scanned (not a rebuild, not `:latest`). -5. **Deterministic install** (`npm ci`). -6. **Validate** by parsing the YAML and checking these policies. - -## Verify (example) -```js -const { loadYaml } = require('/tmp/pscaffold/validators.cjs'); -const fs = require('node:fs'); const assert = require('node:assert'); -const wf = loadYaml(fs.readFileSync('ci.yml', 'utf8')); -const steps = wf.jobs.build.steps.map(s => s.run || '').join('\n'); -const order = ['npm ci', 'lint', 'test', 'docker build', 'trivy', 'docker push']; -let last = -1; -for (const stage of order) { const i = steps.indexOf(stage); assert.ok(i > last, 'stage in order: ' + stage); last = i; } -assert.strictEqual(wf.jobs.deploy.needs, 'build', 'deploy gated on build'); -const deployStep = wf.jobs.deploy.steps.map(s => s.run || '').join('\n'); -assert.ok(/forge-api:\$\{\{ github.sha \}\}/.test(deployStep), 'deploy promotes the SHA-tagged built image'); -assert.ok(!/:latest/.test(deployStep), 'deploy does not use :latest'); -assert.ok(/npm ci/.test(steps) && !/npm install/.test(steps), 'deterministic install'); -console.log('CI VERIFIED: ordered gates (install→lint→test→build→scan→push), deploy needs build, promotes built image'); -``` - -## Deliverable -The pipeline config, the passing validation (ordered gating stages, scan present, `deploy needs build`, promotes the SHA-tagged image, deterministic install), and a note on a release incident the unskippable pipeline prevents. - -## Cleanup -```bash -rm -f /tmp/forge-platform/ci-check.cjs -``` - -## Check -`../solutions/lab-07-solution.md`. diff --git a/labs/lab-07-ci/README.md b/labs/lab-07-ci/README.md new file mode 100644 index 0000000..3853552 --- /dev/null +++ b/labs/lab-07-ci/README.md @@ -0,0 +1,33 @@ +# Lab 07 — Automate the Build Platform + +**Goal:** a CI/CD pipeline with **ordered, unskippable gates** that **promotes the built +image** — proven by parsing the pipeline and checking the policy. + +## What you do + +Complete [`ci.yml`](ci.yml) — a GitHub Actions workflow. (We grade it as `ci.yml` in this +folder; in a real repo it would live at `.github/workflows/ci.yml`.) It must: + +1. **A `build` job with ordered steps**, in this order: + `npm ci` → `lint` → `test` → `docker build` → `trivy` (scan) → `docker push`. + Use the commit SHA to tag the image, e.g. + `docker build -t forge-api:${{ github.sha }} .` +2. **Deterministic install** — `npm ci`, **not** `npm install`. +3. **A `deploy` job gated on build** — `deploy` declares `needs: build` (it only runs if + build fully passed). +4. **Promote, don't rebuild** — the deploy step sets the image to the **SHA-tagged** artifact + (`forge-api:${{ github.sha }}`) and must **not** use `:latest`. + +```bash +npx bats labs/lab-07-ci/tests +``` + +## How it's graded + +`python3` parses the workflow YAML and checks: stage ordering in `build`, deterministic +install, `deploy.needs == build`, the deploy promotes the SHA-tagged image, and no `:latest`. + +## Definition of done + +- Tests green. +- A note on a release incident the unskippable pipeline prevents. diff --git a/labs/lab-07-ci/ci.yml b/labs/lab-07-ci/ci.yml new file mode 100644 index 0000000..26af4a9 --- /dev/null +++ b/labs/lab-07-ci/ci.yml @@ -0,0 +1,11 @@ +# Lab 07 — CI/CD pipeline. See README.md. +# +# TODO: define a GitHub Actions workflow with: +# jobs.build.steps (in order): npm ci -> lint -> test -> docker build -> trivy -> docker push +# tag the image with the commit SHA, e.g. docker build -t forge-api:${{ github.sha }} . +# jobs.deploy: needs: build, and a step that promotes forge-api:${{ github.sha }} (NOT :latest) +# +# Replace everything below with your workflow. + +on: [push, pull_request] +jobs: {} diff --git a/labs/lab-07-ci/tests/ci.bats b/labs/lab-07-ci/tests/ci.bats new file mode 100644 index 0000000..9efbc3f --- /dev/null +++ b/labs/lab-07-ci/tests/ci.bats @@ -0,0 +1,57 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + export WF="$LAB_DIR/ci.yml" +} + +py() { python3 -c "$1"; } + +@test "build job runs stages in order: npm ci -> lint -> test -> docker build -> trivy -> docker push" { + run py " +import yaml,os +wf=yaml.safe_load(open(os.environ['WF'])) +steps='\n'.join((s.get('run') or '') for s in wf['jobs']['build']['steps']) +order=['npm ci','lint','test','docker build','trivy','docker push'] +last=-1 +for stage in order: + i=steps.find(stage) + assert i>last, 'stage out of order or missing: '+stage + last=i +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "install is deterministic (npm ci, not npm install)" { + run py " +import yaml,os +wf=yaml.safe_load(open(os.environ['WF'])) +steps='\n'.join((s.get('run') or '') for s in wf['jobs']['build']['steps']) +assert 'npm ci' in steps and 'npm install' not in steps, 'use npm ci, not npm install' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "deploy is gated on build (needs: build)" { + run py " +import yaml,os +wf=yaml.safe_load(open(os.environ['WF'])) +needs=wf['jobs']['deploy'].get('needs') +ok = needs=='build' or (isinstance(needs,list) and 'build' in needs) +assert ok, 'deploy must declare needs: build' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "deploy promotes the SHA-tagged built image and never uses :latest" { + run py " +import yaml,os +wf=yaml.safe_load(open(os.environ['WF'])) +dep='\n'.join((s.get('run') or '') for s in wf['jobs']['deploy']['steps']) +assert 'github.sha' in dep, 'deploy must promote the forge-api:\${{ github.sha }} image' +assert ':latest' not in dep, 'deploy must not use :latest' +print('ok') +" + [ "$status" -eq 0 ] +} diff --git a/labs/lab-08-production.md b/labs/lab-08-production.md deleted file mode 100644 index 837ce89..0000000 --- a/labs/lab-08-production.md +++ /dev/null @@ -1,91 +0,0 @@ -# Lab 08 — Build the Production Container Platform - -**Lesson:** 08 · **Goal:** production manifests (Deployment + Service) with replicas, probes, resource limits, a pinned image, and zero-downtime rollout — proven by parsing and policy-checking the YAML. - -## Goal -Author the Forge production manifests and validate the production properties a naive single-container setup lacks. - -## Setup -A **naive** Deployment (what to fix): -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: { name: forge-api } -spec: - replicas: 1 - template: - spec: - containers: - - name: api - image: forge-api:latest -``` -Your **production** Deployment: -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: { name: forge-api } -spec: - replicas: 3 - strategy: - type: RollingUpdate - rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } - template: - spec: - containers: - - name: api - image: forge-api:1.4.2 - resources: - requests: { cpu: "100m", memory: "128Mi" } - limits: { cpu: "500m", memory: "256Mi" } - readinessProbe: { httpGet: { path: /readyz, port: 8080 }, initialDelaySeconds: 5 } - livenessProbe: { httpGet: { path: /healthz, port: 8080 }, periodSeconds: 10 } - envFrom: - - secretRef: { name: forge-api-secrets } -``` - -## Tasks -1. **Replicas ≥ 2** (redundancy + headroom). -2. **Rolling update** with `maxUnavailable: 0` (zero-downtime). -3. **Resource requests and limits** (scheduler guarantee + cap). -4. **Both probes:** readiness (traffic) and liveness (restart) — keep liveness trivial, readiness dependency-aware (Module 06). -5. **Pinned image** (`forge-api:1.4.2`, not `latest`); **config/secrets via references** (`secretRef`), not inline plaintext. -6. **Validate** by parsing the YAML — the naive manifest fails with specific findings; the production one passes. - -## Verify (example) -```js -const { loadYaml } = require('/tmp/pscaffold/validators.cjs'); -const fs = require('node:fs'); const assert = require('node:assert'); -function lintManifest(d) { - const issues = []; - if (!(d.spec.replicas >= 2)) issues.push('replicas < 2 (no redundancy)'); - if (d.spec.strategy?.type !== 'RollingUpdate' || d.spec.strategy.rollingUpdate?.maxUnavailable !== 0) issues.push('not zero-downtime rolling update'); - const c = d.spec.template.spec.containers[0]; - if (!c.resources?.limits?.cpu || !c.resources?.limits?.memory) issues.push('no resource limits'); - if (!c.resources?.requests) issues.push('no resource requests'); - if (!c.readinessProbe) issues.push('no readiness probe'); - if (!c.livenessProbe) issues.push('no liveness probe'); - if (/:latest$/.test(c.image) || !/:/.test(c.image)) issues.push('image not pinned (:latest or untagged)'); - return issues; -} -const prod = loadYaml(fs.readFileSync('deployment.yml', 'utf8')); -assert.strictEqual(lintManifest(prod).length, 0, 'production manifest clean'); -const naive = loadYaml(`apiVersion: apps/v1 -kind: Deployment -spec: - replicas: 1 - template: { spec: { containers: [ { name: api, image: forge-api:latest } ] } }`); -const naiveIssues = lintManifest(naive); -assert.ok(naiveIssues.length >= 5, 'naive flagged: ' + naiveIssues.join('; ')); -console.log('PRODUCTION VERIFIED: prod manifest clean; naive flagged [' + naiveIssues.join('; ') + ']'); -``` - -## Deliverable -The Deployment + Service manifests, the passing validation (replicas ≥ 2, rolling update `maxUnavailable: 0`, requests+limits, both probes, pinned image, referenced secrets), a trace of a zero-downtime rollout + rollback, and a note on what each property prevents in an incident. - -## Cleanup -```bash -rm -f /tmp/forge-platform/manifest-check.cjs -``` - -## Check -`../solutions/lab-08-solution.md`. diff --git a/labs/lab-08-production/README.md b/labs/lab-08-production/README.md new file mode 100644 index 0000000..2a0192f --- /dev/null +++ b/labs/lab-08-production/README.md @@ -0,0 +1,38 @@ +# Lab 08 — Build the Production Container Platform + +**Goal:** production Kubernetes manifests (a **Deployment** + a **Service**) with replicas, +both probes, resource limits, a pinned image, injected secrets, and a zero-downtime rollout — +proven by parsing and policy-checking the YAML. + +## What you do + +Complete [`deployment.yml`](deployment.yml). Put **both** documents in the one file, separated +by `---`: + +### The Deployment must have +1. **`replicas >= 2`** (redundancy + headroom). +2. **A zero-downtime rolling update:** `strategy.type: RollingUpdate` with + `rollingUpdate.maxUnavailable: 0`. +3. **Resource `requests` and `limits`** (both cpu and memory under `limits`). +4. **Both probes:** a `readinessProbe` and a `livenessProbe` on the container. +5. **A pinned image** (`forge-api:1.4.2`, **not** `:latest`, not untagged). +6. **Secrets by reference** — inject config via `envFrom: [{ secretRef: { name: ... } }]` + (or `valueFrom.secretKeyRef`), **not** inline plaintext. + +### The Service must +- be a second document (`kind: Service`) selecting the Deployment's pods. + +```bash +npx bats labs/lab-08-production/tests +``` + +## How it's graded + +`python3` loads **all** documents from the file, finds the `Deployment` and the `Service`, +and applies the policy above. No cluster is contacted. + +## Definition of done + +- Tests green. +- A trace of a zero-downtime rollout + rollback and a note on what each property prevents in + an incident. diff --git a/labs/lab-08-production/deployment.yml b/labs/lab-08-production/deployment.yml new file mode 100644 index 0000000..436a114 --- /dev/null +++ b/labs/lab-08-production/deployment.yml @@ -0,0 +1,19 @@ +# Lab 08 — production Kubernetes manifests (Deployment + Service). See README.md. +# +# TODO: write TWO documents separated by `---`. +# +# Document 1 — kind: Deployment, with: +# spec.replicas: >= 2 +# spec.strategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } } +# the container with: +# image: forge-api:1.4.2 (pinned, not :latest) +# resources: { requests: {...}, limits: { cpu, memory } } +# readinessProbe: {...} +# livenessProbe: {...} +# envFrom: [ { secretRef: { name: forge-api-secrets } } ] +# +# Document 2 — kind: Service that selects the Deployment's pods. +# +# Replace this placeholder with your two documents. + +placeholder: true diff --git a/labs/lab-08-production/tests/production.bats b/labs/lab-08-production/tests/production.bats new file mode 100644 index 0000000..6b8519c --- /dev/null +++ b/labs/lab-08-production/tests/production.bats @@ -0,0 +1,84 @@ +setup() { + LAB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + export MANIFEST="$LAB_DIR/deployment.yml" +} + +py() { python3 -c "$1"; } + +# Shared loader: find the Deployment and Service docs. +LOADER=" +import yaml,os +docs=[d for d in yaml.safe_load_all(open(os.environ['MANIFEST'])) if isinstance(d,dict)] +dep=next((d for d in docs if d.get('kind')=='Deployment'), None) +svc=next((d for d in docs if d.get('kind')=='Service'), None) +" + +@test "file contains a Deployment and a Service document" { + run py "$LOADER +assert dep is not None, 'no Deployment document' +assert svc is not None, 'no Service document' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "replicas >= 2" { + run py "$LOADER +assert (dep['spec'].get('replicas') or 0) >= 2, 'replicas must be >= 2' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "zero-downtime rolling update (maxUnavailable: 0)" { + run py "$LOADER +st=dep['spec'].get('strategy') or {} +assert st.get('type')=='RollingUpdate', 'strategy.type must be RollingUpdate' +assert (st.get('rollingUpdate') or {}).get('maxUnavailable')==0, 'maxUnavailable must be 0' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "container has resource requests and limits (cpu + memory)" { + run py "$LOADER +c=dep['spec']['template']['spec']['containers'][0] +r=c.get('resources') or {} +assert r.get('requests'), 'missing resource requests' +lim=r.get('limits') or {} +assert lim.get('cpu') and lim.get('memory'), 'missing cpu/memory limits' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "container has both readiness and liveness probes" { + run py "$LOADER +c=dep['spec']['template']['spec']['containers'][0] +assert c.get('readinessProbe'), 'missing readinessProbe' +assert c.get('livenessProbe'), 'missing livenessProbe' +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "image is pinned (not :latest, not untagged)" { + run py "$LOADER +c=dep['spec']['template']['spec']['containers'][0] +img=c.get('image','') +assert ':' in img and not img.endswith(':latest'), 'image must be pinned, not :latest/untagged: %r'%img +print('ok') +" + [ "$status" -eq 0 ] +} + +@test "secrets injected by reference (secretRef / secretKeyRef), not inline" { + run py "$LOADER +import json +c=dep['spec']['template']['spec']['containers'][0] +blob=json.dumps(c) +assert 'secretRef' in blob or 'secretKeyRef' in blob, 'inject secrets via secretRef/secretKeyRef' +print('ok') +" + [ "$status" -eq 0 ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a2eb13b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "swexp-module-08-platform-containerization", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swexp-module-08-platform-containerization", + "version": "1.0.0", + "devDependencies": { + "bats": "^1.13.0" + } + }, + "node_modules/bats": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/bats/-/bats-1.13.0.tgz", + "integrity": "sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==", + "dev": true, + "license": "MIT", + "bin": { + "bats": "bin/bats" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fa74146 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "swexp-module-08-platform-containerization", + "version": "1.0.0", + "private": true, + "description": "Forge SWEXP Module 08 — interactive Platform Engineering & Containerization exercises (author Dockerfiles, compose, CI, and k8s manifests; run tests; submit).", + "scripts": { + "test": "bats -r labs assignments", + "check": "bash -c 'shopt -s nullglob; files=(labs/*/solution.sh assignments/*/solution.sh); [ ${#files[@]} -eq 0 ] || bash -n \"${files[@]}\"'", + "grade": "node scripts/grade.mjs" + }, + "devDependencies": { + "bats": "^1.13.0" + } +} diff --git a/scripts/grade.mjs b/scripts/grade.mjs new file mode 100644 index 0000000..3927b8c --- /dev/null +++ b/scripts/grade.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Forge SWEXP autograder (Module 08 — Platform Engineering & Containerization, bats harness). + * Runs each exercise's bats tests, plus a shell-syntax gate (`bash -n`), + * then prints a per-exercise score and writes a Markdown report for CI. + * + * Each exercise is a folder under labs/ or assignments/ containing + * `solution.sh` (the student edits) and `tests/*.bats` (the spec). + * No answer keys are shipped. + */ +import { execSync } from 'node:child_process'; +import { readdirSync, existsSync, writeFileSync, appendFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const BATS = join('node_modules', '.bin', 'bats'); + +function listExercises() { + const out = []; + for (const group of ['labs', 'assignments']) { + if (!existsSync(group)) continue; + for (const name of readdirSync(group).sort()) { + const dir = join(group, name); + if (statSync(dir).isDirectory() && existsSync(join(dir, 'tests'))) { + out.push({ key: `${group}/${name}`, dir }); + } + } + } + return out; +} + +function runBats(testsDir) { + let out = ''; + try { + out = execSync(`${BATS} --formatter tap "${testsDir}"`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString(); + } catch (e) { + out = `${e.stdout ?? ''}${e.stderr ?? ''}`; + } + let passed = 0; + let total = 0; + for (const line of out.split('\n')) { + if (/^ok\b/.test(line)) { passed++; total++; } + else if (/^not ok\b/.test(line)) { total++; } + } + return { passed, total }; +} + +function syntaxGate() { + // Not every exercise ships a solution.sh in this module (many author a + // Dockerfile / compose / manifest instead). nullglob makes the gate tolerate + // a folder with no solution.sh — it only syntax-checks the ones present. + try { + execSync( + 'shopt -s nullglob; files=(labs/*/solution.sh assignments/*/solution.sh); [ ${#files[@]} -eq 0 ] || bash -n "${files[@]}"', + { stdio: ['ignore', 'pipe', 'pipe'], shell: '/bin/bash' }, + ); + return { ok: true }; + } catch { + return { ok: false }; + } +} + +const exercises = listExercises(); +const tally = exercises.map((e) => ({ ...e, ...runBats(join(e.dir, 'tests')) })); +const gate = syntaxGate(); + +const passed = tally.reduce((s, t) => s + t.passed, 0); +const total = tally.reduce((s, t) => s + t.total, 0); +const pct = total ? Math.round((passed / total) * 100) : 0; +const complete = passed === total && total > 0 && gate.ok; + +const rows = tally.map((t) => { + const mark = t.total > 0 && t.passed === t.total ? '✅' : '❌'; + return `| \`${t.key}\` | ${t.passed}/${t.total} | ${mark} |`; +}); + +const md = [ + `## Forge SWEXP — Module 08 autograde`, + ``, + `**Score: ${passed}/${total} tests (${pct}%)** · Shell syntax: ${gate.ok ? '✅ clean' : '❌ errors'}`, + ``, + `| Exercise | Tests | Status |`, + `| --- | --- | --- |`, + ...rows, + ``, + complete + ? `🎉 **All exercises complete and every script parses cleanly.**` + : `Keep going — open each exercise folder, complete the \`# TODO\`s in its starter (a \`Dockerfile\`, \`docker-compose.yml\`, \`ci.yml\`, \`deployment.yml\`, or \`solution.sh\`), and run \`npm test\`. The \`tests/*.bats\` files are the spec.`, +].join('\n'); + +writeFileSync('grade-report.md', md + '\n'); +console.log('\n' + md + '\n'); +if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + '\n'); + +process.exit(complete ? 0 : 1);