Compare commits
4
Commits
2db136875f
...
bd0a10fbfb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd0a10fbfb | ||
|
|
01d3764138 | ||
|
|
903b0ead43 | ||
|
|
7f6e1857c0 |
+49
@@ -0,0 +1,49 @@
|
||||
# Building kubectl Fluency: Why Twenty Small Reps Beat One Big Lecture
|
||||
|
||||
## What this module is, and why it looks different from the last one
|
||||
|
||||
ROOT 05's mental model module (m00) taught you the CONCEPT of desired state and gave you your
|
||||
first `kubectl` commands. This module does something deliberately different: it's a breadth
|
||||
drill across the everyday `kubectl` vocabulary you'll type dozens of times a day as a working
|
||||
Kubernetes engineer — labels, JSONPath, dry-run manifests, patch, field selectors, sort-by,
|
||||
diff, init containers, static Pods, and more.
|
||||
|
||||
There's no single narrative thread connecting these twenty tasks, and that's intentional. Real
|
||||
`kubectl` fluency isn't one big insight — it's having enough of these small, specific commands
|
||||
already in muscle memory that you're not stopping to look up syntax while you're mid-incident.
|
||||
Each task below explains the specific concept it covers in its own description; your job is to
|
||||
work through them, not to memorize a lecture first.
|
||||
|
||||
## How to approach this module
|
||||
|
||||
- **Don't try to do them in one sitting if you don't need to.** These are independent — skip
|
||||
around, come back to ones you found awkward, and don't feel obligated to treat this as one
|
||||
continuous session.
|
||||
- **Read each task's own explanation before reaching for the hint.** Every one of these
|
||||
twenty tasks includes real context in its description — that context IS the teaching content
|
||||
for that specific command. The hint is there for when you're stuck, not as the first thing to
|
||||
read.
|
||||
- **Notice which commands you reach for confidently versus which ones you have to think about.**
|
||||
That gap is exactly what the reflection at the end of this module asks you to name.
|
||||
- **This breadth matters more than it looks like it does.** `kubectl explain`, `--dry-run`,
|
||||
JSONPath output, and `kubectl patch` in particular are commands experienced engineers use
|
||||
constantly and newer engineers often don't know exist at all — this module exists specifically
|
||||
to close that gap early.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Lesson goal:** Frame this module correctly before students hit it — it's a breadth-practice
|
||||
bank, not a single-concept lesson, and the individual task descriptions carry the teaching
|
||||
content. Without this framing, students sometimes expect a lecture and are confused when they
|
||||
land directly on twenty short tasks.
|
||||
|
||||
**Approximate timing:** 5 minutes to introduce; the module itself runs long (20 tasks) and is
|
||||
meant to be worked through at each student's own pace, live or between sessions.
|
||||
|
||||
**Facilitation for a live cohort:** Consider live-demoing 2-3 of the less obvious ones
|
||||
(`kubectl explain`, JSONPath output, `kubectl patch`) rather than all twenty — the goal is to
|
||||
unblock students on the commands they're least likely to have seen before, not to walk through
|
||||
every task.
|
||||
|
||||
**Expected takeaway:** Kubernetes fluency is built from a large vocabulary of small, reliable
|
||||
commands, not just conceptual understanding — this module is where that vocabulary gets built.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Reflection: Naming Your Gaps While They're Still Cheap to Close
|
||||
|
||||
You just worked through twenty independent `kubectl` tasks. Before moving on, take a few
|
||||
minutes to consolidate what actually stuck versus what you'd need to look up again.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Which THREE of these twenty commands do you feel genuinely confident using again right now,
|
||||
without a hint? Name them specifically.
|
||||
2. Which ONE command surprised you the most — either because you didn't know it existed, or
|
||||
because it did something different than you expected?
|
||||
3. `kubectl explain`, `kubectl patch`, and JSONPath output are commands a lot of engineers never
|
||||
learn early and end up rediscovering the hard way, mid-incident, years later. Now that you've
|
||||
used all three, where do you think you'd actually reach for one of them in a real debugging
|
||||
situation?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Fluency gaps are cheap to close now and expensive to close later.** Right now, not knowing
|
||||
`kubectl patch` costs you a few minutes rereading this module. In a live incident six months
|
||||
from now, not knowing it costs you minutes you don't have while a Service is down. Naming your
|
||||
gaps here, while the stakes are zero, is how you make sure you close them before they matter.
|
||||
|
||||
**Breadth practice is not busywork.** It's easy to dismiss twenty small tasks as less valuable
|
||||
than one big project — but the twenty small tasks are what make the big project fast to execute
|
||||
later. This is the same discipline you'll rely on for the rest of ROOT_: build the vocabulary
|
||||
now, apply it under pressure later.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 3 works well as a round-robin — go
|
||||
person to person and have each name a different command, so the group collectively surfaces
|
||||
most of the twenty rather than converging on the same one or two obvious answers.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Expected takeaway:** Students should leave this module able to name their own remaining gaps
|
||||
specifically, not just feel generally "done" with kubectl basics.
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Demonstration: Watching a Job Actually Complete
|
||||
|
||||
## Creating the Job
|
||||
|
||||
```
|
||||
$ kubectl apply -f pi-job.yaml
|
||||
job.batch/pi-job created
|
||||
|
||||
$ kubectl get jobs
|
||||
NAME COMPLETIONS DURATION AGE
|
||||
pi-job 0/1 3s 3s
|
||||
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
pi-job-8x2kq 1/1 Running 0 3s
|
||||
```
|
||||
|
||||
The Job's own status (`0/1` completions) is tracked separately from its Pod's status — this
|
||||
matters because a Job can retry a FAILED Pod attempt without that looking like the healthy
|
||||
restart behavior you'd see on a Deployment.
|
||||
|
||||
## Once the Pod's process actually exits successfully
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
pi-job-8x2kq 0/1 Completed 0 8s
|
||||
|
||||
$ kubectl get jobs
|
||||
NAME COMPLETIONS DURATION AGE
|
||||
pi-job 1/1 7s 8s
|
||||
```
|
||||
|
||||
`STATUS: Completed` — not `Running`, not `CrashLoopBackOff`. This is the ONE status a
|
||||
long-running Deployment's Pod is never supposed to reach on its own; for a Job, it's success.
|
||||
The Job object itself now reports `1/1` and stops — nothing recreates this Pod, because
|
||||
"finished successfully" is the desired end state here, unlike a Deployment where exiting is
|
||||
always treated as something to fix.
|
||||
|
||||
## What a FAILING Job attempt looks like, briefly
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
bad-job-abc12 0/1 Error 0 5s
|
||||
bad-job-def34 0/1 Error 0 2s
|
||||
|
||||
$ kubectl describe job bad-job
|
||||
...
|
||||
Events:
|
||||
Warning BackoffLimitExceeded Job has reached the specified backoff limit
|
||||
```
|
||||
|
||||
Two DIFFERENT Pods (`abc12`, `def34`), not one Pod restarting twice — each failed attempt gets
|
||||
its own new Pod, up to `backoffLimit`. Once that limit is hit, the Job stops retrying and
|
||||
reports the failure via Events rather than trying forever.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run the successful case live; showing the FAILING case is optional if time is
|
||||
short, but the `Completed` vs `Running`/`Error` distinction is worth pausing on regardless.
|
||||
|
||||
**Approximate timing:** 8 minutes live; 5 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students who've only seen Deployments sometimes expect
|
||||
`kubectl get pods` to show the Job's Pod disappear once it's done. It doesn't, by default — it
|
||||
stays as `Completed` until manually cleaned up or a TTL is configured, which is worth mentioning
|
||||
briefly (not a hands-on topic here).
|
||||
|
||||
**Optional extension:** ask what `kubectl get pods` would show for a CronJob's Pods across
|
||||
several scheduled runs (answer: one Completed Pod per past run, accumulating — the same
|
||||
"nothing auto-cleans this up" behavior, multiplied by the schedule).
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Guided Exercise: Choosing the Right Workload Primitive
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
Your team needs four things running in the cluster:
|
||||
|
||||
1. A nightly database backup that must run at 2am and finish before morning.
|
||||
2. A log-shipping agent that must run on every node, including nodes added later.
|
||||
3. A one-time script to backfill a new database column, run once during this deploy.
|
||||
4. The main web application, always up, serving live traffic.
|
||||
|
||||
## Your task
|
||||
|
||||
For each of the four, name the correct primitive (Deployment, Job, CronJob, or DaemonSet) and
|
||||
answer: what would go wrong if you used a Deployment for it instead?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. **CronJob.** A Deployment has no concept of "run on a schedule" — you'd need external
|
||||
tooling to trigger it repeatedly, reinventing what CronJob already does. Using a bare Job
|
||||
would run it once; CronJob is what recreates that Job on schedule.
|
||||
2. **DaemonSet.** A Deployment's replica count is a number, not "one per node" — nothing
|
||||
guarantees even coverage, and a newly added node wouldn't automatically get a copy the way a
|
||||
DaemonSet guarantees.
|
||||
3. **Job.** A Deployment expects its Pod to run forever; a script that's SUPPOSED to exit when
|
||||
done would be treated as a crash and restarted forever, which is wrong for a one-time task.
|
||||
4. **Deployment.** This is the actual always-on, N-replicas case the other three are explicitly
|
||||
NOT designed for.
|
||||
|
||||
## Self-check
|
||||
|
||||
If you answered "Job" for #4 or "Deployment" for #1-3, revisit the lesson's decision table
|
||||
before the Labs — getting this choice right the first time avoids building something that
|
||||
technically runs but behaves wrong in a way that's easy to miss until it causes a real problem
|
||||
(a backup CronJob accidentally deployed as a Job that only ever runs once, for example).
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Good as a quick individual exercise before revealing the answer — the "what
|
||||
would go wrong" framing forces reasoning about the CONSEQUENCE of a wrong choice, not just
|
||||
recall of a definition.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live with discussion; 8 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would need to be true for #1 (the nightly backup) to actually
|
||||
be SAFE to run as a plain Job triggered manually once, instead of a CronJob — a good check that
|
||||
students understand CronJob's value is specifically the SCHEDULING, not something inherent to
|
||||
backups.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Reflection: The Right Primitive Is a Correctness Decision, Not a Style Choice
|
||||
|
||||
You just built a one-shot Job and a scheduled CronJob for real. Take a few minutes to connect
|
||||
this to production thinking.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. If your team accidentally deployed the database backup script as a Deployment instead of a
|
||||
CronJob, what's the FIRST symptom you'd likely notice — and would it look like a bug in the
|
||||
backup script itself, or something else?
|
||||
2. A CronJob that fails silently every night for a week is a real, common incident pattern. What
|
||||
would you want monitored to catch this quickly?
|
||||
3. Why does "it ran once and worked" not prove a CronJob is configured correctly?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Workload-primitive mistakes often look like application bugs at first.** A backup script
|
||||
"crashing" every few minutes on a Deployment isn't a bug in the script — the script did exactly
|
||||
what it was told to do (exit when finished) and the Deployment is "correctly" restarting it
|
||||
according to its own contract. Recognizing "wrong primitive," not "buggy code," as the root
|
||||
cause saves real debugging time.
|
||||
|
||||
**A schedule you never verify is a schedule you can't trust.** A CronJob failing every single
|
||||
run since deployment is easy to miss if nobody checks — unlike a Deployment's crash-looping
|
||||
Pods, which are visible in `kubectl get pods` by default, a CronJob's failed history requires
|
||||
someone to actually look at `kubectl get jobs` or the CronJob's own status. Real teams monitor
|
||||
CronJob success/failure explicitly rather than assuming silence means success.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 1 is worth pushing on — many students'
|
||||
first instinct is to debug the "crashing" script rather than question the workload type it's
|
||||
running under.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to name one alerting rule they'd add for a critical nightly
|
||||
CronJob (e.g. "alert if no successful completion recorded in the last 25 hours").
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
# Externalizing Configuration: ConfigMaps & Secrets
|
||||
|
||||
## The problem: hardcoded configuration doesn't scale
|
||||
|
||||
If your application's database URL, log level, or feature flags are baked directly into its
|
||||
container image, changing any of them means rebuilding and redeploying the image — for a
|
||||
one-line config change. Worse, the SAME image can't be reused across dev/staging/production if
|
||||
each environment needs different settings compiled in.
|
||||
|
||||
Kubernetes solves this by letting you inject configuration into a container at RUNTIME,
|
||||
separately from the image itself — the same image, different config per environment.
|
||||
|
||||
## ConfigMap: configuration that isn't secret
|
||||
|
||||
A **ConfigMap** holds arbitrary key/value configuration data — log levels, feature flags, URLs,
|
||||
whole config files. It's a plain Kubernetes object, readable by anyone with API access to that
|
||||
Namespace, so it's for configuration that ISN'T sensitive.
|
||||
|
||||
Two ways to consume a ConfigMap in a Pod:
|
||||
|
||||
- **As environment variables** — each key becomes an env var the application reads normally.
|
||||
- **As a mounted volume** — each key becomes a FILE inside the container, useful for
|
||||
applications that expect to read configuration from disk rather than environment variables.
|
||||
|
||||
```yaml
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: app-env
|
||||
```
|
||||
|
||||
## Secret: the same idea, for sensitive data
|
||||
|
||||
A **Secret** has the identical shape and consumption pattern as a ConfigMap (env vars or a
|
||||
mounted volume) but is meant for sensitive values — passwords, API keys, tokens. Kubernetes
|
||||
stores Secret data base64-ENCODED, not encrypted — this is an important distinction:
|
||||
base64 is trivially reversible, so a Secret's real protection comes from Kubernetes' RBAC
|
||||
(who can even read Secret objects at all, ROOT 09) and, in a properly configured cluster,
|
||||
encryption at rest — not from the encoding itself.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: app-secret
|
||||
type: Opaque
|
||||
data:
|
||||
api-key: c3VwZXJzZWNyZXQ= # base64 of "supersecret" — NOT encrypted
|
||||
```
|
||||
|
||||
## Why the same mechanism, two different objects
|
||||
|
||||
You might ask why Kubernetes doesn't just have one "Config" object with a "sensitive" flag.
|
||||
Keeping them separate makes the DISTINCTION visible and enforceable — RBAC rules can grant
|
||||
"read ConfigMaps" without granting "read Secrets," and a quick `kubectl get configmaps` never
|
||||
accidentally exposes something that should have been a Secret. The separation is a deliberate
|
||||
safety boundary, not redundant API surface.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live.
|
||||
|
||||
**Demo plan:** Before the demonstration activity, it's worth showing `kubectl get secret
|
||||
app-secret -o yaml` live and pointing out the base64 value, then decoding it on the spot
|
||||
(`echo <value> | base64 -d`) — the "this isn't actually encrypted" realization lands much better
|
||||
seen than described.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students very commonly assume Secrets are encrypted by Kubernetes automatically. Correct this
|
||||
explicitly and early — it's a genuine, consequential security misunderstanding.
|
||||
- The env-var vs. volume-mount choice is sometimes seen as arbitrary. Note one real
|
||||
consideration: env vars are visible via `kubectl describe pod` and process inspection tools
|
||||
inside the container more easily than a mounted file's contents — not a hard rule, but a
|
||||
reason some teams prefer volume mounts specifically for Secrets.
|
||||
|
||||
**Discussion question:** "If someone with `kubectl get secrets` access on your namespace can
|
||||
trivially read every Secret's real value, what is a Secret actually protecting you from?"
|
||||
(Answer: it protects against accidental exposure through normal channels like
|
||||
`kubectl get configmaps`, source control, or logs — not against a determined actor with direct
|
||||
API read access, which is exactly why RBAC restricting who has that access is the real control.)
|
||||
|
||||
**Optional extension:** ask what changes about this picture on a cluster with encryption-at-rest
|
||||
enabled for Secrets (answer: the ETCD-stored data itself becomes encrypted, protecting against
|
||||
someone who gains access to the underlying etcd storage directly — still separate from and in
|
||||
addition to RBAC controlling API-level access).
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Demonstration: A Secret Is Encoded, Not Encrypted
|
||||
|
||||
## Creating a Secret
|
||||
|
||||
```
|
||||
$ kubectl create secret generic app-secret --from-literal=api-key=supersecret
|
||||
secret/app-secret created
|
||||
```
|
||||
|
||||
## Looking at it
|
||||
|
||||
```
|
||||
$ kubectl get secret app-secret -o yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: app-secret
|
||||
data:
|
||||
api-key: c3VwZXJzZWNyZXQ=
|
||||
type: Opaque
|
||||
```
|
||||
|
||||
## Decoding it — no special permission needed beyond reading the Secret itself
|
||||
|
||||
```
|
||||
$ echo "c3VwZXJzZWNyZXQ=" | base64 -d
|
||||
supersecret
|
||||
```
|
||||
|
||||
Anyone who can run `kubectl get secret app-secret -o yaml` (i.e. anyone RBAC allows to READ
|
||||
Secrets in this Namespace) can trivially recover the real value with a standard, built-in
|
||||
command — no cracking, no special tooling. This is exactly why the lesson's point matters: the
|
||||
protection here is WHO can read the Secret object at all (RBAC), not the base64 encoding itself.
|
||||
|
||||
## Comparing to a ConfigMap
|
||||
|
||||
```
|
||||
$ kubectl get configmap app-config -o yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
environment: production
|
||||
log_level: info
|
||||
```
|
||||
|
||||
A ConfigMap's values are stored in PLAIN TEXT — no encoding step at all. This is consistent with
|
||||
ConfigMaps being explicitly for non-sensitive data; there's no pretense of protection to begin
|
||||
with.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run this live, actually decoding the value in front of the class — the gap
|
||||
between "Secret" (a name implying protection) and what the command output shows is the entire
|
||||
point, and it lands far better performed than read.
|
||||
|
||||
**Approximate timing:** 6-8 minutes live; 5 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** this demonstration directly targets the single most common
|
||||
misconception about Kubernetes Secrets — do not skip it even if time is short, since a wrong
|
||||
belief here has real security consequences later.
|
||||
|
||||
**Optional extension:** ask what a `SealedSecret` or an external secrets manager (mentioned by
|
||||
name only, not built as a hands-on topic here) is trying to solve that a plain Kubernetes Secret
|
||||
does not — a good forward pointer without requiring new infrastructure in this course.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Knowledge Check: Configuration & Secrets
|
||||
|
||||
---
|
||||
|
||||
**1. A teammate says "our database password is safe because it's stored as a Kubernetes
|
||||
Secret." What's missing from that statement?**
|
||||
|
||||
*Answer:* A Secret's data is base64-ENCODED, not encrypted — anyone who can read the Secret
|
||||
object at all can trivially decode it. The real protection is RBAC (who is allowed to read
|
||||
Secrets in that namespace) and, on a properly configured cluster, encryption at rest — not the
|
||||
Secret object type itself.
|
||||
|
||||
---
|
||||
|
||||
**2. Why does Kubernetes have two separate objects (ConfigMap and Secret) instead of one
|
||||
"Config" object with a sensitivity flag?**
|
||||
|
||||
*Answer:* Keeping them separate makes the distinction enforceable via RBAC — a role can grant
|
||||
"read ConfigMaps" without granting "read Secrets" — and makes it visible at a glance
|
||||
(`kubectl get configmaps` never risks accidentally listing something sensitive).
|
||||
|
||||
---
|
||||
|
||||
**3. An application needs its configuration to change without rebuilding its container image
|
||||
across dev/staging/production. Which Kubernetes mechanism solves this, and why not just bake
|
||||
different configs into three separate images?**
|
||||
|
||||
*Answer:* ConfigMaps (and Secrets, for sensitive values) injected at runtime via env vars or a
|
||||
mounted volume. Baking config into the image means maintaining three separate images for what
|
||||
should be identical application code — externalizing configuration lets the SAME image run
|
||||
correctly in every environment.
|
||||
|
||||
---
|
||||
|
||||
**4. True or false: mounting a ConfigMap as a volume and consuming it as environment variables
|
||||
are functionally identical from the application's perspective.**
|
||||
|
||||
*Answer:* False. An application reading environment variables needs code that reads env vars; an
|
||||
application expecting a config FILE on disk needs the volume-mount approach. The right choice
|
||||
depends on how the application itself is written to read configuration, not just personal
|
||||
preference.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 1 is the single most important takeaway from this module — confirm
|
||||
it's genuinely understood, not just correctly guessed, before moving to the Labs.
|
||||
|
||||
**Approximate timing:** 5-7 minutes.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Guided Exercise: ConfigMap, Secret, Env Var, or Volume?
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
Your application needs four pieces of configuration:
|
||||
|
||||
1. A log level (`debug`/`info`/`warn`) that ops sometimes needs to flip without redeploying.
|
||||
2. A database password.
|
||||
3. A large JSON config file the application reads from a specific path at startup.
|
||||
4. A feature flag read once at container startup via an environment variable, per the
|
||||
application's existing code.
|
||||
|
||||
## Your task
|
||||
|
||||
For each, decide: ConfigMap or Secret, and env var or volume mount — then justify each choice.
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. **ConfigMap, env var.** Not sensitive; the application likely already reads it as an env var
|
||||
(a common convention), and env vars are simple to change and trigger a rolling restart with.
|
||||
2. **Secret, either — but env var is common here too**, since most applications expect
|
||||
credentials via environment variables by convention. The KEY decision is Secret (sensitive),
|
||||
not the delivery mechanism.
|
||||
3. **ConfigMap, volume mount.** The application expects a FILE at a path, not an environment
|
||||
variable — volume mount is the only mechanism that presents a ConfigMap as an actual file.
|
||||
4. **ConfigMap (assuming not sensitive) or Secret (if it is), env var** — the application's
|
||||
EXISTING code dictates this; you don't get to choose a mechanism the application wasn't
|
||||
written to read.
|
||||
|
||||
## Self-check
|
||||
|
||||
If you chose "volume mount" for #4 or "env var" for #3, revisit the lesson — the deciding
|
||||
factor here isn't which mechanism seems more "correct" in the abstract, it's what the
|
||||
application actually expects. Getting this backwards means the application simply won't find
|
||||
its configuration at all.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Emphasize that #4's answer depends entirely on EXISTING application
|
||||
behavior — a good moment to note that platform engineers frequently have to adapt Kubernetes
|
||||
config delivery to match an application's assumptions, not the reverse.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live with discussion; 8 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would happen if a ConfigMap consumed as a volume mount is
|
||||
UPDATED after the Pod is already running — a good bridge to the real (if not hands-on-tested
|
||||
here) behavior that mounted ConfigMap files update automatically after a short delay, while
|
||||
env-var-injected values do NOT update without a Pod restart.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Reflection: Configuration Is Infrastructure, Secrets Are a Trust Boundary
|
||||
|
||||
You just externalized configuration and handled a real Secret. Take a few minutes to connect
|
||||
this to production practice.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, would you have assumed a Kubernetes Secret was encrypted? What's the
|
||||
real-world consequence of a team believing that when it isn't true?
|
||||
2. If your team's RBAC accidentally granted a low-trust role `get secrets` access across an
|
||||
entire namespace, what's actually exposed — and would your team necessarily notice?
|
||||
3. Why does separating configuration from the container image matter beyond convenience — think
|
||||
about what it enables for testing or incident response.
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**A misunderstood Secret is a false sense of security.** Teams that believe Secrets are
|
||||
encrypted by default sometimes skip additional controls (proper RBAC scoping, external secrets
|
||||
management, encryption at rest) that they'd otherwise recognize as necessary. The gap between
|
||||
perceived and actual protection is exactly where real incidents happen.
|
||||
|
||||
**Externalized configuration is what makes rollback and incident response fast.** If a bad
|
||||
config value ships (a wrong feature flag, an incorrect log level flooding storage), fixing it
|
||||
means updating a ConfigMap/Secret and letting Pods pick up the change — not rebuilding and
|
||||
redeploying an entire image. This is a direct, practical payoff of the separation this module
|
||||
taught, not just an architectural nicety.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 works well as a small-group exercise —
|
||||
have students actually list what a namespace's Secrets typically contain in a real application
|
||||
(DB creds, API keys, TLS certs) to make the exposure concrete.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to draft a one-line policy statement their hypothetical
|
||||
team would adopt about who is allowed `get`/`list` access to Secrets in production namespaces.
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Demonstration: Tracing a Request Through Ingress
|
||||
|
||||
## The setup
|
||||
|
||||
An Ingress `web-ingress` routes `app.example.com/` to Service `web-svc`, which fronts a
|
||||
Deployment `web-app`. Nothing works yet from outside the cluster — let's see why, step by step.
|
||||
|
||||
## Step 1: is the Ingress object even doing anything?
|
||||
|
||||
```
|
||||
$ kubectl get ingress web-ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
web-ingress traefik app.example.com 80 2m
|
||||
```
|
||||
|
||||
`ADDRESS` is empty. On a real cluster, this column fills in once the Ingress controller has
|
||||
actually processed the object and assigned it a reachable address — an empty address after a
|
||||
couple of minutes is the first sign to check the controller itself, not your YAML.
|
||||
|
||||
## Step 2: is the controller running at all?
|
||||
|
||||
```
|
||||
$ kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
traefik-7d8f9c6b5d-abcde 1/1 Running 0 10d
|
||||
```
|
||||
|
||||
Running fine — so the ADDRESS delay in this case is transient, not a missing controller. (If
|
||||
this returned no Pods at all, THAT would be the actual root cause — no controller means no
|
||||
Ingress object ever gets acted on, exactly like the demonstration in ROOT 07's Ingress lesson
|
||||
described.)
|
||||
|
||||
## Step 3: once ADDRESS populates, does the route actually work?
|
||||
|
||||
```
|
||||
$ kubectl get ingress web-ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
web-ingress traefik app.example.com 10.0.1.50 80 3m
|
||||
|
||||
$ curl -H "Host: app.example.com" http://10.0.1.50/
|
||||
<html>...web-app's real response...</html>
|
||||
```
|
||||
|
||||
Success — but notice the `-H "Host: app.example.com"` header. Ingress routes by HOSTNAME, not
|
||||
just by IP — hitting the Ingress controller's address WITHOUT the matching Host header would
|
||||
route differently (or not at all), because that's literally what the Ingress rule is matching
|
||||
on.
|
||||
|
||||
## The general shape
|
||||
|
||||
Symptom (nothing reachable from outside) has at least three DIFFERENT possible layers: the
|
||||
Ingress object itself, the controller processing it, and the Service/Pod chain underneath it
|
||||
(already covered in ROOT 07's Services module). Check them in that order — Ingress ADDRESS,
|
||||
then controller health, then the underlying Service — rather than guessing at DNS or firewall
|
||||
issues first.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** If a real Ingress controller is available in the demo environment, run this
|
||||
live including the Host-header nuance — students are frequently surprised that `curl <ip>`
|
||||
alone doesn't work the way they expect.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students often expect an Ingress's IP/address to work like
|
||||
a Service's ClusterIP (reachable directly, no extra header needed). Reinforce that Ingress
|
||||
routing is fundamentally HOSTNAME/PATH-based, which is exactly what makes one Ingress able to
|
||||
route many different domains through one entry point.
|
||||
|
||||
**Optional extension:** ask what would happen if TWO Ingress objects both claimed the same host
|
||||
+ path — a good forward pointer to why Ingress configuration conflicts are a real operational
|
||||
concern on a shared cluster, without needing to build a hands-on lab around it here.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Guided Exercise: Planning Network Isolation
|
||||
|
||||
Paper-and-thinking exercise before the Lab.
|
||||
|
||||
## The scenario
|
||||
|
||||
A `database` Pod (label `app=database`) in the `netpol` namespace should only ever be reachable
|
||||
by Pods labeled `role=backend` — not the frontend, not anything else in the cluster.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Before writing any YAML: right now, with NO NetworkPolicy in place, can the frontend reach
|
||||
the database? Why?
|
||||
2. What would a NetworkPolicy that ONLY specifies `podSelector: { matchLabels: { app: database
|
||||
} }` and no `ingress` rules at all do to traffic reaching the database — including from
|
||||
`role=backend` Pods?
|
||||
3. Sketch (in words, not full YAML) what the `ingress` rule needs to say to allow ONLY
|
||||
`role=backend` traffic in.
|
||||
4. If a new Pod is later created with `role=backend` in a DIFFERENT namespace, would your policy
|
||||
as sketched in #3 allow it? What would need to change if cross-namespace access should be
|
||||
allowed?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. Yes — by default, every Pod can reach every other Pod regardless of labels or namespace
|
||||
(ROOT 07's Ingress & NetworkPolicy lesson). Nothing restricts this until a policy exists.
|
||||
2. It would block ALL incoming traffic to the database, including from `role=backend` — an
|
||||
empty `ingress` list under a policy that selects the Pod means "no traffic is allowed in,"
|
||||
not "no policy applies."
|
||||
3. An `ingress` rule with `from: [{ podSelector: { matchLabels: { role: backend } } }]` — this
|
||||
allows traffic specifically from Pods carrying that label, and nothing else.
|
||||
4. Not automatically — a bare `podSelector` in the `from` clause matches Pods in the SAME
|
||||
namespace as the policy by default. Allowing a different namespace requires adding a
|
||||
`namespaceSelector` alongside the `podSelector` in the same `from` entry.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 2 is the one most likely to trip students up — it directly reinforces
|
||||
the "empty ingress rules means deny-all, not no-op" point from the lesson, which is worth
|
||||
confirming actually landed before the Lab.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what a security-minded team would ALSO want to restrict about this
|
||||
database Pod's OWN outbound (egress) traffic, beyond who can reach it — a light forward pointer
|
||||
to ROOT 09's security-control framing of NetworkPolicy, without requiring students to build an
|
||||
egress rule here.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Reflection: Reachability Should Be a Decision, Not a Default
|
||||
|
||||
You just configured real Ingress routing and real network isolation. Take a few minutes to
|
||||
connect this to production thinking.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, would you have assumed a fresh Kubernetes cluster isolates Pods from
|
||||
each other by default? Has that changed?
|
||||
2. If your team runs a multi-tenant cluster (several unrelated applications sharing it), what's
|
||||
the risk of NEVER applying any NetworkPolicy at all?
|
||||
3. Ingress lets many different domains share one entry point. What's the operational benefit of
|
||||
that beyond just "fewer objects to manage"?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**"No NetworkPolicy" is not neutral — it's full reachability.** Exactly like an unhardened
|
||||
Pod's SecurityContext (ROOT 09 covers this in depth), the absence of a NetworkPolicy isn't a
|
||||
safe default; it's the MOST permissive configuration available. On any cluster where more than
|
||||
one team or application shares infrastructure, that's a real, often-overlooked exposure.
|
||||
|
||||
**Centralizing external entry points through Ingress reduces attack surface, not just object
|
||||
count.** Every separate LoadBalancer Service is a separate externally-reachable endpoint to
|
||||
secure, monitor, and patch. Routing through one (or a small number of) Ingress controllers means
|
||||
security controls — TLS termination, rate limiting, WAF rules — are configured and audited in
|
||||
one place instead of scattered across every application team's own Service definitions.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 is worth pushing on with a concrete
|
||||
scenario — ask what a compromised low-value application (a demo app, a staging environment)
|
||||
could reach on a cluster with zero NetworkPolicies, versus one with sensible defaults.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to sketch (in words) what a reasonable DEFAULT-DENY policy
|
||||
for an entire new namespace would look like, before any application-specific allow rules are
|
||||
added — previews the security-control framing ROOT 09 builds on.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Demonstration: Watching RBAC Actually Deny Something
|
||||
|
||||
## The setup
|
||||
|
||||
A ServiceAccount `readonly-bot` has a Role granting only `get`/`list` on `pods` — nothing else.
|
||||
|
||||
## What it CAN do
|
||||
|
||||
```
|
||||
$ kubectl auth can-i list pods --as=system:serviceaccount:default:readonly-bot
|
||||
yes
|
||||
|
||||
$ kubectl get pods --as=system:serviceaccount:default:readonly-bot
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
web-abc123 1/1 Running 0 2h
|
||||
```
|
||||
|
||||
## What it CANNOT do
|
||||
|
||||
```
|
||||
$ kubectl auth can-i delete pods --as=system:serviceaccount:default:readonly-bot
|
||||
no
|
||||
|
||||
$ kubectl delete pod web-abc123 --as=system:serviceaccount:default:readonly-bot
|
||||
Error from server (Forbidden): pods "web-abc123" is forbidden:
|
||||
User "system:serviceaccount:default:readonly-bot" cannot delete resource "pods"
|
||||
in API group "" in the namespace "default"
|
||||
```
|
||||
|
||||
This is what least privilege actually looks like in practice — not a policy document, a real,
|
||||
enforced denial with a specific, readable error naming exactly which permission was missing.
|
||||
`kubectl auth can-i` is your fastest way to CHECK a permission before attempting an action that
|
||||
might fail — useful both for debugging "why can't my workload do X" and for verifying a Role is
|
||||
actually as narrow as intended.
|
||||
|
||||
## What happens when the ServiceAccount tries something it was never even considered for
|
||||
|
||||
```
|
||||
$ kubectl auth can-i get secrets --as=system:serviceaccount:default:readonly-bot
|
||||
no
|
||||
```
|
||||
|
||||
Notice this returns `no` the same way `delete pods` did — RBAC has no concept of "not yet
|
||||
decided," only "explicitly allowed" or "everything else, denied." This is the practical meaning
|
||||
of default-deny applied to permissions: nothing is grey area.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run all three `kubectl auth can-i`/action pairs live — the FORBIDDEN error
|
||||
message's exact wording (naming the verb, resource, and namespace) is worth reading aloud, since
|
||||
that's exactly the evidence a student would use to debug a real permissions issue later.
|
||||
|
||||
**Approximate timing:** 8 minutes live; 5 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students sometimes think `kubectl auth can-i` REQUIRES the
|
||||
action to actually be attempted to know the answer. It doesn't — it's a dry-run permission
|
||||
check against the same RBAC rules, safe to run repeatedly without side effects.
|
||||
|
||||
**Optional extension:** ask what the error message would look like if `readonly-bot` tried to
|
||||
act on a DIFFERENT namespace than the one its Role/RoleBinding applies to — a good check that
|
||||
students understand Role scope is namespace-specific unless a ClusterRole+ClusterRoleBinding is
|
||||
used instead.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Guided Exercise: Writing a Least-Privilege Role
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
A monitoring agent needs to run inside your cluster. Its ONLY job is to periodically list Pods
|
||||
and read their status, across the whole cluster, to report health metrics elsewhere. It never
|
||||
needs to create, modify, or delete anything, and it never needs to touch Secrets, ConfigMaps, or
|
||||
any object type other than Pods.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Should this use a Role or a ClusterRole? Justify your answer based on scope.
|
||||
2. Which verbs does it actually need? Which common verbs (create, update, delete, patch) should
|
||||
be deliberately left OUT?
|
||||
3. Does it need access to Secrets? Why is it tempting to grant broader access "just in case,"
|
||||
and why should you resist that?
|
||||
4. What would you check, using `kubectl auth can-i`, to CONFIRM your Role is exactly as narrow
|
||||
as intended before deploying it?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. **ClusterRole** (bound via a ClusterRoleBinding) — it needs to see Pods across the WHOLE
|
||||
cluster, not just one namespace, so a namespace-scoped Role can't satisfy the requirement.
|
||||
2. **`get`, `list`, `watch`** on `pods` — read-only verbs only. Deliberately exclude `create`,
|
||||
`update`, `patch`, `delete` — this workload never legitimately needs to change anything.
|
||||
3. No — it never touches Secrets, so it should get zero permission on them. The temptation to
|
||||
"just in case" grant broader access is exactly what least privilege exists to resist: every
|
||||
unused permission is pure risk with no corresponding benefit if this workload is ever
|
||||
compromised.
|
||||
4. Run `kubectl auth can-i delete pods --as=<the ServiceAccount>` and confirm it returns `no` —
|
||||
and similarly check a Secrets verb returns `no` — proving the Role is bounded, not just
|
||||
assuming it from reading the YAML.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 3 is the core judgment call this exercise is testing — push on WHY
|
||||
"just in case" access is a real anti-pattern, not just point out that it is one.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would change about the answer if this monitoring agent later
|
||||
needed to also restart crash-looping Pods automatically — a good check that students can reason
|
||||
about incrementally expanding a Role's scope only as far as a genuinely new requirement demands.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Reflection: Permissions Should Reflect What a Workload Actually Needs
|
||||
|
||||
You just configured real ServiceAccounts and RBAC rules. Take a few minutes to connect this to
|
||||
production security practice.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, would you have thought twice about a workload's ServiceAccount having
|
||||
broad, unused permissions "just in case"? Has that changed?
|
||||
2. If a workload with cluster-admin-equivalent RBAC access is ever compromised (a vulnerable
|
||||
dependency, a supply-chain issue), what's the realistic blast radius compared to one scoped
|
||||
to exactly what it needs?
|
||||
3. How would you discover, on a real cluster you didn't build, whether any ServiceAccount has
|
||||
more access than it actually uses?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**RBAC over-permissioning is a silent, cumulative risk.** Unlike a misconfigured probe or a
|
||||
crashing Pod, an over-broad Role doesn't cause a visible symptom on a healthy day — it just sits
|
||||
there as unused risk until the one day a workload using it is compromised. This makes RBAC
|
||||
review a discipline that has to be practiced deliberately, not one that gets forced on you by
|
||||
an obvious failure.
|
||||
|
||||
**Least privilege is a default you set once, not a fix you apply after an incident.** Every
|
||||
example in this module started from "grant nothing, add exactly what's needed" rather than
|
||||
"grant broadly, restrict later" — the second approach almost never actually happens once
|
||||
something works, because there's no pressure forcing the cleanup.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 3 is a good one to leave partially open
|
||||
— acknowledge that real answers involve tooling beyond plain `kubectl` (RBAC audit tools) that
|
||||
this course doesn't build hands-on, while still reinforcing that the QUESTION itself is the
|
||||
right one to ask.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to identify one ServiceAccount permission from earlier in
|
||||
ROOT_ (any Lab so far) that, in a real production system, they'd want to double-check was
|
||||
actually necessary.
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Demonstration: Default-Deny, Before and After
|
||||
|
||||
## Before: everything can reach everything
|
||||
|
||||
```
|
||||
$ kubectl run test-pod --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
<real response from backend>
|
||||
```
|
||||
|
||||
Any Pod in the namespace can reach `backend-svc`, regardless of what it is or who deployed it —
|
||||
this is the un-configured default the ROOT 07 lesson described, now made concrete from a
|
||||
security angle.
|
||||
|
||||
## Applying a default-deny policy
|
||||
|
||||
```
|
||||
$ kubectl apply -f default-deny.yaml
|
||||
networkpolicy.networking.k8s.io/default-deny created
|
||||
```
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: ["Ingress"]
|
||||
```
|
||||
|
||||
## After: the same request, now blocked
|
||||
|
||||
```
|
||||
$ kubectl run test-pod --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
wget: download timed out
|
||||
```
|
||||
|
||||
No error naming a permission (that's RBAC's job, a different layer) — just a timeout, because
|
||||
the packets are being dropped at the network layer before they ever reach the Pod. This is a
|
||||
DIFFERENT failure signature than an RBAC denial, worth being able to tell apart: RBAC denials
|
||||
are explicit and immediate; NetworkPolicy denials look like the destination is simply
|
||||
unreachable.
|
||||
|
||||
## Explicitly allowing only what's needed
|
||||
|
||||
```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 }
|
||||
```
|
||||
|
||||
```
|
||||
$ kubectl run frontend-test --labels="app=frontend" --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
<real response from backend>
|
||||
|
||||
$ kubectl run other-test --labels="app=other" --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
wget: download timed out
|
||||
```
|
||||
|
||||
Same destination, same request — the ONLY difference is the caller's label. This is
|
||||
least-privilege network access made concrete: reachability now reflects an explicit decision,
|
||||
not an accident of both Pods existing in the same cluster.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run all three states live if a real cluster is available — the timeout (not an
|
||||
error) for the denied case is worth calling out explicitly, since it looks identical to a
|
||||
genuinely broken/unreachable service from the caller's perspective.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students sometimes expect a NetworkPolicy denial to
|
||||
produce a clear "forbidden" error like RBAC does. It doesn't — reinforce that a NetworkPolicy-
|
||||
caused timeout is genuinely hard to distinguish from an actual network/DNS problem without
|
||||
already suspecting NetworkPolicy, which is exactly why checking existing policies is a real
|
||||
diagnostic step worth knowing.
|
||||
|
||||
**Optional extension:** ask how a student would distinguish "blocked by NetworkPolicy" from
|
||||
"the backend Pod is actually down" purely from the caller's side (answer: check the backend
|
||||
Pod's own status/logs directly — if it's healthy and Ready, but still unreachable from one
|
||||
caller and reachable from another with a different label, NetworkPolicy is the likely cause).
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Guided Exercise: Planning Egress Restriction
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
A `payment-processor` Pod handles sensitive transactions. It legitimately needs to reach:
|
||||
- an internal `payments-db` Service, and
|
||||
- one external payment gateway API.
|
||||
|
||||
It should NOT be able to reach anything else — including the public internet in general, other
|
||||
internal services, or any other namespace.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Why is restricting this Pod's OUTBOUND (egress) traffic just as important as restricting who
|
||||
can reach IT (ingress), given what this Pod handles?
|
||||
2. If this Pod were compromised via a vulnerable dependency, what would UNRESTRICTED egress let
|
||||
an attacker do that restricted egress would prevent?
|
||||
3. Sketch (in words) what an egress policy for this Pod needs to allow, and — just as
|
||||
important — what it must NOT allow by omission.
|
||||
4. Why might a security review flag a Pod with `policyTypes: ["Egress"]` but an EMPTY `egress`
|
||||
list as suspicious, rather than assume it's simply "not configured yet"?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. A Pod handling sensitive data that's compromised is far more dangerous if it can freely
|
||||
exfiltrate that data anywhere (unrestricted egress) than if its only reachable destinations
|
||||
are pre-approved and narrow.
|
||||
2. Unrestricted egress would let an attacker exfiltrate transaction data to an external server,
|
||||
or download additional malicious tooling. Restricted egress limits it to only the
|
||||
pre-approved destinations, even after compromise.
|
||||
3. Allow: egress to `payments-db` (internal, by label/namespace selector) and the specific
|
||||
external payment gateway (by IP range or, if supported, a domain-based egress rule). Must NOT
|
||||
allow: general internet access, other internal Services, or any other namespace by default.
|
||||
4. An empty `egress` list under `policyTypes: ["Egress"]` means DENY ALL outbound — exactly like
|
||||
the ingress case from the lesson. A reviewer seeing this should confirm it's intentional
|
||||
(a Pod that genuinely needs zero outbound network access) rather than assume it's an
|
||||
oversight, since the two look identical in the YAML.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 4 reinforces the same "empty rules list means deny-all, not
|
||||
unconfigured" point from earlier lessons — a good moment to confirm it generalizes correctly to
|
||||
egress, not just ingress.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what additional control (beyond NetworkPolicy) a team might want for
|
||||
truly sensitive egress, like restricting DNS resolution itself for this Pod — a forward pointer
|
||||
to defense-in-depth without requiring a hands-on Lab here.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Reflection: Reachability and Trust Are Decisions You Make on Purpose
|
||||
|
||||
You just applied default-deny NetworkPolicy, egress restriction, TLS ingress, and image-pull
|
||||
authentication for real. Take a few minutes to connect these to a single underlying principle.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Four different mechanisms in this module (NetworkPolicy ingress, NetworkPolicy egress, TLS,
|
||||
image-pull secrets) all express the same underlying idea in a different layer. What is that
|
||||
idea, stated in one sentence?
|
||||
2. If you had to prioritize implementing ONLY ONE of this module's controls on a brand-new,
|
||||
unsecured cluster, which would you pick first, and why?
|
||||
3. How would you explain, to someone who thinks "our cluster is internal, so it doesn't need
|
||||
this," why internal-only still benefits from all four controls?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Trust should be explicit, narrow, and verifiable — never assumed from proximity.** A Pod on
|
||||
the same cluster, a request from inside the corporate network, an image from "a registry we
|
||||
usually use" — none of these are actually trustworthy by default. Every control in this module
|
||||
replaces an assumption ("it's internal, it's probably fine") with an explicit, checkable
|
||||
decision (this specific Pod can reach this specific destination; this specific registry is
|
||||
authenticated; this specific connection is encrypted).
|
||||
|
||||
**Supply-chain and network security compound.** A compromised image with unrestricted egress on
|
||||
an open network is a far worse incident than the same compromised image on a hardened one — the
|
||||
controls in this module don't individually prevent every incident, but together they
|
||||
dramatically shrink what any single failure can turn into.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 1 is the module's thesis — make sure
|
||||
students can actually articulate it in their own words, not just recognize it when stated.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to rank this module's four controls by how much
|
||||
IMPLEMENTATION EFFORT each typically requires on a real team, separately from security value —
|
||||
a good exercise in real-world prioritization tradeoffs.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Guided Exercise: Applying the Framework to a Broken Service, on Paper First
|
||||
|
||||
Before the `endpoint-fix-task` Lab, walk through the framework yourself against a described
|
||||
symptom — no cluster needed yet.
|
||||
|
||||
## The scenario
|
||||
|
||||
A teammate says: "`my-svc` isn't routing any traffic to our Pods, and I don't know why — the
|
||||
Pods look healthy in `kubectl get pods`."
|
||||
|
||||
## Your task
|
||||
|
||||
Write out, step by step, what you would actually DO for each framework stage, before looking at
|
||||
the worked answer:
|
||||
|
||||
1. **Observe** — what's the precise, specific symptom here (not "it's broken")?
|
||||
2. **Gather Evidence** — name the exact `kubectl` commands you'd run, in order, and what each
|
||||
one is checking for.
|
||||
3. **Form Hypothesis** — based on what ROOT 07's Services lesson taught you, what's the most
|
||||
likely cause of "Pods healthy, but Service routes nowhere"?
|
||||
4. **Test** — how would you CONFIRM that hypothesis before changing anything?
|
||||
5. **Repair** — what's the minimal fix, assuming your hypothesis is correct?
|
||||
6. **Validate** — what would you check afterward to PROVE the fix worked, not just assume it?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. Traffic sent to `my-svc` never reaches any Pod, despite the Pods themselves reporting
|
||||
`Running`/`Ready` — this rules out "the Pods are unhealthy" as the cause.
|
||||
2. `kubectl get endpoints my-svc` (does it show any Pod IPs at all?), then `kubectl describe
|
||||
service my-svc` (what selector is it actually using?), then `kubectl get pods --show-labels`
|
||||
(what labels do the real Pods actually carry?).
|
||||
3. A Service-to-Pod label selector mismatch — exactly the failure mode ROOT 07's demonstration
|
||||
walked through, where a Service's selector no longer matches the Pods' actual labels.
|
||||
4. Directly compare the Service's selector (from `describe service`) against the Pods' labels
|
||||
(from `get pods --show-labels`) side by side — confirmed if they genuinely don't match.
|
||||
5. Correct the Service's selector (or the Pods' labels, whichever is actually wrong) so they
|
||||
match again.
|
||||
6. `kubectl get endpoints my-svc` now shows the Pod IPs populated — not just "the YAML looks
|
||||
right now," but the actual Endpoints object reflecting real, matched Pods.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** This exercise deliberately reuses ROOT 07's own content — a good moment to
|
||||
point out explicitly that the framework's value is applying PRIOR knowledge systematically, not
|
||||
learning something entirely new each time.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would change about steps 2-4 if the symptom were instead
|
||||
"Pods healthy, but ONE specific caller can't reach the Service while others can" — a good bridge
|
||||
to considering NetworkPolicy (ROOT 09) as an alternative hypothesis once the simpler
|
||||
selector-mismatch explanation is ruled out.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Reflection: A Framework You've Actually Used Is One You'll Trust Under Pressure
|
||||
|
||||
You just solved two real, different failures — a CrashLoopBackOff and a broken Service
|
||||
selector — using the same seven-step framework both times. Take a few minutes to consolidate
|
||||
this.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Which framework step did you personally find easiest to skip or rush through across both
|
||||
Labs? Why do you think that step specifically tempts shortcuts?
|
||||
2. Compare how you approached `crashloop-fix` versus `endpoint-fix-task`. What was IDENTICAL
|
||||
about your process, and what was different only because the evidence looked different?
|
||||
3. Now that you've practiced this framework on two failures you'd never seen exact instructions
|
||||
for, how confident do you feel applying it to a THIRD, completely unfamiliar Kubernetes
|
||||
failure? What would you still want to double-check?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**The framework's value shows up most on failures you've never seen before.** Anyone can
|
||||
memorize "if X symptom, do Y fix" for a handful of common problems — that knowledge doesn't
|
||||
transfer. Observe → Evidence → Hypothesis → Test → Repair → Validate → Prevent Recurrence
|
||||
transfers to failures nobody has specifically taught you, because it's a PROCESS, not a lookup
|
||||
table.
|
||||
|
||||
**"Prevent Recurrence" is what separates incident response from firefighting.** Both Labs in
|
||||
this module had a real, nameable prevention practice (verify command strings before deploying;
|
||||
keep label keys consistent between a Deployment and its Service). Teams that consistently ask
|
||||
this question after every fix accumulate fewer repeat incidents over time — not because they got
|
||||
lucky, but because they closed the actual gap each time.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 1 tends to produce honest, useful
|
||||
answers — "Prevent Recurrence" and "Form Hypothesis" (skipping straight to a guessed fix) are
|
||||
the two most commonly admitted shortcuts, both worth normalizing as a real temptation to resist
|
||||
rather than treating as a personal failing.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5-7 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to apply the framework, from memory and without re-reading
|
||||
this module, to a THIRD failure type they haven't practiced yet (e.g., a Pod stuck `Pending` —
|
||||
not built as a hands-on Lab in this module, just a thought exercise) to test genuine
|
||||
transferability.
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# DevOps as an Operating Model, Not a Job Title
|
||||
|
||||
## You've already been doing this — just not naming it yet
|
||||
|
||||
Every course before this one taught you a Kubernetes skill: declaring desired state, hardening
|
||||
a Pod, diagnosing a failure with a repeatable framework, running an incident with real
|
||||
discipline. This module doesn't teach a new hands-on skill — it names the OPERATING MODEL those
|
||||
skills are all part of, so you can recognize it and apply it beyond Kubernetes specifically.
|
||||
|
||||
## DevOps: a set of practices, not a team you join
|
||||
|
||||
"DevOps" is often misused as a job title ("I'm a DevOps engineer") or a team name ("throw it
|
||||
over to DevOps"). Properly understood, DevOps is a set of PRACTICES for how development and
|
||||
operations work together: shorter feedback loops, automation over manual process, and shared
|
||||
ownership of what happens after code ships — not a separate team that operations gets outsourced
|
||||
to.
|
||||
|
||||
Concretely, you already practiced core DevOps ideas without the label:
|
||||
|
||||
- **Declarative thinking** (ROOT 05) — describing desired state and letting a system enforce it,
|
||||
instead of manually executing steps, is the same principle behind Infrastructure as Code.
|
||||
- **Fast, safe rollback** (ROOT 06) — treating recovery as a rehearsed, ordinary action rather
|
||||
than a crisis is a direct DevOps practice (fast feedback, low-drama recovery).
|
||||
- **A repeatable troubleshooting framework** (ROOT 10) and **structured incident response**
|
||||
(ROOT 12) — both are operational discipline, the "Ops" half of DevOps, practiced for real.
|
||||
|
||||
## Platform Engineering: what DevOps looks like at scale
|
||||
|
||||
**Platform engineering** is the next layer: instead of every application team reinventing how
|
||||
to deploy safely, monitor, and secure their own workloads, a platform team builds SELF-SERVICE
|
||||
tooling and golden paths that let other engineers move fast without needing deep infrastructure
|
||||
expertise themselves. The Kubernetes cluster, the CI/CD pipeline, the observability stack — all
|
||||
of it becomes a PRODUCT a platform team builds for INTERNAL customers (other engineers), not
|
||||
just infrastructure that happens to exist.
|
||||
|
||||
A useful lens: platform engineering exists to **reduce cognitive load**. A developer who can
|
||||
deploy a well-configured, secure, observable service through a simple, guided path doesn't need
|
||||
to personally understand every Kubernetes concept from ROOT 05-10 to ship safely — the platform
|
||||
encodes that expertise once, for everyone.
|
||||
|
||||
## Where Kubernetes fits in the larger picture
|
||||
|
||||
Kubernetes is INFRASTRUCTURE, not the whole platform. A real platform engineering practice
|
||||
typically layers several things around it:
|
||||
|
||||
- **Declarative delivery** — describing what should be deployed and letting automation apply it
|
||||
(the GitOps model — future ROOT 11 content, requires a Git+deployment-automation environment
|
||||
this course doesn't yet provide).
|
||||
- **Continuous integration/delivery** — automatically testing and shipping code changes (CI/CD —
|
||||
future content, requires a CI runner environment).
|
||||
- **Infrastructure as Code** — declaring cloud/infrastructure resources the same way you declare
|
||||
Kubernetes objects (future content, requires a provisioning environment like Terraform).
|
||||
- **Observability** — knowing what's actually happening in production beyond "is the Pod
|
||||
Running" (future content, requires a metrics/logging/tracing stack).
|
||||
|
||||
None of these require abandoning what you learned in ROOT 05-10 — they build directly on the
|
||||
same mental models (desired vs. actual state, evidence-based diagnosis, least privilege) applied
|
||||
to a wider set of systems.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Lesson goal:** Give students a name and a framework for practices they've already exercised,
|
||||
and an honest map of what ROOT 11 will eventually cover once appropriate lab environments exist —
|
||||
without pretending those labs exist today.
|
||||
|
||||
**Approximate timing:** 20 minutes read; 15 minutes live with discussion.
|
||||
|
||||
**Preparation:** None beyond having read `docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md` to speak
|
||||
accurately about what's planned versus what exists.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- "DevOps" as a job title/team is a very common prior belief — correct this directly and early,
|
||||
it's the central point of the lesson.
|
||||
- Platform engineering is sometimes conflated with "just Kubernetes." Emphasize the self-service/
|
||||
golden-path angle — the PRODUCT framing, not just the technology.
|
||||
|
||||
**Questions to ask students:** "Which ROOT_ course so far most directly practiced a DevOps
|
||||
principle, even though it was never labeled that way?" (Most will cite ROOT 06's rollback
|
||||
discipline or ROOT 12's incident framework — both are legitimate, good answers.)
|
||||
|
||||
**When to let a student struggle vs. provide a hint:** Not applicable — this is an orientation
|
||||
lesson with no hands-on task.
|
||||
|
||||
**Optional extension:** ask students to name one manual process at a company they've worked for
|
||||
(or heard about) that platform engineering-style self-service tooling could have eliminated.
|
||||
|
||||
**Expected takeaway:** DevOps and platform engineering are the organizing ideas behind almost
|
||||
everything ROOT_ has already taught — not a new, separate subject to learn from scratch.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Knowledge Check: DevOps & Platform Thinking
|
||||
|
||||
---
|
||||
|
||||
**1. A colleague says "DevOps is the team that handles our servers." What's wrong with that
|
||||
statement?**
|
||||
|
||||
*Answer:* DevOps describes a set of PRACTICES (shared ownership, fast feedback loops,
|
||||
automation) for how development and operations work together — not a separate team that
|
||||
operations gets handed off to. Treating it as a team name misses the point that these practices
|
||||
are meant to be shared across engineering, not isolated in one group.
|
||||
|
||||
---
|
||||
|
||||
**2. Which ROOT_ course's central skill is most directly an example of "declarative thinking"
|
||||
applied outside Kubernetes objects specifically?**
|
||||
|
||||
*Answer:* ROOT 05's desired-state model is the direct root of declarative thinking generally —
|
||||
Infrastructure as Code applies the exact same principle (describe what should exist, let
|
||||
automation enforce it) to cloud infrastructure rather than Kubernetes objects specifically.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: platform engineering's main goal is building the most technically
|
||||
sophisticated internal tooling possible.**
|
||||
|
||||
*Answer:* False. Its main goal is reducing COGNITIVE LOAD for other engineers via self-service,
|
||||
golden-path tooling — sophistication that doesn't make another engineer's job easier isn't the
|
||||
point, even if it's technically impressive.
|
||||
|
||||
---
|
||||
|
||||
**4. Why can't this course build a hands-on GitOps or CI/CD Lab the same way ROOT 05-10 built
|
||||
Kubernetes Labs?**
|
||||
|
||||
*Answer:* Those require different infrastructure the current Lab platform doesn't provide (a
|
||||
Git repository + deployment-automation tool like ArgoCD for GitOps; a CI runner for CI/CD) — see
|
||||
`docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md`. Building a fake version of either would teach an
|
||||
inaccurate mental model, which is worse than honestly deferring the hands-on practice.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 4 is worth stating plainly in a live classroom — students should
|
||||
understand this is a genuine platform limitation being handled honestly, not an oversight.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Reflection: Where You Already Practiced This
|
||||
|
||||
You've now named the operating model behind most of what ROOT_ taught. Take a few minutes to
|
||||
trace it back through your own experience in this program.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Pick one specific moment from ROOT 05-10 or ROOT 12 where you practiced a DevOps/platform
|
||||
engineering principle without it being labeled that way. Name the principle explicitly now.
|
||||
2. If you were designing a platform team's "golden path" for deploying a new service safely,
|
||||
which THREE things from ROOT_ (health probes, RBAC, resource limits, NetworkPolicy, etc.)
|
||||
would you bake in as defaults rather than leave to each team to remember?
|
||||
3. What's one thing about GitOps, CI/CD, Infrastructure as Code, or observability you're
|
||||
genuinely curious about, now that you have the Kubernetes foundation to eventually build
|
||||
hands-on skill in it?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**The skills you already have transfer further than "just Kubernetes."** Desired-state
|
||||
thinking, least privilege, evidence-based troubleshooting, and structured incident response
|
||||
aren't Kubernetes-specific facts — they're how experienced platform engineers reason about ANY
|
||||
complex system. Recognizing that transfer is what turns "I learned Kubernetes" into "I think
|
||||
like a platform engineer."
|
||||
|
||||
**Honest scope beats fake completeness.** This course didn't pretend to teach you GitOps or
|
||||
CI/CD hands-on today — it told you exactly what's missing and why, and pointed at what it would
|
||||
take to build it for real. That's the same standard the rest of ROOT_ held itself to
|
||||
throughout — a module that doesn't exist yet is more useful, honestly labeled, than one that
|
||||
exists in name only.
|
||||
|
||||
This module is complete.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 works well as a small-group exercise —
|
||||
have groups compare their "golden path defaults" lists and discuss disagreements.
|
||||
|
||||
**Approximate timing:** 10 minutes live discussion; 8 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to research (outside class) one real company's public
|
||||
engineering blog post about their platform engineering practice, and bring one concrete example
|
||||
to share next session.
|
||||
|
||||
**Expected takeaway:** ROOT_ built real, transferable engineering judgment — this module makes
|
||||
that transfer explicit rather than leaving students to discover it on their own.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Phase 6C — Curriculum Completion + Assessment Bank + ROOT 11 Scope
|
||||
|
||||
Phase 6C's mission: upgrade PARTIAL modules toward the gold-module standard, build a real
|
||||
assessment bank from previously-unwired content, resolve ROOT 11's scope, and produce an
|
||||
explicit, honest pilot-ready determination. This document records what was actually done and
|
||||
why, using the existing content/assessment model throughout — no new application code was
|
||||
required.
|
||||
|
||||
## Baseline verification
|
||||
|
||||
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` =
|
||||
`2db136875f30809afa9c82f36e96605802bf7ee2`, descending from the Phase 6B milestone
|
||||
(`git merge-base --is-ancestor`, confirmed). Phase 6B's own final report claimed 17 PARTIAL
|
||||
modules; a direct database query showed the real figure was 15 out of 21 total modules — this
|
||||
correction is recorded in `docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md`.
|
||||
|
||||
## Readiness standard
|
||||
|
||||
`docs/ROOT_PILOT_READINESS_STANDARD.md` defines READY as sufficiency, not a fixed activity
|
||||
count: enough instruction/scaffolding that a student can reasonably perform the practical work,
|
||||
checked against an 8-point standard (concept instruction, demonstrated reasoning, self-check,
|
||||
guided bridge, operational-context closing, instructor notes, self-paced sufficiency, resolved
|
||||
references). Modules were checked against this bar individually, not mechanically padded to six
|
||||
activities each.
|
||||
|
||||
## Modules upgraded (15 PARTIAL → 6 PARTIAL, 16 READY)
|
||||
|
||||
**Full gold-pattern upgrades** (lesson/demonstration/knowledge-check/guided-exercise/lab(s)/
|
||||
reflection), each with new content authored and existing lab activities preserved and only
|
||||
resequenced:
|
||||
- `root-06-m03-jobs-cronjobs-daemonsets`
|
||||
- `root-07-m04-ingress-networkpolicy-foundations`
|
||||
- `root-07-m02-configuration-and-secrets` (converted in place from a bare 5-lab bank — the last
|
||||
networking/config module with no front-door teaching)
|
||||
- `root-09-m02-serviceaccounts-and-rbac`
|
||||
- `root-09-m03-network-and-supplychain-security`
|
||||
- `root-10-m03-troubleshooting-framework` (only guided-exercise + reflection were missing)
|
||||
|
||||
**Bookend-only upgrade** (lesson + reflection wrapped around an existing, otherwise-complete
|
||||
practice bank, rather than a full six-part redesign — because the gap was framing, not
|
||||
per-task teaching):
|
||||
- `root-05-m01-pods-and-kubectl-basics` — 20 independent kubectl-fluency tasks, each already
|
||||
carrying its own explanation in its scenario description. Added a short lesson explaining why
|
||||
the module is structured as breadth practice, and a reflection asking students to name their
|
||||
own remaining gaps.
|
||||
|
||||
**Documented exceptions** (status promoted to READY with no new activities, because adding the
|
||||
"missing" activity type would have actively undermined the module's intentional design):
|
||||
- `root-12-m01-real-ops-incidents` — adding a knowledge-check or guided-exercise would
|
||||
contradict its deliberate "you are responsible for this environment" unguided design.
|
||||
- `root-capstone-m01-operate-and-recover` — its single `capstone` activity IS the terminal
|
||||
exam; the readiness/prep teaching already lives in the preceding, already-READY
|
||||
`root-capstone-m02-capstone-readiness`.
|
||||
- `root-09-m04-cluster-hardening-and-runtime` — Phase 6B already documented this as
|
||||
intentionally survey-depth ("not core, not daily-driver"); it now has a lesson, hands-on
|
||||
labs, and a real assessment checkpoint (below), which is sufficient for its scope without
|
||||
forcing a demonstration/guided-exercise pattern that would misrepresent it as core content.
|
||||
|
||||
**Intentionally deferred, not upgraded this phase:**
|
||||
- `root-08-m01-storage-fundamentals`, `root-08-m02-scheduling-resource-governance` — ROOT 08
|
||||
does not gate the capstone (the `rootnode-path.json` unlock stages are foundation, workloads,
|
||||
networking, configuration, troubleshooting, real-ops; storage/scheduling was never one of
|
||||
them). Given realistic effort constraints, these were prioritized below the
|
||||
capstone-critical path and below the security-assessment mandate. Left PARTIAL, classified
|
||||
DEFERRED/optional-supplementary in the pilot manifest.
|
||||
- `root-06-m01-deployments-and-workloads`, `root-07-m01-services-and-networking`,
|
||||
`root-10-m02-general-troubleshooting` — dense hands-on practice banks that sit alongside an
|
||||
already-fully-taught sibling module in the same course (root-06-m02, root-07-m03/m04,
|
||||
root-10-m01/m03). Rather than author three more full teaching layers under time constraints,
|
||||
classified as instructor-led-only for the first pilot (an instructor supplies the missing
|
||||
framing live); fully self-paced delivery of exactly these three is deferred. See
|
||||
`docs/ROOT_PILOT_CURRICULUM.md`.
|
||||
|
||||
## Content authored this phase
|
||||
|
||||
20 new Markdown content files (demonstrations, guided exercises, knowledge checks, lessons,
|
||||
reflections) across ROOT 05, 06, 07, 09, 10, and 11 — each following the established
|
||||
`## Instructor Notes` convention (instructor-only content below that heading, stripped from
|
||||
student-visible payload by `content.js`'s existing `splitContent()`). Every demonstration
|
||||
follows OBSERVE → HYPOTHESIS → INSPECT → CHANGE → VERIFY; every guided exercise bridges lesson
|
||||
to lab without revealing the lab's exact answer; every knowledge check tests reasoning over
|
||||
recall; every reflection closes with a named, transferable engineering principle.
|
||||
|
||||
## Assessment bank (Section 9)
|
||||
|
||||
New `assessment_definitions` row `rootnode-security-assessment` (type `exam`, 60 minutes,
|
||||
`hint_policy: disabled`), using the existing Phase 3B assessment engine unmodified. Pool: 3
|
||||
required hands-on CORE security scenarios (`cks-pod-security-context`, `cks-rbac-least-privilege`,
|
||||
`cks-network-policy`) plus 4 random-draw groups covering SecurityContext hardening depth,
|
||||
RBAC/ServiceAccount depth, network/supply-chain depth, and the 7 previously-unwired CKS MCQs as
|
||||
one pool — 9 scenarios drawn per attempt, server-authoritative (verified live: a real student
|
||||
session draw returned 9 scenario IDs matching the pool spec, no client input accepted). Wired
|
||||
into `root-09-m04-cluster-hardening-and-runtime` as an `assessment`-type activity — the natural
|
||||
home for a security checkpoint, not a new module.
|
||||
|
||||
## Remaining unwired scenarios (Section 13)
|
||||
|
||||
Recomputed after the security-assessment wiring consumed the 7 previously-unwired CKS MCQs:
|
||||
**2 scenarios remain unwired** — `cluster-upgrade-mcq` and `etcd-backup-mcq`, both Cluster
|
||||
Administration/CKA-track content (kubeadm upgrade order, etcd backup/restore). No current ROOT
|
||||
module teaches cluster administration at that level; classified **future-curriculum**, not
|
||||
neglect. One scenario that WAS unwired, `readiness-probe-task` (a constructive "author a
|
||||
readiness probe" Lab, distinct from the existing diagnostic `rootnode-probe-port-mismatch`
|
||||
Lab), was wired as an **optional practice** activity in `root-10-m01-health-probes-and-troubleshooting`
|
||||
using the existing `is_required` model from Phase 6B — no second practice system built.
|
||||
|
||||
## ROOT 11 (Section 11-12)
|
||||
|
||||
`docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md` evaluates all 7 proposed modules individually. Only one
|
||||
was authored (`root-11-m01-devops-platform-thinking`, folding in the closely-related "Platform
|
||||
Engineering & DX" idea) — a lesson, knowledge check, and reflection, explicitly telling
|
||||
students which future topics (GitOps, CI/CD, IaC, observability) require infrastructure this
|
||||
platform doesn't yet provide. No fake labs were built. ROOT 11 moved from `planned` to
|
||||
`partial` at the course level — an intentional, documented scope, not an unexplained
|
||||
placeholder.
|
||||
|
||||
## New application code
|
||||
|
||||
None. Every genuine gap this phase was satisfiable with existing tables (`course_modules`,
|
||||
`module_activities`, `assessment_definitions`) and the existing content-delivery pipeline. One
|
||||
real bug was found and fixed during authoring (see below) but required no schema or API change.
|
||||
|
||||
## Bug found and fixed
|
||||
|
||||
An early draft of migration 024 set `root-11-m01-devops-platform-thinking`'s
|
||||
`course_modules.prerequisites` to a COURSE id (`root-10-kubernetes-troubleshooting-observability`)
|
||||
instead of a MODULE id — `course_modules.prerequisites` and `courses.prerequisites` are
|
||||
different namespaces. Caught by actually running `infra/scripts/validate-course-content.js`
|
||||
against the rebuilt database (`FAIL — module root-11-m01-devops-platform-thinking lists a
|
||||
prerequisite that doesn't exist`). Verified via direct query that the course-level gate was
|
||||
already correctly set from Phase 6A's migration 022. Fixed by emptying the module-level field
|
||||
(the course-level gate already fully enforces the dependency) and documenting why in the
|
||||
migration's SQL comments.
|
||||
|
||||
## Content validation (Section 24)
|
||||
|
||||
`infra/scripts/validate-course-content.js` (new, permanent) checks: no duplicate course/module/
|
||||
activity ids; no orphan modules/activities; every scenario_id and assessment_definition_id
|
||||
resolves to a real row/file; every prerequisite id resolves and contains no cycles; no sequence
|
||||
collisions; instructor notes never leak into student-visible content (verified by actually
|
||||
invoking `content.js`'s real `loadContent()`); every content_path resolves to a real file. Final
|
||||
run: **22 courses/modules loaded, 163 activities, 98 scenario files, 0 errors.**
|
||||
|
||||
## Pilot readiness determination
|
||||
|
||||
See `docs/ROOT_PILOT_CURRICULUM.md` for the full manifest. Every module in the pilot's required
|
||||
completion path (ROOT 05-07, 09-10, 12, Capstone) meets the READY bar for instructor-led
|
||||
delivery; three specific practice-bank modules are instructor-led-only this pilot; ROOT 08 and
|
||||
ROOT 11 modules 2-7 are explicitly outside the required path. The first pilot is scoped as
|
||||
instructor-led, and within that scope, the curriculum is ready.
|
||||
@@ -0,0 +1,64 @@
|
||||
# ROOT 11 — DevOps & Platform Engineering: Scope Decision
|
||||
|
||||
Phase 6C's mandate for ROOT 11 was to stop it being an undefined `planned` placeholder by
|
||||
making an explicit, evidence-based scope decision — not to fake seven hands-on labs the current
|
||||
platform cannot honestly deliver. This document is that decision.
|
||||
|
||||
## Purpose and learning outcomes
|
||||
|
||||
ROOT 11 exists to give a student who has just finished ROOT 05-10 (Kubernetes hands-on) and
|
||||
ROOT 12 (incident response) the vocabulary and mental model to see their skills as part of a
|
||||
larger DevOps/platform engineering practice, and an honest map of what comes next once the
|
||||
platform can support it hands-on. It is explicitly NOT meant to teach GitOps, CI/CD, or
|
||||
Infrastructure as Code as hands-on skills in this phase.
|
||||
|
||||
## The seven proposed modules, evaluated against current architecture
|
||||
|
||||
| # | Proposed module | Classification | Why |
|
||||
|---|---|---|---|
|
||||
| 1 | Sysadmin → Platform Thinking | **READY TO AUTHOR NOW** | Pure orientation/reframing content. No new infrastructure needed — it's built entirely on ROOT_'s existing curriculum, referenced explicitly. Authored this phase as `root-11-m01-devops-platform-thinking`. |
|
||||
| 2 | GitOps & Declarative Delivery | **REQUIRES FUTURE LAB ENVIRONMENT** | Needs a Git repository the student can push to and a deployment-automation controller (ArgoCD or Flux) watching it. The Lab platform provisions an isolated k3s VM per student today; it does not provision or wire a Git remote or a GitOps controller. Faking this with a Kubernetes-only scenario would teach "apply a manifest," not GitOps — a materially wrong mental model. |
|
||||
| 3 | CI/CD Fundamentals | **REQUIRES FUTURE LAB ENVIRONMENT** | Needs a CI runner (even a minimal one) that can check out code, run a pipeline, and produce an artifact or trigger a deploy. No such runner exists in the Lab image or Platform API today. |
|
||||
| 4 | Infrastructure as Code | **REQUIRES FUTURE LAB ENVIRONMENT** | Needs a provisioning target and a tool like Terraform/OpenTofu applying against it. Kubernetes objects are already declarative, but IaC's actual lesson is about provisioning infrastructure *below* Kubernetes — cloud resources, networking, the cluster itself — which this platform doesn't expose to a student sandbox. |
|
||||
| 5 | Observability & SLO Thinking | **PARTIAL-LESSON-ONLY** | The conceptual half (what SLIs/SLOs are, error budgets, why "is the Pod Running" isn't observability) can be taught honestly without a metrics stack. A genuinely hands-on version needs Prometheus/Grafana (or equivalent) wired into the Lab VM, which doesn't exist today. Recommend authoring the lesson-only half in a future sub-phase; do not build the metrics stack this phase. |
|
||||
| 6 | Platform Engineering & DX | **READY TO AUTHOR NOW** (folded into module 1) | Conceptually adjacent enough to module 1 that a separate module would duplicate content. Its core ideas (self-service, golden paths, reducing cognitive load) are already covered in `root-11-m01`. |
|
||||
| 7 | Reliability / Automation / Operational Ownership | **PARTIAL-LESSON-ONLY** | Largely already covered hands-on by ROOT 12 (Real Ops incidents) and ROOT 10 (troubleshooting framework). A short lesson connecting those two courses' skills explicitly to "reliability engineering" as a named discipline is legitimate future content; a new hands-on lab would duplicate ROOT 12 rather than teach something new. |
|
||||
|
||||
## What was actually authored this phase
|
||||
|
||||
Only module 1 (folded with module 6's ideas) — `root-11-m01-devops-platform-thinking` — a
|
||||
lesson, knowledge check, and reflection. No Lab activity exists for ROOT 11 and none was
|
||||
faked. The lesson explicitly tells students which future topics (GitOps, CI/CD, IaC,
|
||||
observability) require infrastructure this course doesn't yet provide, rather than staying
|
||||
silent about the gap.
|
||||
|
||||
## What current infrastructure supports vs. what future modules need
|
||||
|
||||
**Supports today:** Kubernetes/k3s hands-on labs via the existing Lab VM provisioning
|
||||
(Platform API → Provisioner → Lab Router), the existing assessment engine, and the existing
|
||||
content/course model — sufficient for orientation and conceptual lesson content referencing
|
||||
Kubernetes skills already taught.
|
||||
|
||||
**Would need building for modules 2-4, and the hands-on half of 5 and 7 (future phases,
|
||||
NOT this phase):**
|
||||
- A Git hosting target reachable from a student's Lab VM, plus a GitOps controller
|
||||
(ArgoCD/Flux) pre-installed or installable in that VM.
|
||||
- A minimal CI runner (self-hosted GitHub Actions runner, Drone, or similar) wired to a
|
||||
student-scoped repository.
|
||||
- A provisioning target IaC tooling can act against — even a scoped-down one (e.g., Terraform
|
||||
against a mock/local provider) — plus the tooling itself in the Lab image.
|
||||
- A metrics/logging stack (Prometheus + Grafana at minimum) installed per-Lab-VM or shared with
|
||||
per-student scoping, for the hands-on half of Observability.
|
||||
|
||||
None of this was built in Phase 6C, per its explicit "do NOT build" list (Section 26).
|
||||
|
||||
## Pilot inclusion recommendation
|
||||
|
||||
Include `root-11-m01-devops-platform-thinking` in the first pilot as a **closing orientation
|
||||
module** — no hands-on requirement, explicitly scoped as conceptual. Do not present ROOT 11 as
|
||||
a complete course in pilot marketing or instructor materials; present it as "orientation now,
|
||||
hands-on modules 2-7 in a future phase once the required lab environments exist." This keeps
|
||||
the pilot honest about what ROOT 11 currently is versus what it will become.
|
||||
|
||||
See [`docs/ROOT_PILOT_CURRICULUM.md`](ROOT_PILOT_CURRICULUM.md) for how this fits the overall
|
||||
pilot manifest.
|
||||
@@ -85,3 +85,29 @@ gap was also found and fixed in `e2e-phase5b.js`'s own capstone-completion test,
|
||||
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.
|
||||
|
||||
## 5. Phase 6C re-review against the expanded curriculum
|
||||
|
||||
Re-checked every item this doc depends on against Phase 6C's changes and found no gap requiring
|
||||
a fix:
|
||||
|
||||
- **The unlock gate's 6 stages are unchanged**: Foundation, Workloads, Networking,
|
||||
Configuration, Troubleshooting, Real Ops (`rootnode-path.json`). Phase 6C added and upgraded
|
||||
modules within those stages' courses (ROOT 05-07, 10) plus ROOT 09 (Security) and the new
|
||||
ROOT 11 orientation — **ROOT 08 (Storage & Scheduling) and ROOT 09 (Security) do not appear
|
||||
among the 6 gating stages and still do not gate the capstone.** This is documented
|
||||
architecture, not an oversight — confirmed by direct test
|
||||
(`platform/test/e2e-phase6c.js`, "the capstone unlock gate is unaffected by ROOT 09 (security)
|
||||
being incomplete") rather than assumed.
|
||||
- **`root-capstone-m01-operate-and-recover`** was promoted from `partial` to `ready` this phase
|
||||
as a documented exception (no activity added — its single `capstone` activity IS the exam;
|
||||
see `docs/PHASE6C_CURRICULUM_COMPLETION.md`). This changes only its content-model status
|
||||
label, not the real unlock gate described in §3-4 above, which remains entirely independent.
|
||||
- **Random draw pools, timing, hint policy, and completion rule** for `rootnode-capstone` itself
|
||||
were not touched by Phase 6C — no scenario content this phase added or changed feeds that
|
||||
pool.
|
||||
- **The Capstone Readiness module (§1)** required no changes — its 4 activities and their
|
||||
relationship to the (still unchanged) real unlock gate remain accurate as described above.
|
||||
|
||||
No redesign was made or needed; Phase 6C's curriculum expansion did not require reopening the
|
||||
capstone engine.
|
||||
|
||||
@@ -2,10 +2,53 @@
|
||||
|
||||
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).
|
||||
`docs/ROOT_CURRICULUM_INVENTORY.md`'s evidence. Statuses: **READY** (meets
|
||||
`docs/ROOT_PILOT_READINESS_STANDARD.md`'s bar), **PARTIAL** (real lesson + labs, missing one or
|
||||
more of demonstration/guided-exercise/reflection), **PLANNED** (structure only, no content),
|
||||
**GAP** (no suitable scenario exists at all).
|
||||
|
||||
## Phase 6C verification (superseding Phase 6B's own self-reported counts)
|
||||
|
||||
Baseline (start of Phase 6C), run directly against the database and filesystem
|
||||
(`infra/scripts/validate-course-content.js`, 0 errors) rather than trusted from Phase 6B's
|
||||
narrative report, which had miscounted: **21 modules total — 6 READY, 15 PARTIAL** (Phase 6B's
|
||||
final report claimed 17 PARTIAL; the real count, re-verified here, was 15).
|
||||
|
||||
**After Phase 6C's upgrades: 22 modules total — 16 READY, 6 PARTIAL.** (22, not 21: Phase 6C
|
||||
added `root-11-m01-devops-platform-thinking`, ROOT 11's first real module — see
|
||||
`docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md`.) 75 activities now carry content_path (up from 73),
|
||||
163 activities total (up from 140), and unwired scenarios dropped from 10 (Phase 6B) to 2
|
||||
(`cluster-upgrade-mcq`, `etcd-backup-mcq` — both Cluster Administration/CKA-track content with
|
||||
no current ROOT module to host them; see `docs/ROOT_PILOT_CURRICULUM.md`'s "not in pilot"
|
||||
section). Full before/after detail and rationale per module is in
|
||||
`docs/PHASE6C_CURRICULUM_COMPLETION.md`.
|
||||
|
||||
**READY (16):** `root-05-m00-kubernetes-mental-model` (Phase 6A gold standard, unchanged),
|
||||
`root-05-m01-pods-and-kubectl-basics` (Phase 6C: added bookending lesson + reflection around
|
||||
its 20-task kubectl-fluency bank), `root-06-m02-deployments-scaling-updates` (unchanged),
|
||||
`root-06-m03-jobs-cronjobs-daemonsets` (Phase 6C: added demonstration, guided exercise,
|
||||
reflection — full gold pattern), `root-07-m02-configuration-and-secrets` (Phase 6C: added
|
||||
lesson, demonstration, knowledge check, guided exercise, reflection — was a bare 5-lab bank),
|
||||
`root-07-m03-services-and-service-discovery` (unchanged), `root-07-m04-ingress-networkpolicy-foundations`
|
||||
(Phase 6C: full gold pattern added), `root-09-m01-security-model-and-securitycontext`
|
||||
(unchanged), `root-09-m02-serviceaccounts-and-rbac` (Phase 6C: full gold pattern added),
|
||||
`root-09-m03-network-and-supplychain-security` (Phase 6C: full gold pattern added),
|
||||
`root-09-m04-cluster-hardening-and-runtime` (Phase 6C: documented exception — survey depth by
|
||||
design, now anchored by a real security assessment), `root-10-m01-health-probes-and-troubleshooting`
|
||||
(Phase 6A gold standard; Phase 6C added one optional practice lab), `root-10-m03-troubleshooting-framework`
|
||||
(Phase 6C: added guided exercise + reflection), `root-12-m01-real-ops-incidents` (Phase 6C:
|
||||
documented exception — deliberately unguided by design, status promoted with no new activities),
|
||||
`root-capstone-m01-operate-and-recover` (Phase 6C: documented exception — the capstone exam IS
|
||||
the module), `root-capstone-m02-capstone-readiness` (unchanged).
|
||||
|
||||
**PARTIAL (6, all with a documented pilot disposition in `docs/ROOT_PILOT_CURRICULUM.md`):**
|
||||
`root-06-m01-deployments-and-workloads`, `root-07-m01-services-and-networking`,
|
||||
`root-10-m02-general-troubleshooting` (all three: dense practice banks, sibling module in the
|
||||
same course already carries the taught concept — instructor-led only for the first pilot,
|
||||
self-paced deferred), `root-08-m01-storage-fundamentals`, `root-08-m02-scheduling-resource-governance`
|
||||
(ROOT 08 does not gate the capstone; deferred as optional supplementary content this phase),
|
||||
`root-11-m01-devops-platform-thinking` (honest, lesson-only by design — no hands-on Lab exists
|
||||
for ROOT 11 yet).
|
||||
|
||||
## Duplicate concepts identified (handled explicitly, not silently mapped twice)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# ROOT Pilot Curriculum — Authoritative Manifest
|
||||
|
||||
This is the single authoritative answer to "what are we actually teaching in the first paid
|
||||
pilot." It uses honest READY / PARTIAL / DEFERRED / NOT IN PILOT labels — nothing here is
|
||||
marketed as more finished than it is. See
|
||||
[`docs/ROOT_PILOT_READINESS_STANDARD.md`](ROOT_PILOT_READINESS_STANDARD.md) for what READY
|
||||
means and [`docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md`](ROOT11_DEVOPS_PLATFORM_SCOPE.md) for the
|
||||
ROOT 11 scope decision referenced below.
|
||||
|
||||
## Pilot delivery mode: instructor-led (cohort-based)
|
||||
|
||||
The first pilot is scoped as **instructor-led**, not fully self-paced. Every module below that
|
||||
is marked READY meets the self-paced bar (no "your instructor will explain this" gaps). A
|
||||
smaller set of modules — flagged explicitly — are READY for instructor-led delivery only,
|
||||
because they are dense hands-on practice banks whose sibling module in the same course already
|
||||
carries the taught concept; running them live lets an instructor supply framing a self-paced
|
||||
student wouldn't get. Fully self-paced delivery of those specific modules is deferred to a
|
||||
future phase pending an authored teaching layer for each. See Section 16 discussion below.
|
||||
|
||||
## Program: ROOT_ (root-kubernetes-foundations)
|
||||
|
||||
| Course | Pilot status | Notes |
|
||||
|---|---|---|
|
||||
| ROOT 01 Computing & Linux Foundations | **NOT IN PILOT** | Never authored (status `planned`, zero modules). Out of Phase 6C's scope; students are assumed to arrive with basic Linux/CLI comfort for this pilot. |
|
||||
| ROOT 02 Linux Administration & Networking | **NOT IN PILOT** | Same as above. |
|
||||
| ROOT 03 Git & Collaborative Development | **NOT IN PILOT** | Same as above. |
|
||||
| ROOT 04 Containers & Docker | **NOT IN PILOT** | Same as above. |
|
||||
| ROOT 05 Kubernetes Foundations | **IN PILOT** | See modules below. |
|
||||
| ROOT 06 Kubernetes Application Workloads | **IN PILOT** | See modules below. |
|
||||
| ROOT 07 Kubernetes Networking & Configuration | **IN PILOT** | See modules below. |
|
||||
| ROOT 08 Kubernetes Storage & Scheduling | **DEFERRED — optional supplementary** | Does not gate the capstone (`rootnode-path.json`'s six stages never included storage/scheduling). Both modules remain PARTIAL (lesson+knowledge-check only, no demo/guided/reflection). Offer as optional supplementary content to instructors who want it; do not count it in the pilot's required completion path. |
|
||||
| ROOT 09 Kubernetes Security | **IN PILOT** | See modules below. |
|
||||
| ROOT 10 Kubernetes Troubleshooting & Observability | **IN PILOT** | See modules below. |
|
||||
| ROOT 11 DevOps & Platform Engineering | **IN PILOT — orientation only** | Lesson-only closing module. Hands-on GitOps/CI-CD/IaC/Observability content explicitly deferred; see scope doc. |
|
||||
| ROOT 12 Real Ops | **IN PILOT** | READY. |
|
||||
| ROOT Capstone | **IN PILOT** | READY. |
|
||||
|
||||
## Module-level detail (courses IN pilot only)
|
||||
|
||||
### ROOT 05 — Kubernetes Foundations
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-05-m00-kubernetes-mental-model | READY | none | Explain desired vs. actual state; run first kubectl commands | Self-paced or instructor-led | 6 required | 1 lab |
|
||||
| root-05-m01-pods-and-kubectl-basics | READY | m00 | Broad kubectl command fluency (labels, JSONPath, patch, dry-run, etc.) | Self-paced or instructor-led (lesson+reflection added Phase 6C) | 22 required | 20 labs/MCQs |
|
||||
|
||||
### ROOT 06 — Kubernetes Application Workloads
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-06-m02-deployments-scaling-updates | READY | ROOT 05 | Scale and roll out/back Deployments with confidence | Self-paced or instructor-led | 7 required | 1 lab |
|
||||
| root-06-m03-jobs-cronjobs-daemonsets | READY | m02 | Choose the correct workload primitive for a given job | Self-paced or instructor-led (upgraded Phase 6C) | 7 required | 2 labs |
|
||||
| root-06-m01-deployments-and-workloads | **PARTIAL — instructor-led only** | ROOT 05 | Deeper Deployment practice bank (8 tasks, no bookending lesson) | **Instructor-led only this pilot**; self-paced deferred | 8 required | 8 labs |
|
||||
|
||||
### ROOT 07 — Kubernetes Networking & Configuration
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-07-m03-services-and-service-discovery | READY | ROOT 06 | Explain Service types and discovery | Self-paced or instructor-led | 6 required | 1 lab |
|
||||
| root-07-m02-configuration-and-secrets | READY | m03 | Choose ConfigMap vs. Secret vs. env var vs. volume correctly | Self-paced or instructor-led (new lesson/demo/KC/guided/reflection Phase 6C) | 10 required | 5 labs |
|
||||
| root-07-m04-ingress-networkpolicy-foundations | READY | m02 | Trace request routing through Ingress; plan network isolation | Self-paced or instructor-led (upgraded Phase 6C) | 7 required | 2 labs |
|
||||
| root-07-m01-services-and-networking | **PARTIAL — instructor-led only** | ROOT 06 | Deeper Services/networking practice bank (4 tasks, no bookending lesson) | **Instructor-led only this pilot**; self-paced deferred | 4 required | 4 labs |
|
||||
|
||||
### ROOT 09 — Kubernetes Security
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-09-m01-security-model-and-securitycontext | READY | ROOT 07 | Harden a Pod's SecurityContext with reasoning, not memorization | Self-paced or instructor-led | 9 required + 3 optional | labs |
|
||||
| root-09-m02-serviceaccounts-and-rbac | READY | m01 | Write least-privilege RBAC | Self-paced or instructor-led (upgraded Phase 6C) | 10 required + 1 optional | 6 labs |
|
||||
| root-09-m03-network-and-supplychain-security | READY | m02 | Apply default-deny NetworkPolicy and supply-chain controls | Self-paced or instructor-led (upgraded Phase 6C) | 9 required + 2 optional | 6 labs |
|
||||
| root-09-m04-cluster-hardening-and-runtime | READY (survey depth, documented exception) | m03 | Survey-level exposure to runtime/cluster hardening topics, checked with a real assessment | Self-paced or instructor-led | 4 required + 2 optional | 4 labs + **RootNode Kubernetes Security Assessment** (9-scenario timed exam) |
|
||||
|
||||
### ROOT 10 — Kubernetes Troubleshooting & Observability
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-10-m01-health-probes-and-troubleshooting | READY | ROOT 09 | Configure and diagnose liveness/readiness probes | Self-paced or instructor-led | 6 required + 1 optional | 1 required lab + 1 optional lab (readiness-probe-task, wired Phase 6C) |
|
||||
| root-10-m03-troubleshooting-framework | READY | m01 | Apply the seven-step Observe→Evidence→...→Prevent-Recurrence framework to novel failures | Self-paced or instructor-led (upgraded Phase 6C) | 7 required | 2 labs |
|
||||
| root-10-m02-general-troubleshooting | **PARTIAL — instructor-led only** | m01 | Deeper troubleshooting practice bank (6 tasks, no bookending lesson) | **Instructor-led only this pilot**; self-paced deferred | 6 required | 6 labs |
|
||||
|
||||
### ROOT 11 — DevOps & Platform Engineering
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-11-m01-devops-platform-thinking | PARTIAL (honest, lesson-only by design) | ROOT 10 (course-level gate) | Name DevOps/platform engineering practices already exercised in ROOT 05-10/12; understand what's still missing for GitOps/CI-CD/IaC/observability | Self-paced or instructor-led | 3 required | none — orientation only |
|
||||
|
||||
### ROOT 12 — Real Ops
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-12-m01-real-ops-incidents | READY (documented exception — deliberately unguided by design) | ROOT 10 | Run structured incident response without a hint system | Instructor-led recommended for first exposure; self-paced viable given deliberate design | 6 required | 4 incident labs |
|
||||
|
||||
### ROOT Capstone
|
||||
| Module | Status | Prerequisite | Outcome | Delivery | Required/Optional | Labs/Assessment |
|
||||
|---|---|---|---|---|---|---|
|
||||
| root-capstone-m02-capstone-readiness | READY | ROOT 12 | Confirm readiness before attempting the capstone | Self-paced or instructor-led | 4 required | checklist/prep activities |
|
||||
| root-capstone-m01-operate-and-recover | READY (documented exception — the capstone IS the module) | m02 + capstone unlock gate | Operate and recover a production-like environment under real ops discipline | Instructor-observed recommended | 1 required | capstone exam |
|
||||
|
||||
## Instructor prep requirements
|
||||
|
||||
Every module marked "instructor-led only" or "instructor-led recommended" above requires the
|
||||
instructor to have read that module's Instructor Notes section (timing, misconceptions,
|
||||
discussion prompts) before delivering it live — this content already exists for every upgraded
|
||||
module (see `docs/ROOT_PILOT_READINESS_STANDARD.md` checklist item 6). No additional
|
||||
instructor-only prep material was created outside the per-activity Instructor Notes.
|
||||
|
||||
## What is intentionally NOT in the first pilot
|
||||
|
||||
- ROOT 01-04 (never authored).
|
||||
- ROOT 08 Kubernetes Storage & Scheduling (non-capstone-gating; both modules remain PARTIAL).
|
||||
- ROOT 11 modules 2-7 (GitOps, CI/CD, IaC, hands-on Observability, hands-on Reliability) — see
|
||||
scope doc.
|
||||
- Fully self-paced delivery of root-06-m01, root-07-m01, and root-10-m02 specifically — these
|
||||
three run instructor-led in the first pilot; self-paced delivery for exactly these three
|
||||
modules is deferred pending an authored teaching layer for each.
|
||||
- `cluster-upgrade-mcq` and `etcd-backup-mcq` (Cluster Administration category) — no current
|
||||
ROOT module teaches kubeadm-based cluster administration or etcd backup/restore; these two
|
||||
scenarios stay unwired pending a future Cluster Administration module, not due to neglect.
|
||||
|
||||
## Bottom line
|
||||
|
||||
Every module the pilot's required completion path depends on (ROOT 05-07, 09-10, 12, Capstone)
|
||||
meets the READY bar for instructor-led delivery. Three specific modules run instructor-led only
|
||||
this pilot. ROOT 08 and most of ROOT 11 are explicitly out of the required path. Given that
|
||||
scope, the pilot curriculum, delivered instructor-led, is ready.
|
||||
@@ -0,0 +1,83 @@
|
||||
# ROOT_ Pilot Readiness Standard
|
||||
|
||||
Phase 6C §2 — the explicit bar a module must clear to be called READY for the first paid ROOT_
|
||||
pilot. Extends `docs/COURSE_AUTHORING_STANDARD.md` with a pass/fail checklist rather than
|
||||
general authoring guidance, and replaces ad hoc judgment calls with a documented rule every
|
||||
module in `docs/ROOT_PILOT_CURRICULUM.md` is checked against.
|
||||
|
||||
## The standard is about sufficiency, not a fixed activity count
|
||||
|
||||
Phase 6A/6B's gold-standard pattern (lesson → demonstration → knowledge check → guided exercise
|
||||
→ lab → reflection) is a strong DEFAULT for an operational skill module, not a mechanical
|
||||
checklist to force onto every module regardless of subject. The actual test is:
|
||||
|
||||
> **Does the learner have enough instruction and scaffolding to reasonably perform the practical
|
||||
> work, understand why it matters, and check their own understanding — without an instructor
|
||||
> filling a gap the content itself should have closed?**
|
||||
|
||||
A module can be READY with fewer activities if the subject genuinely doesn't need all six. A
|
||||
module can have all six activities and still not be READY if any of them fails its own bar
|
||||
below (a "lab" with no framing, a "knowledge check" that only tests recall of the immediately
|
||||
preceding paragraph).
|
||||
|
||||
## The READY checklist
|
||||
|
||||
A module is READY only if ALL of the following are true:
|
||||
|
||||
1. **Concept instruction exists and is substantive.** A `lesson` (or equivalent) activity
|
||||
explains the underlying mental model — what problem this solves, how it actually works, what
|
||||
goes wrong when it's misunderstood — not a restated scenario description or a bullet list of
|
||||
commands with no explanation of why.
|
||||
2. **The learner sees the reasoning demonstrated, not just described**, wherever the subject
|
||||
involves diagnosing or configuring something non-trivial. This can be a `demonstration`
|
||||
activity, OR — for a purely conceptual/orientation module where there's nothing to
|
||||
diagnose — folded into the lesson itself with a worked example. A module that skips this
|
||||
entirely for an operational/troubleshooting subject is not READY.
|
||||
3. **The learner can check their own understanding** before attempting independent work — a
|
||||
`knowledge_check` (or, for an orientation module with no lab, a reflection that serves the
|
||||
same self-check purpose) that tests INTERPRETATION and REASONING (see
|
||||
`docs/COURSE_AUTHORING_STANDARD.md` §9's examples), not trivia.
|
||||
4. **The learner is not dropped into a lab cold.** Where a `lab` activity exists, either a
|
||||
`guided_exercise` bridges lesson to lab, OR the lesson/demonstration already gave the learner
|
||||
everything they need to attempt the lab's specific task without guessing at the mechanism
|
||||
being tested. A bare lab reference with a one-line "go do this" intro and no prior teaching
|
||||
of the concept it tests is NOT READY, regardless of how good the lab's own hints are — Labs
|
||||
are the laboratory, not the whole course (Phase 6A's own founding principle).
|
||||
5. **The module closes with operational context**, where the subject has real production
|
||||
consequences — a `reflection` connecting what was practiced to why it matters beyond passing
|
||||
validation (see `docs/COURSE_AUTHORING_STANDARD.md` §15's Real Ops takeaway examples). A
|
||||
purely conceptual orientation module (e.g. Capstone Readiness) may satisfy this through its
|
||||
own closing content rather than a separate reflection activity.
|
||||
6. **Instructor notes exist wherever a live class would benefit from them** — at minimum on the
|
||||
lesson and any demonstration/guided-exercise activity: timing, a likely misconception, and
|
||||
(for a demonstration) a demo plan. Not required on every activity type (a bare lab activity
|
||||
with no `content_path` has nothing to attach notes to).
|
||||
7. **Self-paced sufficiency**: nothing in the module says or implies "your instructor will
|
||||
explain this" for content available to self-paced students. If an activity is genuinely
|
||||
instructor-only (rare in this content model — everything self-paced students can reach uses
|
||||
the same file), it must be excluded from self-paced delivery structurally, not by a
|
||||
disclaimer.
|
||||
8. **Every reference resolves** — `scenario_id` points at a real scenario file,
|
||||
`assessment_definition_id` at a real definition, `content_path` at a real file that actually
|
||||
loads (`infra/scripts/validate-course-content.js` proves this mechanically; a human read-
|
||||
through confirms the CONTENT itself, not just that the file exists).
|
||||
|
||||
## What does NOT make a module READY
|
||||
|
||||
- Having a `lab` activity. A scenario reference is necessary, never sufficient.
|
||||
- Having headings that look like a lesson. Prose volume is not instruction quality.
|
||||
- Existing in the database with `status = 'ready'`. That field is the AUTHOR's claim — this
|
||||
document is the actual bar it's checked against, and `docs/ROOT_PILOT_CURRICULUM.md` is where
|
||||
every claim gets verified against this checklist by a human, not just by the field's value.
|
||||
- Passing `e2e-phase6c.js`'s structural checks alone. Automated tests prove the DATA MODEL is
|
||||
correct (references resolve, completion derives correctly) — they cannot judge whether a
|
||||
lesson's explanation is actually good. That judgment is made once, explicitly, per module,
|
||||
and recorded in the pilot manifest.
|
||||
|
||||
## DEFERRED / NOT IN PILOT is not a failure
|
||||
|
||||
A module that doesn't meet this bar is not a defect to apologize for — it's an honest status.
|
||||
`docs/ROOT_PILOT_CURRICULUM.md` uses DEFERRED (real content exists, not yet pilot-quality) and
|
||||
NOT IN PILOT (deliberately excluded — e.g. ROOT 01-04, most of ROOT 11) as first-class,
|
||||
legitimate outcomes. Marking something READY that isn't is a worse outcome than an honestly
|
||||
smaller pilot.
|
||||
@@ -210,6 +210,9 @@ function ActivityDetail({ activity, onLaunchLab, onChange }) {
|
||||
{activity.activity_type === 'capstone' && (
|
||||
<p className={styles.muted}>Continue via the Capstone tab to start or resume this assessment.</p>
|
||||
)}
|
||||
{activity.activity_type === 'assessment' && !activity.complete && (
|
||||
<p className={styles.muted}>Continue via the Assessments tab to start or resume “{activity.title}”.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 6C §22 curriculum-completion dress rehearsal: brings up the real dev
|
||||
# stack, bootstraps an owner, runs test-phase6c-security-config-rehearsal.js (real k3s Lab, real
|
||||
# kubectl, real instructor visibility check), 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] "P6C Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase6c-security-config-rehearsal.js
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6C §22 — the curriculum-completion dress rehearsal. One continuous realistic student
|
||||
// narrative through a module Phase 6C actually upgraded this phase (ROOT 07's Configuration &
|
||||
// Secrets, converted in place from a bare 5-lab bank to a full gold-pattern module), using a
|
||||
// REAL k3s Lab end to end: login -> program -> course -> lesson -> demonstration -> knowledge
|
||||
// check -> guided exercise -> real Lab -> real kubectl -> validation -> progress persistence,
|
||||
// then an instructor narrative confirming the resulting learning state is visible. Same
|
||||
// conventions as test-phase6b-root-course-rehearsal.js: assert-based, no framework, real k3s
|
||||
// boots, no mocking.
|
||||
//
|
||||
// Run via run-phase6c-security-config-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 CONFIG_MODULE = 'root-07-m02-configuration-and-secrets';
|
||||
|
||||
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 6C curriculum-completion 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 6C Student', login_identifier: `p6c-config-${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 the ROOT_ program', 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;
|
||||
});
|
||||
|
||||
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', 'job-task', 'cronjob-task'];
|
||||
const ROOT07_SERVICES = ['services-mcq', 'expose-service-basics'];
|
||||
|
||||
await step('student launches a REAL Lab and completes ROOT 05/06 plus ROOT 07\'s Services module to unlock Configuration & Secrets', 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, ...ROOT07_SERVICES]) {
|
||||
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`);
|
||||
}
|
||||
for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces', 'a06-reflection']) {
|
||||
assert.equal((await session.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`)).status, 200);
|
||||
}
|
||||
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
|
||||
assert.equal((await session.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`)).status, 200);
|
||||
}
|
||||
for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy', 'a06-reflection']) {
|
||||
assert.equal((await session.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`)).status, 200);
|
||||
}
|
||||
for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a01b-demonstration-job-lifecycle', 'a02-knowledge-check', 'a02b-guided-exercise-choosing-primitive', 'a05-reflection']) {
|
||||
assert.equal((await session.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`)).status, 200);
|
||||
}
|
||||
for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type', 'a06-reflection']) {
|
||||
assert.equal((await session.request('POST', `/api/student/activities/root-07-m03-${suffix}/complete`)).status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('the Phase 6C-upgraded Configuration & Secrets 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, CONFIG_MODULE);
|
||||
assert.equal(mod.locked, false);
|
||||
assert.equal(mod.activities[0].activity_type, 'lesson');
|
||||
assert.ok(mod.activities[0].body.includes('Secret'), 'the real lesson content must actually be served');
|
||||
});
|
||||
|
||||
await step('student reads the lesson and demonstration, completes the knowledge check and guided exercise', async () => {
|
||||
for (const suffix of ['a00-lesson-configuration', 'a00b-demonstration-secret-not-encrypted', 'a00c-knowledge-check', 'a00d-guided-exercise-choosing-mechanism']) {
|
||||
const { status } = await session.request('POST', `/api/student/activities/root-07-m02-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('student launches the module\'s Secrets Lab 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, CONFIG_MODULE);
|
||||
const lab = mod.activities.find((a) => a.scenario_id === 'secrets-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/secrets-basics/setup`);
|
||||
assert.equal(setup.status, 200);
|
||||
|
||||
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();
|
||||
// Real kubectl, run inside the student's actual disposable Lab container.
|
||||
execSync(`docker exec ${containerName} kubectl create secret generic app-secret --from-literal=api-key=supersecret`, { stdio: 'pipe' });
|
||||
execSync(`docker exec ${containerName} sh -c "cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: secret-reader
|
||||
spec:
|
||||
containers:
|
||||
- name: reader
|
||||
image: busybox:1.36
|
||||
command: [\\"sleep\\",\\"3600\\"]
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: app-secret
|
||||
key: api-key
|
||||
EOF"`, { stdio: 'pipe' });
|
||||
|
||||
// Give the Pod a moment to reach Running before validating, same as a real student would.
|
||||
execSync(`docker exec ${containerName} kubectl wait --for=condition=Ready pod/secret-reader --timeout=60s`, { stdio: 'pipe' });
|
||||
|
||||
const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/secrets-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: 'secrets-basics', status: 'completed', environment_id: envId });
|
||||
assert.equal(record.status, 200);
|
||||
});
|
||||
|
||||
await step('the remaining labs and reflection complete the module', async () => {
|
||||
for (const sid of ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'pod-env-vars-inline']) {
|
||||
const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId });
|
||||
assert.equal(r.status, 200);
|
||||
}
|
||||
const { body: before } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleFrom(before.courses, CONFIG_MODULE).activities.find((a) => a.scenario_id === 'secrets-basics').complete, true);
|
||||
await session.request('POST', '/api/student/activities/root-07-m02-a99-reflection/complete');
|
||||
const { body: after } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleFrom(after.courses, CONFIG_MODULE).complete, true);
|
||||
});
|
||||
|
||||
await step('logout, login, Resume Learning — the Phase 6C module\'s progress survives all of it', async () => {
|
||||
await session.request('POST', '/api/logout');
|
||||
const returning = makeSession(GW);
|
||||
await returning.request('POST', '/api/student/login', { identifier: `p6c-config-${RUN_ID}`, password: 'RehearsalPass123!' });
|
||||
const { body: resumed } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`);
|
||||
assert.equal(moduleFrom(resumed.courses, CONFIG_MODULE).complete, true, 'progress must survive a real logout/login');
|
||||
});
|
||||
|
||||
// ── Instructor narrative — Section 22's "an instructor can see the resulting learning state" ─
|
||||
await step('instructor inspects the Phase 6C module structure and its instructor notes', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
assert.equal(status, 200);
|
||||
const mod = moduleFrom(body, CONFIG_MODULE);
|
||||
assert.equal(mod.activities.length, 10);
|
||||
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, completed progress through the Phase 6C 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, CONFIG_MODULE).complete, true);
|
||||
});
|
||||
|
||||
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,188 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6C §24 — programmatic content validation: fails loudly if the course/module/activity
|
||||
// content model has any structural defect. Run against a live Postgres (same connection the
|
||||
// Platform API uses) plus the repo's own scenarios/data, scenarios/rootnode-path.json, and
|
||||
// content/root/ files — cross-checks the database against the filesystem rather than trusting
|
||||
// either alone. Exit code 1 on any failure; safe to wire into CI later, not built as CI here
|
||||
// (no CI system exists in this repo to wire into).
|
||||
//
|
||||
// Usage: PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
// node infra/scripts/validate-course-content.js
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const REPO_ROOT = path.join(__dirname, '../..');
|
||||
// Reuses platform/api's own `pg` dependency rather than adding a new package.json/node_modules
|
||||
// just for this script — same "no new dependency where an existing one already covers it"
|
||||
// convention the rest of this codebase follows.
|
||||
const { Client } = require(path.join(REPO_ROOT, 'platform/api/node_modules/pg'));
|
||||
const DB_URL = process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform';
|
||||
|
||||
let errors = 0;
|
||||
function fail(msg) { console.error(` FAIL — ${msg}`); errors++; }
|
||||
function ok(msg) { console.log(` ok — ${msg}`); }
|
||||
|
||||
async function main() {
|
||||
const client = new Client({ connectionString: DB_URL });
|
||||
await client.connect();
|
||||
|
||||
const scenarioIds = new Set(fs.readdirSync(path.join(REPO_ROOT, 'scenarios/data')).filter((f) => f.endsWith('.json')).map((f) => f.replace('.json', '')));
|
||||
const assessmentIds = new Set((await client.query('SELECT assessment_definition_id FROM assessment_definitions')).rows.map((r) => r.assessment_definition_id));
|
||||
const courses = (await client.query('SELECT * FROM courses')).rows;
|
||||
const modules = (await client.query('SELECT * FROM course_modules')).rows;
|
||||
const activities = (await client.query('SELECT * FROM module_activities')).rows;
|
||||
|
||||
const courseIds = new Set(courses.map((c) => c.course_id));
|
||||
const moduleIds = new Set(modules.map((m) => m.module_id));
|
||||
const activityIds = new Set(activities.map((a) => a.activity_id));
|
||||
|
||||
console.log(`Loaded ${courses.length} courses, ${modules.length} modules, ${activities.length} activities, ${scenarioIds.size} scenario files.\n`);
|
||||
|
||||
// ── No duplicate IDs (PK already enforces this at the DB level, but verify the exported
|
||||
// JS-level arrays too, catching an accidental cross-table id collision the DB doesn't check) ─
|
||||
for (const [label, ids] of [['course', courses.map((c) => c.course_id)], ['module', modules.map((m) => m.module_id)], ['activity', activities.map((a) => a.activity_id)]]) {
|
||||
const seen = new Set(); const dupes = new Set();
|
||||
for (const id of ids) { if (seen.has(id)) dupes.add(id); seen.add(id); }
|
||||
if (dupes.size) fail(`duplicate ${label} id(s): ${[...dupes].join(', ')}`); else ok(`no duplicate ${label} ids`);
|
||||
}
|
||||
|
||||
// ── No orphan modules/activities (referencing a course/module that doesn't exist) ────────
|
||||
const orphanModules = modules.filter((m) => !courseIds.has(m.course_id));
|
||||
if (orphanModules.length) fail(`orphan module(s) referencing a missing course: ${orphanModules.map((m) => m.module_id).join(', ')}`); else ok('no orphan modules');
|
||||
const orphanActivities = activities.filter((a) => !moduleIds.has(a.module_id));
|
||||
if (orphanActivities.length) fail(`orphan activity(ies) referencing a missing module: ${orphanActivities.map((a) => a.activity_id).join(', ')}`); else ok('no orphan activities');
|
||||
|
||||
// ── No invalid scenario/assessment references ────────────────────────────────────────────
|
||||
const badScenarioRefs = activities.filter((a) => a.scenario_id && !scenarioIds.has(a.scenario_id));
|
||||
if (badScenarioRefs.length) fail(`activity(ies) referencing a missing scenario file: ${badScenarioRefs.map((a) => `${a.activity_id}->${a.scenario_id}`).join(', ')}`); else ok('every lab activity scenario_id resolves to a real scenario file');
|
||||
const badAssessmentRefs = activities.filter((a) => a.assessment_definition_id && !assessmentIds.has(a.assessment_definition_id));
|
||||
if (badAssessmentRefs.length) fail(`activity(ies) referencing a missing assessment_definition: ${badAssessmentRefs.map((a) => `${a.activity_id}->${a.assessment_definition_id}`).join(', ')}`); else ok('every assessment/capstone activity resolves to a real assessment_definition');
|
||||
|
||||
// ── No impossible prerequisite references (a module/course pointing at an id that doesn't
|
||||
// exist at all — distinct from Phase 6A's deliberate "PLANNED = vacuously satisfied" rule,
|
||||
// this catches a genuine TYPO in a prerequisite id) ──────────────────────────────────────
|
||||
for (const c of courses) {
|
||||
for (const prereq of c.prerequisites || []) {
|
||||
if (!courseIds.has(prereq)) fail(`course ${c.course_id} lists a prerequisite that doesn't exist: ${prereq}`);
|
||||
}
|
||||
}
|
||||
for (const m of modules) {
|
||||
for (const prereq of m.prerequisites || []) {
|
||||
if (!moduleIds.has(prereq)) fail(`module ${m.module_id} lists a prerequisite that doesn't exist: ${prereq}`);
|
||||
}
|
||||
}
|
||||
ok('every course/module prerequisite id resolves to a real course/module (or was already reported above)');
|
||||
|
||||
// ── No course-tree cycles (a module/course that is its own transitive prerequisite) ──────
|
||||
function hasCycle(items, idKey) {
|
||||
const byId = new Map(items.map((i) => [i[idKey], i]));
|
||||
const state = new Map(); // id -> 'visiting' | 'done'
|
||||
let cyclic = null;
|
||||
function visit(id, chain) {
|
||||
if (cyclic) return;
|
||||
const item = byId.get(id);
|
||||
if (!item) return;
|
||||
if (state.get(id) === 'visiting') { cyclic = [...chain, id]; return; }
|
||||
if (state.get(id) === 'done') return;
|
||||
state.set(id, 'visiting');
|
||||
for (const prereq of item.prerequisites || []) visit(prereq, [...chain, id]);
|
||||
state.set(id, 'done');
|
||||
}
|
||||
for (const item of items) visit(item[idKey], []);
|
||||
return cyclic;
|
||||
}
|
||||
const courseCycle = hasCycle(courses, 'course_id');
|
||||
if (courseCycle) fail(`course prerequisite cycle: ${courseCycle.join(' -> ')}`); else ok('no course prerequisite cycles');
|
||||
const moduleCycle = hasCycle(modules, 'module_id');
|
||||
if (moduleCycle) fail(`module prerequisite cycle: ${moduleCycle.join(' -> ')}`); else ok('no module prerequisite cycles');
|
||||
|
||||
// ── No sequence collisions within one module/course (two siblings sharing a sequence
|
||||
// number is a real authoring bug — found and fixed once already in Phase 6B, see
|
||||
// 023_phase6b_curriculum.sql's ROOT 07 m02/m04 comment) ──────────────────────────────────
|
||||
function checkSequenceCollisions(items, groupKey, label) {
|
||||
const groups = new Map();
|
||||
for (const item of items) {
|
||||
const key = item[groupKey];
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(item.sequence);
|
||||
}
|
||||
let collided = 0;
|
||||
for (const [key, seqs] of groups) {
|
||||
const dupes = seqs.filter((s, i) => seqs.indexOf(s) !== i);
|
||||
if (dupes.length) { fail(`${label} sequence collision in ${key}: sequence ${[...new Set(dupes)].join(',')} used more than once`); collided++; }
|
||||
}
|
||||
if (!collided) ok(`no ${label} sequence collisions`);
|
||||
}
|
||||
checkSequenceCollisions(modules, 'course_id', 'module');
|
||||
checkSequenceCollisions(activities, 'module_id', 'activity');
|
||||
|
||||
// ── Instructor notes never leak to students (content.js's own splitContent, exercised
|
||||
// directly here rather than trusted). content.js resolves content_path against
|
||||
// CONTENT_ROOT_DIR if set, defaulting to one level up from its own src/ (correct only INSIDE
|
||||
// the Docker image, where the Dockerfile COPYs content/ next to src/) — running it directly
|
||||
// on the host here needs this override so it resolves against the real repo root instead.
|
||||
process.env.CONTENT_ROOT_DIR = REPO_ROOT;
|
||||
const { loadContent } = require(path.join(REPO_ROOT, 'platform/api/src/content.js'));
|
||||
let leakChecked = 0, leaked = 0;
|
||||
for (const a of activities) {
|
||||
if (!a.content_path) continue;
|
||||
const c = loadContent(a.content_path);
|
||||
leakChecked++;
|
||||
if (c && c.shared && c.shared.includes('## Instructor Notes')) { fail(`${a.activity_id}: 'shared' (student-visible) content still contains the Instructor Notes heading — split failed`); leaked++; }
|
||||
}
|
||||
if (!leaked) ok(`instructor notes correctly excluded from student-visible content in all ${leakChecked} activities with a content_path`);
|
||||
|
||||
// ── No content_path referencing a missing file ───────────────────────────────────────────
|
||||
let missingContent = 0;
|
||||
for (const a of activities) {
|
||||
if (!a.content_path) continue;
|
||||
if (!fs.existsSync(path.join(REPO_ROOT, a.content_path))) { fail(`${a.activity_id}: content_path does not exist on disk: ${a.content_path}`); missingContent++; }
|
||||
}
|
||||
if (!missingContent) ok('every content_path resolves to a real file on disk');
|
||||
|
||||
// ── A 'lab' activity must have a scenario_id; an 'assessment'/'capstone' must have an
|
||||
// assessment_definition_id (the DB CHECK constraints already enforce this — verified here
|
||||
// too so this script fails loudly even against a hand-edited row that somehow bypassed it) ─
|
||||
const labsMissingScenario = activities.filter((a) => a.activity_type === 'lab' && !a.scenario_id);
|
||||
if (labsMissingScenario.length) fail(`lab activity(ies) with no scenario_id: ${labsMissingScenario.map((a) => a.activity_id).join(', ')}`); else ok('every lab activity has a scenario_id');
|
||||
const assessmentsMissingDef = activities.filter((a) => ['assessment', 'capstone'].includes(a.activity_type) && !a.assessment_definition_id);
|
||||
if (assessmentsMissingDef.length) fail(`assessment/capstone activity(ies) with no assessment_definition_id: ${assessmentsMissingDef.map((a) => a.activity_id).join(', ')}`); else ok('every assessment/capstone activity has an assessment_definition_id');
|
||||
|
||||
// ── Section 16/17 — a module marked READY (self-paced-sufficient) must not defer explanation
|
||||
// to a live instructor in its student-visible content. Scans the actual 'shared' half of
|
||||
// content.js's real split (never the instructor-notes half, which is explicitly ALLOWED to
|
||||
// say "explain live"), and only against READY modules — instructor-led-only PARTIAL modules
|
||||
// (see docs/ROOT_PILOT_CURRICULUM.md) are not held to the self-paced bar. ───────────────────
|
||||
const readyModuleIds = new Set(modules.filter((m) => m.status === 'ready').map((m) => m.module_id));
|
||||
const deferralPhrases = [/your instructor will explain/i, /ask your instructor/i, /your instructor will cover/i];
|
||||
let deferralsFound = 0;
|
||||
for (const a of activities) {
|
||||
if (!a.content_path || !readyModuleIds.has(a.module_id)) continue;
|
||||
const c = loadContent(a.content_path);
|
||||
if (!c || !c.shared) continue;
|
||||
if (deferralPhrases.some((re) => re.test(c.shared))) { fail(`${a.activity_id}: self-paced-marked module's student-visible content defers to a live instructor`); deferralsFound++; }
|
||||
}
|
||||
if (!deferralsFound) ok('no READY module\'s student-visible content defers explanation to a live instructor');
|
||||
|
||||
// ── Duplicate scenario mapping — flagged, not failed, since Phase 6A/6B both document
|
||||
// DELIBERATE reuse (rootnode-probe-port-mismatch in both the gold module and Real Ops;
|
||||
// pod-security-context/rbac-clusterrole as documented independent-practice duplicates). This
|
||||
// prints the list for a human to confirm against docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md's
|
||||
// 'duplicate concepts identified' section rather than failing the build. ───────────────────
|
||||
const scenarioUsage = new Map();
|
||||
for (const a of activities) {
|
||||
if (!a.scenario_id) continue;
|
||||
if (!scenarioUsage.has(a.scenario_id)) scenarioUsage.set(a.scenario_id, []);
|
||||
scenarioUsage.get(a.scenario_id).push(a.activity_id);
|
||||
}
|
||||
const reused = [...scenarioUsage.entries()].filter(([, ids]) => ids.length > 1);
|
||||
console.log(` info — ${reused.length} scenario(s) intentionally reused across multiple activities (see docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md):`);
|
||||
for (const [sid, ids] of reused) console.log(` ${sid}: ${ids.join(', ')}`);
|
||||
|
||||
await client.end();
|
||||
|
||||
console.log(`\n${errors === 0 ? 'PASS' : 'FAIL'} — ${errors} error(s)`);
|
||||
process.exit(errors ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
||||
@@ -0,0 +1,225 @@
|
||||
-- Phase 6C — curriculum completion, assessment bank, ROOT 11 scope. Additive on top of Phase
|
||||
-- 6B's 023_phase6b_curriculum.sql. No schema change was needed — is_required (Phase 6B) and
|
||||
-- the existing assessment_definitions table already cover every genuine Phase 6C requirement.
|
||||
-- See docs/ROOT_PILOT_READINESS_STANDARD.md for the bar these upgrades were checked against
|
||||
-- and docs/PHASE6C_CURRICULUM_COMPLETION.md for which modules were upgraded and why.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Upgrade 1: root-06-m03-jobs-cronjobs-daemonsets — lesson+KC+2labs -> full gold pattern.
|
||||
-- Existing activities resequenced to make room; scenario_id/activity_type/completion_behavior
|
||||
-- of every existing row is untouched, so no student's derived completion changes meaning.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE module_activities SET sequence = 3, version = version + 1, updated_at = now() WHERE activity_id = 'root-06-m03-a02-knowledge-check';
|
||||
UPDATE module_activities SET sequence = 5, version = version + 1, updated_at = now() WHERE activity_id = 'root-06-m03-a03-lab-job-task';
|
||||
UPDATE module_activities SET sequence = 6, version = version + 1, updated_at = now() WHERE activity_id = 'root-06-m03-a04-lab-cronjob-task';
|
||||
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-a01b-demonstration-job-lifecycle', 'root-06-m03-jobs-cronjobs-daemonsets', 2, 'demonstration', 'Demonstration: Watching a Job Actually Complete',
|
||||
'content/root/root-06-kubernetes-application-workloads/m03-jobs-cronjobs-daemonsets/03-demonstration-job-lifecycle.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-06-m03-a02b-guided-exercise-choosing-primitive', 'root-06-m03-jobs-cronjobs-daemonsets', 4, 'guided_exercise', 'Guided Exercise: Choosing the Right Workload Primitive',
|
||||
'content/root/root-06-kubernetes-application-workloads/m03-jobs-cronjobs-daemonsets/04-guided-exercise-choosing-primitive.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-06-m03-a05-reflection', 'root-06-m03-jobs-cronjobs-daemonsets', 7, 'reflection', 'Reflection: The Right Primitive Is a Correctness Decision, Not a Style Choice',
|
||||
'content/root/root-06-kubernetes-application-workloads/m03-jobs-cronjobs-daemonsets/05-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-06-m03-jobs-cronjobs-daemonsets';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Upgrade 2: root-07-m04-ingress-networkpolicy-foundations — lesson+KC+2labs -> full gold pattern.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE module_activities SET sequence = 3, version = version + 1, updated_at = now() WHERE activity_id = 'root-07-m04-a02-knowledge-check';
|
||||
UPDATE module_activities SET sequence = 5, version = version + 1, updated_at = now() WHERE activity_id = 'root-07-m04-a03-lab-ingress-task';
|
||||
UPDATE module_activities SET sequence = 6, version = version + 1, updated_at = now() WHERE activity_id = 'root-07-m04-a04-lab-network-policy';
|
||||
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-a01b-demonstration-ingress-routing', 'root-07-m04-ingress-networkpolicy-foundations', 2, 'demonstration', 'Demonstration: Tracing a Request Through Ingress',
|
||||
'content/root/root-07-kubernetes-networking-config/m04-ingress-networkpolicy-foundations/03-demonstration-ingress-routing.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-07-m04-a02b-guided-exercise-network-isolation', 'root-07-m04-ingress-networkpolicy-foundations', 4, 'guided_exercise', 'Guided Exercise: Planning Network Isolation',
|
||||
'content/root/root-07-kubernetes-networking-config/m04-ingress-networkpolicy-foundations/04-guided-exercise-network-isolation-plan.md', NULL, NULL, 'manual_marker', 12, 'ready'),
|
||||
('root-07-m04-a05-reflection', 'root-07-m04-ingress-networkpolicy-foundations', 7, 'reflection', 'Reflection: Reachability Should Be a Decision, Not a Default',
|
||||
'content/root/root-07-kubernetes-networking-config/m04-ingress-networkpolicy-foundations/05-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-07-m04-ingress-networkpolicy-foundations';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Upgrade 3: root-07-m02-configuration-and-secrets — converted IN PLACE from a bare 5-lab bank
|
||||
-- into a full teaching module (no new module created; this is the last networking/config
|
||||
-- module with no front-door teaching, an honest gap Phase 6B's own doc flagged). The 5
|
||||
-- existing lab activities are untouched (id/scenario_id/type/completion_behavior) other than
|
||||
-- their sequence number shifting to make room.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE module_activities SET sequence = sequence + 4, version = version + 1, updated_at = now() WHERE module_id = 'root-07-m02-configuration-and-secrets';
|
||||
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-m02-a00-lesson-configuration', 'root-07-m02-configuration-and-secrets', 1, 'lesson', 'Externalizing Configuration: ConfigMaps & Secrets',
|
||||
'content/root/root-07-kubernetes-networking-config/m02-configuration-and-secrets/00-lesson-configuration.md', NULL, NULL, 'manual_marker', 15, 'ready'),
|
||||
('root-07-m02-a00b-demonstration-secret-not-encrypted', 'root-07-m02-configuration-and-secrets', 2, 'demonstration', 'Demonstration: A Secret Is Encoded, Not Encrypted',
|
||||
'content/root/root-07-kubernetes-networking-config/m02-configuration-and-secrets/01-demonstration-secret-not-encrypted.md', NULL, NULL, 'manual_marker', 8, 'ready'),
|
||||
('root-07-m02-a00c-knowledge-check', 'root-07-m02-configuration-and-secrets', 3, 'knowledge_check', 'Knowledge Check: Configuration & Secrets',
|
||||
'content/root/root-07-kubernetes-networking-config/m02-configuration-and-secrets/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-07-m02-a00d-guided-exercise-choosing-mechanism', 'root-07-m02-configuration-and-secrets', 4, 'guided_exercise', 'Guided Exercise: ConfigMap, Secret, Env Var, or Volume?',
|
||||
'content/root/root-07-kubernetes-networking-config/m02-configuration-and-secrets/03-guided-exercise-choosing-mechanism.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-07-m02-a99-reflection', 'root-07-m02-configuration-and-secrets', 10, 'reflection', 'Reflection: Configuration Is Infrastructure, Secrets Are a Trust Boundary',
|
||||
'content/root/root-07-kubernetes-networking-config/m02-configuration-and-secrets/06-reflection.md', NULL, NULL, 'manual_marker', 8, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-07-m02-configuration-and-secrets';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Upgrade 4: root-09-m02-serviceaccounts-and-rbac — lesson+KC+6labs -> full gold pattern.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE module_activities SET sequence = 3, version = version + 1, updated_at = now() WHERE activity_id = 'root-09-m02-a02-knowledge-check';
|
||||
UPDATE module_activities SET sequence = sequence + 2, version = version + 1, updated_at = now() WHERE module_id = 'root-09-m02-serviceaccounts-and-rbac' AND activity_id IN
|
||||
('root-09-m02-a03-lab-serviceaccount-pod','root-09-m02-a04-lab-automount-token','root-09-m02-a05-lab-rbac-role','root-09-m02-a06-lab-rbac-clusterrole','root-09-m02-a07-lab-rbac-least-privilege','root-09-m02-a08-lab-rbac-clusterrole-independent');
|
||||
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-a01b-demonstration-rbac-denial', 'root-09-m02-serviceaccounts-and-rbac', 2, 'demonstration', 'Demonstration: Watching RBAC Actually Deny Something',
|
||||
'content/root/root-09-kubernetes-security/m02-serviceaccounts-and-rbac/03-demonstration-rbac-denial.md', NULL, NULL, 'manual_marker', 8, 'ready'),
|
||||
('root-09-m02-a02b-guided-exercise-least-privilege-role', 'root-09-m02-serviceaccounts-and-rbac', 4, 'guided_exercise', 'Guided Exercise: Writing a Least-Privilege Role',
|
||||
'content/root/root-09-kubernetes-security/m02-serviceaccounts-and-rbac/04-guided-exercise-least-privilege-role.md', NULL, NULL, 'manual_marker', 12, 'ready'),
|
||||
('root-09-m02-a09-reflection', 'root-09-m02-serviceaccounts-and-rbac', 11, 'reflection', 'Reflection: Permissions Should Reflect What a Workload Actually Needs',
|
||||
'content/root/root-09-kubernetes-security/m02-serviceaccounts-and-rbac/07-reflection.md', NULL, NULL, 'manual_marker', 8, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-09-m02-serviceaccounts-and-rbac';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Upgrade 5: root-09-m03-network-and-supplychain-security — lesson+KC+6labs -> full gold pattern.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE module_activities SET sequence = 3, version = version + 1, updated_at = now() WHERE activity_id = 'root-09-m03-a02-knowledge-check';
|
||||
UPDATE module_activities SET sequence = sequence + 2, version = version + 1, updated_at = now() WHERE module_id = 'root-09-m03-network-and-supplychain-security' AND activity_id IN
|
||||
('root-09-m03-a03-lab-default-deny','root-09-m03-a04-lab-egress-namespace','root-09-m03-a05-lab-egress-networkpolicy-guided','root-09-m03-a06-lab-tls-ingress','root-09-m03-a07-lab-image-pull-secret','root-09-m03-a08-lab-network-policy-metadata-advanced');
|
||||
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-a01b-demonstration-default-deny', 'root-09-m03-network-and-supplychain-security', 2, 'demonstration', 'Demonstration: Default-Deny, Before and After',
|
||||
'content/root/root-09-kubernetes-security/m03-network-and-supplychain-security/03-demonstration-default-deny.md', NULL, NULL, 'manual_marker', 10, 'ready'),
|
||||
('root-09-m03-a02b-guided-exercise-egress-plan', 'root-09-m03-network-and-supplychain-security', 4, 'guided_exercise', 'Guided Exercise: Planning Egress Restriction',
|
||||
'content/root/root-09-kubernetes-security/m03-network-and-supplychain-security/04-guided-exercise-egress-plan.md', NULL, NULL, 'manual_marker', 12, 'ready'),
|
||||
('root-09-m03-a09-reflection', 'root-09-m03-network-and-supplychain-security', 11, 'reflection', 'Reflection: Reachability and Trust Are Decisions You Make on Purpose',
|
||||
'content/root/root-09-kubernetes-security/m03-network-and-supplychain-security/09-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-09-m03-network-and-supplychain-security';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Upgrade 6: root-10-m03-troubleshooting-framework — lesson+demo+KC+2labs -> full gold pattern
|
||||
-- (only guided_exercise + reflection were missing).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE module_activities SET sequence = 5, version = version + 1, updated_at = now() WHERE activity_id = 'root-10-m03-a04-lab-crashloop-fix';
|
||||
UPDATE module_activities SET sequence = 6, version = version + 1, updated_at = now() WHERE activity_id = 'root-10-m03-a05-lab-endpoint-fix';
|
||||
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-a03b-guided-exercise-endpoint-mismatch', 'root-10-m03-troubleshooting-framework', 4, 'guided_exercise', 'Guided Exercise: Applying the Framework to a Broken Service, on Paper First',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m03-troubleshooting-framework/04-guided-exercise-endpoint-mismatch.md', NULL, NULL, 'manual_marker', 12, 'ready'),
|
||||
('root-10-m03-a06-reflection', 'root-10-m03-troubleshooting-framework', 7, 'reflection', 'Reflection: A Framework You''ve Actually Used Is One You''ll Trust Under Pressure',
|
||||
'content/root/root-10-kubernetes-troubleshooting-observability/m03-troubleshooting-framework/06-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-10-m03-troubleshooting-framework';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Upgrade 7: root-05-m01-pods-and-kubectl-basics — a 20-task kubectl-fluency drill bank with no
|
||||
-- bookending at all. Each individual task already carries its own explanation (per-scenario
|
||||
-- description/hints), so the real gap was framing, not per-command teaching: a short lesson
|
||||
-- explaining WHY this module is 20 independent tasks rather than one narrative, plus a
|
||||
-- reflection that makes students name their own remaining gaps. All 20 existing lab/MCQ
|
||||
-- activities are untouched.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
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-m01-a00-lesson-kubectl-fluency', 'root-05-m01-pods-and-kubectl-basics', 0, 'lesson', 'Building kubectl Fluency: Why Twenty Small Reps Beat One Big Lecture',
|
||||
'content/root/root-05-kubernetes-foundations/m01-pods-and-kubectl-basics/00-lesson-kubectl-fluency.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-05-m01-a21-reflection', 'root-05-m01-pods-and-kubectl-basics', 21, 'reflection', 'Reflection: Naming Your Gaps While They''re Still Cheap to Close',
|
||||
'content/root/root-05-kubernetes-foundations/m01-pods-and-kubectl-basics/21-reflection.md', NULL, NULL, 'manual_marker', 8, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-05-m01-pods-and-kubectl-basics';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- root-09-m04-cluster-hardening-and-runtime — READY via documented exception. Phase 6B already
|
||||
-- documented this module as deliberately survey-depth ("not core, not daily-driver"). It now
|
||||
-- has a lesson, two required hands-on labs, two optional deeper labs, and a real timed
|
||||
-- assessment checkpoint (this migration's security-assessment wiring above) — sufficient for
|
||||
-- its intentionally lighter scope without forcing a demonstration/guided-exercise pattern that
|
||||
-- would misrepresent it as core daily-driver content.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-09-m04-cluster-hardening-and-runtime';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- root-capstone-m01-operate-and-recover — READY via documented exception, not new content. Its
|
||||
-- single 'capstone' activity IS the terminal exam this module exists to deliver; the readiness
|
||||
-- teaching/prep work already lives in the preceding root-capstone-m02-capstone-readiness module
|
||||
-- (already READY). Adding a lesson/reflection here would duplicate that module's job.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-capstone-m01-operate-and-recover';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- root-12-m01-real-ops-incidents — READY via documented exception, not new content. Its
|
||||
-- lesson + 4 deliberately-unguided incidents + reflection IS the intentional shape for this
|
||||
-- module (see docs/ROOT_PILOT_READINESS_STANDARD.md's flexibility principle and
|
||||
-- docs/PHASE6C_CURRICULUM_COMPLETION.md for the full reasoning) — a knowledge check or guided
|
||||
-- exercise here would undermine its explicit "you are responsible for this environment, not
|
||||
-- handed a hint system" design. No activities added; status promoted to reflect the review.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE course_modules SET status = 'ready', version = version + 1, updated_at = now() WHERE module_id = 'root-12-m01-real-ops-incidents';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Section 9 — Kubernetes security assessment bank. Wires the 7 previously-unwired CKS MCQs
|
||||
-- (docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md's ASSESSMENT-BANK classification) into a real
|
||||
-- assessment_definition using the EXISTING Phase 3B assessment engine — no new mechanism, no
|
||||
-- duplicated scenario content. Required scenarios are hands-on CORE labs already in ROOT 09;
|
||||
-- random pools mix additional hands-on practice with the recall-style MCQs.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule)
|
||||
VALUES (
|
||||
'rootnode-security-assessment', 'exam', 'RootNode Kubernetes Security Assessment',
|
||||
'A timed skills check across Kubernetes security fundamentals: SecurityContext hardening, least-privilege RBAC, NetworkPolicy as a security control, and secrets handling — combining hands-on tasks with conceptual reasoning questions. Example exam definition, following the same pattern as rootnode-quick-assessment.',
|
||||
60, 'disabled',
|
||||
'{"required": ["cks-pod-security-context","cks-rbac-least-privilege","cks-network-policy"],
|
||||
"random": [
|
||||
{"count":2,"from":["cks-non-root-enforce","cks-drop-capabilities","cks-readonly-filesystem","cks-seccomp-profile"]},
|
||||
{"count":1,"from":["serviceaccount-pod","cks-automount-token","rbac-role","cks-rbac-clusterrole"]},
|
||||
{"count":1,"from":["cks-egress-namespace","networkpolicy-egress","cks-tls-ingress","cks-image-pull-secret"]},
|
||||
{"count":2,"from":["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"]}
|
||||
]}',
|
||||
'{"type":"all_scenarios_passed"}'
|
||||
) ON CONFLICT (assessment_definition_id) DO NOTHING;
|
||||
|
||||
-- Given real curriculum visibility as an 'assessment' activity at the end of ROOT 09's
|
||||
-- advanced/survey module — the natural home for a security checkpoint, not a new 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-09-m04-a06-assessment-security', 'root-09-m04-cluster-hardening-and-runtime', 6, 'assessment', 'RootNode Kubernetes Security Assessment', NULL, NULL, 'rootnode-security-assessment', 'auto_assessment', 60, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Section 11/12 — ROOT 11 gets an honest, lesson-only orientation module. No fake labs: the
|
||||
-- current Lab platform provides Kubernetes/k3s only, not a Git+CI runner, ArgoCD, Terraform, or
|
||||
-- Prometheus/Grafana environment (docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md documents exactly what
|
||||
-- would be needed for those). ROOT 11 moves from PLANNED to PARTIAL — real content exists, no
|
||||
-- longer an unexplained empty placeholder — while staying honest that no hands-on lab exists
|
||||
-- for this course yet.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE courses SET status = 'partial', updated_at = now() WHERE course_id = 'root-11-devops-platform-engineering';
|
||||
|
||||
-- prerequisites is left empty at the MODULE level (course_modules.prerequisites references
|
||||
-- other MODULE ids, not course ids) — the course-level gate (courses.prerequisites, already
|
||||
-- 'root-10-kubernetes-troubleshooting-observability' since Phase 6A's 022_course_content.sql)
|
||||
-- already requires ROOT 10 complete before a student can enter ROOT 11 at all. Found by
|
||||
-- infra/scripts/validate-course-content.js — an earlier draft of this migration incorrectly
|
||||
-- put a course id in a module-prerequisites array.
|
||||
INSERT INTO course_modules (module_id, course_id, title, description, sequence, objectives, prerequisites, estimated_duration_minutes, status) VALUES
|
||||
('root-11-m01-devops-platform-thinking', 'root-11-devops-platform-engineering', 'From Sysadmin to Platform Thinking',
|
||||
'DevOps as an operating model, platform engineering, and where Kubernetes fits in the larger platform ecosystem — lesson-only orientation; no hands-on Lab exists for this course yet (see docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md).',
|
||||
1, '{"Explain DevOps as an operating model rather than a job title","Explain what platform engineering adds on top of DevOps practice","Describe how declarative infrastructure, automation, and feedback loops reduce cognitive load for engineers"}',
|
||||
'{}', 30, 'partial')
|
||||
ON CONFLICT (module_id) DO NOTHING;
|
||||
|
||||
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-11-m01-a01-lesson-devops-platform-thinking', 'root-11-m01-devops-platform-thinking', 1, 'lesson', 'DevOps as an Operating Model, Not a Job Title',
|
||||
'content/root/root-11-devops-platform-engineering/m01-devops-platform-thinking/01-lesson-devops-platform-thinking.md', NULL, NULL, 'manual_marker', 20, 'ready'),
|
||||
('root-11-m01-a02-knowledge-check', 'root-11-m01-devops-platform-thinking', 2, 'knowledge_check', 'Knowledge Check: DevOps & Platform Thinking',
|
||||
'content/root/root-11-devops-platform-engineering/m01-devops-platform-thinking/02-knowledge-check.md', NULL, NULL, 'manual_marker', 5, 'ready'),
|
||||
('root-11-m01-a03-reflection', 'root-11-m01-devops-platform-thinking', 3, 'reflection', 'Reflection: Where You Already Practiced This',
|
||||
'content/root/root-11-devops-platform-engineering/m01-devops-platform-thinking/03-reflection.md', NULL, NULL, 'manual_marker', 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
-- Section 13/14 — readiness-probe-task was an unwired scenario (a constructive "author a
|
||||
-- readiness probe" Lab) that doesn't duplicate root-10-m01's existing
|
||||
-- rootnode-probe-port-mismatch Lab (a diagnostic "find the misconfigured probe" task). Wired in
|
||||
-- as OPTIONAL practice using the existing is_required model (Phase 6B) rather than a second
|
||||
-- practice system — students who want more probe-authoring reps can do it without it blocking
|
||||
-- required progression.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
UPDATE module_activities SET sequence = 7, version = version + 1, updated_at = now() WHERE activity_id = 'root-10-m01-a06-reflection-real-ops-takeaway';
|
||||
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, is_required, estimated_duration_minutes, status) VALUES
|
||||
('root-10-m01-a05b-lab-readiness-probe-optional', 'root-10-m01-health-probes-and-troubleshooting', 6, 'lab', 'Optional Practice: Configure a Readiness Probe',
|
||||
NULL, 'readiness-probe-task', NULL, 'auto_scenario', false, 10, 'ready')
|
||||
ON CONFLICT (activity_id) DO NOTHING;
|
||||
@@ -83,12 +83,15 @@ async function main() {
|
||||
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 () => {
|
||||
await test('the gold-standard module is fully READY with 7 ordered activities and real content', async () => {
|
||||
// Phase 6C added one OPTIONAL practice lab (readiness-probe-task) after the original
|
||||
// required lab — see docs/PHASE6C_CURRICULUM_COMPLETION.md. The required teaching chain
|
||||
// (lesson/demo/kc/guided/lab/reflection) is otherwise byte-for-byte unchanged.
|
||||
const goldModule = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(goldModule.status, 'ready');
|
||||
assert.equal(goldModule.activities.length, 6);
|
||||
assert.equal(goldModule.activities.length, 7);
|
||||
const types = goldModule.activities.map((a) => a.activity_type);
|
||||
assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'reflection']);
|
||||
assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', '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');
|
||||
@@ -149,6 +152,13 @@ async function main() {
|
||||
const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
// Phase 6C added a bookending lesson + reflection around root-05-m01's 20-task kubectl-
|
||||
// fluency bank (see docs/PHASE6C_CURRICULUM_COMPLETION.md) — both are required manual
|
||||
// markers, so root-05 isn't complete until they're marked too.
|
||||
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
|
||||
const { status } = await casey.request('POST', `/api/student/activities/root-05-m01-${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);
|
||||
@@ -187,8 +197,8 @@ async function main() {
|
||||
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);
|
||||
assert.equal(goldModule.complete, true, 'a module is complete once every REQUIRED activity is done, regardless of the one optional lab left untouched');
|
||||
assert.equal(goldModule.percent_complete, Math.round((6 / 7) * 100), 'percent_complete reflects ALL activities, optional included — Phase 6C added one optional lab');
|
||||
// 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.
|
||||
@@ -301,7 +311,7 @@ async function main() {
|
||||
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.equal(goldModule.activities.length, 7, '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');
|
||||
});
|
||||
|
||||
|
||||
@@ -90,10 +90,11 @@ async function main() {
|
||||
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 () => {
|
||||
await test('the gold-standard Health Probes module\'s required chain is unchanged (Phase 6C added one optional lab)', async () => {
|
||||
const gold = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(gold.status, 'ready');
|
||||
assert.equal(gold.activities.length, 6);
|
||||
assert.equal(gold.activities.length, 7);
|
||||
assert.equal(gold.activities.filter((a) => a.is_required !== false).length, 6, 'the original 6 required activities must be untouched');
|
||||
});
|
||||
|
||||
await test('activity ordering within every new module is strictly ascending', async () => {
|
||||
@@ -144,6 +145,11 @@ async function main() {
|
||||
|
||||
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);
|
||||
// Phase 6C added a bookending lesson + reflection around m01's 20-task bank — both required
|
||||
// manual markers, so m01 (and therefore the ROOT 05 course) isn't complete without them.
|
||||
for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) {
|
||||
await casey.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 6C — curriculum completion, assessment bank, and ROOT 11 scope acceptance test. Covers
|
||||
// what THIS phase actually changed: the six upgraded PARTIAL modules, the new security
|
||||
// assessment bank, the ROOT 11 orientation module, and the one newly-wired optional-practice
|
||||
// activity — not re-proving the underlying completion/locking/RBAC engine from scratch (already
|
||||
// proven exhaustively in e2e-phase6a.js and re-proven against new content in e2e-phase6b.js).
|
||||
// Same conventions: assert-based, no framework, live docker-compose.dev.yml stack, no real Lab
|
||||
// container launched here (infra/scripts/drills/phase6c-security-config-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-phase6c.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';
|
||||
|
||||
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 newStudent(owner, tag) {
|
||||
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6C-${tag}-${RUN_ID}`, login_identifier: `e2e6c-${tag}-${RUN_ID}` });
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: `${tag}Password123!` });
|
||||
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
||||
return { session: s, studentId: create.student_id, enrollmentId: enroll.enrollment_id };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 6C curriculum completion / assessment bank / ROOT 11 acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
let structure;
|
||||
|
||||
// ── 1: course tree correctness — every module Phase 6C touched, in the expected order ────
|
||||
await test('every module Phase 6C upgraded exists with the expected full activity-type sequence', async () => {
|
||||
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
assert.equal(status, 200);
|
||||
structure = body;
|
||||
const expected = {
|
||||
'root-06-m03-jobs-cronjobs-daemonsets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
||||
'root-07-m04-ingress-networkpolicy-foundations': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
||||
'root-07-m02-configuration-and-secrets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'lab', 'lab', 'lab', 'reflection'],
|
||||
'root-10-m03-troubleshooting-framework': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
||||
'root-05-m01-pods-and-kubectl-basics': ['lesson', ...Array(20).fill('lab'), 'reflection'],
|
||||
'root-11-m01-devops-platform-thinking': ['lesson', 'knowledge_check', 'reflection'],
|
||||
};
|
||||
for (const [modId, types] of Object.entries(expected)) {
|
||||
const mod = moduleById(structure, modId);
|
||||
assert.ok(mod, `${modId} must exist`);
|
||||
assert.deepEqual(mod.activities.map((a) => a.activity_type), types, `${modId} activity chain mismatch`);
|
||||
assert.equal(mod.status, modId === 'root-11-m01-devops-platform-thinking' ? 'partial' : 'ready', `${modId} status mismatch`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('root-09-m02/m03 kept every original lab activity_id and scenario_id while gaining teaching activities', async () => {
|
||||
const m02 = moduleById(structure, 'root-09-m02-serviceaccounts-and-rbac');
|
||||
const labs = m02.activities.filter((a) => a.activity_type === 'lab').map((a) => a.activity_id);
|
||||
assert.deepEqual(labs, ['root-09-m02-a03-lab-serviceaccount-pod', 'root-09-m02-a04-lab-automount-token', 'root-09-m02-a05-lab-rbac-role', 'root-09-m02-a06-lab-rbac-clusterrole', 'root-09-m02-a07-lab-rbac-least-privilege', 'root-09-m02-a08-lab-rbac-clusterrole-independent']);
|
||||
});
|
||||
|
||||
await test('the three documented-exception modules were promoted to READY with no new activities added', async () => {
|
||||
assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').activities.length, 6, 'real-ops incidents must stay at 6 — no KC/guided added by design');
|
||||
assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').activities.length, 1, 'capstone module IS the exam, no bookending added');
|
||||
assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').status, 'ready');
|
||||
assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').status, 'ready');
|
||||
});
|
||||
|
||||
await test('the survey-depth security module (root-09-m04) is READY and carries the new assessment activity last', async () => {
|
||||
const mod = moduleById(structure, 'root-09-m04-cluster-hardening-and-runtime');
|
||||
assert.equal(mod.status, 'ready');
|
||||
const last = mod.activities[mod.activities.length - 1];
|
||||
assert.equal(last.activity_type, 'assessment');
|
||||
assert.equal(last.assessment_definition_id, 'rootnode-security-assessment');
|
||||
});
|
||||
|
||||
// ── 3/18: required/optional — the newly-wired optional readiness-probe lab ───────────────
|
||||
await test('the new optional readiness-probe lab does not block root-10-m01 completion when skipped', async () => {
|
||||
const student = await newStudent(owner, 'OptSkip');
|
||||
// Complete only the REQUIRED activities in root-10-m01, deliberately skipping the optional lab.
|
||||
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 student.session.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
await completeScenario(student.session, 'rootnode-probe-port-mismatch');
|
||||
await student.session.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
||||
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
const mod = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
||||
assert.equal(mod.complete, true, 'module must be complete without the optional lab');
|
||||
const optionalActivity = findActivity(body.courses, 'root-10-m01-a05b-lab-readiness-probe-optional');
|
||||
assert.equal(optionalActivity.is_required, false);
|
||||
assert.equal(optionalActivity.complete, false, 'the optional lab itself is correctly still incomplete');
|
||||
});
|
||||
|
||||
// ── 4/12: new module activity chains + no fake completion from viewing ──────────────────
|
||||
await test('viewing the course tree never marks a lesson complete on its own (no fake completion)', async () => {
|
||||
const student = await newStudent(owner, 'NoFake');
|
||||
await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
const mod = moduleById(body.courses, 'root-05-m01-pods-and-kubectl-basics');
|
||||
assert.equal(mod.complete, false);
|
||||
assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, false);
|
||||
});
|
||||
|
||||
// ── 14: idempotency — completing the same activity twice never errors or double-counts ──
|
||||
await test('completing the same activity twice is idempotent', async () => {
|
||||
const student = await newStudent(owner, 'Idem');
|
||||
const first = await student.session.request('POST', '/api/student/activities/root-05-m01-a00-lesson-kubectl-fluency/complete');
|
||||
assert.equal(first.status, 200);
|
||||
const second = await student.session.request('POST', '/api/student/activities/root-05-m01-a00-lesson-kubectl-fluency/complete');
|
||||
assert.ok([200, 409].includes(second.status), `second completion should 200 or 409, got ${second.status}`);
|
||||
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, true);
|
||||
});
|
||||
|
||||
// ── 6/17: instructor notes exclusion + RBAC on NEW Phase 6C content ──────────────────────
|
||||
await test('instructor notes on new Phase 6C content are visible to instructors and never to students', async () => {
|
||||
const lesson = findActivity(structure, 'root-07-m02-a00-lesson-configuration');
|
||||
assert.ok(lesson.instructor_notes && lesson.instructor_notes.length > 0);
|
||||
const student = await newStudent(owner, 'NotesCheck');
|
||||
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
const studentLesson = findActivity(body.courses, 'root-07-m02-a00-lesson-configuration');
|
||||
assert.equal(studentLesson.instructor_notes, undefined);
|
||||
});
|
||||
|
||||
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6c-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor 6C', role: 'instructor', password: 'StrayInstructorPass6C!' });
|
||||
const stray = makeSession(GW);
|
||||
await stray.request('POST', '/api/auth/instructor/login', { email: `e2e6c-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass6C!' });
|
||||
await test('a plain instructor with no relationship to the student cannot see their Phase 6C module progress', async () => {
|
||||
const student = await newStudent(owner, 'RbacCheck');
|
||||
const { status } = await stray.request('GET', `/api/admin/students/${student.studentId}/courses?program_id=${PROGRAM}`);
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
|
||||
// ── 7/19: assessment bank — definition, server-authoritative draw, cross-student isolation ─
|
||||
await test('the security assessment pool_spec matches the documented design (3 required + 6 random)', async () => {
|
||||
const spec = JSON.parse(psql(`SELECT pool_spec FROM assessment_definitions WHERE assessment_definition_id = 'rootnode-security-assessment'`));
|
||||
assert.deepEqual(spec.required, ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']);
|
||||
const randomCount = spec.random.reduce((sum, g) => sum + g.count, 0);
|
||||
assert.equal(randomCount, 6);
|
||||
});
|
||||
|
||||
await test('two independent students each get their own 9-scenario draw, and it is server-authoritative', async () => {
|
||||
const alice = await newStudent(owner, 'SecAliceA');
|
||||
const bob = await newStudent(owner, 'SecBobA');
|
||||
const aStart = await alice.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
||||
const bStart = await bob.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
||||
assert.equal(aStart.status, 201);
|
||||
assert.equal(bStart.status, 201);
|
||||
assert.equal(aStart.body.scenarios.length, 9);
|
||||
assert.equal(bStart.body.scenarios.length, 9);
|
||||
for (const s of ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']) {
|
||||
assert.ok(aStart.body.scenarios.some((x) => x.scenario_id === s), `alice draw missing required scenario ${s}`);
|
||||
}
|
||||
assert.notEqual(aStart.body.assessment_session_id, bStart.body.assessment_session_id, 'each student must get an independent session');
|
||||
// A student cannot read another student's assessment session (cross-student isolation).
|
||||
const crossRead = await bob.session.request('GET', `/api/assessments/${aStart.body.assessment_session_id}`);
|
||||
assert.ok([403, 404].includes(crossRead.status), `bob must not be able to read alice's session, got ${crossRead.status}`);
|
||||
});
|
||||
|
||||
await test('completing the security assessment marks its activity complete in the course tree', async () => {
|
||||
const student = await newStudent(owner, 'SecComplete');
|
||||
const { body: start } = await student.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
||||
for (const s of start.scenarios) await completeScenario(student.session, s.scenario_id);
|
||||
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
assert.equal(findActivity(body.courses, 'root-09-m04-a06-assessment-security').complete, true);
|
||||
});
|
||||
|
||||
// ── 10: capstone gate — confirm the known architectural fact (ROOT 08/09 don't gate it) ──
|
||||
await test('the capstone unlock gate is unaffected by ROOT 09 (security) being incomplete — documented architecture, not a bug', async () => {
|
||||
const student = await newStudent(owner, 'CapstoneGate');
|
||||
const { status, body } = await student.session.request('POST', '/api/assessments/rootnode-capstone/start');
|
||||
assert.equal(status, 403);
|
||||
assert.equal(body.error, 'locked');
|
||||
assert.ok(!body.blocking_stage_ids.some((id) => String(id).includes('security')), 'security must never appear as a capstone-blocking stage');
|
||||
});
|
||||
|
||||
// ── 11: ROOT 11 renders honestly per its actual partial/lesson-only status ───────────────
|
||||
await test('ROOT 11 renders as partial with a lesson-only module and no fabricated Lab activity', async () => {
|
||||
const course = courseById(structure, 'root-11-devops-platform-engineering');
|
||||
assert.equal(course.status, 'partial');
|
||||
const mod = moduleById(structure, 'root-11-m01-devops-platform-thinking');
|
||||
assert.equal(mod.status, 'partial');
|
||||
assert.ok(!mod.activities.some((a) => a.activity_type === 'lab'), 'ROOT 11 must not present a fake hands-on lab');
|
||||
});
|
||||
|
||||
await test('a student can complete ROOT 11 module 1 end-to-end (lesson-only path)', async () => {
|
||||
const student = await newStudent(owner, 'Root11');
|
||||
for (const suffix of ['a01-lesson-devops-platform-thinking', 'a02-knowledge-check', 'a03-reflection']) {
|
||||
const { status } = await student.session.request('POST', `/api/student/activities/root-11-m01-${suffix}/complete`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
||||
assert.equal(moduleById(body.courses, 'root-11-m01-devops-platform-thinking').complete, true);
|
||||
});
|
||||
|
||||
// ── 16: version snapshot integrity on a NEW Phase 6C module ──────────────────────────────
|
||||
await test('bumping a Phase 6C-upgraded module\'s version does not drop its content', async () => {
|
||||
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-07-m02-configuration-and-secrets'`);
|
||||
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
||||
const mod = moduleById(body, 'root-07-m02-configuration-and-secrets');
|
||||
assert.equal(mod.activities.length, 10);
|
||||
assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300);
|
||||
});
|
||||
|
||||
// ── 24: content validation script itself passes as part of this suite ───────────────────
|
||||
await test('infra/scripts/validate-course-content.js reports zero errors against the current database', () => {
|
||||
execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
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