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]>
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
# Building kubectl Fluency: Why Twenty Small Reps Beat One Big Lecture
|
||||
|
||||
## What this module is, and why it looks different from the last one
|
||||
|
||||
ROOT 05's mental model module (m00) taught you the CONCEPT of desired state and gave you your
|
||||
first `kubectl` commands. This module does something deliberately different: it's a breadth
|
||||
drill across the everyday `kubectl` vocabulary you'll type dozens of times a day as a working
|
||||
Kubernetes engineer — labels, JSONPath, dry-run manifests, patch, field selectors, sort-by,
|
||||
diff, init containers, static Pods, and more.
|
||||
|
||||
There's no single narrative thread connecting these twenty tasks, and that's intentional. Real
|
||||
`kubectl` fluency isn't one big insight — it's having enough of these small, specific commands
|
||||
already in muscle memory that you're not stopping to look up syntax while you're mid-incident.
|
||||
Each task below explains the specific concept it covers in its own description; your job is to
|
||||
work through them, not to memorize a lecture first.
|
||||
|
||||
## How to approach this module
|
||||
|
||||
- **Don't try to do them in one sitting if you don't need to.** These are independent — skip
|
||||
around, come back to ones you found awkward, and don't feel obligated to treat this as one
|
||||
continuous session.
|
||||
- **Read each task's own explanation before reaching for the hint.** Every one of these
|
||||
twenty tasks includes real context in its description — that context IS the teaching content
|
||||
for that specific command. The hint is there for when you're stuck, not as the first thing to
|
||||
read.
|
||||
- **Notice which commands you reach for confidently versus which ones you have to think about.**
|
||||
That gap is exactly what the reflection at the end of this module asks you to name.
|
||||
- **This breadth matters more than it looks like it does.** `kubectl explain`, `--dry-run`,
|
||||
JSONPath output, and `kubectl patch` in particular are commands experienced engineers use
|
||||
constantly and newer engineers often don't know exist at all — this module exists specifically
|
||||
to close that gap early.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Lesson goal:** Frame this module correctly before students hit it — it's a breadth-practice
|
||||
bank, not a single-concept lesson, and the individual task descriptions carry the teaching
|
||||
content. Without this framing, students sometimes expect a lecture and are confused when they
|
||||
land directly on twenty short tasks.
|
||||
|
||||
**Approximate timing:** 5 minutes to introduce; the module itself runs long (20 tasks) and is
|
||||
meant to be worked through at each student's own pace, live or between sessions.
|
||||
|
||||
**Facilitation for a live cohort:** Consider live-demoing 2-3 of the less obvious ones
|
||||
(`kubectl explain`, JSONPath output, `kubectl patch`) rather than all twenty — the goal is to
|
||||
unblock students on the commands they're least likely to have seen before, not to walk through
|
||||
every task.
|
||||
|
||||
**Expected takeaway:** Kubernetes fluency is built from a large vocabulary of small, reliable
|
||||
commands, not just conceptual understanding — this module is where that vocabulary gets built.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Reflection: Naming Your Gaps While They're Still Cheap to Close
|
||||
|
||||
You just worked through twenty independent `kubectl` tasks. Before moving on, take a few
|
||||
minutes to consolidate what actually stuck versus what you'd need to look up again.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Which THREE of these twenty commands do you feel genuinely confident using again right now,
|
||||
without a hint? Name them specifically.
|
||||
2. Which ONE command surprised you the most — either because you didn't know it existed, or
|
||||
because it did something different than you expected?
|
||||
3. `kubectl explain`, `kubectl patch`, and JSONPath output are commands a lot of engineers never
|
||||
learn early and end up rediscovering the hard way, mid-incident, years later. Now that you've
|
||||
used all three, where do you think you'd actually reach for one of them in a real debugging
|
||||
situation?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Fluency gaps are cheap to close now and expensive to close later.** Right now, not knowing
|
||||
`kubectl patch` costs you a few minutes rereading this module. In a live incident six months
|
||||
from now, not knowing it costs you minutes you don't have while a Service is down. Naming your
|
||||
gaps here, while the stakes are zero, is how you make sure you close them before they matter.
|
||||
|
||||
**Breadth practice is not busywork.** It's easy to dismiss twenty small tasks as less valuable
|
||||
than one big project — but the twenty small tasks are what make the big project fast to execute
|
||||
later. This is the same discipline you'll rely on for the rest of ROOT_: build the vocabulary
|
||||
now, apply it under pressure later.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 3 works well as a round-robin — go
|
||||
person to person and have each name a different command, so the group collectively surfaces
|
||||
most of the twenty rather than converging on the same one or two obvious answers.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Expected takeaway:** Students should leave this module able to name their own remaining gaps
|
||||
specifically, not just feel generally "done" with kubectl basics.
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Demonstration: Watching a Job Actually Complete
|
||||
|
||||
## Creating the Job
|
||||
|
||||
```
|
||||
$ kubectl apply -f pi-job.yaml
|
||||
job.batch/pi-job created
|
||||
|
||||
$ kubectl get jobs
|
||||
NAME COMPLETIONS DURATION AGE
|
||||
pi-job 0/1 3s 3s
|
||||
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
pi-job-8x2kq 1/1 Running 0 3s
|
||||
```
|
||||
|
||||
The Job's own status (`0/1` completions) is tracked separately from its Pod's status — this
|
||||
matters because a Job can retry a FAILED Pod attempt without that looking like the healthy
|
||||
restart behavior you'd see on a Deployment.
|
||||
|
||||
## Once the Pod's process actually exits successfully
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
pi-job-8x2kq 0/1 Completed 0 8s
|
||||
|
||||
$ kubectl get jobs
|
||||
NAME COMPLETIONS DURATION AGE
|
||||
pi-job 1/1 7s 8s
|
||||
```
|
||||
|
||||
`STATUS: Completed` — not `Running`, not `CrashLoopBackOff`. This is the ONE status a
|
||||
long-running Deployment's Pod is never supposed to reach on its own; for a Job, it's success.
|
||||
The Job object itself now reports `1/1` and stops — nothing recreates this Pod, because
|
||||
"finished successfully" is the desired end state here, unlike a Deployment where exiting is
|
||||
always treated as something to fix.
|
||||
|
||||
## What a FAILING Job attempt looks like, briefly
|
||||
|
||||
```
|
||||
$ kubectl get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
bad-job-abc12 0/1 Error 0 5s
|
||||
bad-job-def34 0/1 Error 0 2s
|
||||
|
||||
$ kubectl describe job bad-job
|
||||
...
|
||||
Events:
|
||||
Warning BackoffLimitExceeded Job has reached the specified backoff limit
|
||||
```
|
||||
|
||||
Two DIFFERENT Pods (`abc12`, `def34`), not one Pod restarting twice — each failed attempt gets
|
||||
its own new Pod, up to `backoffLimit`. Once that limit is hit, the Job stops retrying and
|
||||
reports the failure via Events rather than trying forever.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run the successful case live; showing the FAILING case is optional if time is
|
||||
short, but the `Completed` vs `Running`/`Error` distinction is worth pausing on regardless.
|
||||
|
||||
**Approximate timing:** 8 minutes live; 5 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students who've only seen Deployments sometimes expect
|
||||
`kubectl get pods` to show the Job's Pod disappear once it's done. It doesn't, by default — it
|
||||
stays as `Completed` until manually cleaned up or a TTL is configured, which is worth mentioning
|
||||
briefly (not a hands-on topic here).
|
||||
|
||||
**Optional extension:** ask what `kubectl get pods` would show for a CronJob's Pods across
|
||||
several scheduled runs (answer: one Completed Pod per past run, accumulating — the same
|
||||
"nothing auto-cleans this up" behavior, multiplied by the schedule).
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Guided Exercise: Choosing the Right Workload Primitive
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
Your team needs four things running in the cluster:
|
||||
|
||||
1. A nightly database backup that must run at 2am and finish before morning.
|
||||
2. A log-shipping agent that must run on every node, including nodes added later.
|
||||
3. A one-time script to backfill a new database column, run once during this deploy.
|
||||
4. The main web application, always up, serving live traffic.
|
||||
|
||||
## Your task
|
||||
|
||||
For each of the four, name the correct primitive (Deployment, Job, CronJob, or DaemonSet) and
|
||||
answer: what would go wrong if you used a Deployment for it instead?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. **CronJob.** A Deployment has no concept of "run on a schedule" — you'd need external
|
||||
tooling to trigger it repeatedly, reinventing what CronJob already does. Using a bare Job
|
||||
would run it once; CronJob is what recreates that Job on schedule.
|
||||
2. **DaemonSet.** A Deployment's replica count is a number, not "one per node" — nothing
|
||||
guarantees even coverage, and a newly added node wouldn't automatically get a copy the way a
|
||||
DaemonSet guarantees.
|
||||
3. **Job.** A Deployment expects its Pod to run forever; a script that's SUPPOSED to exit when
|
||||
done would be treated as a crash and restarted forever, which is wrong for a one-time task.
|
||||
4. **Deployment.** This is the actual always-on, N-replicas case the other three are explicitly
|
||||
NOT designed for.
|
||||
|
||||
## Self-check
|
||||
|
||||
If you answered "Job" for #4 or "Deployment" for #1-3, revisit the lesson's decision table
|
||||
before the Labs — getting this choice right the first time avoids building something that
|
||||
technically runs but behaves wrong in a way that's easy to miss until it causes a real problem
|
||||
(a backup CronJob accidentally deployed as a Job that only ever runs once, for example).
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Good as a quick individual exercise before revealing the answer — the "what
|
||||
would go wrong" framing forces reasoning about the CONSEQUENCE of a wrong choice, not just
|
||||
recall of a definition.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live with discussion; 8 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would need to be true for #1 (the nightly backup) to actually
|
||||
be SAFE to run as a plain Job triggered manually once, instead of a CronJob — a good check that
|
||||
students understand CronJob's value is specifically the SCHEDULING, not something inherent to
|
||||
backups.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Reflection: The Right Primitive Is a Correctness Decision, Not a Style Choice
|
||||
|
||||
You just built a one-shot Job and a scheduled CronJob for real. Take a few minutes to connect
|
||||
this to production thinking.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. If your team accidentally deployed the database backup script as a Deployment instead of a
|
||||
CronJob, what's the FIRST symptom you'd likely notice — and would it look like a bug in the
|
||||
backup script itself, or something else?
|
||||
2. A CronJob that fails silently every night for a week is a real, common incident pattern. What
|
||||
would you want monitored to catch this quickly?
|
||||
3. Why does "it ran once and worked" not prove a CronJob is configured correctly?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Workload-primitive mistakes often look like application bugs at first.** A backup script
|
||||
"crashing" every few minutes on a Deployment isn't a bug in the script — the script did exactly
|
||||
what it was told to do (exit when finished) and the Deployment is "correctly" restarting it
|
||||
according to its own contract. Recognizing "wrong primitive," not "buggy code," as the root
|
||||
cause saves real debugging time.
|
||||
|
||||
**A schedule you never verify is a schedule you can't trust.** A CronJob failing every single
|
||||
run since deployment is easy to miss if nobody checks — unlike a Deployment's crash-looping
|
||||
Pods, which are visible in `kubectl get pods` by default, a CronJob's failed history requires
|
||||
someone to actually look at `kubectl get jobs` or the CronJob's own status. Real teams monitor
|
||||
CronJob success/failure explicitly rather than assuming silence means success.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 1 is worth pushing on — many students'
|
||||
first instinct is to debug the "crashing" script rather than question the workload type it's
|
||||
running under.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to name one alerting rule they'd add for a critical nightly
|
||||
CronJob (e.g. "alert if no successful completion recorded in the last 25 hours").
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
# Externalizing Configuration: ConfigMaps & Secrets
|
||||
|
||||
## The problem: hardcoded configuration doesn't scale
|
||||
|
||||
If your application's database URL, log level, or feature flags are baked directly into its
|
||||
container image, changing any of them means rebuilding and redeploying the image — for a
|
||||
one-line config change. Worse, the SAME image can't be reused across dev/staging/production if
|
||||
each environment needs different settings compiled in.
|
||||
|
||||
Kubernetes solves this by letting you inject configuration into a container at RUNTIME,
|
||||
separately from the image itself — the same image, different config per environment.
|
||||
|
||||
## ConfigMap: configuration that isn't secret
|
||||
|
||||
A **ConfigMap** holds arbitrary key/value configuration data — log levels, feature flags, URLs,
|
||||
whole config files. It's a plain Kubernetes object, readable by anyone with API access to that
|
||||
Namespace, so it's for configuration that ISN'T sensitive.
|
||||
|
||||
Two ways to consume a ConfigMap in a Pod:
|
||||
|
||||
- **As environment variables** — each key becomes an env var the application reads normally.
|
||||
- **As a mounted volume** — each key becomes a FILE inside the container, useful for
|
||||
applications that expect to read configuration from disk rather than environment variables.
|
||||
|
||||
```yaml
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: app-env
|
||||
```
|
||||
|
||||
## Secret: the same idea, for sensitive data
|
||||
|
||||
A **Secret** has the identical shape and consumption pattern as a ConfigMap (env vars or a
|
||||
mounted volume) but is meant for sensitive values — passwords, API keys, tokens. Kubernetes
|
||||
stores Secret data base64-ENCODED, not encrypted — this is an important distinction:
|
||||
base64 is trivially reversible, so a Secret's real protection comes from Kubernetes' RBAC
|
||||
(who can even read Secret objects at all, ROOT 09) and, in a properly configured cluster,
|
||||
encryption at rest — not from the encoding itself.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: app-secret
|
||||
type: Opaque
|
||||
data:
|
||||
api-key: c3VwZXJzZWNyZXQ= # base64 of "supersecret" — NOT encrypted
|
||||
```
|
||||
|
||||
## Why the same mechanism, two different objects
|
||||
|
||||
You might ask why Kubernetes doesn't just have one "Config" object with a "sensitive" flag.
|
||||
Keeping them separate makes the DISTINCTION visible and enforceable — RBAC rules can grant
|
||||
"read ConfigMaps" without granting "read Secrets," and a quick `kubectl get configmaps` never
|
||||
accidentally exposes something that should have been a Secret. The separation is a deliberate
|
||||
safety boundary, not redundant API surface.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Approximate timing:** 15 minutes read; 10 minutes live.
|
||||
|
||||
**Demo plan:** Before the demonstration activity, it's worth showing `kubectl get secret
|
||||
app-secret -o yaml` live and pointing out the base64 value, then decoding it on the spot
|
||||
(`echo <value> | base64 -d`) — the "this isn't actually encrypted" realization lands much better
|
||||
seen than described.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- Students very commonly assume Secrets are encrypted by Kubernetes automatically. Correct this
|
||||
explicitly and early — it's a genuine, consequential security misunderstanding.
|
||||
- The env-var vs. volume-mount choice is sometimes seen as arbitrary. Note one real
|
||||
consideration: env vars are visible via `kubectl describe pod` and process inspection tools
|
||||
inside the container more easily than a mounted file's contents — not a hard rule, but a
|
||||
reason some teams prefer volume mounts specifically for Secrets.
|
||||
|
||||
**Discussion question:** "If someone with `kubectl get secrets` access on your namespace can
|
||||
trivially read every Secret's real value, what is a Secret actually protecting you from?"
|
||||
(Answer: it protects against accidental exposure through normal channels like
|
||||
`kubectl get configmaps`, source control, or logs — not against a determined actor with direct
|
||||
API read access, which is exactly why RBAC restricting who has that access is the real control.)
|
||||
|
||||
**Optional extension:** ask what changes about this picture on a cluster with encryption-at-rest
|
||||
enabled for Secrets (answer: the ETCD-stored data itself becomes encrypted, protecting against
|
||||
someone who gains access to the underlying etcd storage directly — still separate from and in
|
||||
addition to RBAC controlling API-level access).
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Demonstration: A Secret Is Encoded, Not Encrypted
|
||||
|
||||
## Creating a Secret
|
||||
|
||||
```
|
||||
$ kubectl create secret generic app-secret --from-literal=api-key=supersecret
|
||||
secret/app-secret created
|
||||
```
|
||||
|
||||
## Looking at it
|
||||
|
||||
```
|
||||
$ kubectl get secret app-secret -o yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: app-secret
|
||||
data:
|
||||
api-key: c3VwZXJzZWNyZXQ=
|
||||
type: Opaque
|
||||
```
|
||||
|
||||
## Decoding it — no special permission needed beyond reading the Secret itself
|
||||
|
||||
```
|
||||
$ echo "c3VwZXJzZWNyZXQ=" | base64 -d
|
||||
supersecret
|
||||
```
|
||||
|
||||
Anyone who can run `kubectl get secret app-secret -o yaml` (i.e. anyone RBAC allows to READ
|
||||
Secrets in this Namespace) can trivially recover the real value with a standard, built-in
|
||||
command — no cracking, no special tooling. This is exactly why the lesson's point matters: the
|
||||
protection here is WHO can read the Secret object at all (RBAC), not the base64 encoding itself.
|
||||
|
||||
## Comparing to a ConfigMap
|
||||
|
||||
```
|
||||
$ kubectl get configmap app-config -o yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
data:
|
||||
environment: production
|
||||
log_level: info
|
||||
```
|
||||
|
||||
A ConfigMap's values are stored in PLAIN TEXT — no encoding step at all. This is consistent with
|
||||
ConfigMaps being explicitly for non-sensitive data; there's no pretense of protection to begin
|
||||
with.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run this live, actually decoding the value in front of the class — the gap
|
||||
between "Secret" (a name implying protection) and what the command output shows is the entire
|
||||
point, and it lands far better performed than read.
|
||||
|
||||
**Approximate timing:** 6-8 minutes live; 5 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** this demonstration directly targets the single most common
|
||||
misconception about Kubernetes Secrets — do not skip it even if time is short, since a wrong
|
||||
belief here has real security consequences later.
|
||||
|
||||
**Optional extension:** ask what a `SealedSecret` or an external secrets manager (mentioned by
|
||||
name only, not built as a hands-on topic here) is trying to solve that a plain Kubernetes Secret
|
||||
does not — a good forward pointer without requiring new infrastructure in this course.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Knowledge Check: Configuration & Secrets
|
||||
|
||||
---
|
||||
|
||||
**1. A teammate says "our database password is safe because it's stored as a Kubernetes
|
||||
Secret." What's missing from that statement?**
|
||||
|
||||
*Answer:* A Secret's data is base64-ENCODED, not encrypted — anyone who can read the Secret
|
||||
object at all can trivially decode it. The real protection is RBAC (who is allowed to read
|
||||
Secrets in that namespace) and, on a properly configured cluster, encryption at rest — not the
|
||||
Secret object type itself.
|
||||
|
||||
---
|
||||
|
||||
**2. Why does Kubernetes have two separate objects (ConfigMap and Secret) instead of one
|
||||
"Config" object with a sensitivity flag?**
|
||||
|
||||
*Answer:* Keeping them separate makes the distinction enforceable via RBAC — a role can grant
|
||||
"read ConfigMaps" without granting "read Secrets" — and makes it visible at a glance
|
||||
(`kubectl get configmaps` never risks accidentally listing something sensitive).
|
||||
|
||||
---
|
||||
|
||||
**3. An application needs its configuration to change without rebuilding its container image
|
||||
across dev/staging/production. Which Kubernetes mechanism solves this, and why not just bake
|
||||
different configs into three separate images?**
|
||||
|
||||
*Answer:* ConfigMaps (and Secrets, for sensitive values) injected at runtime via env vars or a
|
||||
mounted volume. Baking config into the image means maintaining three separate images for what
|
||||
should be identical application code — externalizing configuration lets the SAME image run
|
||||
correctly in every environment.
|
||||
|
||||
---
|
||||
|
||||
**4. True or false: mounting a ConfigMap as a volume and consuming it as environment variables
|
||||
are functionally identical from the application's perspective.**
|
||||
|
||||
*Answer:* False. An application reading environment variables needs code that reads env vars; an
|
||||
application expecting a config FILE on disk needs the volume-mount approach. The right choice
|
||||
depends on how the application itself is written to read configuration, not just personal
|
||||
preference.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 1 is the single most important takeaway from this module — confirm
|
||||
it's genuinely understood, not just correctly guessed, before moving to the Labs.
|
||||
|
||||
**Approximate timing:** 5-7 minutes.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Guided Exercise: ConfigMap, Secret, Env Var, or Volume?
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
Your application needs four pieces of configuration:
|
||||
|
||||
1. A log level (`debug`/`info`/`warn`) that ops sometimes needs to flip without redeploying.
|
||||
2. A database password.
|
||||
3. A large JSON config file the application reads from a specific path at startup.
|
||||
4. A feature flag read once at container startup via an environment variable, per the
|
||||
application's existing code.
|
||||
|
||||
## Your task
|
||||
|
||||
For each, decide: ConfigMap or Secret, and env var or volume mount — then justify each choice.
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. **ConfigMap, env var.** Not sensitive; the application likely already reads it as an env var
|
||||
(a common convention), and env vars are simple to change and trigger a rolling restart with.
|
||||
2. **Secret, either — but env var is common here too**, since most applications expect
|
||||
credentials via environment variables by convention. The KEY decision is Secret (sensitive),
|
||||
not the delivery mechanism.
|
||||
3. **ConfigMap, volume mount.** The application expects a FILE at a path, not an environment
|
||||
variable — volume mount is the only mechanism that presents a ConfigMap as an actual file.
|
||||
4. **ConfigMap (assuming not sensitive) or Secret (if it is), env var** — the application's
|
||||
EXISTING code dictates this; you don't get to choose a mechanism the application wasn't
|
||||
written to read.
|
||||
|
||||
## Self-check
|
||||
|
||||
If you chose "volume mount" for #4 or "env var" for #3, revisit the lesson — the deciding
|
||||
factor here isn't which mechanism seems more "correct" in the abstract, it's what the
|
||||
application actually expects. Getting this backwards means the application simply won't find
|
||||
its configuration at all.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Emphasize that #4's answer depends entirely on EXISTING application
|
||||
behavior — a good moment to note that platform engineers frequently have to adapt Kubernetes
|
||||
config delivery to match an application's assumptions, not the reverse.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live with discussion; 8 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would happen if a ConfigMap consumed as a volume mount is
|
||||
UPDATED after the Pod is already running — a good bridge to the real (if not hands-on-tested
|
||||
here) behavior that mounted ConfigMap files update automatically after a short delay, while
|
||||
env-var-injected values do NOT update without a Pod restart.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Reflection: Configuration Is Infrastructure, Secrets Are a Trust Boundary
|
||||
|
||||
You just externalized configuration and handled a real Secret. Take a few minutes to connect
|
||||
this to production practice.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, would you have assumed a Kubernetes Secret was encrypted? What's the
|
||||
real-world consequence of a team believing that when it isn't true?
|
||||
2. If your team's RBAC accidentally granted a low-trust role `get secrets` access across an
|
||||
entire namespace, what's actually exposed — and would your team necessarily notice?
|
||||
3. Why does separating configuration from the container image matter beyond convenience — think
|
||||
about what it enables for testing or incident response.
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**A misunderstood Secret is a false sense of security.** Teams that believe Secrets are
|
||||
encrypted by default sometimes skip additional controls (proper RBAC scoping, external secrets
|
||||
management, encryption at rest) that they'd otherwise recognize as necessary. The gap between
|
||||
perceived and actual protection is exactly where real incidents happen.
|
||||
|
||||
**Externalized configuration is what makes rollback and incident response fast.** If a bad
|
||||
config value ships (a wrong feature flag, an incorrect log level flooding storage), fixing it
|
||||
means updating a ConfigMap/Secret and letting Pods pick up the change — not rebuilding and
|
||||
redeploying an entire image. This is a direct, practical payoff of the separation this module
|
||||
taught, not just an architectural nicety.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 works well as a small-group exercise —
|
||||
have students actually list what a namespace's Secrets typically contain in a real application
|
||||
(DB creds, API keys, TLS certs) to make the exposure concrete.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to draft a one-line policy statement their hypothetical
|
||||
team would adopt about who is allowed `get`/`list` access to Secrets in production namespaces.
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Demonstration: Tracing a Request Through Ingress
|
||||
|
||||
## The setup
|
||||
|
||||
An Ingress `web-ingress` routes `app.example.com/` to Service `web-svc`, which fronts a
|
||||
Deployment `web-app`. Nothing works yet from outside the cluster — let's see why, step by step.
|
||||
|
||||
## Step 1: is the Ingress object even doing anything?
|
||||
|
||||
```
|
||||
$ kubectl get ingress web-ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
web-ingress traefik app.example.com 80 2m
|
||||
```
|
||||
|
||||
`ADDRESS` is empty. On a real cluster, this column fills in once the Ingress controller has
|
||||
actually processed the object and assigned it a reachable address — an empty address after a
|
||||
couple of minutes is the first sign to check the controller itself, not your YAML.
|
||||
|
||||
## Step 2: is the controller running at all?
|
||||
|
||||
```
|
||||
$ kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
traefik-7d8f9c6b5d-abcde 1/1 Running 0 10d
|
||||
```
|
||||
|
||||
Running fine — so the ADDRESS delay in this case is transient, not a missing controller. (If
|
||||
this returned no Pods at all, THAT would be the actual root cause — no controller means no
|
||||
Ingress object ever gets acted on, exactly like the demonstration in ROOT 07's Ingress lesson
|
||||
described.)
|
||||
|
||||
## Step 3: once ADDRESS populates, does the route actually work?
|
||||
|
||||
```
|
||||
$ kubectl get ingress web-ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
web-ingress traefik app.example.com 10.0.1.50 80 3m
|
||||
|
||||
$ curl -H "Host: app.example.com" http://10.0.1.50/
|
||||
<html>...web-app's real response...</html>
|
||||
```
|
||||
|
||||
Success — but notice the `-H "Host: app.example.com"` header. Ingress routes by HOSTNAME, not
|
||||
just by IP — hitting the Ingress controller's address WITHOUT the matching Host header would
|
||||
route differently (or not at all), because that's literally what the Ingress rule is matching
|
||||
on.
|
||||
|
||||
## The general shape
|
||||
|
||||
Symptom (nothing reachable from outside) has at least three DIFFERENT possible layers: the
|
||||
Ingress object itself, the controller processing it, and the Service/Pod chain underneath it
|
||||
(already covered in ROOT 07's Services module). Check them in that order — Ingress ADDRESS,
|
||||
then controller health, then the underlying Service — rather than guessing at DNS or firewall
|
||||
issues first.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** If a real Ingress controller is available in the demo environment, run this
|
||||
live including the Host-header nuance — students are frequently surprised that `curl <ip>`
|
||||
alone doesn't work the way they expect.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students often expect an Ingress's IP/address to work like
|
||||
a Service's ClusterIP (reachable directly, no extra header needed). Reinforce that Ingress
|
||||
routing is fundamentally HOSTNAME/PATH-based, which is exactly what makes one Ingress able to
|
||||
route many different domains through one entry point.
|
||||
|
||||
**Optional extension:** ask what would happen if TWO Ingress objects both claimed the same host
|
||||
+ path — a good forward pointer to why Ingress configuration conflicts are a real operational
|
||||
concern on a shared cluster, without needing to build a hands-on lab around it here.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Guided Exercise: Planning Network Isolation
|
||||
|
||||
Paper-and-thinking exercise before the Lab.
|
||||
|
||||
## The scenario
|
||||
|
||||
A `database` Pod (label `app=database`) in the `netpol` namespace should only ever be reachable
|
||||
by Pods labeled `role=backend` — not the frontend, not anything else in the cluster.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Before writing any YAML: right now, with NO NetworkPolicy in place, can the frontend reach
|
||||
the database? Why?
|
||||
2. What would a NetworkPolicy that ONLY specifies `podSelector: { matchLabels: { app: database
|
||||
} }` and no `ingress` rules at all do to traffic reaching the database — including from
|
||||
`role=backend` Pods?
|
||||
3. Sketch (in words, not full YAML) what the `ingress` rule needs to say to allow ONLY
|
||||
`role=backend` traffic in.
|
||||
4. If a new Pod is later created with `role=backend` in a DIFFERENT namespace, would your policy
|
||||
as sketched in #3 allow it? What would need to change if cross-namespace access should be
|
||||
allowed?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. Yes — by default, every Pod can reach every other Pod regardless of labels or namespace
|
||||
(ROOT 07's Ingress & NetworkPolicy lesson). Nothing restricts this until a policy exists.
|
||||
2. It would block ALL incoming traffic to the database, including from `role=backend` — an
|
||||
empty `ingress` list under a policy that selects the Pod means "no traffic is allowed in,"
|
||||
not "no policy applies."
|
||||
3. An `ingress` rule with `from: [{ podSelector: { matchLabels: { role: backend } } }]` — this
|
||||
allows traffic specifically from Pods carrying that label, and nothing else.
|
||||
4. Not automatically — a bare `podSelector` in the `from` clause matches Pods in the SAME
|
||||
namespace as the policy by default. Allowing a different namespace requires adding a
|
||||
`namespaceSelector` alongside the `podSelector` in the same `from` entry.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 2 is the one most likely to trip students up — it directly reinforces
|
||||
the "empty ingress rules means deny-all, not no-op" point from the lesson, which is worth
|
||||
confirming actually landed before the Lab.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what a security-minded team would ALSO want to restrict about this
|
||||
database Pod's OWN outbound (egress) traffic, beyond who can reach it — a light forward pointer
|
||||
to ROOT 09's security-control framing of NetworkPolicy, without requiring students to build an
|
||||
egress rule here.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Reflection: Reachability Should Be a Decision, Not a Default
|
||||
|
||||
You just configured real Ingress routing and real network isolation. Take a few minutes to
|
||||
connect this to production thinking.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, would you have assumed a fresh Kubernetes cluster isolates Pods from
|
||||
each other by default? Has that changed?
|
||||
2. If your team runs a multi-tenant cluster (several unrelated applications sharing it), what's
|
||||
the risk of NEVER applying any NetworkPolicy at all?
|
||||
3. Ingress lets many different domains share one entry point. What's the operational benefit of
|
||||
that beyond just "fewer objects to manage"?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**"No NetworkPolicy" is not neutral — it's full reachability.** Exactly like an unhardened
|
||||
Pod's SecurityContext (ROOT 09 covers this in depth), the absence of a NetworkPolicy isn't a
|
||||
safe default; it's the MOST permissive configuration available. On any cluster where more than
|
||||
one team or application shares infrastructure, that's a real, often-overlooked exposure.
|
||||
|
||||
**Centralizing external entry points through Ingress reduces attack surface, not just object
|
||||
count.** Every separate LoadBalancer Service is a separate externally-reachable endpoint to
|
||||
secure, monitor, and patch. Routing through one (or a small number of) Ingress controllers means
|
||||
security controls — TLS termination, rate limiting, WAF rules — are configured and audited in
|
||||
one place instead of scattered across every application team's own Service definitions.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 is worth pushing on with a concrete
|
||||
scenario — ask what a compromised low-value application (a demo app, a staging environment)
|
||||
could reach on a cluster with zero NetworkPolicies, versus one with sensible defaults.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to sketch (in words) what a reasonable DEFAULT-DENY policy
|
||||
for an entire new namespace would look like, before any application-specific allow rules are
|
||||
added — previews the security-control framing ROOT 09 builds on.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Demonstration: Watching RBAC Actually Deny Something
|
||||
|
||||
## The setup
|
||||
|
||||
A ServiceAccount `readonly-bot` has a Role granting only `get`/`list` on `pods` — nothing else.
|
||||
|
||||
## What it CAN do
|
||||
|
||||
```
|
||||
$ kubectl auth can-i list pods --as=system:serviceaccount:default:readonly-bot
|
||||
yes
|
||||
|
||||
$ kubectl get pods --as=system:serviceaccount:default:readonly-bot
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
web-abc123 1/1 Running 0 2h
|
||||
```
|
||||
|
||||
## What it CANNOT do
|
||||
|
||||
```
|
||||
$ kubectl auth can-i delete pods --as=system:serviceaccount:default:readonly-bot
|
||||
no
|
||||
|
||||
$ kubectl delete pod web-abc123 --as=system:serviceaccount:default:readonly-bot
|
||||
Error from server (Forbidden): pods "web-abc123" is forbidden:
|
||||
User "system:serviceaccount:default:readonly-bot" cannot delete resource "pods"
|
||||
in API group "" in the namespace "default"
|
||||
```
|
||||
|
||||
This is what least privilege actually looks like in practice — not a policy document, a real,
|
||||
enforced denial with a specific, readable error naming exactly which permission was missing.
|
||||
`kubectl auth can-i` is your fastest way to CHECK a permission before attempting an action that
|
||||
might fail — useful both for debugging "why can't my workload do X" and for verifying a Role is
|
||||
actually as narrow as intended.
|
||||
|
||||
## What happens when the ServiceAccount tries something it was never even considered for
|
||||
|
||||
```
|
||||
$ kubectl auth can-i get secrets --as=system:serviceaccount:default:readonly-bot
|
||||
no
|
||||
```
|
||||
|
||||
Notice this returns `no` the same way `delete pods` did — RBAC has no concept of "not yet
|
||||
decided," only "explicitly allowed" or "everything else, denied." This is the practical meaning
|
||||
of default-deny applied to permissions: nothing is grey area.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run all three `kubectl auth can-i`/action pairs live — the FORBIDDEN error
|
||||
message's exact wording (naming the verb, resource, and namespace) is worth reading aloud, since
|
||||
that's exactly the evidence a student would use to debug a real permissions issue later.
|
||||
|
||||
**Approximate timing:** 8 minutes live; 5 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students sometimes think `kubectl auth can-i` REQUIRES the
|
||||
action to actually be attempted to know the answer. It doesn't — it's a dry-run permission
|
||||
check against the same RBAC rules, safe to run repeatedly without side effects.
|
||||
|
||||
**Optional extension:** ask what the error message would look like if `readonly-bot` tried to
|
||||
act on a DIFFERENT namespace than the one its Role/RoleBinding applies to — a good check that
|
||||
students understand Role scope is namespace-specific unless a ClusterRole+ClusterRoleBinding is
|
||||
used instead.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Guided Exercise: Writing a Least-Privilege Role
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
A monitoring agent needs to run inside your cluster. Its ONLY job is to periodically list Pods
|
||||
and read their status, across the whole cluster, to report health metrics elsewhere. It never
|
||||
needs to create, modify, or delete anything, and it never needs to touch Secrets, ConfigMaps, or
|
||||
any object type other than Pods.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Should this use a Role or a ClusterRole? Justify your answer based on scope.
|
||||
2. Which verbs does it actually need? Which common verbs (create, update, delete, patch) should
|
||||
be deliberately left OUT?
|
||||
3. Does it need access to Secrets? Why is it tempting to grant broader access "just in case,"
|
||||
and why should you resist that?
|
||||
4. What would you check, using `kubectl auth can-i`, to CONFIRM your Role is exactly as narrow
|
||||
as intended before deploying it?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. **ClusterRole** (bound via a ClusterRoleBinding) — it needs to see Pods across the WHOLE
|
||||
cluster, not just one namespace, so a namespace-scoped Role can't satisfy the requirement.
|
||||
2. **`get`, `list`, `watch`** on `pods` — read-only verbs only. Deliberately exclude `create`,
|
||||
`update`, `patch`, `delete` — this workload never legitimately needs to change anything.
|
||||
3. No — it never touches Secrets, so it should get zero permission on them. The temptation to
|
||||
"just in case" grant broader access is exactly what least privilege exists to resist: every
|
||||
unused permission is pure risk with no corresponding benefit if this workload is ever
|
||||
compromised.
|
||||
4. Run `kubectl auth can-i delete pods --as=<the ServiceAccount>` and confirm it returns `no` —
|
||||
and similarly check a Secrets verb returns `no` — proving the Role is bounded, not just
|
||||
assuming it from reading the YAML.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 3 is the core judgment call this exercise is testing — push on WHY
|
||||
"just in case" access is a real anti-pattern, not just point out that it is one.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would change about the answer if this monitoring agent later
|
||||
needed to also restart crash-looping Pods automatically — a good check that students can reason
|
||||
about incrementally expanding a Role's scope only as far as a genuinely new requirement demands.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Reflection: Permissions Should Reflect What a Workload Actually Needs
|
||||
|
||||
You just configured real ServiceAccounts and RBAC rules. Take a few minutes to connect this to
|
||||
production security practice.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Before this module, would you have thought twice about a workload's ServiceAccount having
|
||||
broad, unused permissions "just in case"? Has that changed?
|
||||
2. If a workload with cluster-admin-equivalent RBAC access is ever compromised (a vulnerable
|
||||
dependency, a supply-chain issue), what's the realistic blast radius compared to one scoped
|
||||
to exactly what it needs?
|
||||
3. How would you discover, on a real cluster you didn't build, whether any ServiceAccount has
|
||||
more access than it actually uses?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**RBAC over-permissioning is a silent, cumulative risk.** Unlike a misconfigured probe or a
|
||||
crashing Pod, an over-broad Role doesn't cause a visible symptom on a healthy day — it just sits
|
||||
there as unused risk until the one day a workload using it is compromised. This makes RBAC
|
||||
review a discipline that has to be practiced deliberately, not one that gets forced on you by
|
||||
an obvious failure.
|
||||
|
||||
**Least privilege is a default you set once, not a fix you apply after an incident.** Every
|
||||
example in this module started from "grant nothing, add exactly what's needed" rather than
|
||||
"grant broadly, restrict later" — the second approach almost never actually happens once
|
||||
something works, because there's no pressure forcing the cleanup.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 3 is a good one to leave partially open
|
||||
— acknowledge that real answers involve tooling beyond plain `kubectl` (RBAC audit tools) that
|
||||
this course doesn't build hands-on, while still reinforcing that the QUESTION itself is the
|
||||
right one to ask.
|
||||
|
||||
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to identify one ServiceAccount permission from earlier in
|
||||
ROOT_ (any Lab so far) that, in a real production system, they'd want to double-check was
|
||||
actually necessary.
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Demonstration: Default-Deny, Before and After
|
||||
|
||||
## Before: everything can reach everything
|
||||
|
||||
```
|
||||
$ kubectl run test-pod --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
<real response from backend>
|
||||
```
|
||||
|
||||
Any Pod in the namespace can reach `backend-svc`, regardless of what it is or who deployed it —
|
||||
this is the un-configured default the ROOT 07 lesson described, now made concrete from a
|
||||
security angle.
|
||||
|
||||
## Applying a default-deny policy
|
||||
|
||||
```
|
||||
$ kubectl apply -f default-deny.yaml
|
||||
networkpolicy.networking.k8s.io/default-deny created
|
||||
```
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: ["Ingress"]
|
||||
```
|
||||
|
||||
## After: the same request, now blocked
|
||||
|
||||
```
|
||||
$ kubectl run test-pod --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
wget: download timed out
|
||||
```
|
||||
|
||||
No error naming a permission (that's RBAC's job, a different layer) — just a timeout, because
|
||||
the packets are being dropped at the network layer before they ever reach the Pod. This is a
|
||||
DIFFERENT failure signature than an RBAC denial, worth being able to tell apart: RBAC denials
|
||||
are explicit and immediate; NetworkPolicy denials look like the destination is simply
|
||||
unreachable.
|
||||
|
||||
## Explicitly allowing only what's needed
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-frontend-to-backend
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels: { app: backend }
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels: { app: frontend }
|
||||
```
|
||||
|
||||
```
|
||||
$ kubectl run frontend-test --labels="app=frontend" --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
<real response from backend>
|
||||
|
||||
$ kubectl run other-test --labels="app=other" --image=busybox --rm -it -- wget -qO- --timeout=2 http://backend-svc
|
||||
wget: download timed out
|
||||
```
|
||||
|
||||
Same destination, same request — the ONLY difference is the caller's label. This is
|
||||
least-privilege network access made concrete: reachability now reflects an explicit decision,
|
||||
not an accident of both Pods existing in the same cluster.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Demo script:** Run all three states live if a real cluster is available — the timeout (not an
|
||||
error) for the denied case is worth calling out explicitly, since it looks identical to a
|
||||
genuinely broken/unreachable service from the caller's perspective.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
|
||||
|
||||
**Common misconception to preempt:** students sometimes expect a NetworkPolicy denial to
|
||||
produce a clear "forbidden" error like RBAC does. It doesn't — reinforce that a NetworkPolicy-
|
||||
caused timeout is genuinely hard to distinguish from an actual network/DNS problem without
|
||||
already suspecting NetworkPolicy, which is exactly why checking existing policies is a real
|
||||
diagnostic step worth knowing.
|
||||
|
||||
**Optional extension:** ask how a student would distinguish "blocked by NetworkPolicy" from
|
||||
"the backend Pod is actually down" purely from the caller's side (answer: check the backend
|
||||
Pod's own status/logs directly — if it's healthy and Ready, but still unreachable from one
|
||||
caller and reachable from another with a different label, NetworkPolicy is the likely cause).
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Guided Exercise: Planning Egress Restriction
|
||||
|
||||
Paper-and-thinking exercise before the Labs.
|
||||
|
||||
## The scenario
|
||||
|
||||
A `payment-processor` Pod handles sensitive transactions. It legitimately needs to reach:
|
||||
- an internal `payments-db` Service, and
|
||||
- one external payment gateway API.
|
||||
|
||||
It should NOT be able to reach anything else — including the public internet in general, other
|
||||
internal services, or any other namespace.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Why is restricting this Pod's OUTBOUND (egress) traffic just as important as restricting who
|
||||
can reach IT (ingress), given what this Pod handles?
|
||||
2. If this Pod were compromised via a vulnerable dependency, what would UNRESTRICTED egress let
|
||||
an attacker do that restricted egress would prevent?
|
||||
3. Sketch (in words) what an egress policy for this Pod needs to allow, and — just as
|
||||
important — what it must NOT allow by omission.
|
||||
4. Why might a security review flag a Pod with `policyTypes: ["Egress"]` but an EMPTY `egress`
|
||||
list as suspicious, rather than assume it's simply "not configured yet"?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. A Pod handling sensitive data that's compromised is far more dangerous if it can freely
|
||||
exfiltrate that data anywhere (unrestricted egress) than if its only reachable destinations
|
||||
are pre-approved and narrow.
|
||||
2. Unrestricted egress would let an attacker exfiltrate transaction data to an external server,
|
||||
or download additional malicious tooling. Restricted egress limits it to only the
|
||||
pre-approved destinations, even after compromise.
|
||||
3. Allow: egress to `payments-db` (internal, by label/namespace selector) and the specific
|
||||
external payment gateway (by IP range or, if supported, a domain-based egress rule). Must NOT
|
||||
allow: general internet access, other internal Services, or any other namespace by default.
|
||||
4. An empty `egress` list under `policyTypes: ["Egress"]` means DENY ALL outbound — exactly like
|
||||
the ingress case from the lesson. A reviewer seeing this should confirm it's intentional
|
||||
(a Pod that genuinely needs zero outbound network access) rather than assume it's an
|
||||
oversight, since the two look identical in the YAML.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 4 reinforces the same "empty rules list means deny-all, not
|
||||
unconfigured" point from earlier lessons — a good moment to confirm it generalizes correctly to
|
||||
egress, not just ingress.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what additional control (beyond NetworkPolicy) a team might want for
|
||||
truly sensitive egress, like restricting DNS resolution itself for this Pod — a forward pointer
|
||||
to defense-in-depth without requiring a hands-on Lab here.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Reflection: Reachability and Trust Are Decisions You Make on Purpose
|
||||
|
||||
You just applied default-deny NetworkPolicy, egress restriction, TLS ingress, and image-pull
|
||||
authentication for real. Take a few minutes to connect these to a single underlying principle.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Four different mechanisms in this module (NetworkPolicy ingress, NetworkPolicy egress, TLS,
|
||||
image-pull secrets) all express the same underlying idea in a different layer. What is that
|
||||
idea, stated in one sentence?
|
||||
2. If you had to prioritize implementing ONLY ONE of this module's controls on a brand-new,
|
||||
unsecured cluster, which would you pick first, and why?
|
||||
3. How would you explain, to someone who thinks "our cluster is internal, so it doesn't need
|
||||
this," why internal-only still benefits from all four controls?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**Trust should be explicit, narrow, and verifiable — never assumed from proximity.** A Pod on
|
||||
the same cluster, a request from inside the corporate network, an image from "a registry we
|
||||
usually use" — none of these are actually trustworthy by default. Every control in this module
|
||||
replaces an assumption ("it's internal, it's probably fine") with an explicit, checkable
|
||||
decision (this specific Pod can reach this specific destination; this specific registry is
|
||||
authenticated; this specific connection is encrypted).
|
||||
|
||||
**Supply-chain and network security compound.** A compromised image with unrestricted egress on
|
||||
an open network is a far worse incident than the same compromised image on a hardened one — the
|
||||
controls in this module don't individually prevent every incident, but together they
|
||||
dramatically shrink what any single failure can turn into.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 1 is the module's thesis — make sure
|
||||
students can actually articulate it in their own words, not just recognize it when stated.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to rank this module's four controls by how much
|
||||
IMPLEMENTATION EFFORT each typically requires on a real team, separately from security value —
|
||||
a good exercise in real-world prioritization tradeoffs.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Guided Exercise: Applying the Framework to a Broken Service, on Paper First
|
||||
|
||||
Before the `endpoint-fix-task` Lab, walk through the framework yourself against a described
|
||||
symptom — no cluster needed yet.
|
||||
|
||||
## The scenario
|
||||
|
||||
A teammate says: "`my-svc` isn't routing any traffic to our Pods, and I don't know why — the
|
||||
Pods look healthy in `kubectl get pods`."
|
||||
|
||||
## Your task
|
||||
|
||||
Write out, step by step, what you would actually DO for each framework stage, before looking at
|
||||
the worked answer:
|
||||
|
||||
1. **Observe** — what's the precise, specific symptom here (not "it's broken")?
|
||||
2. **Gather Evidence** — name the exact `kubectl` commands you'd run, in order, and what each
|
||||
one is checking for.
|
||||
3. **Form Hypothesis** — based on what ROOT 07's Services lesson taught you, what's the most
|
||||
likely cause of "Pods healthy, but Service routes nowhere"?
|
||||
4. **Test** — how would you CONFIRM that hypothesis before changing anything?
|
||||
5. **Repair** — what's the minimal fix, assuming your hypothesis is correct?
|
||||
6. **Validate** — what would you check afterward to PROVE the fix worked, not just assume it?
|
||||
|
||||
## A worked answer to check yourself against
|
||||
|
||||
1. Traffic sent to `my-svc` never reaches any Pod, despite the Pods themselves reporting
|
||||
`Running`/`Ready` — this rules out "the Pods are unhealthy" as the cause.
|
||||
2. `kubectl get endpoints my-svc` (does it show any Pod IPs at all?), then `kubectl describe
|
||||
service my-svc` (what selector is it actually using?), then `kubectl get pods --show-labels`
|
||||
(what labels do the real Pods actually carry?).
|
||||
3. A Service-to-Pod label selector mismatch — exactly the failure mode ROOT 07's demonstration
|
||||
walked through, where a Service's selector no longer matches the Pods' actual labels.
|
||||
4. Directly compare the Service's selector (from `describe service`) against the Pods' labels
|
||||
(from `get pods --show-labels`) side by side — confirmed if they genuinely don't match.
|
||||
5. Correct the Service's selector (or the Pods' labels, whichever is actually wrong) so they
|
||||
match again.
|
||||
6. `kubectl get endpoints my-svc` now shows the Pod IPs populated — not just "the YAML looks
|
||||
right now," but the actual Endpoints object reflecting real, matched Pods.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** This exercise deliberately reuses ROOT 07's own content — a good moment to
|
||||
point out explicitly that the framework's value is applying PRIOR knowledge systematically, not
|
||||
learning something entirely new each time.
|
||||
|
||||
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
|
||||
|
||||
**Optional extension:** ask what would change about steps 2-4 if the symptom were instead
|
||||
"Pods healthy, but ONE specific caller can't reach the Service while others can" — a good bridge
|
||||
to considering NetworkPolicy (ROOT 09) as an alternative hypothesis once the simpler
|
||||
selector-mismatch explanation is ruled out.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Reflection: A Framework You've Actually Used Is One You'll Trust Under Pressure
|
||||
|
||||
You just solved two real, different failures — a CrashLoopBackOff and a broken Service
|
||||
selector — using the same seven-step framework both times. Take a few minutes to consolidate
|
||||
this.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Which framework step did you personally find easiest to skip or rush through across both
|
||||
Labs? Why do you think that step specifically tempts shortcuts?
|
||||
2. Compare how you approached `crashloop-fix` versus `endpoint-fix-task`. What was IDENTICAL
|
||||
about your process, and what was different only because the evidence looked different?
|
||||
3. Now that you've practiced this framework on two failures you'd never seen exact instructions
|
||||
for, how confident do you feel applying it to a THIRD, completely unfamiliar Kubernetes
|
||||
failure? What would you still want to double-check?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**The framework's value shows up most on failures you've never seen before.** Anyone can
|
||||
memorize "if X symptom, do Y fix" for a handful of common problems — that knowledge doesn't
|
||||
transfer. Observe → Evidence → Hypothesis → Test → Repair → Validate → Prevent Recurrence
|
||||
transfers to failures nobody has specifically taught you, because it's a PROCESS, not a lookup
|
||||
table.
|
||||
|
||||
**"Prevent Recurrence" is what separates incident response from firefighting.** Both Labs in
|
||||
this module had a real, nameable prevention practice (verify command strings before deploying;
|
||||
keep label keys consistent between a Deployment and its Service). Teams that consistently ask
|
||||
this question after every fix accumulate fewer repeat incidents over time — not because they got
|
||||
lucky, but because they closed the actual gap each time.
|
||||
|
||||
This module is complete. Continue to the next module.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 1 tends to produce honest, useful
|
||||
answers — "Prevent Recurrence" and "Form Hypothesis" (skipping straight to a guessed fix) are
|
||||
the two most commonly admitted shortcuts, both worth normalizing as a real temptation to resist
|
||||
rather than treating as a personal failing.
|
||||
|
||||
**Approximate timing:** 8-10 minutes live discussion; 5-7 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to apply the framework, from memory and without re-reading
|
||||
this module, to a THIRD failure type they haven't practiced yet (e.g., a Pod stuck `Pending` —
|
||||
not built as a hands-on Lab in this module, just a thought exercise) to test genuine
|
||||
transferability.
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# DevOps as an Operating Model, Not a Job Title
|
||||
|
||||
## You've already been doing this — just not naming it yet
|
||||
|
||||
Every course before this one taught you a Kubernetes skill: declaring desired state, hardening
|
||||
a Pod, diagnosing a failure with a repeatable framework, running an incident with real
|
||||
discipline. This module doesn't teach a new hands-on skill — it names the OPERATING MODEL those
|
||||
skills are all part of, so you can recognize it and apply it beyond Kubernetes specifically.
|
||||
|
||||
## DevOps: a set of practices, not a team you join
|
||||
|
||||
"DevOps" is often misused as a job title ("I'm a DevOps engineer") or a team name ("throw it
|
||||
over to DevOps"). Properly understood, DevOps is a set of PRACTICES for how development and
|
||||
operations work together: shorter feedback loops, automation over manual process, and shared
|
||||
ownership of what happens after code ships — not a separate team that operations gets outsourced
|
||||
to.
|
||||
|
||||
Concretely, you already practiced core DevOps ideas without the label:
|
||||
|
||||
- **Declarative thinking** (ROOT 05) — describing desired state and letting a system enforce it,
|
||||
instead of manually executing steps, is the same principle behind Infrastructure as Code.
|
||||
- **Fast, safe rollback** (ROOT 06) — treating recovery as a rehearsed, ordinary action rather
|
||||
than a crisis is a direct DevOps practice (fast feedback, low-drama recovery).
|
||||
- **A repeatable troubleshooting framework** (ROOT 10) and **structured incident response**
|
||||
(ROOT 12) — both are operational discipline, the "Ops" half of DevOps, practiced for real.
|
||||
|
||||
## Platform Engineering: what DevOps looks like at scale
|
||||
|
||||
**Platform engineering** is the next layer: instead of every application team reinventing how
|
||||
to deploy safely, monitor, and secure their own workloads, a platform team builds SELF-SERVICE
|
||||
tooling and golden paths that let other engineers move fast without needing deep infrastructure
|
||||
expertise themselves. The Kubernetes cluster, the CI/CD pipeline, the observability stack — all
|
||||
of it becomes a PRODUCT a platform team builds for INTERNAL customers (other engineers), not
|
||||
just infrastructure that happens to exist.
|
||||
|
||||
A useful lens: platform engineering exists to **reduce cognitive load**. A developer who can
|
||||
deploy a well-configured, secure, observable service through a simple, guided path doesn't need
|
||||
to personally understand every Kubernetes concept from ROOT 05-10 to ship safely — the platform
|
||||
encodes that expertise once, for everyone.
|
||||
|
||||
## Where Kubernetes fits in the larger picture
|
||||
|
||||
Kubernetes is INFRASTRUCTURE, not the whole platform. A real platform engineering practice
|
||||
typically layers several things around it:
|
||||
|
||||
- **Declarative delivery** — describing what should be deployed and letting automation apply it
|
||||
(the GitOps model — future ROOT 11 content, requires a Git+deployment-automation environment
|
||||
this course doesn't yet provide).
|
||||
- **Continuous integration/delivery** — automatically testing and shipping code changes (CI/CD —
|
||||
future content, requires a CI runner environment).
|
||||
- **Infrastructure as Code** — declaring cloud/infrastructure resources the same way you declare
|
||||
Kubernetes objects (future content, requires a provisioning environment like Terraform).
|
||||
- **Observability** — knowing what's actually happening in production beyond "is the Pod
|
||||
Running" (future content, requires a metrics/logging/tracing stack).
|
||||
|
||||
None of these require abandoning what you learned in ROOT 05-10 — they build directly on the
|
||||
same mental models (desired vs. actual state, evidence-based diagnosis, least privilege) applied
|
||||
to a wider set of systems.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Lesson goal:** Give students a name and a framework for practices they've already exercised,
|
||||
and an honest map of what ROOT 11 will eventually cover once appropriate lab environments exist —
|
||||
without pretending those labs exist today.
|
||||
|
||||
**Approximate timing:** 20 minutes read; 15 minutes live with discussion.
|
||||
|
||||
**Preparation:** None beyond having read `docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md` to speak
|
||||
accurately about what's planned versus what exists.
|
||||
|
||||
**Likely misconceptions to watch for:**
|
||||
- "DevOps" as a job title/team is a very common prior belief — correct this directly and early,
|
||||
it's the central point of the lesson.
|
||||
- Platform engineering is sometimes conflated with "just Kubernetes." Emphasize the self-service/
|
||||
golden-path angle — the PRODUCT framing, not just the technology.
|
||||
|
||||
**Questions to ask students:** "Which ROOT_ course so far most directly practiced a DevOps
|
||||
principle, even though it was never labeled that way?" (Most will cite ROOT 06's rollback
|
||||
discipline or ROOT 12's incident framework — both are legitimate, good answers.)
|
||||
|
||||
**When to let a student struggle vs. provide a hint:** Not applicable — this is an orientation
|
||||
lesson with no hands-on task.
|
||||
|
||||
**Optional extension:** ask students to name one manual process at a company they've worked for
|
||||
(or heard about) that platform engineering-style self-service tooling could have eliminated.
|
||||
|
||||
**Expected takeaway:** DevOps and platform engineering are the organizing ideas behind almost
|
||||
everything ROOT_ has already taught — not a new, separate subject to learn from scratch.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Knowledge Check: DevOps & Platform Thinking
|
||||
|
||||
---
|
||||
|
||||
**1. A colleague says "DevOps is the team that handles our servers." What's wrong with that
|
||||
statement?**
|
||||
|
||||
*Answer:* DevOps describes a set of PRACTICES (shared ownership, fast feedback loops,
|
||||
automation) for how development and operations work together — not a separate team that
|
||||
operations gets handed off to. Treating it as a team name misses the point that these practices
|
||||
are meant to be shared across engineering, not isolated in one group.
|
||||
|
||||
---
|
||||
|
||||
**2. Which ROOT_ course's central skill is most directly an example of "declarative thinking"
|
||||
applied outside Kubernetes objects specifically?**
|
||||
|
||||
*Answer:* ROOT 05's desired-state model is the direct root of declarative thinking generally —
|
||||
Infrastructure as Code applies the exact same principle (describe what should exist, let
|
||||
automation enforce it) to cloud infrastructure rather than Kubernetes objects specifically.
|
||||
|
||||
---
|
||||
|
||||
**3. True or false: platform engineering's main goal is building the most technically
|
||||
sophisticated internal tooling possible.**
|
||||
|
||||
*Answer:* False. Its main goal is reducing COGNITIVE LOAD for other engineers via self-service,
|
||||
golden-path tooling — sophistication that doesn't make another engineer's job easier isn't the
|
||||
point, even if it's technically impressive.
|
||||
|
||||
---
|
||||
|
||||
**4. Why can't this course build a hands-on GitOps or CI/CD Lab the same way ROOT 05-10 built
|
||||
Kubernetes Labs?**
|
||||
|
||||
*Answer:* Those require different infrastructure the current Lab platform doesn't provide (a
|
||||
Git repository + deployment-automation tool like ArgoCD for GitOps; a CI runner for CI/CD) — see
|
||||
`docs/ROOT11_DEVOPS_PLATFORM_SCOPE.md`. Building a fake version of either would teach an
|
||||
inaccurate mental model, which is worse than honestly deferring the hands-on practice.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Facilitation:** Question 4 is worth stating plainly in a live classroom — students should
|
||||
understand this is a genuine platform limitation being handled honestly, not an oversight.
|
||||
|
||||
**Approximate timing:** 5 minutes.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Reflection: Where You Already Practiced This
|
||||
|
||||
You've now named the operating model behind most of what ROOT_ taught. Take a few minutes to
|
||||
trace it back through your own experience in this program.
|
||||
|
||||
## Reflection questions
|
||||
|
||||
1. Pick one specific moment from ROOT 05-10 or ROOT 12 where you practiced a DevOps/platform
|
||||
engineering principle without it being labeled that way. Name the principle explicitly now.
|
||||
2. If you were designing a platform team's "golden path" for deploying a new service safely,
|
||||
which THREE things from ROOT_ (health probes, RBAC, resource limits, NetworkPolicy, etc.)
|
||||
would you bake in as defaults rather than leave to each team to remember?
|
||||
3. What's one thing about GitOps, CI/CD, Infrastructure as Code, or observability you're
|
||||
genuinely curious about, now that you have the Kubernetes foundation to eventually build
|
||||
hands-on skill in it?
|
||||
|
||||
## Real Ops takeaway
|
||||
|
||||
**The skills you already have transfer further than "just Kubernetes."** Desired-state
|
||||
thinking, least privilege, evidence-based troubleshooting, and structured incident response
|
||||
aren't Kubernetes-specific facts — they're how experienced platform engineers reason about ANY
|
||||
complex system. Recognizing that transfer is what turns "I learned Kubernetes" into "I think
|
||||
like a platform engineer."
|
||||
|
||||
**Honest scope beats fake completeness.** This course didn't pretend to teach you GitOps or
|
||||
CI/CD hands-on today — it told you exactly what's missing and why, and pointed at what it would
|
||||
take to build it for real. That's the same standard the rest of ROOT_ held itself to
|
||||
throughout — a module that doesn't exist yet is more useful, honestly labeled, than one that
|
||||
exists in name only.
|
||||
|
||||
This module is complete.
|
||||
|
||||
## Instructor Notes
|
||||
|
||||
**Discussion questions for a live classroom:** question 2 works well as a small-group exercise —
|
||||
have groups compare their "golden path defaults" lists and discuss disagreements.
|
||||
|
||||
**Approximate timing:** 10 minutes live discussion; 8 minutes self-paced writing.
|
||||
|
||||
**Optional extension:** ask students to research (outside class) one real company's public
|
||||
engineering blog post about their platform engineering practice, and bring one concrete example
|
||||
to share next session.
|
||||
|
||||
**Expected takeaway:** ROOT_ built real, transferable engineering judgment — this module makes
|
||||
that transfer explicit rather than leaving students to discover it on their own.
|
||||
@@ -0,0 +1,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;
|
||||
Reference in New Issue
Block a user