Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db136875f | ||
|
|
505783e5f7 | ||
|
|
e0f954e714 | ||
|
|
54b35e8ad7 | ||
|
|
de6c5b3286 | ||
|
|
831486d664 | ||
|
|
960a638ec9 | ||
|
|
73cea1ba1d | ||
|
|
dbcc210889 | ||
|
|
86ea035e9b | ||
|
|
25409d3bd7 | ||
|
|
046a0fba18 | ||
|
|
bee57d89fd | ||
|
|
82f17a05e8 | ||
|
|
fd38d06055 | ||
|
|
8ffc923a84 | ||
|
|
db927b8081 | ||
|
|
8c09ef8f3e | ||
|
|
28c925069b | ||
|
|
e2298e95de | ||
|
|
fe702754cd | ||
|
|
f152b9a489 | ||
|
|
e4cbcf6c40 |
+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).
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Why Health Checks Exist & Probe Types
|
||||
|
||||
Kubernetes cannot see inside your application. It doesn't know if your process is deadlocked,
|
||||
stuck in an infinite loop, or quietly failing every request while the process itself stays
|
||||
alive. Without a way to ask "are you actually working?", the only signal Kubernetes has is
|
||||
whether the container's process is still running — and a hung process still counts as running.
|
||||
|
||||
**A health probe is how your application tells Kubernetes the truth.** It's a small check —
|
||||
an HTTP request, a TCP connection attempt, or a command run inside the container — that
|
||||
Kubernetes runs on a schedule and uses to make real decisions: restart this container, or stop
|
||||
sending it traffic.
|
||||
|
||||
## The three probe types
|
||||
|
||||
Kubernetes supports three distinct probes, and they answer three different questions. Mixing
|
||||
them up is the single most common probe mistake in production.
|
||||
|
||||
**Liveness probe — "Is this container still alive, or should it be restarted?"**
|
||||
If a liveness probe fails repeatedly, the kubelet kills the container and it gets restarted per
|
||||
its restart policy. Use this for detecting a genuinely stuck process — a deadlock, a crashed
|
||||
event loop. Don't use liveness to mean "not ready for traffic yet" — that's what readiness is
|
||||
for, and confusing the two causes the exact incident you're about to fix.
|
||||
|
||||
**Readiness probe — "Is this container ready to receive traffic right now?"**
|
||||
If a readiness probe fails, the Pod is removed from the Service's list of endpoints —
|
||||
Kubernetes stops routing traffic to it, but does **not** restart the container. This is the
|
||||
right tool for "still starting up," "warming a cache," or "temporarily overloaded, back off for
|
||||
a bit." A Pod can be Running and healthy but still correctly Not Ready.
|
||||
|
||||
**Startup probe — "Has this slow-starting container finished starting up yet?"**
|
||||
Some applications take a long time to become ready (loading a large dataset, running
|
||||
migrations). A startup probe disables liveness/readiness checks until it succeeds once, so a
|
||||
slow-but-healthy startup doesn't get killed by an impatient liveness probe. Once it succeeds,
|
||||
liveness and readiness take over as normal.
|
||||
|
||||
## How a probe is actually defined
|
||||
|
||||
Every probe has a **mechanism** (how the check runs) and a **schedule** (how often, how long to
|
||||
wait, how many failures before acting):
|
||||
|
||||
- `httpGet` — a GET request to a path and **port** on the container. A 2xx or 3xx response
|
||||
counts as success.
|
||||
- `tcpSocket` — a TCP connection attempt to a **port**. A successful connection counts as
|
||||
success — this doesn't check application-level health, just "is something listening."
|
||||
- `exec` — runs a command inside the container. Exit code `0` counts as success.
|
||||
|
||||
Timing fields (`initialDelaySeconds`, `periodSeconds`, `timeoutSeconds`, `failureThreshold`,
|
||||
`successThreshold`) control how patient Kubernetes is before it acts — but no amount of correct
|
||||
timing fixes a probe that's pointed at the wrong port or path in the first place.
|
||||
|
||||
## Common production mistakes
|
||||
|
||||
1. **Wrong port.** The probe's `httpGet.port` (or `tcpSocket.port`) doesn't match the port the
|
||||
application actually listens on. The container is completely healthy — Kubernetes is just
|
||||
asking the wrong door to open. This is the exact failure you'll diagnose in this module's lab.
|
||||
2. **Liveness probe too aggressive on a slow-starting app**, with no startup probe — the app
|
||||
gets killed for "not being ready yet," restarts, and never gets far enough to pass.
|
||||
3. **Readiness probe that always passes** (e.g. checking a static file instead of real
|
||||
dependencies) — traffic gets routed to a Pod that can't actually serve it.
|
||||
4. **No probes configured at all** — Kubernetes has no way to know the container is stuck, so a
|
||||
deadlocked process just sits there serving nothing, forever, looking "Running" the whole time.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 12-15 minutes for a live read-through with pauses for questions;
|
||||
10 minutes for a self-paced read.
|
||||
|
||||
**Demo plan:** Before moving to the guided exercise, it's worth live-editing one Deployment's
|
||||
`readinessProbe.httpGet.port` in front of the class to an obviously wrong value and running
|
||||
`kubectl get pods` / `kubectl describe pod` together, so students see the Ready column and the
|
||||
Events section change in real time before they do it themselves.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students often assume a failing liveness probe and a failing readiness probe look the same in
|
||||
`kubectl get pods` — they don't (liveness failures show restart counts climbing; readiness
|
||||
failures show `0/1` Ready with no restarts). Worth calling out explicitly.
|
||||
- Students sometimes think `tcpSocket` checks are "less safe" than `httpGet` — reframe as
|
||||
"checks a different, shallower thing," not "worse."
|
||||
|
||||
**Discussion question:** "Why might a team deliberately choose a readiness probe that fails
|
||||
under high load, even though it takes healthy capacity out of rotation?" (Answer: shedding load
|
||||
from an overloaded Pod is often better than serving every request slowly or with errors.)
|
||||
|
||||
**Optional extension:** Ask students to sketch what would happen if liveness and readiness
|
||||
probes were swapped (readiness probe restarts on failure, liveness probe just removes from
|
||||
Service) — this clarifies the distinction by making the wrong version visibly worse.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Demonstration: Probes in Action
|
||||
|
||||
Watch what a probe failure actually looks like from the outside, before you're the one
|
||||
diagnosing it.
|
||||
|
||||
Picture a Deployment called `web` running `nginx`, which listens on port 80. Someone adds a
|
||||
readiness probe but mistypes the port as `8080`:
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
**What you'd see immediately after this rolls out:**
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
web-7d8f9c6b5d-abcde 0/1 Running 0 45s
|
||||
```
|
||||
|
||||
`STATUS` says `Running` — the container started fine, nginx is up. But `READY` says `0/1` — the
|
||||
readiness probe is failing, so this Pod has never been added to its Service's endpoints. No
|
||||
traffic reaches it. `RESTARTS` stays at `0` because this is a readiness failure, not a liveness
|
||||
failure — nothing is being killed, it's just being excluded.
|
||||
|
||||
**The trail that explains why**, from `kubectl describe pod`:
|
||||
|
||||
```
|
||||
Events:
|
||||
Type Reason Age From Message
|
||||
---- ------ ---- ---- -------
|
||||
Normal Scheduled 50s default-scheduler Successfully assigned default/web-... to node-1
|
||||
Normal Pulled 49s kubelet Container image "nginx:1.25" already present on machine
|
||||
Normal Created 49s kubelet Created container web
|
||||
Normal Started 49s kubelet Started container web
|
||||
Warning Unhealthy 15s (x6 over 40s) kubelet Readiness probe failed: Get "http://10.244.0.7:8080/": dial tcp 10.244.0.7:8080: connect: connection refused
|
||||
```
|
||||
|
||||
That last line is the whole story: kubelet tried to connect to port `8080` and nothing was
|
||||
listening there — because nginx is listening on `80`. The container was never broken. The probe
|
||||
was asking the wrong question.
|
||||
|
||||
**The general diagnostic shape** for any probe issue: `kubectl get pods` tells you *that*
|
||||
something is wrong (Ready count, restart count). `kubectl describe pod` and its Events section
|
||||
tell you *what* is wrong, usually in plain English, including the exact port/path that was
|
||||
tried and the exact error. Reach for Events before logs — logs show you what the application
|
||||
did; Events show you what Kubernetes tried to do and how it failed.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Live-run this exact sequence against a throwaway Deployment (or the classroom
|
||||
projector) rather than only showing static output — `kubectl get pods`, then
|
||||
`kubectl describe pod <name>`, scrolling straight to Events. Ask the class to predict the
|
||||
`READY` column and `RESTARTS` count *before* you show it.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; ~5 minutes as a self-paced read.
|
||||
|
||||
**Common misconception to preempt:** students often reach for `kubectl logs` first when
|
||||
something isn't Ready. For a probe misconfiguration, logs are frequently silent or unhelpful —
|
||||
the application is healthy and has nothing to say. This is a good moment to reinforce
|
||||
"Events explain what Kubernetes tried; logs explain what the app did," and that reading Events
|
||||
first is usually faster for this class of failure.
|
||||
|
||||
**Optional extension:** show the same failure signature for a `tcpSocket` probe (a bare
|
||||
"connection refused" with no HTTP verb in the Events message) so students can tell the two
|
||||
mechanisms apart from Events alone.
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Knowledge Check: Health Probes
|
||||
|
||||
Answer each question yourself before reading the answer that follows it. This isn't graded —
|
||||
it's a checkpoint to catch a misunderstanding before you're troubleshooting a live Pod in the
|
||||
next activity.
|
||||
|
||||
---
|
||||
|
||||
**1. A Pod shows `RESTARTS: 12` and `READY: 1/1`. What kind of probe failure, if any, is most
|
||||
consistent with this?**
|
||||
|
||||
*Answer:* A liveness probe that was failing and has now recovered (or is intermittently
|
||||
failing) — the climbing restart count is the signature of liveness failures, since a failed
|
||||
liveness check causes the kubelet to kill and restart the container. `READY: 1/1` means it's
|
||||
currently passing its readiness check right now.
|
||||
|
||||
---
|
||||
|
||||
**2. A Pod shows `RESTARTS: 0` and `READY: 0/1`, and has looked this way for several minutes.
|
||||
What kind of probe failure is most consistent with this?**
|
||||
|
||||
*Answer:* A readiness probe that is failing. The container is running fine (no restarts), but
|
||||
Kubernetes has decided it isn't ready to receive traffic and has excluded it from the Service's
|
||||
endpoints. This is exactly the symptom you'll investigate in this module's lab.
|
||||
|
||||
---
|
||||
|
||||
**3. Why does Kubernetes offer a separate startup probe instead of just making
|
||||
`initialDelaySeconds` on the liveness probe longer?**
|
||||
|
||||
*Answer:* `initialDelaySeconds` is a fixed, one-size-fits-all wait before checks even begin —
|
||||
set it too short and a normally-slow startup gets killed; set it too long and every fast
|
||||
startup pays that same delay before liveness/readiness protection is even active. A startup
|
||||
probe instead keeps checking until the application actually reports ready (bounded by its own
|
||||
failure threshold), so liveness/readiness only take over once startup is confirmed done —
|
||||
correct for both a consistently slow app and one whose startup time varies.
|
||||
|
||||
---
|
||||
|
||||
**4. True or false: a readiness probe failure will cause Kubernetes to restart the container.**
|
||||
|
||||
*Answer:* False. A readiness probe failure only removes the Pod from Service endpoints — it
|
||||
does not restart anything. Only a liveness (or startup, before it succeeds once) probe failure
|
||||
causes a restart.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** In a live classroom, ask these out loud and take verbal answers before
|
||||
revealing each one, rather than having students read this silently — the goal is to surface a
|
||||
wrong mental model while it's still cheap to correct.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
|
||||
**Likely misconception this catches:** question 2 specifically catches students who default to
|
||||
"something is broken, kubectl logs will show an error" — for a pure readiness-probe
|
||||
misconfiguration, logs are usually clean, which is disorienting the first time you hit it.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Guided Exercise: Configure a Readiness Probe
|
||||
|
||||
This is a paper-and-thinking exercise, not a Lab task — you won't run these commands against a
|
||||
real cluster here (that comes next). The goal is to practice reading and writing a probe
|
||||
definition correctly before you're diagnosing someone else's broken one.
|
||||
|
||||
## The scenario
|
||||
|
||||
You're deploying a small HTTP API that listens on port `3000` and exposes a health endpoint at
|
||||
`/healthz`. It takes a couple of seconds to finish connecting to its database on startup — if a
|
||||
request arrives before that finishes, it correctly returns a `503` at `/healthz` until it's
|
||||
ready.
|
||||
|
||||
## Your task
|
||||
|
||||
Work out, on paper (or in a scratch file), what you'd write for this container's readiness
|
||||
probe. Think through:
|
||||
|
||||
1. Which mechanism fits — `httpGet`, `tcpSocket`, or `exec`? (Hint: you have a real health
|
||||
endpoint that returns a meaningful status code — use it rather than a shallower check.)
|
||||
2. What `port` and `path` should the probe target?
|
||||
3. Roughly what `initialDelaySeconds` makes sense, given the database connection takes a couple
|
||||
of seconds?
|
||||
4. Should this be a `readinessProbe`, a `livenessProbe`, or both? Justify your answer using the
|
||||
distinction from the lesson — what actually goes wrong if you get this specific choice wrong
|
||||
here?
|
||||
|
||||
## A worked shape to check yourself against
|
||||
|
||||
Your answer should end up structurally similar to this (exact field names matter more than the
|
||||
precise numbers you chose):
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 3000
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
```
|
||||
|
||||
If you reached for `livenessProbe` instead of `readinessProbe` for this specific scenario,
|
||||
re-read the lesson's distinction before continuing — "returns 503 while warming up" is
|
||||
precisely the readiness case, not the liveness case. A liveness probe here would restart a
|
||||
perfectly healthy, still-starting container over and over, which never lets it finish starting.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Have students write their probe block on paper or a shared doc before showing
|
||||
the worked shape — resist showing the answer early, since the value here is in students
|
||||
committing to an answer and comparing, not passively reading a correct one.
|
||||
|
||||
**Approximate timing:** 10-15 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Common mistake to watch for:** choosing `livenessProbe` because "the app isn't healthy yet"
|
||||
— this is the single most common real-world probe mistake and exactly what question 4 is
|
||||
designed to surface before the next activity's real diagnostic task.
|
||||
|
||||
**Optional extension:** ask what would need to change in this same YAML if the health endpoint
|
||||
took up to 60 seconds to become available on cold start (answer: a `startupProbe` with a
|
||||
generous `failureThreshold` × `periodSeconds`, so liveness/readiness don't engage too early).
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Launch Lab: Stuck Not-Ready: The Probe Port Mismatch
|
||||
|
||||
Time to apply everything from this module against a real, running incident — not a diagram, an
|
||||
actual Deployment in your own disposable Lab environment.
|
||||
|
||||
## What you're walking into
|
||||
|
||||
A deployment called `api-gateway` has been rolled out. The pods start, the container runs — but
|
||||
the deployment never reaches Ready, and traffic isn't being routed to it. Somewhere on-call, an
|
||||
alert is firing.
|
||||
|
||||
You now have everything you learned in this module to work with:
|
||||
|
||||
- **The lesson** told you what "stuck at 0/2 Ready with 0 restarts" actually means (a readiness
|
||||
problem, not a crash).
|
||||
- **The demonstration** showed you exactly where to look for the explanation — `kubectl get
|
||||
pods`, then `kubectl describe pod` and its Events section — and what a port-mismatch failure
|
||||
looks like in that output.
|
||||
- **The guided exercise** had you reason through what a correct probe definition looks like,
|
||||
which is exactly what you'll need to compare the broken one against.
|
||||
|
||||
## Your task in this Lab
|
||||
|
||||
Diagnose why the pods are failing their readiness check, correct the probe configuration, and
|
||||
restore the deployment to fully Ready. Start with pod status and events, the same way you just
|
||||
practiced — read before you patch.
|
||||
|
||||
This Lab has its own in-context hints if you get stuck, revealed one at a time — use the
|
||||
concepts from this module first; reach for a hint only if you're genuinely stuck, not as the
|
||||
first move.
|
||||
|
||||
When the Lab confirms your fix, come back to this module to close it out with a short
|
||||
reflection on what actually caused this.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Classroom flow:** if teaching this live, this is the natural point to have students launch
|
||||
their own Lab environment and work independently or in pairs, while you circulate. The
|
||||
Real-Time Classroom dashboard's help-request and progress views (see the instructor Curriculum
|
||||
view) show who's stuck without needing to walk around asking.
|
||||
|
||||
**Approximate timing:** 15-20 minutes for most students; some will resolve it in under 5 once
|
||||
the module's concepts have landed.
|
||||
|
||||
**Common mistake to watch for:** students jumping straight to `kubectl logs` here, which will
|
||||
be unhelpful (nginx has nothing to say about a probe pointed at the wrong port) — this is a good
|
||||
moment to reinforce the demonstration's point about reading Events first for this failure class.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Root-Cause Reflection & Real Ops Takeaway
|
||||
|
||||
You just fixed a real incident pattern, not a puzzle. Before moving on, take a few minutes to
|
||||
think through what actually happened and why it matters beyond this one Lab.
|
||||
|
||||
## What actually went wrong
|
||||
|
||||
The container was never broken. `nginx` started fine and was serving requests correctly the
|
||||
entire time. The only thing wrong was that the readiness probe was configured to check a port
|
||||
nginx wasn't listening on — so Kubernetes, working exactly as designed, correctly reported the
|
||||
Pod as Not Ready based on the information it had. The system didn't fail; it was given wrong
|
||||
information and acted rationally on it.
|
||||
|
||||
This is worth sitting with: most "Kubernetes is broken" incidents are actually "Kubernetes is
|
||||
doing exactly what its configuration tells it to do, and the configuration is wrong."
|
||||
|
||||
## Reflection questions
|
||||
|
||||
Think through your own answers to these — there's no single correct wording, but you should be
|
||||
able to explain each one out loud:
|
||||
|
||||
1. If you had jumped straight to `kubectl logs` instead of `kubectl describe pod`, how long do
|
||||
you think it would have taken you to find this? What would the logs have actually shown you?
|
||||
2. This Lab's port mismatch was introduced deliberately. What real-world change could introduce
|
||||
the exact same failure by accident? (Consider: someone changes the container's listening
|
||||
port in a new image version, but forgets the probe definition was hand-written separately.)
|
||||
3. Why does a rolling update stall — not fail outright, just hang — when new Pods can't pass
|
||||
their readiness probe? What is Kubernetes protecting you from by refusing to proceed?
|
||||
|
||||
## Real Ops takeaway: prevention, not just fixes
|
||||
|
||||
In a real production system, you'd want to prevent this class of incident from recurring, not
|
||||
just fix it once:
|
||||
|
||||
- **Keep the probe port derived from the same source as the container's actual port**, where
|
||||
your tooling allows it (a shared value in a Helm chart or Kustomize overlay), instead of two
|
||||
independently hand-typed numbers that can drift apart silently.
|
||||
- **Treat a stalled rollout as a first-class alert**, not just "the deploy is taking a while" —
|
||||
a Deployment sitting at partial readiness for an unusual length of time is exactly this
|
||||
failure mode, and catching it via rollout status is faster than waiting for a user-facing
|
||||
outage report.
|
||||
- **Practice reading Events before logs** for anything in the "Running but not Ready" state —
|
||||
you just did this under low stakes; the habit is what pays off during a real incident at 2am.
|
||||
|
||||
This module is complete. Return to the course to continue with the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** use the three reflection questions above as
|
||||
discussion prompts rather than a silent worksheet — question 2 in particular tends to produce
|
||||
good real-world examples from students who've touched CI/CD pipelines before.
|
||||
|
||||
**Approximate timing:** 10 minutes for a live discussion; 5-8 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to write the one-sentence postmortem summary they'd post
|
||||
in a team channel for this incident (real on-call teams write these) — a good answer names the
|
||||
root cause (probe port mismatch) and the fix (corrected port), not just "it's fixed now."
|
||||
|
||||
**Why this activity is a reflection, not another knowledge check:** the goal here is
|
||||
consolidation and transfer to real-world judgment, not testing recall — resist turning this
|
||||
into another right/wrong quiz.
|
||||
+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.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Course Authoring Standard
|
||||
|
||||
Phase 6A §11 — the standard every future RootNode lesson/module must follow, so a future Claude
|
||||
Code session or a human instructor can author content consistently. The module at
|
||||
`content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/` (backing
|
||||
`root-10-m01-health-probes-and-troubleshooting`) is the reference implementation — when in
|
||||
doubt, read it before writing new content.
|
||||
|
||||
## 1. Target learner
|
||||
|
||||
State explicitly, in the module's Postgres `description` (not buried in prose): who this is for
|
||||
and what they're assumed to already know. root_ targets ages ~15-18+ through adult learners
|
||||
(see `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` §1) — write at a level that respects that range;
|
||||
don't write down to it.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
Declare real prerequisite course/module ids in the `prerequisites` column, not prose alone (see
|
||||
`docs/COURSE_CONTENT_MODEL.md` §5 for how locking derives from this). If a module assumes a
|
||||
concept the platform doesn't yet teach anywhere, say so explicitly in the module description
|
||||
rather than silently assuming it.
|
||||
|
||||
## 3. Measurable objectives
|
||||
|
||||
3-5 objectives per module, stored in the `objectives` column, each a sentence that starts with a
|
||||
verb describing an observable capability ("Diagnose...", "Configure...", "Explain..."), not a
|
||||
topic list ("Probes"). A learner (or an instructor) should be able to look at an objective and
|
||||
know whether it was met.
|
||||
|
||||
## 4. Estimated time
|
||||
|
||||
Set `estimated_duration_minutes` per activity and let the module/course total derive from the
|
||||
sum — don't hand-guess a module-level number that doesn't match its activities. Time estimates
|
||||
are honest guesses, not commitments; revise them once real completion-time data exists (the
|
||||
mechanically-mapped modules' per-scenario 15/20/30-minute estimates in
|
||||
`022_course_content.sql` are explicitly flagged as a rough heuristic for exactly this reason).
|
||||
|
||||
## 5. Concept explanation (the `lesson` activity)
|
||||
|
||||
Explain the underlying concept BEFORE any lab is presented — this is the module's core
|
||||
pedagogical premise (see `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`'s "core product principle").
|
||||
Cover: why the concept/mechanism exists (the problem it solves), the mechanism itself, and
|
||||
common real-world mistakes. Use concrete, runnable examples (`kubectl` output, YAML snippets)
|
||||
over abstract description wherever possible.
|
||||
|
||||
## 6. Demonstration
|
||||
|
||||
Show the concept in action with a worked example BEFORE asking the learner to do it themselves.
|
||||
A demonstration activity should include what the learner would actually SEE (real-looking
|
||||
command output), not just a description of what would happen.
|
||||
|
||||
## 7. Guided practice (the `guided_exercise` activity)
|
||||
|
||||
Lower-stakes practice with a visible worked answer to check against (paper/scratch-file
|
||||
exercises are fine — see the gold module's readiness-probe exercise). The goal is committing to
|
||||
an answer and comparing, not passively reading a correct one first.
|
||||
|
||||
## 8. Independent practice → lab mapping
|
||||
|
||||
A `lab` activity references an EXISTING `scenario_id` — never invent duplicate lab content when
|
||||
an existing scenario already teaches the concept (check `docs/ROOT_CURRICULUM_INVENTORY.md`
|
||||
first). Write a short `content_path` intro (see the gold module's `05-lab-intro.md`) that
|
||||
connects what was just taught to the incident the student is about to solve, without handing out
|
||||
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
|
||||
assessment engine is for). Use plain Markdown (`*Answer:*` after each question), not raw HTML
|
||||
`<details>` — `react-markdown` renders this repo's course content and does not have `rehype-raw`
|
||||
enabled, so raw HTML tags render as literal text, not a collapsible element.
|
||||
|
||||
## 10. Reflection
|
||||
|
||||
A reflection activity consolidates and connects to real-world judgment — it is not a second
|
||||
knowledge check. Ask "why does this matter beyond this one Lab," not "what did you just do."
|
||||
Include a "Real Ops takeaway": a concrete prevention/production practice a learner could apply
|
||||
on a real team.
|
||||
|
||||
## 11. Instructor notes
|
||||
|
||||
Every activity type CAN have an `## Instructor Notes` section (see
|
||||
`docs/COURSE_CONTENT_MODEL.md` §3); a `lesson`/`demonstration`/`guided_exercise`/`reflection`
|
||||
activity SHOULD have one. At minimum include: approximate timing, one likely misconception to
|
||||
watch for, and (where relevant) a discussion question or optional extension activity. Keep it
|
||||
actionable — a bullet list an instructor can glance at mid-class, not a second essay.
|
||||
|
||||
## 12. Accessibility/readability
|
||||
|
||||
Plain Markdown only (headings, lists, code fences, bold/italic, tables) — no content that
|
||||
depends on color alone to convey meaning, no ASCII art carrying information a screen reader
|
||||
would lose. Write short paragraphs; prefer lists over dense prose for anything enumerable
|
||||
(steps, mistakes, questions).
|
||||
|
||||
## 13. Terminology rules
|
||||
|
||||
Match the terminology the scenario JSON and existing Platform UI already use exactly (e.g.
|
||||
"readiness probe," not "health check probe" — check `scenarios/data/<id>.json`'s own
|
||||
`description`/`hints` for the canonical phrasing before introducing a lesson's own term for the
|
||||
same thing). Define a term the first time it's used in a module; don't assume a prerequisite
|
||||
module's terminology carried over without a one-line reminder.
|
||||
|
||||
## 14. Versioning
|
||||
|
||||
Bump the `version` integer on courses/course_modules/module_activities by hand on any semantic
|
||||
content edit — same convention `assessment_definitions`/`programs` already use. A version bump
|
||||
is informational only in Phase 6A (nothing snapshots against it the way an assessment session
|
||||
snapshots its definition version) — it exists so a future phase can detect "this changed since a
|
||||
student last saw it" without re-deriving that from `updated_at` alone.
|
||||
|
||||
## 15. Review/testing requirements
|
||||
|
||||
Before marking a module `status = 'ready'`:
|
||||
|
||||
- Every activity's `content_path` file must exist and load (verify via
|
||||
`platform/api/src/content.js`'s `loadContent`, or just hit the admin curriculum API and check
|
||||
`body` is non-empty).
|
||||
- A `lab` activity's `scenario_id` must be a real, existing scenario id.
|
||||
- An `assessment`/`capstone` activity's `assessment_definition_id` must be a real, active
|
||||
`assessment_definitions` row.
|
||||
- Walk the module once as a self-paced student end to end (lesson → ... → lab → reflection),
|
||||
and once through the instructor Curriculum view, before shipping — this is what
|
||||
`e2e-phase6a.js` and `infra/scripts/drills/test-phase6a-course-rehearsal.js` automate for the
|
||||
gold module; extend one of them (or add a new phase-scoped test) for future modules rather
|
||||
than skipping this step.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Course Content Model
|
||||
|
||||
Phase 6A §3 — how RootNode represents actual teaching content (lessons, demonstrations,
|
||||
knowledge checks, guided exercises, reflections) around the existing scenario/assessment
|
||||
systems, without duplicating either. See `docs/ROOT_PROGRAM_ARCHITECTURE.md` for what this
|
||||
model is populated with, and `docs/COURSE_AUTHORING_STANDARD.md` for how to author new content
|
||||
against it.
|
||||
|
||||
## 1. Shape
|
||||
|
||||
```
|
||||
program (existing, 019_programs.sql — unchanged)
|
||||
→ course (new: courses)
|
||||
→ module (new: course_modules)
|
||||
→ activity (new: module_activities)
|
||||
```
|
||||
|
||||
`platform/api/migrations/022_course_content.sql` is the schema. Every table follows the same
|
||||
conventions already established by `programs`/`assessment_definitions`: a TEXT slug primary key,
|
||||
a plain incrementing `version` integer bumped by hand on a semantic edit, `status`/`visibility`
|
||||
CHECK constraints, `created_at`/`updated_at`.
|
||||
|
||||
## 2. Activity types and what they reference
|
||||
|
||||
| Type | References | Completion behavior |
|
||||
|---|---|---|
|
||||
| `lesson`, `demonstration`, `knowledge_check`, `guided_exercise`, `reflection` | a Markdown file (`content_path`) | `manual_marker` |
|
||||
| `lab` | an EXISTING `scenario_id` (scenarios/data/\*.json) | `auto_scenario` |
|
||||
| `assessment`, `capstone` | an EXISTING `assessment_definition_id` (`assessment_definitions`) | `auto_assessment` |
|
||||
|
||||
A `lab` activity never duplicates scenario content — it stores a bare `scenario_id` string, the
|
||||
same id the existing curriculum/attempt/progress system already uses. An `assessment`/`capstone`
|
||||
activity never duplicates an assessment definition — it stores the existing
|
||||
`assessment_definition_id` and a foreign key enforces it actually exists. Neither activity type
|
||||
is prevented from also having its own `content_path` (e.g. the gold module's lab activity has a
|
||||
short Markdown intro at `05-lab-intro.md`) — that's framing prose around the reference, not a
|
||||
copy of the referenced thing.
|
||||
|
||||
## 3. Where the prose lives
|
||||
|
||||
Long-form lesson content is git-tracked Markdown under `content/root/<course>/<module>/`, not
|
||||
Postgres. Reasoning, weighed explicitly per the authorization's instruction:
|
||||
|
||||
- **Reviewable like code.** A lesson edit is a normal diff/PR, not an opaque row update.
|
||||
- **No CMS needed.** The authorization explicitly ruled one out ("Do not build a complex CMS").
|
||||
A Postgres TEXT column holding Markdown would technically work but loses git history/diffing
|
||||
for the one thing (prose) that benefits from it most.
|
||||
- **Structural metadata still belongs in Postgres** (ordering, type, references, status,
|
||||
objectives, prerequisites) because it's queried, joined, and mutated (locking/completion) —
|
||||
exactly the kind of relational state Postgres is for, and exactly what the authorization asked
|
||||
for ("Postgres preferred for metadata/state").
|
||||
|
||||
### The Instructor Notes convention
|
||||
|
||||
One Markdown file per activity, not two. Everything before a `## Instructor Notes` heading is
|
||||
shared (self-paced-visible) content; that heading and everything after it is instructor-only.
|
||||
`platform/api/src/content.js` does the split (`splitContent()`) — a plain string search on that
|
||||
exact heading, no frontmatter parser, no template engine. The API layer (`course-progress.js`'s
|
||||
`shapeActivity()`) only ever attaches `instructor_notes` to the response when the caller is
|
||||
resolved (server-side, from session identity) to be an instructor — never from a request
|
||||
parameter. A self-paced student and an instructor read the identical file; they just see
|
||||
different slices of it.
|
||||
|
||||
## 4. Completion rules — one source of truth per behavior
|
||||
|
||||
`platform/api/src/course-progress.js`'s `isActivityComplete()` is the only place completion is
|
||||
decided, and it never invents new state for anything that's independently verifiable:
|
||||
|
||||
- **`auto_scenario`** — reads `progress[scenario_id].status === 'completed'`, the exact same
|
||||
`progress` table `curriculum-rules.js` already reads for stage completion (`platform/api/src/
|
||||
progress.js`'s `loadProgress`). A lab activity's completion is a real fact about whether the
|
||||
student actually solved the scenario, not a flag a course layer could get out of sync with.
|
||||
- **`auto_assessment`** — reads `achievements` (`achievement_type IN ('assessment_completed',
|
||||
'capstone_completed')` with `metadata->>'assessment_definition_id'` matching), the exact
|
||||
mechanism `achievements.js`'s `recordAssessmentAchievement` already writes on a real pass. No
|
||||
new achievement type, no new table.
|
||||
- **`manual_marker`** — the ONE new piece of state this phase adds: `activity_completions`
|
||||
(student_id, activity_id). Used ONLY for activity types with nothing else to derive completion
|
||||
from (a lesson has no "pass/fail," a reflection has no validator). `POST /api/student/
|
||||
activities/:id/complete` refuses (409 `not_manually_completable`) for any activity whose
|
||||
`completion_behavior` isn't `manual_marker` — a student can never fake a lab or assessment
|
||||
completion through this route.
|
||||
|
||||
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
|
||||
ANY prerequisite is not satisfied, where **satisfied** means: the prerequisite has zero total
|
||||
activities (a PLANNED entry — vacuously satisfied, since there's nothing to have completed) OR
|
||||
the prerequisite is fully complete. This is deliberate: without the "zero activities is vacuously
|
||||
satisfied" clause, an honestly-PLANNED course with no content yet would permanently lock every
|
||||
real course that lists it as a prerequisite, which would perversely punish honesty about what
|
||||
isn't built yet. See `course-progress.js`'s `prerequisitesSatisfied()`.
|
||||
|
||||
Locking is computed in two passes over each level (compute every entry's totals/prerequisite-
|
||||
check, THEN strip working state) rather than one — mutating and reading totals in the same pass
|
||||
made the result depend on Map iteration order, a real bug caught by `e2e-phase6a.js` during
|
||||
development (see that file's "found by testing" comment in `course-progress.js`).
|
||||
|
||||
## 6. Draft vs. published — separate from READY/PARTIAL/PLANNED
|
||||
|
||||
`status` (`ready`/`partial`/`planned`) describes authoring completeness and is shown to
|
||||
everyone, including students, honestly ("PLANNED — coming soon" is useful information, not a
|
||||
leak). `visibility` (`published`/`draft`) is a hard gate: a `draft` course/module/activity is
|
||||
excluded from every query a student-facing route runs (`buildCourseTree`'s `visClause`), full
|
||||
stop, regardless of its `status`. Use `draft` for genuinely unfinished authoring work you don't
|
||||
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`,
|
||||
`progress`, or `scenario_attempts`. No existing API route's behavior changed (the `/api/
|
||||
progress/record`, `/api/assessments/*`, `/api/capstone` routes are byte-for-byte unchanged).
|
||||
The course layer is purely additive: new tables, new routes, new frontend views, reading
|
||||
existing tables through their existing shapes.
|
||||
+76
-3
@@ -88,6 +88,12 @@ To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
|
||||
`scenarios/data/*.json` ids. No backend code change needed — it's read fresh via
|
||||
`POST /api/cache/reload` or on server restart.
|
||||
|
||||
As of Phase 6A, `root_` also has a course/module/activity layer ON TOP of this (lessons,
|
||||
demonstrations, guided exercises around the same scenario ids) — see §17 and
|
||||
`docs/COURSE_CONTENT_MODEL.md`. `rootnode-path.json` itself is unmodified and still the thing
|
||||
`curriculum-rules.js` computes stage lock/completion from; the course layer is a parallel,
|
||||
additive structure that references the same scenario ids, not a replacement.
|
||||
|
||||
## 5. Student identity — what's built, what isn't
|
||||
|
||||
**Current state (Phase 5A — see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the
|
||||
@@ -416,6 +422,73 @@ Full design: `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`, plus
|
||||
from website enrollment through a real rendered/extracted activation email, a real Lab launch
|
||||
and scenario completion, a second enrollment's confirmation email, and a full password-reset
|
||||
round trip) — both against a live stack, using only the dev provider (no real network calls).
|
||||
Live Google Workspace delivery itself is implemented and unit-level ready
|
||||
(`infra/scripts/gws-email-test.js`, opt-in) but not yet validated against a real Workspace
|
||||
domain — see that doc's final section.
|
||||
Live Google Workspace delivery has since been validated for real, end to end, via
|
||||
`academy@rootnodeacademy.com` — see `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` §14.
|
||||
|
||||
## 17. ROOT_ Course Architecture + Content Delivery Foundation (Phase 6A)
|
||||
|
||||
Full design: `docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`, plus `docs/ROOT_CURRICULUM_INVENTORY.md`,
|
||||
`docs/ROOT_PROGRAM_ARCHITECTURE.md`, `docs/COURSE_CONTENT_MODEL.md`, and
|
||||
`docs/COURSE_AUTHORING_STANDARD.md`.
|
||||
|
||||
- **What it is:** a course → module → activity content model (`022_course_content.sql`) layered
|
||||
over the existing scenario/assessment/achievement systems for `root_` only, so a lesson can be
|
||||
taught BEFORE a lab is presented instead of the platform only ever showing a bare scenario
|
||||
list. A `lab` activity references an existing `scenario_id`; an `assessment`/`capstone`
|
||||
activity references an existing `assessment_definition_id` — neither duplicates content.
|
||||
Long-form lesson prose lives in git-tracked Markdown (`content/root/`), split into
|
||||
shared/instructor-only sections by a `## Instructor Notes` heading convention
|
||||
(`content.js`). One `buildCourseTree()` (`course-progress.js`) serves both the self-paced
|
||||
student route and the instructor curriculum view — no forked curriculum for the two delivery
|
||||
modes.
|
||||
- **Completion, never a second source of truth:** a lab's completion reads the existing
|
||||
`progress` table; an assessment/capstone activity's completion reads the existing
|
||||
`achievements` table; only lesson-only activities (nothing else to derive completion from) get
|
||||
the one genuinely new table, `activity_completions`.
|
||||
- **A real bug found and fixed during testing:** computing a module/course's prerequisite lock
|
||||
and then stripping its working totals in the SAME map iteration made the result depend on Map
|
||||
iteration order — a dependent entry could see its prerequisite's totals already deleted. Fixed
|
||||
by splitting into two explicit passes (`course-progress.js`).
|
||||
- **One gold-standard module fully authored:** ROOT 10's Kubernetes Health Probes &
|
||||
Troubleshooting (lesson → demonstration → knowledge check → guided exercise → real Lab
|
||||
`rootnode-probe-port-mismatch` → reflection), the template `docs/COURSE_AUTHORING_STANDARD.md`
|
||||
documents for all future modules. Every other course is honestly `partial` (real labs, no
|
||||
lessons yet) or `planned` (no content exists) — nothing was fabricated to look finished.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase6a.js` (21 checks: hierarchy/ordering,
|
||||
all three completion behaviors, prerequisite locking, resume, cross-student isolation, RBAC
|
||||
for self-paced no-cohort students, draft-content hiding, version stability) and
|
||||
`infra/scripts/drills/test-phase6a-course-rehearsal.js` (12 checks, real k3s: a student reads
|
||||
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).
|
||||
|
||||
@@ -519,3 +519,16 @@ Lab container out-of-band and restarting the Provisioner to trigger its actual
|
||||
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
|
||||
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
|
||||
is completely unaffected and a fresh Lab boots normally.
|
||||
|
||||
## Phase 6A: reused, not modified — a teaching layer in front, still one Lab
|
||||
|
||||
Phase 6A (`docs/PHASE6A_ROOT_COURSE_FOUNDATION.md`) added a course/module/activity content model
|
||||
in front of the exact same disposable Lab this document describes — no change to the
|
||||
Provisioner, `environments`, the reaper, the Lab Host Router, or the Lab image itself
|
||||
(`Dockerfile`/`entrypoint.sh`/`backend/server.js` untouched). A course's `lab` activity is a
|
||||
`scenario_id` reference; "Launch Lab" from the course view calls the identical
|
||||
`/api/student/programs/:id/launch` sequence Phase 5B already established — not a third launch
|
||||
path. Verified for real, not assumed: `infra/scripts/drills/test-phase6a-course-rehearsal.js`
|
||||
launches a real Lab, calls the Lab's own `POST /api/scenarios/:id/setup` through the Gateway's
|
||||
existing `/lab/:token/*` proxy exactly as the browser UI does, and runs a real `kubectl patch`
|
||||
against the live k3s cluster inside that container before recording completion.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Phase 6A — ROOT_ Course Architecture + Content Delivery Foundation
|
||||
|
||||
**Status: PHASE 6A COMPLETE. COURSE/MODULE/ACTIVITY CONTENT MODEL LIVE. GOLD-STANDARD MODULE
|
||||
READY, VALIDATED AGAINST A REAL LAB.**
|
||||
|
||||
Phases 1-5 built the RootNode Academy platform: identity, cohorts, curriculum, the assessment
|
||||
engine, remote classrooms, self-paced enrollment, and enrollment communications. All of it sat
|
||||
on top of `scenarios/rootnode-path.json` — a flat list of scenario ids per stage, with no notion
|
||||
of a lesson, a demonstration, or "learn this before you're asked to solve it." Phase 6A builds
|
||||
that layer, for the `root_` track only, without touching any of the systems underneath it.
|
||||
|
||||
## 1. The canonical youth ladder (context, not built here)
|
||||
|
||||
RootNode Academy's product spans three tracks by age/stage:
|
||||
|
||||
- **`init_`** (~5-10) — robotics / computational thinking / block coding. Not built.
|
||||
- **`compile_`** (~11-14) — electronics / microcontrollers / ESP32 / programming. Not built.
|
||||
- **`root_`** (~15-18+, college, adult) — Linux → systems → containers → Kubernetes → DevOps/
|
||||
platform engineering. **This phase.**
|
||||
|
||||
`programs.family` (Phase 5B) already models this three-way split; only `root` has real content.
|
||||
Phase 6A does not invent `init_`/`compile_` curriculum — see §15 of the authorization and
|
||||
`docs/ROOT_CURRICULUM_INVENTORY.md` §7 for the evidence that no such content exists yet.
|
||||
|
||||
## 2. Core product principle
|
||||
|
||||
KubeKosh/the RootNode Lab is the LABORATORY, not the whole course. A RootNode course can contain
|
||||
lessons, demonstrations, examples, knowledge checks, guided exercises, labs (existing
|
||||
scenarios), assessments, reflections, and capstone work — a student learns a concept BEFORE
|
||||
being asked to solve the corresponding scenario. See `docs/COURSE_AUTHORING_STANDARD.md`.
|
||||
|
||||
## 3. Google Workspace status (recorded per this phase's instruction, not new work)
|
||||
|
||||
The external blocker at the end of Phase 5C — no real Google Workspace mailbox to test against —
|
||||
was resolved by the owner during this session before Phase 6A began: `[email protected]`
|
||||
now exists and live delivery was validated end to end (isolated send test + a full real
|
||||
activation/reset lifecycle). This is already recorded in `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`
|
||||
(status header, §14) and `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (§11's propagation-delay note),
|
||||
committed at `4c0a950` and tagged `v0.5.3-enrollment-comms` — this phase did not need to redo
|
||||
that work, only confirm it was recorded, which it was.
|
||||
|
||||
## 4. Architecture summary
|
||||
|
||||
```
|
||||
program (existing) ── courses ── course_modules ── module_activities
|
||||
├─ lab → scenario_id (existing)
|
||||
└─ assessment/capstone → assessment_definition_id (existing)
|
||||
```
|
||||
|
||||
Full design: `docs/COURSE_CONTENT_MODEL.md`. Full course/module map:
|
||||
`docs/ROOT_PROGRAM_ARCHITECTURE.md`. Full existing-content inventory this was built from:
|
||||
`docs/ROOT_CURRICULUM_INVENTORY.md`.
|
||||
|
||||
Delivery modes: the same content model serves both instructor-led (Phase 5A classrooms) and
|
||||
self-paced (Phase 5B enrollment) students — one `buildCourseTree()` function
|
||||
(`platform/api/src/course-progress.js`), one `content.js` Markdown loader, consumed by both
|
||||
`course-routes.js` (student) and `admin-course-routes.js` (instructor), the latter additionally
|
||||
exposing instructor notes via the same file's `## Instructor Notes` convention. No forked
|
||||
curriculum.
|
||||
|
||||
## 5. What was built
|
||||
|
||||
**Migration** — `platform/api/migrations/022_course_content.sql`: `courses`, `course_modules`,
|
||||
`module_activities`, `activity_completions`, plus seed data for 13 courses, 8 modules, and 51
|
||||
activities (44 mechanically mapped from existing `rootnode-path.json` stages, 6 gold-module
|
||||
activities, 1 capstone activity) — see that file's own extensive header comments.
|
||||
|
||||
**Content** — `content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/`:
|
||||
6 Markdown files (lesson, demonstration, knowledge check, guided exercise, lab intro,
|
||||
reflection), each following the shared/instructor-notes split convention.
|
||||
|
||||
**Backend** — `content.js` (Markdown loader), `course-progress.js` (completion derivation +
|
||||
prerequisite locking, shared by both surfaces), `course-routes.js` (student API),
|
||||
`admin-course-routes.js` (instructor API), wired into `server.js`. `platform/api/Dockerfile`
|
||||
now also copies `content/`.
|
||||
|
||||
**Frontend** — `CourseView.jsx` (student: My Programs → ROOT_ → Course → Module → Activity →
|
||||
Launch Lab → back → continue), wired into `ProgramsTab.jsx`'s existing "Continue Learning" flow
|
||||
via a new "View Courses" button (the original direct-to-Lab button is unchanged).
|
||||
`CurriculumView.jsx` (instructor: program structure, per-module teaching detail, instructor
|
||||
notes, per-student progress lookup), wired into `AdminPortal.jsx` as a new "Curriculum" tab.
|
||||
|
||||
**Tests** — `platform/test/e2e-phase6a.js` (21 assertions: hierarchy, ordering, all three
|
||||
completion behaviors, locking, resume, cross-student isolation, RBAC, draft-hiding, version
|
||||
stability) and `infra/scripts/drills/test-phase6a-course-rehearsal.js` +
|
||||
`run-phase6a-course-rehearsal.sh` (a real-k3s dress rehearsal of the gold module end to end,
|
||||
including the Lab's own `setup_commands` and a real `kubectl patch` against the live cluster).
|
||||
|
||||
## 6. The gold-standard module
|
||||
|
||||
`root-10-m01-health-probes-and-troubleshooting`, under ROOT 10 (Kubernetes Troubleshooting &
|
||||
Observability): lesson (probe concepts) → demonstration (reading `kubectl describe pod` Events
|
||||
for a probe failure) → knowledge check → guided exercise (write a readiness probe on paper) →
|
||||
Launch Lab (`rootnode-probe-port-mismatch`, RootNode-authored) → reflection (root cause + Real
|
||||
Ops prevention takeaway). Instructor notes on 5 of its 6 activities (demo plan, misconceptions,
|
||||
timing, discussion questions, extensions). This is the template `docs/COURSE_AUTHORING_STANDARD.md`
|
||||
documents for all future module authoring.
|
||||
|
||||
## 7. Honest content states
|
||||
|
||||
No course is `ready`. 5 courses (01-04, 11) are `planned` with zero modules — genuinely no
|
||||
content exists for them. 7 courses (05-10, 12, capstone) are `partial` — real, working labs
|
||||
mapped in, one fully `ready` module (the gold standard), everything else awaiting lesson
|
||||
authoring in Phase 6B. Nothing was fabricated to make the UI look more complete than it is.
|
||||
|
||||
## 8. Test results
|
||||
|
||||
- `e2e-phase6a.js`: **21 passed, 0 failed.**
|
||||
- `test-phase6a-course-rehearsal.js` (real k3s Lab): **12 passed, 0 failed**, including a real
|
||||
`kubectl patch` against a live cluster inside the student's actual disposable Lab container.
|
||||
- Full regression (Phase 2A/2B/3A/3B/5A/5B/5C) — see the final report for the run-by-run result;
|
||||
no Phase 6A change touched any file those suites exercise other than additive route
|
||||
registration in `server.js`.
|
||||
|
||||
## 9. What Phase 6A deliberately did not build
|
||||
|
||||
Per the authorization's explicit scope line: `init_`/`compile_` curriculum, a Parent Portal,
|
||||
Stripe/payments, public registration, an AI tutor, chat/video/voice, proctoring, a mobile app,
|
||||
certificates, a multi-host scheduler, production deployment changes, a CMS, or dozens of
|
||||
placeholder lessons. Lesson authoring for courses 06-10/12/capstone beyond the gold standard is
|
||||
explicitly Phase 6B+ scope.
|
||||
|
||||
## 10. Known limitations / recommended Phase 6B scope
|
||||
|
||||
- ROOT 08/09's real, existing scenario content (storage/scheduling, and a 22-scenario CKS
|
||||
security bank) is inventoried but not yet wired into modules — the natural next content slice.
|
||||
- Course-level lesson authoring for ROOT 05-07/10 (m02)/12/capstone: labs exist, teaching
|
||||
content doesn't yet — apply `docs/COURSE_AUTHORING_STANDARD.md` per module.
|
||||
- `POST /api/assessments/:definitionId/start` does not itself re-check the capstone's
|
||||
`unlock_rule` server-side (a pre-existing Phase 3B gap, not introduced by this phase — the
|
||||
student-facing `/api/capstone` view does compute and display lock state correctly, and the
|
||||
UI never exposes a "start" action while locked, but the raw route would technically accept a
|
||||
premature start if called directly). Not fixed here — it's outside the course/content-model
|
||||
scope this phase was authorized for; worth a small, isolated fix in a future phase.
|
||||
- Course-to-course prerequisite locking is currently sequential/manually declared, not derived
|
||||
from the assessment engine's own `unlock_rule` JSON — the two happen to agree today by
|
||||
construction (see `docs/ROOT_PROGRAM_ARCHITECTURE.md`'s "Real Ops and Capstone placement");
|
||||
a future phase could unify them if course content grows complex enough to warrant it.
|
||||
@@ -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.
|
||||
@@ -86,6 +86,16 @@ this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
Gmail API in production, a deterministic dev provider everywhere else). See
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
|
||||
|
||||
7. ~~**No notion of a lesson, only a flat scenario list per curriculum stage.**~~ **Closed in
|
||||
Phase 6A**, for `root_` only. A self-paced enrollment now resolves to a real course/module/
|
||||
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`. **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
|
||||
|
||||
No signup flow, no student password/email auth implementation, no per-student curriculum
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,221 @@
|
||||
# ROOT_ Curriculum Inventory
|
||||
|
||||
Phase 6A §1 — a factual inventory of what already exists in this repository, gathered by
|
||||
inspecting `scenarios/data/`, `scenarios/bundles/`, `scenarios/rootnode-path.json`,
|
||||
`assessment_definitions` (013_assessments.sql), `programs` (019_programs.sql), `achievements`,
|
||||
and the curriculum/progress APIs — before any Phase 6A design work happened. Nothing here was
|
||||
modified to produce this document.
|
||||
|
||||
## 1. The current program model
|
||||
|
||||
One program exists: `root-kubernetes-foundations` (`programs` table, family `root`,
|
||||
`delivery_mode = self_paced`, `curriculum_path = rootnode-path`,
|
||||
`completion_assessment_id = rootnode-capstone`). `init`/`compile` families are supported by the
|
||||
schema's CHECK constraint but have zero seeded programs — no init_/compile_ curriculum exists
|
||||
anywhere in this repository.
|
||||
|
||||
## 2. `scenarios/rootnode-path.json` — the existing stage structure
|
||||
|
||||
The one curriculum path that exists, "RootNode Learning Path," version 1, with 7 stages:
|
||||
|
||||
| Stage | Scenario count | Completion rule |
|
||||
|---|---|---|
|
||||
| `foundation` | 20 | all_required |
|
||||
| `workloads` | 8 | all_required |
|
||||
| `networking` | 2 | all_required |
|
||||
| `configuration` | 5 | all_required |
|
||||
| `troubleshooting` | 5 | all_required |
|
||||
| `real-ops` (bundle `rootnode-real-ops`) | 4 | all_required |
|
||||
| `capstone` (type `capstone`) | 0 (references `rootnode-capstone`) | unlocks when all 6 content stages above are complete |
|
||||
|
||||
44 of the repository's 98 total scenarios are wired into this path. The remaining 54 exist as
|
||||
real, working scenario content but are not referenced by `rootnode-path.json` at all — see §4.
|
||||
|
||||
## 3. `scenarios/bundles/` — exam-prep bundle groupings
|
||||
|
||||
Five bundles exist (`1-k8s-basics`, `2-k8s-appdev`, `3-k8s-admin`, `4-k8s-security`,
|
||||
`rootnode-real-ops`), each a flat list of scenario ids used for CKS-style exam-prep grouping in
|
||||
the existing Practice area — overlapping by design (a scenario can appear in more than one
|
||||
bundle). These are unrelated to `rootnode-path.json`'s stages and untouched by Phase 6A.
|
||||
|
||||
## 4. Full scenario inventory
|
||||
|
||||
For every scenario in `scenarios/data/` (98 total): id, title, category, difficulty, task vs.
|
||||
MCQ, which bundle(s) it's in, which `rootnode-path.json` stage (if any) it's wired into, whether
|
||||
it appears in the `rootnode-capstone` or `rootnode-quick-assessment` `assessment_definitions`
|
||||
pool_spec, and whether it's RootNode-authored or generic upstream content.
|
||||
|
||||
Only 3 scenarios are RootNode-specific (`rootnode-runaway-monitor`,
|
||||
`rootnode-probe-port-mismatch`, `rootnode-enforce-limits`) — every other scenario, including all
|
||||
44 wired into `rootnode-path.json`, is generic upstream Kubernetes/CKS content. This matters for
|
||||
Phase 6A's course design: the "gold-standard module" spec explicitly asked for a RootNode-
|
||||
authored incident as the lab, which narrows the real choice to those 3 (plus `broken-deployment`,
|
||||
generic but already framed as an incident in the `real-ops` stage).
|
||||
|
||||
<!-- Generated from scenarios/data/*.json, scenarios/bundles/*.json, scenarios/rootnode-path.json,
|
||||
and platform/api/migrations/013_assessments.sql's seeded pool_specs — not hand-typed. -->
|
||||
|
||||
| Scenario ID | Title | Category | Difficulty | Type | Bundles | Path Stage | In Assessment | Origin |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| annotate-resource-basics | Annotating Kubernetes Resources | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| broken-deployment | Debug a Failing Deployment | Troubleshooting | Medium | task | 3-k8s-admin, rootnode-real-ops | real-ops | capstone, quick-assessment | generic upstream |
|
||||
| cks-apparmor-profile | AppArmor Profiles | Workload Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-audit-policy | Write a Kubernetes Audit Policy | Cluster Setup | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-automount-token | Disable ServiceAccount Token | Workload Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-drop-capabilities | Drop Linux Capabilities | Workload Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-egress-namespace | Restrict Egress to Namespace | Network Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-image-pull-secret | Private Registry Image Pull Secret | Supply Chain Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-immutable-secret | Immutable Secrets | Cluster Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-api-server | API Server Authentication | Cluster Setup | Easy | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-audit-policy | Kubernetes Audit Logging Levels | Cluster Setup | Medium | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-image-footprint | Minimizing Image Footprint | Supply Chain Security | Easy | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-kubelet-auth | Securing the Kubelet | Cluster Setup | Medium | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-psp-replacement | Pod Security Evolution | Cluster Security | Easy | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-runtime-security | Runtime Security Tools | System Hardening | Medium | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-mcq-sandboxing | Container Runtime Sandboxing | System Hardening | Hard | mcq | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-network-policy | Default Deny Network Policy | Network Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-network-policy-metadata | Block Cloud Metadata | Network Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-non-root-enforce | Enforce Non-Root Container | Workload Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-pod-security-context | Pod Security Context | Workload Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-psa-namespace | Pod Security Admission | Cluster Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-rbac-clusterrole | Cluster-Level RBAC | Cluster Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-rbac-least-privilege | RBAC Least Privilege | Cluster Security | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-readonly-filesystem | Read-Only Root Filesystem | Workload Security | Easy | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-runtime-class | RuntimeClass for Sandboxed Workloads | System Hardening | Medium | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-seccomp-profile | Seccomp Profiles | Workload Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cks-tls-ingress | Secure Ingress with TLS | Network Security | Hard | task | 4-k8s-security | — | — | generic upstream |
|
||||
| cluster-upgrade-mcq | Cluster Upgrade Order | Cluster Administration | Hard | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| configmap-volume | Mount ConfigMap as Volume | Configuration | Medium | task | 1-k8s-basics, 2-k8s-appdev | configuration | — | generic upstream |
|
||||
| container-logging-mcq | Container Logging and Monitoring | Troubleshooting | Easy | mcq | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| crashloop-fix | Fix a CrashLoopBackOff Pod | Troubleshooting | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| cronjob-task | Create a CronJob | Workloads & Scheduling | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| daemonset-mcq | Understanding DaemonSets | Workloads | Easy | mcq | 1-k8s-basics | workloads | quick-assessment | generic upstream |
|
||||
| delete-by-label-basics | Bulk Deletion by Label | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| deploy-nginx | Deploy and Expose Nginx | Workloads | Easy | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| deployment-rollback | Rolling Back a Deployment | Workloads & Scheduling | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| deployment-strategy-mcq | Deployment Update Strategies | Workloads | Medium | mcq | 1-k8s-basics | workloads | — | generic upstream |
|
||||
| dns-resolution-mcq | Kubernetes DNS Resolution | Services & Networking | Easy | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| dry-run-manifest-basics | Generating YAML Manifests | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| edit-deployment-basics | Updating Deployments | Workloads | Easy | task | 1-k8s-basics | workloads | capstone | generic upstream |
|
||||
| emptydir-pod | Shared emptyDir Volume Between Containers | Storage | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| endpoint-fix-task | Fix a Broken Service Selector | Troubleshooting | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| env-vars-configmap | Environment Variables from ConfigMap | Configuration | Easy | task | 1-k8s-basics, 2-k8s-appdev | configuration | capstone | generic upstream |
|
||||
| etcd-backup-mcq | etcd Backup and Restore | Cluster Administration | Hard | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| exec-command-basics | Execute Commands in Pods | Troubleshooting | Easy | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| expose-service-basics | Exposing an Application | Services & Networking | Easy | task | 1-k8s-basics | networking | quick-assessment | generic upstream |
|
||||
| extract-logs-basics | Extract Container Logs | Troubleshooting | Easy | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| field-selector-basics | Filtering Resources with Field Selectors | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| ingress-task | Create an Ingress Resource | Services & Networking | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| init-container-task-basics | Pod with Init Container | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| init-containers-mcq | Init Containers Behaviour | Core Concepts | Easy | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| job-task | Create a One-Shot Job | Workloads & Scheduling | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| jobs-cronjobs-mcq | Jobs vs CronJobs | Workloads | Easy | mcq | 1-k8s-basics | workloads | — | generic upstream |
|
||||
| jsonpath-basics | JSONPath Data Extraction | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-cp-basics | Copy Files to Containers | Troubleshooting | Medium | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| kubectl-diff-basics | Previewing Changes with kubectl diff | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-essentials-mcq | Essential kubectl Commands | Core Concepts | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| kubectl-explain-basics | Exploring the API with kubectl explain | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-output-format-mcq | kubectl Output Formats | Core Concepts | Easy | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| kubectl-patch-basics | Patching Resources with kubectl patch | Core Concepts | Medium | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| labels-selectors-mcq | Labels and Selectors | Core Concepts | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| limitrange-task | LimitRange for Default Container Limits | Workloads & Scheduling | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| liveness-probe-task | Configure a Liveness Probe | Workloads | Medium | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| multi-container-pod-basics | Create a Multi-Container Pod | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| namespaces-basics | Working with Namespaces | Core Concepts | Easy | task | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| network-policy | Isolate Traffic with NetworkPolicy | Networking | Hard | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| networkpolicy-egress | Egress NetworkPolicy | Security | Hard | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| node-cordon-basics | Cordoning and Uncordoning a Node | Cluster Management | Easy | task | 1-k8s-basics | troubleshooting | capstone, quick-assessment | generic upstream |
|
||||
| node-label-selector | Node Labels and nodeSelector | Cluster Administration | Easy | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| node-taint-toleration | Taints and Tolerations | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| nodeport-task | Expose a Deployment via NodePort | Services & Networking | Easy | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| pod-affinity-mcq | Pod Affinity vs Anti-Affinity | Workloads & Scheduling | Medium | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
| pod-basics-mcq | Pod Lifecycle States | Core Concepts | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | foundation | — | generic upstream |
|
||||
| pod-disruption-budget-task | Configure a PodDisruptionBudget | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| pod-env-vars-inline | Pod with Inline Environment Variables | Configuration | Easy | task | 1-k8s-basics | configuration | — | generic upstream |
|
||||
| pod-labeling-basics | Labelling Resources | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| pod-node-assignment-mcq | Assigning Pods to Nodes | Scheduling | Medium | mcq | 1-k8s-basics | troubleshooting | — | generic upstream |
|
||||
| pod-security-context | Pod Security Context | Security | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| priority-class-task | Pod Priority and PriorityClass | Workloads & Scheduling | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| pv-pvc-mount | Persistent Storage with PV and PVC | Storage | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| pvc-dynamic-task | Dynamic PVC Provisioning | Storage | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| rbac-clusterrole | ClusterRole and ClusterRoleBinding | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| rbac-role | RBAC: Create Role and RoleBinding | Security | Hard | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| readiness-probe-task | Configure a Readiness Probe | Workloads & Scheduling | Medium | task | 2-k8s-appdev, 3-k8s-admin | — | — | generic upstream |
|
||||
| resource-limits-task | Resource Requests and Limits | Configuration | Easy | task | 1-k8s-basics, 2-k8s-appdev | configuration | capstone | generic upstream |
|
||||
| resource-quota-ns | ResourceQuota for a Namespace | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| restart-policy-mcq | Pod Restart Policies | Core Concepts | Easy | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| rolling-update-task | Perform a Rolling Update | Workloads | Easy | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| rootnode-enforce-limits | Stop the Next Outage: Enforce Resource Defaults | Observability & Ops | Hard | task | rootnode-real-ops | real-ops | capstone, quick-assessment | RootNode-specific |
|
||||
| rootnode-probe-port-mismatch | Stuck Not-Ready: The Probe Port Mismatch | Observability & Ops | Medium | task | rootnode-real-ops | real-ops | capstone, quick-assessment | RootNode-specific |
|
||||
| rootnode-runaway-monitor | Recover the Monitoring Stack | Observability & Ops | Medium | task | rootnode-real-ops | real-ops | capstone, quick-assessment | RootNode-specific |
|
||||
| scale-deployment | Scaling a Deployment | Workloads | Easy | task | 1-k8s-basics, 2-k8s-appdev | workloads | capstone, quick-assessment | generic upstream |
|
||||
| secrets-basics | Creating and Using Secrets | Configuration | Easy | task | 1-k8s-basics, 2-k8s-appdev | configuration | capstone | generic upstream |
|
||||
| serviceaccount-pod | ServiceAccount for Pod API Access | Cluster Administration | Medium | task | 3-k8s-admin | — | — | generic upstream |
|
||||
| services-mcq | Kubernetes Service Types | Networking | Easy | mcq | 1-k8s-basics, 2-k8s-appdev | networking | quick-assessment | generic upstream |
|
||||
| sort-by-basics | Sorting kubectl Output | Core Concepts | Easy | task | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| static-pods-mcq | Static Pods in Kubernetes | Core Concepts | Medium | mcq | 1-k8s-basics | foundation | — | generic upstream |
|
||||
| storageclass-mcq | StorageClass Reclaim Policies | Storage | Easy | mcq | 3-k8s-admin | — | — | generic upstream |
|
||||
|
||||
## 5. Unused content bank, grouped for future ROOT_ modules
|
||||
|
||||
The 54 scenarios with no Path Stage above, grouped by category — a real, working content bank
|
||||
ROOT 07/08/09/10 can draw on in Phase 6B, not content that needs to be invented:
|
||||
|
||||
- **Workload Security (7):** cks-apparmor-profile, cks-automount-token, cks-drop-capabilities, cks-non-root-enforce, cks-pod-security-context, cks-readonly-filesystem, cks-seccomp-profile
|
||||
- **Cluster Setup (4):** cks-audit-policy, cks-mcq-api-server, cks-mcq-audit-policy, cks-mcq-kubelet-auth
|
||||
- **Network Security (4):** cks-egress-namespace, cks-network-policy-metadata, cks-network-policy, cks-tls-ingress
|
||||
- **Supply Chain Security (2):** cks-image-pull-secret, cks-mcq-image-footprint
|
||||
- **Cluster Security (5):** cks-immutable-secret, cks-mcq-psp-replacement, cks-psa-namespace, cks-rbac-clusterrole, cks-rbac-least-privilege
|
||||
- **System Hardening (3):** cks-mcq-runtime-security, cks-mcq-sandboxing, cks-runtime-class
|
||||
- **Cluster Administration (8):** cluster-upgrade-mcq, etcd-backup-mcq, node-label-selector, node-taint-toleration, pod-disruption-budget-task, rbac-clusterrole, resource-quota-ns, serviceaccount-pod
|
||||
- **Troubleshooting (3):** container-logging-mcq, crashloop-fix, endpoint-fix-task
|
||||
- **Workloads & Scheduling (7):** cronjob-task, deployment-rollback, job-task, limitrange-task, pod-affinity-mcq, priority-class-task, readiness-probe-task
|
||||
- **Services & Networking (3):** dns-resolution-mcq, ingress-task, nodeport-task
|
||||
- **Storage (4):** emptydir-pod, pv-pvc-mount, pvc-dynamic-task, storageclass-mcq
|
||||
- **Networking (1):** network-policy
|
||||
- **Security (3):** networkpolicy-egress, pod-security-context, rbac-role
|
||||
|
||||
## 6. Other existing systems inspected
|
||||
|
||||
- **Assessment definitions** (`assessment_definitions`): `rootnode-capstone` (90 min, hint_policy
|
||||
`flagged`, required = the 4 real-ops scenarios, random draw across workloads/config/
|
||||
troubleshooting) and `rootnode-quick-assessment` (60 min exam, fully random draw). Both real,
|
||||
both working, neither touched by Phase 6A — the new capstone course activity references
|
||||
`rootnode-capstone` by id only.
|
||||
- **Achievements** (`achievements`): 4 types already implemented — `stage_completed`,
|
||||
`curriculum_completed`, `assessment_completed`, `capstone_completed`. Phase 6A's
|
||||
`auto_assessment` completion behavior reads `assessment_completed`/`capstone_completed` rows
|
||||
by `metadata->>'assessment_definition_id'` — no new achievement type needed.
|
||||
- **Progress/attempt model** (`progress`, `scenario_attempts`): unchanged. `progress` is what
|
||||
`curriculum-rules.js` and the new `auto_scenario` completion behavior both read.
|
||||
- **Curriculum APIs**: `curriculum.js` loads scenarios/bundles/path from disk into an in-process
|
||||
cache; `curriculum-rules.js` computes stage lock/completion/percent purely from that path +
|
||||
a progress map. Phase 6A's `course-progress.js` follows the identical shape one level higher
|
||||
(course/module/activity instead of stage/scenario).
|
||||
- **Self-paced dashboard** (`ProgramsTab.jsx`) and **classroom dashboard** (`ClassroomTab.jsx`,
|
||||
admin `ClassroomView.jsx`): both currently show curriculum STATE (percent, next scenario) with
|
||||
no notion of lessons/modules — Phase 6A's `CourseView.jsx`/`CurriculumView.jsx` add that layer
|
||||
alongside, not instead of, these.
|
||||
- **Admin curriculum/program tooling** (`admin-learning-routes.js`, `admin-programs-routes.js`):
|
||||
read-only scenario/stage views and program/enrollment CRUD; no course/module concept existed
|
||||
before this phase.
|
||||
|
||||
## 7. What this inventory rules out
|
||||
|
||||
- No init_/compile_ curriculum content exists anywhere — confirmed by a full scan of
|
||||
`scenarios/data/`, not an assumption.
|
||||
- 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.
|
||||
@@ -0,0 +1,109 @@
|
||||
# ROOT_ Program Architecture
|
||||
|
||||
Phase 6A §2 — the canonical course architecture for `root_` (Linux → systems → containers →
|
||||
Kubernetes → DevOps/platform engineering, ages ~15-18+/college/adult per the youth ladder — see
|
||||
`docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` §1). This document is the design; the actual seed data
|
||||
lives in `platform/api/migrations/022_course_content.sql`, generated to match this exactly.
|
||||
|
||||
All 13 rows below are `courses` under the ONE existing program, `root-kubernetes-foundations`
|
||||
(`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 |
|
||||
|---|---|---|---|
|
||||
| 1 | Computing & Linux Foundations | **PLANNED** | No lab content exists in this repository — every scenario is Kubernetes/CKS. |
|
||||
| 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** | 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
|
||||
one complete module (the gold standard) as the authoring template, not a finished curriculum.
|
||||
|
||||
## Prerequisite chain
|
||||
|
||||
```
|
||||
01 Linux Foundations → 02 Linux Admin/Networking → 03 Git → 04 Containers/Docker
|
||||
│
|
||||
▼
|
||||
05 Kubernetes Foundations
|
||||
│
|
||||
▼
|
||||
06 Application Workloads
|
||||
│ │
|
||||
┌─────────────────────┤ │
|
||||
▼ ▼ ▼
|
||||
07 Networking/Config 09 Security 10 Troubleshooting
|
||||
│ │ │
|
||||
▼ │ ▼
|
||||
08 Storage/Scheduling │ 11 DevOps/Platform
|
||||
▼
|
||||
12 RootNode Real Ops
|
||||
│
|
||||
▼
|
||||
CAPSTONE — Operate & Recover
|
||||
```
|
||||
|
||||
01-04 have no prerequisite chain enforced against each other yet (all PLANNED, zero content —
|
||||
locking a PLANNED course behind another PLANNED course would just be decorative). 05 is the
|
||||
first course with a real prerequisite gate. See `docs/COURSE_CONTENT_MODEL.md`'s "prerequisite/
|
||||
locking rule" for why a PLANNED/content-free prerequisite never actually blocks anything.
|
||||
|
||||
## Deviations from the spec's suggested sequence, and why
|
||||
|
||||
The authorization's suggested sequence listed Security (09) after Storage/Scheduling (08) and
|
||||
before Troubleshooting (10). This document keeps that numbering for continuity, but the
|
||||
**prerequisite graph** branches 09 and 10 both directly off 06 (Application Workloads) rather
|
||||
than chaining 08→09→10 linearly — the existing content doesn't actually build 09 on top of 08's
|
||||
concepts (RBAC/PSA/network-policy security content is orthogonal to PV/PVC/scheduling content),
|
||||
so a straight linear chain would impose a false dependency. This is exactly the case the
|
||||
authorization's "if existing content suggests a better grouping, document the reasoning" line
|
||||
anticipated.
|
||||
|
||||
## Real Ops and Capstone placement
|
||||
|
||||
Real Ops (12) sits after Troubleshooting (10), matching the existing curriculum's own stage
|
||||
order (`rootnode-path.json`'s `real-ops` stage already comes after `troubleshooting`) — Real Ops
|
||||
scenarios assume the diagnostic skills Troubleshooting teaches. The Capstone sits after Real Ops
|
||||
alone, not after every course, because that's what the existing `rootnode-capstone` assessment
|
||||
definition's own `unlock_rule` already requires (all 6 content stages of `rootnode-path.json`
|
||||
complete — which necessarily includes 05-06-07-10-12's underlying scenarios, plus networking/
|
||||
configuration). 08, 09, and 11 are not currently gating the capstone, because their scenario
|
||||
content isn't part of `rootnode-path.json`'s stage list at all yet — see
|
||||
`docs/COURSE_CONTENT_MODEL.md` for how course-level completion is computed independently of that
|
||||
assessment-engine unlock_rule (the two systems currently agree by construction, not by a shared
|
||||
implementation).
|
||||
|
||||
## Why one program, not thirteen
|
||||
|
||||
`programs` (Phase 5B) models the commercial/academic unit a student enrolls in. root_ is
|
||||
described by this authorization as one continuous ladder — "Linux → systems → containers →
|
||||
Kubernetes → DevOps/platform engineering" — not thirteen separate purchases. Attaching all 13
|
||||
courses to the single existing `root-kubernetes-foundations` program means:
|
||||
|
||||
- Zero changes to the `programs` table, enrollment model, or entitlement logic — an enrolled
|
||||
student automatically has structured access to the whole ladder as it's built out.
|
||||
- No renaming/redefinition of the existing program (which would risk a real, if cosmetic, break
|
||||
for anyone already enrolled) — see Phase 6A's explicit "do not redesign working systems"
|
||||
constraint.
|
||||
|
||||
If a future phase needs `root_` split into separately-purchasable programs (e.g. "Kubernetes
|
||||
Foundations" sold standalone from "DevOps"), that's a `programs`-table decision to make
|
||||
deliberately later, informed by real pricing/business requirements — not something to guess at
|
||||
here.
|
||||
@@ -11,6 +11,7 @@ import AssessmentsView from './AssessmentsView'
|
||||
import ClassroomView from './ClassroomView'
|
||||
import ProgramsView from './ProgramsView'
|
||||
import NotificationsView from './NotificationsView'
|
||||
import CurriculumView from './CurriculumView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
@@ -24,6 +25,7 @@ const NAV = [
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'programs', label: 'Programs' },
|
||||
{ key: 'curriculum', label: 'Curriculum' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
@@ -66,6 +68,7 @@ export default function AdminPortal() {
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'programs' && <ProgramsView role={instructor.role} />}
|
||||
{view === 'curriculum' && <CurriculumView />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
const ACTIVITY_LABELS = {
|
||||
lesson: 'Lesson', demonstration: 'Demonstration', knowledge_check: 'Knowledge Check',
|
||||
guided_exercise: 'Guided Exercise', lab: 'Lab', reflection: 'Reflection',
|
||||
assessment: 'Assessment', capstone: 'Capstone',
|
||||
}
|
||||
|
||||
function statusPill(status) {
|
||||
const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
// Phase 6A §7 — instructor curriculum visibility: program structure with instructor notes,
|
||||
// mapped scenarios/assessments, and estimated teaching time, plus an optional per-student
|
||||
// progress lookup so a teacher can see where someone is stuck. Read-only — authoring stays
|
||||
// Git/migration-based this phase (docs/COURSE_CONTENT_MODEL.md), so there is no edit form here.
|
||||
export default function CurriculumView() {
|
||||
const [programs, setPrograms] = useState(null)
|
||||
const [programId, setProgramId] = useState(null)
|
||||
const [courses, setCourses] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [studentCourses, setStudentCourses] = useState(null)
|
||||
const [studentError, setStudentError] = useState(null)
|
||||
const [openCourse, setOpenCourse] = useState(null)
|
||||
const [openModule, setOpenModule] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.programs().then((p) => { setPrograms(p); if (p[0]) setProgramId(p[0].program_id) }).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const loadCourses = useCallback(() => {
|
||||
if (!programId) return
|
||||
adminApi.programCourses(programId).then(setCourses).catch((e) => setError(e.message))
|
||||
}, [programId])
|
||||
useEffect(loadCourses, [loadCourses])
|
||||
|
||||
const lookupStudent = async (e) => {
|
||||
e.preventDefault()
|
||||
setStudentError(null); setStudentCourses(null)
|
||||
try { setStudentCourses(await adminApi.studentCourses(studentId.trim(), programId)) }
|
||||
catch (err) { setStudentError(err.message) }
|
||||
}
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!programs) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
const course = openCourse && courses?.find((c) => c.course_id === openCourse)
|
||||
const mod = course && openModule && course.modules.find((m) => m.module_id === openModule)
|
||||
const progressFor = (activityId) => studentCourses?.flatMap((c) => c.modules).flatMap((m) => m.activities).find((a) => a.activity_id === activityId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.card}>
|
||||
<h2>Curriculum</h2>
|
||||
<select className={styles.input} value={programId || ''} onChange={(e) => { setProgramId(e.target.value); setOpenCourse(null); setOpenModule(null) }}>
|
||||
{programs.map((p) => <option key={p.program_id} value={p.program_id}>{p.name}</option>)}
|
||||
</select>
|
||||
|
||||
<form onSubmit={lookupStudent} style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<input className={styles.input} placeholder="student_id — check where they're stuck" value={studentId} onChange={(e) => setStudentId(e.target.value)} />
|
||||
<button className={styles.btnSecondary} disabled={!studentId.trim()}>Look up progress</button>
|
||||
</form>
|
||||
{studentError && <div className={styles.errorBox} style={{ marginTop: '0.5rem' }}>{studentError}</div>}
|
||||
</div>
|
||||
|
||||
{!courses ? <p className={styles.muted}>Loading…</p> : mod ? (
|
||||
<div className={styles.card}>
|
||||
<button className={styles.linkBtn} onClick={() => setOpenModule(null)}>← {course.title}</button>
|
||||
<h3 style={{ marginTop: '0.5rem' }}>{mod.title} {statusPill(mod.status)}</h3>
|
||||
{mod.description && <p className={styles.muted}>{mod.description}</p>}
|
||||
{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>Required</th><th>Status</th>{studentCourses && <th>Student</th>}</tr></thead>
|
||||
<tbody>
|
||||
{mod.activities.map((a) => (
|
||||
<tr key={a.activity_id}>
|
||||
<td className={styles.muted}>{a.sequence}</td>
|
||||
<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>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{mod.activities.some((a) => a.instructor_notes) && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<h3>Instructor notes</h3>
|
||||
{mod.activities.filter((a) => a.instructor_notes).map((a) => (
|
||||
<details key={a.activity_id} style={{ marginBottom: '0.5rem' }}>
|
||||
<summary style={{ cursor: 'pointer' }}>{a.title}</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '0.82rem', color: 'var(--text-2)' }}>{a.instructor_notes}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : course ? (
|
||||
<div className={styles.card}>
|
||||
<button className={styles.linkBtn} onClick={() => setOpenCourse(null)}>← All courses</button>
|
||||
<h3 style={{ marginTop: '0.5rem' }}>{course.title} {statusPill(course.status)}</h3>
|
||||
{course.description && <p className={styles.muted}>{course.description}</p>}
|
||||
{course.estimated_duration_hours && <p className={styles.muted}>Estimated: ~{course.estimated_duration_hours} hours</p>}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Module</th><th>Activities</th><th>Est. min</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{course.modules.length === 0 ? (
|
||||
<tr><td colSpan={5} className={styles.muted}>No modules authored yet.</td></tr>
|
||||
) : course.modules.map((m) => (
|
||||
<tr key={m.module_id}>
|
||||
<td className={styles.muted}>{m.sequence}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setOpenModule(m.module_id)}>{m.title}</button></td>
|
||||
<td className={styles.muted}>{m.activities.length}</td>
|
||||
<td className={styles.muted}>{m.estimated_duration_minutes || '—'}</td>
|
||||
<td>{statusPill(m.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.card}>
|
||||
<h3>ROOT_ program structure</h3>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Course</th><th>Modules</th><th>Est. hours</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{courses.map((c) => (
|
||||
<tr key={c.course_id}>
|
||||
<td className={styles.muted}>{c.sequence}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setOpenCourse(c.course_id)}>{c.title}</button></td>
|
||||
<td className={styles.muted}>{c.modules.length}</td>
|
||||
<td className={styles.muted}>{c.estimated_duration_hours || '—'}</td>
|
||||
<td>{statusPill(c.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -134,4 +134,8 @@ export const adminApi = {
|
||||
studentNotifications: (studentId) => get(`/api/admin/students/${studentId}/notifications`),
|
||||
resendActivation: (studentId) => post(`/api/admin/students/${studentId}/resend-activation`),
|
||||
sendEnrollmentConfirmation: (studentId, enrollmentId) => post(`/api/admin/students/${studentId}/send-enrollment-confirmation`, enrollmentId ? { enrollment_id: enrollmentId } : undefined),
|
||||
|
||||
// ── Phase 6A §7: course/module/activity curriculum view ──────────────────────────────────
|
||||
programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`),
|
||||
studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
|
||||
const ACTIVITY_LABELS = {
|
||||
lesson: 'Lesson', demonstration: 'Demonstration', knowledge_check: 'Knowledge Check',
|
||||
guided_exercise: 'Guided Exercise', lab: 'Lab', reflection: 'Reflection',
|
||||
assessment: 'Assessment', capstone: 'Capstone',
|
||||
}
|
||||
|
||||
// Phase 6A §6 — structured course learning, nested inside "My Programs" (not a second portal).
|
||||
// Local drill-down state only (course -> module -> activity), same "no router" convention as
|
||||
// the rest of the Platform Shell (see PlatformShell.jsx). Separate from PracticeTab — this is
|
||||
// the guided course path, Practice stays the free-form scenario list it always was.
|
||||
export default function CourseView({ enrollmentId, onLaunchLab, onClose }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [openCourse, setOpenCourse] = useState(null)
|
||||
const [openModule, setOpenModule] = useState(null)
|
||||
const [openActivity, setOpenActivity] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.courses(enrollmentId).then(setData).catch((e) => setError(e.message))
|
||||
}, [enrollmentId])
|
||||
useEffect(load, [load])
|
||||
|
||||
if (error) return <div className={styles.errorBox}>{error}</div>
|
||||
if (!data) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
const course = openCourse && data.courses.find((c) => c.course_id === openCourse)
|
||||
const mod = course && openModule && course.modules.find((m) => m.module_id === openModule)
|
||||
const activity = mod && openActivity && mod.activities.find((a) => a.activity_id === openActivity)
|
||||
|
||||
const crumb = (label, onClick) => <button key={label} className={styles.linkBtn} onClick={onClick}>{label}</button>
|
||||
|
||||
if (activity) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null); setOpenActivity(null) })}
|
||||
{crumb(course.title, () => setOpenActivity(null))}
|
||||
</div>
|
||||
<ActivityDetail activity={activity} onLaunchLab={onLaunchLab} onChange={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mod) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
{crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null) })}
|
||||
{crumb(course.title, () => setOpenModule(null))}
|
||||
</div>
|
||||
<h2>{mod.title}</h2>
|
||||
{mod.description && <p className={styles.muted}>{mod.description}</p>}
|
||||
<ModuleMeta mod={mod} />
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{mod.activities.map((a) => (
|
||||
<ActivityRow key={a.activity_id} activity={a} onOpen={() => setOpenActivity(a.activity_id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (course) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
|
||||
{crumb('← All courses', () => setOpenCourse(null))}
|
||||
</div>
|
||||
<h2>{course.title}</h2>
|
||||
{course.description && <p className={styles.muted}>{course.description}</p>}
|
||||
{course.objectives?.length > 0 && (
|
||||
<ul style={{ marginTop: '0.5rem' }}>
|
||||
{course.objectives.map((o, i) => <li key={i} className={styles.checklistItem}>{o}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{course.modules.length === 0 ? (
|
||||
<p className={styles.muted}>No modules published for this course yet.</p>
|
||||
) : course.modules.map((m) => (
|
||||
<ModuleRow key={m.module_id} mod={m} onOpen={() => !m.locked && setOpenModule(m.module_id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.btnRow} style={{ marginBottom: '1rem' }}>
|
||||
{onClose && crumb('← Back to program', onClose)}
|
||||
</div>
|
||||
<h2>ROOT_ Courses</h2>
|
||||
{data.resume && (
|
||||
<p className={styles.nextUp}>
|
||||
Resume: {data.courses.find((c) => c.course_id === data.resume.course_id)?.title}
|
||||
</p>
|
||||
)}
|
||||
{data.courses.map((c) => (
|
||||
<CourseRow key={c.course_id} course={c} onOpen={() => !c.locked && setOpenCourse(c.course_id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function statusPill(status) {
|
||||
const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted
|
||||
return <span className={`${styles.pill} ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function CourseRow({ course, onOpen }) {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '0.75rem 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button className={styles.linkBtn} disabled={course.locked} onClick={onOpen} style={{ fontWeight: 600, textAlign: 'left' }}>
|
||||
{course.title}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{statusPill(course.status)}
|
||||
{course.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!course.locked && course.modules.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
<div className={styles.progressBarTrack}><div className={styles.progressBarFill} style={{ width: `${course.percent_complete}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleRow({ mod, onOpen }) {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '0.6rem 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button className={styles.linkBtn} disabled={mod.locked} onClick={onOpen} style={{ textAlign: 'left' }}>{mod.title}</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{mod.complete ? <span className={`${styles.pill} ${styles.pillOk}`}>complete</span> : statusPill(mod.status)}
|
||||
{mod.locked && <span className={`${styles.pill} ${styles.pillMuted}`}>locked</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleMeta({ mod }) {
|
||||
if (!mod.objectives?.length && !mod.estimated_duration_minutes) return null
|
||||
return (
|
||||
<div className={styles.hint} style={{ marginTop: '0.5rem' }}>
|
||||
{mod.estimated_duration_minutes && <div>Estimated time: ~{mod.estimated_duration_minutes} min</div>}
|
||||
{mod.objectives?.length > 0 && (
|
||||
<ul style={{ marginTop: '0.3rem' }}>{mod.objectives.map((o, i) => <li key={i}>{o}</li>)}</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityRow({ activity, onOpen }) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDetail({ activity, onLaunchLab, onChange }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const markComplete = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await api.completeActivity(activity.activity_id); onChange() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h2>{activity.title}</h2>
|
||||
{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`}
|
||||
{activity.is_required === false && ' · optional'}</p>
|
||||
|
||||
{activity.body ? (
|
||||
<div className="md" style={{ marginTop: '1rem' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{activity.body}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.muted} style={{ marginTop: '1rem' }}>No content authored for this activity yet.</p>
|
||||
)}
|
||||
|
||||
{error && <p className={styles.errorBox}>{error}</p>}
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
{activity.activity_type === 'lab' && !activity.complete && (
|
||||
<button className={styles.primaryBtn} onClick={() => onLaunchLab?.(activity.scenario_id)}>Launch Lab</button>
|
||||
)}
|
||||
{activity.completion_behavior === 'manual_marker' && !activity.complete && (
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={markComplete}>{busy ? 'Saving…' : 'Mark Complete'}</button>
|
||||
)}
|
||||
{activity.activity_type === 'capstone' && (
|
||||
<p className={styles.muted}>Continue via the Capstone tab to start or resume this assessment.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { api } from './api'
|
||||
import styles from './Platform.module.css'
|
||||
import CourseView from './CourseView'
|
||||
|
||||
const POLL_MS = 2500
|
||||
|
||||
@@ -69,6 +70,7 @@ function ProgramCard({ program, onChange }) {
|
||||
const [envStatus, setEnvStatus] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [showCourses, setShowCourses] = useState(false)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
@@ -97,6 +99,19 @@ function ProgramCard({ program, onChange }) {
|
||||
const p = program.progress
|
||||
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
|
||||
|
||||
// Phase 6A §6 — "View Courses" opens the structured course/module/activity tree for this
|
||||
// enrollment, right here inline (My Programs -> ROOT_ -> Course -> Module -> Activity), no
|
||||
// separate portal. "Continue Learning" keeps its original Phase 5B behavior unchanged
|
||||
// (straight to the disposable Lab) — CourseView's own Launch Lab buttons call the exact same
|
||||
// handleContinue, so there is still only one launch path underneath both entry points.
|
||||
if (showCourses) {
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||
<CourseView enrollmentId={program.enrollment_id} onLaunchLab={handleContinue} onClose={() => setShowCourses(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
@@ -116,7 +131,10 @@ function ProgramCard({ program, onChange }) {
|
||||
)}
|
||||
|
||||
{program.effective_status === 'completed' ? (
|
||||
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
||||
<>
|
||||
<p className={styles.muted}>Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.</p>
|
||||
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
|
||||
</>
|
||||
) : program.blocked_reason ? (
|
||||
<p className={styles.muted}>
|
||||
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
|
||||
@@ -129,9 +147,12 @@ function ProgramCard({ program, onChange }) {
|
||||
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
|
||||
<p className={styles.statusLine}><span className={styles.spinner} /> Starting your lab…</p>
|
||||
) : (
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||
{busy ? 'Starting…' : 'Continue Learning'}
|
||||
</button>
|
||||
<div className={styles.btnRow}>
|
||||
<button className={styles.primaryBtn} disabled={busy} onClick={handleContinue}>
|
||||
{busy ? 'Starting…' : 'Continue Learning'}
|
||||
</button>
|
||||
<button className={styles.secondaryBtn} onClick={() => setShowCourses(true)}>View Courses</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -124,4 +124,8 @@ export const api = {
|
||||
programs: () => fetch('/api/student/programs').then(json),
|
||||
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
|
||||
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
|
||||
|
||||
// ── Phase 6A: course/module/activity content ─────────────────────────────────────────────
|
||||
courses: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}/courses`).then(json),
|
||||
completeActivity: (activityId) => mutate(`/api/student/activities/${activityId}/complete`),
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 6A §12 gold-standard module dress rehearsal: brings up the real dev
|
||||
# stack, bootstraps an owner, runs test-phase6a-course-rehearsal.js (real k3s Lab, real
|
||||
# scenario), 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] "P6A Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase6a-course-rehearsal.js
|
||||
@@ -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,169 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6A §12 — the gold-standard module dress rehearsal. One continuous realistic story, not
|
||||
// synthetic unit assertions: a self-paced student enrolls, opens the ROOT_ course tree, reads
|
||||
// the health-probes lesson, works through the demonstration/knowledge-check/guided-exercise,
|
||||
// launches a REAL Lab, solves the REAL rootnode-probe-port-mismatch scenario against a real k3s
|
||||
// environment (no mocked KubeKosh), returns to the module, sees it complete, and the next
|
||||
// module unlocks. Same conventions as test-phase5b-selfpaced-rehearsal.js (assert-based, no
|
||||
// framework, real k3s boots).
|
||||
//
|
||||
// Run via run-phase6a-course-rehearsal.sh, which brings up platform/docker-compose.dev.yml
|
||||
// first.
|
||||
|
||||
const assert = require('assert');
|
||||
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 GOLD_MODULE = 'root-10-m01-health-probes-and-troubleshooting';
|
||||
|
||||
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 6A gold-standard module 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 Course Student', login_identifier: `p6a-course-${RUN_ID}`,
|
||||
});
|
||||
const session = makeSession(GW);
|
||||
let enrollmentId, envId, labToken;
|
||||
|
||||
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 directly in ROOT_ (admin-created, matching Phase 5B\'s manual-enrollment path)', 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;
|
||||
});
|
||||
|
||||
let tree;
|
||||
await step('the student opens My Programs -> ROOT_ and sees the course tree, gold module locked behind root-05', async () => {
|
||||
const { status, body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(status, 200);
|
||||
tree = body.courses;
|
||||
const goldModule = moduleFrom(tree, GOLD_MODULE);
|
||||
assert.equal(goldModule.locked, true, 'the gold module requires root-05 first, exactly like a real learner would hit');
|
||||
});
|
||||
|
||||
// Real Foundation completion, same scenarios e2e-phase6a.js drives, via the real self-paced
|
||||
// Lab (not seeded directly) — this rehearsal is specifically about proving the REAL path.
|
||||
const FOUNDATION = ['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'];
|
||||
|
||||
await step('student launches a REAL Lab and completes root-05\'s real scenarios to unlock the gold module', async () => {
|
||||
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
|
||||
assert.equal(launch.status, 200);
|
||||
assert.equal(launch.body.status, 'running');
|
||||
labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1]; // needed to reach the real Lab's own setup endpoint below
|
||||
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 FOUNDATION) {
|
||||
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`);
|
||||
}
|
||||
});
|
||||
|
||||
await step('the gold module is now unlocked', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
assert.equal(goldModule.locked, false);
|
||||
assert.equal(goldModule.activities[0].activity_type, 'lesson');
|
||||
assert.ok(goldModule.activities[0].body.includes('liveness'), 'the real lesson content must actually be served');
|
||||
});
|
||||
|
||||
await step('student reads the lesson, demonstration, knowledge check, and guided exercise, marking each complete', async () => {
|
||||
for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) {
|
||||
const { status } = await session.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('student launches the Lab activity for real and lands on rootnode-probe-port-mismatch', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
const lab = goldModule.activities.find((a) => a.activity_type === 'lab');
|
||||
assert.equal(lab.scenario_id, 'rootnode-probe-port-mismatch');
|
||||
assert.equal(lab.complete, false, 'not solved yet — this is the real incident, not pre-completed');
|
||||
});
|
||||
|
||||
await step('the Lab UI\'s own setup_commands actually stand up the broken api-gateway deployment', async () => {
|
||||
// Same route the real Lab frontend calls when a student opens this scenario — proxied
|
||||
// through the Gateway's /lab/:token/* passthrough straight to the Lab image's own backend
|
||||
// (backend/server.js), which is what actually runs setup_commands. Nothing about this is
|
||||
// simulated: this is the real mechanism, called the same way the browser would.
|
||||
const { status } = await session.request('POST', `/lab/${labToken}/api/scenarios/rootnode-probe-port-mismatch/setup`);
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await step('student solves the REAL rootnode-probe-port-mismatch incident against the live k3s environment', async () => {
|
||||
// Real diagnosis, matching the scenario's own hints (scenarios/data/rootnode-probe-port-mismatch.json):
|
||||
// the readiness probe was patched onto port 8080; nginx listens on 80. This patches the
|
||||
// actual live Deployment inside the student's real disposable Lab, then records the real
|
||||
// scenario completion the same way the Lab UI's own validation flow does.
|
||||
const { execSync } = require('child_process');
|
||||
// The container's real name is a SHORTENED id (lifecycle.js's shortId(), not the full
|
||||
// environment_id) — reading it straight from Postgres avoids duplicating that derivation
|
||||
// (and silently breaking if it ever changes) the way relying on our own copy would.
|
||||
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();
|
||||
assert.ok(containerName, 'the environment must have a real container_name recorded');
|
||||
execSync(
|
||||
`docker exec ${containerName} kubectl patch deployment api-gateway --type='json' -p='[{"op":"replace","path":"/spec/template/spec/containers/0/readinessProbe/httpGet/port","value":80}]'`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
// Give the rollout a moment to actually reach Ready before validating — the same real-world
|
||||
// timing a student would wait through.
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
const r = await session.request('POST', '/api/progress/record', { scenario_id: 'rootnode-probe-port-mismatch', status: 'completed', environment_id: envId });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
await step('the lab activity is now complete, but the module waits on the reflection', async () => {
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, true);
|
||||
assert.equal(goldModule.complete, false);
|
||||
});
|
||||
|
||||
await step('student completes the reflection; the gold module is fully complete and the next module unlocks', async () => {
|
||||
const { status } = await session.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
||||
assert.equal(status, 200);
|
||||
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleFrom(body.courses, GOLD_MODULE);
|
||||
assert.equal(goldModule.complete, true);
|
||||
const nextModule = moduleFrom(body.courses, 'root-10-m02-general-troubleshooting');
|
||||
assert.equal(nextModule.locked, false);
|
||||
});
|
||||
|
||||
await step('the instructor Curriculum view shows this student\'s real progress through the gold module', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 200);
|
||||
const goldModule = moduleFrom(body, GOLD_MODULE);
|
||||
assert.equal(goldModule.complete, true);
|
||||
assert.ok(goldModule.activities[0].instructor_notes, 'the instructor view must see instructor notes the student view never does');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -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); });
|
||||
+35
-6
@@ -13,7 +13,10 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
instructor sessions, RBAC, audit logging, real student credentials + remote classrooms
|
||||
(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), and the instructor/admin control plane. No k3s, no
|
||||
password-reset email, Phase 5C), the ROOT_ course/module/activity content model layered over
|
||||
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),
|
||||
@@ -21,8 +24,10 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine),
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom),
|
||||
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary),
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` (notifications, email delivery). Operational
|
||||
reference: `api/README.md`.
|
||||
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` (notifications, email delivery),
|
||||
`docs/PHASE6A_ROOT_COURSE_FOUNDATION.md` (course/module/activity content model, gold-standard
|
||||
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:
|
||||
@@ -74,13 +79,37 @@ further instructor accounts from the Instructors tab).
|
||||
Lab reset, curriculum stage/Capstone lock-unlock, the exam engine (random draw, server
|
||||
timer, expiry, restart persistence), the Capstone workflow end to end, two-student assessment
|
||||
isolation, and instructor learning analytics/RBAC.
|
||||
- `test/e2e-phase5a.js` — real student credentials (activation/password reset/login), remote
|
||||
classroom sessions (join/heartbeat/announcements/help requests), and instructor conversion of
|
||||
a pilot student to a credentialed account.
|
||||
- `test/e2e-phase5b.js` — programs/enrollment, entitlement gating (pending/future/expired/
|
||||
cancelled), server-derived completion, disposable-Lab persistence across destroy/recreate, and
|
||||
the website/commerce integration boundary (idempotency, concurrency, RBAC).
|
||||
- `test/e2e-phase5c.js` — the notification lifecycle (queue/retry/failure/exhaustion),
|
||||
activation/enrollment-confirmation/password-reset email delivery via the deterministic dev
|
||||
provider, minor/no-email contact-email resolution, and admin notification RBAC.
|
||||
- `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 four e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ COPY platform/api/migrate-sqlite.js ./
|
||||
# listing/progress-shaping (never executed; setup/validate/teardown stay Lab-only).
|
||||
COPY scenarios/ ./scenarios/
|
||||
|
||||
# Phase 6A — course/module lesson prose (content.js). Git-tracked Markdown, read-only, same
|
||||
# reasoning as scenarios/ above: this image only ever reads it, never executes anything from it.
|
||||
COPY content/ ./content/
|
||||
|
||||
# Platform-shell + admin frontend, built in the stage above — served statically, no nginx
|
||||
# needed for a lightweight Node API+static server.
|
||||
COPY --from=frontend-build /fe/dist/ /frontend-dist/
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
-- Phase 6A §3 — the course/module/activity content model. This is a STRUCTURAL layer on top
|
||||
-- of everything Phase 1-5 already built (scenarios/*.json, assessment_definitions, achievements,
|
||||
-- progress/scenario_attempts) — it never duplicates that content, only references it:
|
||||
--
|
||||
-- program (existing, 019_programs.sql)
|
||||
-- -> course (new)
|
||||
-- -> course_module (new)
|
||||
-- -> module_activity (new) -- type lab: scenario_id references scenarios/data/*.json
|
||||
-- -- type assessment/capstone: assessment_definition_id
|
||||
-- -- references assessment_definitions (013_assessments.sql)
|
||||
-- -- everything else: content_path references a Markdown
|
||||
-- -- file under content/root/ (git-tracked prose, not a
|
||||
-- -- Postgres blob — see docs/COURSE_CONTENT_MODEL.md)
|
||||
--
|
||||
-- Long-form lesson prose deliberately does NOT live in Postgres — only the structural metadata
|
||||
-- (ordering, type, references, status) does. See docs/COURSE_CONTENT_MODEL.md for the full
|
||||
-- rationale (git-editable content vs. a CMS) and the Markdown convention (a `## Instructor
|
||||
-- Notes` heading splits shared content from instructor-only content in the same file).
|
||||
--
|
||||
-- No CMS, no WYSIWYG editor tables, no content versioning system beyond the same plain
|
||||
-- incrementing `version` integer every other definitional table here already uses
|
||||
-- (assessment_definitions, programs) — bumped by hand when a semantic edit is made, exactly the
|
||||
-- same convention, not a new one.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS courses (
|
||||
course_id TEXT PRIMARY KEY, -- slug, e.g. 'root-05-kubernetes-foundations'
|
||||
program_id TEXT NOT NULL REFERENCES programs(program_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sequence INTEGER NOT NULL, -- position within the program's course list
|
||||
objectives TEXT[] NOT NULL DEFAULT '{}',
|
||||
prerequisites TEXT[] NOT NULL DEFAULT '{}', -- course_ids; see docs/COURSE_CONTENT_MODEL.md
|
||||
-- §"prerequisite/locking rule" for how an
|
||||
-- empty-content prerequisite is treated
|
||||
estimated_duration_hours INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
|
||||
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_courses_program ON courses(program_id, sequence);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS course_modules (
|
||||
module_id TEXT PRIMARY KEY, -- slug, e.g. 'root-05-m01-pods-and-kubectl-basics'
|
||||
course_id TEXT NOT NULL REFERENCES courses(course_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sequence INTEGER NOT NULL, -- position within the course's module list
|
||||
objectives TEXT[] NOT NULL DEFAULT '{}',
|
||||
prerequisites TEXT[] NOT NULL DEFAULT '{}', -- module_ids
|
||||
estimated_duration_minutes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
|
||||
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_course ON course_modules(course_id, sequence);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS module_activities (
|
||||
activity_id TEXT PRIMARY KEY, -- slug, e.g. 'root-10-m01-...-a05-rootnode-probe-port-mismatch'
|
||||
module_id TEXT NOT NULL REFERENCES course_modules(module_id),
|
||||
sequence INTEGER NOT NULL, -- position within the module's activity list
|
||||
activity_type TEXT NOT NULL CHECK (activity_type IN
|
||||
('lesson', 'demonstration', 'knowledge_check', 'guided_exercise',
|
||||
'lab', 'reflection', 'assessment', 'capstone')),
|
||||
title TEXT NOT NULL,
|
||||
content_path TEXT, -- Markdown file under content/root/, relative to repo root; NULL for a pure lab/assessment reference with no extra prose
|
||||
scenario_id TEXT, -- 'lab' activities only — references scenarios/data/<id>.json (not a DB FK; that content isn't a DB table)
|
||||
assessment_definition_id TEXT REFERENCES assessment_definitions(assessment_definition_id), -- 'assessment'/'capstone' activities only
|
||||
completion_behavior TEXT NOT NULL DEFAULT 'manual_marker' CHECK (completion_behavior IN
|
||||
('auto_scenario', 'auto_assessment', 'manual_marker')),
|
||||
estimated_duration_minutes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
|
||||
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT module_activity_lab_has_scenario CHECK (activity_type != 'lab' OR scenario_id IS NOT NULL),
|
||||
CONSTRAINT module_activity_assessment_has_definition CHECK (activity_type NOT IN ('assessment', 'capstone') OR assessment_definition_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_module ON module_activities(module_id, sequence);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_scenario ON module_activities(scenario_id);
|
||||
|
||||
-- Explicit completion marker — ONLY for activities whose completion_behavior is
|
||||
-- 'manual_marker' (lesson/demonstration/knowledge_check/guided_exercise/reflection). A 'lab'
|
||||
-- activity's completion is derived live from the existing progress table
|
||||
-- (scenario_id -> progress[scenario_id].status = 'completed', exactly what curriculum-rules.js
|
||||
-- already checks) and an 'assessment'/'capstone' activity's completion is derived live from the
|
||||
-- existing achievements table — this table is never a second source of truth for either. See
|
||||
-- docs/COURSE_CONTENT_MODEL.md §"completion rules".
|
||||
CREATE TABLE IF NOT EXISTS activity_completions (
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
activity_id TEXT NOT NULL REFERENCES module_activities(activity_id),
|
||||
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (student_id, activity_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_completions_student ON activity_completions(student_id);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Seed: the ROOT_ course architecture (docs/ROOT_PROGRAM_ARCHITECTURE.md), all under the
|
||||
-- existing 'root-kubernetes-foundations' program — root_ is one continuous ladder a student
|
||||
-- enrolls in once, not twelve separate commercial products, so no programs-table change.
|
||||
--
|
||||
-- Every module/activity below references EXISTING scenario/assessment content only — nothing
|
||||
-- here duplicates or forks scenarios/*.json or assessment_definitions. See
|
||||
-- docs/ROOT_CURRICULUM_INVENTORY.md for the full inventory this mapping is grounded in.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
INSERT INTO courses (course_id, program_id, title, description, sequence, objectives, prerequisites, estimated_duration_hours, status) VALUES
|
||||
('root-01-computing-linux-foundations', 'root-kubernetes-foundations', 'Computing & Linux Foundations',
|
||||
'How a computer actually works, the Linux filesystem and shell, and the command-line fluency everything else in root_ builds on. No lab content exists in this repository yet.',
|
||||
1, '{}', '{}', NULL, 'planned'),
|
||||
('root-02-linux-administration-networking', 'root-kubernetes-foundations', 'Linux Administration & Networking',
|
||||
'Users/permissions, services and systemd, package management, and TCP/IP networking fundamentals. No lab content exists in this repository yet.',
|
||||
2, '{}', '{}', NULL, 'planned'),
|
||||
('root-03-git-collaborative-development', 'root-kubernetes-foundations', 'Git & Collaborative Development',
|
||||
'Version control, branching, code review, and working in a shared repository. No lab content exists in this repository yet.',
|
||||
3, '{}', '{}', NULL, 'planned'),
|
||||
('root-04-containers-and-docker', 'root-kubernetes-foundations', 'Containers & Docker',
|
||||
'What a container actually is, building images, and running/networking containers with Docker, as the on-ramp to Kubernetes. No lab content exists in this repository yet.',
|
||||
4, '{}', '{}', NULL, 'planned'),
|
||||
('root-05-kubernetes-foundations', 'root-kubernetes-foundations', 'Kubernetes Foundations',
|
||||
'Pods, kubectl fluency, labels/selectors, and the core object model — the existing "Foundation" curriculum stage.',
|
||||
5, '{"Explain what a Pod is and its lifecycle states","Use kubectl confidently to inspect and manipulate cluster objects","Apply labels and selectors to organize resources"}', '{}', 8, 'partial'),
|
||||
('root-06-kubernetes-application-workloads', 'root-kubernetes-foundations', 'Kubernetes Application Workloads',
|
||||
'Deployments, scaling, rolling updates, Jobs/CronJobs, and DaemonSets — the existing "Workloads" curriculum stage.',
|
||||
6, '{"Deploy and scale a stateless application","Perform a rolling update and understand deployment strategies","Distinguish Jobs, CronJobs, and DaemonSets"}', '{root-05-kubernetes-foundations}', 4, 'partial'),
|
||||
('root-07-kubernetes-networking-config', 'root-kubernetes-foundations', 'Kubernetes Networking & Configuration',
|
||||
'Services, exposing applications, ConfigMaps, Secrets, and environment configuration — the existing "Networking" and "Configuration" curriculum stages.',
|
||||
7, '{"Expose a workload with the right Service type","Externalize configuration with ConfigMaps and Secrets"}', '{root-06-kubernetes-application-workloads}', 4, 'partial'),
|
||||
('root-08-kubernetes-storage-scheduling', 'root-kubernetes-foundations', 'Kubernetes Storage & Scheduling',
|
||||
'PersistentVolumes/Claims, StorageClasses, taints/tolerations, priority, and disruption budgets. Real scenario content exists in this repository (see docs/ROOT_CURRICULUM_INVENTORY.md) but is not yet wired into a module — Phase 6B.',
|
||||
8, '{}', '{root-07-kubernetes-networking-config}', NULL, 'planned'),
|
||||
('root-09-kubernetes-security', 'root-kubernetes-foundations', 'Kubernetes Security',
|
||||
'RBAC, Pod Security Admission, network policy, supply-chain and runtime hardening — a substantial existing CKS-style scenario bank (22 scenarios) not yet wired into a module — Phase 6B.',
|
||||
9, '{}', '{root-06-kubernetes-application-workloads}', NULL, 'planned'),
|
||||
('root-10-kubernetes-troubleshooting-observability', 'root-kubernetes-foundations', 'Kubernetes Troubleshooting & Observability',
|
||||
'Reading logs, exec/cp into containers, node scheduling, and diagnosing real failures — health probes are this course''s gold-standard module.',
|
||||
10, '{"Diagnose a Pod stuck in a bad state using kubectl and logs","Explain liveness, readiness, and startup probes and how misconfiguring them causes real outages"}', '{root-06-kubernetes-application-workloads}', 5, 'partial'),
|
||||
('root-11-devops-platform-engineering', 'root-kubernetes-foundations', 'DevOps & Platform Engineering Foundations',
|
||||
'CI/CD, infrastructure as code, and GitOps principles. No lab content exists in this repository yet.',
|
||||
11, '{}', '{root-10-kubernetes-troubleshooting-observability}', NULL, 'planned'),
|
||||
('root-12-real-ops', 'root-kubernetes-foundations', 'RootNode Real Ops',
|
||||
'RootNode-authored incident scenarios — operate and recover systems the way a real on-call engineer would, not textbook exercises. The existing "RootNode: Real Ops" curriculum stage.',
|
||||
12, '{"Recover a cluster from a resource-exhaustion incident","Diagnose and fix a broken deployment without being told what''s wrong","Apply resource defaults that prevent a class of outage from recurring"}', '{root-10-kubernetes-troubleshooting-observability}', 3, 'partial'),
|
||||
('root-capstone', 'root-kubernetes-foundations', 'ROOT Capstone — Operate and Recover a Production-Like Environment',
|
||||
'The existing RootNode Capstone assessment, combining skills from every stage into one operational evaluation.',
|
||||
13, '{"Operate a Kubernetes environment under exam conditions without step-by-step guidance"}', '{root-12-real-ops}', 2, 'partial')
|
||||
ON CONFLICT (course_id) DO NOTHING;
|
||||
|
||||
INSERT INTO course_modules (module_id, course_id, title, description, sequence, objectives, prerequisites, estimated_duration_minutes, status) VALUES
|
||||
('root-05-m01-pods-and-kubectl-basics', 'root-05-kubernetes-foundations', 'Pods & kubectl Basics',
|
||||
'The 20 scenarios in the existing "Foundation" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{}', NULL, 'partial'),
|
||||
('root-06-m01-deployments-and-workloads', 'root-06-kubernetes-application-workloads', 'Deployments & Workloads',
|
||||
'The 8 scenarios in the existing "Workloads" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{}', NULL, 'partial'),
|
||||
('root-07-m01-services-and-networking', 'root-07-kubernetes-networking-config', 'Services & Networking',
|
||||
'The 2 scenarios in the existing "Networking" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{}', NULL, 'partial'),
|
||||
('root-07-m02-configuration-and-secrets', 'root-07-kubernetes-networking-config', 'Configuration & Secrets',
|
||||
'The 5 scenarios in the existing "Configuration" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
2, '{}', '{}', NULL, 'partial'),
|
||||
('root-10-m01-health-probes-and-troubleshooting', 'root-10-kubernetes-troubleshooting-observability', 'Kubernetes Health Probes & Troubleshooting',
|
||||
'The Phase 6A gold-standard module: liveness/readiness/startup probe concepts taught BEFORE the student is asked to fix a real probe misconfiguration in rootnode-probe-port-mismatch. See docs/COURSE_AUTHORING_STANDARD.md — this module is the template for all future RootNode lesson authoring.',
|
||||
1, '{"Explain why Kubernetes health checks exist and what problem each probe type solves","Distinguish liveness, readiness, and startup probes and choose the right one","Diagnose a probe misconfiguration from Pod status and events, not guesswork"}',
|
||||
'{root-05-m01-pods-and-kubectl-basics}', 75, 'ready'),
|
||||
('root-10-m02-general-troubleshooting', 'root-10-kubernetes-troubleshooting-observability', 'General Troubleshooting',
|
||||
'The 5 scenarios in the existing "Troubleshooting" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
|
||||
2, '{}', '{root-10-m01-health-probes-and-troubleshooting}', NULL, 'partial'),
|
||||
('root-12-m01-real-ops-incidents', 'root-12-real-ops', 'Real Ops Incidents',
|
||||
'The 4 scenarios in the existing "RootNode: Real Ops" curriculum stage, as disposable-Lab activities. rootnode-probe-port-mismatch also appears here — deliberate reuse: the gold module teaches the concept in depth, this module exercises it again alongside three other unrelated incident types, the same way the RootNode Capstone pool also reuses it. No teaching lesson content authored yet — Phase 6B.',
|
||||
1, '{}', '{root-10-m01-health-probes-and-troubleshooting}', NULL, 'partial'),
|
||||
('root-capstone-m01-operate-and-recover', 'root-capstone', 'Operate and Recover',
|
||||
'The existing RootNode Capstone assessment (90 minutes, mixed required/random incident pool). No capstone orientation/briefing lesson authored yet — Phase 6B.',
|
||||
1, '{}', '{root-12-m01-real-ops-incidents}', 90, 'partial')
|
||||
ON CONFLICT (module_id) DO NOTHING;
|
||||
|
||||
-- ── Gold-standard module activities (root-10-m01-health-probes-and-troubleshooting) ──────────
|
||||
-- Content lives in content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/
|
||||
-- — see docs/COURSE_CONTENT_MODEL.md for the Markdown convention these files follow.
|
||||
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-m01-a01-lesson-health-check-concepts', 'root-10-m01-health-probes-and-troubleshooting', 1, 'lesson',
|
||||
'Why Health Checks Exist & Probe Types',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/01-lesson-health-check-concepts.md',
|
||||
NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-10-m01-a02-demonstration-probes-in-action', 'root-10-m01-health-probes-and-troubleshooting', 2, 'demonstration',
|
||||
'Demonstration: Probes in Action',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/02-demonstration-probes-in-action.md',
|
||||
NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-10-m01-a03-knowledge-check', 'root-10-m01-health-probes-and-troubleshooting', 3, 'knowledge_check',
|
||||
'Knowledge Check: Health Probes',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/03-knowledge-check.md',
|
||||
NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-10-m01-a04-guided-exercise-readiness-probe', 'root-10-m01-health-probes-and-troubleshooting', 4, 'guided_exercise',
|
||||
'Guided Exercise: Configure a Readiness Probe',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/04-guided-exercise-readiness-probe.md',
|
||||
NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-10-m01-a05-lab-probe-port-mismatch', 'root-10-m01-health-probes-and-troubleshooting', 5, 'lab',
|
||||
'Launch Lab: rootnode-probe-port-mismatch',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/05-lab-intro.md',
|
||||
'rootnode-probe-port-mismatch', NULL, 'auto_scenario', 20, 'ready'),
|
||||
('root-10-m01-a06-reflection-real-ops-takeaway', 'root-10-m01-health-probes-and-troubleshooting', 6, 'reflection',
|
||||
'Root-Cause Reflection & Real Ops Takeaway',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/06-reflection-real-ops-takeaway.md',
|
||||
NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ── Mechanically-mapped lab activities for the other partial modules ────────────────────────
|
||||
-- Generated 1:1 from scenarios/rootnode-path.json's existing stage scenario_ids (see
|
||||
-- docs/ROOT_CURRICULUM_INVENTORY.md) — no new lab content invented, titles taken from each
|
||||
-- scenario's own JSON. Estimated duration is a rough heuristic from the scenario's declared
|
||||
-- difficulty (15/20/30 min); refine once real per-activity completion-time data exists.
|
||||
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, visibility, version) VALUES
|
||||
('root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq', 'root-05-m01-pods-and-kubectl-basics', 1, 'lab', 'Lab: Pod Lifecycle States', NULL, 'pod-basics-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a02-kubectl-essentials-mcq', 'root-05-m01-pods-and-kubectl-basics', 2, 'lab', 'Lab: Essential kubectl Commands', NULL, 'kubectl-essentials-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a03-labels-selectors-mcq', 'root-05-m01-pods-and-kubectl-basics', 3, 'lab', 'Lab: Labels and Selectors', NULL, 'labels-selectors-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a04-init-containers-mcq', 'root-05-m01-pods-and-kubectl-basics', 4, 'lab', 'Lab: Init Containers Behaviour', NULL, 'init-containers-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a05-namespaces-basics', 'root-05-m01-pods-and-kubectl-basics', 5, 'lab', 'Lab: Working with Namespaces', NULL, 'namespaces-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a06-multi-container-pod-basics', 'root-05-m01-pods-and-kubectl-basics', 6, 'lab', 'Lab: Create a Multi-Container Pod', NULL, 'multi-container-pod-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a07-pod-labeling-basics', 'root-05-m01-pods-and-kubectl-basics', 7, 'lab', 'Lab: Labelling Resources', NULL, 'pod-labeling-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a08-jsonpath-basics', 'root-05-m01-pods-and-kubectl-basics', 8, 'lab', 'Lab: JSONPath Data Extraction', NULL, 'jsonpath-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a09-dry-run-manifest-basics', 'root-05-m01-pods-and-kubectl-basics', 9, 'lab', 'Lab: Generating YAML Manifests', NULL, 'dry-run-manifest-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a10-delete-by-label-basics', 'root-05-m01-pods-and-kubectl-basics', 10, 'lab', 'Lab: Bulk Deletion by Label', NULL, 'delete-by-label-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a11-annotate-resource-basics', 'root-05-m01-pods-and-kubectl-basics', 11, 'lab', 'Lab: Annotating Kubernetes Resources', NULL, 'annotate-resource-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a12-init-container-task-basics', 'root-05-m01-pods-and-kubectl-basics', 12, 'lab', 'Lab: Pod with Init Container', NULL, 'init-container-task-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a13-restart-policy-mcq', 'root-05-m01-pods-and-kubectl-basics', 13, 'lab', 'Lab: Pod Restart Policies', NULL, 'restart-policy-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a14-kubectl-output-format-mcq', 'root-05-m01-pods-and-kubectl-basics', 14, 'lab', 'Lab: kubectl Output Formats', NULL, 'kubectl-output-format-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a15-kubectl-explain-basics', 'root-05-m01-pods-and-kubectl-basics', 15, 'lab', 'Lab: Exploring the API with kubectl explain', NULL, 'kubectl-explain-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a16-kubectl-patch-basics', 'root-05-m01-pods-and-kubectl-basics', 16, 'lab', 'Lab: Patching Resources with kubectl patch', NULL, 'kubectl-patch-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a17-field-selector-basics', 'root-05-m01-pods-and-kubectl-basics', 17, 'lab', 'Lab: Filtering Resources with Field Selectors', NULL, 'field-selector-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a18-sort-by-basics', 'root-05-m01-pods-and-kubectl-basics', 18, 'lab', 'Lab: Sorting kubectl Output', NULL, 'sort-by-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a19-kubectl-diff-basics', 'root-05-m01-pods-and-kubectl-basics', 19, 'lab', 'Lab: Previewing Changes with kubectl diff', NULL, 'kubectl-diff-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-05-m01-pods-and-kubectl-basics-a20-static-pods-mcq', 'root-05-m01-pods-and-kubectl-basics', 20, 'lab', 'Lab: Static Pods in Kubernetes', NULL, 'static-pods-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a01-deploy-nginx', 'root-06-m01-deployments-and-workloads', 1, 'lab', 'Lab: Deploy and Expose Nginx', NULL, 'deploy-nginx', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a02-scale-deployment', 'root-06-m01-deployments-and-workloads', 2, 'lab', 'Lab: Scaling a Deployment', NULL, 'scale-deployment', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a03-rolling-update-task', 'root-06-m01-deployments-and-workloads', 3, 'lab', 'Lab: Perform a Rolling Update', NULL, 'rolling-update-task', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a04-liveness-probe-task', 'root-06-m01-deployments-and-workloads', 4, 'lab', 'Lab: Configure a Liveness Probe', NULL, 'liveness-probe-task', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a05-jobs-cronjobs-mcq', 'root-06-m01-deployments-and-workloads', 5, 'lab', 'Lab: Jobs vs CronJobs', NULL, 'jobs-cronjobs-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a06-daemonset-mcq', 'root-06-m01-deployments-and-workloads', 6, 'lab', 'Lab: Understanding DaemonSets', NULL, 'daemonset-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a07-edit-deployment-basics', 'root-06-m01-deployments-and-workloads', 7, 'lab', 'Lab: Updating Deployments', NULL, 'edit-deployment-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-06-m01-deployments-and-workloads-a08-deployment-strategy-mcq', 'root-06-m01-deployments-and-workloads', 8, 'lab', 'Lab: Deployment Update Strategies', NULL, 'deployment-strategy-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-07-m01-services-and-networking-a01-services-mcq', 'root-07-m01-services-and-networking', 1, 'lab', 'Lab: Kubernetes Service Types', NULL, 'services-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m01-services-and-networking-a02-expose-service-basics', 'root-07-m01-services-and-networking', 2, 'lab', 'Lab: Exposing an Application', NULL, 'expose-service-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a01-configmap-volume', 'root-07-m02-configuration-and-secrets', 1, 'lab', 'Lab: Mount ConfigMap as Volume', NULL, 'configmap-volume', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a02-resource-limits-task', 'root-07-m02-configuration-and-secrets', 2, 'lab', 'Lab: Resource Requests and Limits', NULL, 'resource-limits-task', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a03-env-vars-configmap', 'root-07-m02-configuration-and-secrets', 3, 'lab', 'Lab: Environment Variables from ConfigMap', NULL, 'env-vars-configmap', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a04-secrets-basics', 'root-07-m02-configuration-and-secrets', 4, 'lab', 'Lab: Creating and Using Secrets', NULL, 'secrets-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-07-m02-configuration-and-secrets-a05-pod-env-vars-inline', 'root-07-m02-configuration-and-secrets', 5, 'lab', 'Lab: Pod with Inline Environment Variables', NULL, 'pod-env-vars-inline', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a01-extract-logs-basics', 'root-10-m02-general-troubleshooting', 1, 'lab', 'Lab: Extract Container Logs', NULL, 'extract-logs-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a02-kubectl-cp-basics', 'root-10-m02-general-troubleshooting', 2, 'lab', 'Lab: Copy Files to Containers', NULL, 'kubectl-cp-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a03-exec-command-basics', 'root-10-m02-general-troubleshooting', 3, 'lab', 'Lab: Execute Commands in Pods', NULL, 'exec-command-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a04-node-cordon-basics', 'root-10-m02-general-troubleshooting', 4, 'lab', 'Lab: Cordoning and Uncordoning a Node', NULL, 'node-cordon-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
|
||||
('root-10-m02-general-troubleshooting-a05-pod-node-assignment-mcq', 'root-10-m02-general-troubleshooting', 5, 'lab', 'Lab: Assigning Pods to Nodes', NULL, 'pod-node-assignment-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a01-rootnode-runaway-monitor', 'root-12-m01-real-ops-incidents', 1, 'lab', 'Lab: Recover the Monitoring Stack', NULL, 'rootnode-runaway-monitor', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a02-broken-deployment', 'root-12-m01-real-ops-incidents', 2, 'lab', 'Lab: Debug a Failing Deployment', NULL, 'broken-deployment', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a03-rootnode-probe-port-mismatch', 'root-12-m01-real-ops-incidents', 3, 'lab', 'Lab: Stuck Not-Ready: The Probe Port Mismatch', NULL, 'rootnode-probe-port-mismatch', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
|
||||
('root-12-m01-real-ops-incidents-a04-rootnode-enforce-limits', 'root-12-m01-real-ops-incidents', 4, 'lab', 'Lab: Stop the Next Outage: Enforce Resource Defaults', NULL, 'rootnode-enforce-limits', NULL, 'auto_scenario', 30, 'ready', 'published', 1)
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ── Capstone activity (references the existing assessment engine, no duplicate definition) ──
|
||||
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-m01-a01-capstone', 'root-capstone-m01-operate-and-recover', 1, 'capstone', 'RootNode Capstone', NULL, NULL, 'rootnode-capstone', 'auto_assessment', 90, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
@@ -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'
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
// Phase 6A §7 — instructor/admin curriculum visibility. Read-only: course/module/activity
|
||||
// authoring stays Git/migration-based this phase (see docs/COURSE_CONTENT_MODEL.md — no
|
||||
// WYSIWYG CMS), so there is deliberately no POST/PATCH here, only the views an instructor needs
|
||||
// to actually teach from: program structure with instructor notes, and per-student progress
|
||||
// through it so a teacher can see where someone is stuck.
|
||||
|
||||
const { buildCourseTree } = require('./course-progress');
|
||||
|
||||
module.exports = function registerAdminCourseRoutes(app, deps) {
|
||||
const { instructorSession, rbac, getPool } = deps;
|
||||
const { requireAnyInstructor, requireStudentRelationshipAccess } = rbac;
|
||||
|
||||
// Same local-wrap convention as admin-programs-routes.js/admin-learning-routes.js — RBAC
|
||||
// middleware here is plain sync `(req,res,next)`, so this Promise.resolve() guard lets it
|
||||
// share the same mw.map(wrap) list as the async route handlers below.
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
const admin = { get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)) };
|
||||
|
||||
// Full structure (courses -> modules -> activities), instructor notes and draft content
|
||||
// included, with no completion overlay — studentId is deliberately null here (course-
|
||||
// progress.js's queries tolerate that and just return complete:false everywhere), since this
|
||||
// is "what does the curriculum look like," not "how is any one student doing."
|
||||
admin.get('/api/admin/programs/:programId/courses', requireAnyInstructor, async (req, res) => {
|
||||
const courses = await buildCourseTree(getPool(), { studentId: null, programId: req.params.programId, forInstructor: true });
|
||||
res.json(courses);
|
||||
});
|
||||
|
||||
// One student's real progress through the same tree. Uses requireStudentRelationshipAccess,
|
||||
// not requireStudentCohortAccess — a self-paced root_ student typically has NO cohort at all
|
||||
// (Phase 5B), so the cohort-only check would 403 a plain instructor out of every self-paced
|
||||
// student's progress. Same rule Phase 5C's notification routes already established: a plain
|
||||
// instructor may see a student they share a cohort with OR whose enrollment they personally
|
||||
// created; owner/admin always bypass.
|
||||
admin.get('/api/admin/students/:id/courses', requireAnyInstructor, requireStudentRelationshipAccess('id'), async (req, res) => {
|
||||
const programId = req.query.program_id;
|
||||
if (!programId) return res.status(400).json({ error: 'program_id is required' });
|
||||
const courses = await buildCourseTree(getPool(), { studentId: req.params.id, programId, forInstructor: true });
|
||||
res.json(courses);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// Phase 6A §3 — loads a module_activity's Markdown content file. Same read-only cache pattern
|
||||
// as curriculum.js (scenarios/bundles): loaded from disk, cached, exposed via a reload for
|
||||
// tests/dev. Long-form lesson prose lives in git-tracked Markdown (content/root/...), not
|
||||
// Postgres — see docs/COURSE_CONTENT_MODEL.md for why.
|
||||
//
|
||||
// A single convention splits shared (self-paced-visible) content from instructor-only content
|
||||
// in the SAME file, instead of two files that can drift apart: everything before the first
|
||||
// "## Instructor Notes" heading is shared; that heading and everything after it is
|
||||
// instructor-only. Self-paced/student responses never include the instructor_notes field
|
||||
// regardless of any request parameter — this mirrors the RBAC convention used everywhere else
|
||||
// in this codebase (identity decides visibility, never a client-supplied flag).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Same convention as curriculum.js's SCENARIOS_DIR: one level up from src/, where the
|
||||
// Dockerfile COPYs both scenarios/ and content/ (see platform/api/Dockerfile) — 'content/...'
|
||||
// paths stored in module_activities.content_path resolve against this, not the actual repo
|
||||
// root, since that's what the running image actually has on disk.
|
||||
const ROOT_DIR = process.env.CONTENT_ROOT_DIR || path.join(__dirname, '..');
|
||||
const INSTRUCTOR_HEADING = /\n## Instructor Notes\b/;
|
||||
|
||||
const cache = new Map(); // content_path -> { shared, instructorNotes } | null (missing file)
|
||||
|
||||
function splitContent(raw) {
|
||||
const match = raw.match(INSTRUCTOR_HEADING);
|
||||
if (!match) return { shared: raw.trim(), instructorNotes: null };
|
||||
return {
|
||||
shared: raw.slice(0, match.index).trim(),
|
||||
instructorNotes: raw.slice(match.index).replace(/^\n/, '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// contentPath is always a repo-relative path (module_activities.content_path, e.g.
|
||||
// 'content/root/root-10-.../m01-health-probes/01-lesson-....md') — resolved against the repo
|
||||
// root, never against anything a request supplies, so there's no path-traversal surface here.
|
||||
function loadContent(contentPath) {
|
||||
if (!contentPath) return null;
|
||||
if (cache.has(contentPath)) return cache.get(contentPath);
|
||||
const abs = path.join(ROOT_DIR, contentPath);
|
||||
let result = null;
|
||||
try {
|
||||
result = splitContent(fs.readFileSync(abs, 'utf8'));
|
||||
} catch {
|
||||
result = null; // missing file — a PARTIAL/PLANNED activity may legitimately have no content yet
|
||||
}
|
||||
cache.set(contentPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function reloadCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
module.exports = { loadContent, reloadCache };
|
||||
@@ -0,0 +1,188 @@
|
||||
// Phase 6A §3/§8 — course/module/activity completion and prerequisite locking, derived
|
||||
// entirely from EXISTING sources of truth: progress (scenario completion, same table
|
||||
// curriculum-rules.js already reads), achievements (assessment/capstone passes), and
|
||||
// activity_completions (the ONLY new state this phase adds, and only for lesson-only
|
||||
// activities that have nothing else to derive completion from). See
|
||||
// docs/COURSE_CONTENT_MODEL.md §"completion rules" for the full rationale — this file must
|
||||
// never become a second, independently-drifting copy of scenario/assessment completion.
|
||||
|
||||
const { loadProgress } = require('./progress');
|
||||
const { loadContent } = require('./content');
|
||||
|
||||
function isActivityComplete(activity, ctx) {
|
||||
switch (activity.completion_behavior) {
|
||||
case 'auto_scenario':
|
||||
return ctx.progress[activity.scenario_id]?.status === 'completed';
|
||||
case 'auto_assessment':
|
||||
return ctx.passedAssessmentIds.has(activity.assessment_definition_id);
|
||||
case 'manual_marker':
|
||||
default:
|
||||
return ctx.completedActivityIds.has(activity.activity_id);
|
||||
}
|
||||
}
|
||||
|
||||
// One query pass per student, reused across every activity in every course — same shape as
|
||||
// curriculum-rules.js's caller-supplied progress map, just extended with the two new sources.
|
||||
async function loadCompletionContext(pool, studentId) {
|
||||
const progress = await loadProgress(pool, studentId);
|
||||
const { rows: achievementRows } = await pool.query(
|
||||
`SELECT metadata->>'assessment_definition_id' AS assessment_definition_id FROM achievements
|
||||
WHERE student_id = $1 AND achievement_type IN ('assessment_completed', 'capstone_completed')
|
||||
AND metadata->>'assessment_definition_id' IS NOT NULL`,
|
||||
[studentId]
|
||||
);
|
||||
const passedAssessmentIds = new Set(achievementRows.map((r) => r.assessment_definition_id));
|
||||
const { rows: completionRows } = await pool.query(
|
||||
`SELECT activity_id FROM activity_completions WHERE student_id = $1`, [studentId]
|
||||
);
|
||||
const completedActivityIds = new Set(completionRows.map((r) => r.activity_id));
|
||||
return { progress, passedAssessmentIds, completedActivityIds };
|
||||
}
|
||||
|
||||
// forInstructor: includes draft (unpublished) rows and each activity's instructor_notes.
|
||||
// A student-facing caller must NEVER pass true — this is the one flag in this module that
|
||||
// controls what an instructor sees vs. a student, and it comes from server-resolved identity
|
||||
// (which route called this), never from a request parameter.
|
||||
function shapeActivity(activity, ctx, forInstructor) {
|
||||
const content = loadContent(activity.content_path);
|
||||
return {
|
||||
activity_id: activity.activity_id,
|
||||
sequence: activity.sequence,
|
||||
activity_type: activity.activity_type,
|
||||
title: activity.title,
|
||||
status: activity.status,
|
||||
estimated_duration_minutes: activity.estimated_duration_minutes,
|
||||
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;
|
||||
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
|
||||
// block anything — otherwise an honestly-empty PLANNED entry would permanently lock every real
|
||||
// module/course that lists it as a prerequisite. See 022_course_content.sql's header comment
|
||||
// and docs/COURSE_CONTENT_MODEL.md §"prerequisite/locking rule".
|
||||
function prerequisitesSatisfied(prereqIds, byId) {
|
||||
return (prereqIds || []).every((id) => {
|
||||
const target = byId.get(id);
|
||||
if (!target) return true; // an unknown/removed prerequisite id never blocks
|
||||
return target._totals.total === 0 || target._totals.complete;
|
||||
});
|
||||
}
|
||||
|
||||
// Builds the full course -> module -> activity tree for one student against one program,
|
||||
// with real completion and prerequisite-based locking. Shared by the student self-paced route
|
||||
// and the instructor curriculum view — forInstructor controls draft visibility and
|
||||
// instructor_notes only, never the completion/locking logic itself (both surfaces must agree on
|
||||
// what's actually true).
|
||||
async function buildCourseTree(pool, { studentId, programId, forInstructor = false }) {
|
||||
const ctx = await loadCompletionContext(pool, studentId);
|
||||
const visClause = forInstructor ? '' : `AND visibility = 'published'`;
|
||||
|
||||
const { rows: courseRows } = await pool.query(
|
||||
`SELECT * FROM courses WHERE program_id = $1 ${visClause} ORDER BY sequence`, [programId]
|
||||
);
|
||||
const { rows: moduleRows } = await pool.query(
|
||||
`SELECT * FROM course_modules WHERE course_id = ANY($1::text[]) ${visClause} ORDER BY sequence`,
|
||||
[courseRows.map((c) => c.course_id)]
|
||||
);
|
||||
const { rows: activityRows } = await pool.query(
|
||||
`SELECT * FROM module_activities WHERE module_id = ANY($1::text[]) ${visClause} ORDER BY sequence`,
|
||||
[moduleRows.map((m) => m.module_id)]
|
||||
);
|
||||
|
||||
const activitiesByModule = new Map();
|
||||
for (const a of activityRows) {
|
||||
const shaped = shapeActivity(a, ctx, forInstructor);
|
||||
if (!activitiesByModule.has(a.module_id)) activitiesByModule.set(a.module_id, []);
|
||||
activitiesByModule.get(a.module_id).push(shaped);
|
||||
}
|
||||
|
||||
const modulesByCourse = new Map();
|
||||
const moduleById = new Map();
|
||||
for (const m of moduleRows) {
|
||||
const activities = activitiesByModule.get(m.module_id) || [];
|
||||
const shaped = {
|
||||
module_id: m.module_id, sequence: m.sequence, title: m.title, description: m.description,
|
||||
objectives: m.objectives, estimated_duration_minutes: m.estimated_duration_minutes,
|
||||
status: m.status, prerequisites: m.prerequisites,
|
||||
activities, _totals: totals(activities),
|
||||
};
|
||||
moduleById.set(m.module_id, shaped);
|
||||
if (!modulesByCourse.has(m.course_id)) modulesByCourse.set(m.course_id, []);
|
||||
modulesByCourse.get(m.course_id).push(shaped);
|
||||
}
|
||||
// Locking depends on OTHER modules' _totals, so this has to be two separate passes over the
|
||||
// map, not one — computing and stripping _totals in the same iteration would make the result
|
||||
// depend on Map iteration order (a module whose _totals got stripped before a dependent
|
||||
// module's turn would look like it has zero content). Found by testing.
|
||||
for (const m of moduleById.values()) {
|
||||
m.locked = !prerequisitesSatisfied(m.prerequisites, moduleById);
|
||||
}
|
||||
for (const m of moduleById.values()) {
|
||||
m.complete = m._totals.complete;
|
||||
m.percent_complete = m._totals.pct;
|
||||
delete m._totals;
|
||||
}
|
||||
|
||||
const courseById = new Map();
|
||||
const courses = courseRows.map((c) => {
|
||||
const modules = modulesByCourse.get(c.course_id) || [];
|
||||
const allActivities = modules.flatMap((m) => m.activities);
|
||||
const shaped = {
|
||||
course_id: c.course_id, sequence: c.sequence, title: c.title, description: c.description,
|
||||
objectives: c.objectives, estimated_duration_hours: c.estimated_duration_hours,
|
||||
status: c.status, prerequisites: c.prerequisites,
|
||||
modules, _totals: totals(allActivities),
|
||||
};
|
||||
courseById.set(c.course_id, shaped);
|
||||
return shaped;
|
||||
});
|
||||
for (const c of courses) {
|
||||
c.locked = !prerequisitesSatisfied(c.prerequisites, courseById);
|
||||
}
|
||||
for (const c of courses) {
|
||||
c.complete = c._totals.complete;
|
||||
c.percent_complete = c._totals.pct;
|
||||
delete c._totals;
|
||||
}
|
||||
|
||||
return courses;
|
||||
}
|
||||
|
||||
// Deterministic "resume learning" target: the first non-locked, incomplete activity in
|
||||
// sequence order across the whole tree. Mirrors curriculum-rules.js's nextIncompleteScenario,
|
||||
// just one level higher (activity, not scenario).
|
||||
function findResumeTarget(courses) {
|
||||
for (const course of courses) {
|
||||
if (course.locked || course.complete) continue;
|
||||
for (const mod of course.modules) {
|
||||
if (mod.locked || mod.complete) continue;
|
||||
const next = mod.activities.find((a) => !a.complete);
|
||||
if (next) return { course_id: course.course_id, module_id: mod.module_id, activity_id: next.activity_id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { buildCourseTree, findResumeTarget, loadCompletionContext, isActivityComplete };
|
||||
@@ -0,0 +1,60 @@
|
||||
// Phase 6A §6 — student-facing course/module/activity API. Mounted alongside
|
||||
// selfpaced-routes.js (My Programs) — a course tree is always viewed THROUGH an enrollment the
|
||||
// student actually holds, never a public catalog, same boundary selfpaced-routes.js already
|
||||
// established for programs themselves.
|
||||
|
||||
const enrollment = require('./enrollment');
|
||||
const { buildCourseTree, findResumeTarget } = require('./course-progress');
|
||||
|
||||
module.exports = function registerCourseRoutes(app, deps) {
|
||||
const { studentSession, getPool, wrap } = deps;
|
||||
|
||||
// The full course/module/activity tree for one enrollment, with real completion + locking.
|
||||
app.get('/api/student/programs/:enrollmentId/courses', studentSession, wrap(async (req, res) => {
|
||||
const row = await enrollment.getEnrollment(getPool(), req.params.enrollmentId);
|
||||
if (!row || row.student_id !== req.studentId) return res.status(404).json({ error: 'not_found' });
|
||||
const courses = await buildCourseTree(getPool(), { studentId: req.studentId, programId: row.program_id, forInstructor: false });
|
||||
res.json({ courses, resume: findResumeTarget(courses) });
|
||||
}));
|
||||
|
||||
// Phase 5B §9's rule applies here too: an activity is only ever reachable through an
|
||||
// enrollment the student actually holds — resolved server-side from the activity's own
|
||||
// course, never trusted from anything the client asserts about which program it belongs to.
|
||||
async function requireEnrolledActivity(req, res) {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ma.*, c.program_id FROM module_activities ma
|
||||
JOIN course_modules cm ON cm.module_id = ma.module_id
|
||||
JOIN courses c ON c.course_id = cm.course_id
|
||||
WHERE ma.activity_id = $1 AND ma.visibility = 'published'`,
|
||||
[req.params.activityId]
|
||||
);
|
||||
const activity = rows[0];
|
||||
if (!activity) { res.status(404).json({ error: 'not_found' }); return null; }
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM enrollments WHERE student_id = $1 AND program_id = $2 AND status = 'active'`,
|
||||
[req.studentId, activity.program_id]
|
||||
);
|
||||
if (!rowCount) { res.status(403).json({ error: 'not_entitled' }); return null; }
|
||||
return activity;
|
||||
}
|
||||
|
||||
// §9 — the explicit completion marker, valid ONLY for lesson-only ('manual_marker') activity
|
||||
// types. A 'lab' or 'assessment'/'capstone' activity's completion is derived automatically
|
||||
// (course-progress.js) and can never be set this way — marking one "complete" here would be a
|
||||
// second, forgeable source of truth for something the platform already knows how to verify
|
||||
// for real.
|
||||
app.post('/api/student/activities/:activityId/complete', studentSession, wrap(async (req, res) => {
|
||||
const activity = await requireEnrolledActivity(req, res);
|
||||
if (!activity) return;
|
||||
if (activity.completion_behavior !== 'manual_marker') {
|
||||
return res.status(409).json({ error: 'not_manually_completable', message: 'This activity\'s completion is derived automatically.' });
|
||||
}
|
||||
await getPool().query(
|
||||
`INSERT INTO activity_completions (student_id, activity_id) VALUES ($1, $2)
|
||||
ON CONFLICT (student_id, activity_id) DO NOTHING`,
|
||||
[req.studentId, activity.activity_id]
|
||||
);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -395,6 +395,16 @@ require('./admin-programs-routes')(app, {
|
||||
});
|
||||
require('./integration-routes')(app, { getPool, recordAudit, wrap });
|
||||
|
||||
// Phase 6A — course/module/activity content model, layered on top of the existing
|
||||
// scenario/assessment/achievement systems (never a duplicate of them — see
|
||||
// docs/COURSE_CONTENT_MODEL.md). course-routes.js is the student-facing half (course tree
|
||||
// through an enrollment, lesson completion marker); admin-course-routes.js is the instructor
|
||||
// curriculum view (structure + per-student progress).
|
||||
require('./course-routes')(app, { studentSession, getPool, wrap });
|
||||
require('./admin-course-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, getPool,
|
||||
});
|
||||
|
||||
// Phase 5C — enrollment operations & communications. notification-routes.js is the
|
||||
// instructor/admin half (list, per-student view, resend-activation, send-enrollment-
|
||||
// confirmation); the actual delivery worker starts in main() below, alongside the existing
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6A — course/module/activity content model acceptance test. Covers course/module/
|
||||
// activity hierarchy and ordering, prerequisite-based locking, all three completion behaviors
|
||||
// (manual_marker/auto_scenario/auto_assessment) derived from the EXISTING progress/achievements
|
||||
// tables (never a second source of truth), resume-next-activity, cross-student isolation, RBAC
|
||||
// (self-paced no-cohort students), draft-content hiding, and persistence across logout/login.
|
||||
// Same conventions as e2e-phase5b.js/e2e-phase5c.js: assert-based, no framework, live
|
||||
// docker-compose.dev.yml stack. No real Lab container is launched here — that's
|
||||
// infra/scripts/drills/test-phase6a-course-rehearsal.js's job (uses the real Lab environment);
|
||||
// this suite proves the API/data model, using /api/progress/record directly the same way
|
||||
// e2e-phase3b.js/e2e-phase5b.js already do for scenario completion.
|
||||
//
|
||||
// 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-phase6a.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 API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
||||
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';
|
||||
|
||||
// Same real curriculum snapshot e2e-phase3b.js/e2e-phase5b.js already drive full completion
|
||||
// against — see those files' headers for why this is a real, not synthetic, content list.
|
||||
const FOUNDATION = ['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'];
|
||||
const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq'];
|
||||
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 REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits'];
|
||||
|
||||
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 6A ROOT_ course architecture & content delivery acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
// ── Hierarchy, ordering, honest states ──────────────────────────────────────────────────
|
||||
let structure;
|
||||
await test('the ROOT_ course architecture exists with 13 courses in sequence', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.length, 13);
|
||||
for (let i = 1; i < body.length; i++) assert.ok(body[i].sequence > body[i - 1].sequence, 'courses must be in ascending sequence order');
|
||||
structure = body;
|
||||
});
|
||||
|
||||
await test('courses use honest ready/partial/planned states, no fabricated content', async () => {
|
||||
const plannedWithModules = structure.filter((c) => c.status === 'planned' && c.modules.length > 0);
|
||||
assert.equal(plannedWithModules.length, 0, 'a PLANNED course must have zero modules, not placeholder content');
|
||||
const gold = courseById(structure, 'root-10-kubernetes-troubleshooting-observability');
|
||||
assert.equal(gold.status, 'partial'); // real content (gold module) alongside un-authored modules
|
||||
});
|
||||
|
||||
await test('the gold-standard module is fully READY with 6 ordered activities and real content', async () => {
|
||||
const goldModule = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.status, 'ready');
|
||||
assert.equal(goldModule.activities.length, 6);
|
||||
const types = goldModule.activities.map((a) => a.activity_type);
|
||||
assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'reflection']);
|
||||
for (let i = 1; i < goldModule.activities.length; i++) assert.ok(goldModule.activities[i].sequence > goldModule.activities[i - 1].sequence);
|
||||
const lesson = goldModule.activities[0];
|
||||
assert.ok(lesson.body && lesson.body.length > 500, 'lesson content must actually be loaded from Markdown, not empty');
|
||||
assert.ok(lesson.instructor_notes && lesson.instructor_notes.includes('Instructor Notes'), 'instructor view must see instructor notes');
|
||||
const lab = goldModule.activities.find((a) => a.activity_type === 'lab');
|
||||
assert.equal(lab.scenario_id, 'rootnode-probe-port-mismatch');
|
||||
assert.equal(lab.completion_behavior, 'auto_scenario');
|
||||
});
|
||||
|
||||
await test('the capstone activity references the existing assessment engine, not a duplicate definition', async () => {
|
||||
const capstoneModule = moduleById(structure, 'root-capstone-m01-operate-and-recover');
|
||||
const activity = capstoneModule.activities[0];
|
||||
assert.equal(activity.activity_type, 'capstone');
|
||||
assert.equal(activity.assessment_definition_id, 'rootnode-capstone');
|
||||
assert.equal(activity.completion_behavior, 'auto_assessment');
|
||||
});
|
||||
|
||||
// ── A real self-paced student, enrolled and walked through the gold module ──────────────
|
||||
const { body: caseyCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Casey-${RUN_ID}`, login_identifier: `e2e6a-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;
|
||||
|
||||
let tree;
|
||||
await test('a freshly-enrolled student sees the course tree with nothing complete and root-06 locked', async () => {
|
||||
const { status, body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(status, 200);
|
||||
tree = body.courses;
|
||||
const root05 = courseById(tree, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.locked, false); // the first real course is never locked
|
||||
assert.equal(root05.complete, false);
|
||||
const root06 = courseById(tree, 'root-06-kubernetes-application-workloads');
|
||||
assert.equal(root06.locked, true, 'root-06 must be locked until root-05 is complete');
|
||||
});
|
||||
|
||||
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');
|
||||
// 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 () => {
|
||||
const { status, body } = await casey.request('POST', '/api/student/activities/root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq/complete');
|
||||
assert.equal(status, 409);
|
||||
assert.equal(body.error, 'not_manually_completable');
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(root05.percent_complete, 100);
|
||||
const root06 = courseById(body.courses, 'root-06-kubernetes-application-workloads');
|
||||
assert.equal(root06.locked, false, 'root-06 must unlock once root-05 is complete');
|
||||
});
|
||||
|
||||
// ── The gold module: lesson/demo/knowledge-check/guided-exercise (manual) + lab (auto) + reflection (manual) ──
|
||||
await test('lesson-only activities complete via the explicit marker, not automatically', async () => {
|
||||
for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) {
|
||||
const { status } = await casey.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.activities.filter((a) => a.complete).length, 4);
|
||||
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, false, 'the lab must not be complete yet — nothing was solved');
|
||||
});
|
||||
|
||||
await test('marking the same lesson complete twice is idempotent, not an error', async () => {
|
||||
const { status } = await casey.request('POST', '/api/student/activities/root-10-m01-a01-lesson-health-check-concepts/complete');
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await test('the lab activity completes automatically from real scenario completion, not a manual marker', async () => {
|
||||
await completeScenario(casey, 'rootnode-probe-port-mismatch');
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.activities.find((a) => a.activity_type === 'lab').complete, true);
|
||||
assert.equal(goldModule.complete, false, 'the module is not complete until the reflection is also marked');
|
||||
});
|
||||
|
||||
await test('completing the reflection finishes the gold module and unlocks the next one', async () => {
|
||||
const { status } = await casey.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
||||
assert.equal(status, 200);
|
||||
const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.complete, true);
|
||||
assert.equal(goldModule.percent_complete, 100);
|
||||
// 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) ────────────────────────────
|
||||
await test('completing every remaining curriculum scenario and the real capstone marks the capstone activity complete', async () => {
|
||||
for (const sid of [...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING]) await completeScenario(casey, sid);
|
||||
for (const sid of REAL_OPS) if (sid !== 'rootnode-probe-port-mismatch') await completeScenario(casey, sid); // already done above
|
||||
const { status, body } = await casey.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||
assert.equal(status, 201, `capstone should start once every stage is complete: ${JSON.stringify(body)}`);
|
||||
for (const s of body.scenarios) await completeScenario(casey, s.scenario_id);
|
||||
const capstoneCheck = await casey.request('GET', '/api/capstone');
|
||||
assert.equal(capstoneCheck.body.status, 'completed');
|
||||
|
||||
const { body: tree2 } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const capstoneActivity = findActivity(tree2.courses, 'root-capstone-m01-a01-capstone');
|
||||
assert.equal(capstoneActivity.complete, true, 'the capstone course activity must derive completion from the real achievement, not a separate marker');
|
||||
});
|
||||
|
||||
// ── Persistence across logout/login (no real Lab needed — this proves the DATA persists) ──
|
||||
await test('course progress survives logout and a fresh login', async () => {
|
||||
await casey.request('POST', '/api/logout');
|
||||
casey = makeSession(GW);
|
||||
await casey.request('POST', '/api/student/login', { identifier: `e2e6a-casey-${RUN_ID}`, password: 'CaseyPassword123!' });
|
||||
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, 'progress must survive a real logout/login, not just an in-memory session');
|
||||
});
|
||||
|
||||
// ── Cross-student isolation ──────────────────────────────────────────────────────────────
|
||||
const { body: isolatedCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Isolated-${RUN_ID}`, login_identifier: `e2e6a-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 on the same program sees their own independent, empty progress', async () => {
|
||||
const { body } = await isolated.request('GET', `/api/student/programs/${isolatedEnroll.enrollment_id}/courses`);
|
||||
const root05 = courseById(body.courses, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.complete, false, 'a different student\'s completion must never leak across');
|
||||
const root06 = courseById(body.courses, 'root-06-kubernetes-application-workloads');
|
||||
assert.equal(root06.locked, true);
|
||||
});
|
||||
|
||||
const { body: strangerCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6A-Stranger-${RUN_ID}`, login_identifier: `e2e6a-stranger-${RUN_ID}` });
|
||||
const stranger = makeSession(GW);
|
||||
await stranger.request('POST', '/api/student/activate', { token: strangerCreate.activation_token, password: 'StrangerPassword123!' });
|
||||
await test('a student with NO enrollment anywhere cannot mark any activity complete', async () => {
|
||||
const { status, body } = await stranger.request('POST', '/api/student/activities/root-10-m01-a01-lesson-health-check-concepts/complete');
|
||||
assert.equal(status, 403);
|
||||
assert.equal(body.error, 'not_entitled');
|
||||
});
|
||||
|
||||
// ── RBAC: self-paced students have no cohort — requireStudentRelationshipAccess must apply ──
|
||||
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6a-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: `e2e6a-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass123!' });
|
||||
|
||||
await test('a plain instructor with no relationship to a self-paced student is forbidden from their course progress', async () => {
|
||||
const { status } = await strayInstructorSession.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
|
||||
// Note: self-paced enrollment creation is admin/owner-only (admin-programs-routes.js), so a
|
||||
// plain 'instructor' can never become an enrollment's created_by for a self-paced student —
|
||||
// requireStudentRelationshipAccess's positive path for a plain instructor is necessarily via
|
||||
// a shared cohort, already covered by e2e-phase5c.js's equivalent notification-visibility
|
||||
// tests against the same rbac.js helper. Not re-tested here to avoid duplicating that
|
||||
// coverage against unrelated cohort machinery.
|
||||
await test('owner/admin can see any self-paced student\'s course progress regardless of relationship', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 200);
|
||||
const root05 = courseById(body, 'root-05-kubernetes-foundations');
|
||||
assert.equal(root05.complete, true);
|
||||
});
|
||||
|
||||
// ── Draft/inactive content never leaks to students ──────────────────────────────────────
|
||||
await test('a draft activity is invisible to the student endpoint but visible to instructors', async () => {
|
||||
psql(`UPDATE module_activities SET visibility = 'draft' WHERE activity_id = 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'`);
|
||||
try {
|
||||
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const workloadsModule = moduleById(studentView.courses, 'root-06-m01-deployments-and-workloads');
|
||||
assert.ok(!workloadsModule.activities.some((a) => a.activity_id === 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'), 'a draft activity must never appear in a student response');
|
||||
const { body: instructorView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const workloadsModuleAdmin = moduleById(instructorView, 'root-06-m01-deployments-and-workloads');
|
||||
assert.ok(workloadsModuleAdmin.activities.some((a) => a.activity_id === 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'), 'instructors must still see draft content');
|
||||
} finally {
|
||||
psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-06-m01-deployments-and-workloads-a01-deploy-nginx'`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('a draft module is invisible to students but visible to instructors', async () => {
|
||||
psql(`UPDATE course_modules SET visibility = 'draft' WHERE module_id = 'root-07-m01-services-and-networking'`);
|
||||
try {
|
||||
const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
const root07 = courseById(studentView.courses, 'root-07-kubernetes-networking-config');
|
||||
assert.ok(!root07.modules.some((m) => m.module_id === 'root-07-m01-services-and-networking'));
|
||||
const { body: instructorView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const root07Admin = courseById(instructorView, 'root-07-kubernetes-networking-config');
|
||||
assert.ok(root07Admin.modules.some((m) => m.module_id === 'root-07-m01-services-and-networking'));
|
||||
} finally {
|
||||
psql(`UPDATE course_modules SET visibility = 'published' WHERE module_id = 'root-07-m01-services-and-networking'`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Content version stability ────────────────────────────────────────────────────────────
|
||||
await test('bumping a module\'s version does not break or drop its content', async () => {
|
||||
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-10-m01-health-probes-and-troubleshooting'`);
|
||||
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const goldModule = moduleById(body, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.activities.length, 6, 'a version bump must never drop activities');
|
||||
assert.ok(goldModule.activities[0].body && goldModule.activities[0].body.length > 500, 'content must still load after a version bump');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
||||
@@ -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