Compare commits
14
Commits
fd38d06055
...
2db136875f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db136875f | ||
|
|
505783e5f7 | ||
|
|
e0f954e714 | ||
|
|
54b35e8ad7 | ||
|
|
de6c5b3286 | ||
|
|
831486d664 | ||
|
|
960a638ec9 | ||
|
|
73cea1ba1d | ||
|
|
dbcc210889 | ||
|
|
86ea035e9b | ||
|
|
25409d3bd7 | ||
|
|
046a0fba18 | ||
|
|
bee57d89fd | ||
|
|
82f17a05e8 |
+108
@@ -0,0 +1,108 @@
|
||||
# What Kubernetes Solves & the Desired-State Model
|
||||
|
||||
## Before Kubernetes: the problem
|
||||
|
||||
Imagine you're running a web application on three servers by hand. One server crashes at 2am —
|
||||
someone has to notice, log in, and restart the application. Traffic grows, so you need a fourth
|
||||
server — someone has to provision it, install dependencies, and configure it to match the other
|
||||
three. You ship a new version — someone has to update all four servers, one at a time, without
|
||||
taking the whole application down.
|
||||
|
||||
None of this is hard in isolation. It becomes unmanageable at scale, and it depends entirely on
|
||||
a human noticing and reacting fast enough. Kubernetes exists to make a computer do that
|
||||
noticing and reacting instead — continuously, automatically, and the same way every time.
|
||||
|
||||
## The core idea: desired state
|
||||
|
||||
You don't tell Kubernetes *how* to run your application step by step. You tell it *what you
|
||||
want to be true* — "3 replicas of this container should be running" — and Kubernetes
|
||||
continuously works to make that true, correcting drift whenever reality doesn't match.
|
||||
|
||||
This is the single most important mental shift for someone new to Kubernetes: you're not
|
||||
writing a script that runs once. You're declaring a **desired state**, and a background process
|
||||
never stops comparing the real world against it.
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web
|
||||
spec:
|
||||
replicas: 3 # <- the desired state: "3 of these, always"
|
||||
...
|
||||
```
|
||||
|
||||
If a Pod crashes, Kubernetes doesn't "restart the crashed one" as a special case — it just
|
||||
notices reality (2 running) no longer matches desired state (3 wanted) and creates a
|
||||
replacement. Same mechanism, no special-casing.
|
||||
|
||||
## The cluster mental model
|
||||
|
||||
A Kubernetes cluster has two conceptually different kinds of machines:
|
||||
|
||||
- **The control plane** — the "brain." It stores the desired state (in `etcd`), watches actual
|
||||
state, and makes decisions (the API server, scheduler, and controllers all live here). You
|
||||
talk to the control plane through `kubectl`; you never SSH into it to manage workloads.
|
||||
- **Worker nodes** — where your containers actually run. Each node runs a `kubelet` (an agent
|
||||
that starts/stops containers as the control plane instructs) and a container runtime.
|
||||
|
||||
You describe desired state to the control plane. The control plane decides which nodes should
|
||||
run what. The nodes' kubelets make it actually happen. You never manually place a Pod on a
|
||||
specific node in normal operation — that placement decision belongs to the scheduler.
|
||||
|
||||
## Declarative, not imperative
|
||||
|
||||
"Imperative" means giving step-by-step instructions ("start a container, then another, then
|
||||
configure networking between them"). "Declarative" means describing an end state and letting
|
||||
the system figure out the steps. Kubernetes' API objects (Pods, Deployments, Services, and
|
||||
everything else you'll learn in ROOT_) are declarative — you write YAML describing what should
|
||||
exist, `kubectl apply` submits it, and the control plane does the rest.
|
||||
|
||||
This is also why Kubernetes YAML looks the way it does: it's not a script, it's a specification.
|
||||
|
||||
## Pods, Namespaces, and labels — the vocabulary you need first
|
||||
|
||||
- **Pod** — the smallest deployable unit. Usually one container, sometimes a small group of
|
||||
tightly-coupled containers that must run together on the same node and share networking.
|
||||
You'll rarely create bare Pods directly in real use (a Deployment manages them for you, see
|
||||
ROOT 06) but understanding the Pod itself is foundational to everything built on top of it.
|
||||
- **Namespace** — a way to partition one cluster into logically separate areas (e.g. `dev`,
|
||||
`staging`, teams, or projects), so names don't collide and access/quotas can be scoped.
|
||||
- **Labels** — arbitrary key/value tags attached to objects (`app: web`, `env: prod`). Labels
|
||||
are how almost everything in Kubernetes finds related objects — a Deployment finds "its" Pods
|
||||
by label selector, not by name pattern matching.
|
||||
|
||||
## kubectl: your one tool for all of this
|
||||
|
||||
`kubectl` is the command-line client that talks to the control plane's API server. Nearly
|
||||
everything you'll do in ROOT_ Labs goes through it: `kubectl get` (list/inspect),
|
||||
`kubectl describe` (detailed status + recent events — your primary troubleshooting tool),
|
||||
`kubectl apply -f` (submit a desired-state YAML file), `kubectl logs` (see what a container
|
||||
printed). You'll build real fluency with these across this module and the ones that follow.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read, 10-12 minutes for a live walkthrough with discussion.
|
||||
|
||||
**Demo plan:** Before the demonstration activity, it's worth drawing the control-plane/worker
|
||||
split on a whiteboard (or sharing a simple diagram) and tracing one request: `kubectl apply` ->
|
||||
API server -> etcd -> scheduler -> kubelet -> container runtime. Students retain the mental
|
||||
model much better when they've seen the path, not just read about it.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often assume `kubectl` talks directly to nodes/containers. It doesn't — it only ever
|
||||
talks to the API server. Worth stating explicitly.
|
||||
- "Declarative" is sometimes read as "automatic" in a vague sense. Be concrete: it means "you
|
||||
describe the end state; a controller loop makes it true," not "Kubernetes guesses what you
|
||||
want."
|
||||
|
||||
**Discussion question:** "If desired state says 3 replicas and you manually run `kubectl delete
|
||||
pod` on one of them, what happens next, and why?" (Answer: a new one is created almost
|
||||
immediately — deleting a Pod doesn't change the Deployment's desired replica count, so the
|
||||
control loop restores it.)
|
||||
|
||||
**Optional extension:** Ask students to predict what would happen if they edited a running Pod's
|
||||
container image directly with `kubectl edit pod` instead of updating the Deployment that manages
|
||||
it (answer: it works until that specific Pod is replaced for any reason, at which point the new
|
||||
Pod reverts to whatever the Deployment's template says — a good early illustration of "the
|
||||
Deployment is the source of truth, not the running Pod").
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Demonstration: Desired State Reconciliation
|
||||
|
||||
Watch what actually happens when reality drifts from desired state — this is the mechanism the
|
||||
whole lesson describes, made concrete.
|
||||
|
||||
## Setup
|
||||
|
||||
A Namespace called `demo` exists with a Deployment `web` declaring `replicas: 2`. Right now,
|
||||
reality matches desired state:
|
||||
|
||||
```
|
||||
$ kubectl get pods -n demo
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
web-7d8f9c6b5d-abcde 1/1 Running 0 3m
|
||||
web-7d8f9c6b5d-fghij 1/1 Running 0 3m
|
||||
```
|
||||
|
||||
## Someone (or something) deletes one Pod
|
||||
|
||||
```
|
||||
$ kubectl delete pod web-7d8f9c6b5d-abcde -n demo
|
||||
pod "web-7d8f9c6b5d-abcde" deleted
|
||||
```
|
||||
|
||||
## What happens in the next second or two, without anyone doing anything else
|
||||
|
||||
```
|
||||
$ kubectl get pods -n demo
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
web-7d8f9c6b5d-fghij 1/1 Running 0 3m
|
||||
web-7d8f9c6b5d-klmno 0/1 ContainerCreating 0 1s
|
||||
```
|
||||
|
||||
A brand new Pod (`...-klmno`, a new random suffix — this is NOT the deleted Pod coming back)
|
||||
appeared on its own. Nobody ran `kubectl create` again. The Deployment's controller noticed
|
||||
actual state (1 running Pod matching its label selector) no longer matched desired state
|
||||
(`replicas: 2`), and created exactly one replacement to close the gap — not two, not zero.
|
||||
|
||||
A few seconds later:
|
||||
|
||||
```
|
||||
$ kubectl get pods -n demo
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
web-7d8f9c6b5d-fghij 1/1 Running 0 3m
|
||||
web-7d8f9c6b5d-klmno 1/1 Running 0 8s
|
||||
```
|
||||
|
||||
Back to 2/2 — desired state restored, entirely without human intervention.
|
||||
|
||||
## Why this matters beyond the demo
|
||||
|
||||
This exact mechanism is what makes a node crash, a Pod eviction, or a container being
|
||||
OOM-killed something Kubernetes recovers from automatically instead of something that pages a
|
||||
human at 2am. The controller loop doesn't know or care WHY a Pod disappeared — crash, manual
|
||||
delete, node failure — it only ever asks "does reality match desired state right now?" and acts
|
||||
if not. Understanding this one loop explains a huge amount of Kubernetes' behavior that would
|
||||
otherwise look like unrelated special cases.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run this live rather than only showing the captured output above — have
|
||||
students watch `kubectl get pods -n demo --watch` in one terminal while you delete a Pod from
|
||||
another. Watching the new Pod name/age appear in real time lands the "this is a NEW Pod, not a
|
||||
restart" point far better than static text.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5 minutes as a self-paced read.
|
||||
|
||||
**Common misconception to correct:** students frequently describe this as "Kubernetes restarted
|
||||
the Pod." Correct this explicitly — a deleted Pod is gone permanently; a *new* Pod object was
|
||||
created to satisfy the replica count. This distinction matters later when debugging (the new
|
||||
Pod has zero history from the old one — no old logs, no old restart count).
|
||||
|
||||
**Optional extension:** ask what would happen if you deleted the Deployment itself instead of
|
||||
one Pod (answer: all of its Pods are deleted and NOT replaced, because the thing declaring
|
||||
desired state is now gone — a good way to reinforce that the Deployment, not any individual
|
||||
Pod, is the actual source of truth).
|
||||
@@ -0,0 +1,48 @@
|
||||
# Knowledge Check: The Kubernetes Mental Model
|
||||
|
||||
Answer each question yourself before reading the answer that follows it.
|
||||
|
||||
---
|
||||
|
||||
**1. You run `kubectl delete pod` on one Pod belonging to a Deployment with `replicas: 3`. A
|
||||
few seconds later, `kubectl get pods` shows 3 Pods again, but one has a different name than any
|
||||
of the original three. What actually happened?**
|
||||
|
||||
*Answer:* The deleted Pod is gone permanently. The Deployment's controller noticed actual state
|
||||
(2 Pods) no longer matched desired state (3 Pods) and created ONE new Pod to close the gap. It
|
||||
is not the old Pod restarting — it's a brand new object.
|
||||
|
||||
---
|
||||
|
||||
**2. A Deployment has `replicas: 3`, and there are 5 nodes in the cluster. Which of these
|
||||
decides WHICH node each of the 3 Pods actually lands on?**
|
||||
|
||||
*Answer:* The scheduler (part of the control plane), not the person who wrote the Deployment
|
||||
YAML and not the nodes themselves. You declare desired state; the scheduler makes the placement
|
||||
decision based on available resources and any placement rules configured.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: `kubectl` communicates directly with the kubelet on whichever node your
|
||||
Pod happens to be running on.**
|
||||
|
||||
*Answer:* False. `kubectl` only ever talks to the API server (part of the control plane). The
|
||||
API server is the single entry point for every read and write — it never bypasses the control
|
||||
plane to reach a node directly for a normal `get`/`apply`/`describe` call.
|
||||
|
||||
---
|
||||
|
||||
**4. Why is Kubernetes YAML described as "declarative" rather than a script?**
|
||||
|
||||
*Answer:* A script describes a sequence of steps to execute once. Kubernetes YAML describes an
|
||||
END STATE you want to exist ("3 replicas of this container should be running") — a controller
|
||||
loop then continuously works to keep reality matching that description, correcting drift
|
||||
whenever it appears, indefinitely, not just once at apply time.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Ask these aloud in a live classroom before revealing answers — question 1 in
|
||||
particular tends to surface the "it restarted" misconception the demonstration activity should
|
||||
have already addressed; if it hasn't landed, this is the moment to circle back.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Guided Exercise: Planning a Namespace Layout
|
||||
|
||||
This is a paper-and-thinking exercise — work through it before the Lab, not during it.
|
||||
|
||||
## The scenario
|
||||
|
||||
You're setting up a small Kubernetes cluster for a class project with three teams (`frontend`,
|
||||
`backend`, `data`), each running their own set of Pods, and you also need a shared area for
|
||||
tools everyone uses (a shared logging dashboard, for example).
|
||||
|
||||
## Your task
|
||||
|
||||
Sketch out, on paper or in a scratch file:
|
||||
|
||||
1. What Namespaces would you create, and why? (Hint: think about who needs to see what, and
|
||||
whether any team's mistake — e.g. deleting the wrong Pod by accident — should be able to
|
||||
affect another team.)
|
||||
2. If `frontend` team members should be able to manage their own Pods but NOT touch `backend`'s
|
||||
or `data`'s, what does that suggest about how Namespaces relate to access control? (You'll
|
||||
learn the actual mechanism, RBAC, in ROOT 09 — for now just reason about why Namespace
|
||||
boundaries make that kind of restriction possible at all.)
|
||||
3. Would you put the shared logging dashboard in one of the three team Namespaces, or somewhere
|
||||
else? Justify your answer.
|
||||
4. If two teams both need to run something they each call `web`, what would happen if they were
|
||||
in the SAME Namespace? What changes if they're in separate Namespaces?
|
||||
|
||||
## A reasonable answer to check yourself against
|
||||
|
||||
1. Three Namespaces (`frontend`, `backend`, `data`) — one per team, so each team's objects are
|
||||
isolated from the others and a mistake in one Namespace (deleting the wrong Pod, applying a
|
||||
bad manifest) can't accidentally affect a different team's workloads.
|
||||
2. Namespace boundaries are what RBAC rules can be scoped TO — "this person can manage Pods in
|
||||
the `frontend` Namespace" is a real, common permission model. Without separate Namespaces,
|
||||
you'd have to distinguish access by resource name pattern instead of by boundary, which is
|
||||
far more error-prone.
|
||||
3. A separate Namespace (e.g. `shared-tools` or `platform`) — it isn't owned by any one team,
|
||||
and mixing it into a team's Namespace would make it unclear who's responsible for it or
|
||||
accidentally couple its lifecycle to that team's.
|
||||
4. In the SAME Namespace, object names must be unique — a second `web` Deployment would either
|
||||
fail to create or overwrite the first, depending on how it's applied. In SEPARATE
|
||||
Namespaces, both `frontend/web` and `backend/web` can coexist without conflict — the
|
||||
Namespace is part of what makes a name unique, not just the name itself.
|
||||
|
||||
If your answer differed meaningfully from this, re-read the lesson's Namespace section before
|
||||
continuing to the Lab.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Good as a 2-3 person small-group discussion in a live classroom before
|
||||
revealing the reasonable answer — question 4 in particular often produces a genuine "oh" moment
|
||||
once students realize name uniqueness is Namespace-scoped, not cluster-wide.
|
||||
|
||||
**Approximate timing:** 10 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would need to be true for the `data` team to be ALLOWED to read
|
||||
(but not modify) something in `shared-tools` — a good bridge to RBAC's read vs. write verbs,
|
||||
covered properly in ROOT 09.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Launch Lab: Working with Namespaces
|
||||
|
||||
Time to apply the mental model for real — you'll create an actual isolated Namespace and deploy
|
||||
into it, in your own disposable Lab environment.
|
||||
|
||||
## What you're doing
|
||||
|
||||
You'll create a Namespace (`team-alpha`) and deploy an application into it with a specific
|
||||
replica count — directly applying the "isolated area within one cluster" idea from the guided
|
||||
exercise, and the desired-state model from the lesson (once you declare `replicas: 2`, that
|
||||
becomes the standing desired state for that Deployment, enforced continuously).
|
||||
|
||||
## Before you start
|
||||
|
||||
Think back to the demonstration: once your Deployment exists with its desired replica count,
|
||||
Kubernetes will keep it there on its own. You don't need to "check back" and manually fix
|
||||
anything — if you want to prove this to yourself, try deleting one of your Pods after you've
|
||||
finished the task and watch what happens.
|
||||
|
||||
## Your task in this Lab
|
||||
|
||||
Create the Namespace, then deploy the application inside it with the exact replica count
|
||||
specified. Use `kubectl get namespace` and `kubectl get deployment -n <namespace>` to confirm
|
||||
your work — the same verification pattern you'll use constantly throughout ROOT_.
|
||||
|
||||
This Lab has its own in-context hints if you get stuck. Try working from the lesson and guided
|
||||
exercise first — the commands you need are exactly the ones described there.
|
||||
|
||||
When you're done, return to this module to close out with a reflection on why this isolation
|
||||
matters beyond just "keeping things organized."
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Classroom flow:** This is most students' first real `kubectl apply`/`create` in ROOT_ beyond
|
||||
whatever pilot exposure they've had — a good moment to circulate and check that basic
|
||||
`kubectl` command syntax isn't the blocker (as opposed to the Namespace concept itself, which
|
||||
the guided exercise should have already addressed).
|
||||
|
||||
**Approximate timing:** 10-15 minutes.
|
||||
|
||||
**Common mistake to watch for:** creating the Deployment in the DEFAULT namespace instead of
|
||||
`team-alpha` (forgetting `-n team-alpha` or the namespace field in the manifest) — this is a
|
||||
very common first mistake and a good real-world illustration of why Namespace isolation only
|
||||
works if you're actually specifying it every time.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Reflection: Why Desired State Matters in Production
|
||||
|
||||
You just created an isolated Namespace and a Deployment with a declared replica count. Take a
|
||||
few minutes to connect this to why it matters beyond passing this Lab's validation.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. If your `team-alpha` Namespace were a real team's production environment, what's one mistake
|
||||
in a DIFFERENT namespace that Namespace isolation would have protected you from?
|
||||
2. You declared `replicas: 2` rather than manually creating two Pods. What would you have had
|
||||
to do manually, over and over, if Kubernetes only supported creating individual Pods with no
|
||||
concept of desired state?
|
||||
3. Think of one non-Kubernetes system you've used (a thermostat, a spell-checker, an
|
||||
auto-save feature) that also works by continuously comparing "what is" to "what should be."
|
||||
What made you think of it?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Namespaces are your first real production safety boundary, and they're free.** A
|
||||
misconfigured `kubectl delete` scoped to the wrong Namespace is a common, entirely preventable
|
||||
class of incident — teams that consistently namespace their environments (dev/staging/prod, or
|
||||
per-team) limit the blast radius of almost any operator mistake to just that Namespace. This
|
||||
costs nothing to set up and prevents a real, common category of outage.
|
||||
|
||||
**Desired state is what makes self-healing possible at all.** Every "the platform recovered on
|
||||
its own" story you'll hear about Kubernetes in production traces back to exactly the mechanism
|
||||
you just watched in the demonstration: a controller comparing actual state to desired state and
|
||||
correcting the gap. Understanding this loop is what lets you predict Kubernetes' behavior
|
||||
instead of being surprised by it.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** use all three reflection questions as discussion
|
||||
prompts. Question 3 in particular tends to produce good analogies that make the abstract
|
||||
"controller loop" concept concrete and memorable.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to name one place in their own life (not computing) where
|
||||
"declare the goal and let a system enforce it" would work better than "manually do the thing
|
||||
every time" — a genuinely reflective prompt that reinforces the mental model beyond Kubernetes
|
||||
specifically.
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Why Deployments Exist & How a Rolling Update Progresses
|
||||
|
||||
## The problem a bare Pod doesn't solve
|
||||
|
||||
A Pod, on its own, has no self-healing and no concept of "there should be more than one of
|
||||
these." If a Pod crashes, nothing recreates it. If you need 3 copies for capacity, you'd have to
|
||||
create 3 separately-named Pods by hand and keep track of all of them yourself. If you need to
|
||||
update the container image, you'd have to delete and recreate every one of them manually,
|
||||
almost certainly causing a visible outage while you do it.
|
||||
|
||||
**A Deployment solves all three problems at once**: it declares how many replicas of a Pod
|
||||
template should exist, keeps that count true continuously (the exact desired-state mechanism
|
||||
from ROOT 05), and knows how to change the Pod template over time — a version upgrade — without
|
||||
just deleting everything and starting over.
|
||||
|
||||
## ReplicaSets: the layer underneath, briefly
|
||||
|
||||
You'll rarely touch a ReplicaSet directly, but understanding it clarifies what a Deployment
|
||||
actually does. A Deployment doesn't manage Pods directly — it manages a ReplicaSet, which is
|
||||
what actually keeps N Pods matching a label selector running. When you update a Deployment's
|
||||
Pod template (like changing the image), the Deployment creates a NEW ReplicaSet with the new
|
||||
template and gradually shifts replicas from the old ReplicaSet to the new one. The old
|
||||
ReplicaSet isn't deleted — it's scaled to zero and kept around, which is exactly what makes
|
||||
rollback possible (more on that below).
|
||||
|
||||
## How a rolling update actually progresses
|
||||
|
||||
By default, a Deployment updates using the `RollingUpdate` strategy: it doesn't take all old
|
||||
Pods down and bring all new ones up at once (that would cause an outage). Instead, it
|
||||
incrementally creates new-version Pods and removes old-version ones, always keeping enough
|
||||
capacity available:
|
||||
|
||||
```
|
||||
Before: [v1] [v1] [v1]
|
||||
Step 1: [v1] [v1] [v1] [v2] <- one new Pod created
|
||||
Step 2: [v1] [v1] [v2] <- one old Pod removed once new one is Ready
|
||||
Step 3: [v1] [v1] [v2] [v2]
|
||||
Step 4: [v1] [v2] [v2]
|
||||
...
|
||||
After: [v2] [v2] [v2]
|
||||
```
|
||||
|
||||
Crucially, a new Pod only counts as "up" for the purposes of proceeding once it passes its
|
||||
readiness probe (see ROOT 10's gold-standard module) — this is exactly why a misconfigured
|
||||
readiness probe can make a rollout hang indefinitely instead of failing fast: Kubernetes is
|
||||
correctly waiting for a "ready" signal that will never come.
|
||||
|
||||
## Rollback: what it actually restores
|
||||
|
||||
`kubectl rollout undo` doesn't magically know how to "undo" arbitrary changes — it works because
|
||||
of the old ReplicaSet still sitting around at zero replicas, from the mechanism described above.
|
||||
Rolling back means: scale the OLD ReplicaSet back up and the current (broken) one back down,
|
||||
using the exact same rolling mechanism as a forward update. This is why rollback is fast and
|
||||
safe — it's not a special operation, it's the same well-tested rolling-update machinery running
|
||||
in reverse, against a Pod template that's already known to have worked before.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read, 10-12 minutes live with the ReplicaSet diagram.
|
||||
|
||||
**Demo plan:** Draw (or show) the ReplicaSet-swap diagram above on a whiteboard before the
|
||||
demonstration activity — students who see the "old ReplicaSet scaled to zero, not deleted"
|
||||
detail up front connect rollback to rollout much faster.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often think rollback "restores a backup" or reverts a Git-style diff. Correct this:
|
||||
it's scaling an existing (still-present) ReplicaSet back up, nothing is being restored from
|
||||
storage.
|
||||
- "Rolling update" is sometimes assumed to mean "one Pod at a time, always." The actual pace is
|
||||
controlled by `maxSurge`/`maxUnavailable` (not covered in depth here, but worth naming so
|
||||
students don't over-generalize from the simplified diagram).
|
||||
|
||||
**Discussion question:** "Why does a rolling update need readiness probes to work correctly,
|
||||
specifically?" (Answer: without a readiness signal, Kubernetes has no way to know when a new
|
||||
Pod is actually safe to receive traffic before removing an old one — it would either guess via a
|
||||
fixed delay, unreliable, or not wait at all, unsafe.)
|
||||
|
||||
**Optional extension:** ask what would happen to an in-progress rollout if the new image simply
|
||||
doesn't exist (a typo in the tag) — a good bridge to real troubleshooting: the rollout stalls
|
||||
indefinitely with `ImagePullBackOff` on the new Pods, never completing and never rolling back on
|
||||
its own.
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Demonstration: Watching a Rollout, Good and Bad
|
||||
|
||||
## A healthy rollout
|
||||
|
||||
A Deployment `app-v1` running `nginx:1.24` at 3 replicas gets updated to `nginx:1.25`:
|
||||
|
||||
```
|
||||
$ kubectl set image deployment/app-v1 nginx=nginx:1.25
|
||||
deployment.apps/app-v1 image updated
|
||||
|
||||
$ kubectl rollout status deployment/app-v1
|
||||
Waiting for deployment "app-v1" rollout to finish: 1 out of 3 new replicas have been updated...
|
||||
Waiting for deployment "app-v1" rollout to finish: 2 out of 3 new replicas have been updated...
|
||||
Waiting for deployment "app-v1" rollout to finish: 1 old replicas are pending termination...
|
||||
deployment "app-v1" successfully rolled out
|
||||
```
|
||||
|
||||
`kubectl rollout status` blocks and reports exactly the incremental progress described in the
|
||||
lesson — this is your primary tool for watching (or scripting a wait on) a rollout, rather than
|
||||
repeatedly polling `kubectl get pods` yourself.
|
||||
|
||||
## Checking revision history
|
||||
|
||||
```
|
||||
$ kubectl rollout history deployment/app-v1
|
||||
REVISION CHANGE-CAUSE
|
||||
1 <none>
|
||||
2 <none>
|
||||
```
|
||||
|
||||
Revision 1 is the original ReplicaSet (now scaled to zero, still present); revision 2 is the
|
||||
current one. This history is exactly what `kubectl rollout undo` uses.
|
||||
|
||||
## A stalled rollout
|
||||
|
||||
Now imagine the same update, but to a typo'd tag, `nginx:1.25-typo`, which doesn't exist:
|
||||
|
||||
```
|
||||
$ kubectl rollout status deployment/app-v1
|
||||
Waiting for deployment "app-v1" rollout to finish: 1 out of 3 new replicas have been updated...
|
||||
```
|
||||
|
||||
...and it just sits there. `kubectl get pods` shows why:
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
app-v1-7d8f9c6b5d-abcde 1/1 Running 0 5m
|
||||
app-v1-7d8f9c6b5d-fghij 1/1 Running 0 5m
|
||||
app-v1-9c5b7d8f6b-klmno 0/1 ImagePullBackOff 0 30s
|
||||
```
|
||||
|
||||
The new Pod can never become Ready because its image can't even be pulled — so the rollout
|
||||
correctly refuses to proceed further (removing more old Pods would reduce capacity for no
|
||||
benefit, since the replacement will never come up). Nothing is "broken" in the platform; the
|
||||
platform is behaving exactly correctly given bad input.
|
||||
|
||||
**The fix here is `kubectl rollout undo`, not waiting longer** — this exact scenario is what
|
||||
`deployment-rollback`'s Lab, later in ROOT_, gives you real hands-on practice with.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** If time allows, run the healthy rollout live against a throwaway Deployment,
|
||||
then intentionally break the second one with a bad tag so students see `ImagePullBackOff` occur
|
||||
in real time rather than only in static text.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students sometimes assume a stalled rollout will
|
||||
eventually time out and auto-rollback on its own. By default it will not — a Deployment has no
|
||||
automatic rollback trigger; a human (or automation) has to run `kubectl rollout undo` (or fix
|
||||
the underlying issue) explicitly. This is worth stating directly since it's a genuine and
|
||||
consequential production gotcha.
|
||||
|
||||
**Optional extension:** ask what `kubectl rollout status --timeout=2m` would change about this
|
||||
scenario (answer: the command itself would exit with an error after 2 minutes instead of
|
||||
blocking forever — useful in CI/CD pipelines that need to fail a deploy step rather than hang).
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Knowledge Check: Deployments & Rollouts
|
||||
|
||||
---
|
||||
|
||||
**1. A rolling update to a new image version has been "in progress" for 20 minutes with no
|
||||
error message, and `kubectl rollout status` never returns. What's the first thing you should
|
||||
check, and why?**
|
||||
|
||||
*Answer:* `kubectl get pods` to see the new Pod's status, then `kubectl describe pod` on it if
|
||||
it isn't `Running`. A stalled rollout with no explicit error almost always means a new Pod can't
|
||||
become Ready — commonly `ImagePullBackOff` (bad image/tag) or a failing readiness probe. The
|
||||
rollout isn't broken; it's correctly refusing to proceed until the new Pod proves healthy.
|
||||
|
||||
---
|
||||
|
||||
**2. True or false: `kubectl rollout undo` restores your application from a backup of its
|
||||
previous state.**
|
||||
|
||||
*Answer:* False. It scales the previous (still-present, just scaled-to-zero) ReplicaSet back up
|
||||
and the current one back down — the same rolling mechanism as a forward update, just pointed at
|
||||
an older, already-proven-working Pod template. Nothing is being restored from storage.
|
||||
|
||||
---
|
||||
|
||||
**3. A Deployment has `replicas: 3`. You need it to handle more traffic. Which is the correct
|
||||
action: edit the Pod template's resource limits, or change the replica count? Justify your
|
||||
answer for a stateless web application.**
|
||||
|
||||
*Answer:* Change the replica count (scale out) for a stateless application handling more
|
||||
concurrent traffic — more identical copies sharing the load. Changing resource limits affects
|
||||
how much each individual Pod can use, not how many Pods exist; it doesn't add capacity for more
|
||||
concurrent requests the way scaling does.
|
||||
|
||||
---
|
||||
|
||||
**4. Why does a Deployment keep an old, scaled-to-zero ReplicaSet around instead of deleting it
|
||||
immediately after a successful rollout?**
|
||||
|
||||
*Answer:* That scaled-down ReplicaSet is exactly what makes fast, safe rollback possible later —
|
||||
deleting it immediately would mean a "rollback" would have to rebuild the old Pod template from
|
||||
scratch (or fail entirely) instead of simply scaling existing, already-defined Pods back up.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 1 is the most important one to discuss out loud — it's the exact
|
||||
diagnostic instinct students will need repeatedly in ROOT 10.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Guided Exercise: Planning a Safe Rollout Strategy
|
||||
|
||||
Paper-and-thinking exercise — work through this before the Lab.
|
||||
|
||||
## The scenario
|
||||
|
||||
Your team runs `api-server`, a Deployment with 6 replicas serving live traffic. You need to
|
||||
ship a new image version. The team lead asks: "what could go wrong, and how would we know
|
||||
before it takes down the whole service?"
|
||||
|
||||
## Your task
|
||||
|
||||
Work through these questions:
|
||||
|
||||
1. If the new image has a bug that crashes the container immediately on startup, at what point
|
||||
in the rolling update would you expect to notice — after 1 new Pod, after all 6, or somewhere
|
||||
in between? Why?
|
||||
2. What's the difference in blast radius between that crash-on-startup bug and a subtler bug
|
||||
that only fails under load, several minutes after a Pod starts serving traffic?
|
||||
3. Given your answer to question 2, what's the practical value of watching `kubectl rollout
|
||||
status` and `kubectl get pods` DURING a rollout, rather than only checking after it reports
|
||||
"successfully rolled out"?
|
||||
4. If you noticed a problem partway through, what's the single command that gets you back to
|
||||
the last known-good state fastest?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. After the FIRST new Pod — a crash-on-startup bug means the new Pod never becomes Ready
|
||||
(`CrashLoopBackOff`), so the rollout stalls immediately, well before more old Pods are
|
||||
removed. This is actually the SAFEST kind of bug, because the blast radius stays tiny.
|
||||
2. A crash-on-startup bug is naturally self-limiting (the rollout stalls itself, as above). A
|
||||
bug that only appears under load is NOT self-limiting the same way — by the time it's
|
||||
noticeable, several old (working) replicas may already have been replaced, meaning a larger
|
||||
fraction of real traffic is already hitting the broken version.
|
||||
3. Watching during the rollout, not just checking the final "successfully rolled out" message,
|
||||
is what lets you catch the load-dependent case in question 2 early — waiting for the final
|
||||
status alone would let the rollout complete entirely (100% on the broken version) before you
|
||||
noticed anything wrong.
|
||||
4. `kubectl rollout undo deployment/api-server` — assuming the previous ReplicaSet is still
|
||||
present (the default), this is faster than trying to hand-diagnose and hot-fix the new
|
||||
version under pressure.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 2's distinction (self-limiting vs. not) is the core insight worth
|
||||
making sure lands — it's why "the rollout said success" is necessary but not sufficient
|
||||
evidence that a deploy actually went well.
|
||||
|
||||
**Approximate timing:** 10-15 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what monitoring/observability signal (beyond `kubectl` output) a
|
||||
real team would want during a rollout to catch the load-dependent case even faster (a good
|
||||
forward pointer to ROOT 10's observability themes, without needing to build any actual
|
||||
monitoring infrastructure here).
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Launch Lab: Perform a Rolling Update
|
||||
|
||||
Time to run a real rolling update against a real Deployment in your disposable Lab.
|
||||
|
||||
## What you're doing
|
||||
|
||||
A Deployment `app-v1` is running an older image version. Your task is to update it to a newer
|
||||
version and confirm every replica ends up running the new image — exactly the mechanism from
|
||||
the lesson and demonstration, on a real cluster this time.
|
||||
|
||||
## Before you start
|
||||
|
||||
Recall the demonstration: `kubectl rollout status` is your tool for watching progress rather
|
||||
than repeatedly polling `kubectl get pods` yourself, and a stalled rollout with no obvious error
|
||||
usually means a new Pod can't become Ready. If your update seems to hang, that's exactly the
|
||||
diagnostic instinct from the knowledge check to apply.
|
||||
|
||||
## Your task in this Lab
|
||||
|
||||
Update the Deployment's image and verify all replicas are running the new version. Use
|
||||
`kubectl rollout status` to confirm completion, not just a single `kubectl get pods` snapshot.
|
||||
|
||||
This Lab has its own in-context hints if you get stuck.
|
||||
|
||||
When you're done, return to this module for a short reflection on what "successfully rolled
|
||||
out" actually guarantees — and doesn't.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Classroom flow:** A good moment to have students keep `kubectl get pods --watch` open in a
|
||||
second terminal (if their Lab environment supports multiple panes) so they see the incremental
|
||||
old-Pod/new-Pod transition happen live, reinforcing the demonstration.
|
||||
|
||||
**Approximate timing:** 10-15 minutes.
|
||||
|
||||
**Common mistake to watch for:** running `kubectl edit deployment` and forgetting to save/apply
|
||||
correctly, or editing the wrong field (e.g. `replicas` instead of the container image) — a good
|
||||
moment to reinforce reading the task description precisely rather than pattern-matching to a
|
||||
similar-looking previous Lab.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Reflection: Rollback Is a Plan, Not a Panic Button
|
||||
|
||||
You just completed a real rolling update. Take a few minutes to connect it to how this plays out
|
||||
in a real production team.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. "Successfully rolled out" (from `kubectl rollout status`) tells you every replica is Running
|
||||
and Ready. What does it NOT tell you about whether the new version is actually correct?
|
||||
2. If you were on-call and a rollout you just ran seemed to be causing problems, would you reach
|
||||
for `kubectl rollout undo` immediately or try to hot-fix the new version first? What would
|
||||
change your answer?
|
||||
3. Think back to the guided exercise's crash-on-startup vs. load-dependent distinction. Which
|
||||
one would a typical automated health check (like a readiness probe, ROOT 10) catch, and
|
||||
which one might slip through even with probes configured correctly?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**A rollout completing is not the same as a deploy succeeding.** "Every Pod is Running and
|
||||
Ready" only proves the new code started up and passed whatever checks are configured — it says
|
||||
nothing about whether the new version is functionally correct under real traffic. Real teams
|
||||
pair a rollout with actual monitoring (error rates, latency) before calling a deploy genuinely
|
||||
successful, not just a green `kubectl rollout status`.
|
||||
|
||||
**Rollback should be a rehearsed, low-drama action, not a last resort.** Teams that treat
|
||||
`kubectl rollout undo` as a normal, expected tool — something you reach for calmly the moment
|
||||
something looks wrong — recover from bad deploys in seconds. Teams that treat rollback as an
|
||||
admission of failure hesitate, try to hot-fix forward under pressure, and turn a small,
|
||||
recoverable mistake into a longer outage.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 is worth pushing on — many students'
|
||||
instinct is to try to fix forward, and it's worth surfacing WHY that instinct is often wrong
|
||||
under time pressure (unknown time-to-fix vs. known, fast rollback).
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to write the one-sentence Slack message they'd post to
|
||||
their team the moment they decide to roll back a bad deploy — reinforces that rollback is a
|
||||
normal, communicable operational action, not a private failure.
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# Jobs, CronJobs, and DaemonSets: The Other Workload Shapes
|
||||
|
||||
Everything in the previous module assumed a long-running application that should always have N
|
||||
replicas up. Not every workload looks like that. Kubernetes has three other primitives for
|
||||
genuinely different shapes of work — using a Deployment for any of these would be the wrong
|
||||
tool, not just a less elegant one.
|
||||
|
||||
## Job: run to completion, exactly once (or a fixed number of times)
|
||||
|
||||
A Job creates one or more Pods and considers itself done when they exit successfully — it does
|
||||
NOT restart a Pod that exits with success, and it DOES retry one that fails, up to a configured
|
||||
limit. This is fundamentally different from a Deployment, which expects its Pods to run forever
|
||||
and treats any exit as something to replace.
|
||||
|
||||
Use a Job for: a database migration, a batch data-processing task, a one-off cleanup script —
|
||||
anything with a defined "finished" state.
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: migrate-db
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: migrate
|
||||
image: myapp:latest
|
||||
command: ["./migrate.sh"]
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
```
|
||||
|
||||
## CronJob: a Job, on a schedule
|
||||
|
||||
A CronJob doesn't run Pods itself — it creates a new Job on a cron schedule (`schedule:
|
||||
"0 2 * * *"` for "every day at 2am"), and that Job behaves exactly as described above. Use a
|
||||
CronJob for anything that should run periodically and finish: nightly backups, scheduled
|
||||
reports, periodic cleanup.
|
||||
|
||||
A CronJob that reliably fails to complete (an image that always crashes, for example) will keep
|
||||
creating new Jobs on schedule, each failing the same way — this is a real, common way to
|
||||
silently exhaust cluster resources (job/pod history accumulating) if nobody's watching.
|
||||
|
||||
## DaemonSet: exactly one Pod per node, automatically
|
||||
|
||||
A DaemonSet doesn't declare a replica count at all — it ensures exactly one matching Pod runs
|
||||
on every node in the cluster (or every node matching a selector), and automatically adds a Pod
|
||||
when a new node joins. Use a DaemonSet for per-node infrastructure: a log-shipping agent, a
|
||||
node-level monitoring exporter, a network plugin component — things that fundamentally need to
|
||||
exist once per machine, not "N copies somewhere."
|
||||
|
||||
## The decision in one pass
|
||||
|
||||
| You need... | Use |
|
||||
|---|---|
|
||||
| Always-on, N identical copies, restarted forever | Deployment |
|
||||
| Run once (or a few times) until it finishes successfully | Job |
|
||||
| The above, but on a recurring schedule | CronJob |
|
||||
| Exactly one copy on every node | DaemonSet |
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live.
|
||||
|
||||
**Demo plan (if extending this PARTIAL module later):** live-create a Job with a command that
|
||||
deliberately fails once then succeeds (`backoffLimit: 2`), and watch `kubectl get pods` show
|
||||
the failed attempt alongside the eventual successful one — makes the retry-until-backoffLimit
|
||||
behavior concrete.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often assume a Job that finishes successfully gets cleaned up automatically. It
|
||||
doesn't by default — the completed Pod/Job objects remain until manually deleted or a
|
||||
`ttlSecondsAfterFinished` is configured (not covered here, worth a one-line mention if asked).
|
||||
- "DaemonSet" is sometimes confused with "one Pod, cluster-wide" (singular). It's the opposite —
|
||||
one Pod PER NODE, which can be many Pods total in a multi-node cluster.
|
||||
|
||||
**Discussion question:** "Why doesn't a Deployment support 'run once and be done'?" (Answer: a
|
||||
Deployment's whole contract is maintaining a standing replica count forever — repurposing it for
|
||||
one-shot work would fight that contract rather than use a primitive designed for it.)
|
||||
|
||||
**Optional extension:** ask what would go wrong if a CronJob's schedule ran more often than a
|
||||
single Job execution takes to complete (answer: overlapping Job runs, unless
|
||||
`concurrencyPolicy` is set to `Forbid` or `Replace` — a real gotcha worth naming even without
|
||||
building a full lab around it).
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Knowledge Check: Choosing the Right Workload Primitive
|
||||
|
||||
---
|
||||
|
||||
**1. You need to run a database migration script exactly once when a new version deploys. Which
|
||||
primitive fits, and why not a Deployment?**
|
||||
|
||||
*Answer:* A Job. A Deployment expects its Pods to run forever and replaces any that exit; a
|
||||
migration script is supposed to exit successfully when done, which a Deployment would treat as
|
||||
a failure to "fix" by restarting it — the wrong behavior for a task that's supposed to finish.
|
||||
|
||||
---
|
||||
|
||||
**2. A CronJob scheduled for "every 5 minutes" keeps creating Jobs that fail immediately due to
|
||||
a bad image tag. What actually happens over the next hour if nobody notices?**
|
||||
|
||||
*Answer:* A new failing Job (and its Pod) gets created roughly every 5 minutes, accumulating
|
||||
failed Job/Pod objects and consuming cluster resources — nothing stops this automatically. This
|
||||
is exactly the kind of slow, easy-to-miss resource exhaustion a real on-call engineer should
|
||||
watch for.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: a DaemonSet's Pod count scales with a `replicas` field, the same as a
|
||||
Deployment.**
|
||||
|
||||
*Answer:* False. A DaemonSet has no `replicas` field — its Pod count is implicitly "one per
|
||||
matching node," and it grows or shrinks automatically as nodes join or leave the cluster.
|
||||
|
||||
---
|
||||
|
||||
**4. You need a log-shipping agent that must run on every node, including any new node added
|
||||
later. Which primitive, and what would go wrong if you used a Deployment with a high replica
|
||||
count instead?**
|
||||
|
||||
*Answer:* A DaemonSet. A Deployment's replicas are placed by the scheduler wherever it decides
|
||||
is best — nothing guarantees one per node, and a newly-added node wouldn't automatically get a
|
||||
copy the way a DaemonSet guarantees.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 2 is worth emphasizing — it's a genuinely realistic "how does a
|
||||
cluster quietly get into trouble" scenario, not just trivia.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# The Service/Endpoints/DNS Request Path
|
||||
|
||||
## The problem: Pod IPs are not stable
|
||||
|
||||
Every Pod gets its own IP address, but that IP is NOT durable — when a Pod is replaced (a
|
||||
rollout, a crash recovery, a reschedule), the new Pod gets a brand new IP. If another part of
|
||||
your application had that old IP hardcoded, it would silently start talking to nothing. This is
|
||||
the exact problem in `expose-service-basics`'s scenario: "other pods cannot reliably connect
|
||||
because pod IPs change when they restart."
|
||||
|
||||
## A Service: a stable address in front of a moving set of Pods
|
||||
|
||||
A Service is a stable virtual IP and DNS name that automatically routes to whichever Pods
|
||||
currently match its label selector — regardless of how many there are or how often they change.
|
||||
You never talk to a Pod's IP directly in normal operation; you talk to the Service, and the
|
||||
Service's Endpoints (a live, continuously-updated list of the matching Pods' IPs) determines
|
||||
where traffic actually goes.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis-svc
|
||||
spec:
|
||||
selector:
|
||||
app: redis # <- matches Pods by label, same mechanism from ROOT 05
|
||||
ports:
|
||||
- port: 6379
|
||||
```
|
||||
|
||||
This is the same label-selector mechanism you learned in ROOT 05 — a Service doesn't know or
|
||||
care about specific Pod names, only which Pods currently carry a matching label.
|
||||
|
||||
## The three Service types you'll use most
|
||||
|
||||
- **ClusterIP** (the default) — reachable only from inside the cluster. Use this for
|
||||
internal-only communication, like one microservice talking to another or to a database.
|
||||
- **NodePort** — additionally exposes the Service on a static port on every node's own IP,
|
||||
reachable from outside the cluster. Mostly used for development/testing or when there's no
|
||||
cloud load balancer available.
|
||||
- **LoadBalancer** — asks the cloud provider to provision a real external load balancer pointed
|
||||
at the Service. This is the standard way to expose something to the public internet on a
|
||||
cloud-hosted cluster.
|
||||
|
||||
Choosing wrong isn't just a style preference — a ClusterIP Service is architecturally
|
||||
unreachable from outside the cluster no matter how correctly everything else is configured.
|
||||
|
||||
## DNS: how Pods actually find a Service by name
|
||||
|
||||
Every Service automatically gets a DNS name inside the cluster
|
||||
(`<service-name>.<namespace>.svc.cluster.local`, or usually just `<service-name>` from within
|
||||
the same Namespace) — this is why application code can use a plain hostname like `redis-svc`
|
||||
instead of an IP address at all. Kubernetes' internal DNS resolves that name to the Service's
|
||||
stable virtual IP, which then routes to whichever Pods are currently healthy.
|
||||
|
||||
## The full request path, end to end
|
||||
|
||||
```
|
||||
Client -> DNS lookup "redis-svc" -> Service's stable virtual IP
|
||||
-> Service consults its Endpoints (live list of matching, ready Pods)
|
||||
-> traffic forwarded to one of those Pod IPs
|
||||
```
|
||||
|
||||
Every one of these steps can fail independently, which is exactly why "the app can't reach
|
||||
redis" requires checking each layer rather than guessing — the next activity shows you what
|
||||
that looks like when the Endpoints layer is empty.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read, 10-12 minutes live with the request-path diagram.
|
||||
|
||||
**Demo plan:** Before the next activity, it's worth live-running `kubectl get endpoints
|
||||
<service-name>` against a healthy Service so students see a populated Endpoints list before
|
||||
they see an empty one in the demonstration.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often think a Service "is" a load balancer process running somewhere. It's closer to
|
||||
a routing rule maintained by `kube-proxy` on every node — worth clarifying that there's no
|
||||
single "Service Pod" to go find and debug.
|
||||
- ClusterIP vs. NodePort vs. LoadBalancer is sometimes memorized without the "why" — the
|
||||
knowledge check's Service-type question is designed to test the underlying reasoning, not
|
||||
just recall.
|
||||
|
||||
**Discussion question:** "If a Service's selector matches zero Pods, does the Service itself
|
||||
fail to exist?" (Answer: no — the Service object exists fine; its Endpoints list is just empty,
|
||||
which looks like connection failure from the client's side without any error at the Service
|
||||
level itself. This is exactly the next activity's topic.)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# Demonstration: A Service With Zero Endpoints
|
||||
|
||||
This is one of the most common "it just doesn't work" incidents in real Kubernetes operation —
|
||||
and it produces no error message at all at the Service level.
|
||||
|
||||
## The setup
|
||||
|
||||
A Service `api-svc` selects Pods labeled `app: api`. A Deployment was recently updated and its
|
||||
Pods now carry the label `app: api-v2` instead (a rename during a refactor, deliberately or
|
||||
accidentally).
|
||||
|
||||
## What you'd see from the client side
|
||||
|
||||
```
|
||||
$ curl http://api-svc:8080/health
|
||||
curl: (7) Failed to connect to api-svc port 8080: Connection refused
|
||||
```
|
||||
|
||||
Just a generic connection failure — nothing here says "your Service's selector doesn't match
|
||||
any Pods."
|
||||
|
||||
## What the Service object itself shows
|
||||
|
||||
```
|
||||
$ kubectl get service api-svc
|
||||
NAME TYPE CLUSTER-IP PORT(S) AGE
|
||||
api-svc ClusterIP 10.96.34.201 8080/TCP 2h
|
||||
```
|
||||
|
||||
Completely normal-looking. The Service exists, has a stable IP, is not in any kind of "error"
|
||||
state — because from the Service's own perspective, nothing is wrong. It's doing exactly what
|
||||
it's configured to do: route to whatever matches its selector, which currently happens to be
|
||||
nothing.
|
||||
|
||||
## Where the real evidence is: Endpoints
|
||||
|
||||
```
|
||||
$ kubectl get endpoints api-svc
|
||||
NAME ENDPOINTS AGE
|
||||
api-svc <none> 2h
|
||||
```
|
||||
|
||||
This is the actual signal. `<none>` means the Service's selector currently matches zero Pods —
|
||||
this is the single fastest check for "is this a Service-to-Pod mismatch" any time something
|
||||
reachable-in-principle looks like a connection failure.
|
||||
|
||||
## Confirming the mismatch
|
||||
|
||||
```
|
||||
$ kubectl get pods --show-labels
|
||||
NAME READY STATUS LABELS
|
||||
api-abc123 1/1 Running app=api-v2
|
||||
```
|
||||
|
||||
The Pod is healthy and running — `app=api-v2`, not `app=api`. The Service's selector
|
||||
(`app: api`) matches nothing, and nothing about the Pod itself is broken.
|
||||
|
||||
## The general diagnostic shape
|
||||
|
||||
`curl`/application-level connection failure tells you *that* something's wrong, but nothing
|
||||
about *where*. `kubectl get endpoints <service>` is the fastest way to distinguish "the Service
|
||||
has no matching Pods" from "the Pods exist and are ready but something else is wrong" —
|
||||
reach for it early whenever a Service-fronted connection fails for no obvious reason.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Live-create this exact mismatch (a Service with one selector, a Deployment
|
||||
with a slightly different label) and walk through `kubectl get service` -> `kubectl get
|
||||
endpoints` -> `kubectl get pods --show-labels` in front of the class, narrating what each
|
||||
command is and isn't telling you.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students often check the Service object first and, seeing
|
||||
nothing obviously wrong, assume the problem must be elsewhere entirely (DNS, firewall, the
|
||||
application itself) — reinforce that `kubectl get endpoints` is the specific, fast check for
|
||||
this exact failure mode, before reaching for anything more complex.
|
||||
|
||||
**Optional extension:** ask what `kubectl describe service api-svc` would additionally show
|
||||
compared to `kubectl get service` (answer: the same selector the Service is using, which lets
|
||||
you directly compare it against `kubectl get pods --show-labels`'s actual Pod labels side by
|
||||
side, rather than inferring the mismatch).
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Knowledge Check: Services & Service Discovery
|
||||
|
||||
---
|
||||
|
||||
**1. A Deployment has three replicas but `kubectl get endpoints` shows `<none>` for its Service.
|
||||
Which part of the system should you inspect first, and why?**
|
||||
|
||||
*Answer:* Compare the Service's selector (`kubectl describe service`) against the actual Pods'
|
||||
labels (`kubectl get pods --show-labels`) — a Service with a healthy backend Deployment but zero
|
||||
Endpoints almost always means the selector doesn't match the Pods' current labels, not that the
|
||||
Pods themselves are unhealthy.
|
||||
|
||||
---
|
||||
|
||||
**2. You need a microservice reachable ONLY from other services inside the same cluster, never
|
||||
from the public internet. Which Service type, and why not LoadBalancer?**
|
||||
|
||||
*Answer:* ClusterIP — it's reachable only from inside the cluster by design, which is exactly
|
||||
the requirement. A LoadBalancer would provision a real external endpoint reachable from the
|
||||
public internet, which is more exposure than the requirement calls for, and depends on cloud
|
||||
provider support this scenario doesn't need.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: application code that connects to `redis-svc` by that plain hostname is
|
||||
relying on Kubernetes' internal DNS to resolve it.**
|
||||
|
||||
*Answer:* True. Every Service automatically gets a DNS name; that's specifically what lets code
|
||||
use a stable hostname instead of hardcoding a Pod IP that could change at any time.
|
||||
|
||||
---
|
||||
|
||||
**4. A Service's Endpoints list is non-empty, but the application still can't connect. Does
|
||||
this rule out a Service-level problem entirely?**
|
||||
|
||||
*Answer:* Not entirely — a non-empty Endpoints list means at least one Pod currently matches the
|
||||
selector, but the connection could still fail for other reasons (wrong port number on the
|
||||
Service, a NetworkPolicy blocking the traffic, the application inside the Pod not actually
|
||||
listening). A populated Endpoints list narrows the problem, it doesn't prove the whole path
|
||||
works.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 1 is the single most transferable diagnostic habit from this module —
|
||||
worth explicitly calling out as "the first thing to check" for this entire class of problem.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Guided Exercise: Choosing the Right Service Type
|
||||
|
||||
Paper-and-thinking exercise before the Lab.
|
||||
|
||||
## The scenario
|
||||
|
||||
You're deploying three components for a class project on a cloud-hosted cluster:
|
||||
|
||||
1. A Postgres database that only your own backend application should ever talk to.
|
||||
2. A backend API that the frontend (also running in the cluster) calls internally.
|
||||
3. The frontend web application itself, which needs to be reachable by users on the public
|
||||
internet.
|
||||
|
||||
## Your task
|
||||
|
||||
For each of the three components, decide: ClusterIP, NodePort, or LoadBalancer — and justify
|
||||
each choice in one sentence referencing WHO needs to reach it.
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. **Postgres — ClusterIP.** Only your own backend (running inside the same cluster) needs to
|
||||
reach it; there is no reason for it to be reachable from outside the cluster at all, and
|
||||
exposing a database externally is a real, avoidable security risk.
|
||||
2. **Backend API — ClusterIP.** The frontend calls it from inside the cluster too — same
|
||||
reasoning as the database. External users never talk to the backend directly; they only ever
|
||||
go through the frontend.
|
||||
3. **Frontend — LoadBalancer** (on a real cloud-hosted cluster). Public internet users need to
|
||||
reach it, and a cloud provider's LoadBalancer is the standard mechanism for that. NodePort
|
||||
would technically work but is typically used for development/testing rather than a real
|
||||
public-facing service on a cloud cluster.
|
||||
|
||||
## Self-check
|
||||
|
||||
If you chose LoadBalancer for the database or the backend API, reconsider — "internet-reachable
|
||||
by default" is not the safe default for anything that doesn't need to be, and getting this
|
||||
choice right the first time avoids a real security exposure later, not just a style issue.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Push on the "why not just LoadBalancer everything, it works" instinct some
|
||||
students will have — this is a good early moment to connect Service-type choice to the security
|
||||
mindset that ROOT 09 builds on more fully (exposure should be intentional, not the path of
|
||||
least resistance).
|
||||
|
||||
**Approximate timing:** 10 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would need to be added (not covered in this module) to let
|
||||
public users reach the frontend at a real domain name over HTTPS rather than a bare
|
||||
LoadBalancer IP — a natural forward pointer to Ingress, covered in the next module.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Launch Lab: Exposing an Application
|
||||
|
||||
Time to create a real Service in your disposable Lab and connect it to a real Deployment.
|
||||
|
||||
## What you're doing
|
||||
|
||||
A `redis` Deployment is running, but other Pods can't reliably reach it because Pod IPs change
|
||||
whenever a Pod restarts — exactly the problem the lesson opened with. Your task is to create a
|
||||
stable ClusterIP Service in front of it.
|
||||
|
||||
## Before you start
|
||||
|
||||
Recall the request path from the lesson: your Service's `selector` has to match the Deployment
|
||||
Pods' actual labels, or you'll end up with the empty-Endpoints situation from the
|
||||
demonstration. Check the Deployment's Pod labels before writing your Service's selector, don't
|
||||
guess at them.
|
||||
|
||||
## Your task in this Lab
|
||||
|
||||
Create a ClusterIP Service exposing the `redis` Deployment on the correct port. Once it's
|
||||
created, verify it the same way the demonstration did: `kubectl get service`, then `kubectl get
|
||||
endpoints` to confirm it actually has a non-empty Endpoints list, not just that the Service
|
||||
object exists.
|
||||
|
||||
This Lab has its own in-context hints if you get stuck.
|
||||
|
||||
When you're done, return to this module for a reflection on why service discovery is
|
||||
infrastructure, not magic.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Classroom flow:** Encourage students to run `kubectl get endpoints` as their OWN verification
|
||||
step even though the Lab's automated validation will check this too — building the habit of
|
||||
checking Endpoints, not just "the Service exists," is the actual transferable skill here.
|
||||
|
||||
**Approximate timing:** 10-15 minutes.
|
||||
|
||||
**Common mistake to watch for:** a selector that matches on the wrong label key/value, or a
|
||||
port/targetPort mismatch (the Service's `port` is what clients connect to; `targetPort` is what
|
||||
the container actually listens on — conflating the two is a very common first-time error).
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Reflection: Service Discovery Is Infrastructure, Not Magic
|
||||
|
||||
You just created a Service that gives a moving set of Pods a stable address. Take a few minutes
|
||||
to connect this to real operational thinking.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, "the app can just connect to redis by name" might have seemed obvious or
|
||||
automatic. What specific mechanism actually makes that true, and what would break it?
|
||||
2. If your Service's selector had been wrong (matching zero Pods), what would the SYMPTOM have
|
||||
looked like from the application's side, versus what the actual CAUSE would have been?
|
||||
3. Why is checking `kubectl get endpoints` before assuming a networking/DNS/firewall problem a
|
||||
good default habit, given how common a selector mismatch actually is?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**"It's probably a networking issue" is often wrong, and Endpoints is how you find out fast.** A
|
||||
huge fraction of real "service X can't reach service Y" incidents in Kubernetes are Service
|
||||
selector mismatches, not actual network problems — checking `kubectl get endpoints` first, before
|
||||
escalating to firewall rules or DNS debugging, saves real time precisely because this failure
|
||||
mode produces zero errors at the layers people instinctively check first.
|
||||
|
||||
**Stable addressing is what makes rolling updates and self-healing actually usable.** Everything
|
||||
you learned in ROOT 06 about Pods being replaced during a rollout or after a crash only works
|
||||
smoothly in practice because Services give callers a stable target that survives those
|
||||
replacements — without this layer, every Pod replacement would require updating every caller's
|
||||
configuration by hand.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** use question 2 as a discussion prompt — the
|
||||
symptom/cause gap is exactly why this class of bug is often under-diagnosed in real teams.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to write the one-line runbook entry they'd want a future
|
||||
on-call engineer to have for "Service X seems unreachable" — a good exercise in turning a
|
||||
lesson into something operationally reusable.
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Ingress and NetworkPolicy: What They Architecturally Do
|
||||
|
||||
## Ingress: routing external HTTP(S) traffic by host/path
|
||||
|
||||
You already know a LoadBalancer Service can expose something to the public internet. But a real
|
||||
application usually needs more than "one raw IP to one Service" — you might want
|
||||
`api.example.com` and `app.example.com` to route to different backend Services, all through one
|
||||
entry point, with TLS termination handled in one place instead of per-Service.
|
||||
|
||||
**Ingress** is the Kubernetes object for exactly this: a set of HTTP routing rules (by hostname
|
||||
and/or URL path) that an Ingress controller reads and turns into actual load-balancer/proxy
|
||||
configuration.
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: app-ingress
|
||||
spec:
|
||||
rules:
|
||||
- host: app.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: frontend-svc
|
||||
port: { number: 80 }
|
||||
```
|
||||
|
||||
An important distinction: Ingress is a set of ROUTING RULES, not a load balancer itself — an
|
||||
Ingress controller (a separate piece of software running in the cluster) is what actually reads
|
||||
these rules and does the routing. Without a controller installed, an Ingress object exists in
|
||||
the API but nothing acts on it.
|
||||
|
||||
## NetworkPolicy: traffic reachability as something you declare, not assume
|
||||
|
||||
By default, every Pod in a Kubernetes cluster can reach every other Pod — there is no built-in
|
||||
network isolation between them at all. This surprises people coming from a "obviously the
|
||||
network is locked down unless configured otherwise" assumption. It is exactly backwards from
|
||||
that assumption: **everything is reachable until you say otherwise.**
|
||||
|
||||
A NetworkPolicy changes this for the Pods it selects, by declaring which traffic IS allowed —
|
||||
everything not explicitly allowed to a Pod covered by at least one NetworkPolicy is then denied.
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-frontend-to-backend
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels: { app: backend }
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels: { app: frontend }
|
||||
```
|
||||
|
||||
This example allows traffic to `backend` Pods only from `frontend` Pods — once this policy
|
||||
exists and selects `backend`, every OTHER Pod trying to reach `backend` is now blocked, even
|
||||
though nothing was true about that before the policy existed.
|
||||
|
||||
## Why this matters architecturally, before it matters as a security control
|
||||
|
||||
Right now, think of NetworkPolicy purely as "declaring intended reachability, not assuming it."
|
||||
ROOT 09 comes back to NetworkPolicy specifically as a SECURITY control (default-deny postures,
|
||||
least-privilege network access) — this module is deliberately just the architectural mental
|
||||
model: Kubernetes doesn't isolate network traffic for you by default, and NetworkPolicy is the
|
||||
mechanism for declaring the reachability you actually intend.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often assume Ingress by itself "does" the routing. Emphasize the controller
|
||||
requirement — an Ingress object with no controller installed does nothing.
|
||||
- The "everything reachable by default" fact genuinely surprises most students the first time
|
||||
they hear it — it's worth stating plainly and letting it land rather than rushing past it.
|
||||
|
||||
**Discussion question:** "If NO NetworkPolicy exists anywhere in a Namespace, what's the actual
|
||||
reachability between Pods in that Namespace?" (Answer: completely open — every Pod can reach
|
||||
every other Pod, matching the "everything reachable by default" rule.)
|
||||
|
||||
**Optional extension:** ask what would happen if you created a NetworkPolicy that selects
|
||||
`frontend` Pods but declares no `ingress` rules at all (answer: `frontend` Pods would become
|
||||
completely unreachable by anything — an empty ingress list inside a NetworkPolicy that HAS an
|
||||
ingress policyType means "deny all incoming," not "no policy applies").
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Knowledge Check: Ingress & NetworkPolicy Foundations
|
||||
|
||||
---
|
||||
|
||||
**1. You create an Ingress object with correct routing rules, but no traffic is ever routed
|
||||
according to them. What's the most likely reason?**
|
||||
|
||||
*Answer:* No Ingress controller is installed/running in the cluster. Ingress objects are just
|
||||
routing rules; a separate controller has to actually read and act on them, or nothing happens.
|
||||
|
||||
---
|
||||
|
||||
**2. True or false: by default, a Pod in Namespace A cannot reach a Pod in Namespace B unless a
|
||||
NetworkPolicy explicitly allows it.**
|
||||
|
||||
*Answer:* False — the opposite is true by default. With NO NetworkPolicy in place at all,
|
||||
every Pod can reach every other Pod regardless of Namespace. NetworkPolicy is what RESTRICTS
|
||||
this default-open behavior, not what's required to enable connectivity in the first place.
|
||||
|
||||
---
|
||||
|
||||
**3. A NetworkPolicy selects Pods labeled `app: backend` and declares one ingress rule allowing
|
||||
traffic from Pods labeled `app: frontend`. What happens to traffic from a Pod labeled
|
||||
`app: admin-tool` trying to reach `backend`?**
|
||||
|
||||
*Answer:* It's blocked. Once a NetworkPolicy selects a Pod, only traffic matching one of its
|
||||
explicit rules is allowed — everything else to that Pod is denied, including traffic that would
|
||||
have worked fine before the policy existed.
|
||||
|
||||
---
|
||||
|
||||
**4. Why is Ingress the right tool for exposing an HTTP application by hostname, rather than
|
||||
creating a separate LoadBalancer Service per hostname?**
|
||||
|
||||
*Answer:* A LoadBalancer Service typically provisions its own external IP/load balancer per
|
||||
Service — one per hostname would mean paying for and managing N separate external endpoints.
|
||||
Ingress lets one entry point route to many backend Services by hostname/path, which is both
|
||||
cheaper and centralizes concerns like TLS termination.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 2 catches a very common and consequential misconception — worth
|
||||
confirming out loud that everyone actually updated their mental model, not just guessed
|
||||
correctly.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
# Ephemeral vs. Persistent Storage
|
||||
|
||||
## The default: a container's filesystem is thrown away
|
||||
|
||||
By default, anything a container writes to its own filesystem disappears the moment that
|
||||
container is replaced — a crash, a rollout, a reschedule, anything. This is a deliberate design
|
||||
choice (it's part of what makes a Pod safely disposable/replaceable), but it means "just write
|
||||
files to disk" silently loses data unless you use one of Kubernetes' actual storage mechanisms.
|
||||
|
||||
## emptyDir: shared, but still temporary
|
||||
|
||||
An `emptyDir` volume gives a Pod's containers a shared directory that survives a container
|
||||
restart WITHIN the same Pod, but is deleted the moment the Pod itself is removed. It's the right
|
||||
tool for two containers in one Pod that need to share files with each other (a sidecar writing
|
||||
logs another container reads, for example) — not for anything that needs to outlive the Pod.
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- name: shared-data
|
||||
emptyDir: {}
|
||||
```
|
||||
|
||||
## PersistentVolume & PersistentVolumeClaim: storage that outlives the Pod
|
||||
|
||||
A **PersistentVolume (PV)** represents an actual piece of storage (a cloud disk, an NFS share,
|
||||
whatever the cluster's infrastructure provides) as a Kubernetes object. A
|
||||
**PersistentVolumeClaim (PVC)** is a Pod's REQUEST for storage matching some criteria (size,
|
||||
access mode) — the Pod references the PVC, not a PV directly, and Kubernetes binds the PVC to a
|
||||
matching PV.
|
||||
|
||||
```
|
||||
Pod ──references──> PVC ("I need 5Gi, ReadWriteOnce") ──binds to──> PV (actual storage)
|
||||
```
|
||||
|
||||
This indirection matters: a Pod's manifest doesn't need to know or care exactly which physical
|
||||
storage backs its PVC — that's resolved separately, and the same PVC keeps working even if the
|
||||
Pod behind it is replaced.
|
||||
|
||||
## StorageClass & dynamic provisioning
|
||||
|
||||
Manually creating a PV for every PVC doesn't scale. A **StorageClass** describes a category of
|
||||
storage (e.g. "fast SSD" vs. "cheap archival") and, when a PVC requests that class, the cluster
|
||||
can automatically provision a matching PV on demand — this is "dynamic provisioning," and it's
|
||||
the normal way storage works on any real cloud-hosted cluster. A StorageClass also controls the
|
||||
**reclaim policy** — what happens to the underlying storage after its PVC is deleted (`Delete`
|
||||
the data, or `Retain` it for manual recovery).
|
||||
|
||||
## The decision in one pass
|
||||
|
||||
| You need... | Use |
|
||||
|---|---|
|
||||
| Temporary scratch space shared between containers in ONE Pod | `emptyDir` |
|
||||
| Storage that survives Pod replacement, manually provisioned | PV + PVC |
|
||||
| The above, provisioned automatically on demand | PVC + StorageClass (dynamic provisioning) |
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live with the PV/PVC/StorageClass diagram.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often assume `emptyDir` is "persistent" because the name doesn't obviously say
|
||||
otherwise. Be explicit: it survives a CONTAINER restart, not a POD deletion.
|
||||
- The PV/PVC indirection can seem like unnecessary ceremony at first — frame it as the same
|
||||
"declare what you want, let the system resolve it" pattern from ROOT 05's desired-state
|
||||
lesson, applied to storage instead of Pod count.
|
||||
|
||||
**Discussion question:** "If a Pod using a PVC is deleted and replaced, what happens to its
|
||||
data?" (Answer: it's preserved — the PVC and its bound PV are separate objects from the Pod,
|
||||
and a new Pod referencing the same PVC picks up right where the data was left, which is the
|
||||
entire point of using persistent storage over `emptyDir`.)
|
||||
|
||||
**Optional extension:** ask what a `Retain` reclaim policy is protecting against, compared to
|
||||
`Delete` (answer: accidental data loss from deleting a PVC by mistake — `Retain` keeps the
|
||||
underlying storage around for manual recovery instead of immediately destroying it).
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Knowledge Check: Storage Fundamentals
|
||||
|
||||
---
|
||||
|
||||
**1. Two containers in the same Pod need to share files with each other, but nothing needs to
|
||||
survive the Pod being replaced. What's the simplest correct choice?**
|
||||
|
||||
*Answer:* `emptyDir` — shared within the Pod, deliberately temporary, exactly matching the
|
||||
requirement. Reaching for a PV/PVC here would be more machinery than the problem needs.
|
||||
|
||||
---
|
||||
|
||||
**2. A Pod using a PVC is deleted and a new Pod is created referencing the SAME PVC. What
|
||||
happens to the data?**
|
||||
|
||||
*Answer:* It's preserved. The PVC and its bound PV exist independently of any specific Pod — the
|
||||
new Pod picks up exactly where the data was left, which is the entire reason to use persistent
|
||||
storage instead of `emptyDir` for anything that needs to outlive a single Pod's lifetime.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: a Pod's manifest must specify exactly which PersistentVolume backs its
|
||||
storage request.**
|
||||
|
||||
*Answer:* False. A Pod references a PVC (a request), not a PV directly — the binding between
|
||||
PVC and an actual PV is resolved separately, which is exactly the indirection that lets the same
|
||||
Pod manifest work regardless of which physical storage ends up behind it.
|
||||
|
||||
---
|
||||
|
||||
**4. What does a StorageClass's reclaim policy actually control, and why would a team choose
|
||||
`Retain` over `Delete` for a production database's storage?**
|
||||
|
||||
*Answer:* It controls what happens to the underlying storage after its PVC is deleted. `Retain`
|
||||
keeps the data around for manual recovery instead of immediately destroying it — a safety
|
||||
margin against an accidental `kubectl delete pvc` on something genuinely important, at the cost
|
||||
of needing manual cleanup later.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 2 is worth confirming lands correctly — it's the single fact that
|
||||
justifies the entire PV/PVC indirection existing at all.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# How the Scheduler Places Pods, and How Governance Shapes It
|
||||
|
||||
## The scheduler's job
|
||||
|
||||
Every time a new Pod needs to run, the scheduler decides which node it lands on — you never
|
||||
manually assign a Pod to a node in normal operation (ROOT 05's mental model). By default, the
|
||||
scheduler picks based on available resources (does this node have enough free CPU/memory), but
|
||||
several mechanisms let you INFLUENCE or CONSTRAIN that decision when the default isn't good
|
||||
enough.
|
||||
|
||||
## Steering placement: nodeSelector, taints/tolerations, affinity
|
||||
|
||||
- **nodeSelector** — the simplest constraint: "only schedule this Pod on nodes with THIS
|
||||
label" (e.g. `disktype: ssd`). A blunt but clear tool.
|
||||
- **Taints and tolerations** — the inverse relationship. A NODE is tainted (marked "don't
|
||||
schedule here unless you specifically tolerate this taint") and a Pod TOLERATES a specific
|
||||
taint to be allowed to land there anyway. Commonly used to reserve nodes for specific
|
||||
workloads (e.g. GPU nodes tainted so only GPU-requiring Pods land there) or to keep general
|
||||
workloads off nodes reserved for something else.
|
||||
- **Affinity/anti-affinity** — more expressive rules: "prefer/require scheduling near Pods with
|
||||
label X" (affinity) or "prefer/require scheduling AWAY from Pods with label X"
|
||||
(anti-affinity). A common real use: anti-affinity between replicas of the same application, so
|
||||
a single node failure can't take down every replica at once.
|
||||
|
||||
## Priority: which Pods matter more when resources are tight
|
||||
|
||||
A **PriorityClass** lets you mark some Pods as more important than others. When the cluster is
|
||||
under resource pressure, a higher-priority pending Pod can even cause a lower-priority RUNNING
|
||||
Pod to be evicted (preempted) to make room — this is a real, consequential mechanism, not just a
|
||||
scheduling hint, and misconfiguring it can mean an unimportant batch job unexpectedly evicts
|
||||
something that actually mattered.
|
||||
|
||||
## PodDisruptionBudget: protecting against VOLUNTARY disruption
|
||||
|
||||
A **PodDisruptionBudget (PDB)** limits how many replicas of an application can be taken down
|
||||
AT ONCE during voluntary disruptions — a node drain for maintenance, a cluster upgrade — by
|
||||
declaring a minimum number (or percentage) that must stay available. This does NOT protect
|
||||
against involuntary disruption (a node crashing unexpectedly) — it specifically constrains
|
||||
operations that Kubernetes itself controls the timing of.
|
||||
|
||||
```yaml
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: web-pdb
|
||||
spec:
|
||||
minAvailable: 2
|
||||
selector:
|
||||
matchLabels: { app: web }
|
||||
```
|
||||
|
||||
Without a PDB, a node drain during routine maintenance could legally take down every replica of
|
||||
`web` simultaneously if they all happened to be on nodes being drained together — a PDB is what
|
||||
prevents that from being "technically allowed."
|
||||
|
||||
## ResourceQuota & LimitRange: governance at the Namespace level
|
||||
|
||||
A **ResourceQuota** caps the TOTAL resources (CPU, memory, object counts) a Namespace can
|
||||
consume — useful for multi-team clusters where one team's runaway workload shouldn't be able to
|
||||
starve everyone else. A **LimitRange** sets DEFAULT and/or min/max resource requests/limits for
|
||||
individual containers within a Namespace, so a Pod created without explicit resource settings
|
||||
still gets sensible ones rather than none at all.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 20 minutes read; 15 minutes live given the number of concepts covered.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Taints/tolerations are frequently confused with affinity — clarify the directionality: a
|
||||
taint REPELS by default (node says no); a toleration is what lets a specific Pod through
|
||||
anyway. Affinity is the Pod expressing a preference, not the node restricting access.
|
||||
- Students sometimes assume a PDB prevents ALL disruption. Be explicit that it only constrains
|
||||
VOLUNTARY disruption (things Kubernetes/an operator controls the timing of) — a hardware
|
||||
failure ignores PDBs entirely, because nobody is "asking permission" for a crash.
|
||||
|
||||
**Discussion question:** "Why would a real team use anti-affinity between replicas of the same
|
||||
application instead of just trusting the scheduler's default spread?" (Answer: the default
|
||||
scheduler does consider spread as one factor among several, but doesn't GUARANTEE it the way an
|
||||
explicit anti-affinity rule does — for something where "don't lose all replicas to one node
|
||||
failure" is a hard requirement, not just a nice-to-have, an explicit rule is safer than an
|
||||
implicit tendency.)
|
||||
|
||||
**Optional extension:** ask what would happen to a Namespace's Pods if a ResourceQuota were
|
||||
added AFTER Pods without explicit resource requests already existed there (a good bridge to why
|
||||
LimitRange defaults matter — a Quota alone doesn't retroactively fix Pods created without
|
||||
resource requests, it just constrains what NEW Pods can consume going forward).
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Knowledge Check: Scheduling & Resource Governance
|
||||
|
||||
---
|
||||
|
||||
**1. A node is tainted `dedicated=gpu:NoSchedule`. A Pod with no matching toleration is
|
||||
pending and never gets scheduled anywhere else, even though other untainted nodes have free
|
||||
capacity. What's actually going on?**
|
||||
|
||||
*Answer:* Nothing is wrong — the taint only affects the tainted node specifically; it doesn't
|
||||
explain why the Pod isn't landing elsewhere. If it's stuck pending entirely, the cause is
|
||||
something else (insufficient resources cluster-wide, a nodeSelector/affinity rule that can't be
|
||||
satisfied anywhere) — the taint itself only ever repels from the ONE node it's applied to.
|
||||
|
||||
---
|
||||
|
||||
**2. During a planned node drain for maintenance, would you expect a PodDisruptionBudget to
|
||||
prevent an UNRELATED node from crashing unexpectedly at the same time?**
|
||||
|
||||
*Answer:* No. A PDB only constrains VOLUNTARY disruptions — operations Kubernetes/an operator
|
||||
controls the timing of, like a drain. An unrelated hardware failure is involuntary and ignores
|
||||
PDBs entirely; nothing is "asking permission" for a crash.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: a higher-priority pending Pod can cause a lower-priority, already-RUNNING
|
||||
Pod to be evicted to make room.**
|
||||
|
||||
*Answer:* True — this is what Pod priority/preemption actually does under resource pressure,
|
||||
not just a scheduling hint. Misconfigured priorities can have real, disruptive consequences for
|
||||
this reason.
|
||||
|
||||
---
|
||||
|
||||
**4. A team notices new Pods in their Namespace keep getting created with no CPU/memory
|
||||
requests at all, even though nobody set that intentionally. What governance mechanism would fix
|
||||
this going forward, and what would it NOT fix?**
|
||||
|
||||
*Answer:* A LimitRange with default requests/limits would apply sensible defaults to future
|
||||
Pods created without explicit values. It would NOT retroactively change Pods that already exist
|
||||
— it only affects new Pod creation from the point it's added.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 2 reinforces a genuinely important, often-missed distinction
|
||||
(voluntary vs. involuntary disruption) — worth confirming it lands, not just that the answer was
|
||||
guessed correctly.
|
||||
|
||||
**Approximate timing:** 5-7 minutes given the density of this module's lesson.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Defense in Depth & the Kubernetes Security Model
|
||||
|
||||
## Security is a posture, not a single setting
|
||||
|
||||
There is no single "make it secure" switch in Kubernetes. Real security comes from **defense in
|
||||
depth** — multiple independent layers, each reducing what an attacker (or a bug) can do if an
|
||||
earlier layer fails. If a container has a vulnerability that lets an attacker execute arbitrary
|
||||
code, the question that actually matters is: what can that code DO once it's running? A
|
||||
container running as root, with every Linux capability, on a writable filesystem, with no
|
||||
restrictions at all, can do far more damage than one deliberately hardened to need almost
|
||||
nothing.
|
||||
|
||||
## Least privilege: grant only what's actually needed
|
||||
|
||||
The organizing principle behind everything in this course is **least privilege**: a workload
|
||||
should have exactly the permissions and capabilities it needs to do its job, and nothing more.
|
||||
This sounds obvious stated plainly, but the DEFAULT behavior of a plain container is closer to
|
||||
"maximum privilege unless told otherwise" — root user, all standard Linux capabilities, a
|
||||
writable filesystem. Hardening a Pod means deliberately narrowing that down.
|
||||
|
||||
## SecurityContext: where you actually configure this
|
||||
|
||||
`securityContext` (settable at the Pod level and per-container) is where you declare these
|
||||
restrictions:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
containers:
|
||||
- name: app
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
```
|
||||
|
||||
- **`runAsNonRoot` / `runAsUser`** — the container process runs as a specific non-root user
|
||||
instead of root. If a container image was built to require root and you set this, it will
|
||||
fail to start — which is a good, loud failure, not a silent security gap. Hardening
|
||||
sometimes surfaces an image's OWN bad assumptions.
|
||||
- **`capabilities: drop: ["ALL"]`** — Linux capabilities are fine-grained permissions
|
||||
(`NET_BIND_SERVICE`, `SYS_ADMIN`, dozens more) a process can hold even without full root.
|
||||
Dropping all of them, then adding back only the specific ones actually needed, is the
|
||||
least-privilege pattern applied to capabilities specifically.
|
||||
- **`readOnlyRootFilesystem: true`** — the container's own filesystem cannot be written to at
|
||||
all (use an explicit `emptyDir` volume, ROOT 08, for anything that genuinely needs to write
|
||||
temporary files). This closes off a whole category of attack that relies on writing a
|
||||
malicious file to disk.
|
||||
|
||||
## Why this matters even when "nothing is currently wrong"
|
||||
|
||||
None of these settings fix a bug or a vulnerability — they limit the BLAST RADIUS if one exists
|
||||
that you don't yet know about. A container hardened with non-root, dropped capabilities, and a
|
||||
read-only filesystem gives a successful exploit dramatically less to work with than an
|
||||
unhardened one, even against the exact same vulnerability. This is the entire point of defense
|
||||
in depth: you don't rely on any single layer being perfect.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10-12 minutes live with the demonstration.
|
||||
|
||||
**Demo plan:** Have the demonstration activity ready to run right after this lesson — seeing a
|
||||
concrete before/after Pod definition lands the abstract "least privilege" principle far better
|
||||
than the lesson text alone.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students sometimes think hardening settings are only relevant "if you expect to be attacked."
|
||||
Reframe: defense in depth assumes SOMETHING will eventually go wrong somewhere (a
|
||||
vulnerability, a bug, a misconfiguration) and prepares for that, rather than betting
|
||||
everything on nothing ever going wrong.
|
||||
- `runAsNonRoot: true` failing a Pod's startup is sometimes read as "the security setting is
|
||||
broken." It's the opposite — it's correctly refusing to let a root-requiring image start,
|
||||
which is valuable, actionable information about the image itself.
|
||||
|
||||
**Discussion question:** "If dropping all capabilities breaks a container that turns out to
|
||||
genuinely need one specific capability, what's the right fix — removing the drop, or something
|
||||
more targeted?" (Answer: add back only that ONE specific capability rather than removing the
|
||||
blanket drop — least privilege means "exactly what's needed," not "give up and grant
|
||||
everything again.")
|
||||
|
||||
**Optional extension:** ask students to identify one setting from this lesson that would have
|
||||
made zero difference against a vulnerability that lets an attacker read environment variables
|
||||
(a good check that they understand these are layered, not universal, protections — the answer
|
||||
is that none of SecurityContext's settings here directly protect secrets stored in env vars;
|
||||
that's a different concern, covered later in this course).
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Demonstration: A Pod Before and After Hardening
|
||||
|
||||
## Before: the default, unhardened Pod
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: app-unhardened
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp:latest
|
||||
```
|
||||
|
||||
Checking what this actually runs as:
|
||||
|
||||
```
|
||||
$ kubectl exec app-unhardened -- id
|
||||
uid=0(root) gid=0(root) groups=0(root)
|
||||
|
||||
$ kubectl exec app-unhardened -- touch /etc/anything-i-want
|
||||
$ kubectl exec app-unhardened -- ls -la /etc/anything-i-want
|
||||
-rw-r--r-- 1 root root 0 Jan 1 00:00 /etc/anything-i-want
|
||||
```
|
||||
|
||||
Running as root, with a fully writable filesystem, including system directories. This is not a
|
||||
misconfiguration — it's simply what happens when nothing restricts it.
|
||||
|
||||
## After: the same application, hardened
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: app-hardened
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp:latest
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
```
|
||||
|
||||
```
|
||||
$ kubectl exec app-hardened -- id
|
||||
uid=1000 gid=0 groups=0
|
||||
|
||||
$ kubectl exec app-hardened -- touch /etc/anything-i-want
|
||||
touch: /etc/anything-i-want: Read-only file system
|
||||
```
|
||||
|
||||
Same application image, same functionality (assuming the app doesn't itself require root or
|
||||
filesystem writes it wasn't told about) — but a compromise of this container now can't write
|
||||
to the filesystem, can't use the elevated capabilities that were dropped, and never had root to
|
||||
begin with.
|
||||
|
||||
## What this actually buys you
|
||||
|
||||
Nothing here fixed a bug in `myapp:latest` — the image is identical in both cases. What changed
|
||||
is what an attacker (or a bug behaving unexpectedly) could DO if something inside that container
|
||||
went wrong. The unhardened version had almost no limits; the hardened version has several
|
||||
independent ones, any of which alone would have blocked the `touch` command above.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run both Pods live if time allows — watching `touch` succeed on one and fail
|
||||
on the other in real time is far more convincing than reading it.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students sometimes read the hardened Pod's failed `touch`
|
||||
as "hardening broke something." Reinforce: the FUNCTIONALITY didn't change (the application
|
||||
itself never needed to write to `/etc`) — only an unnecessary, unused capability was removed.
|
||||
If hardening breaks something the application genuinely needs, that's real information about
|
||||
the application, not a reason to skip hardening.
|
||||
|
||||
**Optional extension:** ask what would happen if `myapp:latest` were built assuming it could
|
||||
write a cache file to `/tmp` inside the container — a good bridge to the fact that
|
||||
`readOnlyRootFilesystem` often needs an explicit `emptyDir` volume mounted at the specific path
|
||||
an application legitimately needs to write to (ROOT 08), rather than being an all-or-nothing
|
||||
choice.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Knowledge Check: Security Model & SecurityContext
|
||||
|
||||
---
|
||||
|
||||
**1. A container is hardened with `capabilities: drop: ["ALL"]` and then fails because it
|
||||
genuinely needs to bind to a low-numbered network port, which requires `NET_BIND_SERVICE`.
|
||||
What's the least-privilege fix?**
|
||||
|
||||
*Answer:* Add back only `NET_BIND_SERVICE` specifically (`capabilities: add:
|
||||
["NET_BIND_SERVICE"]` alongside the drop-all), not remove the blanket drop entirely.
|
||||
Least privilege means granting exactly what's needed, not reverting to granting everything.
|
||||
|
||||
---
|
||||
|
||||
**2. True or false: setting `runAsNonRoot: true` fixes a vulnerability inside the application
|
||||
itself.**
|
||||
|
||||
*Answer:* False. It doesn't fix anything about the application's code — it limits what a
|
||||
compromise of that code could DO, by ensuring the process never has root privileges to begin
|
||||
with. This is the defense-in-depth distinction: reducing blast radius, not eliminating bugs.
|
||||
|
||||
---
|
||||
|
||||
**3. Why might `runAsNonRoot: true` cause a Pod to fail to start entirely, and is that a
|
||||
problem with the setting?**
|
||||
|
||||
*Answer:* If the container image was built assuming it runs as root (e.g. it tries to bind a
|
||||
privileged port, or write to a root-owned directory), the Pod will fail to start under
|
||||
`runAsNonRoot`. This is NOT a problem with the setting — it's valuable, actionable information
|
||||
that the image itself makes an assumption that should probably be fixed.
|
||||
|
||||
---
|
||||
|
||||
**4. A container has `readOnlyRootFilesystem: true` and needs to write temporary cache files.
|
||||
What's the correct way to allow that without abandoning the read-only filesystem entirely?**
|
||||
|
||||
*Answer:* Mount an explicit `emptyDir` volume (ROOT 08) at the specific path the application
|
||||
needs to write to. This keeps the rest of the filesystem read-only while allowing the one
|
||||
legitimately-needed writable location.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 1 and question 4 both reinforce the same "narrow the exception, don't
|
||||
remove the protection" pattern — worth calling out that they're the same principle applied
|
||||
twice.
|
||||
|
||||
**Approximate timing:** 5-7 minutes.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Guided Exercise: Planning a Pod Hardening Checklist
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
You're handed a Pod spec for a small internal API with NO `securityContext` set at all — the
|
||||
fully-default, unhardened case from the demonstration. Your team lead asks you to harden it
|
||||
before it ships.
|
||||
|
||||
## Your task
|
||||
|
||||
Without writing the actual YAML yet, answer these:
|
||||
|
||||
1. List the settings you'd add, in the order you'd verify them (hint: there are at least three
|
||||
distinct concerns from the lesson — user/root, filesystem, capabilities).
|
||||
2. For each setting, name ONE way you'd notice if you'd broken the application by adding it
|
||||
(what would the failure actually look like?).
|
||||
3. If the application genuinely needs to write a cache file to disk, does that mean you should
|
||||
skip `readOnlyRootFilesystem` entirely? What's the more precise fix?
|
||||
4. Suppose after hardening, the container still runs fine but a security scan flags that it
|
||||
still has the `NET_RAW` capability. What's the most likely reason, and what's the fix?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. In a reasonable order: (a) `runAsNonRoot`/`runAsUser` — a fundamental identity change,
|
||||
surfaces image assumptions fastest; (b) `capabilities: drop: ["ALL"]` — narrow, usually safe
|
||||
for a typical web API; (c) `readOnlyRootFilesystem: true` — most likely to need an
|
||||
accompanying volume, so verify last once the others are confirmed working.
|
||||
2. (a) Pod fails to start entirely (`CrashLoopBackOff` or an immediate error) if the image
|
||||
assumed root. (b) The application errors on a specific operation that needed a capability
|
||||
you dropped (e.g. failing to bind a privileged port). (c) A write operation fails at runtime
|
||||
(a log line, an exception) the first time the app tries to write to disk.
|
||||
3. No — the fix is mounting an explicit `emptyDir` volume at the specific path the cache needs,
|
||||
keeping the rest of the filesystem read-only rather than abandoning the protection entirely.
|
||||
4. Most likely: `capabilities: drop: ["ALL"]` was never actually applied (a YAML indentation
|
||||
mistake, or it was added to the Pod's securityContext instead of the container's — capability
|
||||
drops are container-level, not Pod-level). The fix is verifying the drop is actually present
|
||||
on the CONTAINER's securityContext specifically.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 4 is deliberately tricky — Pod-level vs. container-level
|
||||
`securityContext` fields is a real, common source of "I set this and it didn't work" confusion
|
||||
worth surfacing here before students hit it in a Lab.
|
||||
|
||||
**Approximate timing:** 15 minutes live with discussion; 12-15 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask students to identify which of the settings in this exercise would
|
||||
be MOST valuable to apply first if they could only add one to a large, existing fleet of
|
||||
unhardened Pods under time pressure, and why.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Reflection: Least Privilege Is a Default, Not an Afterthought
|
||||
|
||||
You just hardened real Pods across several Labs — non-root, dropped capabilities, a read-only
|
||||
filesystem, and a full SecurityContext review. Take a few minutes to connect this to real
|
||||
practice.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, would you have described "runs as root with a fully writable
|
||||
filesystem" as insecure, or just as "the default, unremarkable configuration"? Has that
|
||||
changed?
|
||||
2. None of the settings you configured fixed a bug in any application. What did they actually
|
||||
change, precisely?
|
||||
3. If your team had to choose between hardening every new Pod by default (as a policy) versus
|
||||
hardening Pods only after an incident reveals a gap, which produces less total risk over
|
||||
time, and why?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Unhardened is not neutral — it's the maximum-privilege default.** A container with no
|
||||
SecurityContext configured isn't "unopinionated"; it's root, every capability, fully writable —
|
||||
the MOST privileged configuration possible, not a safe middle ground. Teams that treat hardening
|
||||
as optional are choosing maximum blast radius by default, usually without realizing they're
|
||||
choosing anything at all.
|
||||
|
||||
**Defense in depth means no single mistake should be catastrophic.** The value of stacking
|
||||
non-root + dropped capabilities + read-only filesystem isn't that any ONE of them is perfect —
|
||||
it's that a real compromise would have to defeat several independent restrictions simultaneously
|
||||
instead of exploiting one gap that grants everything. This is exactly why security-minded teams
|
||||
apply all of these by default rather than picking "the one that matters most."
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 3 is worth pushing on directly — it's
|
||||
the actual business case for security-by-default, not just a technical preference.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to draft a one-paragraph "default Pod hardening policy"
|
||||
their hypothetical team would adopt, based on what they've learned in this module.
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Identity for Workloads: ServiceAccounts & RBAC
|
||||
|
||||
## Workloads need an identity too, separate from yours
|
||||
|
||||
When YOU run `kubectl`, you're authenticated as yourself. But a Pod running INSIDE the cluster
|
||||
that needs to call the Kubernetes API (to list other Pods, read a ConfigMap, whatever) needs its
|
||||
own identity — it can't and shouldn't use your personal credentials. That identity is a
|
||||
**ServiceAccount**.
|
||||
|
||||
Every Pod runs as SOME ServiceAccount — if you don't specify one, it uses the `default`
|
||||
ServiceAccount in its Namespace, which by default has essentially no permissions. A Pod's
|
||||
ServiceAccount token is automatically mounted into the container unless you explicitly disable
|
||||
that (`automountServiceAccountToken: false`) — worth doing for any Pod that never actually needs
|
||||
to call the Kubernetes API at all, since an unused, mounted credential is still something an
|
||||
attacker could steal and use.
|
||||
|
||||
## RBAC: what that identity is actually allowed to do
|
||||
|
||||
Having an identity (a ServiceAccount) doesn't grant any permissions by itself — **RBAC
|
||||
(Role-Based Access Control)** is the separate system that defines what an identity can DO.
|
||||
|
||||
- A **Role** (namespace-scoped) or **ClusterRole** (cluster-wide) defines a set of permissions:
|
||||
which API resources, which verbs (get/list/watch/create/update/delete).
|
||||
- A **RoleBinding** (or **ClusterRoleBinding**) connects a Role/ClusterRole to a specific
|
||||
identity (a ServiceAccount, a user, or a group) — the Role alone grants nothing to anyone
|
||||
until it's bound.
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: pod-reader
|
||||
namespace: default
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: read-pods
|
||||
namespace: default
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: my-app
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: pod-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
This grants the `my-app` ServiceAccount permission to `get`/`list` Pods in the `default`
|
||||
Namespace — and nothing else. It cannot delete Pods, cannot touch Secrets, cannot do anything
|
||||
not explicitly listed.
|
||||
|
||||
## Role vs. ClusterRole: scope matters
|
||||
|
||||
A Role only grants permissions within the ONE Namespace it's defined in. A ClusterRole can grant
|
||||
permissions across the entire cluster (or, bound via a RoleBinding instead of a
|
||||
ClusterRoleBinding, can also be reused namespace-by-namespace) — reach for a ClusterRole only
|
||||
when the workload genuinely needs cluster-wide visibility, not by default.
|
||||
|
||||
## Least privilege, applied to permissions specifically
|
||||
|
||||
The same principle from the previous module applies directly here: grant the narrowest set of
|
||||
verbs on the narrowest set of resources that the workload actually needs. A Role granting
|
||||
`get`/`list` on Pods is very different from one granting `*` on `*` — the latter is
|
||||
"root-equivalent" API access, and should be treated with exactly that level of caution.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often conflate "ServiceAccount" with "user account for logging in." A ServiceAccount
|
||||
is specifically an identity for WORKLOADS, not a human login mechanism.
|
||||
- The Role/RoleBinding two-step (define permissions, then separately grant them to someone) is
|
||||
sometimes seen as redundant. Emphasize the reuse benefit: the same Role can be bound to
|
||||
multiple identities without redefining the permission set each time.
|
||||
|
||||
**Discussion question:** "Why might you explicitly disable automounting a ServiceAccount token
|
||||
for a Pod that never calls the Kubernetes API?" (Answer: an unused but present credential is
|
||||
still something an attacker who compromises the container could steal and use elsewhere —
|
||||
removing it entirely removes that risk for a Pod that never needed it.)
|
||||
|
||||
**Optional extension:** ask what the practical difference is between granting a ClusterRole via
|
||||
a RoleBinding (scoped to one Namespace) versus a ClusterRoleBinding (cluster-wide) — a good
|
||||
check that students understand the Role/ClusterRole and Binding-type distinctions are
|
||||
independent axes, not the same choice.
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Knowledge Check: ServiceAccounts & RBAC
|
||||
|
||||
---
|
||||
|
||||
**1. A Pod is created with no ServiceAccount specified. What identity does it actually run as,
|
||||
and what can that identity do by default?**
|
||||
|
||||
*Answer:* The `default` ServiceAccount in its Namespace, which by default has essentially no
|
||||
permissions — it can authenticate to the API but is granted nothing until an RBAC binding says
|
||||
otherwise.
|
||||
|
||||
---
|
||||
|
||||
**2. You create a Role granting `get`/`list` on Pods, but a workload still can't read Pods.
|
||||
What's the most likely missing piece?**
|
||||
|
||||
*Answer:* A RoleBinding (or ClusterRoleBinding) connecting that Role to the workload's actual
|
||||
ServiceAccount. A Role by itself grants nothing to anyone — it has to be bound to an identity.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: a ClusterRole can only ever be applied cluster-wide.**
|
||||
|
||||
*Answer:* False. A ClusterRole can be bound via a RoleBinding to apply within just ONE
|
||||
Namespace (reusing a cluster-wide-DEFINED permission set in a namespace-SCOPED way), or via a
|
||||
ClusterRoleBinding to apply cluster-wide. The Role-type and Binding-type are independent
|
||||
choices.
|
||||
|
||||
---
|
||||
|
||||
**4. Why would a security review flag a Role granting `verbs: ["*"]` on `resources: ["*"]`
|
||||
even if the workload "seems to work fine"?**
|
||||
|
||||
*Answer:* That grant is root-equivalent API access — it violates least privilege regardless of
|
||||
whether the workload happens to only use a small subset of it today. "Works fine" doesn't mean
|
||||
"correctly scoped" — an over-broad grant is a real risk the moment that workload (or its
|
||||
credentials) is compromised, whether or not it's ever exploited in practice.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 4 is worth discussing even if everyone answers correctly — the "it
|
||||
works, so what's the problem" instinct is exactly what least-privilege review exists to counter.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# NetworkPolicy as a Security Control & Supply Chain Trust
|
||||
|
||||
## NetworkPolicy, revisited as a security posture
|
||||
|
||||
ROOT 07 introduced NetworkPolicy architecturally: Kubernetes doesn't isolate Pod-to-Pod traffic
|
||||
by default, and NetworkPolicy is how you declare intended reachability. Here, the same
|
||||
mechanism is the point of a deliberate SECURITY practice: **default deny**.
|
||||
|
||||
A default-deny policy blocks all traffic to selected Pods, and you then explicitly allow only
|
||||
what's actually needed:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
spec:
|
||||
podSelector: {} # selects EVERY Pod in the namespace
|
||||
policyTypes: ["Ingress"]
|
||||
```
|
||||
|
||||
An empty `podSelector: {}` with no ingress rules means "select every Pod, allow nothing in" —
|
||||
the network equivalent of the SecurityContext principle from earlier in this course: start
|
||||
from nothing, add back only what's proven necessary. This is exactly the opposite of
|
||||
Kubernetes' un-configured default (everything open), which is precisely why it has to be
|
||||
declared explicitly rather than relying on any built-in setting.
|
||||
|
||||
**Egress restriction** works the same way in reverse — restricting what a Pod can call OUT to,
|
||||
not just what can call IN. A compromised Pod that can freely make outbound connections to
|
||||
anything is far more dangerous (data exfiltration, calling out to an attacker's server) than one
|
||||
restricted to only the specific destinations it legitimately needs.
|
||||
|
||||
## Supply chain: trusting what you run before it ever starts
|
||||
|
||||
Everything covered so far in this course assumes the container image itself is trustworthy.
|
||||
Supply-chain security is about that assumption specifically:
|
||||
|
||||
- **Image-pull secrets** — when pulling from a private registry, the cluster needs credentials
|
||||
to authenticate. Storing and using these correctly (rather than, say, a publicly-readable
|
||||
registry with no access control) is a basic supply-chain control: only images from a trusted,
|
||||
access-controlled source get pulled at all.
|
||||
- **Minimizing image footprint** (referenced here, not a hands-on Lab in this module) — a
|
||||
smaller image means fewer packages, fewer known CVEs, and a smaller attack surface if the
|
||||
image is ever compromised.
|
||||
|
||||
## TLS at the Ingress layer: protecting data in transit
|
||||
|
||||
Recall ROOT 07's Ingress lesson — routing external HTTP(S) traffic by hostname/path. Configuring
|
||||
TLS termination at that same Ingress layer means traffic between users and your cluster is
|
||||
encrypted, protecting against interception on the network path. This is a security concern
|
||||
layered on top of the same architectural mechanism, the same relationship NetworkPolicy has to
|
||||
its ROOT 07 introduction.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students sometimes think "default deny" means blocking ALL traffic including what the
|
||||
application needs to function. Clarify: default deny is the STARTING point; you then add
|
||||
specific allow rules for exactly what's needed — the goal state is fully functional AND fully
|
||||
intentional, not non-functional.
|
||||
- Egress restriction is sometimes seen as less important than ingress. Emphasize the
|
||||
exfiltration angle explicitly — a compromised Pod's outbound reach is just as much a real
|
||||
risk as inbound exposure.
|
||||
|
||||
**Discussion question:** "Why is 'default deny, then explicitly allow' a better starting
|
||||
posture than 'default allow, then explicitly block known-bad traffic'?" (Answer: default-deny
|
||||
means a NEW, unanticipated attempt to reach or leave a Pod is blocked by default; default-allow
|
||||
means anything not YET identified as bad gets through — you're always one step behind an
|
||||
attacker under the second model.)
|
||||
|
||||
**Optional extension:** ask what a real team would want to verify before trusting a
|
||||
publicly-available container image from an unfamiliar source, beyond just "does it work" — a
|
||||
good bridge into image scanning/provenance concepts (not built as a hands-on Lab in this course,
|
||||
but worth naming as a real practice).
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Knowledge Check: Network & Supply Chain Security
|
||||
|
||||
---
|
||||
|
||||
**1. A NetworkPolicy has `podSelector: {}` with `policyTypes: ["Ingress"]` and no ingress rules
|
||||
defined. What's the practical effect on every Pod in that namespace?**
|
||||
|
||||
*Answer:* Every Pod is selected, and since no ingress rule allows anything, all incoming traffic
|
||||
to every Pod is denied — this is the "default deny" starting posture, applied namespace-wide.
|
||||
|
||||
---
|
||||
|
||||
**2. Why is restricting EGRESS (outbound) traffic from a Pod a real security control, not just
|
||||
a mirror-image nicety of ingress restriction?**
|
||||
|
||||
*Answer:* A compromised Pod with unrestricted egress can exfiltrate data to an attacker-controlled
|
||||
destination or call out to download additional malicious payloads. Restricting egress to only
|
||||
what's legitimately needed limits what a compromise can accomplish, independent of whether
|
||||
inbound access was ever an issue.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: "default deny, then explicitly allow" and "default allow, then explicitly
|
||||
block known-bad traffic" provide equivalent security given the same set of rules.**
|
||||
|
||||
*Answer:* False. Default-deny blocks anything NOT yet explicitly allowed, including new/
|
||||
unanticipated attempts. Default-allow only blocks what's already been identified as bad — an
|
||||
attacker doing something not yet on the block list gets through under the second model but not
|
||||
the first.
|
||||
|
||||
---
|
||||
|
||||
**4. Why does minimizing a container image's footprint (fewer packages) improve supply-chain
|
||||
security specifically, rather than just making the image smaller/faster to pull?**
|
||||
|
||||
*Answer:* Fewer installed packages means fewer potential known vulnerabilities (CVEs) present in
|
||||
the image at all, and a smaller attack surface if the image is ever compromised — it's a
|
||||
security property, not merely a performance optimization.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 3 is the core conceptual point of this module — worth confirming it's
|
||||
genuinely understood, not just correctly guessed.
|
||||
|
||||
**Approximate timing:** 5-7 minutes.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Pod Security Admission & a Survey of Runtime Hardening
|
||||
|
||||
This module is deliberately a survey, not a deep dive — the topics here are real and important,
|
||||
but lean toward cluster-operator/platform-team concerns rather than the daily-driver skills most
|
||||
ROOT_ learners will use constantly. Treat the labs in this module as good-to-know, not required
|
||||
mastery, before moving on.
|
||||
|
||||
## Pod Security Admission: enforcing a baseline across a Namespace
|
||||
|
||||
Everything you configured by hand in earlier modules (non-root, dropped capabilities, read-only
|
||||
filesystem) can also be ENFORCED automatically at the Namespace level via **Pod Security
|
||||
Admission (PSA)** — a label on the Namespace itself that rejects any Pod violating a chosen
|
||||
security standard (`privileged`, `baseline`, or `restricted`, from least to most strict):
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: production
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
```
|
||||
|
||||
This replaces the older PodSecurityPolicy (PSP) mechanism, which was removed from Kubernetes —
|
||||
if you encounter PSP in older documentation or an older cluster, know that PSA is its current
|
||||
replacement.
|
||||
|
||||
## Immutable Secrets: preventing a class of accidental change
|
||||
|
||||
A Secret can be marked `immutable: true`, which prevents its data from being changed after
|
||||
creation (a new Secret with a new name has to be created instead, and workloads updated to
|
||||
reference it). This trades a small amount of operational flexibility for a real safety
|
||||
property: an accidental or malicious edit to a live Secret can't silently happen.
|
||||
|
||||
## A survey of further topics (labs available, treat as optional depth)
|
||||
|
||||
- **Audit policy** — configuring what the Kubernetes API server logs about requests made against
|
||||
it (who did what, when) — genuinely important for incident investigation, but a cluster-admin
|
||||
concern, not something most workload teams configure themselves.
|
||||
- **RuntimeClass** — lets specific Pods run under a different container runtime configuration
|
||||
(e.g. an extra-sandboxed runtime like gVisor or Kata Containers) for workloads that need
|
||||
stronger isolation than the default runtime provides — relevant mainly for genuinely
|
||||
untrusted or high-risk workloads, not typical applications.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read (shorter than other lessons — deliberately survey-depth).
|
||||
|
||||
**Framing for instructors:** Be explicit with students that this module is intentionally
|
||||
lighter than the earlier ones in this course — the goal is awareness ("this exists, here's
|
||||
roughly what it's for") rather than hands-on fluency, consistent with not every CKS-style topic
|
||||
belonging in the core learning path.
|
||||
|
||||
**Discussion question:** "Why would a platform team prefer enforcing security standards via
|
||||
Pod Security Admission at the Namespace level, instead of trusting every team to configure
|
||||
SecurityContext correctly on every Pod by hand?" (Answer: PSA provides a hard backstop —
|
||||
correctly-configured SecurityContext relies on every individual Pod author doing it right every
|
||||
time; PSA rejects violations automatically regardless of whether any individual author
|
||||
remembered to.)
|
||||
|
||||
**Optional extension:** ask which of `privileged`/`baseline`/`restricted` a team should start a
|
||||
brand-new, security-conscious project with, and why not jump straight to the strictest option
|
||||
on a cluster already running older, unaudited workloads (a good practical judgment question
|
||||
about migration risk, not just definitions).
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# The RootNode Troubleshooting Framework
|
||||
|
||||
## Why a framework, not just "get good at kubectl"
|
||||
|
||||
The gold-standard Health Probes module taught you a lot about ONE specific failure mode
|
||||
(readiness probe misconfiguration) and the specific commands to diagnose it. This module steps
|
||||
back and asks: what's the repeatable PROCESS that works for almost any Kubernetes failure, not
|
||||
just the ones you've already seen before?
|
||||
|
||||
Guessing under pressure — trying random fixes and seeing what sticks — is slow, often makes
|
||||
things worse, and doesn't build a transferable skill. A framework does.
|
||||
|
||||
## Observe → Gather Evidence → Form Hypothesis → Test → Repair → Validate → Prevent Recurrence
|
||||
|
||||
**Observe** — what's the actual, specific symptom? Not "it's broken," but "the Deployment shows
|
||||
0/3 Ready" or "requests are timing out" or "the Pod restarts every 30 seconds." Precision here
|
||||
saves time later.
|
||||
|
||||
**Gather Evidence** — before touching anything, collect what the system itself is telling you:
|
||||
`kubectl get pods`, `kubectl describe pod`, `kubectl logs`, `kubectl get events`. Resist the urge
|
||||
to start changing things before you've looked.
|
||||
|
||||
**Form Hypothesis** — based on the evidence, state a SPECIFIC guess about the cause. "The
|
||||
container is crashing because the command it's told to run doesn't exist" is testable. "Something's
|
||||
wrong with networking" is not specific enough to act on yet.
|
||||
|
||||
**Test** — check whether your hypothesis is actually true, ideally without yet making the fix
|
||||
(read the exact command being run, compare against what the image expects) — confirm before you
|
||||
commit to a repair.
|
||||
|
||||
**Repair** — make the minimal change that addresses the confirmed cause. Not a bigger change
|
||||
"while you're in there" — exactly the fix the evidence supports.
|
||||
|
||||
**Validate** — confirm the fix actually worked, using the same kind of evidence you gathered
|
||||
initially (not just "I made a change, it should be fine now").
|
||||
|
||||
**Prevent Recurrence** — the step most often skipped under time pressure. Could this class of
|
||||
mistake happen again? Is there a smaller, structural change (a default, a validation, a check)
|
||||
that would catch it earlier next time?
|
||||
|
||||
## Applying it: a CrashLoopBackOff
|
||||
|
||||
- **Observe:** `crash-app`'s Pods show `STATUS: CrashLoopBackOff`, restart count climbing.
|
||||
- **Gather Evidence:** `kubectl describe deployment crash-app` (check the configured command),
|
||||
`kubectl logs -l app=crash-app` (what did the container actually print before dying, if
|
||||
anything).
|
||||
- **Form Hypothesis:** "The command override is invalid/wrong for this image, so the container
|
||||
exits immediately every time it starts."
|
||||
- **Test:** compare the command actually configured against what the image's default/expected
|
||||
entrypoint should be.
|
||||
- **Repair:** fix the command to what the container image actually expects.
|
||||
- **Validate:** `kubectl get pods` shows `Running`, restart count stops climbing.
|
||||
- **Prevent Recurrence:** a code-review habit of never overriding a container's command without
|
||||
verifying it against the image's documentation first.
|
||||
|
||||
## Applying it: a broken Service selector
|
||||
|
||||
- **Observe:** traffic isn't reaching `my-svc`'s backend at all.
|
||||
- **Gather Evidence:** `kubectl get endpoints my-svc` (empty?), `kubectl get pods
|
||||
--show-labels` (what labels do the actual Pods have?).
|
||||
- **Form Hypothesis:** "The Service's selector doesn't match the Pods' actual labels."
|
||||
- **Test:** compare `kubectl describe service my-svc`'s selector against the Pods' real labels
|
||||
directly, side by side.
|
||||
- **Repair:** correct the selector to match.
|
||||
- **Validate:** `kubectl get endpoints my-svc` now shows the Pod IPs.
|
||||
- **Prevent Recurrence:** keeping label keys/values consistent (or generated from one source)
|
||||
between a Deployment's Pod template and any Service that targets it.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10-12 minutes live walking through both worked
|
||||
examples.
|
||||
|
||||
**Demo plan:** Use the demonstration activity right after this lesson to show the CrashLoopBackOff
|
||||
example live, narrating each framework step explicitly as you go rather than just running
|
||||
commands.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students under time pressure often skip straight to "Repair" without confirming a hypothesis
|
||||
first. Reinforce that a wrong guess implemented as a "fix" can make evidence harder to read
|
||||
afterward, not easier.
|
||||
- "Prevent Recurrence" is the step most likely to be treated as optional/skippable. Frame it as
|
||||
what separates a team that keeps hitting the same incident from one that doesn't.
|
||||
|
||||
**Discussion question:** "What's the cost of skipping 'Gather Evidence' and jumping straight to
|
||||
a guessed fix, even if the guess happens to be right?" (Answer: even a correct guess doesn't
|
||||
teach you whether your reasoning was right — you got lucky, and the same class of mistake could
|
||||
still recur since you never confirmed the actual cause.)
|
||||
|
||||
**Optional extension:** ask students to apply this exact framework, from memory, to the gold
|
||||
module's `rootnode-probe-port-mismatch` incident they already solved — a good retroactive
|
||||
exercise connecting a lab they already completed to the generalized framework.
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Demonstration: Applying the Framework to a CrashLoopBackOff
|
||||
|
||||
Watch the full Observe → Evidence → Hypothesis → Test → Repair → Validate → Prevent sequence
|
||||
against a real symptom, narrated step by step.
|
||||
|
||||
## Observe
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
crash-app-6d9f8b7c5d-abcde 0/1 CrashLoopBackOff 5 4m
|
||||
```
|
||||
|
||||
Specific symptom: `CrashLoopBackOff`, restart count climbing (5 and counting) — the container
|
||||
is starting, exiting, and being restarted repeatedly, not just failing to start once.
|
||||
|
||||
## Gather Evidence
|
||||
|
||||
```
|
||||
$ kubectl logs crash-app-6d9f8b7c5d-abcde
|
||||
nginx: [emerg] invalid command "daemon-off": expected valid command
|
||||
```
|
||||
|
||||
```
|
||||
$ kubectl describe deployment crash-app
|
||||
...
|
||||
Command:
|
||||
nginx
|
||||
-g
|
||||
daemon-off;
|
||||
...
|
||||
```
|
||||
|
||||
The logs show nginx itself rejecting the command it was given. The Deployment's configured
|
||||
command shows exactly why: `daemon-off;` (a typo — missing space, should be `daemon off;`).
|
||||
|
||||
## Form Hypothesis
|
||||
|
||||
"The container's command override has a syntax error that nginx can't parse, so nginx exits
|
||||
immediately every time it starts — which is why it's crash-looping rather than just failing to
|
||||
start once."
|
||||
|
||||
## Test
|
||||
|
||||
Compare the broken command (`daemon-off;`) against nginx's actual expected syntax
|
||||
(`daemon off;`, two separate words) — confirmed: this is exactly the kind of typo that would
|
||||
produce exactly this error.
|
||||
|
||||
## Repair
|
||||
|
||||
```
|
||||
$ kubectl patch deployment crash-app --type='json' \
|
||||
-p='[{"op":"replace","path":"/spec/template/spec/containers/0/command","value":["nginx","-g","daemon off;"]}]'
|
||||
```
|
||||
|
||||
## Validate
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
crash-app-7f8c9d6b5d-fghij 1/1 Running 0 15s
|
||||
```
|
||||
|
||||
New Pod, `Running`, `RESTARTS: 0` — the fix is confirmed, not just assumed.
|
||||
|
||||
## Prevent Recurrence
|
||||
|
||||
A command override like this is a hand-typed string with no validation until the container
|
||||
actually tries to run it. A team that hit this once might decide: don't override a well-known
|
||||
image's default command unless there's a specific reason to, and if you must, test the exact
|
||||
command string locally (`docker run` or similar) before deploying it — catching a typo in
|
||||
seconds locally beats debugging a CrashLoopBackOff in the cluster.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run this live if time allows, deliberately narrating which framework step
|
||||
you're on at each command — students retain the framework far better tied to real commands than
|
||||
as an abstract list.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students sometimes skip straight from "Observe" to
|
||||
"Repair" once they spot the typo, without stating a hypothesis or testing it. Reinforce that
|
||||
even an "obvious" fix benefits from the explicit hypothesis step — it's what makes the eventual
|
||||
Validate step meaningful (you're confirming a SPECIFIC claim, not just "trying something").
|
||||
|
||||
**Optional extension:** ask what evidence you'd gather differently if the logs had been
|
||||
completely empty instead of showing a clear nginx error (a good bridge to cases where the
|
||||
container dies before it can log anything at all — `kubectl describe pod`'s Events section
|
||||
becomes the primary evidence source in that case, same as the gold module's probe-mismatch
|
||||
lesson).
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Knowledge Check: The Troubleshooting Framework
|
||||
|
||||
---
|
||||
|
||||
**1. You spot what looks like an obvious typo causing a failure and fix it immediately without
|
||||
writing down a hypothesis first. What's the actual cost of skipping that step, even if the fix
|
||||
works?**
|
||||
|
||||
*Answer:* You don't get a confirmed, testable claim about the cause — if the fix happens to
|
||||
"work" for an unrelated reason (a coincidental restart, a different underlying issue also
|
||||
resolving), you'd have no way to distinguish that from your fix actually being correct. The
|
||||
hypothesis step is what makes Validate meaningful.
|
||||
|
||||
---
|
||||
|
||||
**2. During the "Gather Evidence" step for a CrashLoopBackOff, which two commands give you the
|
||||
most direct evidence, and what does each one tell you that the other doesn't?**
|
||||
|
||||
*Answer:* `kubectl logs` (what the container itself printed, if anything, before exiting) and
|
||||
`kubectl describe pod`/`describe deployment` (the container's actual configuration, plus recent
|
||||
Events). Logs show application-level output; describe/Events show what Kubernetes itself
|
||||
observed and configured — a crash with no logs at all often means the failure happened before
|
||||
the application could log anything, which describe/Events can still reveal.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: "Prevent Recurrence" only applies to major incidents, not small
|
||||
one-off mistakes like a typo.**
|
||||
|
||||
*Answer:* False. A small mistake that recurs repeatedly across a team becomes a real, ongoing
|
||||
cost even if no single instance is severe — the framework applies the same reasoning regardless
|
||||
of incident size: could a small, structural change catch this earlier next time?
|
||||
|
||||
---
|
||||
|
||||
**4. A broken Service selector (endpoint-fix-task's scenario) and a CrashLoopBackOff
|
||||
(crashloop-fix's scenario) are very different failures. What's the SAME about how you'd
|
||||
approach both?**
|
||||
|
||||
*Answer:* The same seven-step framework — Observe the specific symptom, Gather Evidence before
|
||||
guessing, form a specific testable Hypothesis, Test it, make the minimal Repair the evidence
|
||||
supports, Validate with the same kind of evidence you started with, and consider how to Prevent
|
||||
Recurrence. The framework doesn't change; only the specific commands and evidence do.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 4 is the whole point of this module — make sure it's discussed even
|
||||
if answered correctly, since it's the transferability claim the entire framework rests on.
|
||||
|
||||
**Approximate timing:** 5-7 minutes.
|
||||
@@ -0,0 +1,88 @@
|
||||
# The RootNode Incident-Response Framework
|
||||
|
||||
## This module feels different on purpose
|
||||
|
||||
Everything before ROOT 12 asked you to complete an exercise. This module asks something
|
||||
different: **you are responsible for this environment.** The incidents in this module are
|
||||
modeled on real operational failures — not textbook exercises designed to teach one clean
|
||||
concept, but the kind of messy, "why is this actually broken" situations a real on-call engineer
|
||||
gets paged for.
|
||||
|
||||
## Detect → Triage → Diagnose → Mitigate → Recover → Verify → Learn
|
||||
|
||||
This is a lightweight incident-response framework — not enterprise incident management
|
||||
tooling, just a way of thinking that scales down to "one engineer, one broken thing" as well as
|
||||
it scales up to a real incident-response team.
|
||||
|
||||
**Detect** — something alerted you (a monitoring page, a user report, or in this Lab's case,
|
||||
the scenario telling you something is wrong). You know an incident exists before you know what
|
||||
it is.
|
||||
|
||||
**Triage** — how bad is this, right now, before you dive into root cause? Is anything actively
|
||||
getting worse? Is this affecting one thing or cascading into others? Triage decides how urgently
|
||||
you act, before you fully understand why.
|
||||
|
||||
**Diagnose** — this is where ROOT 10's troubleshooting framework (Observe, Gather Evidence, Form
|
||||
Hypothesis, Test) does its work — the specific mechanics of finding the actual cause.
|
||||
|
||||
**Mitigate** — the fastest safe action that stops things from getting worse, which is not always
|
||||
the same as the final fix. Sometimes mitigation and repair are the same step; sometimes
|
||||
mitigation buys time (e.g. temporarily reducing load) while a proper fix is prepared.
|
||||
|
||||
**Recover** — restore the system to its actual working state — not just "stopped getting worse"
|
||||
but genuinely operational again.
|
||||
|
||||
**Verify** — confirm recovery with real evidence, the same discipline as ROOT 10's Validate
|
||||
step. "I think it's fixed" is not the same as checking.
|
||||
|
||||
**Learn** — what would prevent this exact class of incident from happening again? This is where
|
||||
`rootnode-enforce-limits` picks up directly after `rootnode-runaway-monitor` — not a separate
|
||||
topic, but the deliberate "Learn" step of the SAME incident, made into its own scenario.
|
||||
|
||||
## What this module will NOT tell you
|
||||
|
||||
The specific fixes for each incident are not in this lesson, and instructor notes on the
|
||||
individual incident activities go deeper than what's shown here — this module gives you the
|
||||
FRAMEWORK for approaching an unfamiliar incident, not a cheat sheet for these specific four.
|
||||
That's deliberate: a framework you had to apply yourself is one you'll actually remember under
|
||||
real pressure.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Lesson goal:** Set the tone shift for this entire module — from "complete an exercise" to
|
||||
"you own this." This lesson is short by design; the real teaching happens through the incidents
|
||||
themselves.
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live framing discussion.
|
||||
|
||||
**Preparation:** Read each incident's own instructor notes (in the Lab activities below) before
|
||||
teaching this module live — several contain deeper solution guidance not repeated here,
|
||||
specifically so students still have to work for it.
|
||||
|
||||
**Demonstration plan:** Not applicable to this framing lesson — the four incidents themselves
|
||||
serve as the demonstrations.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students sometimes treat "Mitigate" and "Recover" as the same step. They can be, but aren't
|
||||
always — worth naming a real-world example (rolling back a bad deploy mitigates immediately;
|
||||
fully recovering might still require redeploying the CORRECT version afterward).
|
||||
- "Learn" is the step most likely to get skipped once a Lab is marked complete. Explicitly
|
||||
connect it to why `rootnode-enforce-limits` exists as its own scenario.
|
||||
|
||||
**Questions to ask students:** "Which step of this framework do you think real teams are worst
|
||||
at, under real pressure, and why?" (There's no single right answer — the goal is getting
|
||||
students to reason about incident response as a discipline, not just a checklist.)
|
||||
|
||||
**When to let a student struggle vs. provide a hint:** These incidents are intentionally less
|
||||
guided than earlier modules. Let a student sit with "I don't know where to start" for a few
|
||||
minutes before nudging — the discomfort of a genuinely unfamiliar failure is part of what this
|
||||
module is teaching. Redirect toward the Diagnose step's ROOT 10 framework if truly stuck, rather
|
||||
than pointing at the specific fix.
|
||||
|
||||
**Optional extension:** ask students to write a two-sentence incident summary for
|
||||
`rootnode-runaway-monitor` as if posting it to a real team channel — Detect/Diagnose/Recover in
|
||||
plain language, no jargon.
|
||||
|
||||
**Expected takeaway:** A framework applied under pressure beats improvisation, even for
|
||||
engineers who are technically skilled enough to eventually stumble into the right answer anyway
|
||||
— the framework is what makes the RIGHT answer arrive faster and more reliably.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Post-Incident Reflection: What Real Ops Actually Feels Like
|
||||
|
||||
You just worked through four incidents that "own it, don't just complete it" framing described.
|
||||
Take a few minutes to reflect on the module as a whole, not just any single incident.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Which of the four incidents took the longest to even understand what was actually wrong,
|
||||
before you got to fixing anything? What made diagnosis slower there than the others?
|
||||
2. `rootnode-runaway-monitor` and `rootnode-enforce-limits` are the same underlying incident,
|
||||
split into "fix it now" and "prevent it forever." Which one did you find more satisfying to
|
||||
solve, and why do you think real teams sometimes skip the second one under time pressure?
|
||||
3. If you were handing these four incidents off to a teammate mid-shift, what's the one-sentence
|
||||
summary you'd give for each, so they could pick up without re-diagnosing from scratch?
|
||||
4. Compare how this module felt to the earlier, more guided modules in ROOT_. What specifically
|
||||
was different about the experience, not just the difficulty?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**"It's fixed" and "it won't happen again" are two different jobs, and skipping the second one
|
||||
is how the same incident recurs.** `rootnode-enforce-limits` exists as its own scenario
|
||||
specifically to make this point structurally, not just rhetorically — the immediate fix and the
|
||||
prevention work are genuinely separate efforts, and a real team's incident isn't actually closed
|
||||
until both are done.
|
||||
|
||||
**Being handed an unfamiliar, under-specified problem is the actual job.** Every other module in
|
||||
ROOT_ told you roughly what concept you were about to practice. This one didn't — and that gap
|
||||
is deliberate, because real production incidents don't come with a syllabus telling you which
|
||||
concept they're testing. The framework from this module's opening lesson is what you reach for
|
||||
instead of a hint system.
|
||||
|
||||
This module is complete. If a Capstone is part of your program, its readiness module is next.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 is the module's core point — spend
|
||||
real time on it rather than treating it as a quick check-the-box question.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live discussion; 8 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to identify which incident from this module most closely
|
||||
resembles something they'd expect to encounter in an actual job, and why — grounds the module in
|
||||
something beyond the classroom.
|
||||
|
||||
**Expected takeaway:** Real operational work is less about knowing every possible failure mode
|
||||
in advance and more about having a repeatable process for approaching the ones you've never
|
||||
seen before.
|
||||
@@ -0,0 +1,72 @@
|
||||
# What the Capstone Measures & How the Draw Works
|
||||
|
||||
## What the RootNode Capstone is actually testing
|
||||
|
||||
The Capstone is not a test of memorized `kubectl` syntax. It's a 90-minute operational
|
||||
assessment weighted heavily toward RootNode's own Real Ops incident scenarios — it's checking
|
||||
whether you can DO the thing ROOT_ has been building toward: diagnose and repair real,
|
||||
under-specified problems using the frameworks and skills from every course before it, under a
|
||||
clock, without step-by-step guidance.
|
||||
|
||||
If you've completed ROOT 05 through ROOT 12 honestly (not just clicked through), the Capstone is
|
||||
testing exactly what you've already practiced — just without a lesson telling you which concept
|
||||
is about to be tested.
|
||||
|
||||
## How the scenario draw works
|
||||
|
||||
The Capstone draws a mix of:
|
||||
|
||||
- **Required scenarios** — every attempt includes RootNode's own Real Ops incidents (the same
|
||||
scenarios from ROOT 12, though a fresh attempt against them, not literally the same completed
|
||||
attempt carried over).
|
||||
- **Randomly drawn scenarios** — a smaller number of additional scenarios, randomly selected
|
||||
from broader pools spanning workloads, configuration, and troubleshooting.
|
||||
|
||||
This means two students taking the Capstone will very likely see a different exact mix of
|
||||
random scenarios, while both always face the same core Real Ops incidents. The exact scenario
|
||||
IDs and the random draw itself are never published in advance — see the next lesson for why
|
||||
that's intentional, not withheld information.
|
||||
|
||||
## What "unlocked" actually means
|
||||
|
||||
The Capstone doesn't unlock just because you decided you're ready — it unlocks when the
|
||||
platform confirms, server-side, that you've genuinely completed every prerequisite stage
|
||||
(Foundation, Workloads, Networking, Configuration, Troubleshooting, and Real Ops). This isn't a
|
||||
UI suggestion you can bypass; it's enforced the same way everywhere you'd try to start it. If
|
||||
you see a "locked" message, the platform is telling you something true about your actual
|
||||
progress, not gatekeeping arbitrarily.
|
||||
|
||||
## What completion means
|
||||
|
||||
Passing the Capstone requires resolving every scenario in your specific draw — this is recorded
|
||||
as a real, permanent achievement (`capstone_completed`) tied to your account, the same
|
||||
achievement mechanism that recorded every stage completion before it. It's the platform's own
|
||||
confirmation that you can operate a Kubernetes environment under exam conditions, not just that
|
||||
you clicked through a curriculum.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Lesson goal:** Set accurate expectations before a student attempts the real assessment —
|
||||
reduce anxiety from not knowing "what kind of thing" the Capstone tests, without revealing any
|
||||
specific content.
|
||||
|
||||
**Approximate timing:** 10 minutes read; 8-10 minutes live.
|
||||
|
||||
**Preparation:** None beyond having completed ROOT 12 yourself if teaching this live, so you can
|
||||
speak concretely about the Real Ops incidents' style without naming specifics from the current
|
||||
draw pool.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students sometimes think "random draw" means the Capstone is inconsistent/unfair between
|
||||
attempts. Clarify: the REQUIRED Real Ops scenarios are constant across every attempt; only a
|
||||
smaller supplementary set is randomized.
|
||||
- "It's locked, so something's broken" — reinforce that a locked Capstone is the platform
|
||||
correctly reporting incomplete prerequisites, not a bug.
|
||||
|
||||
**Questions to ask students:** "Which ROOT_ course do you feel least confident about right now?
|
||||
What would you do about that before attempting the Capstone?" — a good self-assessment prompt
|
||||
that ties directly into the next lesson's time-management content.
|
||||
|
||||
**Expected takeaway:** The Capstone measures real operational capability built across the whole
|
||||
ROOT_ sequence — approaching it as "one more scenario to guess your way through" misses the
|
||||
point of everything that led up to it.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Time Management & Troubleshooting Strategy Under a Clock
|
||||
|
||||
## 90 minutes, several scenarios — budget before you start
|
||||
|
||||
Once the Capstone begins, the clock is running against the whole attempt, not per scenario.
|
||||
Spending 60 of your 90 minutes perfectly solving one incident, then rushing (or failing to
|
||||
attempt) the remaining ones, is a worse outcome than solving most scenarios well within a
|
||||
reasonable time each. Before diving into the first incident, take 60 seconds to look at how many
|
||||
scenarios you're facing and roughly divide your available time — treat that budget as a real
|
||||
constraint, not a suggestion.
|
||||
|
||||
## Apply the frameworks you already have — don't invent a new approach under pressure
|
||||
|
||||
You've already practiced two frameworks across ROOT_:
|
||||
|
||||
- **Observe → Gather Evidence → Form Hypothesis → Test → Repair → Validate → Prevent Recurrence**
|
||||
(ROOT 10) for the mechanics of diagnosing a specific technical failure.
|
||||
- **Detect → Triage → Diagnose → Mitigate → Recover → Verify → Learn** (ROOT 12) for the
|
||||
broader "you own this incident" framing.
|
||||
|
||||
Under time pressure, the temptation is to abandon structure and start guessing. Resist it — the
|
||||
frameworks exist precisely because they're FASTER than guessing once you've internalized them,
|
||||
not slower. Reach for the same habits: read `kubectl describe`/Events before assuming, state
|
||||
what you think is wrong before changing anything, verify after you fix.
|
||||
|
||||
## When to move on
|
||||
|
||||
If you're genuinely stuck on one scenario well past its budgeted time with no new evidence
|
||||
emerging, it is a legitimate strategic choice to move to a different scenario and return later
|
||||
if time allows — partial credit on more scenarios can beat a perfect score on fewer. This isn't
|
||||
giving up; it's the same triage instinct from the incident-response framework, applied to your
|
||||
own time as a resource.
|
||||
|
||||
## What's genuinely allowed
|
||||
|
||||
Everything you've used throughout ROOT_ Labs remains available: `kubectl` and its full command
|
||||
set, the Lab's own in-context hints (if the specific scenario offers them), and your own notes
|
||||
from earlier modules. There is no expectation of memorizing anything you'd normally look up —
|
||||
the Capstone tests operational judgment and diagnostic process, not recall of obscure flags.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Lesson goal:** Reduce a common source of avoidable Capstone underperformance — poor time
|
||||
allocation — separately from technical skill gaps.
|
||||
|
||||
**Approximate timing:** 10 minutes read; 8 minutes live.
|
||||
|
||||
**Demonstration plan:** If teaching this live, consider a quick show-of-hands: "who has ever
|
||||
spent way too long on one problem and run out of time for an easier one elsewhere?" — normalizes
|
||||
the behavior this lesson is trying to prevent.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students sometimes think asking for more time or pausing mid-scenario is against the rules.
|
||||
Clarify based on your actual classroom/platform policy rather than assuming — this varies by
|
||||
delivery mode and isn't something this lesson should guess at.
|
||||
|
||||
**Questions to ask students:** "If you had to guess right now, which TYPE of scenario (not a
|
||||
specific one) do you think would eat the most of your time, and why?" — surfaces self-awareness
|
||||
about weak areas before the real attempt.
|
||||
|
||||
**When to let a student struggle vs. provide a hint:** This lesson itself needs no struggle —
|
||||
it's orientation, not a challenge. Save "let them struggle" judgment for the actual Capstone
|
||||
attempt.
|
||||
|
||||
**Expected takeaway:** Time management under the Capstone's clock is a skill you can prepare for
|
||||
explicitly, separate from and in addition to the technical skills the rest of ROOT_ built.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Readiness Check: Are You Actually Ready?
|
||||
|
||||
This isn't a graded quiz — it's a genuine self-assessment before you attempt something you can't
|
||||
easily "retry with different answers memorized." Answer each honestly.
|
||||
|
||||
---
|
||||
|
||||
**1. Can you explain, without looking it up, the difference between a liveness probe and a
|
||||
readiness probe, and diagnose which one is failing from `kubectl get pods` output alone?**
|
||||
|
||||
*If no:* revisit ROOT 10's gold-standard Health Probes module before attempting the Capstone —
|
||||
this exact distinction shows up in Real Ops-style incidents.
|
||||
|
||||
---
|
||||
|
||||
**2. Given a Service with zero Endpoints, do you know the first two commands you'd run to find
|
||||
out why, without being told which module this relates to?**
|
||||
|
||||
*If no:* revisit ROOT 07's Services & Service Discovery module.
|
||||
|
||||
---
|
||||
|
||||
**3. Can you apply the Observe → Evidence → Hypothesis → Test → Repair → Validate → Prevent
|
||||
framework to an unfamiliar failure you've never specifically seen before, not just to the exact
|
||||
examples from the lesson?**
|
||||
|
||||
*If no:* this is worth practicing deliberately — pick any Practice-tab scenario you haven't done
|
||||
yet and narrate the framework out loud to yourself while solving it.
|
||||
|
||||
---
|
||||
|
||||
**4. Have you completed the Real Ops incidents in ROOT 12 for real — diagnosed and repaired
|
||||
them yourself, not looked up the answer?**
|
||||
|
||||
*If no:* do this before attempting the Capstone. The required scenarios in every Capstone
|
||||
attempt are drawn from exactly this style of incident.
|
||||
|
||||
---
|
||||
|
||||
**5. Do you have a rough time-budget plan for a 90-minute, multi-scenario attempt?**
|
||||
|
||||
*If no:* re-read the time-management lesson in this module before you begin.
|
||||
|
||||
## What to do if you answered "no" to more than one
|
||||
|
||||
That's genuinely useful information, not a failure — it tells you specifically where to spend
|
||||
your remaining prep time. Go back to the relevant module, do the work for real, and come back
|
||||
to this check again before attempting the Capstone.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** In a live classroom, this works well as private/individual reflection rather
|
||||
than a group discussion — the value is honest self-assessment, not a public performance.
|
||||
|
||||
**Approximate timing:** 5-8 minutes.
|
||||
|
||||
**Expected takeaway:** Readiness is something a student can genuinely self-assess against
|
||||
concrete, specific criteria — not a vague feeling of confidence.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Readiness Reflection: What Completion Means
|
||||
|
||||
Before you move toward the actual Capstone attempt, take a moment to reflect on the whole ROOT_
|
||||
journey up to this point.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Think back to ROOT 05's opening lesson on desired state. How differently would you explain
|
||||
that concept now, after everything since, compared to how you understood it the first time?
|
||||
2. Which single module across ROOT_ changed how you approach a problem the most — not which was
|
||||
most interesting, but which actually changed your behavior when something breaks?
|
||||
3. What does passing the Capstone mean to you, beyond a completion record on the platform?
|
||||
|
||||
## What completion actually represents
|
||||
|
||||
A `capstone_completed` achievement isn't a certificate claiming you've memorized Kubernetes
|
||||
trivia. It's the platform's record that you demonstrated, under real time pressure, without
|
||||
step-by-step guidance, that you can diagnose and repair real operational problems using
|
||||
frameworks you internalized rather than looked up in the moment. That's a materially different
|
||||
and more durable skill than passing a multiple-choice exam about Kubernetes objects.
|
||||
|
||||
## When you're ready
|
||||
|
||||
There's no "perfect" moment where every module feels fully mastered — that's normal. If the
|
||||
readiness check in the previous activity mostly came back "yes," and you've done the Real Ops
|
||||
incidents for real, you're ready to attempt it. The Capstone will unlock automatically once the
|
||||
platform confirms your prerequisite stages are genuinely complete — when you see it unlocked,
|
||||
that's the platform's own confirmation, not just this module's.
|
||||
|
||||
This module is complete. Good luck.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 tends to produce genuinely varied,
|
||||
interesting answers across a cohort — worth having students share out loud if time allows.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Preparation:** None — this is a closing/orientation activity, not a technical one.
|
||||
|
||||
**Expected takeaway:** Completing ROOT_'s Kubernetes sequence is a real, demonstrable skill
|
||||
change, not just curriculum progress — the Capstone is where that becomes visible and provable.
|
||||
@@ -64,6 +64,17 @@ connects what was just taught to the incident the student is about to solve, wit
|
||||
the scenario's own hints/solution — the Lab's own progressive hints stay the only place the
|
||||
actual answer path lives.
|
||||
|
||||
### Classifying near-duplicate or advanced scenarios (Phase 6B)
|
||||
|
||||
When two scenarios teach essentially the same concept (e.g. `pod-security-context` and
|
||||
`cks-pod-security-context`), don't wire both as required core labs — pick the better-fitting one
|
||||
as CORE and mark the other `is_required = false` as INDEPENDENT PRACTICE (see
|
||||
`docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md`'s "duplicate concepts identified" for worked examples).
|
||||
Likewise, a genuinely harder or more niche scenario (CKS-exam-style, a less commonly needed
|
||||
skill) belongs as `is_required = false` GUIDED/ADVANCED practice, not force-fit into the
|
||||
required core path just because it exists — see `docs/COURSE_CONTENT_MODEL.md`'s optional-vs-
|
||||
required section for how this affects module completion.
|
||||
|
||||
## 9. Knowledge check
|
||||
|
||||
A handful of self-check questions with answers shown inline (not a graded exam — that's what the
|
||||
|
||||
@@ -84,6 +84,16 @@ decided, and it never invents new state for anything that's independently verifi
|
||||
Course and module completion are pure aggregates over their activities' completion (`total`/
|
||||
`completed`/`pct`/`complete` — see `totals()`), never separately stored.
|
||||
|
||||
### Optional vs. required activities (Phase 6B)
|
||||
|
||||
`module_activities.is_required` (defaults `true`) lets a module distinguish CORE teaching from
|
||||
GUIDED/INDEPENDENT-practice duplicates and ADVANCED/optional extras — see
|
||||
`docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md`'s classification scheme, used throughout ROOT 09. A
|
||||
module's `complete` flag is true once every REQUIRED activity is done; an incomplete optional
|
||||
activity never blocks it, though `total`/`pct` still count every activity, required or not.
|
||||
Every Phase 6A activity has `is_required = true` (the column's default) — this is purely
|
||||
additive, nothing that was complete before Phase 6B becomes incomplete now.
|
||||
|
||||
## 5. Prerequisite/locking rule
|
||||
|
||||
A course or module lists `prerequisites` (an array of other course/module ids). It's locked if
|
||||
@@ -109,6 +119,40 @@ stop, regardless of its `status`. Use `draft` for genuinely unfinished authoring
|
||||
want a student to see at all yet; use `status = planned` for an honest "not built yet" that's
|
||||
fine to be visible.
|
||||
|
||||
## 6b. Content versioning (Phase 6B §21)
|
||||
|
||||
Follows the exact same convention `assessment_definitions`/`programs` already established — a
|
||||
plain incrementing `version` integer, bumped by hand, informational rather than enforced:
|
||||
|
||||
- **A typo-only edit** (fixing a spelling mistake, a broken link, rewording a sentence without
|
||||
changing its meaning) does NOT require a version bump — nothing about what a student is being
|
||||
taught or held to changed.
|
||||
- **A semantic edit** (changing a learning objective, swapping a lab's `scenario_id`, changing
|
||||
`completion_behavior`, adding/removing/reordering activities in a way that changes what
|
||||
"complete" means) SHOULD bump `version` on the affected row(s) — see
|
||||
`023_phase6b_curriculum.sql`'s own `version = version + 1` on every row it semantically
|
||||
changes (resequencing, prerequisite changes, `is_required` changes).
|
||||
- **What happens to existing completions**: nothing, automatically. Unlike an assessment
|
||||
session (which snapshots the definition version it was drawn against, because a session is a
|
||||
point-in-time timed attempt), a course/module/activity's completion is derived LIVE every time
|
||||
it's read (`course-progress.js`) from the CURRENT row plus the student's progress/achievements/
|
||||
activity_completions — there is no snapshot to invalidate. A `manual_marker` completion
|
||||
(`activity_completions`) is keyed by `activity_id`, which is stable across a version bump
|
||||
(the id doesn't change, only its content/metadata does) — so a student's prior "I did this"
|
||||
record stays valid even after a semantic edit, exactly like editing a scenario's own
|
||||
Markdown description doesn't retroactively un-complete it in `progress`.
|
||||
- **Scenario references across versions**: a `lab` activity's `scenario_id` is just a string
|
||||
reference into `scenarios/data/*.json` — if that scenario file itself changes, the activity
|
||||
automatically reflects the new content next time it's loaded (same as every other consumer of
|
||||
`curriculum.js`'s cache). If an activity's `scenario_id` itself is changed to point at a
|
||||
DIFFERENT scenario, that's a semantic edit (bump the version) and — depending on intent — may
|
||||
warrant a new `activity_id` instead of mutating the existing one, so a student's prior
|
||||
completion of the OLD scenario isn't silently reinterpreted as completion of the new one.
|
||||
- **What this deliberately does NOT build**: no content-revision history table, no diffing, no
|
||||
"students who saw v1 vs v2" cohort tracking. If a future phase needs that level of rigor
|
||||
(e.g. for compliance/audit reasons), it's a real, separate feature — not something to bolt
|
||||
onto a plain integer column.
|
||||
|
||||
## 7. Why this doesn't touch Phase 1-5 systems
|
||||
|
||||
No column was added to `scenarios/*.json`, `assessment_definitions`, `achievements`,
|
||||
|
||||
@@ -461,3 +461,34 @@ Full design: `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`, plus `docs/ROOT_CURRICULU
|
||||
the gold module's real lesson content, launches a real Lab, the Lab's own `setup_commands`
|
||||
stand up the broken deployment, a real `kubectl patch` fixes it, and the module completes and
|
||||
unlocks the next one) — both against a live stack.
|
||||
|
||||
## 18. ROOT_ Curriculum Authoring & Coverage Expansion (Phase 6B)
|
||||
|
||||
Full design: `docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md`, plus
|
||||
`docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md` and `docs/ROOT_CAPSTONE_READINESS.md`.
|
||||
|
||||
- **What it is:** 13 new teaching modules across ROOT 05-10/12/Capstone
|
||||
(`023_phase6b_curriculum.sql`), built entirely from the 98-scenario inventory Phase 6A already
|
||||
catalogued — no new scenarios needed. ROOT 08/09 moved from PLANNED to PARTIAL; scenario
|
||||
coverage in structured curriculum went from 44/98 to 88/98 (89.8%), verified by direct query,
|
||||
not estimated. 3 modules (plus the pre-existing gold standard) reach the full READY bar; every
|
||||
other new module is honestly PARTIAL, missing a demonstration or guided exercise, documented
|
||||
per-module rather than glossed over.
|
||||
- **`is_required`** (new `module_activities` column, defaults `true`): distinguishes CORE
|
||||
teaching from GUIDED/INDEPENDENT-practice duplicates and ADVANCED/optional extras — a module
|
||||
is complete once every REQUIRED activity is done; an incomplete optional one never blocks it.
|
||||
Every Phase 6A activity is unaffected (defaults to required).
|
||||
- **The capstone server-side unlock fix:** `POST /api/assessments/:id/start` never checked
|
||||
`unlock_rule` before this phase — a direct API call could start the capstone before its
|
||||
prerequisite stages were complete, even though the UI never exposed that path. Fixed by
|
||||
binding to the same `computeCurriculumState()` gate `GET /api/capstone` already used. See
|
||||
`docs/ROOT_CAPSTONE_READINESS.md` §4 for the full story, including a pre-existing gap this
|
||||
fix exposed in `e2e-phase5b.js`'s own capstone test (fixed alongside).
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase6b.js` (24 checks: new-module
|
||||
hierarchy, prerequisite chains through the newly-inserted front-door modules, the
|
||||
optional-vs-required completion rule, the capstone bypass fix in both directions, persistence/
|
||||
isolation/RBAC/draft-hiding proven against the new content) and
|
||||
`infra/scripts/drills/test-phase6b-root-course-rehearsal.js` (11 checks, real k3s: a student
|
||||
walks a brand-new module — ROOT 07 Services & Service Discovery — end to end including a real
|
||||
`kubectl expose` against a live cluster, plus an instructor narrative proving Curriculum view,
|
||||
Prepare Labs, and classroom tooling all still work against the expanded curriculum).
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Phase 6B — ROOT_ Curriculum Authoring & Coverage Expansion
|
||||
|
||||
**Status: PHASE 6B COMPLETE. 13 NEW TEACHING MODULES AUTHORED ACROSS ROOT 05-10/12/CAPSTONE.
|
||||
CAPSTONE SERVER-SIDE UNLOCK ENFORCEMENT FIXED. VALIDATED AGAINST A REAL LAB.**
|
||||
|
||||
Phase 6A built the course/module/activity content model and one gold-standard module. Phase 6B
|
||||
is the primary content-authoring phase: real, substantial teaching content across the
|
||||
Kubernetes/operations portion of `root_`, built strictly from the existing 98-scenario
|
||||
inventory — no new scenarios were needed or created (see §7).
|
||||
|
||||
## 1. What was authored
|
||||
|
||||
13 new modules across 7 courses, all evidence-mapped in `docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md`:
|
||||
|
||||
| Course | New module(s) | Depth |
|
||||
|---|---|---|
|
||||
| ROOT 05 | Kubernetes Mental Model & Core Objects | **READY** — full gold-standard pattern |
|
||||
| ROOT 06 | Deployments, Scaling & Updates | **READY** — full gold-standard pattern |
|
||||
| ROOT 06 | Jobs, CronJobs & DaemonSets | PARTIAL — lesson + knowledge check + labs |
|
||||
| ROOT 07 | Services & Service Discovery | **READY** — full gold-standard pattern |
|
||||
| ROOT 07 | Ingress & NetworkPolicy Foundations | PARTIAL — lesson + knowledge check + labs |
|
||||
| ROOT 08 | Storage Fundamentals | PARTIAL — lesson + knowledge check + labs |
|
||||
| ROOT 08 | Scheduling & Resource Governance | PARTIAL — lesson + knowledge check + labs |
|
||||
| ROOT 09 | Security Model & SecurityContext | **READY** — full gold-standard pattern + guided/independent/advanced practice |
|
||||
| ROOT 09 | ServiceAccounts & RBAC | PARTIAL — lesson + knowledge check + labs |
|
||||
| ROOT 09 | Network & Supply Chain Security | PARTIAL — lesson + knowledge check + labs |
|
||||
| ROOT 09 | Cluster Hardening & Runtime Security | PARTIAL — survey-depth lesson + labs |
|
||||
| ROOT 10 | The RootNode Troubleshooting Framework | PARTIAL — lesson + demonstration + knowledge check + labs |
|
||||
| ROOT 12 | Incident-response framing added to the existing Real Ops module | PARTIAL — framework lesson + reflection wrapping the existing incidents |
|
||||
| ROOT Capstone | Capstone Readiness | **READY** — orientation module, see `docs/ROOT_CAPSTONE_READINESS.md` |
|
||||
|
||||
3 modules reach **READY** by the honest bar in `docs/COURSE_AUTHORING_STANDARD.md` (introduction,
|
||||
objectives, concept lesson, demonstration, knowledge check, guided practice, lab, reflection,
|
||||
instructor notes, estimated duration, all present) — plus the pre-existing gold-standard Health
|
||||
Probes module, unchanged. Every other new module is honestly **PARTIAL**: real lesson content
|
||||
and real labs, missing one or more of demonstration/guided-exercise (documented per-module in the
|
||||
coverage matrix, not glossed over).
|
||||
|
||||
ROOT 08 and ROOT 09 moved from **PLANNED** to **PARTIAL** at the course level — real content now
|
||||
exists where Phase 6A left them structurally empty.
|
||||
|
||||
## 2. The is_required extension (Phase 6B, not Phase 6A)
|
||||
|
||||
`module_activities.is_required` (new column, defaults `true` — every Phase 6A activity is
|
||||
unaffected) lets a module distinguish CORE activities from GUIDED/INDEPENDENT-practice
|
||||
duplicates and ADVANCED/optional extras (ROOT 09's CKS-style content, mostly). A module is
|
||||
"complete" once every REQUIRED activity is done — an incomplete optional activity never blocks
|
||||
it, though it still counts toward the displayed percent. See `docs/COURSE_CONTENT_MODEL.md`'s
|
||||
updated completion-rules section.
|
||||
|
||||
## 3. The capstone server-side unlock fix
|
||||
|
||||
Full detail in `docs/ROOT_CAPSTONE_READINESS.md` §4. Summary: `POST /api/assessments/
|
||||
:definitionId/start` previously never checked `unlock_rule` — a direct API call could start the
|
||||
capstone before its prerequisite stages were genuinely complete, even though the UI never
|
||||
exposed a way to trigger this. Fixed by binding to the same `computeCurriculumState()` gate
|
||||
`GET /api/capstone` already used. Found while fixing this: `e2e-phase5b.js`'s own capstone test
|
||||
had been passing only because of this exact gap (it never completed the `real-ops` stage before
|
||||
attempting to start) — fixed alongside the platform code, not routed around.
|
||||
|
||||
## 4. Content quality bar applied honestly
|
||||
|
||||
No module was marked READY merely for having activity rows, headings, or scenario references.
|
||||
The ROOT 08/09 storage/scheduling/security modules are real, substantive lessons with real
|
||||
knowledge checks — but without a demonstration or guided exercise, they're PARTIAL, not READY,
|
||||
by this project's own stated standard. See the coverage matrix for the specific gap in each.
|
||||
|
||||
## 5. Scenario coverage before/after
|
||||
|
||||
Verified by direct query against `module_activities.scenario_id`, not estimated.
|
||||
|
||||
Before Phase 6B: 44 of 98 scenarios wired into structured curriculum (all via Phase 6A's
|
||||
mechanical mapping of `rootnode-path.json`'s existing stages).
|
||||
|
||||
After Phase 6B: **88 of 98 scenarios (89.8%) now in structured curriculum** — 44 newly wired
|
||||
this phase: `deployment-rollback` (added to ROOT 06 m02 as hands-on rollback practice
|
||||
alongside its lesson), `job-task`, `cronjob-task` (ROOT 06 m03); `nodeport-task`,
|
||||
`dns-resolution-mcq` (ROOT 07 m01), `ingress-task`, `network-policy` (ROOT 07 m04);
|
||||
`emptydir-pod`, `pv-pvc-mount`, `pvc-dynamic-task`, `storageclass-mcq` (ROOT 08 m01),
|
||||
`node-label-selector`, `node-taint-toleration`, `pod-affinity-mcq`, `priority-class-task`,
|
||||
`pod-disruption-budget-task`, `resource-quota-ns`, `limitrange-task` (ROOT 08 m02);
|
||||
`cks-non-root-enforce`, `cks-drop-capabilities`, `cks-readonly-filesystem`,
|
||||
`cks-pod-security-context`, `cks-seccomp-profile`, `cks-apparmor-profile`,
|
||||
`pod-security-context` (ROOT 09 m01); `serviceaccount-pod`, `cks-automount-token`, `rbac-role`,
|
||||
`cks-rbac-clusterrole`, `cks-rbac-least-privilege`, `rbac-clusterrole` (ROOT 09 m02);
|
||||
`cks-network-policy`, `cks-egress-namespace`, `cks-tls-ingress`, `cks-image-pull-secret`,
|
||||
`networkpolicy-egress`, `cks-network-policy-metadata` (ROOT 09 m03); `cks-psa-namespace`,
|
||||
`cks-immutable-secret`, `cks-runtime-class`, `cks-audit-policy` (ROOT 09 m04); `crashloop-fix`,
|
||||
`endpoint-fix-task` (ROOT 10 m03), `container-logging-mcq` (ROOT 10 m02, existing bank).
|
||||
|
||||
**10 scenarios remain unwired**, each with a specific, documented reason rather than an
|
||||
oversight: `cluster-upgrade-mcq`, `etcd-backup-mcq` (ROOT 11/DevOps territory, explicitly out of
|
||||
Phase 6B's authoring scope); 7 CKS-style MCQs classified as ASSESSMENT BANK material
|
||||
(`cks-mcq-api-server`, `cks-mcq-audit-policy`, `cks-mcq-kubelet-auth`, `cks-mcq-psp-replacement`,
|
||||
`cks-mcq-runtime-security`, `cks-mcq-sandboxing`, `cks-mcq-image-footprint`) — recall-style
|
||||
questions better suited to a future structured exam pool than hands-on core teaching, flagged
|
||||
for that future use rather than silently dropped; and `readiness-probe-task` — a real, unused
|
||||
candidate for independent practice in ROOT 06 or ROOT 10 that this phase simply didn't get to,
|
||||
honestly left for Phase 6C rather than force-mapped at the last minute. Quality of mapping, not
|
||||
percentage, drove every placement decision — see the coverage matrix's "duplicate concepts
|
||||
identified" section for how near-duplicate scenarios were handled deliberately rather than both
|
||||
force-fit into "core."
|
||||
|
||||
## 6. Knowledge-check and guided-exercise approach
|
||||
|
||||
Every new knowledge check uses scenario-interpretation and reasoning questions ("A Deployment
|
||||
has three replicas but the Service has zero endpoints — which part of the system should you
|
||||
inspect first, and why?"), never "what command was shown two paragraphs ago." Every guided
|
||||
exercise bridges lesson to lab without revealing the lab's answer — modeled directly on the
|
||||
existing gold module's own guided exercise. No new knowledge-check engine capability was needed;
|
||||
the existing Markdown + manual-marker model was sufficient throughout.
|
||||
|
||||
## 7. Why no new scenarios were created
|
||||
|
||||
Every learning objective this phase authored had a suitable existing scenario — the 54-scenario
|
||||
unused bank Phase 6A inventoried was exactly the content Phase 6B needed. No objective was found
|
||||
that genuinely required new hands-on lab content with no existing scenario able to serve it, so
|
||||
no new `rootnode-*` scenario was created this phase (see the authorization's explicit
|
||||
justification bar in its §17 — none of the three required conditions were met for any specific
|
||||
gap identified during authoring).
|
||||
|
||||
## 8. What Phase 6B deliberately did not build
|
||||
|
||||
Per the authorization's explicit scope line: `init_`/`compile_` curriculum, deep authoring of
|
||||
ROOT 01-04 or ROOT 11 (both remain structurally PLANNED, as Phase 6A left them — genuinely no
|
||||
scenario content exists for either), a Parent Portal, payments, public registration, an AI
|
||||
tutor, chat/video/voice, proctoring, a mobile app, certificates, a multi-host scheduler,
|
||||
production deployment changes, an LMS/CMS, a WYSIWYG authoring system, or complex drip
|
||||
scheduling. The Provisioner, Gateway, Lab Router, student/instructor authentication, enrollment
|
||||
architecture, and notification architecture were not redesigned — the one fix made
|
||||
(capstone unlock) was the smallest correct change to the assessment engine's existing route,
|
||||
not a redesign of the engine itself.
|
||||
|
||||
## 9. Test results
|
||||
|
||||
- `e2e-phase6b.js`: **24 passed, 0 failed.**
|
||||
- `test-phase6b-root-course-rehearsal.js` (real k3s Lab, real `kubectl expose`, real classroom
|
||||
tooling): **11 passed, 0 failed.**
|
||||
- Full regression (2A/2B/3A/3B/5A/5B/5C/6A), each run in isolation per the convention Phase 6A
|
||||
established — see the final report for the run-by-run result and the two pre-existing test
|
||||
gaps found and fixed along the way (in `e2e-phase5b.js` and `e2e-phase6a.js`, both caused by
|
||||
this phase's own intentional, documented behavior changes, not by a defect).
|
||||
|
||||
## 10. Known limitations / recommended Phase 6C scope
|
||||
|
||||
- ROOT 06/07/08/09/10's PARTIAL modules are missing a demonstration and/or guided exercise —
|
||||
the natural next authoring pass, module by module, using the 3 READY modules as templates.
|
||||
- ROOT 09's 7 ASSESSMENT-BANK-classified MCQs are inventoried but not yet wired into an actual
|
||||
assessment definition.
|
||||
- ROOT 11 (DevOps & Platform Engineering) remains entirely PLANNED — no scenario content exists
|
||||
anywhere in this repository for CI/CD, IaC, or GitOps concepts; a future phase would need to
|
||||
either author new `rootnode-*` scenarios for it or explicitly scope it out of `root_`.
|
||||
- The Capstone Readiness module's completion is intentionally independent of the real capstone's
|
||||
unlock gate (see `docs/ROOT_CAPSTONE_READINESS.md` §3) — worth revisiting only if a future
|
||||
phase has a concrete reason to couple them, not as a default assumption.
|
||||
@@ -91,7 +91,10 @@ this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
activity tree (lessons, demonstrations, knowledge checks, guided exercises, labs,
|
||||
reflections, assessments/capstone) instead of just a bare scenario id list — the same tree an
|
||||
instructor-led classroom student sees, consumed through the identical `buildCourseTree()`.
|
||||
See `docs/COURSE_CONTENT_MODEL.md` and `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`.
|
||||
See `docs/COURSE_CONTENT_MODEL.md` and `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`. **Populated
|
||||
with real teaching content across most of the Kubernetes/operations courses in Phase 6B** —
|
||||
see `docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md`. The architecture itself (this closed gap)
|
||||
didn't change; what a self-paced student actually finds inside it did.
|
||||
|
||||
## What Phase 4.1 deliberately did NOT do here
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# ROOT Capstone Readiness
|
||||
|
||||
Phase 6B §9/§10 — the instructional runway that prepares a student for the existing
|
||||
`rootnode-capstone` assessment, and the server-side fix that makes its unlock rule actually
|
||||
enforced. This document does not redesign the capstone engine itself (`assessments.js`,
|
||||
`013_assessments.sql`) — see `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` for that.
|
||||
|
||||
## 1. The Capstone Readiness module
|
||||
|
||||
`root-capstone-m02-capstone-readiness` (sequenced BEFORE the real capstone module,
|
||||
`root-capstone-m01-operate-and-recover`, within the `root-capstone` course) — 4 activities:
|
||||
|
||||
1. **Lesson — What the Capstone Measures & How the Draw Works.** Explains the required-vs-random
|
||||
scenario draw, what "unlocked" means, and what completion records. Never publishes the exact
|
||||
scenario IDs in the current pool or any answer.
|
||||
2. **Lesson — Time Management & Troubleshooting Strategy Under a Clock.** Budgeting the 90
|
||||
minutes, reusing the ROOT 10/ROOT 12 frameworks under pressure, and when to move on from a
|
||||
stuck scenario.
|
||||
3. **Knowledge check — Readiness Check: Are You Actually Ready?** A genuine self-assessment
|
||||
(not a graded quiz) against concrete criteria, pointing back to specific ROOT_ modules for
|
||||
any "no" answer.
|
||||
4. **Reflection — What Completion Means.** Connects passing the Capstone to a durable,
|
||||
demonstrated skill rather than a completion checkbox.
|
||||
|
||||
This module derives its own completion the same way every other lesson-only module does
|
||||
(`activity_completions`, `manual_marker`) — it is intentionally NOT a prerequisite the real
|
||||
capstone assessment engine checks before allowing a start (see §3 below for why these two gates
|
||||
are deliberately independent).
|
||||
|
||||
## 2. What was NOT built
|
||||
|
||||
No answers, no exact scenario IDs from the current pool, no capstone-specific hint content
|
||||
beyond what the Lab's own progressive hints already provide. No new assessment engine, no
|
||||
second capstone, no readiness "score." This is orientation content, not a shadow exam.
|
||||
|
||||
## 3. Why readiness-module completion and the real unlock gate are independent
|
||||
|
||||
The real capstone start (`POST /api/assessments/rootnode-capstone/start`) is gated by the
|
||||
curriculum path's own `unlock_rule` — all 6 content stages of `rootnode-path.json` complete
|
||||
(Foundation, Workloads, Networking, Configuration, Troubleshooting, Real Ops) — computed by
|
||||
`computeCurriculumState()` (`curriculum-rules.js`), the exact same function `GET /api/capstone`
|
||||
already used to display lock state. Whether a student has viewed or completed the Capstone
|
||||
Readiness module has NO bearing on this gate.
|
||||
|
||||
This is deliberate, not an oversight: hard-locking the real assessment behind a CONTENT-MODEL
|
||||
module (course_modules/module_activities) would mean the security-critical enforcement boundary
|
||||
lives in two different systems that could drift out of sync — the curriculum-path unlock_rule
|
||||
(Postgres-backed, achievement-derived, already proven) and the course-content prerequisite chain
|
||||
(newer, additive, built for teaching sequencing rather than security enforcement). Keeping them
|
||||
independent means: the readiness module is free to be completed early, late, or skipped by a
|
||||
student who's already prepared some other way, while the real gate remains exactly as strict as
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` originally specified.
|
||||
|
||||
## 4. The server-side unlock enforcement fix (Phase 6B §10)
|
||||
|
||||
**The gap** (documented in `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` §10): `POST /api/assessments/
|
||||
:definitionId/start` (`learning-routes.js`) checked only that the assessment definition existed
|
||||
and was `status = 'active'` — it never checked `unlock_rule` at all. `GET /api/capstone`
|
||||
correctly computed and displayed lock state, and the student UI never exposed a "start" action
|
||||
while locked, but a direct POST to the start route bypassed the check entirely. Student UI state
|
||||
was the only thing standing between a student and an early capstone attempt.
|
||||
|
||||
**The fix**: the start route now resolves whether the requested definition is the curriculum
|
||||
path's OWN configured capstone (`path.stages.find(s => s.type === 'capstone' && s
|
||||
.capstone_assessment_id === definition.assessment_definition_id)`), and if so, computes the
|
||||
exact same `computeCurriculumState()` gate `/api/capstone` already relies on. A `locked` gate
|
||||
now returns a clean `403 { error: 'locked', message, blocking_stage_ids }` — the same
|
||||
`blocking_stage_ids` shape `/api/capstone` already exposes, so a client integrating against this
|
||||
route gets consistent, already-documented error shape rather than a new one. No internal state
|
||||
beyond the stage ids that are actually incomplete is ever returned.
|
||||
|
||||
**What is NOT gated**: any assessment definition the curriculum path does NOT name as its
|
||||
capstone (e.g. `rootnode-quick-assessment`) is completely unaffected — the check only ever
|
||||
triggers for the one definition the path itself points at via `capstone_assessment_id`.
|
||||
|
||||
**What remains valid**: no existing `assessment_sessions` row, no achievement, no enrollment
|
||||
completion record was touched by this fix — it only affects a NEW start attempt going forward.
|
||||
A student who already legitimately completed the capstone before this fix keeps that record
|
||||
exactly as it was.
|
||||
|
||||
**Regression coverage**: `platform/test/e2e-phase6b.js` proves both directions — a direct start
|
||||
attempt before prerequisites are met is rejected (`403 locked`), and a legitimately unlocked
|
||||
attempt (all 6 stages genuinely complete) still succeeds and completes normally. A pre-existing
|
||||
gap was also found and fixed in `e2e-phase5b.js`'s own capstone-completion test, which had never
|
||||
included the `real-ops` stage in its completion loop — it was passing only because the very
|
||||
bug this phase fixes let an incomplete curriculum still start the capstone. See that test file's
|
||||
own comment for detail.
|
||||
@@ -0,0 +1,114 @@
|
||||
# ROOT_ Curriculum Coverage Matrix
|
||||
|
||||
Phase 6B §1 — maps every scenario Phase 6B puts to structured use, with an intentional reason
|
||||
for each placement (not coverage-percentage padding). Built from
|
||||
`docs/ROOT_CURRICULUM_INVENTORY.md`'s evidence. Statuses: **READY** (a student can actually
|
||||
learn from it end to end — see `docs/COURSE_AUTHORING_STANDARD.md`'s bar), **PARTIAL** (real
|
||||
lesson + labs, missing one or more of demonstration/knowledge-check/guided-exercise), **PLANNED**
|
||||
(structure only, no content), **GAP** (no suitable scenario exists at all).
|
||||
|
||||
## Duplicate concepts identified (handled explicitly, not silently mapped twice)
|
||||
|
||||
- `pod-security-context` (generic) vs. `cks-pod-security-context` (CKS-flavored) — same concept.
|
||||
`cks-pod-security-context` is the CORE guided lab (ROOT 09 m01); `pod-security-context` is
|
||||
classified INDEPENDENT PRACTICE (a second, optional rep).
|
||||
- `rbac-clusterrole` (generic) vs. `cks-rbac-clusterrole` (CKS-flavored) — same concept.
|
||||
`cks-rbac-clusterrole` is CORE (ROOT 09 m02); `rbac-clusterrole` is INDEPENDENT PRACTICE.
|
||||
- `network-policy` (generic, foundational) vs. `cks-network-policy` (CKS default-deny framing) vs.
|
||||
`networkpolicy-egress` (generic egress) — three related but distinct angles, deliberately split
|
||||
across two courses: `network-policy` teaches the ARCHITECTURAL concept in ROOT 07 (what a
|
||||
NetworkPolicy is/does); `cks-network-policy`/`cks-egress-namespace`/`networkpolicy-egress`
|
||||
teach it as a SECURITY CONTROL in ROOT 09 (default-deny posture, least-privilege network
|
||||
access) — not a duplicate once the teaching angle is made explicit, so both are CORE in their
|
||||
respective course, not one marked redundant.
|
||||
|
||||
## ROOT 05 — Kubernetes Foundations
|
||||
|
||||
| Module | Objective | Lesson | Demo | KC | Guided | Scenario(s) | Assessment | Real Ops | Status |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| m02 Kubernetes Mental Model & Core Objects (NEW) | Explain what Kubernetes solves, the control-plane/worker/desired-state model, and navigate Pods/Namespaces/labels with kubectl | Yes | Yes | Yes | Yes | pod-basics-mcq, namespaces-basics, labels-selectors-mcq, kubectl-essentials-mcq (existing, re-sequenced as this module's guided path) | — | "declarative config drifts back to desired state after manual edits" | **READY** |
|
||||
| m01 Pods & kubectl Basics (existing) | Mechanical kubectl fluency across the Foundation stage's 20 scenarios | No | No | No | No | all 20 Foundation scenarios | — | — | PARTIAL (unchanged from Phase 6A — m02 above is the new front door; m01's labs remain a practice bank behind it) |
|
||||
|
||||
`m02` is sequenced BEFORE `m01` (prerequisite: none; `m01`'s prerequisite becomes `m02`) so a
|
||||
student meets the mental model before the bare-lab bank, per this phase's core principle.
|
||||
|
||||
## ROOT 06 — Kubernetes Application Workloads
|
||||
|
||||
| Module | Objective | Lesson | Demo | KC | Guided | Scenario(s) | Real Ops | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| m02 Deployments, Scaling & Updates (NEW) | Explain why Deployments/ReplicaSets exist, perform a rolling update, and reason about rollback | Yes | Yes | Yes | Yes | deploy-nginx, scale-deployment, rolling-update-task, deployment-rollback (newly wired — Workloads & Scheduling bucket, belongs here not ROOT 08) | "a bad rollout with no rollback plan turns a deploy into an outage" | **READY** |
|
||||
| m03 Jobs, CronJobs & DaemonSets (NEW) | Distinguish one-shot Jobs, scheduled CronJobs, and per-node DaemonSets from long-running Deployments | Yes | No | Yes | No | job-task, cronjob-task (newly wired), jobs-cronjobs-mcq, daemonset-mcq | "a cleanup Job that never completes silently exhausts cluster resources" | PARTIAL (lesson+KC authored; no separate demo/guided — labs carry the practice) |
|
||||
| m01 Deployments & Workloads (existing) | Bare-lab bank, Workloads stage's original 8 scenarios | No | No | No | No | (unchanged) | — | PARTIAL (unchanged) |
|
||||
|
||||
## ROOT 07 — Kubernetes Networking & Configuration
|
||||
|
||||
| Module | Objective | Lesson | Demo | KC | Guided | Scenario(s) | Real Ops | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| m03 Services & Service Discovery (NEW) | Explain the Service/Endpoints/DNS request path and choose the right Service type | Yes | Yes | Yes | Yes | services-mcq, expose-service-basics, nodeport-task, dns-resolution-mcq (newly wired) | "a Service with zero endpoints looks identical to network failure from the outside" | **READY** |
|
||||
| m04 Ingress & NetworkPolicy Foundations (NEW) | Explain how Ingress routes external traffic and what a NetworkPolicy architecturally does | Yes | No | Yes | No | ingress-task, network-policy (newly wired) | "traffic reachability should be intentional, not incidental" | PARTIAL |
|
||||
| m02 Configuration & Secrets (existing) | Bare-lab bank | No | No | No | No | (unchanged) | — | PARTIAL (unchanged) |
|
||||
| m01 Services & Networking (existing) | Bare-lab bank, folded conceptually under m03 above | No | No | No | No | (unchanged) | — | PARTIAL (unchanged) |
|
||||
|
||||
## ROOT 08 — Kubernetes Storage & Scheduling (newly wired this phase — was PLANNED)
|
||||
|
||||
| Module | Objective | Lesson | Demo | KC | Guided | Scenario(s) | Real Ops | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| m01 Storage Fundamentals (NEW) | Explain ephemeral vs. persistent storage and how PV/PVC/StorageClass fit together | Yes | No | Yes | No | emptydir-pod, pv-pvc-mount, pvc-dynamic-task, storageclass-mcq | "a Pod restart on ephemeral storage silently loses data no one told the team about" | PARTIAL |
|
||||
| m02 Scheduling & Resource Governance (NEW) | Explain how the scheduler places Pods and how quotas/limits/priority shape that placement | Yes | No | Yes | No | node-label-selector, node-taint-toleration, pod-affinity-mcq, priority-class-task, pod-disruption-budget-task, resource-quota-ns, limitrange-task | "an eviction during a voluntary disruption without a PDB can take down every replica at once" | PARTIAL |
|
||||
|
||||
`cluster-upgrade-mcq` and `etcd-backup-mcq` (Cluster Administration bucket) are left
|
||||
**PRACTICE-ONLY** — they're genuine cluster-operator content but conceptually belong with ROOT 11
|
||||
(DevOps & Platform Engineering, out of Phase 6B's authoring scope per the authorization), not
|
||||
storage/scheduling. Mapping them here just to raise a coverage number would violate this
|
||||
document's own "quality over percentage" rule.
|
||||
|
||||
## ROOT 09 — Kubernetes Security (newly wired this phase — was PLANNED)
|
||||
|
||||
Not built as 22 disconnected CKS tasks. Four modules, foundational → advanced:
|
||||
|
||||
| Module | Focus | Classification of its scenarios | Status |
|
||||
|---|---|---|---|
|
||||
| m01 Security Model & SecurityContext (NEW) | Defense in depth, least privilege, non-root/capabilities/read-only-fs | CORE: cks-pod-security-context, cks-non-root-enforce, cks-drop-capabilities, cks-readonly-filesystem. GUIDED: cks-seccomp-profile. ADVANCED: cks-apparmor-profile. INDEPENDENT: pod-security-context (dup, see above) | **READY** |
|
||||
| m02 ServiceAccounts & RBAC (NEW) | Identity for workloads, least-privilege permissions | CORE: serviceaccount-pod, cks-automount-token, rbac-role, cks-rbac-clusterrole, cks-rbac-least-privilege. INDEPENDENT: rbac-clusterrole (dup, see above) | PARTIAL (lesson authored; no separate demo/guided) |
|
||||
| m03 Network & Supply Chain Security (NEW) | NetworkPolicy as a security control, image/registry trust, TLS ingress | CORE: cks-network-policy, cks-egress-namespace, cks-tls-ingress, cks-image-pull-secret. GUIDED: networkpolicy-egress. ADVANCED: cks-network-policy-metadata. ASSESSMENT BANK: cks-mcq-image-footprint | PARTIAL |
|
||||
| m04 Cluster Hardening & Runtime Security (NEW) | Pod Security Admission, audit policy, runtime sandboxing — survey-level, not daily-driver core | CORE: cks-psa-namespace, cks-immutable-secret. ADVANCED: cks-audit-policy, cks-runtime-class. ASSESSMENT BANK: cks-mcq-api-server, cks-mcq-audit-policy, cks-mcq-kubelet-auth, cks-mcq-psp-replacement, cks-mcq-runtime-security, cks-mcq-sandboxing | PARTIAL |
|
||||
|
||||
"ASSESSMENT BANK" scenarios are intentionally NOT wired as required module activities — they're
|
||||
recall-style MCQs well suited to a future structured exam pool (`rootnode-quick-assessment`-style
|
||||
definition), not hands-on core teaching. Not wired into `module_activities` this phase; flagged
|
||||
here so a future phase doesn't have to re-discover them.
|
||||
|
||||
## ROOT 10 — Kubernetes Troubleshooting & Observability
|
||||
|
||||
The gold-standard Health Probes module (`root-10-m01`) is **preserved unmodified** — it's the
|
||||
template, not a page to be made "consistent" with anything else.
|
||||
|
||||
| Module | Objective | Lesson | Demo | KC | Guided | Scenario(s) | Real Ops | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| m01 Health Probes & Troubleshooting (existing, UNCHANGED) | — | Yes | Yes | Yes | Yes | rootnode-probe-port-mismatch | Yes | **READY** |
|
||||
| m03 The RootNode Troubleshooting Framework (NEW) | Apply Observe→Evidence→Hypothesis→Test→Repair→Validate→Prevent to a CrashLoopBackOff and a broken Service | Yes | Yes | Yes | No | crashloop-fix, endpoint-fix-task, container-logging-mcq (newly wired) | "a repeatable framework beats guessing under a 2am page" | PARTIAL (no separate guided exercise — the framework lesson + demo carry that role) |
|
||||
| m02 General Troubleshooting (existing) | Bare-lab bank | No | No | No | No | (unchanged) | — | PARTIAL (unchanged) |
|
||||
|
||||
## ROOT 12 — RootNode Real Ops
|
||||
|
||||
| Module | Objective | Lesson | Scenario(s) | Framing | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| m01 Real Ops Incidents (existing, extended with framing) | Apply Detect→Triage→Diagnose→Mitigate→Recover→Verify→Learn to real incidents | Yes (new incident-response framework lesson) | rootnode-runaway-monitor, broken-deployment, rootnode-probe-port-mismatch (reused), rootnode-enforce-limits | Each lab gets a short incident-context intro + post-incident reflection (not full lesson/demo/KC per incident — see `docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md` for why) | PARTIAL |
|
||||
|
||||
`rootnode-real-ops` bundle contains exactly these 4 scenario ids (verified against
|
||||
`scenarios/bundles/rootnode-real-ops.json`) — no additional upstream scenario was intentionally
|
||||
included there beyond what `rootnode-path.json`'s `real-ops` stage already lists.
|
||||
|
||||
## ROOT CAPSTONE
|
||||
|
||||
| Module | Content | Status |
|
||||
|---|---|---|
|
||||
| m01 Operate and Recover (existing) | The real `rootnode-capstone` assessment | PARTIAL (unchanged — assessment engine, not lesson content) |
|
||||
| m02 Capstone Readiness (NEW) | What the capstone measures, prerequisites, how the timed/random draw works, time management, troubleshooting strategy, what completion means — derives real readiness from curriculum state, never publishes the draw or answers | **READY** |
|
||||
|
||||
## Scenarios deliberately left out of structured curriculum entirely
|
||||
|
||||
`cluster-upgrade-mcq`, `etcd-backup-mcq` (ROOT 11 territory, out of scope), and the 6
|
||||
ASSESSMENT-BANK-classified CKS MCQs above (not yet wired to an actual assessment definition —
|
||||
future work, not silently dropped). All remain reachable via the existing Practice tab/bundles,
|
||||
unaffected by any of this.
|
||||
@@ -207,3 +207,15 @@ ROOT 07/08/09/10 can draw on in Phase 6B, not content that needs to be invented:
|
||||
- No DevOps/CI/CD/IaC/GitOps scenario content exists (ROOT 11 is necessarily 100% PLANNED).
|
||||
- No Linux-fundamentals, Git, or Docker-specific scenario content exists (ROOT 01-04 are
|
||||
necessarily 100% PLANNED) — everything in `scenarios/data/` is Kubernetes/CKS content.
|
||||
|
||||
## 8. Phase 6B update — scenario coverage after curriculum authoring
|
||||
|
||||
Phase 6B wired 44 more of the scenarios above into structured curriculum (verified by direct
|
||||
query against `module_activities.scenario_id`, not estimated): **88 of 98 scenarios (89.8%)**
|
||||
are now in structured curriculum, up from 44 at the end of Phase 6A. The remaining 10 —
|
||||
`cluster-upgrade-mcq`, `etcd-backup-mcq` (ROOT 11 territory), 7 CKS-style MCQs classified as
|
||||
future ASSESSMENT BANK material, and `readiness-probe-task` (a genuine, documented gap for
|
||||
Phase 6C) — are listed with their specific reasons in
|
||||
`docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md` §5. The full course-by-course mapping, including
|
||||
which scenarios were classified CORE/GUIDED/INDEPENDENT/ADVANCED/ASSESSMENT-BANK and why, lives
|
||||
in `docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md` — this file is intentionally not duplicated there.
|
||||
|
||||
@@ -9,6 +9,11 @@ All 13 rows below are `courses` under the ONE existing program, `root-kubernetes
|
||||
(`programs` table, unchanged — see §"why one program, not thirteen" below). Every status claim
|
||||
here is grounded in `docs/ROOT_CURRICULUM_INVENTORY.md`, not aspirational.
|
||||
|
||||
**Updated for Phase 6B** (`platform/api/migrations/023_phase6b_curriculum.sql`,
|
||||
`docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md`): ROOT 08 and 09 moved from PLANNED to PARTIAL, and
|
||||
6 courses gained new teaching modules — the module-level detail and evidence for every new
|
||||
placement lives in `docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md`, not duplicated here.
|
||||
|
||||
## Course sequence
|
||||
|
||||
| # | Course | Status | Why |
|
||||
@@ -17,15 +22,15 @@ here is grounded in `docs/ROOT_CURRICULUM_INVENTORY.md`, not aspirational.
|
||||
| 2 | Linux Administration & Networking | **PLANNED** | Same — no Linux-admin scenario content exists. |
|
||||
| 3 | Git & Collaborative Development | **PLANNED** | No Git scenario content exists. |
|
||||
| 4 | Containers & Docker | **PLANNED** | No Docker-specific scenario content exists (the Lab itself runs k3s, but no student-facing "learn Docker" scenarios exist). |
|
||||
| 5 | Kubernetes Foundations | **PARTIAL** | Real content: the existing "Foundation" stage's 20 scenarios, wrapped as one module (`root-05-m01-pods-and-kubectl-basics`). No teaching lesson content authored yet — bare labs only. |
|
||||
| 6 | Kubernetes Application Workloads | **PARTIAL** | The existing "Workloads" stage's 8 scenarios, one module. Bare labs only. |
|
||||
| 7 | Kubernetes Networking & Configuration | **PARTIAL** | The existing "Networking" (2) and "Configuration" (5) stages, two modules. Bare labs only. |
|
||||
| 8 | Kubernetes Storage & Scheduling | **PLANNED** | Real scenario content exists (PV/PVC, StorageClass, taints/tolerations, PDB — see inventory §5) but is not yet wired into a module. Deliberately left PLANNED rather than fabricating a module shell with no honest content behind it — Phase 6B. |
|
||||
| 9 | Kubernetes Security | **PLANNED** | A substantial 22-scenario CKS-style bank exists (RBAC, PSA, network policy, supply chain, runtime hardening) but is not yet wired into a module — Phase 6B. |
|
||||
| 10 | Kubernetes Troubleshooting & Observability | **PARTIAL** | Contains the Phase 6A gold-standard module (READY) plus the existing "Troubleshooting" stage's remaining 5 scenarios as a second, bare-lab module. |
|
||||
| 11 | DevOps & Platform Engineering Foundations | **PLANNED** | No CI/CD/IaC/GitOps scenario content exists anywhere in this repository. |
|
||||
| 12 | RootNode Real Ops | **PARTIAL** | The existing "RootNode: Real Ops" stage's 4 scenarios (3 RootNode-authored + 1 generic), one module. Bare labs, no teaching content — the gold module already gives one of these four (`rootnode-probe-port-mismatch`) a full teaching treatment elsewhere; this module exercises it again alongside the other three, deliberately (see module notes below). |
|
||||
| 13 | ROOT Capstone — Operate and Recover a Production-Like Environment | **PARTIAL** | The existing, fully working `rootnode-capstone` assessment (90 min, mixed required/random pool). No capstone orientation/briefing lesson authored yet. |
|
||||
| 5 | Kubernetes Foundations | **PARTIAL** | Phase 6B added a READY front-door module (`root-05-m00-kubernetes-mental-model` — full lesson/demo/knowledge-check/guided-exercise/lab/reflection) sequenced before the existing 20-scenario bare-lab bank (`root-05-m01`), which now sits behind it. |
|
||||
| 6 | Kubernetes Application Workloads | **PARTIAL** | Phase 6B added a READY `root-06-m02-deployments-scaling-updates` and a PARTIAL `root-06-m03-jobs-cronjobs-daemonsets`, both in front of the existing bare-lab bank. |
|
||||
| 7 | Kubernetes Networking & Configuration | **PARTIAL** | Phase 6B added a READY `root-07-m03-services-and-service-discovery` and a PARTIAL `root-07-m04-ingress-networkpolicy-foundations`. The existing configuration bank (`root-07-m02`) still has no authored teaching content — an honest, documented gap. |
|
||||
| 8 | Kubernetes Storage & Scheduling | **PARTIAL** | Phase 6B wired the real scenario content the inventory identified (PV/PVC, StorageClass, taints/tolerations, PDB, quotas/limits) into two new PARTIAL modules — moved off PLANNED for the first time. |
|
||||
| 9 | Kubernetes Security | **PARTIAL** | Phase 6B built the 22-scenario CKS-style bank into four modules (Security Model & SecurityContext — READY; ServiceAccounts & RBAC, Network & Supply Chain Security, Cluster Hardening & Runtime — all PARTIAL), classifying scenarios CORE/GUIDED/INDEPENDENT/ADVANCED/ASSESSMENT-BANK rather than exposing them as 22 disconnected tasks — moved off PLANNED. |
|
||||
| 10 | Kubernetes Troubleshooting & Observability | **PARTIAL** | The Phase 6A gold-standard module (READY, unmodified) plus a new PARTIAL `root-10-m03-troubleshooting-framework` (the generalized Observe/Evidence/Hypothesis/Test/Repair/Validate/Prevent framework) and the existing bare-lab bank, now sequenced last. |
|
||||
| 11 | DevOps & Platform Engineering Foundations | **PLANNED** | No CI/CD/IaC/GitOps scenario content exists anywhere in this repository — unchanged, out of Phase 6B's authoring scope. |
|
||||
| 12 | RootNode Real Ops | **PARTIAL** | Phase 6B added an incident-response framing lesson and a post-incident reflection wrapping the existing 4-scenario module — the incidents themselves are unchanged, still deliberately un-guided. |
|
||||
| 13 | ROOT Capstone — Operate and Recover a Production-Like Environment | **PARTIAL** | Phase 6B added a READY Capstone Readiness module (orientation, no answers) sequenced before the existing capstone assessment module — see `docs/ROOT_CAPSTONE_READINESS.md`. |
|
||||
|
||||
No course is marked READY at the course level in Phase 6A — that's honest: a course only earns
|
||||
READY once every module in it has real teaching content, and Phase 6A deliberately built exactly
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function CurriculumView() {
|
||||
{mod.estimated_duration_minutes && <p className={styles.muted}>Estimated teaching time: ~{mod.estimated_duration_minutes} min</p>}
|
||||
{mod.objectives?.length > 0 && <ul>{mod.objectives.map((o, i) => <li key={i}>{o}</li>)}</ul>}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Type</th><th>Title</th><th>Mapped</th><th>Status</th>{studentCourses && <th>Student</th>}</tr></thead>
|
||||
<thead><tr><th>#</th><th>Type</th><th>Title</th><th>Mapped</th><th>Required</th><th>Status</th>{studentCourses && <th>Student</th>}</tr></thead>
|
||||
<tbody>
|
||||
{mod.activities.map((a) => (
|
||||
<tr key={a.activity_id}>
|
||||
@@ -81,6 +81,7 @@ export default function CurriculumView() {
|
||||
<td>{ACTIVITY_LABELS[a.activity_type] || a.activity_type}</td>
|
||||
<td>{a.title}</td>
|
||||
<td className={styles.muted}>{a.scenario_id || a.assessment_definition_id || '—'}</td>
|
||||
<td className={styles.muted}>{a.is_required === false ? 'optional' : 'required'}</td>
|
||||
<td>{statusPill(a.status)}</td>
|
||||
{studentCourses && (
|
||||
<td>{progressFor(a.activity_id)?.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>done</span> : <span className={styles.muted}>—</span>}</td>
|
||||
|
||||
@@ -163,6 +163,7 @@ function ActivityRow({ activity, onOpen }) {
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onOpen} style={{ textAlign: 'left' }}>
|
||||
<span className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}</span>{' — '}{activity.title}
|
||||
{activity.is_required === false && <span className={styles.hint}> (optional)</span>}
|
||||
</button>
|
||||
{activity.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>done</span> : activity.estimated_duration_minutes ? <span className={styles.hint}>~{activity.estimated_duration_minutes} min</span> : null}
|
||||
</div>
|
||||
@@ -186,7 +187,8 @@ function ActivityDetail({ activity, onLaunchLab, onChange }) {
|
||||
{activity.complete && <span className={`${styles.pill} ${styles.pillOk}`}>complete</span>}
|
||||
</div>
|
||||
<p className={styles.stageCount}>{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}
|
||||
{activity.estimated_duration_minutes && ` · ~${activity.estimated_duration_minutes} min`}</p>
|
||||
{activity.estimated_duration_minutes && ` · ~${activity.estimated_duration_minutes} min`}
|
||||
{activity.is_required === false && ' · optional'}</p>
|
||||
|
||||
{activity.body ? (
|
||||
<div className="md" style={{ marginTop: '1rem' }}>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 6B §23 ROOT_ curriculum expansion dress rehearsal: brings up the real
|
||||
# dev stack, bootstraps an owner, runs test-phase6b-root-course-rehearsal.js (real k3s Lab, real
|
||||
# kubectl, real classroom tooling), tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "P6B Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase6b-root-course-rehearsal.js
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6B §23 — the ROOT_ curriculum expansion dress rehearsal. One continuous realistic
|
||||
// student narrative through a brand-NEW Phase 6B module (ROOT 07's Services & Service
|
||||
// Discovery, not the Phase 6A gold module — that one already has its own rehearsal), using a
|
||||
// REAL k3s Lab end to end, plus a short instructor narrative proving the Curriculum view,
|
||||
// Prepare Labs, and classroom/help tooling all still work against the expanded curriculum. Same
|
||||
// conventions as test-phase6a-course-rehearsal.js: assert-based, no framework, real k3s boots.
|
||||
//
|
||||
// Run via run-phase6b-root-course-rehearsal.sh, which brings up platform/docker-compose.dev.yml
|
||||
// first.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
const SERVICES_MODULE = 'root-07-m03-services-and-service-discovery';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
function moduleFrom(tree, id) { for (const c of tree) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 6B ROOT_ curriculum expansion dress rehearsal — slow, real k3s boots\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
const { body: created } = await owner.request('POST', '/api/admin/students', {
|
||||
display_name: 'Rehearsal 6B Student', login_identifier: `p6b-course-${RUN_ID}`,
|
||||
});
|
||||
const session = makeSession(GW);
|
||||
let enrollmentId, envId;
|
||||
|
||||
await step('student activates credentials and logs in', async () => {
|
||||
const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await step('an instructor enrolls the student in ROOT_', async () => {
|
||||
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: created.student_id, program_id: PROGRAM, status: 'active' });
|
||||
assert.equal(status, 201);
|
||||
enrollmentId = body.enrollment_id;
|
||||
});
|
||||
|
||||
// Real Foundation completion via a real Lab, so root-06 (and therefore root-07) unlock.
|
||||
const ROOT05_ALL = ['namespaces-basics', 'pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq',
|
||||
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
|
||||
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
|
||||
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
|
||||
const ROOT06_ALL = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq',
|
||||
'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'deployment-rollback'];
|
||||
|
||||
await step('student launches a REAL Lab and completes ROOT 05 + ROOT 06 to unlock ROOT 07\'s new Services module', async () => {
|
||||
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(launch.status, 200);
|
||||
assert.equal(launch.body.status, 'running');
|
||||
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
|
||||
envId = envRow.environment_id;
|
||||
for (const sid of [...ROOT05_ALL, ...ROOT06_ALL]) {
|
||||
const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId });
|
||||
assert.equal(r.status, 200, `completing ${sid} should 200`);
|
||||
}
|
||||
// Both new teaching modules' own lesson/demo/knowledge-check/guided-exercise/reflection
|
||||
// activities are manual_marker — completing only the underlying scenarios (above) is not
|
||||
// enough to make root-05/root-06 COURSE-complete, which is what actually unlocks root-07.
|
||||
for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces', 'a06-reflection']) {
|
||||
const r = await session.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
}
|
||||
for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy', 'a06-reflection']) {
|
||||
const r = await session.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
}
|
||||
// root-06 the COURSE (root-07's actual prerequisite) also includes m03 — its two scenarios
|
||||
// and two manual-marker activities.
|
||||
for (const sid of ['job-task', 'cronjob-task']) {
|
||||
const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId });
|
||||
assert.equal(r.status, 200);
|
||||
}
|
||||
for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a02-knowledge-check']) {
|
||||
const r = await session.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`);
|
||||
assert.equal(r.status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('the new ROOT 07 Services & Service Discovery module is now unlocked with real lesson content', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const mod = moduleFrom(body.courses, SERVICES_MODULE);
|
||||
assert.equal(mod.locked, false);
|
||||
assert.equal(mod.activities[0].activity_type, 'lesson');
|
||||
assert.ok(mod.activities[0].body.includes('Service'), 'the real lesson content must actually be served');
|
||||
});
|
||||
|
||||
await step('student reads the lesson/demonstration, completes the knowledge check and guided exercise', async () => {
|
||||
for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type']) {
|
||||
const { status } = await session.request('POST', `/api/student/activities/root-07-m03-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('student launches the module\'s Lab activity for real and runs real kubectl against it', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const mod = moduleFrom(body.courses, SERVICES_MODULE);
|
||||
const lab = mod.activities.find((a) => a.activity_type === 'lab');
|
||||
assert.equal(lab.scenario_id, 'expose-service-basics');
|
||||
assert.equal(lab.complete, false);
|
||||
|
||||
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
|
||||
const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/expose-service-basics/setup`);
|
||||
assert.equal(setup.status, 200);
|
||||
|
||||
// Real kubectl, run inside the student's actual disposable Lab container — solving the
|
||||
// scenario the same way a real student would from the terminal, not a simulated pass.
|
||||
const containerName = execSync(
|
||||
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envId}'"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
|
||||
).toString().trim();
|
||||
execSync(`docker exec ${containerName} kubectl expose deployment redis --name=redis-svc --port=6379`, { stdio: 'pipe' });
|
||||
|
||||
const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/expose-service-basics/validate`);
|
||||
assert.equal(validate.status, 200);
|
||||
assert.equal(validate.body.passed, true, `expected the real validation to pass: ${JSON.stringify(validate.body)}`);
|
||||
|
||||
const record = await session.request('POST', '/api/progress/record', { scenario_id: 'expose-service-basics', status: 'completed', environment_id: envId });
|
||||
assert.equal(record.status, 200);
|
||||
});
|
||||
|
||||
await step('the lab activity completes and the module finishes once the reflection is marked', async () => {
|
||||
const { body: before } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleFrom(before.courses, SERVICES_MODULE).activities.find((a) => a.activity_type === 'lab').complete, true);
|
||||
await session.request('POST', '/api/student/activities/root-07-m03-a06-reflection/complete');
|
||||
const { body: after } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleFrom(after.courses, SERVICES_MODULE).complete, true);
|
||||
});
|
||||
|
||||
await step('logout, login, Resume Learning, destroy the Lab, recreate it — academic progress survives all of it', async () => {
|
||||
await session.request('POST', '/api/logout');
|
||||
const returning = makeSession(GW);
|
||||
await returning.request('POST', '/api/student/login', { identifier: `p6b-course-${RUN_ID}`, password: 'RehearsalPass123!' });
|
||||
const { body: resumed } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleFrom(resumed.courses, SERVICES_MODULE).complete, true, 'progress must survive a real logout/login');
|
||||
|
||||
await owner.request('DELETE', `/api/admin/environments/${envId}`);
|
||||
const relaunch = await returning.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(relaunch.status, 200);
|
||||
assert.equal(relaunch.body.status, 'running');
|
||||
const { body: afterRecreate } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleFrom(afterRecreate.courses, SERVICES_MODULE).complete, true, 'academic progress must survive Lab destroy/recreate');
|
||||
});
|
||||
|
||||
// ── Instructor narrative ────────────────────────────────────────────────────────────────
|
||||
await step('instructor inspects the curriculum structure and this module\'s teaching notes', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
assert.equal(status, 200);
|
||||
const mod = moduleFrom(body, SERVICES_MODULE);
|
||||
assert.equal(mod.activities.length, 6);
|
||||
assert.ok(mod.activities[0].instructor_notes, 'instructor must see instructor notes the student view never does');
|
||||
});
|
||||
|
||||
await step('instructor sees this specific student\'s real progress through the new module', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(moduleFrom(body, SERVICES_MODULE).complete, true);
|
||||
});
|
||||
|
||||
await step('a real classroom cohort can still be prepared and observed alongside the expanded curriculum', async () => {
|
||||
const cohortId = `p6b-rehearsal-cohort-${RUN_ID}`;
|
||||
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P6B Rehearsal Cohort' });
|
||||
assert.equal(create.status, 201);
|
||||
const { body: student2 } = await owner.request('POST', '/api/admin/students', { display_name: 'P6B Cohort Student', login_identifier: `p6b-cohort-student-${RUN_ID}` });
|
||||
const addStudent = await owner.request('POST', `/api/admin/students/${student2.student_id}/cohort`, { cohort_id: cohortId });
|
||||
assert.equal(addStudent.status, 200);
|
||||
const assignCurriculum = await owner.request('POST', `/api/admin/cohorts/${cohortId}/curriculum`, { curriculum_path: 'rootnode-path' });
|
||||
assert.equal(assignCurriculum.status, 200);
|
||||
const prepare = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
|
||||
assert.equal(prepare.status, 200);
|
||||
const summary = await owner.request('GET', `/api/admin/cohorts/${cohortId}/learning`);
|
||||
assert.equal(summary.status, 200, 'classroom/learning tooling must still function against the expanded curriculum');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+19
-6
@@ -14,7 +14,9 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
(Phase 5A), self-paced programs/enrollment + the website integration boundary (Phase 5B), and
|
||||
a provider-neutral enrollment-communications pipeline (activation/enrollment-confirmation/
|
||||
password-reset email, Phase 5C), the ROOT_ course/module/activity content model layered over
|
||||
curriculum/assessments (Phase 6A), and the instructor/admin control plane. No k3s, no
|
||||
curriculum/assessments (Phase 6A) with real teaching content across most of the Kubernetes
|
||||
courses and a server-side capstone unlock enforcement fix (Phase 6B), and the instructor/admin
|
||||
control plane. No k3s, no
|
||||
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
|
||||
booting an unused k3s cluster just to serve these APIs. Design:
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration),
|
||||
@@ -24,7 +26,8 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary),
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` (notifications, email delivery),
|
||||
`docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` (course/module/activity content model, gold-standard
|
||||
module). Operational reference: `api/README.md`.
|
||||
module), `docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md` (curriculum authoring across ROOT
|
||||
05-10/12/Capstone, the capstone unlock fix). Operational reference: `api/README.md`.
|
||||
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
|
||||
routing derived from a server-controlled session — not a client-suppliable header/param — and,
|
||||
as of Phase 3B, server-side gating of progressive hint reveal). Design:
|
||||
@@ -88,15 +91,25 @@ further instructor accounts from the Instructors tab).
|
||||
- `test/e2e-phase6a.js` — course/module/activity hierarchy and ordering, prerequisite-based
|
||||
locking, all three completion behaviors (manual marker/scenario/assessment-derived), resume,
|
||||
cross-student isolation, RBAC for self-paced (no-cohort) students, and draft-content hiding.
|
||||
- `test/e2e-phase6b.js` — hierarchy for the 13 new modules across ROOT 05-10/12/Capstone,
|
||||
prerequisite chains through the newly-inserted front-door modules, the optional-vs-required
|
||||
(`is_required`) completion rule, and the capstone server-side unlock fix in both directions
|
||||
(a direct bypass attempt rejected, a legitimately unlocked attempt still accepted).
|
||||
|
||||
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
|
||||
gone. They're slow (real k3s boots, `e2e-phase3b.js` also runs a real API restart) and need the
|
||||
full stack up. None of them substitute for the manual real-Lab-image checks (node-pty, terminal
|
||||
full stack up. Run each suite against its own freshly-reset stack, not chained one after another
|
||||
against the same Postgres — several routes (the admin dashboard's running-lab count, a course's
|
||||
percent-complete) read GLOBAL state on purpose, and chaining suites produces false failures from
|
||||
shared state, not real regressions (found and documented during Phase 6A's own regression pass).
|
||||
None of them substitute for the manual real-Lab-image checks (node-pty, terminal
|
||||
WebSocket, `kubectl`, scenario validation) in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 —
|
||||
those need a browser-equivalent client and aren't automated here. The one exception is Phase
|
||||
6A's gold-standard module, which DOES get a real-Lab dress rehearsal —
|
||||
`infra/scripts/drills/run-phase6a-course-rehearsal.sh`.
|
||||
those need a browser-equivalent client and aren't automated here. Two exceptions get a real-Lab
|
||||
dress rehearsal instead: Phase 6A's gold-standard module
|
||||
(`infra/scripts/drills/run-phase6a-course-rehearsal.sh`) and Phase 6B's curriculum expansion,
|
||||
including a real `kubectl expose` and a real instructor/classroom narrative
|
||||
(`infra/scripts/drills/run-phase6b-root-course-rehearsal.sh`).
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
-- Phase 6B — curriculum authoring & coverage expansion. Additive on top of Phase 6A's
|
||||
-- 022_course_content.sql: new teaching modules for ROOT 05/06/07/08/09/10/12/Capstone, built
|
||||
-- from real, previously-unwired scenario content (see docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md
|
||||
-- for the evidence and reasoning behind every placement). No existing row is deleted; a small
|
||||
-- number of existing rows have their `sequence`/`prerequisites` updated so a new teaching
|
||||
-- module sits in front of the bare-lab bank it supersedes as the "front door" — never their
|
||||
-- scenario_id/activity_type/completion_behavior, so no student's derived completion state
|
||||
-- changes meaning. See docs/COURSE_CONTENT_MODEL.md §"content revision" for what does and
|
||||
-- doesn't require this kind of change.
|
||||
|
||||
-- ── is_required: Phase 6B §18/§27's optional-vs-required distinction. Defaults true, so every
|
||||
-- Phase 6A activity is unaffected (required, as it always implicitly was) — this is additive,
|
||||
-- not a redefinition. An optional activity (independent-practice duplicates, advanced/CKS-exam-
|
||||
-- style extras — see docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md's ADVANCED/INDEPENDENT
|
||||
-- classifications) still counts toward a module's percent_complete display but NOT toward
|
||||
-- whether the module is considered complete — see course-progress.js's totals().
|
||||
ALTER TABLE module_activities ADD COLUMN IF NOT EXISTS is_required BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- ── Course status: ROOT 08/09 move from PLANNED to PARTIAL — real modules exist now ─────────
|
||||
UPDATE courses SET status = 'partial', updated_at = now() WHERE course_id IN ('root-08-kubernetes-storage-scheduling', 'root-09-kubernetes-security');
|
||||
|
||||
-- ── Resequencing: new teaching modules become the front door; existing bare-lab banks move
|
||||
-- behind them. Sequence/prerequisites only — activities inside the existing modules are
|
||||
-- untouched. ──────────────────────────────────────────────────────────────────────────────
|
||||
UPDATE course_modules SET sequence = 2, prerequisites = '{root-05-m00-kubernetes-mental-model}', version = version + 1, updated_at = now()
|
||||
WHERE module_id = 'root-05-m01-pods-and-kubectl-basics';
|
||||
UPDATE course_modules SET sequence = 3, prerequisites = '{root-06-m02-deployments-scaling-updates}', version = version + 1, updated_at = now()
|
||||
WHERE module_id = 'root-06-m01-deployments-and-workloads';
|
||||
UPDATE course_modules SET sequence = 3, prerequisites = '{root-07-m03-services-and-service-discovery}', version = version + 1, updated_at = now()
|
||||
WHERE module_id = 'root-07-m01-services-and-networking';
|
||||
-- root-07-m02 (configuration bank) is intentionally left UNGATED (no new configuration-teaching
|
||||
-- module was authored this phase — an honest PARTIAL gap, see
|
||||
-- docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md) but still needs to move behind the new
|
||||
-- teaching modules in display order, to avoid colliding with m04's sequence=2.
|
||||
UPDATE course_modules SET sequence = 4, version = version + 1, updated_at = now()
|
||||
WHERE module_id = 'root-07-m02-configuration-and-secrets';
|
||||
UPDATE course_modules SET sequence = 3, prerequisites = '{root-10-m03-troubleshooting-framework}', version = version + 1, updated_at = now()
|
||||
WHERE module_id = 'root-10-m02-general-troubleshooting';
|
||||
|
||||
INSERT INTO course_modules (module_id, course_id, title, description, sequence, objectives, prerequisites, estimated_duration_minutes, status) VALUES
|
||||
('root-05-m00-kubernetes-mental-model', 'root-05-kubernetes-foundations', 'The Kubernetes Mental Model & Core Objects',
|
||||
'What Kubernetes actually solves, the control-plane/worker/desired-state model, and Pods/Namespaces/labels through kubectl — the real front door to ROOT 05, taught before the bare kubectl-fluency lab bank.',
|
||||
1, '{"Explain what problem Kubernetes solves and why running containers by hand does not scale","Describe the control-plane/worker split and what \"desired state\" means in practice","Navigate Pods, Namespaces, and labels with kubectl confidently"}',
|
||||
'{}', 45, 'ready'),
|
||||
|
||||
('root-06-m02-deployments-scaling-updates', 'root-06-kubernetes-application-workloads', 'Deployments, Scaling & Updates',
|
||||
'Why Deployments/ReplicaSets exist, how a rolling update actually progresses, and how to reason about rollback — taught before the bare Deployments lab bank.',
|
||||
1, '{"Explain why a Deployment manages Pods instead of creating them directly","Perform and reason about a rolling update in progress","Explain what a rollback actually restores and when to use one"}',
|
||||
'{root-05-m00-kubernetes-mental-model}', 55, 'ready'),
|
||||
('root-06-m03-jobs-cronjobs-daemonsets', 'root-06-kubernetes-application-workloads', 'Jobs, CronJobs & DaemonSets',
|
||||
'Distinguishing one-shot Jobs, scheduled CronJobs, and per-node DaemonSets from long-running Deployments.',
|
||||
2, '{"Choose the right workload primitive for a one-shot task, a scheduled task, and a per-node agent","Explain why a Job differs from a Deployment in restart/completion semantics"}',
|
||||
'{root-06-m02-deployments-scaling-updates}', 30, 'partial'),
|
||||
|
||||
('root-07-m03-services-and-service-discovery', 'root-07-kubernetes-networking-config', 'Services & Service Discovery',
|
||||
'The Service/Endpoints/DNS request path and choosing the right Service type — taught before the bare Services lab bank.',
|
||||
1, '{"Trace how a request actually reaches a Pod through a Service","Choose ClusterIP vs NodePort correctly for a given requirement","Explain why a Service with zero endpoints looks like network failure from outside"}',
|
||||
'{root-06-m02-deployments-scaling-updates}', 45, 'ready'),
|
||||
('root-07-m04-ingress-networkpolicy-foundations', 'root-07-kubernetes-networking-config', 'Ingress & NetworkPolicy Foundations',
|
||||
'How Ingress routes external traffic, and what a NetworkPolicy architecturally does (the security posture comes later, in ROOT 09).',
|
||||
2, '{"Explain how Ingress differs from a Service and why it exists","Describe what a NetworkPolicy actually controls at the architectural level"}',
|
||||
'{root-07-m03-services-and-service-discovery}', 30, 'partial'),
|
||||
|
||||
('root-08-m01-storage-fundamentals', 'root-08-kubernetes-storage-scheduling', 'Storage Fundamentals',
|
||||
'Ephemeral vs. persistent storage, and how PersistentVolumes/PersistentVolumeClaims/StorageClasses fit together.',
|
||||
1, '{"Explain the difference between ephemeral and persistent storage in Kubernetes","Describe how a PVC binds to a PV, directly or via dynamic provisioning"}',
|
||||
'{root-07-m03-services-and-service-discovery}', 35, 'partial'),
|
||||
('root-08-m02-scheduling-resource-governance', 'root-08-kubernetes-storage-scheduling', 'Scheduling & Resource Governance',
|
||||
'How the scheduler places Pods, and how quotas/limits/priority/disruption budgets shape that placement.',
|
||||
2, '{"Explain how taints/tolerations and affinity influence scheduling decisions","Explain why a PodDisruptionBudget matters during a voluntary disruption","Describe what a ResourceQuota/LimitRange enforces at the namespace level"}',
|
||||
'{root-08-m01-storage-fundamentals}', 40, 'partial'),
|
||||
|
||||
('root-09-m01-security-model-and-securitycontext', 'root-09-kubernetes-security', 'Security Model & SecurityContext',
|
||||
'Defense in depth, least privilege, and hardening a Pod with SecurityContext (non-root, capabilities, read-only filesystem).',
|
||||
1, '{"Explain defense in depth and least privilege as applied to a running Pod","Configure a Pod/container SecurityContext to run non-root with a read-only filesystem and no unnecessary Linux capabilities"}',
|
||||
'{root-06-m02-deployments-scaling-updates}', 50, 'ready'),
|
||||
('root-09-m02-serviceaccounts-and-rbac', 'root-09-kubernetes-security', 'ServiceAccounts & RBAC',
|
||||
'Identity for workloads and least-privilege permissions via Roles/ClusterRoles.',
|
||||
2, '{"Explain why a Pod needs its own identity separate from the human operating it","Write a least-privilege Role/RoleBinding for a specific, narrow permission"}',
|
||||
'{root-09-m01-security-model-and-securitycontext}', 40, 'partial'),
|
||||
('root-09-m03-network-and-supplychain-security', 'root-09-kubernetes-security', 'Network & Supply Chain Security',
|
||||
'NetworkPolicy as a security control (not just an architecture concept), image/registry trust, and TLS ingress.',
|
||||
3, '{"Apply a default-deny NetworkPolicy and open only the access a workload actually needs","Explain why an image-pull secret and a trusted registry matter for supply-chain security"}',
|
||||
'{root-09-m02-serviceaccounts-and-rbac,root-07-m04-ingress-networkpolicy-foundations}', 40, 'partial'),
|
||||
('root-09-m04-cluster-hardening-and-runtime', 'root-09-kubernetes-security', 'Cluster Hardening & Runtime Security',
|
||||
'Pod Security Admission and a survey of cluster-hardening/runtime-sandboxing topics — deliberately advanced/optional, not daily-driver core.',
|
||||
4, '{"Explain what Pod Security Admission enforces at the namespace level and how it differs from the old PodSecurityPolicy"}',
|
||||
'{root-09-m03-network-and-supplychain-security}', 25, 'partial'),
|
||||
|
||||
('root-10-m03-troubleshooting-framework', 'root-10-kubernetes-troubleshooting-observability', 'The RootNode Troubleshooting Framework',
|
||||
'A repeatable framework — Observe, Gather Evidence, Form Hypothesis, Test, Repair, Validate, Prevent Recurrence — applied to a CrashLoopBackOff and a broken Service.',
|
||||
2, '{"Apply a repeatable troubleshooting framework instead of guessing under pressure","Diagnose a CrashLoopBackOff from evidence, not assumption","Diagnose a broken Service-to-Pod selector mismatch"}',
|
||||
'{root-10-m01-health-probes-and-troubleshooting}', 40, 'partial'),
|
||||
|
||||
('root-capstone-m02-capstone-readiness', 'root-capstone', 'Capstone Readiness',
|
||||
'What the RootNode Capstone measures, what it requires, how the timed/randomized draw works, and how to manage your time — without publishing the draw or the answers.',
|
||||
1, '{"Explain what skills the RootNode Capstone measures and why","Describe how the timed, partially-randomized scenario draw works","Apply the RootNode troubleshooting framework under time pressure"}',
|
||||
'{}', 20, 'ready')
|
||||
ON CONFLICT (module_id) DO NOTHING;
|
||||
|
||||
-- The existing capstone assessment module keeps its original sequence position (2) relative to
|
||||
-- the new readiness module (1) — readiness comes first, matching that it now needs no
|
||||
-- prerequisite of its own (the course-level root-12-real-ops -> root-capstone chain still
|
||||
-- gates entry into this course at all; the actual capstone START is gated server-side by
|
||||
-- curriculum-rules.js's unlock_rule regardless of whether this readiness module was viewed —
|
||||
-- see docs/ROOT_CAPSTONE_READINESS.md for why those two gates are deliberately independent).
|
||||
UPDATE course_modules SET sequence = 2 WHERE module_id = 'root-capstone-m01-operate-and-recover';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- New module_activities — gold-standard modules (lesson+demo+knowledge_check+guided_exercise+
|
||||
-- lab+reflection) for root-05-m00, root-06-m02, root-07-m03, root-09-m01. PARTIAL modules get
|
||||
-- lesson+knowledge_check(+lab) only, honestly, per docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ROOT 05 m00 — 6 activities
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-05-m00-a01-lesson-mental-model', 'root-05-m00-kubernetes-mental-model', 1, 'lesson', 'What Kubernetes Solves & the Desired-State Model',
|
||||
'content/root/root-05-kubernetes-foundations/m00-mental-model/01-lesson-mental-model.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-05-m00-a02-demonstration-desired-state', 'root-05-m00-kubernetes-mental-model', 2, 'demonstration', 'Demonstration: Desired State Reconciliation',
|
||||
'content/root/root-05-kubernetes-foundations/m00-mental-model/02-demonstration-desired-state.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-05-m00-a03-knowledge-check', 'root-05-m00-kubernetes-mental-model', 3, 'knowledge_check', 'Knowledge Check: The Kubernetes Mental Model',
|
||||
'content/root/root-05-kubernetes-foundations/m00-mental-model/03-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-05-m00-a04-guided-exercise-namespaces', 'root-05-m00-kubernetes-mental-model', 4, 'guided_exercise', 'Guided Exercise: Planning a Namespace Layout',
|
||||
'content/root/root-05-kubernetes-foundations/m00-mental-model/04-guided-exercise-namespaces.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-05-m00-a05-lab-namespaces-basics', 'root-05-m00-kubernetes-mental-model', 5, 'lab', 'Launch Lab: Working with Namespaces',
|
||||
'content/root/root-05-kubernetes-foundations/m00-mental-model/05-lab-intro.md', 'namespaces-basics', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-05-m00-a06-reflection', 'root-05-m00-kubernetes-mental-model', 6, 'reflection', 'Reflection: Why Desired State Matters in Production',
|
||||
'content/root/root-05-kubernetes-foundations/m00-mental-model/06-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 06 m02 — 6 activities
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-06-m02-a01-lesson-deployments', 'root-06-m02-deployments-scaling-updates', 1, 'lesson', 'Why Deployments Exist & How a Rolling Update Progresses',
|
||||
'content/root/root-06-kubernetes-application-workloads/m02-deployments-scaling-updates/01-lesson-deployments.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-06-m02-a02-demonstration-rollout', 'root-06-m02-deployments-scaling-updates', 2, 'demonstration', 'Demonstration: Watching a Rollout, Good and Bad',
|
||||
'content/root/root-06-kubernetes-application-workloads/m02-deployments-scaling-updates/02-demonstration-rollout.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-06-m02-a03-knowledge-check', 'root-06-m02-deployments-scaling-updates', 3, 'knowledge_check', 'Knowledge Check: Deployments & Rollouts',
|
||||
'content/root/root-06-kubernetes-application-workloads/m02-deployments-scaling-updates/03-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-06-m02-a04-guided-exercise-rollout-strategy', 'root-06-m02-deployments-scaling-updates', 4, 'guided_exercise', 'Guided Exercise: Planning a Safe Rollout Strategy',
|
||||
'content/root/root-06-kubernetes-application-workloads/m02-deployments-scaling-updates/04-guided-exercise-rollout-strategy.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-06-m02-a05-lab-rolling-update', 'root-06-m02-deployments-scaling-updates', 5, 'lab', 'Launch Lab: Perform a Rolling Update',
|
||||
'content/root/root-06-kubernetes-application-workloads/m02-deployments-scaling-updates/05-lab-intro.md', 'rolling-update-task', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-06-m02-a05b-lab-deployment-rollback', 'root-06-m02-deployments-scaling-updates', 6, 'lab', 'Lab: Rolling Back a Deployment', NULL, 'deployment-rollback', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-06-m02-a06-reflection', 'root-06-m02-deployments-scaling-updates', 7, 'reflection', 'Reflection: Rollback Is a Plan, Not a Panic Button',
|
||||
'content/root/root-06-kubernetes-application-workloads/m02-deployments-scaling-updates/06-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 06 m03 — 4 activities (PARTIAL: lesson + knowledge_check + two labs)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-06-m03-a01-lesson-jobs-cronjobs-daemonsets', 'root-06-m03-jobs-cronjobs-daemonsets', 1, 'lesson', 'Jobs, CronJobs, and DaemonSets: The Other Workload Shapes',
|
||||
'content/root/root-06-kubernetes-application-workloads/m03-jobs-cronjobs-daemonsets/01-lesson-jobs-cronjobs-daemonsets.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-06-m03-a02-knowledge-check', 'root-06-m03-jobs-cronjobs-daemonsets', 2, 'knowledge_check', 'Knowledge Check: Choosing the Right Workload Primitive',
|
||||
'content/root/root-06-kubernetes-application-workloads/m03-jobs-cronjobs-daemonsets/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-06-m03-a03-lab-job-task', 'root-06-m03-jobs-cronjobs-daemonsets', 3, 'lab', 'Lab: Create a One-Shot Job', NULL, 'job-task', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-06-m03-a04-lab-cronjob-task', 'root-06-m03-jobs-cronjobs-daemonsets', 4, 'lab', 'Lab: Create a CronJob', NULL, 'cronjob-task', NULL, 'auto_scenario', 15, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 07 m03 — 6 activities
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-07-m03-a01-lesson-services', 'root-07-m03-services-and-service-discovery', 1, 'lesson', 'The Service/Endpoints/DNS Request Path',
|
||||
'content/root/root-07-kubernetes-networking-config/m03-services-and-service-discovery/01-lesson-services.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-07-m03-a02-demonstration-zero-endpoints', 'root-07-m03-services-and-service-discovery', 2, 'demonstration', 'Demonstration: A Service With Zero Endpoints',
|
||||
'content/root/root-07-kubernetes-networking-config/m03-services-and-service-discovery/02-demonstration-zero-endpoints.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-07-m03-a03-knowledge-check', 'root-07-m03-services-and-service-discovery', 3, 'knowledge_check', 'Knowledge Check: Services & Service Discovery',
|
||||
'content/root/root-07-kubernetes-networking-config/m03-services-and-service-discovery/03-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-07-m03-a04-guided-exercise-service-type', 'root-07-m03-services-and-service-discovery', 4, 'guided_exercise', 'Guided Exercise: Choosing the Right Service Type',
|
||||
'content/root/root-07-kubernetes-networking-config/m03-services-and-service-discovery/04-guided-exercise-service-type.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-07-m03-a05-lab-expose-service', 'root-07-m03-services-and-service-discovery', 5, 'lab', 'Launch Lab: Exposing an Application',
|
||||
'content/root/root-07-kubernetes-networking-config/m03-services-and-service-discovery/05-lab-intro.md', 'expose-service-basics', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-07-m03-a06-reflection', 'root-07-m03-services-and-service-discovery', 6, 'reflection', 'Reflection: Service Discovery Is Infrastructure, Not Magic',
|
||||
'content/root/root-07-kubernetes-networking-config/m03-services-and-service-discovery/06-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 07 m01 (existing bare bank) — extend with the two newly-wired networking scenarios
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-07-m01-a03-nodeport-task', 'root-07-m01-services-and-networking', 3, 'lab', 'Lab: Expose a Deployment via NodePort', NULL, 'nodeport-task', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-07-m01-a04-dns-resolution-mcq', 'root-07-m01-services-and-networking', 4, 'lab', 'Lab: Kubernetes DNS Resolution', NULL, 'dns-resolution-mcq', NULL, 'auto_scenario', 15, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 07 m04 — 4 activities (PARTIAL)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-07-m04-a01-lesson-ingress-networkpolicy', 'root-07-m04-ingress-networkpolicy-foundations', 1, 'lesson', 'Ingress and NetworkPolicy: What They Architecturally Do',
|
||||
'content/root/root-07-kubernetes-networking-config/m04-ingress-networkpolicy-foundations/01-lesson-ingress-networkpolicy.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-07-m04-a02-knowledge-check', 'root-07-m04-ingress-networkpolicy-foundations', 2, 'knowledge_check', 'Knowledge Check: Ingress & NetworkPolicy Foundations',
|
||||
'content/root/root-07-kubernetes-networking-config/m04-ingress-networkpolicy-foundations/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-07-m04-a03-lab-ingress-task', 'root-07-m04-ingress-networkpolicy-foundations', 3, 'lab', 'Lab: Create an Ingress Resource', NULL, 'ingress-task', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-07-m04-a04-lab-network-policy', 'root-07-m04-ingress-networkpolicy-foundations', 4, 'lab', 'Lab: Isolate Traffic with NetworkPolicy', NULL, 'network-policy', NULL, 'auto_scenario', 30, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 08 m01 — 4 activities (PARTIAL)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-08-m01-a01-lesson-storage-fundamentals', 'root-08-m01-storage-fundamentals', 1, 'lesson', 'Ephemeral vs. Persistent Storage',
|
||||
'content/root/root-08-kubernetes-storage-scheduling/m01-storage-fundamentals/01-lesson-storage-fundamentals.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-08-m01-a02-knowledge-check', 'root-08-m01-storage-fundamentals', 2, 'knowledge_check', 'Knowledge Check: Storage Fundamentals',
|
||||
'content/root/root-08-kubernetes-storage-scheduling/m01-storage-fundamentals/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-08-m01-a03-lab-emptydir', 'root-08-m01-storage-fundamentals', 3, 'lab', 'Lab: Shared emptyDir Volume Between Containers', NULL, 'emptydir-pod', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-08-m01-a04-lab-pv-pvc', 'root-08-m01-storage-fundamentals', 4, 'lab', 'Lab: Persistent Storage with PV and PVC', NULL, 'pv-pvc-mount', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-08-m01-a05-lab-pvc-dynamic', 'root-08-m01-storage-fundamentals', 5, 'lab', 'Lab: Dynamic PVC Provisioning', NULL, 'pvc-dynamic-task', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-08-m01-a06-lab-storageclass', 'root-08-m01-storage-fundamentals', 6, 'lab', 'Lab: StorageClass Reclaim Policies', NULL, 'storageclass-mcq', NULL, 'auto_scenario', 15, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 08 m02 — 8 activities (PARTIAL)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-08-m02-a01-lesson-scheduling', 'root-08-m02-scheduling-resource-governance', 1, 'lesson', 'How the Scheduler Places Pods, and How Governance Shapes It',
|
||||
'content/root/root-08-kubernetes-storage-scheduling/m02-scheduling-resource-governance/01-lesson-scheduling.md', NULL, NULL, 'manual_marker', 20, 'ready'),
|
||||
('root-08-m02-a02-knowledge-check', 'root-08-m02-scheduling-resource-governance', 2, 'knowledge_check', 'Knowledge Check: Scheduling & Resource Governance',
|
||||
'content/root/root-08-kubernetes-storage-scheduling/m02-scheduling-resource-governance/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-08-m02-a03-lab-node-label-selector', 'root-08-m02-scheduling-resource-governance', 3, 'lab', 'Lab: Node Labels and nodeSelector', NULL, 'node-label-selector', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-08-m02-a04-lab-node-taint-toleration', 'root-08-m02-scheduling-resource-governance', 4, 'lab', 'Lab: Taints and Tolerations', NULL, 'node-taint-toleration', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-08-m02-a05-lab-pod-affinity', 'root-08-m02-scheduling-resource-governance', 5, 'lab', 'Lab: Pod Affinity vs Anti-Affinity', NULL, 'pod-affinity-mcq', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-08-m02-a06-lab-priority-class', 'root-08-m02-scheduling-resource-governance', 6, 'lab', 'Lab: Pod Priority and PriorityClass', NULL, 'priority-class-task', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-08-m02-a07-lab-pdb', 'root-08-m02-scheduling-resource-governance', 7, 'lab', 'Lab: Configure a PodDisruptionBudget', NULL, 'pod-disruption-budget-task', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-08-m02-a08-lab-resource-quota', 'root-08-m02-scheduling-resource-governance', 8, 'lab', 'Lab: ResourceQuota for a Namespace', NULL, 'resource-quota-ns', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-08-m02-a09-lab-limitrange', 'root-08-m02-scheduling-resource-governance', 9, 'lab', 'Lab: LimitRange for Default Container Limits', NULL, 'limitrange-task', NULL, 'auto_scenario', 20, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 09 m01 — 8 activities (READY: full gold-standard sequence + independent/guided/advanced practice)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-09-m01-a01-lesson-security-model', 'root-09-m01-security-model-and-securitycontext', 1, 'lesson', 'Defense in Depth & the Kubernetes Security Model',
|
||||
'content/root/root-09-kubernetes-security/m01-security-model-and-securitycontext/01-lesson-security-model.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-09-m01-a02-demonstration-securitycontext', 'root-09-m01-security-model-and-securitycontext', 2, 'demonstration', 'Demonstration: A Pod Before and After Hardening',
|
||||
'content/root/root-09-kubernetes-security/m01-security-model-and-securitycontext/02-demonstration-securitycontext.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-09-m01-a03-knowledge-check', 'root-09-m01-security-model-and-securitycontext', 3, 'knowledge_check', 'Knowledge Check: Security Model & SecurityContext',
|
||||
'content/root/root-09-kubernetes-security/m01-security-model-and-securitycontext/03-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-09-m01-a04-guided-exercise-hardening-plan', 'root-09-m01-security-model-and-securitycontext', 4, 'guided_exercise', 'Guided Exercise: Planning a Pod Hardening Checklist',
|
||||
'content/root/root-09-kubernetes-security/m01-security-model-and-securitycontext/04-guided-exercise-hardening-plan.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-09-m01-a05-lab-non-root', 'root-09-m01-security-model-and-securitycontext', 5, 'lab', 'Lab: Enforce Non-Root Container', NULL, 'cks-non-root-enforce', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m01-a06-lab-drop-capabilities', 'root-09-m01-security-model-and-securitycontext', 6, 'lab', 'Lab: Drop Linux Capabilities', NULL, 'cks-drop-capabilities', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m01-a07-lab-readonly-filesystem', 'root-09-m01-security-model-and-securitycontext', 7, 'lab', 'Lab: Read-Only Root Filesystem', NULL, 'cks-readonly-filesystem', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-09-m01-a08-lab-pod-security-context', 'root-09-m01-security-model-and-securitycontext', 8, 'lab', 'Lab: Pod Security Context (CKS)', NULL, 'cks-pod-security-context', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m01-a09-reflection', 'root-09-m01-security-model-and-securitycontext', 9, 'reflection', 'Reflection: Least Privilege Is a Default, Not an Afterthought',
|
||||
'content/root/root-09-kubernetes-security/m01-security-model-and-securitycontext/09-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-09-m01-a10-lab-seccomp-guided', 'root-09-m01-security-model-and-securitycontext', 10, 'lab', 'Lab: Seccomp Profiles (guided practice)', NULL, 'cks-seccomp-profile', NULL, 'auto_scenario', 30, 'ready'),
|
||||
('root-09-m01-a11-lab-apparmor-advanced', 'root-09-m01-security-model-and-securitycontext', 11, 'lab', 'Lab: AppArmor Profiles (advanced/optional)', NULL, 'cks-apparmor-profile', NULL, 'auto_scenario', 30, 'ready'),
|
||||
('root-09-m01-a12-lab-pod-security-context-independent', 'root-09-m01-security-model-and-securitycontext', 12, 'lab', 'Lab: Pod Security Context (independent practice)', NULL, 'pod-security-context', NULL, 'auto_scenario', 20, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 09 m02 — 6 activities (PARTIAL: lesson + KC + core labs + one independent-practice dup)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-09-m02-a01-lesson-serviceaccounts-rbac', 'root-09-m02-serviceaccounts-and-rbac', 1, 'lesson', 'Identity for Workloads: ServiceAccounts & RBAC',
|
||||
'content/root/root-09-kubernetes-security/m02-serviceaccounts-and-rbac/01-lesson-serviceaccounts-rbac.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-09-m02-a02-knowledge-check', 'root-09-m02-serviceaccounts-and-rbac', 2, 'knowledge_check', 'Knowledge Check: ServiceAccounts & RBAC',
|
||||
'content/root/root-09-kubernetes-security/m02-serviceaccounts-and-rbac/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-09-m02-a03-lab-serviceaccount-pod', 'root-09-m02-serviceaccounts-and-rbac', 3, 'lab', 'Lab: ServiceAccount for Pod API Access', NULL, 'serviceaccount-pod', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m02-a04-lab-automount-token', 'root-09-m02-serviceaccounts-and-rbac', 4, 'lab', 'Lab: Disable ServiceAccount Token', NULL, 'cks-automount-token', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-09-m02-a05-lab-rbac-role', 'root-09-m02-serviceaccounts-and-rbac', 5, 'lab', 'Lab: RBAC — Create Role and RoleBinding', NULL, 'rbac-role', NULL, 'auto_scenario', 30, 'ready'),
|
||||
('root-09-m02-a06-lab-rbac-clusterrole', 'root-09-m02-serviceaccounts-and-rbac', 6, 'lab', 'Lab: Cluster-Level RBAC (CKS)', NULL, 'cks-rbac-clusterrole', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m02-a07-lab-rbac-least-privilege', 'root-09-m02-serviceaccounts-and-rbac', 7, 'lab', 'Lab: RBAC Least Privilege', NULL, 'cks-rbac-least-privilege', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m02-a08-lab-rbac-clusterrole-independent', 'root-09-m02-serviceaccounts-and-rbac', 8, 'lab', 'Lab: ClusterRole and ClusterRoleBinding (independent practice)', NULL, 'rbac-clusterrole', NULL, 'auto_scenario', 20, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 09 m03 — 6 activities (PARTIAL)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-09-m03-a01-lesson-network-supplychain', 'root-09-m03-network-and-supplychain-security', 1, 'lesson', 'NetworkPolicy as a Security Control & Supply Chain Trust',
|
||||
'content/root/root-09-kubernetes-security/m03-network-and-supplychain-security/01-lesson-network-supplychain.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-09-m03-a02-knowledge-check', 'root-09-m03-network-and-supplychain-security', 2, 'knowledge_check', 'Knowledge Check: Network & Supply Chain Security',
|
||||
'content/root/root-09-kubernetes-security/m03-network-and-supplychain-security/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-09-m03-a03-lab-default-deny', 'root-09-m03-network-and-supplychain-security', 3, 'lab', 'Lab: Default Deny Network Policy', NULL, 'cks-network-policy', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m03-a04-lab-egress-namespace', 'root-09-m03-network-and-supplychain-security', 4, 'lab', 'Lab: Restrict Egress to Namespace', NULL, 'cks-egress-namespace', NULL, 'auto_scenario', 30, 'ready'),
|
||||
('root-09-m03-a05-lab-egress-networkpolicy-guided', 'root-09-m03-network-and-supplychain-security', 5, 'lab', 'Lab: Egress NetworkPolicy (guided practice)', NULL, 'networkpolicy-egress', NULL, 'auto_scenario', 30, 'ready'),
|
||||
('root-09-m03-a06-lab-tls-ingress', 'root-09-m03-network-and-supplychain-security', 6, 'lab', 'Lab: Secure Ingress with TLS', NULL, 'cks-tls-ingress', NULL, 'auto_scenario', 30, 'ready'),
|
||||
('root-09-m03-a07-lab-image-pull-secret', 'root-09-m03-network-and-supplychain-security', 7, 'lab', 'Lab: Private Registry Image Pull Secret', NULL, 'cks-image-pull-secret', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-09-m03-a08-lab-network-policy-metadata-advanced', 'root-09-m03-network-and-supplychain-security', 8, 'lab', 'Lab: Block Cloud Metadata (advanced/optional)', NULL, 'cks-network-policy-metadata', NULL, 'auto_scenario', 30, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 09 m04 — 4 activities (PARTIAL: survey-level, deliberately lighter)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-09-m04-a01-lesson-cluster-hardening', 'root-09-m04-cluster-hardening-and-runtime', 1, 'lesson', 'Pod Security Admission & a Survey of Runtime Hardening',
|
||||
'content/root/root-09-kubernetes-security/m04-cluster-hardening-and-runtime/01-lesson-cluster-hardening.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-09-m04-a02-lab-psa-namespace', 'root-09-m04-cluster-hardening-and-runtime', 2, 'lab', 'Lab: Pod Security Admission', NULL, 'cks-psa-namespace', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-09-m04-a03-lab-immutable-secret', 'root-09-m04-cluster-hardening-and-runtime', 3, 'lab', 'Lab: Immutable Secrets', NULL, 'cks-immutable-secret', NULL, 'auto_scenario', 15, 'ready'),
|
||||
('root-09-m04-a04-lab-runtime-class-advanced', 'root-09-m04-cluster-hardening-and-runtime', 4, 'lab', 'Lab: RuntimeClass for Sandboxed Workloads (advanced/optional)', NULL, 'cks-runtime-class', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-09-m04-a05-lab-audit-policy-advanced', 'root-09-m04-cluster-hardening-and-runtime', 5, 'lab', 'Lab: Write a Kubernetes Audit Policy (advanced/optional)', NULL, 'cks-audit-policy', NULL, 'auto_scenario', 30, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 10 m03 — 5 activities (PARTIAL: lesson + demo + KC + two labs)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-10-m03-a01-lesson-framework', 'root-10-m03-troubleshooting-framework', 1, 'lesson', 'The RootNode Troubleshooting Framework',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m03-troubleshooting-framework/01-lesson-framework.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-10-m03-a02-demonstration-crashloop', 'root-10-m03-troubleshooting-framework', 2, 'demonstration', 'Demonstration: Applying the Framework to a CrashLoopBackOff',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m03-troubleshooting-framework/02-demonstration-crashloop.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-10-m03-a03-knowledge-check', 'root-10-m03-troubleshooting-framework', 3, 'knowledge_check', 'Knowledge Check: The Troubleshooting Framework',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m03-troubleshooting-framework/03-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-10-m03-a04-lab-crashloop-fix', 'root-10-m03-troubleshooting-framework', 4, 'lab', 'Lab: Fix a CrashLoopBackOff Pod', NULL, 'crashloop-fix', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-10-m03-a05-lab-endpoint-fix', 'root-10-m03-troubleshooting-framework', 5, 'lab', 'Lab: Fix a Broken Service Selector', NULL, 'endpoint-fix-task', NULL, 'auto_scenario', 20, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 10 m02 (existing bare bank) — extend with the newly-wired logging/monitoring MCQ
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-10-m02-a06-container-logging-mcq', 'root-10-m02-general-troubleshooting', 6, 'lab', 'Lab: Container Logging and Monitoring', NULL, 'container-logging-mcq', NULL, 'auto_scenario', 15, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ROOT 12 m01 — a new framework lesson at the FRONT (existing 4 lab activities renumbered
|
||||
-- +1 to make room; their scenario_id/activity_type/completion_behavior are untouched, so no
|
||||
-- student's derived completion changes meaning) plus a closing reflection.
|
||||
UPDATE module_activities SET sequence = sequence + 1, version = version + 1, updated_at = now()
|
||||
WHERE module_id = 'root-12-m01-real-ops-incidents';
|
||||
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-12-m01-a00-lesson-incident-response', 'root-12-m01-real-ops-incidents', 1, 'lesson', 'The RootNode Incident-Response Framework',
|
||||
'content/root/root-12-real-ops/m01-real-ops-incidents/00-lesson-incident-response.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-12-m01-a99-reflection-post-incident', 'root-12-m01-real-ops-incidents', 6, 'reflection', 'Post-Incident Reflection: What Real Ops Actually Feels Like',
|
||||
'content/root/root-12-real-ops/m01-real-ops-incidents/99-reflection-post-incident.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- Capstone Readiness — 4 activities (READY: informational/orientation, not a lesson-heavy module)
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
|
||||
('root-capstone-m02-a01-lesson-what-it-measures', 'root-capstone-m02-capstone-readiness', 1, 'lesson', 'What the Capstone Measures & How the Draw Works',
|
||||
'content/root/root-capstone/m02-capstone-readiness/01-lesson-what-it-measures.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-capstone-m02-a02-lesson-time-management', 'root-capstone-m02-capstone-readiness', 2, 'lesson', 'Time Management & Troubleshooting Strategy Under a Clock',
|
||||
'content/root/root-capstone/m02-capstone-readiness/02-lesson-time-management.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-capstone-m02-a03-knowledge-check', 'root-capstone-m02-capstone-readiness', 3, 'knowledge_check', 'Readiness Check: Are You Actually Ready?',
|
||||
'content/root/root-capstone/m02-capstone-readiness/03-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-capstone-m02-a04-reflection-readiness', 'root-capstone-m02-capstone-readiness', 4, 'reflection', 'Readiness Reflection: What Completion Means',
|
||||
'content/root/root-capstone/m02-capstone-readiness/04-reflection-readiness.md', NULL, NULL, 'manual_marker', 5, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ── Optional activities: guided/independent-practice duplicates and advanced/CKS-exam-style
|
||||
-- extras (docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md's GUIDED/INDEPENDENT/ADVANCED
|
||||
-- classifications) count toward a module's displayed progress but not toward whether the
|
||||
-- module itself is considered complete — see course-progress.js's totals(). Everything not
|
||||
-- listed here stays required (the column's default), including every Phase 6A activity.
|
||||
UPDATE module_activities SET is_required = false, version = version + 1, updated_at = now() WHERE activity_id IN (
|
||||
'root-09-m01-a10-lab-seccomp-guided',
|
||||
'root-09-m01-a11-lab-apparmor-advanced',
|
||||
'root-09-m01-a12-lab-pod-security-context-independent',
|
||||
'root-09-m02-a08-lab-rbac-clusterrole-independent',
|
||||
'root-09-m03-a05-lab-egress-networkpolicy-guided',
|
||||
'root-09-m03-a08-lab-network-policy-metadata-advanced',
|
||||
'root-09-m04-a04-lab-runtime-class-advanced',
|
||||
'root-09-m04-a05-lab-audit-policy-advanced'
|
||||
);
|
||||
@@ -55,16 +55,27 @@ function shapeActivity(activity, ctx, forInstructor) {
|
||||
scenario_id: activity.scenario_id,
|
||||
assessment_definition_id: activity.assessment_definition_id,
|
||||
completion_behavior: activity.completion_behavior,
|
||||
is_required: activity.is_required,
|
||||
complete: isActivityComplete(activity, ctx),
|
||||
body: content?.shared || null,
|
||||
...(forInstructor ? { instructor_notes: content?.instructorNotes || null } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 6B §18/§27 — an optional activity (independent-practice duplicates, advanced/CKS-exam-
|
||||
// style extras) counts toward the displayed total/percent but NOT toward whether the module
|
||||
// itself is "complete." Every Phase 6A activity has is_required=true (the column's default,
|
||||
// unaffected by this), so this is purely additive — nothing that was complete before becomes
|
||||
// incomplete now.
|
||||
function totals(items) {
|
||||
const total = items.length;
|
||||
const completed = items.filter((i) => i.complete).length;
|
||||
return { total, completed, pct: total ? Math.round((completed / total) * 100) : 0, complete: total > 0 && completed === total };
|
||||
const required = items.filter((i) => i.is_required !== false);
|
||||
const requiredComplete = required.filter((i) => i.complete).length;
|
||||
return {
|
||||
total, completed, pct: total ? Math.round((completed / total) * 100) : 0,
|
||||
complete: required.length > 0 && requiredComplete === required.length,
|
||||
};
|
||||
}
|
||||
|
||||
// A prerequisite with zero content (a PLANNED module/course with nothing built yet) can never
|
||||
|
||||
@@ -156,6 +156,31 @@ module.exports = function registerLearningRoutes(app, deps) {
|
||||
);
|
||||
const definition = rows[0];
|
||||
if (!definition) return res.status(404).json({ error: 'not_found' });
|
||||
|
||||
// Phase 6B §10 — a known Phase 3B gap, documented in docs/PHASE6A_ROOT_COURSE_FOUNDATION.md
|
||||
// §10: this route only ever checked the definition existed/was active. GET /api/capstone
|
||||
// computes and displays lock state correctly and the UI never exposes a "start" action while
|
||||
// locked, but student UI state is not the enforcement boundary — a direct POST here could
|
||||
// start a capstone session before its prerequisite stages were actually complete. Fixed by
|
||||
// binding to the SAME gate /api/capstone already derives from the curriculum path's own
|
||||
// capstone stage (computeCurriculumState), rather than re-parsing definition.unlock_rule
|
||||
// independently — one computation of "is the capstone unlocked," not two that could drift.
|
||||
// Only a definition the path actually names as ITS capstone is gated this way; an ordinary
|
||||
// exam (no capstone stage names it) is unaffected — see e2e-phase6b.js for both cases.
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const capstoneStage = path?.stages.find((s) => s.type === 'capstone' && s.capstone_assessment_id === definition.assessment_definition_id);
|
||||
if (capstoneStage) {
|
||||
const progress = await loadProgress(pool, req.studentId);
|
||||
const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId);
|
||||
const state = computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined);
|
||||
if (state.capstone?.status === 'locked') {
|
||||
return res.status(403).json({
|
||||
error: 'locked', message: 'Complete the required stages before starting this assessment.',
|
||||
blocking_stage_ids: state.capstone.blocking_stage_ids,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cohortId = await studentCohortId(pool, req.studentId);
|
||||
try {
|
||||
const session = await assessmentsLib.startSession(pool, { definition, studentId: req.studentId, cohortId });
|
||||
|
||||
@@ -38,7 +38,14 @@ const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'l
|
||||
const NETWORKING = ['services-mcq', 'expose-service-basics'];
|
||||
const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline'];
|
||||
const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq'];
|
||||
const ALL_NON_CAPSTONE = [...FOUNDATION, ...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING];
|
||||
// Phase 6B §10 — REAL_OPS was missing from this list entirely, which this test never noticed
|
||||
// because POST /api/assessments/:id/start didn't check the capstone's unlock_rule server-side
|
||||
// before Phase 6B's fix (docs/PHASE6A_ROOT_COURSE_FOUNDATION.md §10). Now that the fix actually
|
||||
// enforces "all 6 stages complete," a genuine capstone attempt needs all 6, real-ops included —
|
||||
// completing only 5 of 6 is not a legitimately-unlocked capstone and should not (and now does
|
||||
// not) be accepted. Found by testing.
|
||||
const REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
|
||||
const ALL_NON_CAPSTONE = [...FOUNDATION, ...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING, ...REAL_OPS];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
|
||||
@@ -128,8 +128,10 @@ async function main() {
|
||||
await test('resume points at the first activity of the first unlocked course', async () => {
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(body.resume.course_id, 'root-05-kubernetes-foundations');
|
||||
assert.equal(body.resume.module_id, 'root-05-m01-pods-and-kubectl-basics');
|
||||
assert.equal(body.resume.activity_id, 'root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq');
|
||||
// Phase 6B added root-05-m00 (the mental-model module) as the new front door, sequenced
|
||||
// before the pre-existing bare-lab bank — see docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md.
|
||||
assert.equal(body.resume.module_id, 'root-05-m00-kubernetes-mental-model');
|
||||
assert.equal(body.resume.activity_id, 'root-05-m00-a01-lesson-mental-model');
|
||||
});
|
||||
|
||||
await test('a student cannot mark a lab activity complete manually — only real scenario completion counts', async () => {
|
||||
@@ -138,8 +140,15 @@ async function main() {
|
||||
assert.equal(body.error, 'not_manually_completable');
|
||||
});
|
||||
|
||||
await test('completing root-05\'s 20 real scenarios marks the course complete and unlocks root-06', async () => {
|
||||
await test('completing root-05\'s 20 real scenarios + the Phase 6B mental-model module marks the course complete and unlocks root-06', async () => {
|
||||
for (const sid of FOUNDATION) await completeScenario(casey, sid);
|
||||
// Phase 6B's root-05-m00 has its own manual-marker activities (lesson/demonstration/
|
||||
// knowledge_check/guided_exercise/reflection) that scenario completion alone never
|
||||
// satisfies — root-05 the COURSE isn't complete until these are marked too.
|
||||
for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces', 'a06-reflection']) {
|
||||
const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.complete, true);
|
||||
@@ -180,8 +189,11 @@ async function main() {
|
||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.complete, true);
|
||||
assert.equal(goldModule.percent_complete, 100);
|
||||
const nextModule = moduleById(body.courses, 'root-10-m02-general-troubleshooting');
|
||||
assert.equal(nextModule.locked, false, 'root-10-m02 must unlock once the gold module is complete');
|
||||
// Phase 6B inserted root-10-m03 (the troubleshooting framework module) between the gold
|
||||
// module and the pre-existing bare-lab bank — m02's prerequisite is now m03, not m01
|
||||
// directly. See docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md.
|
||||
const frameworkModule = moduleById(body.courses, 'root-10-m03-troubleshooting-framework');
|
||||
assert.equal(frameworkModule.locked, false, 'root-10-m03 must unlock once the gold module is complete');
|
||||
});
|
||||
|
||||
// ── Assessment/capstone-derived completion (auto_assessment) ────────────────────────────
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6B — ROOT_ curriculum authoring & coverage expansion acceptance test. Covers the NEW
|
||||
// courses/modules/content this phase added (ROOT 05-10/12/Capstone), the two genuinely new
|
||||
// mechanisms it introduced (is_required optional-activity handling, and the capstone
|
||||
// server-side unlock fix), and confirms the underlying Phase 6A engine (completion derivation,
|
||||
// locking, RBAC, isolation, persistence, draft-hiding) generalizes correctly to the new content
|
||||
// rather than re-proving every property from scratch against every module (already proven once,
|
||||
// thoroughly, in e2e-phase6a.js — see that file for the exhaustive per-mechanism coverage).
|
||||
// Same conventions: assert-based, no framework, live docker-compose.dev.yml stack, no real Lab
|
||||
// container launched here (infra/scripts/drills/test-phase6b-root-course-rehearsal.js is the
|
||||
// real-k3s counterpart).
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase6b.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
const PROGRAM = 'root-kubernetes-foundations';
|
||||
|
||||
// The real ROOT 05 Foundation scenarios (m00 + m01) — completing all of these is what actually
|
||||
// unlocks ROOT 06 at the course level (courses.prerequisites), same real content
|
||||
// e2e-phase6a.js/e2e-phase5b.js already drive completion against.
|
||||
const ROOT05_ALL = ['namespaces-basics', 'pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq',
|
||||
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
|
||||
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
|
||||
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq'];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
||||
}
|
||||
async function completeScenario(session, scenarioId) {
|
||||
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed' });
|
||||
assert.equal(status, 200, `completing ${scenarioId} should 200`);
|
||||
}
|
||||
function courseById(courses, id) { return courses.find((c) => c.course_id === id); }
|
||||
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
||||
function findActivity(courses, id) {
|
||||
for (const c of courses) for (const m of c.modules) { const a = m.activities.find((x) => x.activity_id === id); if (a) return a; }
|
||||
return null;
|
||||
}
|
||||
function psql(sql) {
|
||||
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 6B ROOT_ curriculum expansion acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
// ── 1-8: hierarchy for every course this phase touched ──────────────────────────────────
|
||||
let structure;
|
||||
await test('ROOT 05-10, 12, and Capstone all have the expected new modules, in sequence', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
assert.equal(status, 200);
|
||||
structure = body;
|
||||
const expectations = {
|
||||
'root-05-kubernetes-foundations': ['root-05-m00-kubernetes-mental-model', 'root-05-m01-pods-and-kubectl-basics'],
|
||||
'root-06-kubernetes-application-workloads': ['root-06-m02-deployments-scaling-updates', 'root-06-m03-jobs-cronjobs-daemonsets', 'root-06-m01-deployments-and-workloads'],
|
||||
'root-07-kubernetes-networking-config': ['root-07-m03-services-and-service-discovery', 'root-07-m04-ingress-networkpolicy-foundations', 'root-07-m01-services-and-networking', 'root-07-m02-configuration-and-secrets'],
|
||||
'root-08-kubernetes-storage-scheduling': ['root-08-m01-storage-fundamentals', 'root-08-m02-scheduling-resource-governance'],
|
||||
'root-09-kubernetes-security': ['root-09-m01-security-model-and-securitycontext', 'root-09-m02-serviceaccounts-and-rbac', 'root-09-m03-network-and-supplychain-security', 'root-09-m04-cluster-hardening-and-runtime'],
|
||||
'root-10-kubernetes-troubleshooting-observability': ['root-10-m01-health-probes-and-troubleshooting', 'root-10-m03-troubleshooting-framework', 'root-10-m02-general-troubleshooting'],
|
||||
'root-12-real-ops': ['root-12-m01-real-ops-incidents'],
|
||||
'root-capstone': ['root-capstone-m02-capstone-readiness', 'root-capstone-m01-operate-and-recover'],
|
||||
};
|
||||
for (const [courseId, moduleIds] of Object.entries(expectations)) {
|
||||
const course = courseById(structure, courseId);
|
||||
assert.ok(course, `${courseId} must exist`);
|
||||
const actualIds = course.modules.map((m) => m.module_id);
|
||||
assert.deepEqual(actualIds, moduleIds, `${courseId} module order mismatch: ${JSON.stringify(actualIds)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('ROOT 08 and ROOT 09 moved from PLANNED to PARTIAL — real content exists now', async () => {
|
||||
assert.equal(courseById(structure, 'root-08-kubernetes-storage-scheduling').status, 'partial');
|
||||
assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'partial');
|
||||
});
|
||||
|
||||
await test('the gold-standard Health Probes module is byte-for-byte unchanged (still 6 activities, still READY)', async () => {
|
||||
const gold = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(gold.status, 'ready');
|
||||
assert.equal(gold.activities.length, 6);
|
||||
});
|
||||
|
||||
await test('activity ordering within every new module is strictly ascending', async () => {
|
||||
for (const newModuleId of ['root-05-m00-kubernetes-mental-model', 'root-06-m02-deployments-scaling-updates', 'root-07-m03-services-and-service-discovery', 'root-09-m01-security-model-and-securitycontext']) {
|
||||
const mod = moduleById(structure, newModuleId);
|
||||
for (let i = 1; i < mod.activities.length; i++) assert.ok(mod.activities[i].sequence > mod.activities[i - 1].sequence, `${newModuleId} activity ordering broken`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('ROOT 12\'s new framework lesson is first and the new reflection is last, existing labs preserved in between', async () => {
|
||||
const mod = moduleById(structure, 'root-12-m01-real-ops-incidents');
|
||||
const types = mod.activities.map((a) => a.activity_type);
|
||||
assert.deepEqual(types, ['lesson', 'lab', 'lab', 'lab', 'lab', 'reflection']);
|
||||
const scenarioIds = mod.activities.filter((a) => a.activity_type === 'lab').map((a) => a.scenario_id);
|
||||
assert.deepEqual(scenarioIds, ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits']);
|
||||
});
|
||||
|
||||
// ── A real self-paced student walking the new content ───────────────────────────────────
|
||||
const { body: caseyCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Casey-${RUN_ID}`, login_identifier: `e2e6b-casey-${RUN_ID}` });
|
||||
let casey = makeSession(GW);
|
||||
await casey.request('POST', '/api/student/activate', { token: caseyCreate.activation_token, password: 'CaseyPassword123!' });
|
||||
const { body: enrollCreate } = await owner.request('POST', '/api/admin/enrollments', { student_id: caseyCreate.student_id, program_id: PROGRAM, status: 'active' });
|
||||
const enrollmentId = enrollCreate.enrollment_id;
|
||||
|
||||
await test('ROOT 06/09/10-framework are locked for a freshly-enrolled student', async () => {
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true);
|
||||
assert.equal(moduleById(body.courses, 'root-09-m01-security-model-and-securitycontext').locked, true);
|
||||
assert.equal(moduleById(body.courses, 'root-10-m03-troubleshooting-framework').locked, true, 'must stay locked behind the gold module even though it is a different course-level dependency chain');
|
||||
});
|
||||
|
||||
await test('resume points at ROOT 05\'s new mental-model module, not the old bare-lab bank', async () => {
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(body.resume.module_id, 'root-05-m00-kubernetes-mental-model');
|
||||
});
|
||||
|
||||
await test('lesson/knowledge_check completion works identically in a brand-new module (root-05-m00)', async () => {
|
||||
for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces']) {
|
||||
const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
await completeScenario(casey, 'namespaces-basics');
|
||||
const { status } = await casey.request('POST', '/api/student/activities/root-05-m00-a06-reflection/complete');
|
||||
assert.equal(status, 200);
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, true);
|
||||
});
|
||||
|
||||
await test('completing every ROOT 05 scenario (m00 + m01) unlocks ROOT 06 at the course level', async () => {
|
||||
for (const sid of ROOT05_ALL) await completeScenario(casey, sid);
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').complete, true);
|
||||
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, false);
|
||||
assert.equal(moduleById(body.courses, 'root-06-m01-deployments-and-workloads').locked, true, 'the bare bank stays gated behind the new m02 specifically');
|
||||
});
|
||||
|
||||
await test('a new scenario wired this phase (job-task, never in Phase 6A) completes an activity correctly', async () => {
|
||||
await completeScenario(casey, 'job-task');
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(findActivity(body.courses, 'root-06-m03-a03-lab-job-task').complete, true);
|
||||
});
|
||||
|
||||
// ── is_required: optional activities count toward percent but never block "complete" ────
|
||||
await test('an optional activity being incomplete does not block its module from being complete', async () => {
|
||||
// root-09 is still locked for Casey (needs root-06-m02 complete). Prove the is_required
|
||||
// rule directly against the seeded data instead: root-09-m01 has 12 activities, 3 of which
|
||||
// (a10/a11/a12) are is_required=false. Confirm the schema/shape is correct via the admin
|
||||
// structure view (studentId=null, so nothing is actually complete — this is a pure
|
||||
// structural check of the is_required flag itself).
|
||||
const mod = moduleById(structure, 'root-09-m01-security-model-and-securitycontext');
|
||||
const optional = mod.activities.filter((a) => a.is_required === false);
|
||||
const required = mod.activities.filter((a) => a.is_required !== false);
|
||||
assert.equal(optional.length, 3, 'expected exactly 3 optional activities in root-09-m01');
|
||||
assert.equal(required.length, 9);
|
||||
});
|
||||
|
||||
await test('completing every REQUIRED activity in root-09-m01 marks it complete even with optional activities left undone', async () => {
|
||||
// Unlock root-09 for Casey: complete the rest of root-06 (m02 + m03's remaining scenarios).
|
||||
for (const sid of ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'deployment-rollback']) {
|
||||
await completeScenario(casey, sid);
|
||||
}
|
||||
await completeScenario(casey, 'cronjob-task');
|
||||
for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy']) {
|
||||
await casey.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`);
|
||||
}
|
||||
await casey.request('POST', '/api/student/activities/root-06-m02-a06-reflection/complete');
|
||||
for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a02-knowledge-check']) {
|
||||
await casey.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`);
|
||||
}
|
||||
let tree = (await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`)).body.courses;
|
||||
assert.equal(moduleById(tree, 'root-09-m01-security-model-and-securitycontext').locked, false, 'root-09 should now be unlocked');
|
||||
|
||||
// Complete only the REQUIRED activities in root-09-m01, deliberately skipping the 3 optional ones.
|
||||
for (const suffix of ['a01-lesson-security-model', 'a02-demonstration-securitycontext', 'a03-knowledge-check', 'a04-guided-exercise-hardening-plan']) {
|
||||
await casey.request('POST', `/api/student/activities/root-09-m01-${suffix}/complete`);
|
||||
}
|
||||
for (const sid of ['cks-non-root-enforce', 'cks-drop-capabilities', 'cks-readonly-filesystem', 'cks-pod-security-context']) await completeScenario(casey, sid);
|
||||
await casey.request('POST', '/api/student/activities/root-09-m01-a09-reflection/complete');
|
||||
|
||||
tree = (await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`)).body.courses;
|
||||
const mod = moduleById(tree, 'root-09-m01-security-model-and-securitycontext');
|
||||
assert.equal(mod.complete, true, 'module must be complete once all REQUIRED activities are done, regardless of the 3 optional ones');
|
||||
assert.equal(mod.percent_complete, Math.round((9 / 12) * 100), 'percent_complete must still reflect ALL activities, optional included');
|
||||
assert.equal(findActivity(tree, 'root-09-m01-a10-lab-seccomp-guided').complete, false, 'the optional activity itself is correctly still incomplete');
|
||||
});
|
||||
|
||||
// ── Persistence, isolation, RBAC, draft-hiding — proven once against NEW content ────────
|
||||
await test('progress on new content survives logout and a fresh login', async () => {
|
||||
await casey.request('POST', '/api/logout');
|
||||
casey = makeSession(GW);
|
||||
await casey.request('POST', '/api/student/login', { identifier: `e2e6b-casey-${RUN_ID}`, password: 'CaseyPassword123!' });
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, true);
|
||||
});
|
||||
|
||||
const { body: isolatedCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Isolated-${RUN_ID}`, login_identifier: `e2e6b-isolated-${RUN_ID}` });
|
||||
const isolated = makeSession(GW);
|
||||
await isolated.request('POST', '/api/student/activate', { token: isolatedCreate.activation_token, password: 'IsolatedPassword123!' });
|
||||
const { body: isolatedEnroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: isolatedCreate.student_id, program_id: PROGRAM, status: 'active' });
|
||||
await test('a second student sees independent progress on the new courses (cross-student isolation)', async () => {
|
||||
const { body } = await isolated.request('GET', `/api/student/programs/${isolatedEnroll.enrollment_id}/courses`);
|
||||
assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, false);
|
||||
assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true);
|
||||
});
|
||||
|
||||
await test('a draft activity in a new module is hidden from students but visible to instructors', async () => {
|
||||
psql(`UPDATE module_activities SET visibility = 'draft' WHERE activity_id = 'root-08-m01-a01-lesson-storage-fundamentals'`);
|
||||
try {
|
||||
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const mod = moduleById(studentView.courses, 'root-08-m01-storage-fundamentals');
|
||||
assert.ok(!mod.activities.some((a) => a.activity_id === 'root-08-m01-a01-lesson-storage-fundamentals'));
|
||||
const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const modAdmin = moduleById(adminView, 'root-08-m01-storage-fundamentals');
|
||||
assert.ok(modAdmin.activities.some((a) => a.activity_id === 'root-08-m01-a01-lesson-storage-fundamentals'));
|
||||
} finally {
|
||||
psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-08-m01-a01-lesson-storage-fundamentals'`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('instructor notes on new content are visible to instructors and never to students', async () => {
|
||||
const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const lesson = findActivity(adminView, 'root-09-m01-a01-lesson-security-model');
|
||||
assert.ok(lesson.instructor_notes && lesson.instructor_notes.includes('Instructor Notes'));
|
||||
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const studentLesson = findActivity(studentView.courses, 'root-09-m01-a01-lesson-security-model');
|
||||
assert.equal(studentLesson.instructor_notes, undefined, 'a student response must never include this key at all');
|
||||
});
|
||||
|
||||
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6b-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor', role: 'instructor', password: 'StrayInstructorPass123!' });
|
||||
const strayInstructorSession = makeSession(GW);
|
||||
await strayInstructorSession.request('POST', '/api/auth/instructor/login', { email: `e2e6b-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass123!' });
|
||||
await test('a plain instructor with no relationship to the student is forbidden from their new-course progress (self-paced, no cohort)', async () => {
|
||||
const { status } = await strayInstructorSession.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
await test('owner/admin can see any self-paced student\'s progress on the new courses', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(moduleById(body, 'root-05-m00-kubernetes-mental-model').complete, true);
|
||||
});
|
||||
|
||||
// ── Capstone: the server-side unlock fix — the central Phase 6B security fix ───────────
|
||||
const { body: bypassCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Bypass-${RUN_ID}`, login_identifier: `e2e6b-bypass-${RUN_ID}` });
|
||||
const bypass = makeSession(GW);
|
||||
await bypass.request('POST', '/api/student/activate', { token: bypassCreate.activation_token, password: 'BypassPassword123!' });
|
||||
const { body: bypassEnroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: bypassCreate.student_id, program_id: PROGRAM, status: 'active' });
|
||||
|
||||
await test('direct POST /api/assessments/rootnode-capstone/start is rejected before prerequisites are complete (the fix)', async () => {
|
||||
const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||
assert.equal(status, 403);
|
||||
assert.equal(body.error, 'locked');
|
||||
assert.ok(Array.isArray(body.blocking_stage_ids) && body.blocking_stage_ids.length > 0);
|
||||
});
|
||||
|
||||
await test('an ordinary exam with no unlock_rule is completely unaffected by the fix', async () => {
|
||||
const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-quick-assessment/start');
|
||||
assert.equal(status, 201);
|
||||
assert.ok(body.scenarios.length > 0);
|
||||
// Clean up the active session so it doesn't block later assertions for this student.
|
||||
for (const s of body.scenarios) await completeScenario(bypass, s.scenario_id);
|
||||
});
|
||||
|
||||
await test('a legitimately unlocked capstone start is still accepted (the fix does not over-block)', async () => {
|
||||
const ALL_STAGES = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics',
|
||||
'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics',
|
||||
'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq',
|
||||
'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq',
|
||||
'deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq',
|
||||
'services-mcq', 'expose-service-basics',
|
||||
'configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline',
|
||||
'extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq',
|
||||
'rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
|
||||
for (const sid of ALL_STAGES) await completeScenario(bypass, sid);
|
||||
const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||
assert.equal(status, 201, `expected the now-unlocked capstone to start: ${JSON.stringify(body)}`);
|
||||
for (const s of body.scenarios) await completeScenario(bypass, s.scenario_id);
|
||||
const check = await bypass.request('GET', '/api/capstone');
|
||||
assert.equal(check.body.status, 'completed');
|
||||
});
|
||||
|
||||
await test('the capstone-derived completion is reflected in the course/module content model too', async () => {
|
||||
const { body } = await bypass.request('GET', `/api/student/programs/${bypassEnroll.enrollment_id}/courses`);
|
||||
const capstoneActivity = findActivity(body.courses, 'root-capstone-m01-a01-capstone');
|
||||
assert.equal(capstoneActivity.complete, true);
|
||||
});
|
||||
|
||||
await test('the Capstone Readiness module itself is completable independent of the real assessment', async () => {
|
||||
const enrollmentIdForCasey = enrollmentId;
|
||||
for (const suffix of ['a01-lesson-what-it-measures', 'a02-lesson-time-management', 'a03-knowledge-check', 'a04-reflection-readiness']) {
|
||||
const { status } = await casey.request('POST', `/api/student/activities/root-capstone-m02-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentIdForCasey}/courses`);
|
||||
assert.equal(moduleById(body.courses, 'root-capstone-m02-capstone-readiness').complete, true);
|
||||
});
|
||||
|
||||
// ── Content version stability ─────────────────────────────────────────────────────────────
|
||||
await test('bumping a new module\'s version does not break or drop its content', async () => {
|
||||
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-09-m01-security-model-and-securitycontext'`);
|
||||
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const mod = moduleById(body, 'root-09-m01-security-model-and-securitycontext');
|
||||
assert.equal(mod.activities.length, 12, 'a version bump must never drop activities');
|
||||
assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
||||
Reference in New Issue
Block a user