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:
2026-08-26 11:20:17 -05:00
co-authored by Claude Sonnet 5
parent 2db136875f
commit 7f6e1857c0
26 changed files with 1716 additions and 0 deletions
@@ -0,0 +1,84 @@
# Externalizing Configuration: ConfigMaps & Secrets
## The problem: hardcoded configuration doesn't scale
If your application's database URL, log level, or feature flags are baked directly into its
container image, changing any of them means rebuilding and redeploying the image — for a
one-line config change. Worse, the SAME image can't be reused across dev/staging/production if
each environment needs different settings compiled in.
Kubernetes solves this by letting you inject configuration into a container at RUNTIME,
separately from the image itself — the same image, different config per environment.
## ConfigMap: configuration that isn't secret
A **ConfigMap** holds arbitrary key/value configuration data — log levels, feature flags, URLs,
whole config files. It's a plain Kubernetes object, readable by anyone with API access to that
Namespace, so it's for configuration that ISN'T sensitive.
Two ways to consume a ConfigMap in a Pod:
- **As environment variables** — each key becomes an env var the application reads normally.
- **As a mounted volume** — each key becomes a FILE inside the container, useful for
applications that expect to read configuration from disk rather than environment variables.
```yaml
envFrom:
- configMapRef:
name: app-env
```
## Secret: the same idea, for sensitive data
A **Secret** has the identical shape and consumption pattern as a ConfigMap (env vars or a
mounted volume) but is meant for sensitive values — passwords, API keys, tokens. Kubernetes
stores Secret data base64-ENCODED, not encrypted — this is an important distinction:
base64 is trivially reversible, so a Secret's real protection comes from Kubernetes' RBAC
(who can even read Secret objects at all, ROOT 09) and, in a properly configured cluster,
encryption at rest — not from the encoding itself.
```yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secret
type: Opaque
data:
api-key: c3VwZXJzZWNyZXQ= # base64 of "supersecret" — NOT encrypted
```
## Why the same mechanism, two different objects
You might ask why Kubernetes doesn't just have one "Config" object with a "sensitive" flag.
Keeping them separate makes the DISTINCTION visible and enforceable — RBAC rules can grant
"read ConfigMaps" without granting "read Secrets," and a quick `kubectl get configmaps` never
accidentally exposes something that should have been a Secret. The separation is a deliberate
safety boundary, not redundant API surface.
## Instructor Notes
**Approximate timing:** 15 minutes read; 10 minutes live.
**Demo plan:** Before the demonstration activity, it's worth showing `kubectl get secret
app-secret -o yaml` live and pointing out the base64 value, then decoding it on the spot
(`echo <value> | base64 -d`) — the "this isn't actually encrypted" realization lands much better
seen than described.
**Likely misconceptions to watch for:**
- Students very commonly assume Secrets are encrypted by Kubernetes automatically. Correct this
explicitly and early — it's a genuine, consequential security misunderstanding.
- The env-var vs. volume-mount choice is sometimes seen as arbitrary. Note one real
consideration: env vars are visible via `kubectl describe pod` and process inspection tools
inside the container more easily than a mounted file's contents — not a hard rule, but a
reason some teams prefer volume mounts specifically for Secrets.
**Discussion question:** "If someone with `kubectl get secrets` access on your namespace can
trivially read every Secret's real value, what is a Secret actually protecting you from?"
(Answer: it protects against accidental exposure through normal channels like
`kubectl get configmaps`, source control, or logs — not against a determined actor with direct
API read access, which is exactly why RBAC restricting who has that access is the real control.)
**Optional extension:** ask what changes about this picture on a cluster with encryption-at-rest
enabled for Secrets (answer: the ETCD-stored data itself becomes encrypted, protecting against
someone who gains access to the underlying etcd storage directly — still separate from and in
addition to RBAC controlling API-level access).
@@ -0,0 +1,66 @@
# Demonstration: A Secret Is Encoded, Not Encrypted
## Creating a Secret
```
$ kubectl create secret generic app-secret --from-literal=api-key=supersecret
secret/app-secret created
```
## Looking at it
```
$ kubectl get secret app-secret -o yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secret
data:
api-key: c3VwZXJzZWNyZXQ=
type: Opaque
```
## Decoding it — no special permission needed beyond reading the Secret itself
```
$ echo "c3VwZXJzZWNyZXQ=" | base64 -d
supersecret
```
Anyone who can run `kubectl get secret app-secret -o yaml` (i.e. anyone RBAC allows to READ
Secrets in this Namespace) can trivially recover the real value with a standard, built-in
command — no cracking, no special tooling. This is exactly why the lesson's point matters: the
protection here is WHO can read the Secret object at all (RBAC), not the base64 encoding itself.
## Comparing to a ConfigMap
```
$ kubectl get configmap app-config -o yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
environment: production
log_level: info
```
A ConfigMap's values are stored in PLAIN TEXT — no encoding step at all. This is consistent with
ConfigMaps being explicitly for non-sensitive data; there's no pretense of protection to begin
with.
## Instructor Notes
**Demo script:** Run this live, actually decoding the value in front of the class — the gap
between "Secret" (a name implying protection) and what the command output shows is the entire
point, and it lands far better performed than read.
**Approximate timing:** 6-8 minutes live; 5 minutes self-paced.
**Common misconception to preempt:** this demonstration directly targets the single most common
misconception about Kubernetes Secrets — do not skip it even if time is short, since a wrong
belief here has real security consequences later.
**Optional extension:** ask what a `SealedSecret` or an external secrets manager (mentioned by
name only, not built as a hands-on topic here) is trying to solve that a plain Kubernetes Secret
does not — a good forward pointer without requiring new infrastructure in this course.
@@ -0,0 +1,48 @@
# Knowledge Check: Configuration & Secrets
---
**1. A teammate says "our database password is safe because it's stored as a Kubernetes
Secret." What's missing from that statement?**
*Answer:* A Secret's data is base64-ENCODED, not encrypted — anyone who can read the Secret
object at all can trivially decode it. The real protection is RBAC (who is allowed to read
Secrets in that namespace) and, on a properly configured cluster, encryption at rest — not the
Secret object type itself.
---
**2. Why does Kubernetes have two separate objects (ConfigMap and Secret) instead of one
"Config" object with a sensitivity flag?**
*Answer:* Keeping them separate makes the distinction enforceable via RBAC — a role can grant
"read ConfigMaps" without granting "read Secrets" — and makes it visible at a glance
(`kubectl get configmaps` never risks accidentally listing something sensitive).
---
**3. An application needs its configuration to change without rebuilding its container image
across dev/staging/production. Which Kubernetes mechanism solves this, and why not just bake
different configs into three separate images?**
*Answer:* ConfigMaps (and Secrets, for sensitive values) injected at runtime via env vars or a
mounted volume. Baking config into the image means maintaining three separate images for what
should be identical application code — externalizing configuration lets the SAME image run
correctly in every environment.
---
**4. True or false: mounting a ConfigMap as a volume and consuming it as environment variables
are functionally identical from the application's perspective.**
*Answer:* False. An application reading environment variables needs code that reads env vars; an
application expecting a config FILE on disk needs the volume-mount approach. The right choice
depends on how the application itself is written to read configuration, not just personal
preference.
## Instructor Notes
**Facilitation:** Question 1 is the single most important takeaway from this module — confirm
it's genuinely understood, not just correctly guessed, before moving to the Labs.
**Approximate timing:** 5-7 minutes.
@@ -0,0 +1,50 @@
# Guided Exercise: ConfigMap, Secret, Env Var, or Volume?
Paper-and-thinking exercise before the Labs.
## The scenario
Your application needs four pieces of configuration:
1. A log level (`debug`/`info`/`warn`) that ops sometimes needs to flip without redeploying.
2. A database password.
3. A large JSON config file the application reads from a specific path at startup.
4. A feature flag read once at container startup via an environment variable, per the
application's existing code.
## Your task
For each, decide: ConfigMap or Secret, and env var or volume mount — then justify each choice.
## A worked answer to check yourself against
1. **ConfigMap, env var.** Not sensitive; the application likely already reads it as an env var
(a common convention), and env vars are simple to change and trigger a rolling restart with.
2. **Secret, either — but env var is common here too**, since most applications expect
credentials via environment variables by convention. The KEY decision is Secret (sensitive),
not the delivery mechanism.
3. **ConfigMap, volume mount.** The application expects a FILE at a path, not an environment
variable — volume mount is the only mechanism that presents a ConfigMap as an actual file.
4. **ConfigMap (assuming not sensitive) or Secret (if it is), env var** — the application's
EXISTING code dictates this; you don't get to choose a mechanism the application wasn't
written to read.
## Self-check
If you chose "volume mount" for #4 or "env var" for #3, revisit the lesson — the deciding
factor here isn't which mechanism seems more "correct" in the abstract, it's what the
application actually expects. Getting this backwards means the application simply won't find
its configuration at all.
## Instructor Notes
**Facilitation:** Emphasize that #4's answer depends entirely on EXISTING application
behavior — a good moment to note that platform engineers frequently have to adapt Kubernetes
config delivery to match an application's assumptions, not the reverse.
**Approximate timing:** 8-10 minutes live with discussion; 8 minutes self-paced.
**Optional extension:** ask what would happen if a ConfigMap consumed as a volume mount is
UPDATED after the Pod is already running — a good bridge to the real (if not hands-on-tested
here) behavior that mounted ConfigMap files update automatically after a short delay, while
env-var-injected values do NOT update without a Pod restart.
@@ -0,0 +1,39 @@
# Reflection: Configuration Is Infrastructure, Secrets Are a Trust Boundary
You just externalized configuration and handled a real Secret. Take a few minutes to connect
this to production practice.
## Reflection questions
1. Before this module, would you have assumed a Kubernetes Secret was encrypted? What's the
real-world consequence of a team believing that when it isn't true?
2. If your team's RBAC accidentally granted a low-trust role `get secrets` access across an
entire namespace, what's actually exposed — and would your team necessarily notice?
3. Why does separating configuration from the container image matter beyond convenience — think
about what it enables for testing or incident response.
## Real Ops takeaway
**A misunderstood Secret is a false sense of security.** Teams that believe Secrets are
encrypted by default sometimes skip additional controls (proper RBAC scoping, external secrets
management, encryption at rest) that they'd otherwise recognize as necessary. The gap between
perceived and actual protection is exactly where real incidents happen.
**Externalized configuration is what makes rollback and incident response fast.** If a bad
config value ships (a wrong feature flag, an incorrect log level flooding storage), fixing it
means updating a ConfigMap/Secret and letting Pods pick up the change — not rebuilding and
redeploying an entire image. This is a direct, practical payoff of the separation this module
taught, not just an architectural nicety.
This module is complete. Continue to the next module.
## Instructor Notes
**Discussion questions for a live classroom:** question 2 works well as a small-group exercise —
have students actually list what a namespace's Secrets typically contain in a real application
(DB creds, API keys, TLS certs) to make the exposure concrete.
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
**Optional extension:** ask students to draft a one-line policy statement their hypothetical
team would adopt about who is allowed `get`/`list` access to Secrets in production namespaces.
@@ -0,0 +1,72 @@
# Demonstration: Tracing a Request Through Ingress
## The setup
An Ingress `web-ingress` routes `app.example.com/` to Service `web-svc`, which fronts a
Deployment `web-app`. Nothing works yet from outside the cluster — let's see why, step by step.
## Step 1: is the Ingress object even doing anything?
```
$ kubectl get ingress web-ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
web-ingress traefik app.example.com 80 2m
```
`ADDRESS` is empty. On a real cluster, this column fills in once the Ingress controller has
actually processed the object and assigned it a reachable address — an empty address after a
couple of minutes is the first sign to check the controller itself, not your YAML.
## Step 2: is the controller running at all?
```
$ kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik
NAME READY STATUS RESTARTS AGE
traefik-7d8f9c6b5d-abcde 1/1 Running 0 10d
```
Running fine — so the ADDRESS delay in this case is transient, not a missing controller. (If
this returned no Pods at all, THAT would be the actual root cause — no controller means no
Ingress object ever gets acted on, exactly like the demonstration in ROOT 07's Ingress lesson
described.)
## Step 3: once ADDRESS populates, does the route actually work?
```
$ kubectl get ingress web-ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
web-ingress traefik app.example.com 10.0.1.50 80 3m
$ curl -H "Host: app.example.com" http://10.0.1.50/
<html>...web-app's real response...</html>
```
Success — but notice the `-H "Host: app.example.com"` header. Ingress routes by HOSTNAME, not
just by IP — hitting the Ingress controller's address WITHOUT the matching Host header would
route differently (or not at all), because that's literally what the Ingress rule is matching
on.
## The general shape
Symptom (nothing reachable from outside) has at least three DIFFERENT possible layers: the
Ingress object itself, the controller processing it, and the Service/Pod chain underneath it
(already covered in ROOT 07's Services module). Check them in that order — Ingress ADDRESS,
then controller health, then the underlying Service — rather than guessing at DNS or firewall
issues first.
## Instructor Notes
**Demo script:** If a real Ingress controller is available in the demo environment, run this
live including the Host-header nuance — students are frequently surprised that `curl <ip>`
alone doesn't work the way they expect.
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
**Common misconception to preempt:** students often expect an Ingress's IP/address to work like
a Service's ClusterIP (reachable directly, no extra header needed). Reinforce that Ingress
routing is fundamentally HOSTNAME/PATH-based, which is exactly what makes one Ingress able to
route many different domains through one entry point.
**Optional extension:** ask what would happen if TWO Ingress objects both claimed the same host
+ path — a good forward pointer to why Ingress configuration conflicts are a real operational
concern on a shared cluster, without needing to build a hands-on lab around it here.
@@ -0,0 +1,47 @@
# Guided Exercise: Planning Network Isolation
Paper-and-thinking exercise before the Lab.
## The scenario
A `database` Pod (label `app=database`) in the `netpol` namespace should only ever be reachable
by Pods labeled `role=backend` — not the frontend, not anything else in the cluster.
## Your task
1. Before writing any YAML: right now, with NO NetworkPolicy in place, can the frontend reach
the database? Why?
2. What would a NetworkPolicy that ONLY specifies `podSelector: { matchLabels: { app: database
} }` and no `ingress` rules at all do to traffic reaching the database — including from
`role=backend` Pods?
3. Sketch (in words, not full YAML) what the `ingress` rule needs to say to allow ONLY
`role=backend` traffic in.
4. If a new Pod is later created with `role=backend` in a DIFFERENT namespace, would your policy
as sketched in #3 allow it? What would need to change if cross-namespace access should be
allowed?
## A worked answer to check yourself against
1. Yes — by default, every Pod can reach every other Pod regardless of labels or namespace
(ROOT 07's Ingress & NetworkPolicy lesson). Nothing restricts this until a policy exists.
2. It would block ALL incoming traffic to the database, including from `role=backend` — an
empty `ingress` list under a policy that selects the Pod means "no traffic is allowed in,"
not "no policy applies."
3. An `ingress` rule with `from: [{ podSelector: { matchLabels: { role: backend } } }]` — this
allows traffic specifically from Pods carrying that label, and nothing else.
4. Not automatically — a bare `podSelector` in the `from` clause matches Pods in the SAME
namespace as the policy by default. Allowing a different namespace requires adding a
`namespaceSelector` alongside the `podSelector` in the same `from` entry.
## Instructor Notes
**Facilitation:** Question 2 is the one most likely to trip students up — it directly reinforces
the "empty ingress rules means deny-all, not no-op" point from the lesson, which is worth
confirming actually landed before the Lab.
**Approximate timing:** 10-12 minutes live with discussion; 10 minutes self-paced.
**Optional extension:** ask what a security-minded team would ALSO want to restrict about this
database Pod's OWN outbound (egress) traffic, beyond who can reach it — a light forward pointer
to ROOT 09's security-control framing of NetworkPolicy, without requiring students to build an
egress rule here.
@@ -0,0 +1,40 @@
# Reflection: Reachability Should Be a Decision, Not a Default
You just configured real Ingress routing and real network isolation. Take a few minutes to
connect this to production thinking.
## Reflection questions
1. Before this module, would you have assumed a fresh Kubernetes cluster isolates Pods from
each other by default? Has that changed?
2. If your team runs a multi-tenant cluster (several unrelated applications sharing it), what's
the risk of NEVER applying any NetworkPolicy at all?
3. Ingress lets many different domains share one entry point. What's the operational benefit of
that beyond just "fewer objects to manage"?
## Real Ops takeaway
**"No NetworkPolicy" is not neutral — it's full reachability.** Exactly like an unhardened
Pod's SecurityContext (ROOT 09 covers this in depth), the absence of a NetworkPolicy isn't a
safe default; it's the MOST permissive configuration available. On any cluster where more than
one team or application shares infrastructure, that's a real, often-overlooked exposure.
**Centralizing external entry points through Ingress reduces attack surface, not just object
count.** Every separate LoadBalancer Service is a separate externally-reachable endpoint to
secure, monitor, and patch. Routing through one (or a small number of) Ingress controllers means
security controls — TLS termination, rate limiting, WAF rules — are configured and audited in
one place instead of scattered across every application team's own Service definitions.
This module is complete. Continue to the next module.
## Instructor Notes
**Discussion questions for a live classroom:** question 2 is worth pushing on with a concrete
scenario — ask what a compromised low-value application (a demo app, a staging environment)
could reach on a cluster with zero NetworkPolicies, versus one with sensible defaults.
**Approximate timing:** 8 minutes live discussion; 5 minutes self-paced writing.
**Optional extension:** ask students to sketch (in words) what a reasonable DEFAULT-DENY policy
for an entire new namespace would look like, before any application-specific allow rules are
added — previews the security-control framing ROOT 09 builds on.