Skip to content

Feature/4.0/helm installer#1064

Open
jorgemoralespou wants to merge 196 commits into
educates:developfrom
jorgemoralespou:feature/4.0/helm-installer
Open

Feature/4.0/helm installer#1064
jorgemoralespou wants to merge 196 commits into
educates:developfrom
jorgemoralespou:feature/4.0/helm-installer

Conversation

@jorgemoralespou

@jorgemoralespou jorgemoralespou commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Work on migrating Carvel installer to Helm installer. Also, incorporating some of the changes in PR #871

  • Added Helm charts for educates-training-platform and educates additional cloud services
  • Added an Educates Training Platform Installer operator with 4 CRDs
    • EducatesClusterConfig
    • SecretsManager
    • SessionManager
    • LookupService
  • Removed Carvel installer
  • Refactored CLI to support Educates Installer operator with typed Opinionated Config
    • EducatesLocalConfig
    • EducatesGKEConfig
    • EducatesEKSConfig
    • EducatesInlineConfig
    • EducatesConfig
  •  Refactoring to avoid code duplication.
    • We have added examples to every command, added arguments validation and refactored/moved code around.
    • Upgraded functionality for educates local config edit that does validation and prevents saving on error
    • New educates local mirror list command
    •  Adds a --kubernetes-version flag to educates create-cluster and defaults to 1.36 and defines the kind images by sha to use.
    • Refactored registry and mirror code to remove code duplication and improve performance
    • Enhancements to educates docker workshop deploy. It creates port bindings for additional services containers.
    • Adds labels to all educates containers in docker.
  • Docker compose has been upgraded from v2 to v5.0.1 to later support more local capabilities. Fixes Support docker compose v5 #817
  • Added multinode support for local educates (1 control plane, up to 5 worker nodes) (Added docs)
  • Added support for node labels and node taints. Specially useful on multinode. (Added docs)
  • Fixed ips for registry and mirrors. Fixes Local kind cluster ip change might cause issues #850
    • Local registry will now get by default 172.18.200.1 (so kind network with 200.1 as offset)
    • Every mirror will also get a similar offset but hashed by registry name and if there's collision, will increase by 1 until available.
  • Remove options from help as we don't use global flags (a.k.a options). Fixes The new-workshop command help strings says it has an options sub command. #869
  • Add cluster portal export command to export a training portal and related workshops to stdout or files. Fixes new export-portal CLI command #91
  • Add docs for new features, updates
  • Add release notes for new features, updates, etc...

This has been rebased on latest develop, and updated to supersede #855 and #871. More commits will come later but guaranteeing that we keep this aligned with develop.

Adds the v4 architecture-of-record under docs/architecture/:
  - educates-current-state.md  — what v3 actually is today.
  - educates-v4-development-plan.md  — phased plan + open items + the
    pre-phase chart workstream this commit-set lands.
  - educates-crd-draft-v1alpha1-r3.md  — operator CRD design (informs
    Phase 0+).
  - decisions.md  — append-only decisions log; entries grouped by
    topic with reconsider triggers where relevant.

CLAUDE.md is the briefing for any future Claude Code session in this
repo: scope of v4 vs v3, what's safe to touch, working norms,
references back to the architecture docs.

.gitignore picks up .claude/ so user-local agent state doesn't leak.
Pre-phase deliverable from docs/architecture/educates-v4-development-plan.md.
The chart is the canonical Helm install for the Educates v4 runtime —
what users helm install today (and what the v4 operator will install
on their behalf in Phase 4).

Layout:
  installer/charts/educates-training-platform/
  ├── Chart.yaml, values.yaml, .helmignore        (umbrella)
  └── charts/
      ├── secrets-manager/        (CRDs + operator: cross-NS secret
      │                            propagation primitives)
      ├── lookup-service/         (federation API; off by default)
      ├── remote-access/          (read-only RBAC + token Secret for
      │                            external CLI clients; toggleable
      │                            independently)
      └── session-manager/        (workshop runtime + bundled v3
                                   Kyverno policies)

CRDs ship in each subchart's `crds/` directory rather than templates/
because Helm can't apply CRDs and CRs of those CRDs in a single
release otherwise (see decisions.md).

session-manager ships v3-vendored Kyverno policies on two paths:
  - bundledKyvernoPolicies.clusterPolicies — Pod Security Standards
    profiles installed cluster-wide as ClusterPolicy resources.
  - bundledKyvernoPolicies.workshopPolicies — operational policies +
    Educates-internal `require-ingress-session-name`, written into
    the educates-config Secret for session-manager to clone per
    workshop environment.
additionalKyvernoPolicies.{clusterPolicies,workshopPolicies} let
admins extend either bundle through chart values — net-new vs v3,
which required out-of-band kubectl-apply.

Image refs default to ghcr.io/educates/educates-* (v3 naming
convention). Tag default is the chart's appVersion; scenarios pin to
3.7.1 because no v4 runtime images exist yet.
…lates

Stray file from troubleshooting .Files.Glob behaviour in
kyverno-cluster-policies.yaml during chart development. Helm treats
templates/_*.txt as a partial-style include and skips it during
render, so it never affected output — but it shouldn't be in the
chart.
Six end-to-end scenarios under installer/charts/educates-training-platform/tests/.
Each scenario provisions a kind cluster (cluster-only), runs an
optional pre-install hook to stage cluster-side fixtures, applies the
chart, runs an optional post-install hook before rollout-status, and
exercises the deploy-workshop / browse path.

run-scenario.sh wires four hook points (pre-install, post-install,
post-deploy, teardown) so each scenario carries its own setup +
assertions. .env loading + envsubst rendering of scenario files lets
the runner pick up DOMAIN, TLS_CERT_PATH, CA_CERT_PATH from the user
shell (handy with mkcert auto-detection) without touching scenario
files.

Scenarios:
  01-local-http-nip-io          — minimal HTTP smoke; everything
                                  optional off.
  02-kind-tls-wildcard          — offline-generated wildcard +
                                  secretPropagation.upstream paths.
  03-kind-cert-manager-issuer   — cert-manager + certs package
                                  issues the wildcard from a
                                  user-provided CA.
  04-website-theme              — custom websiteTheme reaches the
                                  live portal HTML (post-deploy
                                  curls portal URL, greps marker).
  05-image-pull-secrets         — auth'd local registry serves a
                                  copy of educates-session-manager;
                                  scenario fails if the chart's
                                  pull-secret chain breaks.
  06-additional-kyverno-policies — bundled + user-supplied
                                  ClusterPolicies on both paths
                                  (cluster-wide + per-environment).

Step 5 prints a click-through portal URL with URL-encoded password
(uses a pure-bash percent-encoder).
Add the docs-of-record for the session-manager subchart's typed values
shape (values.yaml + JSON schema), and update the v4 development plan
to mark Kyverno-policy bundling as done and rescope the typed-runtime-
config follow-up against the broader target shape.
Refactor the session-manager subchart's values surface from an opaque
config blob plus ad-hoc toggles into the typed shape defined by the
docs-of-record (docs/architecture/session-manager-chart-values{.yaml,
-schema.json}). Adds values.schema.json so Helm catches shape errors
at template time.

Key changes:

- clusterIngress / clusterSecurity / workshopSecurity replace the
  config: clusterIngress + bundledKyvernoPolicies + openshift.enabled
  knobs. policyEngine and rulesEngine are PascalCase enums in chart
  values, lowercased when emitted into the runtime config blob to
  match the runtime's existing expectations (no runtime change).
- Promotes imageRegistry, imageVersions, sessionCookies, clusterStorage,
  clusterRuntime, clusterNetwork, dockerDaemon, workshopAnalytics, and
  websiteStyling.{defaultTheme,frameAncestors} out of config into typed
  values; chart auto-injects operator.namespace (release ns) and
  version (chart appVersion).
- websiteStyling.inline.{workshopDashboard,workshopInstructions,
  workshopStarted,workshopFinished,trainingPortal} replaces the flat
  websiteTheme map; the chart maps the structured triples to the
  flat secret keys (.html / .js / .css) the runtime expects.
- SecretCopier rules for ingress TLS+CA are auto-derived from
  clusterIngress.{tls,ca}CertificateRef.namespace; the explicit
  secretPropagation.upstream.ingressTLS / ingressCA knobs are gone.
  themeDataRefs entries with non-local namespaces are also auto-copied.
- Materialises empty-string TLS/CA refs in the operator-config blob
  even when unset, papering over the runtime's xget-no-default-None
  quirk.
- config remains as an opaque escape hatch, deep-merged on top of the
  typed-derived blob so users can land new fields before promotion.

Scenarios 01 (local HTTP nip.io) and 02 (kind + TLS wildcard) are
updated to the typed shape and verified via helm template. The
remaining scenarios (03-06) move to the typed shape in a follow-up
commit.
Brings scenarios 03-06 onto the typed values shape introduced in the
previous commit and adds scenario 07 to exercise the `config:` escape-
hatch deep-merge.

- 03 (kind cert-manager issuer): cross-namespace TLS/CA refs now drive
  the SecretCopier auto-derive instead of an explicit
  `secretPropagation.upstream.ingressTLS/ingressCA` block.
- 04 (website theme): uses `websiteStyling.inline.trainingPortal.{html,
  style}`, exercising the chart's structured-triple → flat-secret-key
  mapping (`style` → `.css`).
- 05 (image-pull secrets): typed top-level `imagePullSecrets` for the
  PodSpec; `secretPropagation.imagePullSecretNames` and
  `secretPropagation.upstream.imagePullSecrets` unchanged.
- 06 (additional Kyverno policies): toggles renamed —
  `clusterSecurity.{policyEngine,additionalKyvernoPolicies}` and
  `workshopSecurity.{rulesEngine,additionalKyvernoPolicies}` replace
  the old `bundledKyvernoPolicies` / `additionalKyvernoPolicies.{cluster,
  workshop}Policies` blocks.
- 07 (new): asserts the `config:` opaque map deep-merges on top of the
  typed-derived runtime config and wins on conflict (`dockerDaemon.
  networkMTU` override) and passes through unknown fields untouched
  (`experimental.markerKey`).

Drive-by: schema's top-level `imagePullSecrets` was [string] but the
PodSpec wants the standard k8s [{name: ...}] shape — fixed in both the
chart's values.schema.json and the docs-of-record.
Records the decision behind the typed-values refactor (commits
a57ef864 / d339c016 / f61d3c8e): a single typed surface serves both
the operator and standalone chart users, with `config:` retained as
an escape hatch for not-yet-promoted runtime fields. The earlier
"operator-driven, not v3-driven" decision is superseded — its framing
left standalone users writing opaque YAML against the v3 schema from
memory, which contradicted the chart's own publish-as-canonical-Helm
install positioning.
…default imageVersions

Fixes ErrImagePull on training-portal (and any other runtime-spawned
child image) by stopping the chart from auto-injecting Chart.AppVersion
(`4.0.0-alpha.1`) as the runtime config's `version` field. Restores the
defaulted `imageVersions` list that v3's carvel installer rendered out
of `config/images.yaml`, which the previous refactor silently dropped.

- New typed value `runtimeVersion: "3.7.1"` drives:
    * the chart-pod `image.tag` default
    * the `imagePuller.pauseImage.tag` default
    * the `version` field auto-injected into the operator-config blob
- `imageVersions` defaults to the full v3 list: 12 Educates-published
  images pinned to `runtimeVersion`, 7 upstream pins (docker-in-docker,
  loftsh-kubernetes-v1.31..34, loftsh-vcluster, debian-base-image) at
  their v3-vendored tags. Visible in values.yaml so chart users see
  exactly which images the runtime can pull.
- Drops the redundant `image.tag: "3.7.1"` overrides in scenarios 01-07
  (chart default now resolves correctly). Scenario 05 keeps its
  `image.repository` override for the auth'd local registry but no
  longer pins the tag.
- Mirrored in docs-of-record (values.yaml + JSON schema), the v4 dev
  plan, and a new decisions.md entry covering the rationale and the
  documented two-place-edit when bumping `runtimeVersion`.
…helper-defaulted imageVersions with per-key merge

Supersedes the runtimeVersion + populated-values approach from e71e41af
with a more idiomatic shape:

- `Chart.appVersion` IS the runtime image version. Set to "3.7.1"
  across the umbrella + four subchart Chart.yamls (was "4.0.0-alpha.1"
  — which doesn't exist as a published image and was the original
  source of the ErrImagePull). `Chart.version` stays at 4.0.0-alpha.1
  (the chart-package version). They normally move together at release
  time; the field separation exists for chart-only patches.
- Removed `runtimeVersion` typed value. Image-tag defaults
  (`session-manager.image.tag`, `imagePuller.pauseImage.tag`) and the
  operator-config blob's `version` field all source `Chart.appVersion`
  directly.
- The full default `imageVersions` set moves into a new template
  helper `session-manager.imageVersions` (mirroring v3's
  carvel-installer images.yaml). Educates-published entries derive
  their tag from `Chart.appVersion`; upstream pins (docker-in-docker,
  loftsh-*, debian-base-image) are hard-coded.
- User-supplied `.Values.imageVersions` entries merge BY NAME on top
  of the helper defaults: an override replaces just the matching
  default's image, other defaults pass through, names not in the
  default list are appended (forward-compat). Strictly better UX than
  v3's full-list replacement; chart users override only what they need.
- `values.yaml`, `values.schema.json`, and the docs-of-record return to
  `imageVersions: []` with comments documenting the per-key merge
  semantics. The helper is the documented inventory.
- Dev plan updated. decisions.md entry replaced with the corrected
  rationale (Chart.appVersion sourcing, helper-defaulted list, per-key
  merge UX).
…-pod, pause, and runtime children

A chart user pointing at a fork or a local registry now redirects
every Educates-image reference with one knob. Previously only the
runtime-spawned children were derivable from imageRegistry — the
chart pod and the pause image were hard-coded to ghcr.io/educates/...,
which broke the dev workflow against a fork.

- `imageRegistry.host` / `.namespace` default to `ghcr.io` / `educates`
  (was empty/empty). They now compose the prefix used by:
    * `session-manager.imageRegistryPrefix` helper (new)
    * the chart-pod `image.repository` default (when empty)
    * the pause image `imagePuller.pauseImage.repository` default
      (when empty)
    * the Educates-published entries in the `session-manager.imageVersions`
      helper
- Upstream pins (docker-in-docker, loftsh-*, debian-base-image) are NOT
  relocated by imageRegistry — they're public upstream images that don't
  follow the educates-<name> naming convention. Mirror them via per-entry
  imageVersions overrides instead.
- `image.repository` and `imagePuller.pauseImage.repository` defaults
  flip from hard-coded refs to empty strings; helpers `session-manager.
  image.repository` and `session-manager.pause.image.repository` resolve
  the empty-derives-from-imageRegistry behaviour. Schema's `imageRef`
  drops the minLength on `repository` to allow the empty.
- Helper bails fast (`fail`) if imageRegistry.host is empty — the
  chart can't compose a default ref without it.
- Verified end-to-end: setting `imageRegistry: {host: localhost:5001,
  namespace: educates-fork}` redirects 12 runtime children + chart pod
  + pause to that prefix, while upstream pins stay put.

Mirrored in docs-of-record (values.yaml + JSON schema), v4 dev plan,
and the prior decisions.md entry.
…curity to umbrella globals

Cross-cutting deployment-scope values now live at the umbrella under
`global:`. Helm propagates them to every subchart as `.Values.global.<key>`.
Subcharts retain a local block of the same name with sensible defaults;
new helpers deep-merge the umbrella global over the subchart local, with
globals winning per-leaf where set. Subcharts remain independently
installable.

Concretely:

- session-manager: new `resolved{ImageRegistry,ClusterIngress,
  ClusterSecurity}` helpers feed `imageRegistryPrefix`, `derivedProtocol`,
  `operatorConfigYAML`, `kyverno-cluster-policies.yaml`,
  `clusterrolebindings.yaml`, and `secretcopiers.yaml`. Schema drops
  `clusterIngress` and `clusterSecurity` from `required` and removes the
  subchart-local `clusterIngress.domain` minLength — helpers do the
  post-merge `fail` instead.
- lookup-service: new `imageRegistry` block (default ghcr.io/educates),
  `image.repository` flips to empty (derives from imageRegistry),
  helpers added (`resolvedImageRegistry`, `imageRegistryPrefix`,
  `image.repository`). Wired into the Deployment. New
  `values.schema.json`.
- secrets-manager: same image-helper additions. `openshift.enabled`
  removed; the SCC ClusterRoleBinding now gates on
  `clusterSecurity.policyEngine == "OpenShiftSCC"` (resolved from
  globals when present). New `values.schema.json`.
- Umbrella `values.yaml` gains a `global:` block with commented examples
  for the three keys.
- All seven scenarios converted to canonical-globals shape: cross-
  cutting values live under `global:`, subchart blocks shrink to per-
  subchart concerns only. Verified end-to-end — setting
  `global.imageRegistry.{host,namespace}` redirects all chart pods +
  runtime children; `global.clusterSecurity.policyEngine: OpenShiftSCC`
  triggers SCC bindings in BOTH session-manager and secrets-manager.

Mirrored in the doc-of-record (note about dual-source pattern), the v4
dev plan (each cross-cutting block now flagged as a global), and a new
decisions.md entry covering the rationale and trade-offs.

Drive-by: `tests` added to .helmignore so scenario fixtures don't ship
with the chart package.
… render ca-trust-store init container

Drops lookup-service's specialised `caTrust` block and `ingress.tls`
field in favour of consuming `global.clusterIngress` (with subchart-
local fall-back). The lookup-service Ingress's TLS Secret now derives
from the resolved `clusterIngress.tlsCertificateRef.name` (typically
the wildcard cert covering `*.<domain>`), and the chart renders a
ca-trust-store init container when `clusterIngress.caCertificateRef.name`
is set.

- New `clusterIngress` block in lookup-service values.yaml mirrors the
  shape introduced in session-manager + the umbrella global.
- `caTrust.{secretName,initImage}` removed; the init image is no longer
  base-environment but the lookup-service main image itself (Fedora-
  based: has `update-ca-trust` and `tar`). Zero extra image pulls; the
  kubelet already has it on the node. Mirrors v3's
  overlay-ca-injector.yaml mechanism without the cost of pulling a
  multi-GB workshop image.
- `ingress.tls.secretName` removed; the Ingress derives TLS from the
  resolved `clusterIngress.tlsCertificateRef`.
- New `secretcopiers.yaml` auto-derives copy rules for both the TLS
  Secret and the CA Secret when their refs target a foreign namespace.
  Renders independently of session-manager's SecretCopier so this chart
  is installable standalone; under the umbrella both subcharts render
  their own rules (idempotent — same source-Secret copied once
  regardless of how many rules reference it).
- Helpers updated: drop `caTrust.image.{tag,pullPolicy}`, add
  `resolvedClusterIngress` and `caTrustEnabled`.
- Schema reflects the new shape.

Verified by enabling lookup-service against a TLS+CA scenario:
- Ingress emits `tls: [{secretName: wildcard-tls}]` with the
  hostname-specific `host` and the resolved cert name.
- Init container reuses the lookup-service image and runs
  `update-ca-trust && tar -C /etc/pki/ca-trust ...`.
- Main container mounts the CA-populated trust store at
  /etc/pki/ca-trust read-only.
- SecretCopier `educates-lookup-service-ingress-secrets` pulls both
  refs into the release namespace.
…ger Deployment

Mirrors what step 2 added to lookup-service: a chart-side ca-trust-store
init container that builds a CA-populated trust store from
`global.clusterIngress.caCertificateRef` and the main container mounts
the result at /etc/pki/ca-trust read-only. Reuses the main session-
manager image (Fedora-based: has `update-ca-trust` and `tar`) so no
extra image pull on the node.

v3 only injected the trust store into lookup-service. Including it in
session-manager too is harmless when the CA isn't needed and avoids
debugging "why does X fail TLS verify?" later if session-manager ever
gains code paths that reach external TLS endpoints fronted by the
private CA.

- New `session-manager.caTrustEnabled` helper + Deployment template
  rewire — initContainers / volumes / volumeMount conditionally on the
  resolved `clusterIngress.caCertificateRef.name`.
- The init container's securityContext explicitly sets
  `runAsNonRoot: false` to override the pod-level `runAsNonRoot: true`
  enforcement (the trust-store update needs UID 0 to write
  /etc/pki/ca-trust). Mirrored in lookup-service for consistency.
- Verified: scenario 02 (TLS+CA) renders the init container; scenario
  01 (no CA) does not; existing SecretCopier auto-derive still pulls
  the CA Secret into the release namespace where the init container
  consumes it.
…chart

Brings remote-access in line with the schema discipline applied to the
other three subcharts (additionalProperties: false; only `enabled` and
the Helm-injected `global` are valid). The subchart has no configurable
knobs in v0.1.0 — the schema serves purely as a typo-catcher and a
contract that future additions are deliberate.

All seven scenarios still render; an additional smoke-render with
remote-access.enabled=true also passes.
Restores the v3 cluster-node CA injection feature as a sibling subchart
under the umbrella, replacing the never-rendered
`session-manager.clusterIngress.caNodeInjector.enabled` stub. Toggle is
the umbrella's `node-ca-injector.enabled: false` (default off, opt-in)
via Helm's standard subchart-condition mechanism.

What renders when enabled (mirroring v3's 07-node-ca-injector.yaml):

- ServiceAccount + ClusterRole/Binding (Ingress watch) +
  Role/RoleBinding (ConfigMap manage in release ns).
- `node-ca-injector-controller` Deployment running the `controller`
  subcommand — watches Ingresses, builds the `educates-registry-hosts`
  ConfigMap.
- `node-ca-injector` DaemonSet running the `sync` subcommand —
  privileged, mounts the CA Secret + hosts ConfigMap + hostPath
  `/etc/containerd/certs.d`. Writes per-host containerd registry-CA
  configuration so containerd trusts the cluster's private CA when
  pulling images.
- SecretCopier auto-derived when the CA ref's namespace is foreign.

The subchart consumes `global.clusterIngress.caCertificateRef` (with
subchart-local fall-back for standalone) and fails fast at template time
if the resolved CA ref is empty. Image is derived from
`global.imageRegistry` so the same fork/local-registry knob redirects
this subchart too. Has its own `values.schema.json`.

Relationship to the per-pod ca-trust-store init container (steps 2/3):
complementary, not overlapping. Init container handles in-pod TLS verify
for our own Deployments; node-ca-injector handles container-runtime-
level trust for image pulls (including pulls performed by pods we don't
render — third-party operators, docker-in-docker workshop sessions).
Both keyed on the same global CA ref; both independently togglable.

`session-manager.clusterIngress.caNodeInjector.enabled` is removed from
values.yaml + values.schema.json + the doc-of-record. New decisions.md
entry covers the rationale + the relationship between the two CA-trust
mechanisms.
… top-level shape

Closes the validation gap on the umbrella's cross-cutting `global:`
block. Subchart schemas correctly treat `global` as opaque (they
shouldn't dictate the umbrella's contract), which meant typos like
`global.clusterSecuirty.policyEngine` or `global.imageRegistry.namespece`
silently fell through — every subchart fell back to its local defaults
and the user's intended override was lost.

The umbrella schema:
- Validates the `global.{imageRegistry,clusterIngress,clusterSecurity}`
  shape with `additionalProperties: false` at every level.
- Forbids unknown top-level keys (catches misspelled subchart names
  like `sesion-manager:` that Helm would otherwise treat as inert).
- Treats each subchart block (`secrets-manager`, `lookup-service`,
  `remote-access`, `session-manager`, `node-ca-injector`) as
  `{ "type": "object" }` and delegates detailed validation to that
  subchart's own schema. No duplication.

Verified: all three classes of typo (misspelled global key, misspelled
global nested field, unknown top-level key) trigger a clear schema
error at `helm template` time. All seven existing scenarios still
render cleanly.

decisions.md entry covers the split rationale.
End-to-end test for node-ca-injector: a workshop session builds a tiny
container image, pushes it to the per-session registry (HTTPS via the
wildcard cert), then creates a Deployment that pulls from that
registry. Successful rollout is the proof — without the cluster CA in
containerd's per-host trust under /etc/containerd/certs.d/, the pull
would fail with TLS verify errors and the rollout would hang.

Runner change:

- run-scenario.sh now detects `<scenario>/workshop/resources/workshop.yaml`
  and, if present, publishes the scenario-local workshop to the local
  registry stood up by `educates local cluster create` (localhost:5001)
  via `educates publish-workshop <scenario>/workshop`, then deploys it
  with `educates deploy-workshop -f <that path>`. Scenarios 01-07 keep
  the existing default-WORKSHOP_URL behaviour because they don't ship
  their own workshop directory.

Scenario 08 (`08-node-ca-injector-image-pull`):

- educates-config.yaml + pre-install.sh: identical to scenario 02
  (kind + Contour + Kyverno; pre-install materialises a wildcard TLS
  Secret + CA Secret in `educates-secrets`).
- chart-values.yaml: scenario-02 globals shape plus
  `node-ca-injector.enabled: true` at the umbrella.
- description.md: explains what the test demonstrates and what success
  looks like.
- workshop/: a complete `lab-node-ca-pull` workshop. Enables `docker`
  and `registry` session applications. Four content pages walk the user
  through writing a Dockerfile, building, tagging and pushing to
  `${REGISTRY_HOST}`, creating the Deployment, and watching
  `kubectl rollout status` succeed. The summary calls out which step is
  the actual proof of node-ca-injector working.

Verified all eight scenarios render cleanly under `helm template`;
scenario 08 emits all eight node-ca-injector resources plus the chart
pods with the ca-trust-store init container.

The runner pause at step 5/6 is the verification surface — interactive
since the proof lives in a workshop session, not in a kubectl assertion
the runner can make against the cluster directly.
…me defaults via Chart.yaml annotations

The user-facing image-registry knob is renamed to
`development.imageRegistry` (subchart-local) and
`global.development.imageRegistry` (umbrella), and the publish-time
default registry moves from a populated `values.yaml` block to
Chart.yaml annotations:

  educates.dev/image-registry-host: "ghcr.io"
  educates.dev/image-registry-namespace: "educates"

The release workflow updates these annotations per fork (one `yq -i`
call per Chart.yaml) so the chart that gets shipped points at the
right registry without a values override. Mirrors v3's
`push-installer-bundle` Makefile target which baked refs at OCI-bundle
build time, translated to a chart-publish edit step.

The runtime IMAGE_REPOSITORY semantic now matches v3's intent:

- When `development.imageRegistry` is set: emitted into the runtime
  config so workshop sessions get IMAGE_REPOSITORY={host}/{namespace}
  for `$(image_repository)` content placeholder resolution.
- When empty (normal use): runtime config's `imageRegistry` block is
  emitted empty; runtime falls back to
  `registry.default.svc.cluster.local` per
  `session-manager/handlers/operator_config.py:35`. This avoids
  silently breaking the local-dev workflow on installs that left a
  populated registry in place.

Implementation notes:

- Each subchart helper now has TWO resolvers:
    * `resolvedImageRegistry` falls back to Chart.yaml annotations and
      is consumed by chart-rendered + runtime-children image-ref
      composition.
    * `resolvedDevelopmentImageRegistry` (session-manager only — the
      subchart that owns the operator-config emission) does NOT fall
      back to annotations; returns user/global only. Emitted into the
      runtime config blob's `imageRegistry` field.
- Annotations added to all four image-rendering Chart.yamls (session-
  manager, lookup-service, secrets-manager, node-ca-injector). Helper
  reads `.Chart.Annotations["educates.dev/image-registry-..."]`.
- Subchart `values.yaml`: `imageRegistry` block dropped; replaced by
  empty `development.imageRegistry`. Schemas updated. Doc-of-record
  follows the session-manager subchart shape.
- Umbrella `values.yaml` and schema: `global.imageRegistry` →
  `global.development.imageRegistry`.
- Helper failure message updated to point at both override paths.

Verified end-to-end:

- Normal mode (scenario 01 with empty development.imageRegistry):
  chart pods resolve to `ghcr.io/educates/educates-{secrets,session}-
  manager:3.7.1` from annotations; runtime config blob has
  `imageRegistry: { host: "", namespace: "" }`.
- Dev override (`global.development.imageRegistry: { host:
  localhost:5001, namespace: educates-dev }`): all 12 Educates
  imageVersions entries redirect to localhost:5001/educates-dev/
  AND the runtime config blob carries the same registry, so workshops
  with `$(image_repository)` placeholders resolve consistently.

decisions.md gets a new entry superseding the prior `imageRegistry`
decision with the development-knob framing and the rationale for the
two-resolver split.
Captures GitHub-issue drafts for runtime simplifications that should
land once the v4 chart-based install ships in develop. Format mirrors
decisions.md — one heading per issue, prose body, date added — so
entries can be transcribed to the issue tracker with minimal further
editing.

Initial entries:

- Simplify `operator_config.py` IMAGE_REPOSITORY resolution. Drop the
  `imageRegistry.host` + `imageRegistry.namespace` compose logic in
  favour of a single `imageRepository` field, and stop falling through
  in `image_reference()` for short-names not in `imageVersions` —
  treat them as config errors instead.
- Drop `clusterIngress.tlsCertificate` / `caCertificate` inline forms
  from `operator_config.py`. The chart only emits the `*Ref` forms.
- CI lint: assert Chart.yaml annotations stay in sync across all four
  image-rendering subcharts (and optionally that version / appVersion
  / dependency versions match across umbrella + subcharts).
- Document the chart release workflow's annotation update step in the
  release runbook.

Each entry has a "trigger to file" so it doesn't get filed prematurely
while v3 is still the production install path.
Tighten Phase 0 in the v4 development plan and add three decisions-log
entries covering the choices made for kubebuilder bootstrap:

- Operator at installer/operator/, kubebuilder's config/ kustomize tree
  stripped; controller-gen writes CRDs and RBAC directly into the
  educates-installer Helm chart.
- Spec types adopt the full r3 shape from Phase 0; status grows
  alongside the reconciler that produces each field. Avoids dead API
  surface drifting from r3.
- Operator image at Phase 0 is a local-dev placeholder built via
  make docker-build; publish-time annotations and release workflow are
  deferred to Phase 6.

Also narrows Phase 0 CEL scope to singleton-name + mode-immutability
(mode-field exclusivity moves to Phase 1) and Phase 0 RBAC to the four
CRDs only (referenced-resource watches move to Phase 1). CLAUDE.md gets
a new "Operator project (Phase 0+)" block listing the make targets and
conventions.
Phase 0 step 1: bare kubebuilder scaffold at installer/operator/. No
real types or reconciler logic — just the layout we'll grow.

- Multigroup project (config + platform groups under domain
  educates.dev), repo path
  github.com/educates/educates-training-platform/installer/operator,
  added to root go.work.
- Four APIs scaffolded with controller stubs: EducatesClusterConfig
  (config/v1alpha1), SecretsManager / LookupService / SessionManager
  (platform/v1alpha1).
- Per the Phase 0 layout decision, kubebuilder's config/ kustomize
  tree is stripped; controller-gen writes CRDs and RBAC into
  bin/manifests/{crd,rbac} for now and will retarget the
  educates-installer chart in step 5.
- Makefile pruned of kustomize-dependent targets (install, uninstall,
  deploy, undeploy, build-installer, kustomize tool target,
  setup-test-e2e/test-e2e, docker-buildx, docker-push) and the
  kubebuilder-default test/e2e/ tree removed. smoke-test target is
  staged with a fail-fast message until step 5 wires it.
- Operator-local .github/workflows/ removed; the monorepo CI workflow
  for the operator lands in step 6.

Verified: go build ./..., go vet ./..., make generate, and make
manifests all run clean. CRDs + RBAC YAML produced for all four kinds.
Translate the full r3 EducatesClusterConfig spec surface into Go types
under api/config/v1alpha1/. Mirrors the CRD draft revision 3 in
docs/architecture/educates-crd-draft-v1alpha1-r3.md:

- Mode (Managed | Inline), with the full Managed-mode tree:
  Infrastructure (provider + optional cloud + service-account identities),
  Ingress (domain, ingressClassName, controller, certificates),
  Certificates (BundledCertManager / ExternalCertManager / StaticCertificate),
  ACME with DNS01 solvers (Route53, CloudDNS, Cloudflare, AzureDNS),
  DNS (BundledExternalDNS / Manual / None),
  PolicyEnforcement (clusterPolicy, workshopPolicy, kyverno),
  ImageRegistry (prefix + pullSecrets).
- Inline-mode tree mirroring the same surface where applicable
  (ingress, policyEnforcement, imageRegistry).
- Shared OperationalBlock duplicated at every Bundled use site per the
  r3 design intent (no schema-ref factoring).
- Static defaults marked with +kubebuilder:default for fields the r3
  doc calls out: dns.provider=None, clusterPolicy.engine=Kyverno,
  workshopPolicy.engine=Kyverno, kyverno.provider=Bundled.
- Enum validation on every closed-set field via
  +kubebuilder:validation:Enum.

Phase 0 CEL rules added (the only two in scope; mode-field exclusivity
moves to Phase 1 per the development plan):

- Singleton-name on the wrapper type:
  self.metadata.name == 'cluster'
- Mode immutability on the spec:
  self.mode == oldSelf.mode

Status surface is intentionally minimal (observedGeneration, phase,
conditions) per the "status grows alongside reconcilers" decision.

CRD shape is now Cluster-scoped with shortName ecc and Mode/Phase/Age
printer columns. controller-gen output verified: scope: Cluster, both
CEL rules present, four defaults populated, three printer columns,
~1.2k lines of well-formed YAML.

go build, go vet, make generate, make manifests all pass.
…rom r3

Translate the three platform-group CRDs from r3 into Go types under
api/platform/v1alpha1/. Mirrors the CRD draft revision 3:

- SecretsManager: image override, logLevel (default info), resources.
  No replicas knob (singleton at the pod level upstream). Image-pull
  credentials inherit from EducatesClusterConfig.status.imageRegistry.
- LookupService: ingress (prefix + optional tlsSecretRef override),
  image, logLevel (default info), resources. Component-specific knobs
  (auth, rate-limiting, storage) deferred until the lookup-service
  owner specifies them.
- SessionManager: ingressOverrides, workshopPolicyOverride, images
  (overrides only — registry prefix + pullSecrets inherit from
  EducatesClusterConfig.status), themes (ConfigMap/Secret/URL source
  type), defaultTheme, tracking (Google Analytics, Amplitude, Clarity,
  webhook), defaultAccessCredentials, sessionCookieDomain,
  allowedEmbeddingHosts, storage, network (packetSize, blockedCidrs),
  imageCache (default disabled), registryMirrors, logLevel.

Shared types in common_types.go: LogLevel, ComponentPhase,
LocalObjectReference, ImageRef. WorkshopPolicyEngine is duplicated in
sessionmanager_types.go to avoid coupling the platform package to the
config API group.

All three CRDs Cluster-scoped with singleton-name CEL
(self.metadata.name == 'cluster') and Phase/Age printer columns.
Status surface intentionally minimal (observedGeneration, phase,
conditions) per the Phase 0 status policy in decisions.md.

go build, go vet, make generate, make manifests all pass. CRDs render
clean (lookup ~257, secrets ~234, session ~431 lines).
…ines

Phase 0 step 5: stand up the educates-installer Helm chart and wire the
four trivial reconcilers. The chart is now the canonical artefact for
the v4 installer; controller-gen targets it directly per the Phase 0
layout decision.

Chart at installer/charts/educates-installer/:

- Chart.yaml apiVersion v2, kubeVersion >=1.31.0-0, version and
  appVersion locked at 4.0.0-alpha.1 (matches the runtime chart's
  versioning approach but tracks operator releases independently).
- crds/: the four CRDs from controller-gen, in Helm's reserved
  location — installed once on first helm install, not templated, not
  deleted on uninstall (mirrors the runtime chart's CRD-shipping
  decision).
- templates/rbac/role.yaml: ClusterRole "educates-installer-manager"
  generated by controller-gen. Phase 0 RBAC scope is exactly the four
  CRDs and their /status + /finalizers — no Secrets/ClusterIssuers/
  IngressClasses watches yet (those land in Phase 1 with the Inline
  validator).
- templates/rbac/role-binding.yaml, serviceaccount.yaml,
  deployment.yaml: hand-written, Helm-templated. Deployment runs the
  manager binary with --health-probe-bind-address=:8081, metrics off
  by default, leader election off (single replica).
- values.yaml: image as repository + tag (dev placeholder),
  imagePullSecrets, resources, nodeSelector, tolerations, affinity,
  leaderElection.enabled. Comment block in values.yaml documents the
  Phase 0 local-dev workflow (make docker-build + kind load + helm
  install).
- NOTES.txt: post-install message naming the four CRDs, calling out
  the Phase 0 stub-only state, and listing useful kubectl commands.

Operator changes:

- All four Reconcile() bodies emit a single "Reconciling X" log line
  with the request name and return — gives the smoke test something
  to grep for. The kubebuilder TODO scaffolding is removed and replaced
  with a Phase-pointing doc comment.
- Makefile manifests target now writes to ../charts/educates-installer/
  {crds,templates/rbac}/ instead of bin/manifests/. Role name set to
  "educates-installer-manager" to match the chart's hand-written
  ClusterRoleBinding.

Verified: go build/vet/generate clean, helm lint passes (only the
benign "icon is recommended" info), helm template renders all four
expected resources (ServiceAccount, ClusterRole, ClusterRoleBinding,
Deployment).
Phase 0 step 6 / final: replace the kubebuilder-scaffolded reconciler
tests with Phase 0 CEL validation specs, wire envtest to load CRDs from
the chart, add a local kind-based smoke test, and add a repo-root CI
workflow.

CRD validation tests (envtest, ginkgo):

- EducatesClusterConfig (config group): three specs — valid Managed-mode
  CR named "cluster" is accepted; CR with name != "cluster" is rejected
  by the singleton CEL; spec.mode change on update is rejected by the
  mode-immutability CEL.
- Platform group: one shared file, one Describe per CRD. Each verifies
  singleton-name acceptance and rejection.
- Both suite_test.go files now point at
  installer/charts/educates-installer/crds/ instead of the no-longer-
  present kubebuilder default config/crd/bases.
- The kubebuilder-scaffolded reconciler tests (one per kind, with TODO
  placeholders and incompatible "test-resource" naming) are removed.

Smoke test (hack/smoke-test.sh, local-only):

- Creates kind cluster on demand, builds the operator image with
  make docker-build, kind-loads it, helm-installs the
  educates-installer chart, applies a minimal EducatesClusterConfig,
  and asserts the "Reconciling EducatesClusterConfig" log line appears
  within 60s. Tears down on exit unless KEEP_CLUSTER=true.
- Wired into the previously-stubbed make smoke-test target.

CI (.github/workflows/installer-operator-ci.yaml):

- Triggers on changes to installer/operator/, the chart,
  go.work/go.work.sum, or the workflow itself.
- Steps: go vet, go build, manifests-drift check, generate-drift
  check, make test (envtest), make lint.
- Uses go-version-file pointing at the operator's go.mod so CI tracks
  whatever the project declares.

Go-version pin lowered:

- go.work and operator go.mod were both bumped to 1.25.7 by kubebuilder
  init. That triggered a "compile: version go1.25.7 does not match go
  tool version go1.25.6" warning chain under bash -e, which cascaded
  through the test recipe even though tests themselves passed. Lowered
  both to 1.25.0 — works under any 1.25.x toolchain and keeps the
  workspace consistent with the existing client-programs and node-ca-
  injector modules.

Operator README:

- Replaces the kubebuilder TODO-stub README with a tight summary of
  layout, the architecture docs, and the make targets.
Phase 1 step 1: extend the EducatesClusterConfig API surface with the
two structural rules deferred from Phase 0 and the inter-CR contract
fields component reconcilers will read.

CEL exclusivity (two new rules on EducatesClusterConfigSpec):

- When mode is Inline, the Managed-mode top-level fields
  (infrastructure, ingress, dns, policyEnforcement, imageRegistry) are
  forbidden.
- When mode is Managed, spec.inline is forbidden.

Combined with the existing mode-immutability rule, the spec now carries
three CEL invariants. All three are envtest-verified.

Status surface (the inter-CR contract):

- status.mode echoes spec.mode at the time of last successful reconcile
  so components can branch without reading spec.
- status.ingress (StatusIngress): domain, ingressClassName,
  wildcardCertificateSecretRef (NamespacedSecretRef — namespace +
  name), optional caCertificateSecretRef, optional clusterIssuerRef.
- status.policyEnforcement (StatusPolicyEnforcement): clusterPolicyEngine,
  workshopPolicyEngine.
- status.imageRegistry (reuses spec ImageRegistry shape): prefix and
  pullSecrets, populated even when empty so components see a single
  source of truth.

Status fields are populated by the Phase 1 reconciler in the next step.
The Managed-mode-only fields (bundledChartVersions) and conditions
(InfrastructureConfigured, IngressReady, CertificatesReady, DNSReady,
PolicyEnforcementReady) remain deferred to Phase 2/3 alongside their
producing reconcilers.

go build, go vet, make generate/manifests, and make test all pass; the
generated CRD reflects all three CEL rules.
Phase 1 step 2: thread the operator's own namespace from the chart
through to the reconciler, and restrict the Secret cache to that
namespace.

- Chart Deployment template: inject OPERATOR_NAMESPACE via the
  downward API (fieldRef metadata.namespace).
- cmd/main.go: read OPERATOR_NAMESPACE at startup; fail fast if unset
  so a misconfigured Deployment doesn't silently misbehave.
- Manager cache.Options.ByObject restricts &corev1.Secret{} reads to
  the operator namespace only — user-supplied Secrets referenced from
  spec.inline live there, and the operator has no need to cache
  Secrets cluster-wide. ClusterIssuers (cluster-scoped) and
  IngressClasses (cluster-scoped) keep cluster-wide cache, as they
  must.
- EducatesClusterConfigReconciler gains an OperatorNamespace string
  field; main.go threads the value in. The Phase 0 stub doesn't read
  it yet, but the wiring is now in place for the Inline validator
  (steps 4-5).

go build, go vet, make test all pass; helm lint clean; helm template
shows the new env var injection in the rendered Deployment.
Phase 1 step 3: extend the operator's ClusterRole with read-only access
to the resources Inline-mode validation needs to look up.

- Secrets (core): for the wildcard TLS Secret, optional CA Secret, and
  imageRegistry pullSecrets. Reads are cache-restricted to the
  operator namespace by the Phase 1 step 2 cache.Options.
- ClusterIssuers (cert-manager.io): when spec.inline.ingress.
  clusterIssuerRef is set, the validator checks existence and the
  Ready condition.
- IngressClasses (networking.k8s.io): the validator checks the
  referenced IngressClass exists.

All three are get/list/watch only — Inline mode never modifies cluster
state. Markers added on the EducatesClusterConfigReconciler so
controller-gen produces them; role.yaml regenerated into the chart.
Phase 1 step 4: implement the EducatesClusterConfig Inline-mode
reconciler. The operator now drains validates referenced cluster state
and publishes the inter-CR status contract that Phase 4 components
will consume.

Validator (validator.go):

- checkIngressClass: cluster-scoped Get against networkingv1.IngressClass.
- checkWildcardSecret: Get in operator namespace; assert tls.crt and
  tls.key keys present.
- checkCASecret: optional; Get in operator namespace; assert ca.crt key
  present.
- checkClusterIssuer: optional; unstructured.Unstructured against
  cert-manager.io/v1 ClusterIssuer; assert status.conditions[Ready]==True.
  IsNoMatchError (cert-manager CRD absent) is surfaced as a validation
  error rather than a reconcile retry — matches how a user would
  experience it. Vendored cert-manager Go types are deferred to Phase 2
  per the recorded decision.
- validationError type carries spec.<field> path + reason so condition
  messages name the offending input.

Reconcile flow (educatesclusterconfig_controller.go):

- Add finalizer "educatesclusterconfig.config.educates.dev/finalizer"
  on first sight; first pass returns Requeue so the next pass sees a
  stable resource version. Phase 1 deletion handler is a no-op; Phase
  2 Managed-mode will uninstall charts here in reverse install order.
- For Inline mode: run validator → on success, populate
  status.{mode,ingress,policyEnforcement,imageRegistry} and set
  Ready=True / ValidationSucceeded=True. On failure: set Phase Degraded
  and both conditions to False with the field-specific message.
  status.imageRegistry is always populated (empty struct when unset)
  so components see a single source of truth.
- For Managed mode: no-op stub until Phase 2.
- Defensive guard: if mode==Inline and spec.inline is nil (CEL bypass),
  Degraded with "spec.inline required".

Envtest specs (validator_test.go):

- All-refs-valid → Ready, finalizer set, full status contract populated.
- Wildcard Secret missing → Degraded, message names the field +
  "not found".
- Wildcard Secret present without tls.crt → Degraded.
- IngressClass missing → Degraded.
- Optional CA Secret referenced but missing → Degraded.
- Delete clears the finalizer and the apiserver removes the object.

Test helpers: makeWildcardSecret uses Opaque type because
kubernetes.io/tls Secrets are apiserver-validated to require both
tls.crt + tls.key, which would block the missing-key test.

go build / vet / test all pass; config-package coverage 63.2% (the
gap is mostly the unreachable Managed-mode branch in Reconcile, which
becomes covered once Phase 2 implements it).
…onalKyvernoPolicies

The bundled Pod Security Standards (baseline + restricted) policies are
now `ValidatingPolicy` resources, which compile to native
ValidatingAdmissionPolicies and ignore Kyverno's resourceFilters
ConfigMap the way the legacy ClusterPolicy path relied on. As a result
they matched every Pod in the cluster and reported spurious audit
violations against system namespaces such as kube-system and
local-path-storage.

The kyverno-cluster-policies.yaml template now injects a
`spec.matchConstraints.namespaceSelector` into each bundled policy at
render time, matching the `training.educates.dev/policy.engine` and
`policy.name` labels session-manager stamps on its namespaces — baseline
policies select [baseline, restricted], restricted policies select
[restricted] — restoring the v3 ClusterPolicy scoping. The vendored
policy files stay pristine; scoping is purely a template concern.

Also widen `additionalKyvernoPolicies` support in the chart schemas:
clusterSecurity (applied verbatim) now accepts a legacy ClusterPolicy or
any policies.kyverno.io type (Validating/Mutating/Generating/Deleting);
workshopSecurity (runtime-scoped per environment) accepts ClusterPolicy
or ValidatingPolicy, matching what the runtime honours today.

Scenarios 06 and 09 now exercise both a ClusterPolicy and a
ValidatingPolicy on each path, with post-deploy assertions updated for
the ValidatingPolicy resource kinds and the new namespace scoping.

Repackages the embedded session-manager subchart tarball to carry these
chart changes.
The cert-manager chart bump to v1.20.3 updated embed.go, SHA256SUMS, and
the README but left load_test.go pinned to the removed v1.20.2 tarball,
failing ci-operator. The version is hardcoded here (an import cycle stops
the helm package test from referencing vendoredcharts.CertManagerVersion).
…ocol

The four-place consistency test does not cover tests that hardcode a
version string, so they go stale silently and only surface in CI. Add a
straggler grep step, document the known cert-manager load_test.go case,
and add a matching post-upgrade checklist item.
…r is deleted

The Inline-mode reconcile returned ctrl.Result{} on its terminal Ready
and validation-Degraded paths, so status was only re-evaluated on watch
events. The referenced ClusterIssuer is served by a deferred unstructured
informer (CRDWatcher); its delete event can be missed or observed against
a momentarily-stale cache, wedging status at Ready until the next spec
change. This was the watches_test.go ClusterIssuer-delete CI flake.

Add a belt-and-suspenders RequeueAfter to the Inline terminal returns so
the reconcile periodically re-runs validateInline and status self-heals,
matching the pattern already used by the Managed-mode cluster-service
reconcilers.
…orter

The `educates local cluster create` flow previously mixed four output
voices on two streams: hand-rolled `→` headers in the command, the
registry package's plain sentence prints, the deploy reporter's
`→`/`✓`/`·` lines, and kind's own logger. The reporter also corrupted
lines on a TTY — it padded carriage-return rewrites by byte length
(while `→`/`✓`/emoji are multi-byte) and left no clear-to-EOL, so
interleaved writers produced garble like `registry mirror rror ghcr.io`.

Harden the reporter: rewrites now erase to end of line (`\033[K`) and a
step occupies a single line that morphs `→ …` into its `✓`/`✗` and
commits with a newline. No width math, no trailing-space residue,
multibyte-safe.

Route the registry package through the reporter via a narrow nil-safe
`Progress` (Update-only) interface; the command owns each step's
lifecycle and the registry surfaces sub-operation detail. The docker
image-pull stream is now drained silently instead of dumped to stdout
where it corrupted the progress line.

Reshape the deploy phase into an install narrative: the cached-secrets
sync is a real step, each component collapses its apply+wait into one
`Installing <Component>` line (the LookupService/SessionManager pair
keeps apply-both-then-wait-both), the operator chart install reads
`Installing Educates Kubernetes Operator`, and the closing line is
`Educates successfully deployed`.

Kind's own logger output is left untouched for now (a follow-up tames it).
kind's cluster bootstrap previously printed its own status spinner plus a
salutation/usage footer ("You can now use your cluster with…", "Have a
question…? 🙂"), which clashed with the rest of the create output and
couldn't be themed.

Give kind a custom logger (phaseLogger) instead of its CLI logger. kind's
status helper only drives its spinner for its own *cli.Logger, so for any
other logger it falls back to V(0).Infof per phase; phaseLogger captures
those, strips the marker/ellipsis, and forwards each phase to a callback.
The create command wires that callback to its progress step's Update, so
kind's multi-line spinner collapses onto one morphing line that finalizes
as "✓ creating kind cluster 'educates': ready". The footer is turned off
(DisplayUsage/DisplaySalutation false) and the "Cluster config used is
saved to…" print is gated behind --verbose.

With --verbose, kind's real logger (full spinner + detail on stderr) is
used and framed by a plain header/footer, since a morphing line can't
wrap kind's own terminal output. kind warnings/errors always reach
stderr.
Under --verbose the deploy/delete pipelines stream helm SDK debug to the
same writer the progress reporter draws on, and kind uses its own
terminal spinner. A morphing reporter (in-place \r rewrites) would be
corrupted by that interleaved detail — the same failure class the reporter
hardening fixed.

Build the reporter with in-place morphing disabled when verbose
(`isStdoutTTY(w) && !verbose`) in the create, deploy and delete paths, so
every step state change commits its own line and interleaves cleanly with
helm/kind detail. Default (non-verbose) keeps the clean one-line-per-step
morphing.

This also lets the kind step drop its special-cased verbose branch: a
single runStep now handles both modes — default forwards kind's phases
onto the morphing line, verbose lets kind print its full spinner while the
non-morphing step frames it with committed header/footer lines.
The standalone `educates local registry deploy` and `local mirror deploy`
commands rendered a single collapsed progress line with no way to see the
underlying steps. Add a --verbose flag to both, threaded through
stepOnStdout, which now builds its reporter with in-place morphing
disabled when verbose so each sub-operation (pulling the image, adding
registry config to the kind nodes, linking to the cluster network) is
committed as its own line. Default output is unchanged. The other
stepOnStdout callers (registry/mirror/cluster delete, prune) pass false.
…for air-gap

The Contour ingress controller deployed inside a vcluster session used
hardcoded upstream Contour/Envoy image refs that bypassed image
relocation and were absent from the published image list, so vcluster
ingress could not work in air-gapped or mirrored-registry environments.

Make them first-class imageVersions entries (vcluster-internal-contour,
vcluster-internal-envoy) and rewrite the loaded Contour manifests to the
inventory refs at session creation, so they relocate via per-name
overrides like the loft-sh images and are captured in the air-gap image
list. Drop the dead contour-bundle inventory entry and its unused
constant. Repackage the vendored session-manager subchart tarball.

Also extend hack/generate-image-list.sh to render the session-manager
chart so the full imageVersions inventory (workshop environments plus
the vcluster application images) is included in the published list, and
add publish-workshop-images to the image-list job's needs so the
JDK/conda image digests resolve.
Add a None value to EducatesClusterConfig certificates.provider for
installs that serve no in-cluster TLS, and a new ingress.protocol field
(http or https) published as status.ingress.protocol so components learn
the public URL scheme independently of whether the cluster terminates
TLS. status.ingress.wildcardCertificateSecretRef becomes optional, and
the inline ingress wildcard reference is now optional with its own
protocol field.

The cert-manager phase short-circuits for the None provider, marking
CertificatesReady with reason CertificatesNotManaged and installing
nothing. The platform reconcilers nil-guard the wildcard reference and
pass the resolved protocol through to the runtime charts. A CEL rule
restricts protocol http to the None provider.

Regenerates the CRDs, the CLI-embedded chart copy, and the CRD-derived
EducatesConfig schema.
…mination

EducatesLocalConfig gains ingress.insecure, which translates to the None
certificates provider with ingress.protocol http and skips the CA lookup
and prerequisite. A local configuration with no ingress.domain now
defaults to a <host-IP>.nip.io address with insecure enabled, since that
address cannot obtain a trusted certificate. The nip.io and insecure
defaulting is consolidated into the shared maybeApplyHostDomain helper
used by render, deploy and cluster create.

externalTLSTermination on the GKE, EKS and inline kinds now maps to the
None provider with ingress.protocol https, so no unused in-cluster
certificate is issued when TLS is terminated outside the cluster. The
acme settings (GKE and EKS) and wildcardCertificateSecret (inline)
become optional when it is set. A v3 clusterIngress.protocol http
migrates to ingress.insecure.
… --defaults'

local config view now prints the effective configuration with all CLI
defaults materialised, so the values the CLI supplies (including the
nip.io domain and the insecure default it implies) are explicit. A --raw
flag prints the configuration file as written instead. When applying the
defaults changes nothing beyond the file, the explanatory comment is
skipped so the output matches --raw.

local config init gains --defaults, which writes the fully-defaulted
configuration into the file instead of the minimal apiVersion and kind.
The yaml-language-server modeline is present on every output.
Add release-notes entries for the None certificates provider,
ingress.insecure, the externalTLSTermination behaviour change, and the
local config view and init updates. Correct the migration guide and the
secure HTTP connections guide, which previously stated certificate-less
installs were not possible, and describe the insecure local default in
the local environment guide.

Rework the quick start guide to lead with the plain HTTP default and add
an optional section for serving workshops over HTTPS using a custom
domain, the local DNS resolver and a local CA, with cross-reference
labels for the relevant local environment sections.
The imagePrePuller toggle now defaults to true in EducatesLocalConfig,
EducatesGKEConfig, EducatesEKSConfig and EducatesInlineConfig, so installs
created from the CLI pre-pull workshop images onto every node ahead of
time unless imagePrePuller is set to false. Updates WithDefaults, the
embedded JSON schemas and the affected tests.

The operator SessionManager CRD and the chart keep their disabled
default, so installs applied directly from custom resources or the Helm
chart are unaffected; the CLI supplies this opinionated default itself.
A secure local install (ingress.insecure not set) needs a cached CA for
its domain, but the lookup only happened during deploy, after the kind
cluster and registry were already created. A failed lookup then left a
half-built cluster to delete by hand.

Move the CA lookup into the cluster-create preflight, ahead of any Docker
or cluster mutation, via a shared localCASecretIfSecure helper that
deploy and create both use (an insecure install skips it). The lookup is
a pure local-cache read, so a missing CA is reported without touching
Docker at all.

Also reword the missing-CA error: it now explains that a secure install
is the default and offers both fixes, adding a CA or setting
ingress.insecure to true, and shows the self-signed generate path as the
simplest option.
set unmarshalled the file into a map, mutated it and re-marshalled,
which dropped every comment (the yaml-language-server modeline included)
and re-sorted the keys alphabetically, scrambling the file on each edit.

Edit the YAML as a yaml.v3 node tree instead, so the file's comments and
key order survive the round-trip. Intermediate mapping nodes are created
as needed and existing nodes are updated in place. Output uses two-space
indentation to match the files the CLI writes.
Add a schema-driven 'educates local config explain' command that
describes configuration fields in the style of 'kubectl explain', from
the embedded JSON schemas and without needing a cluster or a
configuration file. A generic walker resolves $ref against $defs,
descends into array items, merges allOf/anyOf/oneOf branch fields, and
renders the field type, default, enum and description plus its
sub-fields.

The argument is a dotted field path; the kind is chosen with --kind
(default EducatesLocalConfig), accepting a kind name or a short alias
(local, gke, eks, inline, escape), so every config kind including the
CRD-derived escape hatch can be explored.

Also backfill field descriptions into the four scenario schemas, which
were almost undocumented. The same descriptions drive editor hovers via
the published schema, so the command and editors agree.
Document 'educates local config explain' in the configuration settings
reference and the local environment guide, and add release-notes entries
for the command and for the configuration schemas now carrying field
documentation for every kind.
The deploy pipeline applied LookupService and SessionManager together but
waited on them sequentially, so the user saw LookupService finish before
SessionManager's line even appeared — even though the operator reconciles
both at once.

Add a concurrent multi-line progress group: on a TTY the two installs
render as a contiguous block repainted in place (docker-pull style), each
line morphing with its own status.phase, so one can go Ready while the
other is still reconciling. On a non-TTY each state change appends a
grep-able line. The two waits now run in parallel goroutines, each
reporting to its own line.

Apply (and display) SessionManager before LookupService. The apply order
between the two is not load-bearing — SessionManager's reconciler watches
LookupService and re-evaluates its Auto remote-access decision when one
appears — so SessionManager goes first to read top-to-bottom in the block.
…inds none

Previously 'educates local cluster create' errored with MissingLocalConfigError when <data-home>/config.yaml was absent, forcing a separate 'local config init' first. It now writes the minimal default EducatesLocalConfig in place, reports where it landed and how to view/edit it, and continues with defaults.

The v3->v4 migration still takes priority: a laptop-kind values.yaml is migrated, and a non-laptop values.yaml still errors rather than being overwritten. The minimal-config template moves to the config package so 'local config init' and the auto-init stay byte-for-byte identical.

Getting-started guides drop the mandatory init step and document the auto-create behaviour.
Make the `educates` CLI commands consistent across every command group.

- Externalize usage examples into package-level vars and add an example
  to every leaf command that lacked one, so all commands document
  themselves consistently in `--help`.
- Validate positional arguments in the cobra Args function via new
  utils.CmdError / CmdErrorFullUsage helpers, so a missing, extra, or
  invalid argument reports a clear message plus a `--help` pointer
  instead of cobra's generic "accepts 1 arg(s), received 0". Shared
  exactArgs/maximumArgs validators live in pkg/cmd/args.go.
- Move the duplicated local secrets cache plumbing into pkg/secrets
  (CacheDir, WriteCachedSecret, LoadCachedSecret, ListCachedSecretNames,
  RemoveCachedSecret, ValidateSecretName, the secret builders, and the
  self-signed CA generator), and split `local secrets add` into one file
  per subcommand (ca, tls, docker-registry, generic).

Covers the local, admin, cluster, docker, workshop, template, project,
and tunnel groups.
… educates containers

Add two commands, reimplemented against this branch (the referenced
PR educates#871 is a divergent refactor on a different architecture and is not
directly portable):

- 'educates local mirror list' lists the deployed local registry mirrors
  as a table (name, URL, username, status, container name).
- 'educates cluster portal export' exports a training portal and the
  workshops it references as sanitised, re-appliable Kubernetes YAML —
  to stdout as one stream, or with --as-files one file per resource.
  The --image-repository / --workshop-version options substitute the
  $(image_repository) / $(workshop_version) variables in the workshops.

Give every container Educates creates via the Docker client a consistent
set of educates.dev/* labels so they can be discovered, filtered, and
cleaned up by label rather than by container name:

- New pkg/constants holds the educates.dev/{app,role,mirror,url,username}
  keys and the registry/mirror/resolver role values.
- The registry and mirror containers move to the namespaced keys (mirrors
  now also carry url/username); the resolver container, which previously
  had no labels, is labelled app=educates role=resolver.
- ListRegistryMirrors and the bulk-delete path share one label filter.

The kind node container is created by the kind library and keeps its own
io.x-k8s.kind.* labels; it stays discovered by its well-known name.
Replace the per-command text/tabwriter blocks with the shared table
helpers so tabular output is produced one way across the CLI. Column
headers and output are unchanged.

- utils.PrintTable: cluster portal list, cluster portal password --admin,
  cluster workshop list, cluster session list, docker workshop list.
- utils.PrintKeyValuesTable (new): the cluster session status / extend /
  terminate detail output, previously a duplicated six-line block, now a
  shared printSessionDetails helper.
…cker preflight

'educates local cluster create' gains a --kubernetes-version flag (supported
1.33-1.36, default 1.36; an explicit --kind-cluster-image still wins), backed
by a new pkg/constants/kubernetes.go map of kind v0.32.0 node images.

The local Kind cluster can now be multi-node: EducatesLocalConfig cluster.nodes
declares each node's role, Kubernetes labels, and taints, rendered into the kind
config. A single control-plane remains the default when nodes is unset. Adds the
config type, the JSON schema entry, and a testdata fixture.

Adds a Docker-daemon preflight (docker.CheckDaemonRunning) run before every
command group that needs Docker (local cluster/registry/mirror/resolver and
docker workshops), so a stopped daemon reports a clear, actionable message
instead of a cryptic kind ("docker ps ... exit status 1") or Docker SDK error.
The port-availability preflight now pulls busybox only when it isn't already
cached locally.

Updates the educates-upgrade-kubernetes skill to the current supported set and
default, and notes to keep version-referencing code comments in sync on updates.
'educates local cluster status' now also reads the Educates platform CRs
(EducatesClusterConfig, SecretsManager, LookupService, SessionManager) and
reports whether Educates is installed and its components are ready. This
distinguishes a cluster-only cluster (kind running, Educates not installed)
from an installed-but-not-ready cluster from a fully installed and ready one.

LookupService is optional; the other three are required, so an absent required
component counts as not-ready. An unreachable API (e.g. a stopped cluster) is
reported as unknown rather than failing the command.
…ling

Reconcile the operator's own CRDs to the schemas embedded in the binary
before the manager starts, so an imperative `helm upgrade` (which never
touches the chart's crds/) and a declarative GitOps sync (which re-applies
them) converge on identical CRD schemas at the moment a schema changes.
`make manifests` now mirrors the generated CRDs into internal/crds/files/,
.dockerignore re-includes them for the image build, and the operator gains
customresourcedefinitions write access in its ClusterRole.

Also harden and tidy the Helm and cluster-service layer:

- Set explicit timeouts on all four Helm actions (Install/Upgrade/
  Uninstall/Rollback) instead of inheriting the SDK's implicit 30s
  default, which is too tight for the slower hooks the vendored charts
  ship (Kyverno pre-delete cleanup, post-upgrade migration).
- Add a decWait path (ActionWaitingUninstall): a release found
  mid-uninstall now requeues quietly instead of failing an install with
  "name still in use" and surfacing it as a reconcile error.
- Make vendored-chart references in comments version-agnostic so they do
  not go stale on the next chart bump; correct the cert-manager crds
  note; drop process narrative from the cluster-admin binding doc.

Sync the CLI-embedded copy of the installer chart.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant