diff --git a/.claude/skills/chart-change.md b/.claude/skills/chart-change.md new file mode 100644 index 0000000..96239f3 --- /dev/null +++ b/.claude/skills/chart-change.md @@ -0,0 +1,50 @@ +--- +name: chart-change +description: Change an EduIDE Helm chart safely. Use when editing templates or values in EduIDE-Helm, or when asked why a chart change is or is not behaviour-preserving. +--- + +# Changing a chart + +The question that matters is not "does it lint" but **"what does this do to the +five live environments"**. There is a command for that. + +## Loop + +```bash +# 1. baseline BEFORE touching anything +./scripts/render-envs.sh /tmp/before + +# 2. make one change, one concern at a time + +# 3. what did it actually do? +./scripts/render-envs.sh /tmp/after +diff -r /tmp/before /tmp/after +``` + +For a refactor the diff must be **empty**. If it is not, either the refactor is +not behaviour-preserving or the change was larger than intended — both worth +knowing before review. + +For an intentional change, the diff should contain exactly that change and +nothing else. Ten lines across five environments is one line per environment. + +## Then + +```bash +helm lint charts/eduide charts/eduide-cluster +# bump the chart version - CI enforces it, and release.yml silently +# publishes nothing if you forget +docker run --rm -v "$PWD/charts:/helm-docs" -u "$(id -u)" jnorwood/helm-docs:v1.14.2 +``` + +## Traps + +- **Never release-prefix resource names.** The operator mounts + `oauth2-proxy-config`, `oauth2-templates` and `oauth2-emails` by literal name + into every session pod. +- **Adding a `lookup`?** Add it to the mask list in `scripts/render-envs.sh` too, + or every future PR shows a false diff. +- **A Go-template comment is `{{/* */}}`.** A YAML `#` comment inside a template + ends up in the rendered manifest and shows as a diff. +- **`helm lint` accepts invalid YAML.** Duplicate keys pass lint and render, and + are only caught by `kubeconform`. Run it. diff --git a/.claude/skills/cut-a-release.md b/.claude/skills/cut-a-release.md new file mode 100644 index 0000000..fbddcc9 --- /dev/null +++ b/.claude/skills/cut-a-release.md @@ -0,0 +1,76 @@ +--- +name: cut-a-release +description: Cut an EduIDE platform release across all four repositories. Use when asked to release, cut a version, publish charts, or ship a version of EduIDE. +--- + +# Cutting a release + +A release is one version across EduIDE-Cloud, EduIDE, EduIDE-Landing-Page and +EduIDE-Helm. + +**Do not bump the chart version from a workflow or by pushing to `main`.** The +release train deliberately does not do this, and neither should you. It checks +the charts are already at the requested version and fails otherwise. The bump is +a reviewed pull request; automation that pushes to `main` triggers the workflows +watching `main`. + +## 1. Dry run first, always + +```bash +gh workflow run release-train.yml --repo EduIDE/EduIDE-Helm \ + -f version=2.3.0 -f dry_run=true +``` + +Read the summary. It reports which images the version would need, and builds +nothing. + +## 2. Bump both charts in a pull request + +Both charts, both fields — four values, all identical: + +```yaml +# charts/eduide/Chart.yaml AND charts/eduide-cluster/Chart.yaml +version: 2.3.0 +appVersion: "2.3.0" +``` + +Then regenerate the READMEs, or the `docs-drift` job fails: + +```bash +docker run --rm -v "$PWD/charts:/helm-docs" -u "$(id -u)" jnorwood/helm-docs:v1.14.2 +``` + +Open the PR and let CI run. Do not merge it yourself unless asked to. + +## 3. Run it for real + +```bash +gh workflow run release-train.yml --repo EduIDE/EduIDE-Helm \ + -f version=2.3.0 -f dry_run=false +``` + +Order: validate, build all 14 images, verify they exist and are multi-arch, +**then** tag, then publish. Building before tagging means a flaky image build +costs a re-run rather than stranding immutable tags on repositories whose +images were never published. + +## 4. Roll it out separately + +The train deploys nothing. In EduIDE-deployment, bump +`spec.platform.chartVersion` in the relevant `environments/*/env.yaml`, in a +pull request. Production is never deployed automatically. + +## Version forms + +- git tags `vX.Y.Z` +- chart `version`, chart `appVersion` and image tags all `X.Y.Z` +- release candidates `2.3.0-rc.1` throughout + +## Common failures + +| Message | Meaning | +|---|---| +| `version is 'X', expected 'Y'` | step 2 skipped, or only one chart/field bumped | +| `missing ghcr.io/...` | a component build failed; check that repository's Actions | +| `is not multi-arch` | one architecture failed; re-run the whole build, not just the merge job | +| `tag v2.3.0 already exists` | pick the next version, tags are immutable | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b91aaa9..05137b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: with: version: ${{ env.HELM_VERSION }} + - name: AGENTS.md does not reference missing paths + run: ./scripts/check-agents-md.sh + - name: helm lint run: | set -euo pipefail @@ -40,6 +43,9 @@ jobs: echo "::endgroup::" done + - name: App definitions, landing page and preloading agree + run: ./scripts/test-app-consistency.sh + - name: Chart version must be bumped when a chart changes if: github.event_name == 'pull_request' run: | @@ -103,7 +109,9 @@ jobs: failed=0 for chart in charts/*/; do echo "::group::kubeconform $(basename "$chart")" + ./scripts/resolve-deps.sh "$chart" helm template test "$chart" \ + --set keycloak.allowUnauthenticated=true \ | kubeconform -strict -summary -ignore-missing-schemas -skip HTTPRoute "${SCHEMAS[@]}" || failed=1 echo "::endgroup::" done diff --git a/.github/workflows/release-train.yml b/.github/workflows/release-train.yml new file mode 100644 index 0000000..a2b2cc2 --- /dev/null +++ b/.github/workflows/release-train.yml @@ -0,0 +1,258 @@ +# Cut one EduIDE platform release across four repositories. +# +# The order is deliberate: BUILD, VERIFY, then TAG. +# +# Tagging first is the obvious design and the wrong one. Building 15 multi-GB +# IDE images is the flakiest step in the pipeline; if image 17 of 19 fails after +# the tags exist, immutable vX.Y.Z tags are stranded on repositories whose +# images were never published. Building first means a flake costs a re-run. +# +# Every image is checked to actually exist before any chart claims to pin it, +# which removes the "chart pins a tag that was never pushed" failure mode. + +name: Release train + +on: + workflow_dispatch: + inputs: + version: + description: "Platform version, e.g. 2.3.0 or 2.3.0-rc.1 (no leading v)" + required: true + type: string + dry_run: + description: "Plan only. Build nothing, tag nothing, publish nothing." + type: boolean + default: true + +permissions: + contents: write + packages: write + +env: + # Everything the platform version pins. swift is deliberately absent: it + # exists under images/ and in the compose file but is in no build matrix, so + # it is never published. Listing it here would fail every release; leaving it + # implicit would let the gap rot unnoticed. + CLOUD_IMAGES: "eduide-cloud/operator eduide-cloud/service eduide-cloud/conversion-webhook" + IDE_IMAGES: "eduide/base eduide/c eduide/c-templates eduide/haskell eduide/java-17 eduide/java-17-templates eduide/javascript eduide/ocaml eduide/python eduide/rust" + OTHER_IMAGES: "eduidec-landing-page" + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@v4 + - id: v + env: + V: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "$V" == v* ]]; then + echo "::error::give the version without a leading v (chart versions and image tags are X.Y.Z)" + exit 1 + fi + if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::'$V' is not semver" + exit 1 + fi + if git ls-remote --tags origin "refs/tags/v$V" | grep -q .; then + echo "::error::tag v$V already exists in this repository" + exit 1 + fi + echo "version=$V" >> "$GITHUB_OUTPUT" + + # Build every component at the release tag, without tagging anything yet. + build: + needs: validate + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + repo: [EduIDE-Cloud, EduIDE, EduIDE-Landing-Page] + steps: + - name: Dispatch the component build + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + wf=$([[ "${{ matrix.repo }}" == "EduIDE-Landing-Page" ]] && echo docker-build.yml || echo build.yml) + gh workflow run "$wf" --repo "EduIDE/${{ matrix.repo }}" --ref main -f image_tag="$V" + echo "dispatched $wf on ${{ matrix.repo }} with image_tag=$V" + + - name: Wait for it + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + sleep 20 + for _ in $(seq 1 120); do + read -r run_id status conclusion < <( + gh run list --repo "EduIDE/${{ matrix.repo }}" --limit 1 \ + --json databaseId,status,conclusion \ + --jq '.[0] | "\(.databaseId) \(.status) \(.conclusion)"') + if [[ "$status" == "completed" ]]; then + if [[ "$conclusion" != "success" ]]; then + echo "::error::${{ matrix.repo }} build ${run_id} concluded ${conclusion}" + exit 1 + fi + echo "${{ matrix.repo }} build ${run_id} succeeded" + exit 0 + fi + sleep 30 + done + echo "::error::${{ matrix.repo }} build did not finish in an hour"; exit 1 + + # Nothing may claim to pin a tag that was never pushed. + verify-images: + needs: [validate, build] + if: ${{ always() && needs.validate.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Install crane + run: | + set -euo pipefail + curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \ + | sudo tar -xz -C /usr/local/bin crane + echo '${{ secrets.GITHUB_TOKEN }}' | crane auth login ghcr.io -u '${{ github.actor }}' --password-stdin + + - name: Every expected image exists, for both architectures + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -uo pipefail + missing=0; single=0; n=0 + for img in $CLOUD_IMAGES $IDE_IMAGES $OTHER_IMAGES; do + n=$((n + 1)) + ref="ghcr.io/eduide/${img}:${V}" + if ! manifest=$(crane manifest "$ref" 2>/dev/null); then + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + echo " would need $ref" + else + echo "::error::missing $ref"; missing=$((missing + 1)) + fi + continue + fi + arches=$(jq -r '[.manifests[]?.platform.architecture] | sort | join(",")' <<<"$manifest") + if [[ "$arches" != *amd64* || "$arches" != *arm64* ]]; then + echo "::error::$ref is not multi-arch (has: ${arches:-none})"; single=$((single + 1)) + else + echo " ok $ref [$arches]" + fi + done + echo "checked $n images" + if [[ "${{ inputs.dry_run }}" == "true" ]]; then exit 0; fi + [[ $missing -eq 0 && $single -eq 0 ]] || exit 1 + + # Only now do immutable tags appear. + tag-components: + needs: [validate, verify-images] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + strategy: + matrix: + repo: [EduIDE-Cloud, EduIDE, EduIDE-Landing-Page] + steps: + - name: Tag and release + env: + GH_TOKEN: ${{ secrets.RELEASE_TRAIN_TOKEN || secrets.GITHUB_TOKEN }} + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + pre="" + [[ "$V" == *-* ]] && pre="--prerelease" + gh release create "v$V" --repo "EduIDE/${{ matrix.repo }}" \ + --title "v$V" --generate-notes $pre \ + --notes "Images for this release were built and verified before the tag was created." + + # The version bump is a reviewed commit, not something this workflow makes. + # A workflow that pushes to main triggers the workflows that watch main, and + # release automation able to trigger itself is a bad thing to own. This job + # therefore CHECKS the charts are already at the requested version and fails + # with instructions if they are not. + publish-charts: + needs: [validate, verify-images] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + + - name: The charts must already be at this version + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + fail=0 + for c in eduide eduide-cluster; do + cv=$(yq -r '.version' "charts/$c/Chart.yaml") + av=$(yq -r '.appVersion' "charts/$c/Chart.yaml") + if [[ "$cv" != "$V" ]]; then + echo "::error file=charts/$c/Chart.yaml::version is '$cv', expected '$V'" + fail=1 + fi + if [[ "$av" != "$V" ]]; then + echo "::error file=charts/$c/Chart.yaml::appVersion is '$av', expected '$V'" + fail=1 + fi + done + if [[ $fail -ne 0 ]]; then + echo "::error::Bump the charts in a reviewed pull request first, then re-run. See README: Cutting a release." + exit 1 + fi + echo "both charts are at $V" + + - name: Tag this repository + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + git tag -a "v${V}" -m "EduIDE ${V}" + git push origin "v${V}" + + - name: Publish both charts + env: + V: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin + mkdir -p dist + for c in eduide-cluster eduide; do + ./scripts/resolve-deps.sh "charts/$c" + helm package "charts/$c" --destination dist + helm push "dist/${c}-${V}.tgz" oci://ghcr.io/eduide/charts + done + + summary: + needs: [validate, verify-images, tag-components, publish-charts] + if: always() + runs-on: ubuntu-latest + steps: + - env: + V: ${{ needs.validate.outputs.version }} + run: | + { + echo "## EduIDE ${V}" + echo "" + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + echo "Dry run. Nothing was built, tagged or published." + else + echo '```bash' + echo "helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster --version ${V} -n eduide-system --create-namespace" + echo "helm install eduide oci://ghcr.io/eduide/charts/eduide --version ${V} -n " + echo '```' + echo "" + echo "Bump \`spec.platform.chartVersion\` in the relevant \`environments/*/env.yaml\` to roll this out. Production is never deployed automatically." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c698b8..b1b0749 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,8 @@ jobs: mkdir -p dist charts=( - theia-cloud-base - theia-cloud-crds - theia-cloud + eduide-cluster + eduide ) for chart in "${charts[@]}"; do @@ -63,6 +62,7 @@ jobs: continue fi + ./scripts/resolve-deps.sh "charts/${chart}" helm package "charts/${chart}" --destination dist helm push "${artifact}" "${OCI_PREFIX}" helm show chart "${ref}" --version "${version}" >/dev/null @@ -103,18 +103,31 @@ jobs: OWNER="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" OCI_PREFIX="oci://${OCI_REGISTRY}/${OWNER}/${OCI_REPOSITORY}" - PREVIEW_SUFFIX="pr-${PR_NUMBER}" mkdir -p dist + # A preview version has to be valid semver, and it has to sort below + # the release it previews so `helm upgrade` never picks one up by + # accident. + # + # Appending ".pr-24" only worked while every chart version was already + # a prerelease: 1.4.0-next.7.pr-24 parses, because the dot just adds + # another prerelease identifier. 2.0.0.pr-24 does not - a release + # version needs the hyphen that starts the prerelease part. Bumping to + # a clean 2.0.0 is what surfaced this. + # + # The sha keeps successive pushes on one PR distinguishable. + SHA7="$(git rev-parse --short=7 HEAD)" to_preview_version() { local base="$1" - printf '%s.%s' "$base" "$PREVIEW_SUFFIX" + case "$base" in + *-*) printf '%s.pr.%s.%s' "$base" "$PR_NUMBER" "$SHA7" ;; # already a prerelease + *) printf '%s-pr.%s.%s' "$base" "$PR_NUMBER" "$SHA7" ;; + esac } charts=( - theia-cloud-base - theia-cloud-crds - theia-cloud + eduide-cluster + eduide ) for chart in "${charts[@]}"; do @@ -130,6 +143,9 @@ jobs: sed -i "s/^version: .*/version: ${preview_version}/" "${work_dir}/Chart.yaml" + # The copy under dist/ needs its own dependencies: helm package + # reads charts/ from the directory it is given, not from the source. + ./scripts/resolve-deps.sh "$work_dir" helm package "$work_dir" --destination dist helm push "${artifact}" "${OCI_PREFIX}" helm show chart "${ref}" --version "${preview_version}" >/dev/null diff --git a/.gitignore b/.gitignore index e43b0f9..d34bb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .DS_Store + +# Resolved by `helm dependency update`. Chart.lock is committed; the archives +# it pins are not - they are pulled at build and publish time. +charts/*/charts/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3337314 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,189 @@ +# AGENTS.md — EduIDE-Helm + +The Helm charts. This is the installable product: an administrator anywhere +gets EduIDE from here. + +`CLAUDE.md` is a symlink to this file. + +## Two charts + +| Chart | Installed | Owns | +|---|---|---| +| `eduide-cluster` | once per **cluster** | CRDs, conversion webhook, ClusterRoles, cert-manager issuers | +| `eduide` | once per **environment** | operator, REST service, landing page, routes | + +Released together, same version. Environment values live in +**EduIDE-deployment**, not here. + +## Before you change a template + +```bash +helm lint charts/eduide charts/eduide-cluster +./scripts/render-envs.sh /tmp/out # renders every real environment +``` + +CI additionally renders the PR base and head and posts the diff. **For a pure +refactor the expected result is an empty diff.** That is the acceptance +criterion, not "it still lints". + +## Things that will catch you out + +**Do not release-prefix resource names.** The operator mounts +`oauth2-proxy-config`, `oauth2-templates` and `oauth2-emails` **by literal name** +into every session pod — see `AddedHandlerUtil.java:88` and +`templateDeployment.yaml` in EduIDE-Cloud. Renaming them breaks every running +session. One install is one namespace, so prefixing buys no collision +protection anyway. Use `app.kubernetes.io/*` labels for grouping; they are +additive on upgrade. + +**CRDs are templates, not `crds/`.** Helm never upgrades anything in `crds/`, +and these change most releases (`v1beta8` through `v1beta11`). They carry +`helm.sh/resource-policy: keep` because deleting a CRD deletes every live +Session, Workspace and AppDefinition on the cluster. + +**The conversion webhook belongs to the cluster chart.** A CRD names exactly +one conversion service, so it cannot be a per-environment resource. + +**Preloading must not be under `--wait`.** It pulls ~10 multi-GB images on every +node. Inside the main release it would time out and `--atomic` would roll back a +healthy deploy. + +**`hosts.usePaths` is gone.** Do not reintroduce path-based routing without +wiring it through the Gateway API properly; it branched in seven files and no +environment ever set it. + +**Host names come from the helpers.** `theia-cloud.host.{base,landing,service,instance}` +and `theia-cloud.url.service` in `_helpers.tpl`. Before those existed the same +two-line `printf` was re-derived inline in about a dozen places, each wrapped in +`tpl (X | toString) .`. + +## `lookup` makes rendering nondeterministic + +`theia-appdefinitions` preserves live scaling values and `theia-shared-cache` +generates a Redis password when its lookup finds nothing. Both are correct, but +`lookup` returns empty under `helm template`, so both are masked in +`scripts/render-envs.sh`. **Add any new `lookup` to the mask list**, or +render-diff fills with false changes and stops being read. + +## Releasing + +`Release train` cuts a version across all four repositories. It builds, then +verifies every image exists and is multi-arch, and only then tags — because a +failure after tagging strands immutable tags on repositories whose images were +never published. + +**It does not bump the chart version.** That is a reviewed pull request; the +workflow checks the charts are already at the requested version and fails +otherwise. Do not make automation push to `main`: it triggers the workflows +watching `main`. Full procedure in the README, and as a skill in +`.claude/skills/cut-a-release.md`. + +## Conventions + +- A changed chart must have a bumped version. CI enforces this: `release.yml` + skips a version that already exists, so a forgotten bump publishes nothing + and fails silently. It has happened three times. +- Chart READMEs are generated. Run helm-docs and commit the result; CI fails on + drift. +- `kubeconform` skips `HTTPRoute`: the CRDs-catalog schema declares + `minItems: 1` on `spec.rules`, but the upstream Gateway API CRD does not, and + `httproute-instances.yaml` ships `rules: []` deliberately for the operator to + patch. + +## appDefinitions.apps is the single source of truth + +Three things derive from it: the AppDefinition custom resources, the app list +the landing page offers, and the images the preloading DaemonSet pulls onto +every node. **Never write a preload list, and never add an app in two places.** + +They used to be three hand-maintained lists across two repositories, the +preload one addressed by array index, with production's list one shorter than +test's. Production offered `c-templates` while preloading everything except +`c-templates`. `scripts/test-app-consistency.sh` asserts they agree and CI runs +it; it also fails on any floating tag in a default render. + +## One version knob per source repository + +`versions.ide` (EduIDE), `versions.cloud` (EduIDE-Cloud), `versions.landingPage` +(EduIDE-Landing-Page). `versions.ide` empty means the chart's `appVersion`, so +`helm install --version X` with no overrides pins every image to that release. + +A deploy override names exactly one. Never set a blanket tag - a pull request +only builds the images of the repo it came from, so the rest of the namespace +goes into `ImagePullBackOff`. + +The three image values are plain strings rendered through `tpl`, so they can +interpolate `.Values.versions.*` without any template change. Keep it that way: +turning them into `{registry, repository, tag}` maps would break every values +file for no gain. + +## The garbage collector is pinned to a commit, not a version + +Its repository has never cut a release - GHCR holds only `latest`, `main` and +per-commit SHAs - and its own chart defaults to `latest`. A released chart must +not install whatever was built most recently, and `helm upgrade` would see no +diff when it changed. `garbageCollector.image.tag` therefore pins a commit SHA. +Replace it with a semver tag when that repo starts releasing. + +## Dependency aliases must be lowercase + +An `alias:` becomes `.Chart.Name` inside the subchart, and charts build resource +names and label values from it. `alias: sharedCache` rendered +`sharedCache-redis`, which the API server rejects - RFC 1123 names are lowercase +only, and `helm template` renders it happily. The dependencies are declared +under their real names for that reason, so the values keys are +`eduide-shared-cache:` and `theia-workspace-garbage-collector:`. +`test-app-consistency.sh` checks every rendered name. + +## One expected warning from `helm template` + +``` +warning: cannot overwrite table with non table for eduide-shared-cache.gateway.parentRefs +``` + +Both this chart and the cache subchart have a top-level `gateway:` table, and +this chart's `parentRefs` is a list where the subchart's is a map. Helm +coalesces the parent's table down and says so. The subchart's map wins, its +HTTPRoutes stay off, and only this chart's three routes render - +`test-app-consistency.sh` asserts exactly that, so the day the behaviour +changes it fails rather than quietly publishing routes for hostnames nobody +configured. + +Do not parse `helm template` output with `2>&1`. That warning lands in the YAML +and anything downstream reads it as a broken document. + +## Always resolve dependencies before touching a chart + +`charts/*/charts/` is gitignored - dependencies are resolved, not vendored, +which is what stops Chart.lock, Chart.yaml and HEAD drifting apart the way they +used to. The cost is that a fresh checkout has none, and `helm template`, +`helm package` and `helm dependency list` all refuse outright rather than +degrading. + +That was rediscovered four times in one afternoon: the kubeconform job, +`render-envs.sh`, `test-app-consistency.sh` and the PR preview publish. Call +`scripts/resolve-deps.sh ` first; every caller does. + +## Preflight checks must stay silent offline + +`helm template`, the render diff and CI all run without a cluster. A preflight +that fires there breaks all three. `lookup` returns empty both offline and when +the thing is genuinely missing, so it cannot tell them apart alone - look up the +`kube-system` namespace first, and only check anything if that comes back. + +The earlier version keyed off `.Release.IsInstall`, which is true under +`helm template` too. It went unnoticed because the define was never invoked from +any template. Both are fixed; the includes are at the top of `operator.yaml`, +which every install renders. + +## The oauth2 ConfigMaps are not gated on keycloak.enable + +They cannot be: the operator mounts `oauth2-proxy-config`, `oauth2-templates` +and `oauth2-emails` into every session pod by literal name. So a chart left at +the default `keycloak.authUrl` ships a live proxy pointed at +`https://keycloak.url/auth/realms/TheiaCloud`, and sessions fail at the proxy +instead of running unauthenticated. + +`eduide.preflightKeycloak` refuses to render on the placeholder values. +An installation that genuinely has no identity provider yet sets +`keycloak.allowUnauthenticated: true`. diff --git a/README.md b/README.md index b7701c0..39cb244 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,207 @@ -# EduIDE Cloud Helm Charts +# EduIDE Helm Charts -This repository contains the helm charts for Theia Cloud. +The installable EduIDE platform. Two charts, published as OCI artifacts to +`ghcr.io/eduide/charts`. -There are three charts: +| Chart | Installed | Owns | +|---|---|---| +| `eduide-cluster` | once per **cluster** | CRDs, conversion webhook, ClusterRoles, cert-manager issuers | +| `eduide` | once per **environment** | operator, REST service, landing page, routes | -- `theia-cloud-base` installs cluster wide resources that may be used by multiple Theia Cloud installations -- `theia-cloud-crds` installs the custom resource definitions -- `theia-cloud` installs Theia Cloud itself and depends on `theia-cloud-base` and `theia-cloud-crds` +```bash +# once per cluster +helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster \ + --version 2.0.0 -n eduide-system --create-namespace + +# once per environment +helm install eduide oci://ghcr.io/eduide/charts/eduide \ + --version 2.0.0 -n eduide-test1 -f my-values.yaml +``` + +Both charts always carry the same version. `docs/charts.md` explains why the +split exists and what it is safe to change. + +## What one install pins + +A bare `--version 2.0.0` with no overrides pins every image. Nothing floats. + +| Value | Source repository | Default | +|---|---|---| +| `versions.ide` | EduIDE (the IDE images) | empty → the chart's `appVersion` | +| `versions.cloud` | EduIDE-Cloud (operator, REST service) | `1.2.0` | +| `versions.landingPage` | EduIDE-Landing-Page | `1.2.0` | + +The three release on their own cadence, so each has its own knob and an +override names exactly one. A blanket tag would be wrong: a pull request only +builds the images of the repo it came from, so pointing everything at `pr-451` +puts the rest of the namespace into `ImagePullBackOff`. + +## Adding a language + +One entry in `appDefinitions.apps`: + +```yaml +appDefinitions: + apps: + haskell-latest: + image: eduide/haskell # tag comes from versions.ide + landingPage: + label: Haskell # omit this key to deploy it but hide it +``` + +That single map drives the AppDefinition custom resource, the landing page's +app list, and the set of images preloaded onto every node. It used to be three +hand-maintained lists across two repositories, the preload one addressed by +array index - which is how production came to offer `c-templates` while +preloading everything except `c-templates`. +`scripts/test-app-consistency.sh` asserts the three agree, and CI runs it. + +Environment configuration for the TUM installations lives in +[EduIDE-deployment](https://github.com/EduIDE/EduIDE-deployment), not here. + +--- + +# Cutting a release + +A release is one version across four repositories: EduIDE-Cloud, EduIDE, +EduIDE-Landing-Page and this one. + +**The release train does not bump the chart version.** You do, in a reviewed +pull request. The workflow checks the charts are already at the version you +asked for and refuses to continue otherwise. Automation that pushes to `main` +triggers the workflows watching `main`, and release automation that can trigger +itself is a bad thing to own. + +## The three steps + +### 1. Dry run + +``` +Actions -> Release train -> Run workflow + version: 2.3.0 (no leading v) + dry_run: true +``` -## Release Model +Nothing is built, tagged or published. It reports which images the version +would need. Read the summary before continuing. -Released charts are published as OCI artifacts to `ghcr.io/eduide/charts`. -`theia-deployment` should consume those published chart versions directly for normal staging and production deployments. +### 2. Bump the charts in a pull request -Pull requests also publish preview OCI chart versions using a `pr-` suffix on top of the chart version already present in `Chart.yaml`. -`theia-deployment` can consume those previews through a simple tag input such as `pr-123`. +Both charts, both fields, all four values identical: -## Cluster Prerequisites +```yaml +# charts/eduide/Chart.yaml AND charts/eduide-cluster/Chart.yaml +version: 2.3.0 +appVersion: "2.3.0" +``` -The charts depend on well-established software in the Kubernetes ecosystem. Please make sure to install the dependencies before releasing with _helm_. +```bash +docker run --rm -v "$PWD/charts:/helm-docs" -u "$(id -u)" jnorwood/helm-docs:v1.14.2 +``` + +Open the PR, let CI run, get it reviewed, merge. CI checks the version moved, +the READMEs match, and shows what the change does to every live environment. + +### 3. Run the release train for real + +``` +Actions -> Release train -> Run workflow + version: 2.3.0 + dry_run: false +``` -- **cert-manager.io** is used for certificate management, supports internal/testing issuers and supports Let's Encrypt certificates. Installation instructions can be found [here](https://cert-manager.io), a helm chart [here](https://cert-manager.io/docs/installation/helm/). +It then, in this order: -- **Envoy Gateway** (Gateway API) is used to route traffic to Theia Cloud components. - Ensure Gateway API CRDs and Envoy Gateway are installed in your cluster and the GatewayClass name - matches `theia-cloud.gateway.className` (default: `envoy`). +1. **validates** the version is semver and `v2.3.0` is free +2. **builds** all 14 images at that tag, by dispatching each repository's own + build workflow — **nothing is tagged yet** +3. **verifies** every image exists in GHCR and is multi-arch +4. **tags** the three component repositories and creates their releases +5. **checks** the charts are at `2.3.0`, tags this repository, publishes both -You can find more information in the official [Theia Cloud documentation](https://theia-cloud.io/documentation/setuptheiacloud/). +Building before tagging is deliberate. Building 15 multi-GB IDE images is the +flakiest step in the pipeline; a failure after tagging strands immutable +`v2.3.0` tags on repositories whose images were never published. This way a +flake costs a re-run. -## Versioning +## Rolling it out -The chart `version` should get updated on every change/commit/PR.\ -However only changed charts should get an increased version, e.g. when a commit changes the theia-cloud chart, only this chart version has to be increased.\ -See below for more information: +The train deploys nothing. In EduIDE-deployment: ```yaml -# Releases -# follow semantic versioning (starting with release 0.9.0) -version: 1.0.0 - -# Pre-Releases -# append -next.X to the next version. X should be increased on every change/commit/PR -version: 1.0.0-next.0 -version: 1.0.0-next.1 +# environments/prod-tum/env.yaml +spec: + platform: + chartVersion: 2.3.0 # the only line that changes ``` -The `appVersion` is pointing to the `-next` tag this means that the images consumed are bound to change, when a new pre-release of that component is published. +Merge that, then run `Deploy environment`. Production is never deployed +automatically. -Therefore, you should only use full releases for deployments, as the next tag might change at any time. -If you still want to use a next version you should pin the used images to a specific version (`-next.`). +## Release candidates -### Release a new version +`2.3.0-rc.1` everywhere — same pipeline, same registry, marked as a prerelease +on GitHub. Consume with an explicit `--version`; never `--devel`. -New release every three months. +## Versioning rules -Provide a commit where the next parts are removed from the `version` and the `appVersion` fields of ALL charts. -Also set the images used in charts to the version of the release. -The release should be done after the main repository provided a release and the docker images were pushed. +| Thing | Form | Example | +|---|---|---| +| git tag | `vX.Y.Z` | `v2.3.0` | +| chart `version` | `X.Y.Z` | `2.0.0` | +| chart `appVersion` | the EduIDE IDE image version | `1.2.0` | +| image tag | `X.Y.Z` | `1.2.0` | -With next change after a release needs the version number should be bumped and `-next.0/-next` should be added to the version/appVersion fields. -Furthermore, the new version, together with a release estimation date, should be added to the changelog. +`appVersion` is the tag of the IDE images, so a chart says exactly which IDEs it +runs. The operator, REST service and landing page carry their own versions in +`versions.cloud` and `versions.landingPage`, because they release on a different +cadence and a one-line landing page fix should not rebuild fifteen +multi-gigabyte IDE images. -## How to generate Chart READMEs +The chart version is the platform version and is what an environment pins. + +## Checklist + +``` +[ ] dry run is clean +[ ] both Chart.yaml files at the same `version` +[ ] `appVersion` set to the EduIDE release the IDE images were published under +[ ] `versions.cloud` and `versions.landingPage` set to their releases +[ ] scripts/test-app-consistency.sh passes (no floating tags, three consumers agree) +[ ] helm-docs run, READMEs committed +[ ] bump PR reviewed and merged +[ ] release train run with dry_run: false +[ ] chartVersion bumped in EduIDE-deployment for the environments to move +``` + +## If something goes wrong + +| Symptom | Cause | Fix | +|---|---|---| +| `version is 'X', expected 'Y'` | step 2 skipped or half-done | bump both charts, both fields | +| `missing ghcr.io/...` | a component build failed | check that repository's Actions, re-run | +| `is not multi-arch` | one architecture failed | re-run the whole build, not just the merge job | +| `tag v2.3.0 already exists` | version already used | pick the next one, tags are immutable | + +--- + +## Cluster prerequisites + +- **cert-manager** — certificate management, including Let's Encrypt. + [Install](https://cert-manager.io/docs/installation/helm/). +- **Envoy Gateway** with the Gateway API CRDs. The GatewayClass name must match + `gateway.className` (default `envoy`). + +## Working on the charts ```bash -docker pull jnorwood/helm-docs:latest && docker run --rm --volume "$(pwd)/charts:/helm-docs" -u $(id -u) jnorwood/helm-docs:latest +helm lint charts/eduide charts/eduide-cluster +./scripts/render-envs.sh /tmp/out # renders every real environment ``` -or run the `Rebuild READMEs` task. +CI also renders the PR base and head and posts the diff. **For a refactor the +expected result is an empty diff** — that is the acceptance criterion, not "it +still lints". + +See `AGENTS.md` for the things that catch people out, and +`.claude/skills/chart-change.md` for the loop to follow when editing a template. diff --git a/charts/theia-cloud-base/.helmignore b/charts/eduide-cluster/.helmignore similarity index 100% rename from charts/theia-cloud-base/.helmignore rename to charts/eduide-cluster/.helmignore diff --git a/charts/eduide-cluster/Chart.yaml b/charts/eduide-cluster/Chart.yaml new file mode 100644 index 0000000..1332c33 --- /dev/null +++ b/charts/eduide-cluster/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: eduide-cluster +description: | + Cluster-scoped half of EduIDE: CRDs, the conversion webhook, ClusterRoles and + cert-manager issuers. Install once per cluster, before any eduide release. +type: application +version: 2.0.0 +appVersion: "1.2.0" diff --git a/charts/eduide-cluster/README.md b/charts/eduide-cluster/README.md new file mode 100644 index 0000000..4474757 --- /dev/null +++ b/charts/eduide-cluster/README.md @@ -0,0 +1,60 @@ +# eduide-cluster + +![Version: 2.0.0](https://img.shields.io/badge/Version-2.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0](https://img.shields.io/badge/AppVersion-1.2.0-informational?style=flat-square) + +Cluster-scoped half of EduIDE: CRDs, the conversion webhook, ClusterRoles and +cert-manager issuers. Install once per cluster, before any eduide release. + +*This chart was tested with Helm version v3.17.0.* +*Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| certmanager.namespace | string | `"cert-manager"` | the namespace where the cert-manager is installed | +| clusterIssuer | string | `"theia-cloud-selfsigned-issuer"` | The cluster issuer to use for the certificate | +| conversion.certMountPath | string | `"/etc/webhook/certs"` | The location of where the certificates are mounted into the container (needs to match with application.properties) | +| conversion.certReloadPeriod | int | `604800` | The certificate reload period in seconds | +| conversion.image | string | `"theiacloud/theia-cloud-conversion-webhook:1.2.0-next"` | The image of the webhook container | +| envoyProxy.annotations | object | `{}` | | +| envoyProxy.create | bool | `false` | | +| envoyProxy.labels | object | `{}` | | +| envoyProxy.name | string | `"theia-shared-gateway"` | | +| envoyProxy.namespace | string | `"envoy-gateway-system"` | | +| envoyProxy.spec | object | `{}` | | +| gateway | object | `{"addresses":[],"allowedRoutes":{"namespaces":{"from":"All"}},"annotations":{},"className":"envoy","labels":{},"listeners":[],"name":"theia-shared-gateway","namespace":"eduide-system"}` | ------------------------------------------------------------------------ | +| gatewayAcmeIssuer.email | string | `""` | | +| gatewayAcmeIssuer.enabled | bool | `false` | | +| gatewayAcmeIssuer.name | string | `"letsencrypt-prod-gateway"` | | +| gatewayAcmeIssuer.privateKeySecretName | string | `"letsencrypt-prod-gateway-priv-key"` | | +| gatewayAcmeIssuer.server | string | `"https://acme-v02.api.letsencrypt.org/directory"` | | +| gatewayAcmeIssuer.serviceType | string | `"ClusterIP"` | | +| gatewayClass.annotations | object | `{}` | | +| gatewayClass.controllerName | string | `"gateway.envoyproxy.io/gatewayclass-controller"` | | +| gatewayClass.create | bool | `false` | | +| gatewayClass.labels | object | `{}` | | +| gatewayClass.parametersRef | object | `{}` | | +| issuer.email | string | `"mmorlock@example.com"` | email used to issue let's encrypt certificates | +| issuerca.enable | bool | `true` | whether to install the CA certificate signer | +| issuerca.name | string | `"theia-cloud-ca-certificate-signer"` | name for the issuer preparing a self signed CA certificate | +| issuerprod.enable | bool | `false` | whether to install the let's encrypt production cluster issuer | +| issuerprod.name | string | `"letsencrypt-prod"` | name for the let's encrypt production cluster issuer | +| issuerprod.solvers | list | `[]` | ACME solver list for cert-manager (required when `issuerprod.enable=true`) | +| issuerstaging.name | string | `"theia-cloud-selfsigned-issuer"` | name for the self signed cluster issuer | +| managedCertificates.certificates | list | `[]` | Each entry takes either `hostname` (one name) or `dnsNames` (a list). `bootstrap-cluster.yml` fills this in from the environments on the cluster, so a new environment gets its certificate names without a second edit - which is how test3 ran for 184 days on a certificate that only covered test1, test2 and staging. | +| managedCertificates.enabled | bool | `false` | | +| managedCertificates.issuerRef.kind | string | `"ClusterIssuer"` | | +| managedCertificates.issuerRef.name | string | `"letsencrypt-prod"` | | +| monitoring | object | `{"dashboardNamespace":"cattle-dashboards","enabled":true,"namespace":"cattle-monitoring-system","sessionNamespaces":[],"targetNamespaces":[]}` | ------------------------------------------------------------------------ | +| monitoring.enabled | bool | `true` | Create the PodMonitors and dashboards. Set to false by `bootstrap-cluster.yml` when no environment on the cluster opts in, so an all-opted-out cluster is a supported state rather than a render failure. | +| operatorrole.name | string | `"operator-api-access"` | name for the operator's cluster role | +| servicerole.name | string | `"service-api-access"` | name for the services' cluster role | +| wildcardTLSSecret.certificate | string | `""` | | +| wildcardTLSSecret.create | bool | `false` | | +| wildcardTLSSecret.key | string | `""` | | +| wildcardTLSSecret.name | string | `"static-theia-cert"` | | +| wildcardTLSSecret.namespace | string | `"eduide-system"` | | + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/theia-cloud-base/README.md.gotmpl b/charts/eduide-cluster/README.md.gotmpl similarity index 100% rename from charts/theia-cloud-base/README.md.gotmpl rename to charts/eduide-cluster/README.md.gotmpl diff --git a/charts/theia-cloud-crds/templates/appdefinition-spec-resource.yaml b/charts/eduide-cluster/templates/crds/appdefinition.yaml similarity index 98% rename from charts/theia-cloud-crds/templates/appdefinition-spec-resource.yaml rename to charts/eduide-cluster/templates/crds/appdefinition.yaml index f439de9..3ca8941 100644 --- a/charts/theia-cloud-crds/templates/appdefinition-spec-resource.yaml +++ b/charts/eduide-cluster/templates/crds/appdefinition.yaml @@ -3,6 +3,9 @@ kind: CustomResourceDefinition metadata: name: appdefinitions.theia.cloud annotations: + # Never delete on uninstall: that would take every live Session, + # Workspace and AppDefinition on the cluster with it. + helm.sh/resource-policy: keep cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/conversion-webhook-certificate spec: group: theia.cloud diff --git a/charts/theia-cloud-crds/templates/conversion-webhook-certificate.yaml b/charts/eduide-cluster/templates/crds/conversion-webhook-certificate.yaml similarity index 100% rename from charts/theia-cloud-crds/templates/conversion-webhook-certificate.yaml rename to charts/eduide-cluster/templates/crds/conversion-webhook-certificate.yaml diff --git a/charts/theia-cloud-crds/templates/conversion-webhook-deployment.yaml b/charts/eduide-cluster/templates/crds/conversion-webhook-deployment.yaml similarity index 100% rename from charts/theia-cloud-crds/templates/conversion-webhook-deployment.yaml rename to charts/eduide-cluster/templates/crds/conversion-webhook-deployment.yaml diff --git a/charts/theia-cloud-crds/templates/conversion-webhook-service.yaml b/charts/eduide-cluster/templates/crds/conversion-webhook-service.yaml similarity index 100% rename from charts/theia-cloud-crds/templates/conversion-webhook-service.yaml rename to charts/eduide-cluster/templates/crds/conversion-webhook-service.yaml diff --git a/charts/theia-cloud-crds/templates/session-spec-resource.yaml b/charts/eduide-cluster/templates/crds/session.yaml similarity index 97% rename from charts/theia-cloud-crds/templates/session-spec-resource.yaml rename to charts/eduide-cluster/templates/crds/session.yaml index 3af7353..382caad 100644 --- a/charts/theia-cloud-crds/templates/session-spec-resource.yaml +++ b/charts/eduide-cluster/templates/crds/session.yaml @@ -3,6 +3,9 @@ kind: CustomResourceDefinition metadata: name: sessions.theia.cloud annotations: + # Never delete on uninstall: that would take every live Session, + # Workspace and AppDefinition on the cluster with it. + helm.sh/resource-policy: keep cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/conversion-webhook-certificate spec: group: theia.cloud diff --git a/charts/theia-cloud-crds/templates/workspace-spec-resource.yaml b/charts/eduide-cluster/templates/crds/workspace.yaml similarity index 97% rename from charts/theia-cloud-crds/templates/workspace-spec-resource.yaml rename to charts/eduide-cluster/templates/crds/workspace.yaml index 3c9d5da..6f72ae8 100644 --- a/charts/theia-cloud-crds/templates/workspace-spec-resource.yaml +++ b/charts/eduide-cluster/templates/crds/workspace.yaml @@ -3,6 +3,9 @@ kind: CustomResourceDefinition metadata: name: workspaces.theia.cloud annotations: + # Never delete on uninstall: that would take every live Session, + # Workspace and AppDefinition on the cluster with it. + helm.sh/resource-policy: keep cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/conversion-webhook-certificate spec: group: theia.cloud diff --git a/charts/eduide-cluster/templates/gateway/certificates.yaml b/charts/eduide-cluster/templates/gateway/certificates.yaml new file mode 100644 index 0000000..3e169b1 --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/certificates.yaml @@ -0,0 +1,37 @@ +{{- if and .Values.managedCertificates.enabled (gt (len .Values.managedCertificates.certificates) 0) }} +{{- range $cert := .Values.managedCertificates.certificates }} +{{- /* +A certificate may cover one hostname or many. The real installations use one +certificate per cluster covering every environment's landing, service and +instance host, so `dnsNames` is the useful shape and `hostname` is kept as the +single-name shorthand. + +Only names that actually have a listener can be included: cert-manager solves +HTTP-01 by serving a token on port 80 for each name, and a name with no listener +answers 404 and leaves the whole order pending - which blocks the certificate +for every other name on it too. +*/}} +{{- $names := $cert.dnsNames | default (list $cert.hostname) }} +{{- if not $names }} +{{- fail (printf "managedCertificates.certificates %q has neither hostname nor dnsNames" $cert.name) }} +{{- end }} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $cert.name }} + namespace: {{ default $.Values.gateway.namespace $cert.namespace }} +spec: + secretName: {{ $cert.secretName }} + commonName: {{ (first $names) | quote }} + dnsNames: + {{- range $names }} + - {{ . | quote }} + {{- end }} + issuerRef: + kind: {{ $.Values.managedCertificates.issuerRef.kind }} + name: {{ $.Values.managedCertificates.issuerRef.name }} + privateKey: + rotationPolicy: Never +{{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/envoyproxy.yaml b/charts/eduide-cluster/templates/gateway/envoyproxy.yaml new file mode 100644 index 0000000..8671814 --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/envoyproxy.yaml @@ -0,0 +1,17 @@ +{{- if .Values.envoyProxy.create }} +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyProxy +metadata: + name: {{ .Values.envoyProxy.name }} + namespace: {{ .Values.envoyProxy.namespace }} + {{- with .Values.envoyProxy.labels }} + labels: +{{ toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.envoyProxy.annotations }} + annotations: +{{ toYaml . | nindent 4 }} + {{- end }} +spec: +{{ toYaml .Values.envoyProxy.spec | nindent 2 }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/gateway-acme-issuer.yaml b/charts/eduide-cluster/templates/gateway/gateway-acme-issuer.yaml new file mode 100644 index 0000000..0988c54 --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/gateway-acme-issuer.yaml @@ -0,0 +1,21 @@ +{{- if .Values.gatewayAcmeIssuer.enabled }} +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: {{ .Values.gatewayAcmeIssuer.name }} +spec: + acme: + server: {{ .Values.gatewayAcmeIssuer.server | quote }} + email: {{ required "gatewayAcmeIssuer.email must be set when gatewayAcmeIssuer.enabled=true" .Values.gatewayAcmeIssuer.email | quote }} + privateKeySecretRef: + name: {{ .Values.gatewayAcmeIssuer.privateKeySecretName | quote }} + solvers: + - http01: + gatewayHTTPRoute: + serviceType: {{ .Values.gatewayAcmeIssuer.serviceType | quote }} + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: {{ .Values.gateway.name }} + namespace: {{ .Values.gateway.namespace }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/gateway.yaml b/charts/eduide-cluster/templates/gateway/gateway.yaml new file mode 100644 index 0000000..f5e9085 --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/gateway.yaml @@ -0,0 +1,52 @@ +{{- if gt (len .Values.gateway.listeners) 0 }} +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: {{ .Values.gateway.name }} + namespace: {{ .Values.gateway.namespace }} + {{- with .Values.gateway.labels }} + labels: +{{ toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gateway.annotations }} + annotations: +{{ toYaml . | nindent 4 }} + {{- end }} +spec: + gatewayClassName: {{ .Values.gateway.className }} + {{- with .Values.gateway.addresses }} + addresses: + {{- range $address := . }} + - type: IPAddress + value: {{ $address | quote }} + {{- end }} + {{- end }} + listeners: + {{- range $listener := .Values.gateway.listeners }} + {{- $protocol := default "HTTPS" $listener.protocol }} + {{- $port := default (ternary 443 80 (eq $protocol "HTTPS")) $listener.port }} + - name: {{ $listener.name }} + protocol: {{ $protocol }} + port: {{ $port }} + {{- with $listener.hostname }} + hostname: {{ $listener.hostname | quote }} + {{- end }} + {{- if eq $protocol "HTTPS" }} + {{- /* + An HTTPS listener with no secret renders certificateRefs with an empty name. + Nothing rejects that: the Gateway is accepted and simply never programs TLS + for the hostname, so the first symptom is a connection failure in a browser. + */}} + {{- if not $listener.tlsSecretName }} + {{- fail (printf "gateway listener %q is HTTPS but has no tlsSecretName" $listener.name) }} + {{- end }} + tls: + mode: Terminate + certificateRefs: + - kind: Secret + name: {{ $listener.tlsSecretName | quote }} + {{- end }} + allowedRoutes: +{{ toYaml (default $.Values.gateway.allowedRoutes $listener.allowedRoutes) | nindent 6 }} + {{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/gatewayclass.yaml b/charts/eduide-cluster/templates/gateway/gatewayclass.yaml new file mode 100644 index 0000000..d183ffb --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/gatewayclass.yaml @@ -0,0 +1,20 @@ +{{- if .Values.gatewayClass.create }} +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: {{ .Values.gateway.className }} + {{- with .Values.gatewayClass.labels }} + labels: +{{ toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gatewayClass.annotations }} + annotations: +{{ toYaml . | nindent 4 }} + {{- end }} +spec: + controllerName: {{ .Values.gatewayClass.controllerName }} + {{- with .Values.gatewayClass.parametersRef }} + parametersRef: +{{ toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/gateway/wildcard-secret.yaml b/charts/eduide-cluster/templates/gateway/wildcard-secret.yaml new file mode 100644 index 0000000..325ed6c --- /dev/null +++ b/charts/eduide-cluster/templates/gateway/wildcard-secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.wildcardTLSSecret.create }} +apiVersion: v1 +kind: Secret +type: kubernetes.io/tls +metadata: + name: {{ .Values.wildcardTLSSecret.name }} + namespace: {{ .Values.wildcardTLSSecret.namespace }} +data: + tls.crt: {{ .Values.wildcardTLSSecret.certificate }} + tls.key: {{ .Values.wildcardTLSSecret.key }} +{{- end }} diff --git a/charts/theia-cloud-base/templates/clusterissuer-for-ca.yaml b/charts/eduide-cluster/templates/issuers/clusterissuer-for-ca.yaml similarity index 100% rename from charts/theia-cloud-base/templates/clusterissuer-for-ca.yaml rename to charts/eduide-cluster/templates/issuers/clusterissuer-for-ca.yaml diff --git a/charts/theia-cloud-base/templates/clusterissuer-production.yaml b/charts/eduide-cluster/templates/issuers/clusterissuer-production.yaml similarity index 100% rename from charts/theia-cloud-base/templates/clusterissuer-production.yaml rename to charts/eduide-cluster/templates/issuers/clusterissuer-production.yaml diff --git a/charts/theia-cloud-base/templates/clusterissuer-selfsigned.yaml b/charts/eduide-cluster/templates/issuers/clusterissuer-selfsigned.yaml similarity index 100% rename from charts/theia-cloud-base/templates/clusterissuer-selfsigned.yaml rename to charts/eduide-cluster/templates/issuers/clusterissuer-selfsigned.yaml diff --git a/charts/theia-cloud-base/templates/theia-cloud-ca-certificate.yaml b/charts/eduide-cluster/templates/issuers/theia-cloud-ca-certificate.yaml similarity index 100% rename from charts/theia-cloud-base/templates/theia-cloud-ca-certificate.yaml rename to charts/eduide-cluster/templates/issuers/theia-cloud-ca-certificate.yaml diff --git a/charts/eduide-cluster/templates/monitoring/dashboard-session-startup.yaml b/charts/eduide-cluster/templates/monitoring/dashboard-session-startup.yaml new file mode 100644 index 0000000..3947a0d --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/dashboard-session-startup.yaml @@ -0,0 +1,318 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + grafana_dashboard: "1" + name: theia-cloud-dashboard-session-startup + namespace: {{ .Values.monitoring.dashboardNamespace }} +data: + session-startup.json: |- + { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 49, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "rate(application_application_theiacloud_session_startup_seconds_seconds_sum{namespace=\"$namespace\"}[5m]) \n/ \nrate(application_application_theiacloud_session_startup_seconds_seconds_count{namespace=\"$namespace\"}[5m])", + "instant": false, + "interval": "", + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Average Session Startup Seconds", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "application_application_theiacloud_session_startup_seconds_seconds{namespace=\"$namespace\", quantile=\"0.5\"}", + "instant": false, + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Median Session Startup Seconds", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "application_application_theiacloud_session_startup_seconds_seconds{namespace=\"$namespace\", quantile=\"0.95\"}", + "instant": false, + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "P95 Session Startup Seconds", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 80 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "editorMode": "code", + "expr": "application_application_theiacloud_session_startup_seconds_seconds{namespace=\"$namespace\", quantile=\"0.99\"}", + "instant": false, + "legendFormat": "{{ "{{" }}app_definition{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "P99 Session Startup Seconds", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "test1", "value": "test1" }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "definition": "label_values(application_application_theiacloud_session_startup_seconds_seconds_count,namespace)", + "hide": 0, + "includeAll": false, + "label": "Namespace", + "multi": false, + "name": "namespace", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(application_application_theiacloud_session_startup_seconds_seconds_count,namespace)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { "from": "now-1h", "to": "now" }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "Theia Cloud Session Startup Time", + "uid": "bf4ha4miogutcc", + "version": 8, + "weekStart": "" + } +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/dashboard-theiacloud.yaml b/charts/eduide-cluster/templates/monitoring/dashboard-theiacloud.yaml new file mode 100644 index 0000000..91e64cc --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/dashboard-theiacloud.yaml @@ -0,0 +1,1129 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + grafana_dashboard: "1" + name: theia-cloud-dashboard-overview + namespace: {{ .Values.monitoring.dashboardNamespace }} +data: + theiacloud.json: |- + { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Visualization to observe a Theia Cloud deployment", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 5, + "panels": [], + "title": "Theia Cloud", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Shows the number of running session pods.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "stepBefore", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "fieldMinMax": false, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 1 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "count(count by (pod) (container_cpu_usage_seconds_total{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!=\"POD\"}))", + "format": "time_series", + "instant": false, + "interval": "", + "legendFormat": "Theia Session Count", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Pod Count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "CPU usage of all Theia Cloud pods. This includes session pods and service pods (i.e. landing page, REST service, operator).\nAlso shows the total over all Theia Cloud pods.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 1 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(container_cpu_usage_seconds_total{container!=\"POD\",container!=\"\", job=\"kubelet\", metrics_path=\"/metrics/cadvisor\", namespace=\"$namespace\"}[1m])) by (pod)", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(container_cpu_usage_seconds_total{container!=\"POD\",container!=\"\", job=\"kubelet\", metrics_path=\"/metrics/cadvisor\", namespace=\"$namespace\"}[1m]))", + "hide": false, + "instant": false, + "legendFormat": "TOTAL", + "range": true, + "refId": "B" + } + ], + "title": "Theia Cloud CPU usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Theia Backend process memory consumption for each session pod", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 60000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "container_memory_working_set_bytes{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"}", + "instant": false, + "legendFormat": "{{ "{{" }}pod{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Backend Memory", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "CPU usage for Theia Backend processes for each session pod.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "rate(container_cpu_usage_seconds_total{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"}[1m])", + "instant": false, + "interval": "", + "legendFormat": "{{ "{{" }}pod{{ "}}" }}", + "range": true, + "refId": "Theia Backend CPU" + } + ], + "title": "Theia Session Backend CPU", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Average Theia Backend process memory consumption per app definition", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 60000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg by (container) (container_memory_working_set_bytes{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"})", + "instant": false, + "legendFormat": "{{ "{{" }}container{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Backend Memory Averages", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Average Theia Backend process CPU consumption per app definition", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 60000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg by (container) (rate(container_cpu_usage_seconds_total{namespace=\"$namespace\", pod=~\"(session-|instance-).*\", container!=\"\", container!~\"oauth.*\", service=~\"theia-.*\"}[1m]))", + "instant": false, + "legendFormat": "{{ "{{" }}container{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Theia Session Backend CPU Averages", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 9, + "panels": [], + "title": "Nodes", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "CPU Usage ratio per node in the cluster", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "instance:node_cpu:ratio", + "instant": false, + "legendFormat": "Node {{ "{{" }}instance{{ "}}" }}", + "range": true, + "refId": "A" + } + ], + "title": "Nodes CPU Usage Ratio", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Memory Usage Ratio for each node in the cluster.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Nodes Memory Usage Ratio", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 6, + "panels": [], + "title": "Cluster", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Ratio of CPUs used across the whole cluster", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 35 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "cluster:node_cpu:ratio", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Cluster CPU Usage Ratio", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Memory usage across the whole cluster", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 35 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Cluster Memory Usage Ratio", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "theia", "value": "theia" }, + "hide": 0, + "includeAll": false, + "label": "Namespace", + "multi": false, + "name": "namespace", + "options": [ + { + "selected": false, + "text": "theia", + "value": "theia" + }, + { + "selected": false, + "text": "theia-staging", + "value": "theia-staging" + }, + { + "selected": true, + "text": "test1", + "value": "test1" + }, + { + "selected": false, + "text": "test2", + "value": "test2" + }, + { + "selected": false, + "text": "test3", + "value": "test3" + } + ], + "query": "theia,theia-staging,test1,test2,test3", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { "from": "now-3h", "to": "now" }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": {}, + "timezone": "browser", + "title": "Theia Cloud", + "uid": "bdrjgy1fv3d34b", + "version": 7, + "weekStart": "" + } +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml b/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml new file mode 100644 index 0000000..ee28236 --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/podmonitor-service.yaml @@ -0,0 +1,29 @@ +{{- if .Values.monitoring.enabled }} +{{- /* +A PodMonitor naming no namespaces watches nothing, so it is not rendered at all +rather than created as a no-op. The list is derived by bootstrap-cluster.yml +from the environments on the cluster; an empty one means every environment +opted out, which is a supported state. A bare `helm install eduide-cluster` +lands here too, and must not fail. +*/}} +{{- if .Values.monitoring.targetNamespaces }} +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: theia-cloud-service + namespace: {{ .Values.monitoring.namespace }} +spec: + selector: + matchLabels: + app: service + namespaceSelector: + matchNames: + {{- range .Values.monitoring.targetNamespaces }} + - {{ . }} + {{- end }} + podMetricsEndpoints: + - port: http + interval: 15s + path: /q/metrics +{{- end }} +{{- end }} diff --git a/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml b/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml new file mode 100644 index 0000000..d35e743 --- /dev/null +++ b/charts/eduide-cluster/templates/monitoring/podmonitor-sessions.yaml @@ -0,0 +1,34 @@ +{{- if .Values.monitoring.enabled }} +{{- /* +A PodMonitor naming no namespaces watches nothing, so it is not rendered at all +rather than created as a no-op. The list is derived by bootstrap-cluster.yml +from the environments on the cluster; an empty one means every environment +opted out, which is a supported state. A bare `helm install eduide-cluster` +lands here too, and must not fail. +*/}} +{{- if .Values.monitoring.sessionNamespaces }} +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: theia-cloud-sessions + namespace: {{ .Values.monitoring.namespace }} +spec: + selector: + matchExpressions: + - key: app + operator: NotIn + values: + - conversion-webhook + - landing-page + - operator + - service + namespaceSelector: + matchNames: + {{- range .Values.monitoring.sessionNamespaces }} + - {{ . }} + {{- end }} + podMetricsEndpoints: + - port: application + interval: 15s +{{- end }} +{{- end }} diff --git a/charts/theia-cloud-base/templates/operator-role.yaml b/charts/eduide-cluster/templates/rbac/clusterrole-operator.yaml similarity index 100% rename from charts/theia-cloud-base/templates/operator-role.yaml rename to charts/eduide-cluster/templates/rbac/clusterrole-operator.yaml diff --git a/charts/theia-cloud-base/templates/service-role.yaml b/charts/eduide-cluster/templates/rbac/clusterrole-service.yaml similarity index 100% rename from charts/theia-cloud-base/templates/service-role.yaml rename to charts/eduide-cluster/templates/rbac/clusterrole-service.yaml diff --git a/charts/eduide-cluster/templates/version-configmap.yaml b/charts/eduide-cluster/templates/version-configmap.yaml new file mode 100644 index 0000000..7a9af3f --- /dev/null +++ b/charts/eduide-cluster/templates/version-configmap.yaml @@ -0,0 +1,17 @@ +{{/* + Marker a tenant release can look for to tell whether this cluster has been + bootstrapped, and with which version. Without it the first sign of a missing + bootstrap is the operator crash-looping on absent CRDs. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: eduide-cluster-version + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: eduide-cluster + app.kubernetes.io/part-of: eduide + app.kubernetes.io/managed-by: {{ .Release.Service }} +data: + platformVersion: {{ .Chart.Version | quote }} + appVersion: {{ .Chart.AppVersion | quote }} diff --git a/charts/eduide-cluster/values.yaml b/charts/eduide-cluster/values.yaml new file mode 100644 index 0000000..7437860 --- /dev/null +++ b/charts/eduide-cluster/values.yaml @@ -0,0 +1,131 @@ +# Cluster-scoped half of EduIDE. Installed ONCE per cluster. +# +# The tenant chart (eduide) is installed once per environment and owns +# nothing in here. Splitting them is what removes the race where three +# concurrent tenant deploys all reinstalled the same cluster-scoped +# objects into the default namespace. + +issuerca: + # -- whether to install the CA certificate signer + enable: true + # -- name for the issuer preparing a self signed CA certificate + name: theia-cloud-ca-certificate-signer + +issuerprod: + # -- whether to install the let's encrypt production cluster issuer + enable: false + # -- name for the let's encrypt production cluster issuer + name: letsencrypt-prod + # -- ACME solver list for cert-manager (required when `issuerprod.enable=true`) + solvers: [] + +issuerstaging: + # -- name for the self signed cluster issuer + name: theia-cloud-selfsigned-issuer + +issuer: + # -- email used to issue let's encrypt certificates + email: mmorlock@example.com + +operatorrole: + # -- name for the operator's cluster role + name: operator-api-access + +servicerole: + # -- name for the services' cluster role + name: service-api-access + +certmanager: + # -- the namespace where the cert-manager is installed + namespace: cert-manager + +# --- CRDs and the conversion webhook --- +conversion: + # -- The image of the webhook container + image: theiacloud/theia-cloud-conversion-webhook:1.2.0-next + # -- The location of where the certificates are mounted into the container (needs to match with application.properties) + certMountPath: /etc/webhook/certs + # -- The certificate reload period in seconds + certReloadPeriod: 604800 + +# -- The cluster issuer to use for the certificate +clusterIssuer: theia-cloud-selfsigned-issuer + +# -------------------------------------------------------------------------- +# The shared Gateway. One per cluster, one listener set per environment. +# +# This was a chart of its own in EduIDE-deployment until 2.0.0. It belongs here: +# a Gateway is cluster-scoped, and keeping chart source in the deployment repo +# meant the cluster half of an install was not versioned with the rest. +# +# `gateway.listeners` is filled in by the Bootstrap cluster workflow, derived +# from the environments that claim this cluster. Do not write it by hand - that +# is what deployments/shared-gateway/values.yaml was, and adding an environment +# meant remembering to edit a second file. +# -------------------------------------------------------------------------- +gateway: + name: theia-shared-gateway + namespace: eduide-system + className: envoy + labels: {} + annotations: {} + addresses: [] + allowedRoutes: + namespaces: + from: All + listeners: [] +gatewayClass: + create: false + controllerName: gateway.envoyproxy.io/gatewayclass-controller + labels: {} + annotations: {} + parametersRef: {} +envoyProxy: + create: false + name: theia-shared-gateway + namespace: envoy-gateway-system + labels: {} + annotations: {} + spec: {} +managedCertificates: + enabled: false + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod + # -- Each entry takes either `hostname` (one name) or `dnsNames` (a list). + # `bootstrap-cluster.yml` fills this in from the environments on the cluster, + # so a new environment gets its certificate names without a second edit - + # which is how test3 ran for 184 days on a certificate that only covered + # test1, test2 and staging. + certificates: [] +gatewayAcmeIssuer: + enabled: false + name: letsencrypt-prod-gateway + email: '' + privateKeySecretName: letsencrypt-prod-gateway-priv-key + server: https://acme-v02.api.letsencrypt.org/directory + serviceType: ClusterIP +wildcardTLSSecret: + create: false + name: static-theia-cert + namespace: eduide-system + certificate: '' + key: '' + +# -------------------------------------------------------------------------- +# PodMonitors and Grafana dashboards for Rancher's monitoring stack. +# +# Cluster-scoped like the Gateway: the PodMonitors live in Rancher's namespace +# and name every namespace they watch. That list was hand-written and had gone +# stale - it still named `theia` and `theia-staging`, which no longer exist. +# Bootstrap derives it from the environments on this cluster instead. +# -------------------------------------------------------------------------- +monitoring: + # -- Create the PodMonitors and dashboards. Set to false by + # `bootstrap-cluster.yml` when no environment on the cluster opts in, so an + # all-opted-out cluster is a supported state rather than a render failure. + enabled: true + namespace: cattle-monitoring-system + dashboardNamespace: cattle-dashboards + targetNamespaces: [] + sessionNamespaces: [] diff --git a/charts/theia-cloud-crds/.helmignore b/charts/eduide/.helmignore similarity index 100% rename from charts/theia-cloud-crds/.helmignore rename to charts/eduide/.helmignore diff --git a/charts/theia-cloud/.project b/charts/eduide/.project similarity index 100% rename from charts/theia-cloud/.project rename to charts/eduide/.project diff --git a/charts/eduide/Chart.lock b/charts/eduide/Chart.lock new file mode 100644 index 0000000..e5595da --- /dev/null +++ b/charts/eduide/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: eduide-shared-cache + repository: oci://ghcr.io/eduide/charts + version: 0.5.3 +- name: theia-workspace-garbage-collector + repository: oci://ghcr.io/eduide/charts + version: 0.1.0 +digest: sha256:b17d9558356c541259bf6d69b0d86c10e324bd7bc53ea54d2050c705d0f9b5b6 +generated: "2026-08-26T17:09:32.134187+02:00" diff --git a/charts/eduide/Chart.yaml b/charts/eduide/Chart.yaml new file mode 100644 index 0000000..ff76579 --- /dev/null +++ b/charts/eduide/Chart.yaml @@ -0,0 +1,46 @@ +apiVersion: v2 +name: eduide +description: | + EduIDE tenant release: operator, REST service, landing page and routes for one + environment. Requires eduide-cluster to be installed on the cluster first. +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 2.0.0 +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.2.0" + +# The two components that release on their own cadence and are genuinely +# optional. Everything else the chart needs is a template in this chart, not a +# dependency - vendoring the rest is what removed the three-way Chart.lock / +# Chart.yaml / HEAD version drift. +# +# eduide-shared-cache was called theia-shared-cache until 0.5.0. The umbrella it +# replaces still pinned theia-shared-cache 0.3.1, months after the rename, which +# is why the name is spelled out here with a current version. +dependencies: + # No alias. An alias becomes .Chart.Name inside the subchart, and this one + # builds resource names and label values from it - a camelCase alias renders + # `sharedCache-redis`, which the API server rejects because RFC 1123 names are + # lowercase only. + - name: eduide-shared-cache + version: "0.5.3" + repository: "oci://ghcr.io/eduide/charts" + condition: eduide-shared-cache.enabled + - name: theia-workspace-garbage-collector + version: "0.1.0" + repository: "oci://ghcr.io/eduide/charts" + condition: theia-workspace-garbage-collector.enabled + diff --git a/charts/theia-cloud/README.md b/charts/eduide/README.md similarity index 69% rename from charts/theia-cloud/README.md rename to charts/eduide/README.md index 580f598..3493279 100644 --- a/charts/theia-cloud/README.md +++ b/charts/eduide/README.md @@ -1,12 +1,20 @@ -# theia-cloud +# eduide -![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.4.0-next](https://img.shields.io/badge/AppVersion-1.4.0--next-informational?style=flat-square) +![Version: 2.0.0](https://img.shields.io/badge/Version-2.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0](https://img.shields.io/badge/AppVersion-1.2.0-informational?style=flat-square) -A Helm chart for Theia Cloud +EduIDE tenant release: operator, REST service, landing page and routes for one +environment. Requires eduide-cluster to be installed on the cluster first. *This chart was tested with Helm version v3.17.0.* *Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* +## Requirements + +| Repository | Name | Version | +|------------|------|---------| +| oci://ghcr.io/eduide/charts | eduide-shared-cache | 0.5.3 | +| oci://ghcr.io/eduide/charts | theia-workspace-garbage-collector | 0.1.0 | + ## Values | Key | Type | Default | Description | @@ -14,6 +22,9 @@ A Helm chart for Theia Cloud | app | object | (see details below) | General information about the deployed app | | app.id | Deprecated | `"asdfghjkl"` | The app id which is used in the communication between website and REST-API as a spam migitation. This id is public. Please choose an random generated string. Use service.authToken instead. | | app.name | string | `"Theia Blueprint"` | The name of the application that may be displayed e.g. on the landing pages | +| appDefinitions | object | (see details below) | The IDE applications this installation offers. This map is the single source of truth for three things that used to be configured separately and drifted apart: the AppDefinition custom resources, the app list the landing page shows, and the set of images preloaded onto every node. Adding a language is one entry here, not three edits in two repositories. Each key is the AppDefinition name. `image` is a repository without a tag - the tag comes from versions.ide (or the chart's appVersion), so a release moves every IDE image at once. An entry with a `landingPage` key is offered in the landing page drop-down; one without is deployable but hidden. | +| appDefinitions.apps | object | `{"c-latest":{"image":"eduide/c","landingPage":{"label":"C","visible":false}},"c-templates-latest":{"image":"eduide/c-templates","landingPage":{"buildSystems":[{"id":"bazel","label":"Bazel"},{"id":"make","label":"Make"}],"label":"C"}},"java-17-latest":{"image":"eduide/java-17","landingPage":{"label":"Java 17","visible":false},"limitsMemory":"3000M","minInstances":3,"requestsCpu":"500m"},"java-17-templates-latest":{"image":"eduide/java-17-templates","landingPage":{"buildSystems":[{"id":"maven","label":"Maven"},{"id":"gradle","label":"Gradle"}],"label":"Java 17"},"limitsMemory":"3000M","requestsCpu":"500m"},"javascript-latest":{"image":"eduide/javascript","landingPage":{"label":"Javascript"}},"ocaml-latest":{"image":"eduide/ocaml","landingPage":{"label":"Ocaml"}},"python-latest":{"image":"eduide/python","landingPage":{"label":"Python"}},"rust-latest":{"image":"eduide/rust","landingPage":{"label":"Rust"}}}` | The applications. Key is the AppDefinition name. | +| appDefinitions.defaults | object | `{"downlinkLimit":30000,"imagePullPolicy":"IfNotPresent","limitsCpu":"2","limitsMemory":"2400M","maxInstances":1000,"minInstances":0,"mountPath":"/home/project","options":{"dataBridgeEnabled":"true","dataBridgePort":"16281"},"port":3000,"requestsCpu":"200m","requestsMemory":"500M","timeout":1440,"uid":101,"uplinkLimit":30000}` | Applied to every app that does not state its own. Only the four scaling and sizing values genuinely differ between languages. | | demoApplication | object | (see details below) | Information about the demo application to be installed | | demoApplication.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the main application's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | demoApplication.install | bool | `true` | Should the demo application be installed | @@ -25,6 +36,7 @@ A Helm chart for Theia Cloud | demoApplication.name | string | `"theiacloud/theia-cloud-demo:1.2.0-next"` | The name of docker image to be used | | demoApplication.pullSecret | string | `""` | the image pull secret. Leave empty if registry is public | | demoApplication.timeout | string | `"30"` | Limit in minutes | +| eduide-shared-cache | object | `{"enabled":false}` | The Gradle build cache and Maven proxy. Optional: nothing reaches it unless operator.enableBuildCaching or operator.enableDependencyCaching is also turned on, so enabling this alone deploys a cache with no clients. | | gateway | object | `{"className":"envoy","create":true,"enabled":true,"httpEnabled":false,"httpPort":80,"httpsPort":443,"instancesRouteName":"theia-cloud-demo-ws-route","instancesWildcardSecretNames":{},"name":"theia-cloud-gateway","parentRefs":[],"routes":{"enabled":true},"serviceRouteRequestTimeout":"60s","tls":true}` | Gateway API configuration (Envoy Gateway by default) | | gateway.className | string | `"envoy"` | GatewayClassName to use (Envoy Gateway default is typically "envoy") | | gateway.create | bool | `true` | Whether to render a Gateway resource in the release namespace. Set to false when using a centralized shared Gateway in another namespace. | @@ -34,7 +46,7 @@ A Helm chart for Theia Cloud | gateway.instancesRouteName | string | `"theia-cloud-demo-ws-route"` | Name of the HTTPRoute that is updated to publish new Theia application instances | | gateway.instancesWildcardSecretNames | object | `{}` | Additional wildcard hostnames and optional dedicated TLS secret names Only accepts wildcard hostnames that are configured in `hosts.allWildcardInstances`. | | gateway.name | string | `"theia-cloud-gateway"` | Name of the Gateway resource | -| gateway.parentRefs | list | `[]` | Optional explicit parentRefs for HTTPRoutes. If empty, routes attach to `gateway.name` in the same namespace. Example for a centralized shared gateway: parentRefs: - name: theia-shared-gateway namespace: gateway-system | +| gateway.parentRefs | list | `[]` | Optional explicit parentRefs for HTTPRoutes. If empty, routes attach to `gateway.name` in the same namespace. Example for a centralized shared gateway: parentRefs: - name: theia-shared-gateway namespace: eduide-system | | gateway.routes.enabled | bool | `true` | Whether to render HTTPRoute resources. | | gateway.serviceRouteRequestTimeout | string | `"60s"` | HTTPRoute request timeout for service-route (Envoy default can be 15s) | | gateway.tls | bool | `true` | Does Theia Cloud expect TLS connections (true) or is TLS terminated outside of Theia Cloud (false) | @@ -52,8 +64,10 @@ A Helm chart for Theia Cloud | hosts.configuration.landing | string | `"trynow"` | afix of the landing page | | hosts.configuration.service | string | `"servicex"` | afix of the REST service | | imagePullPolicy | string | `"Always"` | The default imagePullPolicy for containers of theia cloud. Can be overridden for individual components by specifying the imagePullPolicy variable there. Possible values: - Always - IfNotPresent - Never | +| imageRegistry | string | `"ghcr.io/eduide"` | The container registry every EduIDE image is pulled from. | | keycloak | object | (see details below) | Values related to Keycloak | | keycloak.adminGroup | string | `"theia-cloud/admin"` | The name of the Keycloak group identifying admin users who are allowed to access the service's admin endpoints. | +| keycloak.allowUnauthenticated | bool | `false` | Install even though the Keycloak values below are still the chart's placeholders. The oauth2-proxy ConfigMaps render regardless of `enable` (the operator mounts them into every session by literal name), so leaving the placeholders means a proxy pointed at a host that does not exist and sessions that fail rather than run unauthenticated. Only set this for an installation that is deliberately not exposed yet. | | keycloak.authUrl | string | `"https://keycloak.url/auth/"` | Key cloak auth URL. Only has to be specified when enable: true | | keycloak.clientId | string | `"theia-cloud"` | The client-id. Only has to be specified when enable: true | | keycloak.clientSecret | string | `"publicbutoauth2proxywantsasecret"` | The oaid client secret. In case you configure your keycloak client as confidential, then you may specifiy the secret here. If you stick with our default public client, you may leave below value. For public clients keycloak does not generate a client-secret, but in order to make oath2-proxy happy, we will pass a value | @@ -62,12 +76,12 @@ A Helm chart for Theia Cloud | keycloak.realm | string | `"TheiaCloud"` | The Keycloak Realm. Only has to be specified when enable: true | | landingPage | object | (see details below) | Values related to the landing page | | landingPage.additionalApps | string | `nil` | The page may show these additional apps in a drop down. This is a map. The key maps to the app definition name The value contains the label shown in the UI and may optionally contain an image override that is forwarded to the landing page config. Example: different-app-definition: label: "Different App Definition" image: "different-app-definition" visible: false further-app-definition: label: "Further App Definition" | -| landingPage.appDefinition | string | `"theia-cloud-demo"` | the app id to launch | +| landingPage.appDefinition | string | `"java-17-templates-latest"` | the app id to launch | | landingPage.disableInfo | bool | `false` | Should showing info title and text below the launch button be disabled true hides the info title and text false shows the info title and text | | landingPage.enabled | bool | `true` | Whether the landing page shall be enabled | | landingPage.ephemeralStorage | bool | `true` | If set to true no persisted storage is used when creating sessions on the landing page. Set to false if you want to use persisted storage. | | landingPage.footerLinks | string | (see details below) | Optional: Customize footer links on the landing page All footer link configurations are optional. If not provided, default values will be used. | -| landingPage.image | string | `"theiacloud/theia-cloud-landing-page:1.2.0-next"` | the landing page image to use | +| landingPage.image | string | `"{{ .Values.imageRegistry }}/eduidec-landing-page:{{ .Values.versions.landingPage }}"` | the landing page image to use. Templated, so the tag follows versions.landingPage unless the whole string is overridden. | | landingPage.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the landing page's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | landingPage.imagePullSecret | string | `nil` | Optional: the image pull secret | | landingPage.infoText | string | `nil` | Optional: If specified with a value, this overrides the info text shown on the landing page. Empty values are ignored. Use `disableInfo` to deactivate showing the info completely. | @@ -83,6 +97,7 @@ A Helm chart for Theia Cloud | monitor.activityTracker.enable | bool | `true` | Should the activityTracker module be enabled | | monitor.activityTracker.interval | int | `1` | Minutes between re-pinging the pods | | monitor.enable | bool | `true` | Should the monitor be enabled | +| monitoring | object | `{"enabled":true}` | Whether this installation is scraped by Prometheus and appears on the dashboards. The PodMonitor objects themselves are in `eduide-cluster`, not here. They have to be created in Rancher's own namespace to be discovered, and one PodMonitor per tenant writing into a shared namespace would collide on names. So the cluster chart owns the objects and this flag decides whether this release's namespace is in the list they watch - `bootstrap-cluster.yml` reads it when it derives that list. Turning it off means this environment stops being scraped. Nothing else about the release changes; `monitor.enable` below is a different thing entirely (the operator's own session activity tracker). | | oauth2Proxy | object | `{"cookieDomains":[],"sslInsecureSkipVerify":false,"whitelistDomains":[]}` | Values related to OAuth2 Proxy configuration | | oauth2Proxy.sslInsecureSkipVerify | bool | `false` | Whether OAuth2 Proxy skips TLS certificate verification of the OIDC provider (sets ssl_insecure_skip_verify). Defaults to false to enforce certificate validation. Set to true only when the provider uses a self-signed or otherwise untrusted certificate. | | operator | object | (see details below) | Values related to the operator | @@ -98,7 +113,7 @@ A Helm chart for Theia Cloud | operator.dependencyCache.enabled | bool | `false` | Whether to enable the dependency cache | | operator.dependencyCache.url | string | `""` | The URL of the dependency cache server. | | operator.eagerStart | bool | `false` | Whether theia applications shall be started eager. This means that the application is already running without a user. When a user requests a new session, one of the already launched ones is assigned. Currently only false is fully supported. | -| operator.image | string | `"theiacloud/theia-cloud-operator:1.2.0-next"` | The operator image | +| operator.image | string | `"{{ .Values.imageRegistry }}/eduide-cloud/operator:{{ .Values.versions.cloud }}"` | The operator image. Templated, so the tag follows versions.cloud unless the whole string is overridden. | | operator.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the operator's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | operator.imagePullSecret | string | `nil` | Optional: the image pull secret | | operator.leaderElection | object | (see details below) | Options to influence the operator's leader election | @@ -114,13 +129,15 @@ A Helm chart for Theia Cloud | operator.wondershaperImage | string | `"theiacloud/theia-cloud-wondershaper:1.2.0-next"` | If bandwidthLimiter is set to WONDERSHAPER or K8SANNOTATIONANDWONDERSHAPER this image will be used for the wondershaper init container | | operatorrole.name | string | `"operator-api-access"` | | | preloading | object | (see details below) | Values to configure preloading of images on Kubernetes nodes. | +| preloading.deriveFromApps | bool | `true` | Set to false to preload only preloading.images and nothing derived. | | preloading.enable | bool | `true` | Is image preloading enabled. | | preloading.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the image preloading containers. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | -| preloading.images | list | `[]` | Images to preload. Each item is either an image reference string or a map: `{ image: "...", args: ["--version"] }` to use the image entrypoint (distroless-friendly), or `{ image: "...", command: [...], args: [...] }` for a full override. If only strings are used, the chart runs `/bin/sh -c 'echo …; exit 0'` (shell required in the image). If the list is empty and demoApplication.install == true, demoApplication.name is automatically added. | +| preloading.images | list | `[]` | Extra images to preload, on top of the ones derived automatically. Leave this empty. The chart preloads every appDefinitions.apps image, every sidecar image and the landing page image without being told, so the list cannot fall out of step with what the installation actually offers. It used to be written out by hand per environment and addressed by array index, which is how production ended up offering c-templates while preloading everything except c-templates. Each item is either an image reference string or a map: `{ image: "...", args: ["--version"] }` to use the image entrypoint (distroless-friendly), or `{ image: "...", command: [...], args: [...] }` for a full override. If only strings are used, the chart runs `/bin/sh -c 'echo …; exit 0'` (shell required in the image). | | service | object | (see details below) | Values of the Theia Cloud REST service | -| service.adminApiTokenSecret | object | `{"key":"ADMIN_API_TOKEN","name":"service-admin-api-token"}` | Reference to an existing Kubernetes Secret containing the bearer token for admin API token protected endpoints. The chart does not create or manage this Secret. | +| service.adminApiToken | string | `""` | Base64-encoded admin API token. Only read when adminApiTokenSecret.create is true. Comes from a deployment secret, never from a file in git. | +| service.adminApiTokenSecret | object | `{"create":false,"key":"ADMIN_API_TOKEN","name":"service-admin-api-token"}` | The Kubernetes Secret holding the bearer token for admin API token protected endpoints. Set `create: true` and supply `adminApiToken` to have the chart manage it, or leave `create: false` and reference one you created yourself. | | service.authToken | string | `"asdfghjkl"` | The service authentication token used in the communication between website and REST-API for spam mitigation. This token is public. Please choose a random generated string. | -| service.image | string | `"theiacloud/theia-cloud-service:1.2.0-next"` | The image to use | +| service.image | string | `"{{ .Values.imageRegistry }}/eduide-cloud/service:{{ .Values.versions.cloud }}"` | The image to use. Templated, so the tag follows versions.cloud unless the whole string is overridden. | | service.imagePullPolicy | string | `nil` | Optional: Override the imagePullPolicy for the service's docker image. If this is omitted or empty, the root at .Values.imagePullPolicy is used. | | service.imagePullSecret | string | `nil` | Optional: the image pull secret | | service.port | int | `8081` | service port (default: 8081) | @@ -128,6 +145,12 @@ A Helm chart for Theia Cloud | service.sentry | object | (see details below) | Values related to Sentry on the service. | | service.sentry.enable | bool | `true` | Whether to set SENTRY_ENABLE=true in the service deployment. | | servicerole.name | string | `"service-api-access"` | | +| skipPreflight | bool | `false` | Skip the check that eduide-cluster is installed on this cluster. Only useful for rendering against a cluster that intentionally lacks it. | +| theia-workspace-garbage-collector | object | `{"enabled":true,"image":{"tag":"599557839e5c5893eb0c20785dac671ae70f7e8a"}}` | Reaps workspaces whose sessions are long gone. | +| versions | object | (see details below) | Image versions, one per source repository. Every image the chart deploys derives its tag from one of these three, so a release is three numbers rather than nineteen image strings scattered across environment values files. | +| versions.cloud | string | `"1.2.0"` | EduIDE-Cloud: the operator and the REST service. Released independently of the IDE images, so it carries its own version. | +| versions.ide | string | `""` | The IDE images from the EduIDE repository (java-17, c, python, ...). Empty falls through to the chart's appVersion, which is what a release sets, so a plain `helm install --version 2.0.0` pins every IDE image to the tag that release published. | +| versions.landingPage | string | `"1.2.0"` | EduIDE-Landing-Page. Released independently as well. | ---------------------------------------------- Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/theia-cloud-crds/README.md.gotmpl b/charts/eduide/README.md.gotmpl similarity index 100% rename from charts/theia-cloud-crds/README.md.gotmpl rename to charts/eduide/README.md.gotmpl diff --git a/charts/theia-cloud/logos/cdtcloud.svg b/charts/eduide/logos/cdtcloud.svg similarity index 100% rename from charts/theia-cloud/logos/cdtcloud.svg rename to charts/eduide/logos/cdtcloud.svg diff --git a/charts/theia-cloud/logos/theiablueprint.svg b/charts/eduide/logos/theiablueprint.svg similarity index 100% rename from charts/theia-cloud/logos/theiablueprint.svg rename to charts/eduide/logos/theiablueprint.svg diff --git a/charts/theia-cloud/templates/_gateway-helpers.tpl b/charts/eduide/templates/_gateway-helpers.tpl similarity index 100% rename from charts/theia-cloud/templates/_gateway-helpers.tpl rename to charts/eduide/templates/_gateway-helpers.tpl diff --git a/charts/theia-cloud/templates/_helpers.tpl b/charts/eduide/templates/_helpers.tpl similarity index 51% rename from charts/theia-cloud/templates/_helpers.tpl rename to charts/eduide/templates/_helpers.tpl index 4ef5360..1275b0a 100644 --- a/charts/theia-cloud/templates/_helpers.tpl +++ b/charts/eduide/templates/_helpers.tpl @@ -55,3 +55,64 @@ section name, so non-alphanumerics collapse to hyphens and it is capped at 63. {{- define "theia-cloud.gateway.wildcardListenerName" -}} {{- printf "https-%s" (regexReplaceAll "[^a-zA-Z0-9-]" .wildcard "-") | trunc 63 | trimSuffix "-" -}} {{- end -}} + +{{/* +The tag every IDE image carries. versions.ide wins if set, otherwise the +chart's appVersion, so `helm install --version 2.0.0` with no overrides pins +every IDE image to the tag that release published. +*/}} +{{- define "eduide.ideTag" -}} +{{- .Values.versions.ide | default .Chart.AppVersion -}} +{{- end -}} + +{{/* +One app's fully qualified image. `image` is a bare repository; a value that +already carries a tag or a digest is passed through untouched so an environment +can pin one app without restructuring anything. +Call with (dict "app" $app "ctx" $). +*/}} +{{- define "eduide.appImage" -}} +{{- $app := .app -}} +{{- $ctx := .ctx -}} +{{- $repo := $app.image | toString -}} +{{- if or (contains "@sha256:" $repo) (regexMatch ".*:[^/]+$" $repo) -}} +{{- $repo -}} +{{- else -}} +{{- $tag := $app.imageTag | default (include "eduide.ideTag" $ctx) -}} +{{- if contains "/" $repo -}} +{{- if hasPrefix $ctx.Values.imageRegistry $repo -}} +{{- printf "%s:%s" $repo $tag -}} +{{- else -}} +{{- printf "%s/%s:%s" $ctx.Values.imageRegistry $repo $tag -}} +{{- end -}} +{{- else -}} +{{- printf "%s/%s:%s" $ctx.Values.imageRegistry $repo $tag -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Every image this installation needs on every node: one per app, one per +sidecar, plus the landing page. Derived rather than listed, so it cannot +disagree with what the installation offers. +*/}} +{{- define "eduide.preloadImages" -}} +{{- $out := list -}} +{{- if .Values.preloading.deriveFromApps -}} +{{- if .Values.landingPage.enabled -}} +{{- $out = append $out (tpl (.Values.landingPage.image | toString) .) -}} +{{- end -}} +{{- $ctx := . -}} +{{- range $name := (.Values.appDefinitions.apps | default dict | keys | sortAlpha) -}} +{{- $app := index $ctx.Values.appDefinitions.apps $name -}} +{{- $out = append $out (include "eduide.appImage" (dict "app" $app "ctx" $ctx)) -}} +{{- range $sc := ($app.sidecars | default list) -}} +{{- $out = append $out (include "eduide.appImage" (dict "app" $sc "ctx" $ctx)) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- range $extra := (.Values.preloading.images | default list) -}} +{{- $out = append $out $extra -}} +{{- end -}} +{{- $out | toJson -}} +{{- end -}} diff --git a/charts/eduide/templates/_preflight.tpl b/charts/eduide/templates/_preflight.tpl new file mode 100644 index 0000000..9eaa165 --- /dev/null +++ b/charts/eduide/templates/_preflight.tpl @@ -0,0 +1,54 @@ +{{/* + Fail early and legibly when the cluster has not been bootstrapped. + + Without this the first symptom of a missing eduide-cluster release is the + operator crash-looping because the AppDefinition CRD does not exist, which is + a considerably worse way to find out. + + This has to stay silent offline, or it breaks `helm template`, the render + diff and CI. lookup returns empty in both cases - offline and genuinely + missing - so it cannot tell them apart on its own. Looking up the kube-system + namespace first can: every cluster has one, so an empty result means there is + no cluster to talk to and there is nothing to check. + + The earlier version keyed off .Release.IsInstall, which is true under + `helm template` as well, so it failed every offline render. It was also never + invoked from any template, which is the only reason that went unnoticed. +*/}} +{{- define "eduide.preflight" -}} +{{- if not .Values.skipPreflight }} +{{- if .Capabilities.APIVersions.Has "theia.cloud/v1beta11" }} +{{- /* CRDs are present, so the cluster chart is installed. */ -}} +{{- else if (lookup "v1" "Namespace" "" "kube-system") }} +{{- /* A real cluster, and it does not have the CRDs. */ -}} +{{- if not (lookup "v1" "ConfigMap" "eduide-system" "eduide-cluster-version") }} +{{- fail "eduide-cluster is not installed on this cluster. Run the Bootstrap cluster workflow first, or set skipPreflight=true if you know better." }} +{{- end }} +{{- end }} +{{- end }} +{{- end -}} + +{{/* + Refuse to install with the chart's placeholder Keycloak values. + + The oauth2-proxy ConfigMaps render unconditionally - the operator mounts them + into every session pod by literal name, so they are not gated on + keycloak.enable. With the defaults left in place that means a live oauth2 + proxy pointed at `https://keycloak.url/auth/realms/TheiaCloud`, a host that + does not exist. Sessions fail at the proxy rather than starting without + authentication, which is the worst of both outcomes and gives no clue why. + + An installation that genuinely has no identity provider yet sets + keycloak.allowUnauthenticated: true and says so out loud. +*/}} +{{- define "eduide.preflightKeycloak" -}} +{{- $kc := .Values.keycloak -}} +{{- if not .Values.gitea.enable }} +{{- $placeholder := or (eq ($kc.authUrl | toString) "https://keycloak.url/auth/") + (eq ($kc.realm | toString) "TheiaCloud") + (eq ($kc.clientId | toString) "theia-cloud") -}} +{{- if and $placeholder (not $kc.allowUnauthenticated) }} +{{- fail (printf "keycloak is left at the chart's placeholder values (authUrl=%s realm=%s clientId=%s). Configure them, or set keycloak.allowUnauthenticated=true to install without a working identity provider." $kc.authUrl $kc.realm $kc.clientId) }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/charts/eduide/templates/admin-api-token-secret.yaml b/charts/eduide/templates/admin-api-token-secret.yaml new file mode 100644 index 0000000..e10012b --- /dev/null +++ b/charts/eduide/templates/admin-api-token-secret.yaml @@ -0,0 +1,23 @@ +{{- /* +The bearer token the admin scaling API checks. The service template already +references this Secret by name; before 2.0.0 it was created by a separate +theia-certificates chart, whose other three templates were disabled in every +environment. Carrying one Secret is not worth a chart. + +The token itself comes from the environment's GitHub Environment secret, never +from a values file in git. +*/ -}} +{{- if .Values.service.adminApiTokenSecret.create }} +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: {{ .Values.service.adminApiTokenSecret.name }} + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: service +data: + {{ .Values.service.adminApiTokenSecret.key }}: {{ required "service.adminApiTokenSecret.create is true but service.adminApiToken is empty" .Values.service.adminApiToken | quote }} +{{- end }} diff --git a/charts/eduide/templates/appdefinitions.yaml b/charts/eduide/templates/appdefinitions.yaml new file mode 100644 index 0000000..b219de0 --- /dev/null +++ b/charts/eduide/templates/appdefinitions.yaml @@ -0,0 +1,100 @@ +{{- /* +One AppDefinition per entry in appDefinitions.apps. The same map drives the +landing page's app list and the preloading DaemonSet, so the three cannot +disagree. + +Sorted by name so the rendered output is stable and a helm diff shows real +changes rather than reordering. +*/ -}} +{{- $ctx := . -}} +{{- $d := .Values.appDefinitions.defaults | default dict -}} +{{- range $name := (.Values.appDefinitions.apps | default dict | keys | sortAlpha) }} +{{- $app := index $ctx.Values.appDefinitions.apps $name }} +{{- if not $app.image }} +{{- fail (printf "appDefinitions.apps.%s has no image" $name) }} +{{- end }} +{{- /* +minInstances and maxInstances are bootstrap defaults only. Both are required by +the CRD and a CEL rule enforces min <= max, so they cannot simply be omitted; +but the admin scaling API mutates them on the live resource, and Helm must not +reset that on every upgrade. The lookup reads back what is there. It returns +empty under `helm template`, which is why both fields are masked in the render +diff - see AGENTS.md. +*/ -}} +{{- $live := (lookup "theia.cloud/v1beta11" "AppDefinition" $ctx.Release.Namespace $name).spec | default dict }} +{{- $min := $app.minInstances | default $d.minInstances | default 0 }} +{{- if hasKey $live "minInstances" }}{{ $min = get $live "minInstances" }}{{ end }} +{{- $max := $app.maxInstances | default $d.maxInstances | default 10 }} +{{- if hasKey $live "maxInstances" }}{{ $max = get $live "maxInstances" }}{{ end }} +--- +apiVersion: theia.cloud/v1beta11 +kind: AppDefinition +metadata: + name: {{ $name }} + labels: + app.kubernetes.io/name: {{ $name }} + app.kubernetes.io/instance: {{ $ctx.Release.Name }} + app.kubernetes.io/managed-by: {{ $ctx.Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: app-definition + annotations: + helm.sh/revision: {{ $ctx.Release.Revision | quote }} +spec: + name: {{ $name }} + image: {{ include "eduide.appImage" (dict "app" $app "ctx" $ctx) }} + imagePullPolicy: {{ $app.imagePullPolicy | default $d.imagePullPolicy | default "IfNotPresent" }} + uid: {{ $app.uid | default $d.uid | default 101 }} + port: {{ $app.port | default $d.port | default 3000 }} + {{- /* The route this app's sessions are exposed through. Taken from the + gateway values rather than repeated, so renaming the route cannot orphan an + AppDefinition. */}} + ingressname: {{ tpl ($ctx.Values.gateway.instancesRouteName | toString) $ctx }} + ingressHostnamePrefixes: + {{- range ($app.ingressHostnamePrefixes | default $d.ingressHostnamePrefixes | default (list "*.webview.")) }} + - {{ . | quote }} + {{- end }} + minInstances: {{ $min }} + maxInstances: {{ $max }} + requestsMemory: {{ $app.requestsMemory | default $d.requestsMemory }} + requestsCpu: {{ $app.requestsCpu | default $d.requestsCpu }} + limitsMemory: {{ $app.limitsMemory | default $d.limitsMemory }} + limitsCpu: {{ $app.limitsCpu | default $d.limitsCpu | quote }} + downlinkLimit: {{ $app.downlinkLimit | default $d.downlinkLimit | default 30000 }} + uplinkLimit: {{ $app.uplinkLimit | default $d.uplinkLimit | default 30000 }} + mountPath: {{ $app.mountPath | default $d.mountPath | default "/home/project" }} + timeout: {{ $app.timeout | default $d.timeout | default 1440 }} + monitor: + port: {{ (($app.monitor).port) | default (($d.monitor).port) | default 3000 }} + activityTracker: + timeoutAfter: {{ ((($app.monitor).activityTracker).timeoutAfter) | default ((($d.monitor).activityTracker).timeoutAfter) | default 60 }} + notifyAfter: {{ ((($app.monitor).activityTracker).notifyAfter) | default ((($d.monitor).activityTracker).notifyAfter) | default 55 }} + {{- with ($app.options | default $d.options) }} + options: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $app.sidecars }} + sidecars: + {{- range . }} + - name: {{ .name }} + image: {{ include "eduide.appImage" (dict "app" . "ctx" $ctx) }} + port: {{ .port | default 5000 }} + {{- with .languages }} + languages: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .cpuLimit }} + cpuLimit: {{ . }} + {{- end }} + {{- with .memoryLimit }} + memoryLimit: {{ . }} + {{- end }} + {{- with .cpuRequest }} + cpuRequest: {{ . }} + {{- end }} + {{- with .memoryRequest }} + memoryRequest: {{ . }} + {{- end }} + mountWorkspace: {{ if hasKey . "mountWorkspace" }}{{ .mountWorkspace }}{{ else }}true{{ end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/theia-cloud/templates/gateway.yaml b/charts/eduide/templates/gateway.yaml similarity index 100% rename from charts/theia-cloud/templates/gateway.yaml rename to charts/eduide/templates/gateway.yaml diff --git a/charts/theia-cloud/templates/httproute-instances.yaml b/charts/eduide/templates/httproute-instances.yaml similarity index 100% rename from charts/theia-cloud/templates/httproute-instances.yaml rename to charts/eduide/templates/httproute-instances.yaml diff --git a/charts/theia-cloud/templates/httproute-landing.yaml b/charts/eduide/templates/httproute-landing.yaml similarity index 100% rename from charts/theia-cloud/templates/httproute-landing.yaml rename to charts/eduide/templates/httproute-landing.yaml diff --git a/charts/theia-cloud/templates/httproute-service.yaml b/charts/eduide/templates/httproute-service.yaml similarity index 100% rename from charts/theia-cloud/templates/httproute-service.yaml rename to charts/eduide/templates/httproute-service.yaml diff --git a/charts/theia-cloud/templates/image-preloading.yaml b/charts/eduide/templates/image-preloading.yaml similarity index 81% rename from charts/theia-cloud/templates/image-preloading.yaml rename to charts/eduide/templates/image-preloading.yaml index fc02fb9..57c6ca2 100644 --- a/charts/theia-cloud/templates/image-preloading.yaml +++ b/charts/eduide/templates/image-preloading.yaml @@ -1,16 +1,18 @@ {{ if .Values.preloading.enable -}} {{- /* -If the image preloading list is empty and the demo application is installed, -add the demo application image to the list. -If the list is not empty, it was explicitly overridden by the user and used as is. -Initialize the $images variable because otherwise it is scoped to the if/else +The list is derived from appDefinitions.apps, their sidecars and the landing +page, with preloading.images appended. Nothing has to be listed by hand, so the +preloaded set cannot disagree with what the installation actually offers - the +old hand-written, index-addressed list is why production offered c-templates +while preloading everything except c-templates. */ -}} -{{- $images := list -}} -{{- if and (not .Values.preloading.images) .Values.demoApplication.install -}} +{{- $images := include "eduide.preloadImages" . | fromJsonArray -}} +{{- if and (not $images) .Values.demoApplication.install -}} {{- $images = list .Values.demoApplication.name -}} -{{- else -}} - {{- $images = .Values.preloading.images -}} +{{- end -}} +{{- if not $images -}} +{{- fail "preloading.enable is true but no images resolved: set appDefinitions.apps, or preloading.images, or disable preloading" -}} {{- end -}} {{- /* Set image pull policy to local variable for increase readability. */ -}} diff --git a/charts/theia-cloud/templates/landing-page-config-map.yaml b/charts/eduide/templates/landing-page-config-map.yaml similarity index 87% rename from charts/theia-cloud/templates/landing-page-config-map.yaml rename to charts/eduide/templates/landing-page-config-map.yaml index b05094e..740cd0a 100644 --- a/charts/theia-cloud/templates/landing-page-config-map.yaml +++ b/charts/eduide/templates/landing-page-config-map.yaml @@ -25,8 +25,25 @@ data: {{- end }} serviceUrl: "{{ include "theia-cloud.url.service" . }}", appDefinition: "{{ tpl (.Values.landingPage.appDefinition | toString) . }}", + {{- /* + The app list is derived from appDefinitions.apps: every entry carrying a + `landingPage` key is offered, in the order it is declared there. That is + the same map the AppDefinitions and the preloading DaemonSet come from, so + the page cannot advertise an app that was never deployed - which is what + happened when this was a separate hand-maintained map. + landingPage.additionalApps still wins if set, as an escape hatch. + */}} + {{- $apps := .Values.landingPage.additionalApps }} + {{- if not $apps }} + {{- $apps = dict }} + {{- $ctx := . }} + {{- range $n := (.Values.appDefinitions.apps | default dict | keys | sortAlpha) }} + {{- $a := index $ctx.Values.appDefinitions.apps $n }} + {{- if $a.landingPage }}{{ $apps = set $apps $n $a.landingPage }}{{ end }} + {{- end }} + {{- end }} additionalApps: [ - {{- range $key, $val := .Values.landingPage.additionalApps }} + {{- range $key, $val := $apps }} {{- $image := (get $val "image" | default (get $val "Image")) }} { serviceAuthToken: {{ $key | quote}}, diff --git a/charts/theia-cloud/templates/landing-page.yaml b/charts/eduide/templates/landing-page.yaml similarity index 100% rename from charts/theia-cloud/templates/landing-page.yaml rename to charts/eduide/templates/landing-page.yaml diff --git a/charts/theia-cloud/templates/oauth2-configmap-htmlpage.yaml b/charts/eduide/templates/oauth2-configmap-htmlpage.yaml similarity index 100% rename from charts/theia-cloud/templates/oauth2-configmap-htmlpage.yaml rename to charts/eduide/templates/oauth2-configmap-htmlpage.yaml diff --git a/charts/theia-cloud/templates/oauth2-configmap-oauth2proxy-keycloak.yaml b/charts/eduide/templates/oauth2-configmap-oauth2proxy-keycloak.yaml similarity index 100% rename from charts/theia-cloud/templates/oauth2-configmap-oauth2proxy-keycloak.yaml rename to charts/eduide/templates/oauth2-configmap-oauth2proxy-keycloak.yaml diff --git a/charts/theia-cloud/templates/operator-api-service-account.yaml b/charts/eduide/templates/operator-api-service-account.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-api-service-account.yaml rename to charts/eduide/templates/operator-api-service-account.yaml diff --git a/charts/theia-cloud/templates/operator-configmap-logging.yaml b/charts/eduide/templates/operator-configmap-logging.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-configmap-logging.yaml rename to charts/eduide/templates/operator-configmap-logging.yaml diff --git a/charts/theia-cloud/templates/operator-configmap.yaml b/charts/eduide/templates/operator-configmap.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-configmap.yaml rename to charts/eduide/templates/operator-configmap.yaml diff --git a/charts/theia-cloud/templates/operator-gateway-role.yaml b/charts/eduide/templates/operator-gateway-role.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-gateway-role.yaml rename to charts/eduide/templates/operator-gateway-role.yaml diff --git a/charts/theia-cloud/templates/operator-role.yaml b/charts/eduide/templates/operator-role.yaml similarity index 100% rename from charts/theia-cloud/templates/operator-role.yaml rename to charts/eduide/templates/operator-role.yaml diff --git a/charts/eduide/templates/operator-sidecar-pod-restart-role.yaml b/charts/eduide/templates/operator-sidecar-pod-restart-role.yaml new file mode 100644 index 0000000..387a394 --- /dev/null +++ b/charts/eduide/templates/operator-sidecar-pod-restart-role.yaml @@ -0,0 +1,43 @@ +{{- /* +The operator restarts a session's sidecar pods when an AppDefinition's sidecar +set changes. It needs delete on pods in its own namespace to do that. + +This was the only template in the theia-cloud-combined umbrella. Because it was +not part of any release the umbrella pulled, the deploy workflow used to adopt +it with an inline `kubectl annotate meta.helm.sh/release-name=...` on every run. +It is a normal template now and that hack is gone. +*/ -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: operator-sidecar-pod-restart + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: operator +rules: + - apiGroups: [""] + resources: ["pods"] + # deletecollection is intentionally kept for batch sidecar-pod restart cleanup operations. + verbs: ["get", "list", "watch", "delete", "deletecollection"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: operator-sidecar-pod-restart + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/part-of: eduide + app.kubernetes.io/component: operator +subjects: + - kind: ServiceAccount + name: operator-api-service-account + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: operator-sidecar-pod-restart diff --git a/charts/theia-cloud/templates/operator.yaml b/charts/eduide/templates/operator.yaml similarity index 98% rename from charts/theia-cloud/templates/operator.yaml rename to charts/eduide/templates/operator.yaml index 15b6da2..af64749 100644 --- a/charts/theia-cloud/templates/operator.yaml +++ b/charts/eduide/templates/operator.yaml @@ -1,3 +1,5 @@ +{{- include "eduide.preflight" . -}} +{{- include "eduide.preflightKeycloak" . -}} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/charts/theia-cloud/templates/service-api-service-account.yaml b/charts/eduide/templates/service-api-service-account.yaml similarity index 100% rename from charts/theia-cloud/templates/service-api-service-account.yaml rename to charts/eduide/templates/service-api-service-account.yaml diff --git a/charts/theia-cloud/templates/service-configmap.yaml b/charts/eduide/templates/service-configmap.yaml similarity index 100% rename from charts/theia-cloud/templates/service-configmap.yaml rename to charts/eduide/templates/service-configmap.yaml diff --git a/charts/theia-cloud/templates/service-role.yaml b/charts/eduide/templates/service-role.yaml similarity index 100% rename from charts/theia-cloud/templates/service-role.yaml rename to charts/eduide/templates/service-role.yaml diff --git a/charts/theia-cloud/templates/service.yaml b/charts/eduide/templates/service.yaml similarity index 100% rename from charts/theia-cloud/templates/service.yaml rename to charts/eduide/templates/service.yaml diff --git a/charts/theia-cloud/templates/theia-appdefinition-spec.yaml b/charts/eduide/templates/theia-appdefinition-spec.yaml similarity index 100% rename from charts/theia-cloud/templates/theia-appdefinition-spec.yaml rename to charts/eduide/templates/theia-appdefinition-spec.yaml diff --git a/charts/theia-cloud/values.yaml b/charts/eduide/values.yaml similarity index 70% rename from charts/theia-cloud/values.yaml rename to charts/eduide/values.yaml index 392c9ac..eb98195 100644 --- a/charts/theia-cloud/values.yaml +++ b/charts/eduide/values.yaml @@ -1,3 +1,22 @@ +# -- Image versions, one per source repository. Every image the chart deploys +# derives its tag from one of these three, so a release is three numbers rather +# than nineteen image strings scattered across environment values files. +# @default -- (see details below) +versions: + # -- The IDE images from the EduIDE repository (java-17, c, python, ...). + # Empty falls through to the chart's appVersion, which is what a release sets, + # so a plain `helm install --version 2.0.0` pins every IDE image to the tag + # that release published. + ide: "" + # -- EduIDE-Cloud: the operator and the REST service. Released independently + # of the IDE images, so it carries its own version. + cloud: "1.2.0" + # -- EduIDE-Landing-Page. Released independently as well. + landingPage: "1.2.0" + +# -- The container registry every EduIDE image is pulled from. +imageRegistry: ghcr.io/eduide + # -- The default imagePullPolicy for containers of theia cloud. # Can be overridden for individual components by specifying the imagePullPolicy variable there. # Possible values: @@ -72,8 +91,9 @@ hosts: landingPage: # -- Whether the landing page shall be enabled enabled: true - # -- the landing page image to use - image: theiacloud/theia-cloud-landing-page:1.2.0-next + # -- the landing page image to use. Templated, so the tag follows + # versions.landingPage unless the whole string is overridden. + image: '{{ .Values.imageRegistry }}/eduidec-landing-page:{{ .Values.versions.landingPage }}' # -- Optional: Override the imagePullPolicy for the landing page's docker image. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: @@ -85,7 +105,7 @@ landingPage: # -- Whether to set SENTRY_ENABLE=true in the landing page deployment. enable: true # -- the app id to launch - appDefinition: "theia-cloud-demo" + appDefinition: "java-17-templates-latest" # -- If set to true no persisted storage is used when creating sessions on the landing page. # Set to false if you want to use persisted storage. ephemeralStorage: true @@ -183,6 +203,13 @@ landingPage: keycloak: # -- Whether keycloak authentication shall be used enable: false + # -- Install even though the Keycloak values below are still the chart's + # placeholders. The oauth2-proxy ConfigMaps render regardless of `enable` + # (the operator mounts them into every session by literal name), so leaving + # the placeholders means a proxy pointed at a host that does not exist and + # sessions that fail rather than run unauthenticated. Only set this for an + # installation that is deliberately not exposed yet. + allowUnauthenticated: false # -- The name of the Keycloak group identifying admin users who are allowed to access the service's admin endpoints. adminGroup: "theia-cloud/admin" # -- Key cloak auth URL. Only has to be specified when enable: true @@ -245,8 +272,9 @@ oauth2Proxy: # -- Values related to the operator # @default -- (see details below) operator: - # -- The operator image - image: theiacloud/theia-cloud-operator:1.2.0-next + # -- The operator image. Templated, so the tag follows versions.cloud unless + # the whole string is overridden. + image: '{{ .Values.imageRegistry }}/eduide-cloud/operator:{{ .Values.versions.cloud }}' # -- Optional: Override the imagePullPolicy for the operator's docker image. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: @@ -346,13 +374,20 @@ service: # -- The service authentication token used in the communication between website and REST-API # for spam mitigation. This token is public. Please choose a random generated string. authToken: asdfghjkl - # -- Reference to an existing Kubernetes Secret containing the bearer token for admin API token protected endpoints. - # The chart does not create or manage this Secret. + # -- The Kubernetes Secret holding the bearer token for admin API token + # protected endpoints. Set `create: true` and supply `adminApiToken` to have + # the chart manage it, or leave `create: false` and reference one you created + # yourself. adminApiTokenSecret: + create: false name: service-admin-api-token key: ADMIN_API_TOKEN - # -- The image to use - image: theiacloud/theia-cloud-service:1.2.0-next + # -- Base64-encoded admin API token. Only read when adminApiTokenSecret.create + # is true. Comes from a deployment secret, never from a file in git. + adminApiToken: "" + # -- The image to use. Templated, so the tag follows versions.cloud unless + # the whole string is overridden. + image: '{{ .Values.imageRegistry }}/eduide-cloud/service:{{ .Values.versions.cloud }}' # -- Optional: Override the imagePullPolicy for the service's docker image. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: @@ -387,7 +422,7 @@ gateway: # Example for a centralized shared gateway: # parentRefs: # - name: theia-shared-gateway - # namespace: gateway-system + # namespace: eduide-system parentRefs: [] # -- HTTPS listener port httpsPort: 443 @@ -420,17 +455,152 @@ monitor: enable: true # -- Minutes between re-pinging the pods interval: 1 +# -- The IDE applications this installation offers. +# +# This map is the single source of truth for three things that used to be +# configured separately and drifted apart: the AppDefinition custom resources, +# the app list the landing page shows, and the set of images preloaded onto +# every node. Adding a language is one entry here, not three edits in two +# repositories. +# +# Each key is the AppDefinition name. `image` is a repository without a tag - +# the tag comes from versions.ide (or the chart's appVersion), so a release +# moves every IDE image at once. An entry with a `landingPage` key is offered in +# the landing page drop-down; one without is deployable but hidden. +# @default -- (see details below) +appDefinitions: + # -- Applied to every app that does not state its own. Only the four scaling + # and sizing values genuinely differ between languages. + defaults: + uid: 101 + port: 3000 + mountPath: /home/project + timeout: 1440 + requestsCpu: 200m + requestsMemory: 500M + limitsMemory: 2400M + limitsCpu: "2" + minInstances: 0 + maxInstances: 1000 + downlinkLimit: 30000 + uplinkLimit: 30000 + imagePullPolicy: IfNotPresent + options: + dataBridgeEnabled: "true" + dataBridgePort: "16281" + # -- The applications. Key is the AppDefinition name. + apps: + java-17-latest: + image: eduide/java-17 + requestsCpu: 500m + limitsMemory: 3000M + # Java is the common case, so some instances are kept warm. + minInstances: 3 + landingPage: + label: Java 17 + # Deployable and startable by name, but not offered in the drop-down - + # the -templates variant is what students pick. + visible: false + java-17-templates-latest: + image: eduide/java-17-templates + requestsCpu: 500m + limitsMemory: 3000M + landingPage: + label: Java 17 + # A -templates image exists to offer a build-system choice. Dropping + # this list is what makes the picker disappear from the landing page. + buildSystems: + - { id: maven, label: Maven } + - { id: gradle, label: Gradle } + c-latest: + image: eduide/c + landingPage: + label: C + visible: false + c-templates-latest: + image: eduide/c-templates + landingPage: + label: C + buildSystems: + - { id: bazel, label: Bazel } + - { id: make, label: Make } + javascript-latest: + image: eduide/javascript + landingPage: + label: Javascript + ocaml-latest: + image: eduide/ocaml + landingPage: + label: Ocaml + python-latest: + image: eduide/python + landingPage: + label: Python + rust-latest: + image: eduide/rust + landingPage: + label: Rust + # -- Values to configure preloading of images on Kubernetes nodes. # @default -- (see details below) preloading: # -- Is image preloading enabled. enable: true - # -- Images to preload. Each item is either an image reference string or a map: + # -- Extra images to preload, on top of the ones derived automatically. + # + # Leave this empty. The chart preloads every appDefinitions.apps image, every + # sidecar image and the landing page image without being told, so the list + # cannot fall out of step with what the installation actually offers. It used + # to be written out by hand per environment and addressed by array index, + # which is how production ended up offering c-templates while preloading + # everything except c-templates. + # + # Each item is either an image reference string or a map: # `{ image: "...", args: ["--version"] }` to use the image entrypoint (distroless-friendly), # or `{ image: "...", command: [...], args: [...] }` for a full override. If only strings are used, # the chart runs `/bin/sh -c 'echo …; exit 0'` (shell required in the image). - # If the list is empty and demoApplication.install == true, demoApplication.name is automatically added. images: [] + # -- Set to false to preload only preloading.images and nothing derived. + deriveFromApps: true # -- Optional: Override the imagePullPolicy for the image preloading containers. # If this is omitted or empty, the root at .Values.imagePullPolicy is used. imagePullPolicy: + +# -- Whether this installation is scraped by Prometheus and appears on the +# dashboards. +# +# The PodMonitor objects themselves are in `eduide-cluster`, not here. They have +# to be created in Rancher's own namespace to be discovered, and one PodMonitor +# per tenant writing into a shared namespace would collide on names. So the +# cluster chart owns the objects and this flag decides whether this release's +# namespace is in the list they watch - `bootstrap-cluster.yml` reads it when it +# derives that list. +# +# Turning it off means this environment stops being scraped. Nothing else about +# the release changes; `monitor.enable` below is a different thing entirely (the +# operator's own session activity tracker). +monitoring: + enabled: true + +# -- The Gradle build cache and Maven proxy. Optional: nothing reaches it +# unless operator.enableBuildCaching or operator.enableDependencyCaching is also +# turned on, so enabling this alone deploys a cache with no clients. +eduide-shared-cache: + enabled: false + +# -- Reaps workspaces whose sessions are long gone. +theia-workspace-garbage-collector: + enabled: true + image: + # The garbage collector has never been released with a version tag - GHCR + # holds only `latest`, `main` and per-commit SHAs. Its own chart defaults to + # `latest`, which would mean a release of this chart installs whatever was + # built most recently, and `helm upgrade` would see no diff when it changed. + # Pinned to the commit that `latest` pointed at on 2026-08-25 so a 2.0.0 + # install is reproducible. Replace this with a semver tag once the + # workspace-garbage-collector repo cuts a release. + tag: "599557839e5c5893eb0c20785dac671ae70f7e8a" + +# -- Skip the check that eduide-cluster is installed on this cluster. +# Only useful for rendering against a cluster that intentionally lacks it. +skipPreflight: false diff --git a/charts/theia-cloud-base/Chart.yaml b/charts/theia-cloud-base/Chart.yaml deleted file mode 100644 index d0a7410..0000000 --- a/charts/theia-cloud-base/Chart.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: v2 -name: theia-cloud-base -description: Theia-cloud base chart - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.0-rc0 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: "1.4.0-next" diff --git a/charts/theia-cloud-base/README.md b/charts/theia-cloud-base/README.md deleted file mode 100644 index c65849a..0000000 --- a/charts/theia-cloud-base/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# theia-cloud-base - -![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.4.0-next](https://img.shields.io/badge/AppVersion-1.4.0--next-informational?style=flat-square) - -Theia-cloud base chart - -*This chart was tested with Helm version v3.17.0.* -*Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* - -## Values - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| certmanager.namespace | string | `"cert-manager"` | the namespace where the cert-manager is installed | -| issuer.email | string | `"mmorlock@example.com"` | email used to issue let's encrypt certificates | -| issuerca.enable | bool | `true` | whether to install the CA certificate signer | -| issuerca.name | string | `"theia-cloud-ca-certificate-signer"` | name for the issuer preparing a self signed CA certificate | -| issuerprod.enable | bool | `false` | whether to install the let's encrypt production cluster issuer | -| issuerprod.name | string | `"letsencrypt-prod"` | name for the let's encrypt production cluster issuer | -| issuerprod.solvers | list | `[]` | ACME solver list for cert-manager (required when `issuerprod.enable=true`) | -| issuerstaging.name | string | `"theia-cloud-selfsigned-issuer"` | name for the self signed cluster issuer | -| operatorrole.name | string | `"operator-api-access"` | name for the operator's cluster role | -| servicerole.name | string | `"service-api-access"` | name for the services' cluster role | - ----------------------------------------------- -Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/theia-cloud-base/values.yaml b/charts/theia-cloud-base/values.yaml deleted file mode 100644 index 9761a83..0000000 --- a/charts/theia-cloud-base/values.yaml +++ /dev/null @@ -1,33 +0,0 @@ -issuerca: - # -- whether to install the CA certificate signer - enable: true - # -- name for the issuer preparing a self signed CA certificate - name: theia-cloud-ca-certificate-signer - -issuerprod: - # -- whether to install the let's encrypt production cluster issuer - enable: false - # -- name for the let's encrypt production cluster issuer - name: letsencrypt-prod - # -- ACME solver list for cert-manager (required when `issuerprod.enable=true`) - solvers: [] - -issuerstaging: - # -- name for the self signed cluster issuer - name: theia-cloud-selfsigned-issuer - -issuer: - # -- email used to issue let's encrypt certificates - email: mmorlock@example.com - -operatorrole: - # -- name for the operator's cluster role - name: operator-api-access - -servicerole: - # -- name for the services' cluster role - name: service-api-access - -certmanager: - # -- the namespace where the cert-manager is installed - namespace: cert-manager diff --git a/charts/theia-cloud-crds/Chart.yaml b/charts/theia-cloud-crds/Chart.yaml deleted file mode 100644 index d1df032..0000000 --- a/charts/theia-cloud-crds/Chart.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: v2 -name: theia-cloud-crds -description: A Helm chart for the custom resource definitions (CRDs) of Theia Cloud - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.0-rc0 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: "1.2.0-next" diff --git a/charts/theia-cloud-crds/README.md b/charts/theia-cloud-crds/README.md deleted file mode 100644 index 114976a..0000000 --- a/charts/theia-cloud-crds/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# theia-cloud-crds - -![Version: 1.0.0-rc0](https://img.shields.io/badge/Version-1.0.0--rc0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.2.0-next](https://img.shields.io/badge/AppVersion-1.2.0--next-informational?style=flat-square) - -A Helm chart for the custom resource definitions (CRDs) of Theia Cloud - -*This chart was tested with Helm version v3.17.0.* -*Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* - -## Values - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| clusterIssuer | string | `"theia-cloud-selfsigned-issuer"` | The cluster issuer to use for the certificate | -| conversion.certMountPath | string | `"/etc/webhook/certs"` | The location of where the certificates are mounted into the container (needs to match with application.properties) | -| conversion.certReloadPeriod | int | `604800` | The certificate reload period in seconds | -| conversion.image | string | `"theiacloud/theia-cloud-conversion-webhook:1.2.0-next"` | The image of the webhook container | - ----------------------------------------------- -Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2) diff --git a/charts/theia-cloud-crds/values.yaml b/charts/theia-cloud-crds/values.yaml deleted file mode 100644 index 0829c42..0000000 --- a/charts/theia-cloud-crds/values.yaml +++ /dev/null @@ -1,10 +0,0 @@ -conversion: - # -- The image of the webhook container - image: theiacloud/theia-cloud-conversion-webhook:1.2.0-next - # -- The location of where the certificates are mounted into the container (needs to match with application.properties) - certMountPath: /etc/webhook/certs - # -- The certificate reload period in seconds - certReloadPeriod: 604800 - -# -- The cluster issuer to use for the certificate -clusterIssuer: theia-cloud-selfsigned-issuer diff --git a/charts/theia-cloud/.helmignore b/charts/theia-cloud/.helmignore deleted file mode 100644 index 0e8a0eb..0000000 --- a/charts/theia-cloud/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/charts/theia-cloud/Chart.yaml b/charts/theia-cloud/Chart.yaml deleted file mode 100644 index 84f47bc..0000000 --- a/charts/theia-cloud/Chart.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: v2 -name: theia-cloud -description: A Helm chart for Theia Cloud -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.0-rc0 -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: "1.4.0-next" diff --git a/charts/theia-cloud/README.md.gotmpl b/charts/theia-cloud/README.md.gotmpl deleted file mode 100644 index f7d38c6..0000000 --- a/charts/theia-cloud/README.md.gotmpl +++ /dev/null @@ -1,21 +0,0 @@ -{{ template "chart.header" . }} -{{ template "chart.deprecationWarning" . }} - -{{ template "chart.badgesSection" . }} - -{{ template "chart.description" . }} - -*This chart was tested with Helm version v3.17.0.* -*Other versions may work as well, but if you encounter any issues, we recommend trying with the tested version to rule out version-specific problems.* - -{{ template "chart.homepageLine" . }} - -{{ template "chart.maintainersSection" . }} - -{{ template "chart.sourcesSection" . }} - -{{ template "chart.requirementsSection" . }} - -{{ template "chart.valuesSection" . }} - -{{ template "helm-docs.versionFooter" . }} diff --git a/docs/charts.md b/docs/charts.md new file mode 100644 index 0000000..376e660 --- /dev/null +++ b/docs/charts.md @@ -0,0 +1,90 @@ +# The charts + +Two charts, released together with the same version. + +| Chart | Installed | Owns | +|---|---|---| +| `eduide-cluster` | once per **cluster** | CRDs, the conversion webhook, ClusterRoles, cert-manager issuers | +| `eduide` | once per **environment** | operator, REST service, landing page, routes, config | + +```bash +# once per cluster +helm install eduide-cluster oci://ghcr.io/eduide/charts/eduide-cluster \ + --version 2.0.0 -n eduide-system --create-namespace + +# once per environment +helm install eduide oci://ghcr.io/eduide/charts/eduide \ + --version 2.0.0 -n eduide-test1 -f my-values.yaml +``` + +## Why two charts and not one + +Everything in `eduide-cluster` is cluster-scoped or singular: a CRD exists once, +and a CRD names exactly one conversion webhook service. Everything in `eduide` +exists once per environment. + +Before the split, every tenant deploy also reinstalled the cluster-scoped +charts into the `default` namespace. Three concurrent test deploys therefore +raced over the same objects, which was worked around with a six-attempt retry +loop. One owner removes the race instead of retrying through it. + +It also means a tenant upgrade cannot touch a CRD, so it cannot break the other +environments on the same cluster. + +### The conversion webhook belongs to the cluster + +A CRD's `conversion.webhook.clientConfig.service` names one namespace and one +service. If the webhook were a tenant resource, "which of the four environments +on this cluster serves CRD conversion?" would have no answer, and tenants on +different chart versions would fight over one conversion schema. + +## Install order + +`eduide-cluster` first. The tenant chart checks for it and fails with a usable +message if it is missing; without that check the first symptom is the operator +crash-looping on an absent CRD. Set `skipPreflight=true` to bypass it. + +## CRDs are annotated `helm.sh/resource-policy: keep` + +`helm uninstall eduide-cluster` will not delete them. Deleting a CRD deletes +every object of that kind, which here means every live Session, Workspace and +AppDefinition on the cluster. Remove them by hand if you really mean to. + +They are ordinary templates rather than files under `crds/`, because Helm never +upgrades anything in `crds/` and these change with almost every release +(`v1beta8` through `v1beta11` so far). + +## Renaming an existing installation + +Helm will not manage an object it did not create, so pointing a new release +name at existing objects normally deletes and recreates everything. Adopt them +instead: + +```bash +DRY_RUN=1 ./scripts/adopt-release.sh test1 eduide \ + deploy/operator-deployment deploy/service-deployment deploy/landing-page-deployment +``` + +Drop `DRY_RUN` once the output looks right, then upgrade under the new name. + +## Resource names are deliberately not release-prefixed + +The operator mounts `oauth2-proxy-config`, `oauth2-templates` and +`oauth2-emails` **by literal name** into every session pod +(`AddedHandlerUtil.java:88` and `templateDeployment.yaml`). Prefixing them would +break every running session. + +One install means one namespace, so prefixing buys no collision protection +anyway. Standard `app.kubernetes.io/*` labels give the same grouping without +the rename, and labels are additive on upgrade. + +## Checking a change + +```bash +helm lint charts/eduide charts/eduide-cluster +./scripts/render-envs.sh /tmp/out # render every real environment +``` + +CI additionally renders the PR base and head and posts the diff, which is the +only reliable answer to "what will this do to production". For a pure refactor +the expected result is an empty diff. diff --git a/scripts/adopt-release.sh b/scripts/adopt-release.sh new file mode 100755 index 0000000..4e2c164 --- /dev/null +++ b/scripts/adopt-release.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Hand existing objects over to a new Helm release without recreating them. +# +# ./scripts/adopt-release.sh ... +# DRY_RUN=1 ./scripts/adopt-release.sh ... # print, change nothing +# +# Helm refuses to manage an object it did not create, and renaming a release +# would otherwise mean deleting and recreating every object in it. Annotating +# them first makes the rename an in-place upgrade instead. +# +# Generalises the inline `kubectl annotate role/operator-sidecar-pod-restart` +# hack that had grown into the old deploy workflow. +# +# Idempotent. Run it immediately before the first upgrade under the new name. + +set -euo pipefail + +NS="${1:?usage: adopt-release.sh ...}" +REL="${2:?usage: adopt-release.sh ...}" +shift 2 +[[ $# -gt 0 ]] || { echo "no objects given" >&2; exit 2; } + +run() { + if [[ -n "${DRY_RUN:-}" ]]; then echo " would: $*"; else "$@"; fi +} + +for obj in "$@"; do + if ! kubectl -n "$NS" get "$obj" >/dev/null 2>&1; then + echo " skip $obj (does not exist)" + continue + fi + owner=$(kubectl -n "$NS" get "$obj" -o jsonpath={.metadata.annotations.meta.helm.sh/release-name} 2>/dev/null || true) + if [[ "$owner" == "$REL" ]]; then + echo " ok $obj (already owned by $REL)" + continue + fi + echo " adopt $obj ${owner:+(was $owner)}" + run kubectl -n "$NS" annotate --overwrite "$obj" \ + "meta.helm.sh/release-name=$REL" "meta.helm.sh/release-namespace=$NS" + run kubectl -n "$NS" label --overwrite "$obj" "app.kubernetes.io/managed-by=Helm" +done diff --git a/scripts/check-agents-md.sh b/scripts/check-agents-md.sh new file mode 100755 index 0000000..2aa8353 --- /dev/null +++ b/scripts/check-agents-md.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Flag paths referenced by AGENTS.md that no longer exist. +# +# Both pre-existing AGENTS.md files in this org had rotted into fiction. One +# named a CI job that had been deleted and a package.json path that does not +# exist; the other described a landing page removed months earlier. Nothing +# checked them, so nothing noticed. +# +# Only repo-relative, extension-bearing paths in backticks are checked. Prose is +# not validated, and this is a lint rather than a proof. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOC="$ROOT/AGENTS.md" +[[ -f "$DOC" ]] || { echo "no AGENTS.md here"; exit 0; } + +missing=0 +while read -r p; do + [[ "$p" == */* ]] || continue # must look like a path + [[ "$p" == *.* ]] || continue # and carry an extension + case "$p" in + http*|*ghcr.io*|*github.com*|oci://*|*.tum.de*) continue ;; + esac + if [[ ! -e "$ROOT/$p" ]]; then + echo " missing: $p" + missing=1 + fi +done < <(grep -oE '`[A-Za-z0-9_./-]+`' "$DOC" | tr -d '`' | sort -u) + +if [[ $missing -ne 0 ]]; then + echo "AGENTS.md references paths that do not exist. Fix the doc or the path." + exit 1 +fi +echo "AGENTS.md: every referenced path exists" diff --git a/scripts/render-envs.sh b/scripts/render-envs.sh index c33427a..bcc509f 100755 --- a/scripts/render-envs.sh +++ b/scripts/render-envs.sh @@ -13,9 +13,18 @@ # diffs the two trees. That diff answers the only question that matters when # changing a chart: "what will this actually do to production?" # -# Environment values live in EduIDE-deployment, nested under a `theia-cloud:` -# key of the umbrella chart's values, and use YAML anchors that only resolve in -# the context of the whole file - hence `explode(.)` before extracting. +# Environment values live in EduIDE-deployment, which has two layouts while the +# restructure lands: +# +# environments//values.yaml plain values for the eduide chart +# deployments//values.yaml the old umbrella's values, nested under a +# `theia-cloud:` key and using YAML anchors +# that only resolve within the whole file - +# hence `explode(.)` before extracting +# +# Whichever is present is used. Rendering the old layout against the new chart +# is meaningless (different value keys entirely), so the layout is chosen from +# the deployment checkout, not from the chart. # # NOTE: two templates call `lookup`, which returns empty under `helm template` # and would otherwise produce a false diff on every single PR. Both are masked @@ -29,26 +38,57 @@ CHARTS_ARG="${3:-}" if [[ -z "$DEPLOY" ]]; then for candidate in ../EduIDE-deployment ../../EduIDE-deployment ./EduIDE-deployment; do - [[ -d "$candidate/deployments" ]] && { DEPLOY="$candidate"; break; } + if [[ -d "$candidate/environments" || -d "$candidate/deployments" ]]; then + DEPLOY="$candidate"; break + fi done fi -[[ -d "${DEPLOY:-/nonexistent}/deployments" ]] || { +if [[ ! -d "${DEPLOY:-/nonexistent}/environments" && ! -d "${DEPLOY:-/nonexistent}/deployments" ]]; then echo "Could not find EduIDE-deployment. Pass its path as the second argument." >&2 exit 2 -} +fi if [[ -n "$CHARTS_ARG" ]]; then CHARTS_DIR="$(cd "$CHARTS_ARG" && pwd)" else CHARTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/charts" fi -[[ -d "$CHARTS_DIR/theia-cloud" ]] || { - echo "No theia-cloud chart under $CHARTS_DIR" >&2 +# The tenant chart was called theia-cloud before the split into +# eduide (tenant) and eduide-cluster (cluster-scoped). Accept both so this +# script can render a base checkout that predates the rename. +TENANT_CHART=eduide +[[ -d "$CHARTS_DIR/$TENANT_CHART" ]] || TENANT_CHART=theia-cloud +[[ -d "$CHARTS_DIR/$TENANT_CHART" ]] || { + echo "No eduide or theia-cloud chart under $CHARTS_DIR" >&2 exit 2 } -echo "rendering from $CHARTS_DIR" + +# The layout follows the chart generation, not whatever the deployment checkout +# happens to have. The old values are keyed under `theia-cloud:` and mean +# nothing to the eduide chart, so rendering one against the other produces a +# failure that looks like a chart bug and is not one. +if [[ "$TENANT_CHART" == eduide ]]; then LAYOUT=environments; else LAYOUT=deployments; fi + +if [[ ! -d "$DEPLOY/$LAYOUT" ]]; then + # Expected while the restructure is in flight: the head chart is `eduide` but + # EduIDE-deployment's main branch still carries `deployments/`. There is + # nothing comparable, and saying so beats failing. + echo "::notice::$TENANT_CHART needs the $LAYOUT/ layout, which this EduIDE-deployment checkout does not have." + echo "::notice::Nothing to render. Merge the matching EduIDE-deployment PR, or point this at that branch." + mkdir -p "$OUT" + exit 0 +fi +echo "rendering from $CHARTS_DIR (chart $TENANT_CHART, $LAYOUT layout)" mkdir -p "$OUT" +# charts/*/charts/ is gitignored, so a fresh checkout has no dependencies and +# `helm template` refuses to render. render-diff renders two chart trees, so +# both need resolving; the base tree may predate the dependencies entirely, +# which is why a failure there is not fatal. +"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-deps.sh" \ + "$CHARTS_DIR/$TENANT_CHART" >/dev/null 2>&1 \ + || echo "warning: could not resolve dependencies for $CHARTS_DIR/$TENANT_CHART" >&2 + # --- MASKS --------------------------------------------------------------- # Lines whose value is nondeterministic under `helm template`. If you add a # `lookup` to a template, add it here too or render-diff becomes noise. @@ -67,28 +107,42 @@ mask() { } rendered=0 -for dir in "$DEPLOY"/deployments/*/; do +for dir in "$DEPLOY"/$LAYOUT/*/; do env_name="$(basename "$dir")" case "$env_name" in *shared-gateway*) continue ;; esac [[ -f "$dir/values.yaml" ]] || continue values="$(mktemp)" - yq -r 'explode(.) | ."theia-cloud"' "$dir/values.yaml" > "$values" - ns="$(yq -r '.hosts.configuration.landing // "default"' "$values")" + extra=() + if [[ "$LAYOUT" == environments ]]; then + # The deploy supplies these from the environment's GitHub secrets. Rendering + # needs them present, not correct - they are constants here, so they add no + # diff noise. + secrets="$(mktemp)" + printf 'keycloak:\n cookieSecret: render-only\nservice:\n adminApiToken: cmVuZGVyLW9ubHk=\n' > "$secrets" + extra+=(-f "$secrets") + # Plain values, plus the base every deploy applies first. + cp "$dir/values.yaml" "$values" + [[ -f "$DEPLOY/environments/_base.yaml" ]] && extra+=(-f "$DEPLOY/environments/_base.yaml") + ns="$(yq -r '.spec.namespace // "default"' "$dir/env.yaml" 2>/dev/null || echo default)" + else + yq -r 'explode(.) | ."theia-cloud"' "$dir/values.yaml" > "$values" + ns="$(yq -r '.hosts.configuration.landing // "default"' "$values")" + fi - if ! helm template theia-cloud "$CHARTS_DIR/theia-cloud" \ - -f "$values" --namespace "$ns" 2> "$OUT/$env_name.err" | mask > "$OUT/$env_name.yaml"; then + if ! helm template theia-cloud "$CHARTS_DIR/$TENANT_CHART" \ + "${extra[@]}" -f "$values" --namespace "$ns" 2> "$OUT/$env_name.err" | mask > "$OUT/$env_name.yaml"; then echo "RENDER FAILED for $env_name:" >&2 cat "$OUT/$env_name.err" >&2 exit 1 fi - rm -f "$values" "$OUT/$env_name.err" + rm -f "$values" "$OUT/$env_name.err" "${secrets:-}" printf ' rendered %-45s %s resources\n' "$env_name" "$(grep -c '^kind:' "$OUT/$env_name.yaml" || true)" rendered=$((rendered + 1)) done # The cluster-scoped charts take no per-environment values. -for chart in theia-cloud-base theia-cloud-crds; do +for chart in eduide-cluster; do helm template "$chart" "$CHARTS_DIR/$chart" --namespace default | mask > "$OUT/_$chart.yaml" printf ' rendered %-45s %s resources\n' "$chart" "$(grep -c '^kind:' "$OUT/_$chart.yaml" || true)" rendered=$((rendered + 1)) diff --git a/scripts/resolve-deps.sh b/scripts/resolve-deps.sh new file mode 100755 index 0000000..25fb399 --- /dev/null +++ b/scripts/resolve-deps.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Fetch a chart's dependencies, if it has any. +# +# ./scripts/resolve-deps.sh charts/eduide +# ./scripts/resolve-deps.sh # every chart under charts/ +# +# charts/*/charts/ is gitignored: dependencies are resolved, not vendored, which +# is what keeps Chart.lock, Chart.yaml and HEAD from drifting apart the way they +# did before. The cost is that every command touching a chart on a fresh +# checkout has to fetch them first, and `helm template`, `helm package` and +# `helm dependency list` all refuse outright when they are missing. +# +# That was rediscovered four times in one afternoon - kubeconform, render-envs, +# test-app-consistency and the PR preview publish - so it lives in one place now +# and every caller uses it. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +resolve_one() { + local chart="$1" + [[ -f "$chart/Chart.yaml" ]] || return 0 + yq -e '.dependencies' "$chart/Chart.yaml" >/dev/null 2>&1 || return 0 + + # build honours Chart.lock and is reproducible; update re-resolves and is the + # fallback when the lock is stale or absent. + if helm dependency build "$chart" >/dev/null 2>&1; then + echo " resolved $(basename "$chart") (from Chart.lock)" + elif helm dependency update "$chart" >/dev/null 2>&1; then + echo " resolved $(basename "$chart") (re-resolved)" + else + echo " FAILED to resolve dependencies for $chart" >&2 + helm dependency build "$chart" 2>&1 | tail -3 >&2 + return 1 + fi +} + +failed=0 +if [[ $# -gt 0 ]]; then + for c in "$@"; do resolve_one "$c" || failed=1; done +else + for c in "$ROOT"/charts/*/; do resolve_one "${c%/}" || failed=1; done +fi +exit $failed diff --git a/scripts/test-app-consistency.sh b/scripts/test-app-consistency.sh new file mode 100755 index 0000000..f103b7a --- /dev/null +++ b/scripts/test-app-consistency.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# The three consumers of appDefinitions.apps must agree. +# +# An installation offers an app in three places: the AppDefinition custom +# resource that makes it deployable, the landing page entry that lets a student +# pick it, and the preloading DaemonSet that pulls its image onto every node. +# These used to be three hand-maintained lists in two repositories, addressed by +# array index. Production ended up offering c-templates while preloading +# everything except c-templates - students picking it waited for a cold +# multi-gigabyte pull. +# +# They are all derived from one map now. This asserts the derivation, so a +# template change cannot quietly reintroduce the skew. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CHART="$ROOT/charts/eduide" +FAILED=0 + +ok() { printf ' PASS %s\n' "$1"; } +bad() { printf ' FAIL %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' "$2"; FAILED=1; } + +# charts/*/charts/ is gitignored - dependencies are resolved, not vendored - so +# a fresh checkout has none and `helm template` refuses outright. Resolve once +# up front rather than leaving this script only working where someone happened +# to have run `helm dependency update` by hand. +"$(dirname "${BASH_SOURCE[0]}")/resolve-deps.sh" "$CHART" >/dev/null || { + echo "could not resolve chart dependencies" >&2; exit 1; } + +render() { + helm template t "$CHART" --set skipPreflight=true --set demoApplication.install=false \ + --set keycloak.allowUnauthenticated=true "$@" 2>/dev/null +} + +echo "=== app definitions, landing page and preloading agree ===" + +OUT=$(render) || { echo " chart does not render"; exit 1; } + +declared=$(yq -r '.appDefinitions.apps | keys | .[]' "$CHART/values.yaml" | sort) +defs=$(yq -r 'select(.kind=="AppDefinition") | .metadata.name' <<<"$OUT" | grep -v '^---$' | sort) + +if [[ "$declared" == "$defs" ]]; then + ok "$(wc -l <<<"$defs" | tr -d ' ') AppDefinitions, one per declared app" +else + bad "AppDefinitions do not match the declared apps" "$(diff <(echo "$declared") <(echo "$defs") | tr '\n' ' ')" +fi + +# Every image an AppDefinition references, and every sidecar image, has to be +# on the node before a session starts. +app_images=$(yq -r 'select(.kind=="AppDefinition") | .spec.image, (.spec.sidecars // [])[].image' <<<"$OUT" \ + | grep -v '^---$' | sort -u) +preloaded=$(yq -r 'select(.kind=="DaemonSet" and .metadata.name=="image-preloading") + | .spec.template.spec.initContainers[].image' <<<"$OUT" | grep -v '^---$' | sort -u) + +missing=$(comm -23 <(echo "$app_images") <(echo "$preloaded")) +if [[ -z "$missing" ]]; then + ok "every app and sidecar image is preloaded" +else + bad "images offered but not preloaded" "$(tr '\n' ' ' <<<"$missing")" +fi + +# The landing page must not advertise an app that was never deployed. +offered=$(yq -r 'select(.kind=="ConfigMap" and (.metadata.name|test("landing"))) + | .data | to_entries[0].value' <<<"$OUT" \ + | sed -n '/additionalApps: \[/,/^ *\],/p' \ + | grep -oE 'serviceAuthToken: "[^"]+"' | sed 's/.*"\(.*\)"/\1/' | sort -u) +if [[ -z "$offered" ]]; then + bad "landing page offers no apps at all" "" +else + orphan=$(comm -23 <(echo "$offered") <(echo "$defs")) + if [[ -z "$orphan" ]]; then + ok "$(wc -l <<<"$offered" | tr -d ' ') apps offered, all of them deployed" + else + bad "landing page offers apps with no AppDefinition" "$(tr '\n' ' ' <<<"$orphan")" + fi +fi + +# The landing page's own default app has to be one of them, or the page loads +# pointing at nothing. +default=$(yq -r '.landingPage.appDefinition' "$CHART/values.yaml") +if grep -qx "$default" <<<"$defs"; then + ok "landingPage.appDefinition '$default' exists" +else + bad "landingPage.appDefinition '$default' has no AppDefinition" "" +fi + +echo +echo "=== versions ===" + +# A release is `helm install --version X` with no overrides. Every EduIDE image +# must then carry a tag that release published, not a floating one. +APP_VERSION=$(yq -r '.appVersion' "$CHART/Chart.yaml") +floating=$(grep -oE "ghcr\.io/eduide/[^ \";']+" <<<"$OUT" | sort -u | grep -E ':(latest|main|next)$') +if [[ -z "$floating" ]]; then + ok "no floating tags in a default render" +else + bad "default render uses floating tags" "$(tr '\n' ' ' <<<"$floating")" +fi + +ide=$(grep -oE "ghcr\.io/eduide/eduide/[^ \";']+" <<<"$OUT" | sort -u) +wrong=$(grep -v ":${APP_VERSION}\$" <<<"$ide") +if [[ -z "$wrong" ]]; then + ok "every IDE image defaults to appVersion ${APP_VERSION}" +else + bad "IDE images not on appVersion ${APP_VERSION}" "$(tr '\n' ' ' <<<"$wrong")" +fi + +# Cloud and landing page release on their own cadence, so they must be +# overridable without touching anything else. +for pair in "cloud:eduide-cloud/operator" "landingPage:eduidec-landing-page"; do + key="${pair%%:*}"; repo="${pair##*:}" + got=$(render --set "versions.${key}=9.9.9" | grep -oE "ghcr\.io/eduide/${repo}:[^ \";']+" | sort -u) + if [[ "$got" == "ghcr.io/eduide/${repo}:9.9.9" ]]; then + ok "versions.${key} overrides ${repo} independently" + else + bad "versions.${key} did not take effect" "$got" + fi +done + +echo +echo "=== the dependency cache adds no routing ===" + +# The cache subchart has its own `gateway:` table, and so does this chart. Helm +# coalesces the parent's into it, which it announces as +# warning: cannot overwrite table with non table for +# eduide-shared-cache.gateway.parentRefs +# The subchart's map wins, so its HTTPRoutes stay off and only this chart's +# three routes render. That is the behaviour being relied on, so assert it: +# if coalescing ever starts propagating `gateway.enabled: true` down, the cache +# would publish routes for hostnames nobody configured. +with_cache=$(render --set eduide-shared-cache.enabled=true \ + | yq -r 'select(.kind=="HTTPRoute") | .metadata.name' 2>/dev/null | grep -vE '^(---|null)$' | sort) +without=$(render | yq -r 'select(.kind=="HTTPRoute") | .metadata.name' 2>/dev/null \ + | grep -vE '^(---|null)$' | sort) +if [[ "$with_cache" == "$without" ]]; then + ok "enabling the cache adds no HTTPRoute" +else + bad "the cache added routes" "$(comm -23 <(echo "$with_cache") <(echo "$without") | tr '\n' ' ')" +fi + +echo +echo "=== rendered names are valid Kubernetes names ===" + +# RFC 1123: lowercase alphanumerics and dashes. Easy to break from a values file +# without noticing, because helm template happily renders an invalid name and +# only the API server rejects it. A camelCase dependency alias did exactly this: +# it becomes .Chart.Name in the subchart, which built `sharedCache-redis`. +for extra in "" "--set eduide-shared-cache.enabled=true"; do + # shellcheck disable=SC2086 + names=$(render $extra | yq -r '.metadata.name' 2>/dev/null | grep -vE '^(---|null)$' | sort -u) + bad_names=$(grep -vE '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' <<<"$names" || true) + label="${extra:-defaults}" + if [[ -z "$bad_names" ]]; then + ok "$(wc -l <<<"$names" | tr -d ' ') names valid (${label})" + else + bad "invalid resource names (${label})" "$(tr '\n' ' ' <<<"$bad_names")" + fi +done + +echo +[[ $FAILED -eq 0 ]] && echo "ALL PASS" || echo "SOME FAILED" +exit $FAILED