diff --git a/.github/workflows/pkg.yml b/.github/workflows/pkg.yml index 47810b4..e432f7e 100644 --- a/.github/workflows/pkg.yml +++ b/.github/workflows/pkg.yml @@ -73,7 +73,7 @@ jobs: # name: docs # path: docs - name: Setup Go environment - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 - name: Generate index.html run: | go install github.com/halkeye/helm-repo-html@v0.2.1 diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index bc94ac6..690956f 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -1,6 +1,7 @@ ο»Ώgitignore: true ignores: - "**/node_modules/**" + - "CLAUDE.md" config: default: true MD013: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8844788 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +ο»Ώ# helm-charts: agent context + +## What this is + +A collection of Helm charts published as a chart repository at `https://devpro.github.io/helm-charts`, alongside a VitePress documentation site. +Charts cover both custom applications maintained by devpro and third-party applications packaged for convenience. + +Target platform is Linux with Docker, including WSL2, and `bash`. + +## Repository layout + +- **`charts/`**: the source of every chart, one directory per chart. + [Helm Chart Releaser](https://github.com/helm/chart-releaser) supports neither multiple chart directories nor nested levels, so every chart lives directly under `charts/` and nowhere else. +- **`docs/`**: the VitePress site, with application guides in `docs/application-guides` and custom chart documentation in `docs/custom-charts`. +- **`samples/`**: example manifests and values. +- **`scripts/`**: helper scripts, including `add_helm_repo.sh` which adds the dependency repositories the charts need. + +## Common commands + +```bash +helm lint charts/ # lint one chart +helm template myapp charts/ -f values.yaml --namespace myns # render the manifests +helm upgrade --install myapp charts/ -f values.yaml --namespace myns --create-namespace +kube-linter lint charts --config .kube-linter.yaml # the check CI runs +npm run docs:dev # serve the documentation site +``` + +## Releasing a chart + +A chart is published when its `version` in `Chart.yaml` changes on `main`, so a change to any chart file needs a version bump in the same commit or nothing is released. +`version` is the chart's own version and `appVersion` is the version of the packaged application: they move independently. +Where a chart pins an image tag in `values.yaml`, that tag and `appVersion` must be updated together. + +## Conventions + +- Chart values are documented with `# --` comments above the key, which is the helm-docs annotation form. +- A chart that packages a devpro application keeps its values structure close to the application's configuration keys, so that a setting can be traced from `values.yaml` to the environment variable it becomes. +- CI runs `kube-linter` over `charts/` with `.kube-linter.yaml`, and `ct lint` over the charts changed against `main`. + Exclusions belong in `.kube-linter.yaml` with a comment saying why. +- Markdown and YAML are linted in CI through `.markdownlint-cli2.yaml` and `.yamllint.yaml`. +- Do not run markdownlint: linting is run manually by the maintainer and in CI. +- Files carrying real values for a local deployment are named `values.mine.yaml` and are gitignored, so they are never edited or committed. + +## Working rules + +- **Every command runs in the foreground, and the agent waits for it.** + No background commands, no subagents, no forks, no parallel tasks, even for a long commands. +- Headers stay: short documentation still has sections. +- Commit only when asked, and never push. + Shell scripts are `snake_case` and committed with the executable bit (`git update-index --chmod=+x`). +- Documentation is as short as possible. + +## Writing style + +Applies to Markdown, code comments, commit messages and prose in scripts. + +- **A comment says why, not what, and the why is timeless.** +- **One thought per line.** + Every sentence starts on its own line, and there is no maximum line length. +- **No em dash, no en dash.** + A colon, a comma, or a full stop. +- **No second person.** + "The working tree", not "your working tree"; ``, not ``. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index d1b76fe..7608036 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ -ο»Ώ# Kube Workload Toolkit +ο»Ώ# Devpro Helm Charts -[![GitLab Pipeline Status](https://gitlab.com/devpro-labs/automation/helm-charts/badges/main/pipeline.svg)](https://gitlab.com/devpro-labs/automation/helm-charts/-/pipelines) [![CI](https://github.com/devpro/helm-charts/actions/workflows/ci.yml/badge.svg)](https://github.com/devpro/helm-charts/actions/workflows/ci.yml) [![PKG](https://github.com/devpro/helm-charts/actions/workflows/pkg.yml/badge.svg)](https://github.com/devpro/helm-charts/actions/workflows/pkg.yml) +[![GitLab Pipeline Status](https://gitlab.com/devpro-labs/automation/helm-charts/badges/main/pipeline.svg)](https://gitlab.com/devpro-labs/automation/helm-charts/-/pipelines) Welcome! This project provides: - **Curated guides**: Step-by-step instructions for installing popular applications using their official Helm charts in a Kubernetes cluster. - **Custom Helm charts**: A collection of charts that were created for deploying unique workloads on Kubernetes. -Whether you're deploying custom solutions or setting up well-known applications like NGINX or Prometheus, this repository aims to simplify your Kubernetes journey with tested configurations and clear documentation. +Whether you're deploying custom solutions or setting up well-known applications like NGINX or Prometheus, +this repository aims to simplify your Kubernetes journey with tested configurations and clear documentation. πŸš€ Get started with the [Kube Workload Toolkit](https://kwt.devpro.fr/) @@ -80,5 +81,7 @@ helm: ## Samples -- [DevOpsDays Geneva 2023](samples/devopsdays-geneva-2023/README.md) -- [SUSE Exchange Paris 2023](samples/suse-exchange-paris-2023/README.md) +GitOps: + +- [DevOpsDays Geneva 2023](samples/gitops/devopsdays-geneva-2023/README.md) +- [SUSE Exchange Paris 2023](samples/gitops/suse-exchange-paris-2023/README.md) diff --git a/charts/keeptrack/Chart.yaml b/charts/keeptrack/Chart.yaml index 323d470..83a044d 100644 --- a/charts/keeptrack/Chart.yaml +++ b/charts/keeptrack/Chart.yaml @@ -2,7 +2,7 @@ name: keeptrack description: Helm chart for Keeptrack type: application -version: 0.2.5 +version: 0.2.6 appVersion: "2.4.1" home: https://github.com/devpro/keeptrack sources: diff --git a/charts/keeptrack/README.md b/charts/keeptrack/README.md index f3d07ee..918a95c 100644 --- a/charts/keeptrack/README.md +++ b/charts/keeptrack/README.md @@ -53,12 +53,12 @@ and the daily reference-data sync elects a single runner through a MongoDB lease Just raise `webapi.replicaCount`. -**`blazorapp`** (Blazor Server): two settings make multiple replicas work, both on by the time you scale: +**`blazorapp`** (Blazor Server): two things make multiple replicas work: 1. `blazorapp.dataProtection.enabled: true` (plus its MongoDB connection, typically the same secret as `webapi.db`) - shares the cookie-encryption key ring across replicas. Without it, a login cookie issued by one pod is unreadable by the others and users bounce between logged-in and logged-out. Worth enabling even at 1 replica: sessions then survive pod restarts. -2. `blazorapp.webSocketsOnly: true` (the default) - +2. The application's WebSockets-only transport, on by default: each Blazor circuit runs on one long-lived WebSocket and therefore naturally sticks to the pod that owns its in-memory state, with no load-balancer affinity needed. Whatever sits in front only has to pass WebSockets through (Cloudflare tunnels and every mainstream ingress do). diff --git a/charts/keeptrack/templates/_helpers.tpl b/charts/keeptrack/templates/_helpers.tpl index 4c7acf5..97994f7 100644 --- a/charts/keeptrack/templates/_helpers.tpl +++ b/charts/keeptrack/templates/_helpers.tpl @@ -1,8 +1,6 @@ -{{/* -Recommended Kubernetes labels (https://helm.sh/docs/chart_best_practices/labels/), added on top of -the app-specific `app` / `app.kubernetes.io/name` labels every template already sets directly. -These are informational only - never add them to a Deployment's spec.selector.matchLabels, which -is immutable and would break `helm upgrade` for existing releases. +ο»Ώ{{/* +Recommended Kubernetes labels (https://helm.sh/docs/chart_best_practices/labels/), added on top of the app-specific `app` / `app.kubernetes.io/name` labels every template already sets directly. +These are informational only: never add them to a Deployment's spec.selector.matchLabels, which is immutable and would break `helm upgrade` for existing releases. Call with a dict: (dict "app" . "root" $) where "." is the current blazorapp/webapi values entry. */}} {{- define "keeptrack.commonLabels" -}} diff --git a/charts/keeptrack/templates/deployment.yaml b/charts/keeptrack/templates/deployment.yaml index c4892b8..2f368fb 100644 --- a/charts/keeptrack/templates/deployment.yaml +++ b/charts/keeptrack/templates/deployment.yaml @@ -104,8 +104,6 @@ spec: {{- if eq .role "frontend" }} - name: WebApi__BaseUrl value: "http://{{ $.Values.webapi.name }}" - - name: Features__IsWebSocketsOnlyEnabled - value: {{ .webSocketsOnly | quote }} {{- if .dataProtection.enabled }} {{- if .dataProtection.connectionStringSecretKeyRef }} - name: DataProtection__MongoDb__ConnectionString @@ -215,6 +213,10 @@ spec: - name: ReferenceData__BookProvider value: {{ .referenceData.bookProvider | quote }} {{- end }} + {{- if .referenceData.videoGameProvider }} + - name: ReferenceData__VideoGameProvider + value: {{ .referenceData.videoGameProvider | quote }} + {{- end }} {{- if .referenceData.tmdb.apiKeySecretKeyRef }} - name: Tmdb__ApiKey valueFrom: diff --git a/charts/keeptrack/values.yaml b/charts/keeptrack/values.yaml index e258494..40ee318 100644 --- a/charts/keeptrack/values.yaml +++ b/charts/keeptrack/values.yaml @@ -35,12 +35,6 @@ # key: "" # connectionString: "" databaseName: "keeptrack" - # Forces the Blazor Server circuit onto a single WebSocket with no negotiate round-trip, so a circuit - # naturally sticks to the replica that owns its state - - # no load-balancer cookie affinity needed, which makes replicaCount > 1 work behind anything that passes WebSockets through (cloudflared, Traefik, plain Services). - # Only set to false if a proxy in front can't pass WebSockets; - # such a deployment must then stay at replicaCount 1. - webSocketsOnly: true webapi: enabled: true @@ -72,8 +66,10 @@ webapi: # optional: third-party providers used to enrich movies/TV shows/video games/books/albums with posters, synopsis, cast and episode data. # Leaving a key empty just means that provider's enrichment silently fails (401/403 from the provider) - the rest of the app is unaffected. referenceData: - # overrides the app's own default ("OpenLibrary") - see WebApi's AppConfiguration.BookReferenceProvider + # overrides the app's own default ("googlebooks", from its appsettings.json), see WebApi's AppConfiguration.BookReferenceProvider bookProvider: "" + # overrides the app's own default ("igdb"), see WebApi's AppConfiguration.VideoGameReferenceProvider + videoGameProvider: "" # periodic background re-sync of stale reference data (every 24h); matches the app's own default isSyncEnabled: true tmdb: diff --git a/charts/sidelab/CONTRIBUTING.md b/charts/sidelab/CONTRIBUTING.md deleted file mode 100644 index 53098ea..0000000 --- a/charts/sidelab/CONTRIBUTING.md +++ /dev/null @@ -1,306 +0,0 @@ -ο»Ώ# Contribution guide - -## Update chart dependencies - -Add the Bitnami Helm repository: - -```bash -helm repo add bitnami https://charts.bitnami.com/bitnami -helm repo update -``` - -Look for existing versions: - -```bash -helm search repo -l mongodb --versions -``` - -Manually edit `Chart.yaml` with the version. - -Update `Chart.lock` and fetch the archive into `charts/` (gitignored, rebuilt from `Chart.lock` by anyone who needs it): - -```bash -helm dependency update . -``` - -## Review the generated manifest - -```bash -helm template sidelab . -f values.yaml -f values.mine.yaml --namespace sidelab --debug > temp.yaml -``` - -Useful sanity checks on the output: - -- the same value should never appear twice from two different sources (that's what the top-level `domain` value and reusing `ingress.className`/`ingress.tls`/the cert-manager annotation for lab sessions are for), -- and `--set database.backend=mongo` with nothing else set, or `--set ingress.enabled=true` with no `domain`/`ingress.host`, should fail fast with a clear `fail` message rather than render something broken. - -## Validate on MicroK8s - -This is the fastest local loop: no cloud cluster, no DNS, no cert-manager required for the base cases below. - -### 0. Point Helm/kubectl at MicroK8s - -If your default `helm`/`kubectl` context points somewhere else (a cloud cluster, etc.), either use MicroK8s' own bundled tooling: - -```bash -microk8s helm3 -microk8s kubectl -``` - -Or export MicroK8s' kubeconfig for the rest of this session: - -```bash -microk8s config > /tmp/microk8s-kubeconfig.yaml -export KUBECONFIG=/tmp/microk8s-kubeconfig.yaml -``` - -Both were used interchangeably while validating this chart. - -### 1. Build and import the images - -MicroK8s can't `docker pull` images that only exist in the local Docker daemon, it needs to be imported them explicitly. -From the `sidelab` repo: - -```bash -docker compose --profile build-only build -docker tag sidelab-app:latest sidelab-app:v1 # avoid :latest (see docs/wsl-microk8s.md) -docker save sidelab-launcher:latest | microk8s images import - -docker save sidelab-app:v1 | microk8s images import - -``` - -`microk8s images import` reports success/failure via its exit code, not via `microk8s ctr images list`. -That command needs `sudo` and silently prints nothing without it. - -The reliable way to confirm an image actually landed is to deploy and check the Pod's events: - -```bash -kubectl describe pod -n sidelab -l app.kubernetes.io/name=sidelab | grep -A2 Events -``` - -`Pulled ... already present on machine` means it's there; `ErrImageNeverPull`/`ImagePullBackOff` means the import didn't take (or the tag doesn't match), re-run the `docker save | microk8s images import -` step. - -This state is not persistent across every MicroK8s restart. -A `microk8s stop`/`start` cycle (or one triggered by a snap refresh) can clear the image cache, so re-check before assuming a stale import is still good. - -### 2. Known-good `values.mine.yaml` combinations - -Point `image.repository`/`launcher.labImage` at the locally-imported tags in every scenario below (MicroK8s doesn't need the registry path prefix, plain `sidelab-launcher`/`sidelab-app` resolve to what was imported). - -#### a. Zero-config default: SQLite + NodePort - -No `values.mine.yaml` needed at all: - -```bash -microk8s helm3 upgrade --install sidelab . \ - --namespace sidelab --create-namespace \ - --set image.repository=sidelab-launcher --set image.tag=latest \ - --set launcher.labImage=sidelab-app:v1 -``` - -Retrieve the auto-generated admin password and log in: - -```bash -kubectl get secret -n sidelab sidelab-auth -o jsonpath='{.data.ADMIN_PASSWORD}' | base64 -d; echo -kubectl port-forward -n sidelab svc/sidelab 3000:3000 -``` - -#### b. SQLite + Ingress, on MicroK8s' bundled ingress-nginx - -MicroK8s registers ingress classes `public` and `nginx` once `microk8s enable ingress` has run (see `docs/microk8s-ingress.md` in the sidelab repo for the full mirrored-networking setup). - -No cert-manager here, plain HTTP, `nip.io` wildcard DNS: - -```yaml -domain: "127.0.0.1.nip.io" -ingress: - enabled: true - className: "public" -launcher: - labAccess: "ingress" -``` - -```bash -microk8s helm3 upgrade --install sidelab . -f values.mine.yaml \ - --namespace sidelab --create-namespace \ - --set image.repository=sidelab-launcher --set image.tag=latest \ - --set launcher.labImage=sidelab-app:v1 -``` - -Dashboard ends up at `http://sidelab.127.0.0.1.nip.io`, lab sessions at `http://.labs.127.0.0.1.nip.io`. - -#### c. MongoDB as bundled chart, disposable demo - -```yaml -database: - backend: "mongo" -mongodb: - enabled: true - auth: - rootPassword: "demo12345" # required (see the comment in values.yaml for why this can't auto-generate) -``` - -```bash -microk8s helm3 upgrade --install sidelab . -f values.mine.yaml \ - --namespace sidelab --create-namespace \ - --set image.repository=sidelab-launcher --set image.tag=latest \ - --set launcher.labImage=sidelab-app:v1 -``` - -Confirms in the launcher logs as `πŸ“¦ Database : MongoDB (sidelab)`. -Both Pods (`sidelab` and `sidelab-mongodb`) should reach `1/1 Running`. - -#### d. MongoDB external (Atlas or self-hosted) - -```yaml -database: - backend: "mongo" - mongo: - url: "mongodb+srv://user:pass@cluster.mongodb.net/sidelab" -``` - -Or, to avoid putting a credential in a values file at all, create the Secret out of band first and reference it: - -```bash -kubectl create secret generic sidelab-mongo -n sidelab \ - --from-literal=DB_URL='mongodb+srv://user:pass@cluster.mongodb.net/sidelab' -``` - -```yaml -database: - backend: "mongo" - mongo: - existingSecret: "sidelab-mongo" -``` - -#### e. Ingress + Traefik + cert-manager + Let's Encrypt (production shape) - -Needs a real cluster with Traefik and cert-manager installed. -Not exercisable on a bare MicroK8s node the way (b) is, but this is the intended production configuration, and worth keeping here as the known-valid reference: - -```yaml -domain: "labs.example.com" -ingress: - enabled: true - className: "traefik" - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-prod" - traefik.ingress.kubernetes.io/router.entrypoints: websecure - traefik.ingress.kubernetes.io/router.tls: "true" - tls: - enabled: true -launcher: - labAccess: "ingress" -database: - backend: "mongo" - mongo: - url: "mongodb+srv://user:pass@cluster.mongodb.net/sidelab" -``` - -Dashboard: `https://sidelab.labs.example.com` (from the shared `domain`). -Lab sessions: `https://.labs.labs.example.com`. -If that double `labs.` reads wrong for the naming, set `launcher.labDomain` explicitly instead of relying on the `domain` derivation (e.g. `launcher.labDomain: "labs.example.com"` directly, so sessions land on `.labs.example.com`). - -#### f. TLS terminated upstream of the cluster (Cloudflare Tunnel, external LB) - -The other production shape: nothing in the cluster holds a certificate, because an upstream hop terminates TLS and reaches the cluster over plain HTTP. -The dashboard doesn't need an `Ingress` at all (the tunnel routes straight to its `Service`) but lab sessions still do, because their host-based routing is what an ingress controller exists to do. - -```yaml -domain: "example.com" -ingress: - enabled: false # dashboard: tunnel -> Service directly, no Ingress object - className: "traefik" # still stamped onto the per-session lab Ingress objects - tls: - enabled: true # see below (this is not about holding a certificate) -launcher: - labAccess: "ingress" -database: - backend: "mongo" - mongo: - existingSecret: "sidelab-mongo" -persistence: - enabled: false # mongo backend keeps no local launcher state -extraEnv: - - name: TRUST_PROXY - value: "2" # tunnel -> ingress controller = two hops -``` - -Two settings in there are easy to get wrong, and both fail quietly: - -- **`ingress.tls.enabled: true` despite no cert-manager and no certificate.** - The launcher picks the scheme of the URL it hands learners by reading back whether the lab `Ingress` it just created has a `tls:` block. - Leave this false and every session opens at `http://.labs.example.com`: the single-use `labToken` crosses the public internet in a plaintext query string, and the lab session cookie isn't marked `Secure`. - The per-session secret it names never materializes, and that's harmless here: the controller falls back to its default certificate, which nothing ever sees, because the upstream hop reaches it over HTTP. -- **`ingress.enabled: false` does not disable `ingress.className`/`ingress.tls`.** Both are deliberately reused for lab sessions, so you configure the ingress controller once rather than twice. - -One thing to confirm on the certificate side, since `launcher.labDomain` decides it: -a wildcard covers a single label, so `.labs.example.com` needs a certificate issued for `*.labs.example.com` specifically, one for `*.example.com` won't match it. - -Cloudflare's Universal SSL is the common case where that bites, covering the apex and `*.example.com` only; a deeper wildcard there needs Total TLS / Advanced Certificate Manager. -Whoever terminates TLS in the topology is who this question is for, it doesn't arise at all when sessions are reached over a private network rather than a public hostname. - -#### g. Multiple launcher replicas - -Builds on (e) or (f), it's the same production shape with the Pod count raised. - -The launcher holds nothing in process: sessions, lab-token replay protection and expiry claims all go through the database, expiry is a 30s sweep where replicas race for an atomic claim, -and running lab Pods are reconciled from the database on startup rather than tracked in memory. - -```yaml -replicaCount: 3 -database: - backend: "mongo" - mongo: - existingSecret: "sidelab-mongo" -persistence: - enabled: false # nothing left to persist locally -launcher: - labAccess: "ingress" -podDisruptionBudget: - enabled: true # now meaningful: minAvailable 1 no longer means "never evict" -extraEnv: - - name: TRUST_PROXY - value: "1" -``` - -The two required lines are `database.backend: mongo` and `persistence.enabled: false`. -Both are enforced: asking for `replicaCount > 1` (or `autoscaling.enabled` with a `maxReplicas > 1`) on SQLite, or with a PVC that isn't `ReadWriteMany`, fails the render with the fix in the message. -SQLite is a single-writer local file, and a `ReadWriteOnce` PVC can't be mounted by two Pods to begin with β€” a chart that let either through would hand you a silently split or corrupted database. -Meeting both conditions is also what flips the `Deployment` strategy from `Recreate` to `RollingUpdate`, so upgrades stop dropping the dashboard. -Verify with: - -```bash -helm template sidelab . -f values.mine.yaml | grep -A2 'strategy:' -``` - -Two more, not enforced because neither is wrong enough to block on: - -- **`launcher.labAccess: ingress`.** NodePort lab URLs are built from `K8S_NODE_IP`, the IP of the node running the replica that served the request, so learners get URLs pointing at different nodes depending on which replica answered. -- **`TRUST_PROXY`.** The failed-login throttle is in-process, so `replicaCount: 3` already gives an attacker three times the budget (per-username throttling still applies on each replica). Without `TRUST_PROXY` the per-IP half stops working entirely, every request appearing to come from the ingress controller. - -### 3. Check everything came up - -```bash -kubectl get pod,svc,deploy,ingress,secret,pvc -n sidelab -``` - -### 4. Debug - -Shell into the launcher: - -```bash -kubectl exec -it -n sidelab deploy/sidelab -- sh -``` - -With the bundled MongoDB chart, forward its port to inspect data from Compass (`mongodb://root:@localhost:27017/sidelab?authSource=admin`): - -```bash -kubectl port-forward -n sidelab svc/sidelab-mongodb 27017:27017 -``` - -### 5. Clean up - -```bash -microk8s helm3 uninstall sidelab -n sidelab -kubectl delete namespace sidelab -``` diff --git a/charts/sidelab/Chart.yaml b/charts/sidelab/Chart.yaml index 2c4fd83..9409a2a 100644 --- a/charts/sidelab/Chart.yaml +++ b/charts/sidelab/Chart.yaml @@ -4,7 +4,7 @@ description: > Self-hosted interactive lab platform. Deploys the Sidelab launcher, which manages lab sessions as ephemeral Kubernetes Pods inside tenant-scoped namespaces. type: application -version: 0.1.5 +version: 0.1.6 appVersion: "0.1.0" keywords: - sidelab diff --git a/charts/sidelab/README.md b/charts/sidelab/README.md index 7008e73..b95765a 100644 --- a/charts/sidelab/README.md +++ b/charts/sidelab/README.md @@ -1,59 +1,18 @@ ο»Ώ# Sidelab Helm Chart -Helm chart for [Sidelab](https://github.com/devpro-training/sidelab) β€” a self-hosted interactive lab platform. -Deploys the Sidelab launcher, which manages lab sessions as ephemeral Kubernetes Pods inside tenant-scoped namespaces. - -Supports SQLite (zero-dependency, quick look/demo) or MongoDB as the database backend, and NodePort or Ingress for exposing lab sessions. -`values.yaml` is the source of truth for every option. -It's fully commented, including Traefik/cert-manager/Let's Encrypt examples. +Helm chart for Sidelab, a self-hosted interactive lab platform. ## Quick Start -Add the chart repository: - ```bash helm repo add devpro https://devpro.github.io/helm-charts helm repo update +helm upgrade --install sidelab devpro/sidelab \ + --set image.repository=/sidelab-launcher \ + --namespace sidelab --create-namespace ``` -Create the `values.yaml` file to override [default values](values.yaml). - -Install the application: - -```bash -helm upgrade --install sidelab devpro/sidelab -f values.yaml --namespace sidelab --create-namespace -``` - -One value is always required: `image.repository`. -The chart is public but the sidelab images are not published to a public registry. -Everything else has working defaults: SQLite + NodePort + auto-generated secrets, no external dependencies. -See [CONTRIBUTING.md](CONTRIBUTING.md) for ready-to-use `values.mine.yaml` snippets covering the other use cases (MongoDB, Ingress + cert-manager, the bundled MongoDB demo chart). - -## Running several launcher replicas - -The launcher is stateless β€” sessions, lab-token replay protection and expiry claims are coordinated through the database, and running lab Pods are reconciled from it on startup β€” so it scales horizontally once nothing is stored on the Pod itself: - -```yaml -replicaCount: 3 - -database: - backend: mongo - mongo: - url: mongodb://user:pass@mongo:27017/sidelab - -persistence: - enabled: false # nothing left to persist locally; a ReadWriteOnce PVC would pin the launcher to one Pod - -launcher: - labAccess: ingress # nodeport lab URLs are built from the answering node's IP - -extraEnv: - - name: TRUST_PROXY - value: "1" # the login throttle is per-replica; without this, per-IP throttling sees only the proxy -``` - -That combination also switches the Deployment from `Recreate` to `RollingUpdate`, so upgrades no longer drop the dashboard, and makes `autoscaling.enabled` usable. -Asking for more than one replica while `database.backend=sqlite` or a non-`ReadWriteMany` PVC is enabled fails at render time with the fix in the message, rather than silently corrupting a database. +> **Tip:** Every option is documented in [values.yaml](values.yaml). ## Uninstall @@ -62,9 +21,4 @@ helm uninstall sidelab -n sidelab kubectl delete namespace sidelab ``` -The auto-generated `-auth` Secret (admin password) and the data PVC are deleted along with the namespace. -Back up `database.mongo.url`'s target or the PVC first if data must be kept. - -## Going further - -Check the [contribution guide](CONTRIBUTING.md) for MicroK8s validation steps and known-good `values.yaml` combinations for every use case. +> **Note:** Deleting the namespace deletes the generated `-auth` Secret and the data volume. diff --git a/charts/sidelab/templates/_helpers.tpl b/charts/sidelab/templates/_helpers.tpl index adeb3ef..262d504 100644 --- a/charts/sidelab/templates/_helpers.tpl +++ b/charts/sidelab/templates/_helpers.tpl @@ -94,14 +94,16 @@ Ingress template ConfigMap name (for lab session Ingress resources). {{- end }} {{/* -Image tag β€” falls back to .Chart.AppVersion. +Image tag. +Falls back to .Chart.AppVersion. */}} {{- define "sidelab.imageTag" -}} {{- .Values.image.tag | default .Chart.AppVersion }} {{- end }} {{/* -Dashboard hostname. ingress.host wins; otherwise derived from the shared top-level `domain` value as "sidelab.". +Dashboard hostname. +ingress.host wins; otherwise derived from the shared top-level `domain` value as "sidelab.". Empty if neither is set. */}} {{- define "sidelab.ingressHost" -}} @@ -113,7 +115,8 @@ Empty if neither is set. {{- end }} {{/* -Lab session wildcard domain. launcher.labDomain wins; otherwise derived from the shared top-level `domain` value as "labs.". +Lab session wildcard domain. +launcher.labDomain wins; otherwise derived from the shared top-level `domain` value as "labs.". Empty if neither is set. */}} {{- define "sidelab.labDomain" -}} @@ -126,9 +129,7 @@ Empty if neither is set. {{/* MongoDB connection string. -database.mongo.url wins; -otherwise, if the bundled mongodb subchart is enabled, derived from its default standalone Service name -("-mongodb", per the alias in Chart.yaml and the Bitnami chart's own naming convention) and mongodb.auth.rootPassword. +database.mongo.url wins; otherwise, with the bundled mongodb subchart, built from its standalone Service name "-mongodb" and mongodb.auth.rootPassword. Only called from secret.yaml when database.mongo.existingSecret is not set. */}} {{- define "sidelab.mongoUrl" -}} @@ -140,9 +141,9 @@ Only called from secret.yaml when database.mongo.existingSecret is not set. {{- end }} {{/* -JWT secret β€” auth.jwtSecret wins; otherwise reused from the existing Secret on upgrade (via lookup), or freshly generated on first install. -`lookup` returns nothing outside a real cluster (e.g. `helm template`), so offline rendering always generates a fresh value β€” -expected, and harmless for a dry render. +JWT secret. +auth.jwtSecret wins; otherwise reused from the existing Secret on upgrade (via lookup), or freshly generated on first install. +`lookup` returns nothing outside a real cluster (e.g. `helm template`), so offline rendering always generates a fresh value (expected, and harmless for a dry render). Only called from secret.yaml when auth.existingSecret is not set. */}} {{- define "sidelab.jwtSecret" -}} @@ -159,7 +160,8 @@ Only called from secret.yaml when auth.existingSecret is not set. {{- end }} {{/* -Admin password β€” same reuse-then-generate pattern as sidelab.jwtSecret above. +Admin password. +Same reuse-then-generate pattern as sidelab.jwtSecret above. Only called from secret.yaml when auth.existingSecret is not set. */}} {{- define "sidelab.adminPassword" -}} @@ -176,20 +178,8 @@ Only called from secret.yaml when auth.existingSecret is not set. {{- end }} {{/* -Whether this configuration owns local state that two launcher Pods must never -share. Returns "true" or "" (empty is false in an `if`). - -Two independent reasons, and only these two: - - the sqlite backend is a single-writer file β€” unsafe between processes even on - a shared volume, and worse without one (each Pod would silently get its own - empty database on its own emptyDir); - - a PVC that isn't ReadWriteMany simply cannot be mounted by two Pods. - -Nothing else in the launcher is per-Pod state: sessions, lab-token replay -protection and expiry claims all live in the database, and lab Pods are -reconciled from it on startup. So with database.backend=mongo and -persistence.enabled=false this is empty, and the chart rolls and scales normally. - +Whether the configuration holds state only one launcher Pod may own: a sqlite database, or a volume that is not ReadWriteMany. +Returns "true" or "" (empty is false in an `if`). Drives both the Deployment update strategy and the replica validation below. */}} {{- define "sidelab.singleWriter" -}} @@ -232,10 +222,10 @@ Validate required values and emit a clear error message. {{- end }} {{- if gt (int (include "sidelab.maxReplicas" .)) 1 }} {{- if eq .Values.database.backend "sqlite" }} -{{- fail "database.backend=sqlite is a single local file and cannot be shared between launcher Pods. To run more than one replica, set database.backend=mongo (pointing at your own MongoDB via database.mongo.url) and persistence.enabled=false." }} +{{- fail "database.backend=sqlite is a single local file and cannot be shared between launcher Pods. More than one replica requires database.backend=mongo and persistence.enabled=false." }} {{- end }} {{- if and .Values.persistence.enabled (ne .Values.persistence.accessMode "ReadWriteMany") }} -{{- fail "persistence.accessMode=ReadWriteOnce pins the launcher to a single Pod. On the mongo backend the launcher keeps no local state (lab Pods clone courses themselves), so set persistence.enabled=false to run more than one replica." }} +{{- fail "persistence.accessMode=ReadWriteOnce pins the launcher to a single Pod. More than one replica requires persistence.enabled=false." }} {{- end }} {{- end }} {{- end }} diff --git a/charts/sidelab/templates/configmap.yaml b/charts/sidelab/templates/configmap.yaml index 30f8557..091f786 100644 --- a/charts/sidelab/templates/configmap.yaml +++ b/charts/sidelab/templates/configmap.yaml @@ -6,8 +6,7 @@ metadata: labels: {{- include "sidelab.labels" . | nindent 4 }} data: - # Makes the app refuse to start rather than run with an insecure default JWT_SECRET; - # belt and suspenders, since the chart always generates a real one anyway (see auth.jwtSecret in values.yaml). + # Refuses to start without a real JWT_SECRET. NODE_ENV: "production" LAUNCHER_PORT: "3000" LAUNCHER_BACKEND: {{ .Values.launcher.backend | quote }} @@ -30,18 +29,12 @@ data: {{- if .Values.launcher.labImagePullSecret }} K8S_LAB_IMAGE_PULL_SECRET: {{ .Values.launcher.labImagePullSecret | quote }} {{- end }} - # Merged into the namespaces that orphan cleanup and the restart delete-by-label fallback scan. - # The launcher discovers tenant namespaces itself, by the app.kubernetes.io/managed-by=sidelab label it stamps on every one it creates, - # so this is only needed for namespaces created outside the launcher. - # The seeded admin tenant's namespace is kept as a default anyway: it is also the fallback if the namespace-list RBAC rule is ever missing. + # The admin tenant's namespace is the default, as a fallback when namespaces cannot be listed. K8S_KNOWN_NAMESPACES: {{ .Values.launcher.knownNamespaces | default (printf "%s-admin" .Values.launcher.namespacePrefix) | quote }} --- {{- if and (eq .Values.launcher.labAccess "ingress") (not .Values.launcher.ingressTemplateConfigMap) }} -# Default Ingress template for lab session Pods, modeled on samples/manifests/ingress-template-microk8s.yaml in the sidelab repo. -# It includes the /websockify path needed by VNC-enabled labs, and the sidelab.io/session + sidelab.io/tenant labels the launcher relies on -# to reconcile orphaned lab resources after a restart (deleteByLabel fallback). -# Reuses the dashboard's own ingress.className / ingress.tls / cert-manager annotation from above, so you don't configure the same ingress controller and issuer twice. -# Override launcher.ingressTemplateConfigMap for a different shape entirely. +# Default Ingress template for lab sessions, reusing the dashboard's ingress.className, ingress.tls and cert-manager annotation. +# launcher.ingressTemplateConfigMap replaces it. apiVersion: v1 kind: ConfigMap metadata: diff --git a/charts/sidelab/templates/deployment.yaml b/charts/sidelab/templates/deployment.yaml index 914de83..0341d72 100644 --- a/charts/sidelab/templates/deployment.yaml +++ b/charts/sidelab/templates/deployment.yaml @@ -11,13 +11,10 @@ spec: {{- end }} strategy: {{- if include "sidelab.singleWriter" . }} - # This configuration keeps state only one Pod may own, the SQLite file, or a non-ReadWriteMany PVC (see the sidelab.singleWriter helper). - # The old Pod must therefore be gone before the new one starts: Recreate, at the cost of brief dashboard downtime on upgrades. - # Lab sessions are unaffected either way: their Pods keep running and the launcher reconciles from the database on the way back up. + # State only one Pod may own (see sidelab.singleWriter): the old Pod must be gone before the new one starts. type: Recreate {{- else }} - # No Pod-local state (mongo backend, no ReadWriteOnce PVC): roll without downtime, and scale to replicaCount Pods. - # The launcher is stateless here!: sessions, lab-token replay protection and expiry claims are all coordinated through the database. + # No Pod-local state: roll without downtime. type: RollingUpdate {{- end }} selector: @@ -73,15 +70,14 @@ spec: name: {{ include "sidelab.mongoSecretName" . }} {{- end }} env: - # ── Kubernetes launcher identity ─────────────────────────────────── - # The launcher uses its own namespace + Service name to know which Service lab Pods should call back to on session finish. + # The Service lab Pods call back. - name: K8S_LAUNCHER_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace - name: K8S_LAUNCHER_SERVICE value: {{ include "sidelab.fullname" . }} - # ── Node IP, used to build lab URLs when launcher.labAccess=nodeport ── + # Used to build lab URLs when launcher.labAccess=nodeport. - name: K8S_NODE_IP valueFrom: fieldRef: @@ -98,7 +94,7 @@ spec: {{- toYaml .Values.resources | nindent 12 }} {{- end }} volumeMounts: - # Writable data volume (SQLite DB + git course cache) + # Writable data volume - name: data mountPath: /app/data {{- if eq .Values.launcher.labAccess "ingress" }} diff --git a/charts/sidelab/templates/networkpolicy.yaml b/charts/sidelab/templates/networkpolicy.yaml index d0b428f..5d1b93f 100644 --- a/charts/sidelab/templates/networkpolicy.yaml +++ b/charts/sidelab/templates/networkpolicy.yaml @@ -32,8 +32,7 @@ spec: ports: - port: {{ .Values.service.port }} protocol: TCP - # Allow lab pods in tenant namespaces (sidelab-), as they call the launcher back for token validation on lab login and on session finish. - # The launcher labels every namespace it creates with this. + # Lab Pods in tenant namespaces call the launcher back. - from: - namespaceSelector: matchLabels: @@ -58,7 +57,7 @@ spec: protocol: UDP - port: 53 protocol: TCP - # Kubernetes API server (manages lab Pods/Services/Secrets/Ingresses/namespaces), and HTTPS git remotes (the launcher itself clones git-sourced courses for caching) + # Kubernetes API server and HTTPS remotes - ports: - port: 443 protocol: TCP diff --git a/charts/sidelab/templates/rbac.yaml b/charts/sidelab/templates/rbac.yaml index 7fdfb2f..e595dbe 100644 --- a/charts/sidelab/templates/rbac.yaml +++ b/charts/sidelab/templates/rbac.yaml @@ -1,6 +1,5 @@ ο»Ώ{{- if .Values.rbac.create }} -# The launcher needs cluster-scoped permissions because it creates lab Pods in dynamically-named tenant namespaces (sidelab-). -# A namespaced Role would require pre-creating one per tenant namespace, which defeats the purpose. +# Cluster-scoped because tenant namespaces are created on demand. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -8,10 +7,7 @@ metadata: labels: {{- include "sidelab.labels" . | nindent 4 }} rules: - # Namespace management β€” creates sidelab- on first lab start. - # "list" is what discoverNamespaces() uses to find tenant namespaces by the app.kubernetes.io/managed-by=sidelab label, - # for orphan cleanup on restart and the delete-by-label terminate fallback. - # Without it the launcher degrades to launcher.knownNamespaces only; silently, since the failure is caught. + # Tenant namespaces - apiGroups: [""] resources: ["namespaces"] verbs: ["get", "list", "create"] @@ -21,24 +17,22 @@ rules: resources: ["pods"] verbs: ["get", "list", "watch", "create", "delete"] - # Lab Services (NodePort or ClusterIP for ingress backend) + # Lab Services - apiGroups: [""] resources: ["services"] verbs: ["get", "list", "create", "delete"] - # Per-session Secrets holding private git PATs: created and deleted per session, never read back. - # No get/list here: on a ClusterRole that would mean read access to every Secret in the cluster. + # No get or list: on a ClusterRole that would read every Secret in the cluster. - apiGroups: [""] resources: ["secrets"] verbs: ["create", "delete"] - # Lab Ingress resources (when K8S_LAB_ACCESS=ingress) + # Lab Ingresses - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "list", "create", "delete"] - # Attaches launcher.labImagePullSecret to each tenant namespace's default ServiceAccount so lab Pods can pull a private launcher.labImage. - # resourceNames restricts this to the "default" ServiceAccount only, in any namespace. + # Attaches launcher.labImagePullSecret to the default ServiceAccount of tenant namespaces. - apiGroups: [""] resources: ["serviceaccounts"] resourceNames: ["default"] @@ -60,8 +54,7 @@ subjects: namespace: {{ .Release.Namespace }} {{- if .Values.launcher.labImagePullSecret }} --- -# Scoped read access to the *source* pull secret only, deliberately a namespaced Role in this chart's own namespace, not added to the ClusterRole above, -# which would mean read access to every Secret in the cluster. +# Read access to the source pull secret only, in a namespaced Role rather than the ClusterRole above. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: diff --git a/charts/sidelab/values.yaml b/charts/sidelab/values.yaml index f854060..3703d99 100644 --- a/charts/sidelab/values.yaml +++ b/charts/sidelab/values.yaml @@ -1,17 +1,11 @@ -ο»Ώ# ── Domain ──────────────────────────────────────────────────────────────────── -# Single source of truth for hostnames, so you don't repeat your domain in multiple places. -# Leave the two derived values below empty to use it: -# ingress.host defaults to "sidelab." (the dashboard) -# launcher.labDomain defaults to "labs." (lab sessions, wildcard) -# Only used when left empty β€” set ingress.host / launcher.labDomain directly to override. +ο»Ώ# -- Base domain: the dashboard defaults to sidelab. and lab sessions to *.labs.. domain: "" image: - # -- Required: the chart is public but the sidelab images are not published to a public registry. - # The chart fails fast at render time when unset. + # -- Launcher image repository, required. repository: pullPolicy: IfNotPresent - # -- Defaults to .Chart.AppVersion when empty. + # -- Defaults to the chart appVersion. tag: "" imagePullSecrets: [] @@ -20,82 +14,39 @@ imagePullSecrets: [] nameOverride: "" fullnameOverride: "" -# The launcher itself is stateless: sessions, lab-token replay protection and expiry claims are all coordinated through the database, -# so replicas share them, they survive restarts and rollouts, and lab Pods keep running throughout (the launcher reconciles them from the database on the way back up). -# -# Running more than one replica needs exactly two things, and the chart fails at render time with the fix in the message if either is missing: -# database.backend: mongo sqlite is a single local file, unshareable between Pods -# persistence.enabled: false a ReadWriteOnce PVC can only be mounted by one Pod (on mongo there is nothing left to persist) -# Those two also flip the update strategy from Recreate to RollingUpdate, so upgrades stop dropping the dashboard. -# -# Two things worth setting when you scale out, neither enforced: -# - launcher.labAccess=ingress β€” nodeport lab URLs are built from the IP of whichever node answered, which is unstable across replicas -# - extraEnv TRUST_PROXY (see the bottom of this file) β€” the per-IP login throttle is in-process, so N replicas already give an attacker N times the budget; -# without TRUST_PROXY it degrades further, every request looking like it came from the ingress controller +# -- More than one replica requires database.backend=mongo and persistence.enabled=false. replicaCount: 1 -# ── Auth ────────────────────────────────────────────────────────────────────── auth: - # -- JWT secret used to sign lab tokens and session cookies. - # Leave empty to auto-generate a strong random value on first install, it is then kept stable across upgrades (read back from the existing Secret). - # The app itself refuses to start in production with an unset/default secret, so this is not optional in practice, just easy to leave to the chart. + # -- Signing secret, generated on first install when empty and kept across upgrades. jwtSecret: "" - - # Initial admin credentials (only used to seed the database when it's empty) + # -- Initial admin account, only used while the database is empty. adminUsername: "admin" - # -- Leave empty to auto-generate a strong random password on first install (kept stable across upgrades). - # Retrieve it with the command printed by `helm install`/`helm upgrade` (see NOTES.txt). + # -- Generated on first install when empty, the install notes show how to read it. adminPassword: "" - - # -- Use your own pre-created Secret instead of the two fields above. - # Keys expected: JWT_SECRET, ADMIN_USERNAME, ADMIN_PASSWORD. - # GitOps note (ArgoCD/Flux): those tools render templates offline, where the auto-generate-then-reuse trick above can't look up the existing Secret, - # so every sync would rotate JWT_SECRET, logging everyone out and making stored repo tokens undecryptable. - # Under GitOps, always set this (or auth.jwtSecret). - # Operational note: the Deployment's checksum/secret annotation only covers the Secret this chart renders, so editing an *existing* Secret's contents won't roll the Pod on its own, - # follow it with `kubectl rollout restart deploy/`. + # -- Existing Secret with the keys JWT_SECRET, ADMIN_USERNAME and ADMIN_PASSWORD, replacing the three values above. existingSecret: "" -# ── Database ──────────────────────────────────────────────────────────────────── database: - # -- Options: sqlite, mongo. - # sqlite is the zero-dependency default: good for a quick look or a demo. - # mongo is the recommended backend for anything you intend to keep around; - # point it at your own MongoDB (Atlas or self-hosted) via database.mongo.url, or flip on the bundled chart below for a disposable demo instance. - # There are no schema migrations on either backend (see the app's CLAUDE.md), an in-place upgrade across a schema change needs a fresh database. + # -- sqlite or mongo. backend: "sqlite" sqlite: - # -- Path inside the container where the SQLite file lives. path: "/app/data/sidelab.db" mongo: - # -- Full MongoDB connection string, e.g. mongodb+srv://user:pass@cluster.mongodb.net/sidelab - # Not needed if mongodb.enabled=true below, the chart wires that up for you. + # -- Connection string, e.g. mongodb+srv://:@/sidelab url: "" - # -- Use your own pre-created Secret instead of database.mongo.url. - # Key expected: DB_URL. - # Takes priority over database.mongo.url and over the bundled mongodb chart below. + # -- Existing Secret with the key DB_URL, taking priority over url. existingSecret: "" -# ── Bundled MongoDB (optional, for a quick disposable demo) ─────────────────────── -# Deploys https://github.com/bitnami/charts/blob/main/bitnami/mongodb/values.yaml as a subchart alongside the launcher. -# Off by default: prefer a real, backed-up MongoDB (Atlas or self-hosted) for anything you care about; -# this is uncomfortable to lose, since it's just another Pod with a PVC, not a managed database. -# -# When enabled, the chart auto-builds database.mongo's connection string from mongodb.auth.rootPassword; -# set that one field and DB_BACKEND=mongo wiring is automatic. -# database.mongo.url / database.mongo.existingSecret, if set, always take priority over this -# (so you can point at an external Mongo even with an unrelated mongodb.enabled=true left over from testing). +# Bundled Bitnami MongoDB, for a disposable demo only. mongodb: enabled: false architecture: standalone auth: enabled: true - # -- Required when mongodb.enabled=true, unless database.mongo.url or - # database.mongo.existingSecret is set instead. - # This is intentionally not auto-generated: the same value has to reach both this subchart's own root user AND the launcher's connection string, - # and Helm cannot pass a chart-generated value into a subchart's values. + # -- Required when mongodb.enabled=true, unless database.mongo.url or database.mongo.existingSecret is set. rootPassword: "" image: registry: docker.io @@ -107,120 +58,67 @@ mongodb: global: security: allowInsecureImages: true - # -- Uncomment to point the subchart at a specific cluster storage class. - # global: - # defaultStorageClass: "" persistence: - # -- Enable a PersistentVolumeClaim for /app/data. - # Required for the sqlite backend. - # With the mongo backend the launcher keeps no local state at all (lab Pods clone courses themselves in an init container), so set this to false; - # that is what unlocks RollingUpdate deployments and multiple replicas (see replicaCount above). + # -- Volume for /app/data, required with sqlite. enabled: true - # -- Leave empty to use the cluster default. + # -- Empty for the cluster default. storageClass: "" - # -- ReadWriteOnce is right for the sqlite backend and pins the launcher to a single Pod. - # ReadWriteMany only helps if you mount extra shared state of your own β€” it does *not* make sqlite shareable - # (the chart still refuses replicaCount > 1 on sqlite: it is a single-writer file whatever the volume underneath). - # With persistence.existingClaim set, no PVC is created and this value is read only to decide whether the launcher is pinned to one Pod, - # so it must still describe what that claim actually supports. accessMode: ReadWriteOnce size: 2Gi - # -- Use an existing PVC instead of creating one. + # -- Existing PersistentVolumeClaim, instead of creating one. existingClaim: "" launcher: - # -- Only "kubernetes" is meaningful in-cluster, it's what makes the launcher create lab sessions as Pods via the Kubernetes API instead of Docker/Podman. backend: "kubernetes" labImage: "ghcr.io/devpro/sidelab-app:latest" labImagePullPolicy: "IfNotPresent" - # -- Default lab session duration, in minutes. + # -- Default session duration, in minutes. sessionDefaultDuration: 30 - # -- Maximum lab session duration a user can request, in minutes. + # -- Maximum session duration, in minutes. sessionMaxDuration: 60 - # -- How lab Pods are exposed to end users. - # Options: nodeport, ingress. - # nodeport needs nothing else and is the default; ingress needs an ingress controller and, for TLS, cert-manager (see the ingress section below). + # -- How lab sessions are exposed: nodeport or ingress. labAccess: "nodeport" - # -- Wildcard domain used to build lab session URLs when labAccess=ingress, - # e.g. "labs.example.com" makes lab URLs "https://.labs.example.com". - # Defaults to "labs." (see the top-level domain value) when left empty. + # -- Wildcard domain of lab sessions when labAccess=ingress, defaults to labs.. labDomain: "" - # -- Name of a ConfigMap holding a custom Ingress template for lab sessions (key: ingress-template.yaml). - # Leave empty to use the chart's own template, which is generated from the ingress section below and good enough for most ingress-nginx/Traefik setups. + # -- ConfigMap holding a custom lab session Ingress template (key ingress-template.yaml), replacing the generated one. ingressTemplateConfigMap: "" - # -- Kubernetes namespace prefix for tenant lab namespaces, e.g. tenant "admin" gets namespace "sidelab-admin" with the default prefix. + # -- Prefix of tenant namespaces. namespacePrefix: "sidelab" - # -- Extra tenant namespaces for orphan cleanup and the delete-by-label fallback, comma-separated. - # Normally leave empty: the launcher discovers tenant namespaces itself via the app.kubernetes.io/managed-by=sidelab label it puts on every namespace it creates. - # Only needed for namespaces created by hand without that label, e.g. "sidelab-legacy". + # -- Additional tenant namespaces, comma-separated, only for namespaces created outside the launcher. knownNamespaces: "" - # Note: the git-clone init container used for git-sourced courses always runs "alpine/git:latest". - # this is fixed in the app itself, not chart-configurable. - # Make sure that image is reachable (or pre-imported) wherever it is deployed. - - # -- Name of an existing dockerconfigjson Secret in THIS chart's own namespace (release namespace), used to pull a private launcher.labImage. - # Create it yourself first, e.g.: - # kubectl create secret docker-registry ghcr-pull \ - # --docker-server=ghcr.io --docker-username= --docker-password= \ - # -n - # The chart then copies it into every tenant namespace automatically (on every session start, self-healing) and attaches it to that namespace's default ServiceAccount. - # lab Pods run under that ServiceAccount and never set imagePullSecrets themselves, so this is what makes a private launcher.labImage pullable without any per-tenant manual step. - # Leave empty for public images. + # -- dockerconfigjson Secret in the release namespace used to pull labImage, copied into every tenant namespace. labImagePullSecret: "" -# ── Service (the launcher dashboard) ─────────────────────────────────────────── -# This is independent from launcher.labAccess above, which is only about lab session Pods. -# The dashboard itself is always reached via this Service; through the Ingress below, a NodePort, or `kubectl port-forward`. service: - # -- ClusterIP, NodePort, or LoadBalancer. + # -- ClusterIP, NodePort or LoadBalancer. type: ClusterIP port: 3000 # -- Only used when service.type=NodePort. - # Leave empty to let Kubernetes pick one. nodePort: "" -# ── Ingress (dashboard only β€” lab sessions use launcher.labAccess) ─────────────── +# Dashboard Ingress. className, tls and the cert-manager.io/cluster-issuer annotation also apply to lab sessions. ingress: - # -- Off by default so a first install doesn't need an ingress controller. enabled: false - # -- e.g. "public" or "nginx" for MicroK8s' bundled ingress-nginx, "traefik" for Traefik. + # -- e.g. "traefik" or "nginx". className: "" - # -- Hostname for the launcher dashboard. Defaults to "sidelab." - # (see the top-level domain value) when left empty. + # -- Dashboard hostname, defaults to sidelab.. host: "" annotations: {} # cert-manager.io/cluster-issuer: "letsencrypt-prod" - # traefik.ingress.kubernetes.io/router.entrypoints: websecure - # traefik.ingress.kubernetes.io/router.tls: "true" - # traefik.ingress.kubernetes.io/router.middlewares: default-redirect-https@kubernetescrd tls: - # -- Requests a certificate automatically when a cert-manager ClusterIssuer annotation is present above (see letsencrypt example in CONTRIBUTING.md). - # - # This flag reaches further than the dashboard: it also decides whether the generated *lab session* Ingress template carries a `tls:` block, - # and the launcher reads that back to pick the scheme of the URL it hands learners (`manifest.spec.tls ? 'https' : 'http'`). - # Leaving it false with launcher.labAccess=ingress means sessions open at `http://.`, - # so the single-use labToken travels in a plaintext query string and the lab session cookie is not marked Secure. - # - # Set it true even when TLS is terminated *upstream* of the cluster (a Cloudflare tunnel, an external load balancer) and no cert-manager issuer is configured: - # the referenced per-session secret simply never materializes and the ingress controller falls back to its default certificate, - # which is never presented anyway because the upstream hop reaches it over plain http. - # The flag's real job in that topology is getting the scheme right. + # -- Lab session URLs use https only when true, including when TLS terminates in front of the cluster. enabled: false secretName: "sidelab-tls" -# ── Security ──────────────────────────────────────────────────────────────────── -# All hardening below is opt-in and off by default, so the chart runs unmodified on a plain cluster. -# Uncomment what your cluster/policy needs. -# The launcher image has no baked-in non-root user, but any non-zero UID works fine; /app/data is a PVC (fsGroup handles it) and /tmp is a separate emptyDir. podSecurityContext: {} # runAsNonRoot: true # runAsUser: 1000 @@ -236,7 +134,6 @@ containerSecurityContext: {} # drop: # - ALL -# ── Resources ─────────────────────────────────────────────────────────────────── resources: {} # requests: # cpu: 100m @@ -245,8 +142,6 @@ resources: {} # cpu: 500m # memory: 512Mi -# ── Probes ─────────────────────────────────────────────────────────────────────── -# The launcher has no dedicated /healthz route, "/" serves the dashboard's static index.html unauthenticated, which works fine as a liveness signal. livenessProbe: httpGet: path: / @@ -263,54 +158,42 @@ readinessProbe: periodSeconds: 10 failureThreshold: 3 -# ── Autoscaling ────────────────────────────────────────────────────────────────── -# Same two conditions as replicaCount > 1 (see above): database.backend=mongo and persistence.enabled=false. -# maxReplicas is validated against them, so enabling this on sqlite fails the render rather than corrupting a database. -# The Deployment drops its own `replicas` field when this is on, so Helm and the HPA don't fight over the count. +# Same requirements as replicaCount above. autoscaling: enabled: false minReplicas: 1 maxReplicas: 3 targetCPUUtilizationPercentage: 80 -# ── Pod Disruption Budget ──────────────────────────────────────────────────────── -# Off by default: with replicaCount 1, a PDB with minAvailable=1 blocks voluntary node drains from ever evicting the Pod. -# Makes sense once you run 2+ replicas on the mongo backend (see replicaCount above). +# Only useful with two or more replicas: with one, it blocks node drains. podDisruptionBudget: enabled: false minAvailable: 1 -# ── Network Policies ───────────────────────────────────────────────────────────── -# Lab pods calling back into the launcher (token validation, session finish) are always allowed: -# the policy admits any namespace labeled app.kubernetes.io/managed-by=sidelab, which the launcher sets on every tenant namespace it creates. networkPolicy: enabled: false - # -- Namespace the ingress controller runs in, allowed to reach the dashboard Service. + # -- Namespace of the ingress controller allowed to reach the dashboard. ingressControllerNamespace: "kube-system" - # -- Pod label selector for the ingress controller within that namespace. - # Defaults work for Traefik; for MicroK8s' ingress-nginx use "app.kubernetes.io/name: ingress-nginx". + # -- Pod labels of the ingress controller in that namespace. ingressControllerPodSelector: app.kubernetes.io/name: traefik - # -- Extra namespace selectors allowed to reach the dashboard Service. + # -- Additional namespaces allowed to reach the dashboard. additionalIngressNamespaces: [] - # -- Extra CIDRs allowed on egress (e.g. a self-hosted MongoDB not on the default 27017 rule). + # -- Additional CIDRs allowed on egress. additionalEgressCIDRs: [] -# ── RBAC ────────────────────────────────────────────────────────────────────────── -# Required, not just hardening: without it the launcher's Kubernetes backend cannot create the namespaces/Pods/Services/Ingresses/Secrets that lab sessions are made of. -# Disable only if you manage equivalent RBAC externally. +# Required: the launcher creates the namespaces, Pods, Services and Ingresses of lab sessions. rbac: create: true serviceAccount: create: true annotations: {} - # -- Defaults to the chart's fullname. + # -- Defaults to the chart fullname. name: "" - # -- Required: the launcher authenticates to the Kubernetes API using this Pod's own mounted token. Unlike most charts, this can't default to false. + # -- Required, the launcher calls the Kubernetes API with this token. automountServiceAccountToken: true -# ── Scheduling ─────────────────────────────────────────────────────────────────── nodeSelector: {} tolerations: [] affinity: {} @@ -318,19 +201,11 @@ affinity: {} podAnnotations: {} podLabels: {} -# ── Extra environment variables passed to the launcher container ───────────────── -# TRUST_PROXY is the one most deployments actually need: the launcher throttles POST /api/auth/login per username (10 failures / 15 min) and per client IP (30), -# and the per-IP half is meaningless behind an ingress or a tunnel unless Express is told how many proxy hops to trust. -# Every request otherwise shares the proxy's address. -# Set it to the hop count ("1" behind a single ingress controller, "2" behind e.g. cloudflared -> Traefik), or "true" to trust the leftmost X-Forwarded-For. -# Per-username throttling works either way. -# -# extraEnv: -# - name: TRUST_PROXY -# value: "1" -# -# Note SESSION_HOST is *not* useful here, it is only read by the local and container backends, never by the kubernetes one this chart deploys. +# -- Extra launcher environment variables. +# TRUST_PROXY is the number of proxies in front of the launcher, needed for per-IP login throttling behind an ingress. extraEnv: [] + # - name: TRUST_PROXY + # value: "1" extraVolumes: [] extraVolumeMounts: [] diff --git a/charts/terraform-backend-mongodb/Chart.yaml b/charts/terraform-backend-mongodb/Chart.yaml index 0514096..e54f42d 100644 --- a/charts/terraform-backend-mongodb/Chart.yaml +++ b/charts/terraform-backend-mongodb/Chart.yaml @@ -2,8 +2,8 @@ name: terraform-backend-mongodb description: Helm chart for Terraform Backend MongoDB type: application -version: 0.2.0 -appVersion: "1.2.3" +version: 0.3.0 +appVersion: "1.3.0" home: https://github.com/devpro/terraform-backend-mongodb sources: - https://github.com/devpro/terraform-backend-mongodb diff --git a/charts/terraform-backend-mongodb/README.md b/charts/terraform-backend-mongodb/README.md index fabc2e7..3c9914d 100644 --- a/charts/terraform-backend-mongodb/README.md +++ b/charts/terraform-backend-mongodb/README.md @@ -86,6 +86,43 @@ If none of the three is configured, or `mongodb.enabled=true` is set without `mo the chart fails at render/install time with a clear error instead of deploying a Pod that can't reach a database. +## Authentication hardening + +Application version 1.3.0 adds a failed-attempt lockout, a short-lived credential cache and an authentication failure log that carries the caller's address. +The defaults under `authentication` in [values.yaml](values.yaml) suit most deployments and need no change. + +The setting that does need a decision is `network.trustAllProxies`, because the other two depend on it. + +Terraform's `http` backend sends a Basic credential on every single request, so the server verifies the same password over and over and every failed guess costs it a BCrypt verify. +The lockout puts a ceiling on that, and it is scoped to the username and the caller's source address together rather than to the username alone: a lockout on the username alone would let anybody on the internet lock the real operator out of the backend. + +Behind an ingress, an application that does not trust the proxy sees the proxy's address on every request. +Every caller then shares one lockout bucket, which turns the protection into the denial of service it was designed to avoid. +`network.trustAllProxies` therefore defaults to `true`, which is safe where the pod is reachable through the ingress alone. + +Where other workloads can reach the service directly and are not trusted, name the ingress instead: + +```yaml +network: + trustAllProxies: false + knownNetworks: + - 10.42.0.0/16 +``` + +Rate limiting by source address belongs at the ingress rather than in the application, since the edge rejects a request before any CPU is spent on it and holds one counter across every replica. +The annotation depends on the ingress controller, for example with ingress-nginx: + +```yaml +ingress: + enabled: true + annotations: + nginx.ingress.kubernetes.io/limit-rps: "10" +``` + +TLS termination is not optional for this application. +The password is replayed on every request, so a single plaintext hop exposes the state of every workspace in the tenant. +Note that `skip_cert_verification` in a Terraform backend block defeats the protection it appears to configure. + ## Going further Check the [contribution guide](CONTRIBUTING.md). diff --git a/charts/terraform-backend-mongodb/templates/deployment.yaml b/charts/terraform-backend-mongodb/templates/deployment.yaml index cb136cb..33d1bcd 100644 --- a/charts/terraform-backend-mongodb/templates/deployment.yaml +++ b/charts/terraform-backend-mongodb/templates/deployment.yaml @@ -105,6 +105,22 @@ spec: value: {{ $.Values.dotnet.httpsRedirectionEnabled | quote }} - name: Features__IsScalarEnabled value: {{ $.Values.dotnet.enableScalar | quote }} + - name: Authentication__CredentialCacheSeconds + value: {{ $.Values.authentication.credentialCacheSeconds | quote }} + - name: Authentication__MaxFailedAttempts + value: {{ $.Values.authentication.maxFailedAttempts | quote }} + - name: Authentication__LockoutSeconds + value: {{ $.Values.authentication.lockoutSeconds | quote }} + - name: Network__TrustAllProxies + value: {{ $.Values.network.trustAllProxies | quote }} + {{- range $index, $proxy := $.Values.network.knownProxies }} + - name: Network__KnownProxies__{{ $index }} + value: {{ $proxy | quote }} + {{- end }} + {{- range $index, $cidr := $.Values.network.knownNetworks }} + - name: Network__KnownNetworks__{{ $index }} + value: {{ $cidr | quote }} + {{- end }} {{- if $.Values.dotnet.openTelemetry.enabled }} - name: OTEL_SERVICE_NAME value: {{ $.Values.dotnet.openTelemetry.serviceName | default (include "terraform-backend-mongodb.fullname" (dict "app" . "root" $)) | quote }} diff --git a/charts/terraform-backend-mongodb/values.yaml b/charts/terraform-backend-mongodb/values.yaml index 1baf622..7721af6 100644 --- a/charts/terraform-backend-mongodb/values.yaml +++ b/charts/terraform-backend-mongodb/values.yaml @@ -4,7 +4,7 @@ webapi: name: tfbackend image: docker.io/devprofr/terraform-backend-mongodb # images are hosted on DockerHub: https://hub.docker.com/r/devprofr/terraform-backend-mongodb/tags - tag: "1.2.3" # pins to Chart.yaml appVersion - avoid floating tags (e.g. latest) in Production + tag: "1.3.0" # pins to Chart.yaml appVersion - avoid floating tags (e.g. latest) in Production replicaCount: 1 port: 80 containerPort: 8080 @@ -68,6 +68,34 @@ dotnet: endpoint: "" protocol: "http/protobuf" +# Authentication hardening, available from application version 1.3.0. +# Terraform's http backend sends a Basic credential on every single request and supports no token, so the same password is verified over and over and every failed guess costs the server a BCrypt verify. +authentication: + # -- How long a verified credential is reused without running BCrypt again, which is what stops a busy terraform apply paying around 139 ms of CPU on every request. + # Set to 0 to disable the cache. + credentialCacheSeconds: 60 + # -- Consecutive failures before that username is refused from that source address. + # The application refuses to start below 1. + maxFailedAttempts: 10 + # -- How long the refusal lasts, in seconds. + # The application refuses to start at 0 or below. + lockoutSeconds: 300 + +# Which reverse proxies the application believes when it reads the caller's address. +# This is a prerequisite for the lockout above and for the authentication failure log, not a detail: behind an ingress an unconfigured application sees the proxy's address on every request, so every caller shares one lockout bucket and an attacker on the internet can lock the real operator out of the backend. +network: + # -- Accept X-Forwarded-For from any caller. + # It defaults to true because it is the only setting that works behind an ingress whose address is not known in advance, and because the alternative, every request looking like the ingress, is worse than the risk it avoids. + # It is safe where the pod is reachable through the ingress alone. + # Where other workloads can reach the service directly and are not trusted, set this to false and list the ingress below instead. + trustAllProxies: true + # -- Specific proxy addresses to trust, read only when trustAllProxies is false. + knownProxies: [] + # - 10.0.0.7 + # -- Networks to trust in CIDR notation, read only when trustAllProxies is false. + knownNetworks: [] + # - 10.42.0.0/16 + # optional: image registry credentials, e.g. for a private mirror of the image above imagePullSecrets: [] # - name: my-registry-secret diff --git a/docs/custom-charts/terraform-backend-mongodb.md b/docs/custom-charts/terraform-backend-mongodb.md index 8605032..e760889 100644 --- a/docs/custom-charts/terraform-backend-mongodb.md +++ b/docs/custom-charts/terraform-backend-mongodb.md @@ -1,4 +1,4 @@ -# Terraform Backend MongoDB +ο»Ώ# Terraform Backend MongoDB Let's see how to deploy [Terraform Backend MongoDB](https://github.com/devpro/terraform-backend-mongodb) on a Kubernetes cluster. @@ -20,8 +20,9 @@ Create the `values.yaml` file to override [default parameters](https://github.co ```yaml [Application] dotnet: environment: Development - enableSwagger: true - enableOpenTelemetry: false + enableScalar: true + openTelemetry: + enabled: false ``` ```yaml [Ingress] @@ -57,7 +58,7 @@ helm upgrade --install tfbackend devpro/terraform-backend-mongodb -f values.yaml ## Optional checks -If enabled, open the Swagger page from the browser (`/swagger`). +If enabled, open the Scalar page from the browser (`/scalar`). Forward MongoDB port for local access: diff --git a/charts/gitlab-runner/Chart.lock b/samples/charts/gitlab-runner/Chart.lock similarity index 100% rename from charts/gitlab-runner/Chart.lock rename to samples/charts/gitlab-runner/Chart.lock diff --git a/charts/gitlab-runner/Chart.yaml b/samples/charts/gitlab-runner/Chart.yaml similarity index 100% rename from charts/gitlab-runner/Chart.yaml rename to samples/charts/gitlab-runner/Chart.yaml diff --git a/charts/gitlab-runner/README.md b/samples/charts/gitlab-runner/README.md similarity index 100% rename from charts/gitlab-runner/README.md rename to samples/charts/gitlab-runner/README.md diff --git a/charts/gitlab-runner/values.yaml b/samples/charts/gitlab-runner/values.yaml similarity index 100% rename from charts/gitlab-runner/values.yaml rename to samples/charts/gitlab-runner/values.yaml diff --git a/charts/gitlab/Chart.lock b/samples/charts/gitlab/Chart.lock similarity index 100% rename from charts/gitlab/Chart.lock rename to samples/charts/gitlab/Chart.lock diff --git a/charts/gitlab/Chart.yaml b/samples/charts/gitlab/Chart.yaml similarity index 100% rename from charts/gitlab/Chart.yaml rename to samples/charts/gitlab/Chart.yaml diff --git a/charts/gitlab/README.md b/samples/charts/gitlab/README.md similarity index 100% rename from charts/gitlab/README.md rename to samples/charts/gitlab/README.md diff --git a/charts/gitlab/values.yaml b/samples/charts/gitlab/values.yaml similarity index 100% rename from charts/gitlab/values.yaml rename to samples/charts/gitlab/values.yaml diff --git a/charts/hobbyfarm/Chart.lock b/samples/charts/hobbyfarm/Chart.lock similarity index 100% rename from charts/hobbyfarm/Chart.lock rename to samples/charts/hobbyfarm/Chart.lock diff --git a/charts/hobbyfarm/Chart.yaml b/samples/charts/hobbyfarm/Chart.yaml similarity index 100% rename from charts/hobbyfarm/Chart.yaml rename to samples/charts/hobbyfarm/Chart.yaml diff --git a/charts/hobbyfarm/README.md b/samples/charts/hobbyfarm/README.md similarity index 100% rename from charts/hobbyfarm/README.md rename to samples/charts/hobbyfarm/README.md diff --git a/charts/hobbyfarm/app-readme.md b/samples/charts/hobbyfarm/app-readme.md similarity index 100% rename from charts/hobbyfarm/app-readme.md rename to samples/charts/hobbyfarm/app-readme.md diff --git a/charts/hobbyfarm/questions.yml b/samples/charts/hobbyfarm/questions.yml similarity index 100% rename from charts/hobbyfarm/questions.yml rename to samples/charts/hobbyfarm/questions.yml diff --git a/charts/hobbyfarm/values.yaml b/samples/charts/hobbyfarm/values.yaml similarity index 100% rename from charts/hobbyfarm/values.yaml rename to samples/charts/hobbyfarm/values.yaml diff --git a/charts/keycloak/CONTRIBUTING.md b/samples/charts/keycloak/CONTRIBUTING.md similarity index 100% rename from charts/keycloak/CONTRIBUTING.md rename to samples/charts/keycloak/CONTRIBUTING.md diff --git a/charts/keycloak/Chart.lock b/samples/charts/keycloak/Chart.lock similarity index 100% rename from charts/keycloak/Chart.lock rename to samples/charts/keycloak/Chart.lock diff --git a/charts/keycloak/Chart.yaml b/samples/charts/keycloak/Chart.yaml similarity index 100% rename from charts/keycloak/Chart.yaml rename to samples/charts/keycloak/Chart.yaml diff --git a/charts/keycloak/README.md b/samples/charts/keycloak/README.md similarity index 100% rename from charts/keycloak/README.md rename to samples/charts/keycloak/README.md diff --git a/charts/keycloak/values.yaml b/samples/charts/keycloak/values.yaml similarity index 100% rename from charts/keycloak/values.yaml rename to samples/charts/keycloak/values.yaml diff --git a/charts/kratos/CONTRIBUTING.md b/samples/charts/kratos/CONTRIBUTING.md similarity index 100% rename from charts/kratos/CONTRIBUTING.md rename to samples/charts/kratos/CONTRIBUTING.md diff --git a/charts/kratos/Chart.lock b/samples/charts/kratos/Chart.lock similarity index 100% rename from charts/kratos/Chart.lock rename to samples/charts/kratos/Chart.lock diff --git a/charts/kratos/Chart.yaml b/samples/charts/kratos/Chart.yaml similarity index 100% rename from charts/kratos/Chart.yaml rename to samples/charts/kratos/Chart.yaml diff --git a/charts/kratos/README.md b/samples/charts/kratos/README.md similarity index 100% rename from charts/kratos/README.md rename to samples/charts/kratos/README.md diff --git a/charts/kratos/examples/kratos/email-password/identity.schema.json b/samples/charts/kratos/examples/kratos/email-password/identity.schema.json similarity index 100% rename from charts/kratos/examples/kratos/email-password/identity.schema.json rename to samples/charts/kratos/examples/kratos/email-password/identity.schema.json diff --git a/charts/kratos/values.yaml b/samples/charts/kratos/values.yaml similarity index 100% rename from charts/kratos/values.yaml rename to samples/charts/kratos/values.yaml diff --git a/charts/mongodb-bitnami/CONTRIBUTING.md b/samples/charts/mongodb-bitnami/CONTRIBUTING.md similarity index 100% rename from charts/mongodb-bitnami/CONTRIBUTING.md rename to samples/charts/mongodb-bitnami/CONTRIBUTING.md diff --git a/charts/mongodb-bitnami/Chart.lock b/samples/charts/mongodb-bitnami/Chart.lock similarity index 100% rename from charts/mongodb-bitnami/Chart.lock rename to samples/charts/mongodb-bitnami/Chart.lock diff --git a/charts/mongodb-bitnami/Chart.yaml b/samples/charts/mongodb-bitnami/Chart.yaml similarity index 100% rename from charts/mongodb-bitnami/Chart.yaml rename to samples/charts/mongodb-bitnami/Chart.yaml diff --git a/charts/mongodb-bitnami/README.md b/samples/charts/mongodb-bitnami/README.md similarity index 100% rename from charts/mongodb-bitnami/README.md rename to samples/charts/mongodb-bitnami/README.md diff --git a/charts/mongodb-bitnami/values.yaml b/samples/charts/mongodb-bitnami/values.yaml similarity index 100% rename from charts/mongodb-bitnami/values.yaml rename to samples/charts/mongodb-bitnami/values.yaml diff --git a/charts/mongodb-community/CONTRIBUTING.md b/samples/charts/mongodb-community/CONTRIBUTING.md similarity index 100% rename from charts/mongodb-community/CONTRIBUTING.md rename to samples/charts/mongodb-community/CONTRIBUTING.md diff --git a/charts/mongodb-community/Chart.lock b/samples/charts/mongodb-community/Chart.lock similarity index 100% rename from charts/mongodb-community/Chart.lock rename to samples/charts/mongodb-community/Chart.lock diff --git a/charts/mongodb-community/Chart.yaml b/samples/charts/mongodb-community/Chart.yaml similarity index 100% rename from charts/mongodb-community/Chart.yaml rename to samples/charts/mongodb-community/Chart.yaml diff --git a/charts/mongodb-community/README.md b/samples/charts/mongodb-community/README.md similarity index 100% rename from charts/mongodb-community/README.md rename to samples/charts/mongodb-community/README.md diff --git a/charts/mongodb-community/templates/mdbc.yaml b/samples/charts/mongodb-community/templates/mdbc.yaml similarity index 100% rename from charts/mongodb-community/templates/mdbc.yaml rename to samples/charts/mongodb-community/templates/mdbc.yaml diff --git a/charts/mongodb-community/values.yaml b/samples/charts/mongodb-community/values.yaml similarity index 100% rename from charts/mongodb-community/values.yaml rename to samples/charts/mongodb-community/values.yaml diff --git a/samples/devopsdays-geneva-2023/README.md b/samples/gitops/devopsdays-geneva-2023/README.md similarity index 100% rename from samples/devopsdays-geneva-2023/README.md rename to samples/gitops/devopsdays-geneva-2023/README.md diff --git a/samples/devopsdays-geneva-2023/fleet/cert-manager/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/cert-manager/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/cert-manager/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/cert-manager/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/cow-demo/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/cow-demo/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/cow-demo/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/cow-demo/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/crds/README.md b/samples/gitops/devopsdays-geneva-2023/fleet/crds/README.md similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/crds/README.md rename to samples/gitops/devopsdays-geneva-2023/fleet/crds/README.md diff --git a/samples/devopsdays-geneva-2023/fleet/crds/cert-manager.crds.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/crds/cert-manager.crds.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/crds/cert-manager.crds.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/crds/cert-manager.crds.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/crds/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/crds/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/crds/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/crds/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/ingress-nginx/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/ingress-nginx/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/ingress-nginx/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/ingress-nginx/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/keycloak/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/keycloak/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/keycloak/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/keycloak/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/letsencrypt/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/letsencrypt/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/letsencrypt/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/letsencrypt/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/neuvector/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/neuvector/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/neuvector/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/neuvector/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/nfs-server-provisioner/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/nfs-server-provisioner/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/nfs-server-provisioner/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/nfs-server-provisioner/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/otel-collector/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/otel-collector/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/otel-collector/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/otel-collector/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/salesportal/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/salesportal/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/salesportal/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/salesportal/fleet.yaml diff --git a/samples/devopsdays-geneva-2023/fleet/sealed-secrets/fleet.yaml b/samples/gitops/devopsdays-geneva-2023/fleet/sealed-secrets/fleet.yaml similarity index 100% rename from samples/devopsdays-geneva-2023/fleet/sealed-secrets/fleet.yaml rename to samples/gitops/devopsdays-geneva-2023/fleet/sealed-secrets/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/README.md b/samples/gitops/suse-exchange-paris-2023/README.md similarity index 100% rename from samples/suse-exchange-paris-2023/README.md rename to samples/gitops/suse-exchange-paris-2023/README.md diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/above-crds/README.md b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/above-crds/README.md similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/above-crds/README.md rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/above-crds/README.md diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/above-crds/cert-manager.crds.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/above-crds/cert-manager.crds.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/above-crds/cert-manager.crds.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/above-crds/cert-manager.crds.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/above-crds/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/above-crds/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/above-crds/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/above-crds/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/cert-manager/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/cert-manager/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/cert-manager/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/cert-manager/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/cow-demo/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/cow-demo/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/cow-demo/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/cow-demo/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/ecorp-demo/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/ecorp-demo/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/ecorp-demo/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/ecorp-demo/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/gitlab/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/gitlab/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/gitlab/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/gitlab/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/harbor/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/harbor/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/harbor/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/harbor/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/ingress-nginx/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/ingress-nginx/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/ingress-nginx/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/ingress-nginx/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/letsencrypt/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/letsencrypt/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/letsencrypt/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/letsencrypt/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/neuvector/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/neuvector/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/neuvector/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/neuvector/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/sealed-secrets/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/sealed-secrets/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/sealed-secrets/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/sealed-secrets/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-content/sonarqube/fleet.yaml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-content/sonarqube/fleet.yaml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-content/sonarqube/fleet.yaml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-content/sonarqube/fleet.yaml diff --git a/samples/suse-exchange-paris-2023/fleet/repo-definitions/gitrepo.yml b/samples/gitops/suse-exchange-paris-2023/fleet/repo-definitions/gitrepo.yml similarity index 100% rename from samples/suse-exchange-paris-2023/fleet/repo-definitions/gitrepo.yml rename to samples/gitops/suse-exchange-paris-2023/fleet/repo-definitions/gitrepo.yml