4 Commits
Author SHA1 Message Date
ced4568andClaude Sonnet 5 bd0a10fbfb docs(phase6c): pilot readiness standard, pilot curriculum manifest, ROOT 11 scope
Adds the four required Phase 6C documents:

- ROOT_PILOT_READINESS_STANDARD.md — the explicit 8-point sufficiency bar
  used to judge every module this phase, replacing any fixed activity-count
  assumption.
- ROOT11_DEVOPS_PLATFORM_SCOPE.md — evaluates all 7 proposed ROOT 11 modules
  individually (READY TO AUTHOR NOW / PARTIAL-LESSON-ONLY / REQUIRES FUTURE
  LAB ENVIRONMENT), with what current infra supports vs. what GitOps/CI-CD/
  IaC/observability would each require.
- ROOT_PILOT_CURRICULUM.md — the authoritative pilot manifest: instructor-led
  as the first pilot's delivery mode, honest READY/PARTIAL/DEFERRED/NOT IN
  PILOT status per course and module, and what is intentionally excluded
  (ROOT 01-04 never authored, ROOT 08 non-capstone-gating and deferred, ROOT
  11 modules 2-7, three practice-bank modules instructor-led-only this pilot).
- PHASE6C_CURRICULUM_COMPLETION.md — the phase's own record of what was
  upgraded, why, the bug found and fixed, and the pilot readiness conclusion.

Updates ROOT_CURRICULUM_COVERAGE_MATRIX.md with the verified before/after
module counts (21->22 modules, 6->16 READY) and per-module upgrade rationale,
and ROOT_CAPSTONE_READINESS.md with a re-review confirming the unlock gate's
6 stages, and ROOT 08/09's non-gating status, are unchanged by this phase's
curriculum expansion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 11:21:09 -05:00
ced4568andClaude Sonnet 5 01d3764138 feat(frontend): visibility note for standalone assessment-type activities
CourseView.jsx already special-cased 'capstone' activities with a pointer to
the Capstone tab (since only capstone-type activities existed before this
phase). Phase 6C's new rootnode-security-assessment introduces the first
non-capstone 'assessment'-type module activity — without a similar note, a
student would see it in the course tree with no way to discover it needs to
be started from the Assessments tab. Adds the same pattern for 'assessment'
type, naming the assessment by its own title.

No other frontend change was needed: CurriculumView.jsx (instructor view)
already renders activity_type labels, scenario_id/assessment_definition_id,
is_required, instructor_notes availability, and READY/PARTIAL/PLANNED status
pills generically — Phase 6C's new module/activity shapes render correctly
under Phase 6B's existing generic logic, verified by inspection.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 11:20:51 -05:00
ced4568andClaude Sonnet 5 903b0ead43 test(phase6c): acceptance suite, real-k3s rehearsal, and 6A/6B staleness fixes
Adds platform/test/e2e-phase6c.js (17 checks): upgraded-module activity
chains, documented-exception promotions, optional-lab non-blocking behavior,
no-fake-completion-from-viewing, idempotent completion, instructor-notes
exclusion and RBAC on new content, security-assessment pool/draw/cross-student
isolation/completion, the capstone gate's documented indifference to ROOT 09,
ROOT 11's honest partial/lesson-only rendering, and version-bump content
stability — plus running validate-course-content.js as part of the suite.
All 17 pass against both a warm and a freshly-reset dev stack.

Adds infra/scripts/drills/{run,test}-phase6c-security-config-rehearsal —
Section 22's real-k3s dress rehearsal: login -> program -> course -> lesson ->
demonstration -> knowledge-check -> guided-exercise -> real Lab -> real
kubectl (create Secret, apply Pod, wait for Ready) -> validation -> progress
persistence across logout/login -> instructor visibility of the resulting
learning state, walking root-07-m02-configuration-and-secrets (upgraded from
PARTIAL this phase). 10/10 pass against a real k3s Lab, no mocking.

Fixes two stale hardcoded assumptions in e2e-phase6a.js and e2e-phase6b.js
that Phase 6C's own legitimate content changes broke: the gold module
(root-10-m01) now has 7 activities (6 required + 1 new optional lab, not 6),
and root-05-m01 now has 2 required manual-marker activities the course-
completion path must also complete (its new bookending lesson/reflection).
Both suites are back to green (21/21 and 24/24) against a fresh stack.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 11:20:37 -05:00
ced4568andClaude Sonnet 5 7f6e1857c0 feat(curriculum): Phase 6C module upgrades, security assessment bank, ROOT 11 orientation
Upgrades six PARTIAL modules to the gold-module standard (lesson/demonstration/
knowledge-check/guided-exercise/lab(s)/reflection), preserving every existing lab
activity_id and scenario_id: root-06-m03 (jobs/cronjobs/daemonsets), root-07-m04
(ingress/networkpolicy), root-07-m02 (configuration/secrets, converted in place
from a bare 5-lab bank), root-09-m02 (serviceaccounts/rbac), root-09-m03
(network/supply-chain security), root-10-m03 (troubleshooting framework).

root-05-m01 (20-task kubectl-fluency bank) gets a minimal bookending lesson +
reflection rather than a full redesign, since each task already carries its own
explanation. root-12-m01, root-capstone-m01, and root-09-m04 are promoted to
READY via documented exceptions (no new activities) where adding one would
misrepresent their intentional design.

Wires a new rootnode-security-assessment (exam, 60min, hint_policy disabled)
using the existing Phase 3B assessment engine: 3 required hands-on CORE
scenarios + 4 random-draw pools covering SecurityContext, RBAC, network
policy, and the 7 previously-unwired CKS MCQs. Wires the previously-unwired
readiness-probe-task scenario as an OPTIONAL practice lab in root-10-m01,
using the existing is_required model.

Adds root-11-m01-devops-platform-thinking — an honest, lesson-only ROOT 11
orientation module naming DevOps/platform engineering practices already
exercised in ROOT 05-10/12, explicit that GitOps/CI-CD/IaC/observability need
lab infrastructure this platform doesn't yet provide. No fake labs.

Adds infra/scripts/validate-course-content.js: cross-checks the DB against
scenario/content files for duplicate ids, orphans, invalid references, cycles,
sequence collisions, instructor-notes leakage, and (new) self-paced content
deferring explanation to a live instructor. 0 errors against the resulting
22 courses/modules, 163 activities, 98 scenario files.

No application code changed — every gap was satisfiable with existing tables
(course_modules, module_activities, assessment_definitions) and the existing
content-delivery pipeline.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 11:20:17 -05:00
38 changed files with 2698 additions and 12 deletions
@@ -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.
@@ -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.
@@ -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).
@@ -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.
@@ -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").
@@ -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).
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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).
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+150
View File
@@ -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.
+64
View File
@@ -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.
+26
View File
@@ -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.
+47 -4
View File
@@ -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)
+118
View File
@@ -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.
+83
View File
@@ -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.
+3
View File
@@ -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 &ldquo;{activity.title}&rdquo;.</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); });
+188
View File
@@ -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;
+16 -6
View File
@@ -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');
});
+8 -2
View File
@@ -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);
+243
View File
@@ -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); });