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