From 7c89370750c7d822c168d5df1bb5ed165b1b3642 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Tue, 31 Mar 2026 01:02:50 -0400 Subject: [PATCH] feat(vaultwarden): add kURL platform example with CMX testing workflow New example application demonstrating Vaultwarden distribution via Replicated KOTS and kURL embedded Kubernetes installer, with full CMX compatibility testing support. Co-Authored-By: Claude Opus 4.6 --- .github/dependabot.yml | 1 + applications/vaultwarden/Makefile | 203 ++++++++++++++ applications/vaultwarden/README.md | 262 ++++++++++++++++++ .../vaultwarden/charts/vaultwarden/Chart.yaml | 19 ++ .../charts/vaultwarden/templates/NOTES.txt | 18 ++ .../charts/vaultwarden/templates/_helpers.tpl | 60 ++++ .../vaultwarden/templates/deployment.yaml | 112 ++++++++ .../charts/vaultwarden/templates/ingress.yaml | 35 +++ .../charts/vaultwarden/templates/pvc.yaml | 17 ++ .../charts/vaultwarden/templates/secret.yaml | 15 + .../charts/vaultwarden/templates/service.yaml | 21 ++ .../charts/vaultwarden/values.yaml | 87 ++++++ applications/vaultwarden/kots/k8s-app.yaml | 8 + applications/vaultwarden/kots/kots-app.yaml | 15 + .../vaultwarden/kots/kots-config.yaml | 102 +++++++ .../vaultwarden/kots/kots-preflight.yaml | 48 ++++ .../vaultwarden/kots/kots-support-bundle.yaml | 57 ++++ .../vaultwarden/kots/kurl-installer.yaml | 23 ++ .../vaultwarden/kots/vaultwarden-chart.yaml | 29 ++ .../vaultwarden/tests/helm/ci-values.yaml | 13 + .../vaultwarden/tests/requirements.txt | 1 + applications/vaultwarden/tests/smoke_test.py | 223 +++++++++++++++ 22 files changed, 1369 insertions(+) create mode 100644 applications/vaultwarden/Makefile create mode 100644 applications/vaultwarden/README.md create mode 100644 applications/vaultwarden/charts/vaultwarden/Chart.yaml create mode 100644 applications/vaultwarden/charts/vaultwarden/templates/NOTES.txt create mode 100644 applications/vaultwarden/charts/vaultwarden/templates/_helpers.tpl create mode 100644 applications/vaultwarden/charts/vaultwarden/templates/deployment.yaml create mode 100644 applications/vaultwarden/charts/vaultwarden/templates/ingress.yaml create mode 100644 applications/vaultwarden/charts/vaultwarden/templates/pvc.yaml create mode 100644 applications/vaultwarden/charts/vaultwarden/templates/secret.yaml create mode 100644 applications/vaultwarden/charts/vaultwarden/templates/service.yaml create mode 100644 applications/vaultwarden/charts/vaultwarden/values.yaml create mode 100644 applications/vaultwarden/kots/k8s-app.yaml create mode 100644 applications/vaultwarden/kots/kots-app.yaml create mode 100644 applications/vaultwarden/kots/kots-config.yaml create mode 100644 applications/vaultwarden/kots/kots-preflight.yaml create mode 100644 applications/vaultwarden/kots/kots-support-bundle.yaml create mode 100644 applications/vaultwarden/kots/kurl-installer.yaml create mode 100644 applications/vaultwarden/kots/vaultwarden-chart.yaml create mode 100644 applications/vaultwarden/tests/helm/ci-values.yaml create mode 100644 applications/vaultwarden/tests/requirements.txt create mode 100644 applications/vaultwarden/tests/smoke_test.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e7300d23..fc2f71cb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,7 @@ updates: - "/applications/onlineboutique/chart" - "/applications/powerdns/charts/powerdns-authoritative" - "/applications/storagebox/charts/storagebox" + - "/applications/vaultwarden/charts/vaultwarden" - "/applications/wg-easy/charts/wg-easy" schedule: interval: "weekly" diff --git a/applications/vaultwarden/Makefile b/applications/vaultwarden/Makefile new file mode 100644 index 00000000..2ce90f5d --- /dev/null +++ b/applications/vaultwarden/Makefile @@ -0,0 +1,203 @@ +manifests_dir := $(shell pwd)/kots +chart_archives := $(wildcard $(manifests_dir)/*.tgz) + +ARGS = $(filter-out $@,$(MAKECMDGOALS)) +%: + @: + +SHELL := /bin/bash +.SHELLFLAGS = -x +u -c + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +HELM_CHARTS_DIR = ./charts +KOTS_DIR = ./kots + +VAULTWARDEN_CHART_PATH = ./charts/vaultwarden/Chart.yaml + +# CMX defaults — override on the command line as needed: +# make cmx-create CLUSTER_NAME=my-test INSTANCE_TYPE=r1.xlarge +CLUSTER_NAME ?= vaultwarden-kurl +INSTANCE_TYPE ?= r1.xlarge +DISK_GB ?= 100 +TTL ?= 4h + +# Helm test defaults +CI_VALUES = tests/helm/ci-values.yaml +RELEASE_NAME = vaultwarden +NAMESPACE = default + +# --------------------------------------------------------------------------- +# Version helpers +# --------------------------------------------------------------------------- + +define get_vaultwarden_chart_version + cat $(VAULTWARDEN_CHART_PATH) | grep '^version:' | cut -d ' ' -f 2 +endef + +define get_kots_chart_version + grep 'chartVersion:' $(1) | sed 's/.*chartVersion: //' +endef + +define get_helm_chart_version + helm show chart $(1) | grep '^version:' | cut -d ' ' -f 2 +endef + + +# =================================================================== +# Build targets +# =================================================================== + +.PHONY: update-dependencies +update-dependencies: ## Update Helm chart dependencies (pulls Replicated SDK) + @for chart_dir in $(HELM_CHARTS_DIR)/*; do \ + if [ -d $$chart_dir ]; then \ + echo "Updating dependencies for $$chart_dir"; \ + helm dependency update $$chart_dir; \ + fi; \ + done + +.PHONY: package-and-update +package-and-update: clean ## Package Helm chart and sync version into KOTS HelmChart CR + @for chart in $(HELM_CHARTS_DIR)/*; do \ + echo "Packaging $$chart"; \ + helm package $$chart -d $(KOTS_DIR); \ + version=$$(eval $(call get_helm_chart_version,$$chart)); \ + chart_name=$$(basename $$chart); \ + echo "Updating chartVersion to $$version in $(KOTS_DIR)/$$chart_name-chart.yaml"; \ + sed -i.bak 's|chartVersion: [0-9a-zA-Z.-]*|chartVersion: '$$version'|g' $(KOTS_DIR)/$$chart_name-chart.yaml && rm -f $(KOTS_DIR)/$$chart_name-chart.yaml.bak; \ + done + +.PHONY: clean +clean: ## Remove packaged chart archives and temp directories + @echo "Cleaning up build artifacts in $(KOTS_DIR)" + @rm -f $(KOTS_DIR)/*.tgz + @echo "Removing old Helm tmpcharts-* directories" + @rm -rf $(HELM_CHARTS_DIR)/*/tmpcharts-* + + +# =================================================================== +# Release targets — create a Replicated release from the KOTS dir +# =================================================================== + +.PHONY: release +release: package-and-update ## Package chart and create a Replicated release on Unstable + @chart_version=$$(eval $(call get_vaultwarden_chart_version)); \ + echo "Creating Replicated release version $$chart_version"; \ + replicated release create --yaml-dir $(KOTS_DIR) --promote Unstable --version "$$chart_version" + + +# =================================================================== +# CMX targets — provision and manage kURL clusters via Compatibility Matrix +# =================================================================== +# +# Typical workflow: +# 1. make release — push a release to Unstable +# 2. Promote the release and create a customer with kURL entitlement +# in the Vendor Portal +# 3. make cmx-create LICENSE_ID= — spin up a kURL cluster +# 4. make cmx-status — poll until the cluster is ready +# 5. make cmx-shell — open a shell to configure via KOTS admin +# 6. make cmx-smoke — run smoke tests against the cluster +# 7. make cmx-delete — tear down when done +# + +.PHONY: cmx-create +cmx-create: ## Create a kURL cluster in CMX (requires LICENSE_ID=<...>) +ifndef LICENSE_ID + $(error LICENSE_ID is required. Get it from the Vendor Portal customer page.) +endif + replicated cluster create \ + --distribution kurl \ + --instance-type $(INSTANCE_TYPE) \ + --disk $(DISK_GB) \ + --license-id $(LICENSE_ID) \ + --ttl $(TTL) \ + --name $(CLUSTER_NAME) + @echo "" + @echo "Cluster creation started. Run 'make cmx-status' to check progress." + +.PHONY: cmx-status +cmx-status: ## List CMX clusters and their status + replicated cluster ls + +.PHONY: cmx-shell +cmx-shell: ## Open a shell into the CMX cluster (requires CLUSTER_ID=<...>) +ifndef CLUSTER_ID + $(error CLUSTER_ID is required. Run 'make cmx-status' to find it.) +endif + replicated cluster shell $(CLUSTER_ID) + +.PHONY: cmx-kubeconfig +cmx-kubeconfig: ## Write the CMX cluster kubeconfig to ./kubeconfig (requires CLUSTER_ID=<...>) +ifndef CLUSTER_ID + $(error CLUSTER_ID is required. Run 'make cmx-status' to find it.) +endif + replicated cluster kubeconfig $(CLUSTER_ID) > kubeconfig + @echo "Kubeconfig written to ./kubeconfig" + @echo "Export it: export KUBECONFIG=$$(pwd)/kubeconfig" + +.PHONY: cmx-expose-admin +cmx-expose-admin: ## Expose the KOTS admin console port on CMX (requires CLUSTER_ID=<...>) +ifndef CLUSTER_ID + $(error CLUSTER_ID is required. Run 'make cmx-status' to find it.) +endif + replicated cluster port expose $(CLUSTER_ID) --port 8800 + @echo "" + @echo "The KOTS admin console should be reachable at the URL above on port 8800." + +.PHONY: cmx-delete +cmx-delete: ## Delete a CMX cluster (requires CLUSTER_ID=<...>) +ifndef CLUSTER_ID + $(error CLUSTER_ID is required. Run 'make cmx-status' to find it.) +endif + replicated cluster rm $(CLUSTER_ID) + @echo "Cluster $(CLUSTER_ID) deletion requested." + + +# =================================================================== +# Test targets +# =================================================================== + +.PHONY: test-lint +test-lint: update-dependencies ## Lint and template-render the chart + helm lint ./charts/vaultwarden + helm template $(RELEASE_NAME) ./charts/vaultwarden -f $(CI_VALUES) > /dev/null + +.PHONY: test-install +test-install: update-dependencies ## Helm install Vaultwarden with CI values (requires a running cluster) + helm install $(RELEASE_NAME) ./charts/vaultwarden \ + -f $(CI_VALUES) \ + --namespace $(NAMESPACE) \ + --wait --timeout 5m + +.PHONY: test-smoke +test-smoke: ## Run smoke tests against a running Vaultwarden instance + python3 -m venv ./venv + ./venv/bin/pip install -r tests/requirements.txt + ./venv/bin/python tests/smoke_test.py \ + --release $(RELEASE_NAME) --namespace $(NAMESPACE) + +.PHONY: test-all +test-all: test-lint test-install test-smoke ## Full test sequence (lint → install → smoke) + + +# =================================================================== +# Help +# =================================================================== + +.PHONY: help +help: ## Show this help + @echo "Build targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; /^[^c]/ {printf " %-22s %s\n", $$1, $$2}' + @echo "" + @echo "CMX targets (kURL cluster management):" + @grep -E '^cmx-[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " %-22s %s\n", $$1, $$2}' + @echo "" + @echo "Test targets:" + @grep -E '^test-[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " %-22s %s\n", $$1, $$2}' diff --git a/applications/vaultwarden/README.md b/applications/vaultwarden/README.md new file mode 100644 index 00000000..d731df4b --- /dev/null +++ b/applications/vaultwarden/README.md @@ -0,0 +1,262 @@ +# Vaultwarden with kURL + +This example demonstrates how to distribute [Vaultwarden](https://github.com/dani-garcia/vaultwarden) (an unofficial Bitwarden-compatible password manager) as a self-hosted application using [Replicated KOTS](https://docs.replicated.com/intro-replicated) and [kURL](https://kurl.sh), and how to test it using [Compatibility Matrix (CMX)](https://docs.replicated.com/vendor/testing-about). + +## What is kURL? + +kURL is a Replicated open-source project that creates custom Kubernetes installers. It lets you define a Kubernetes distribution as a YAML spec — choosing specific versions of the container runtime, networking, storage, ingress, and other add-ons — and then install that entire stack on a bare Linux machine with a single command. + +This is useful when your customers: +- Run on bare metal or VMs without an existing Kubernetes cluster +- Need an air-gapped installation +- Want a single-command install experience + +The kURL installer spec for this example is in [`kots/kurl-installer.yaml`](kots/kurl-installer.yaml). It provisions: + +| Add-on | Purpose | +|--------|---------| +| **Kubernetes 1.29** | Container orchestration via kubeadm | +| **Containerd** | Container runtime | +| **Flannel** | Pod networking (CNI) | +| **OpenEBS** | Local persistent volumes for Vaultwarden data | +| **Contour** | Ingress controller (Envoy-based) | +| **MinIO** | Object storage for KOTS snapshots/backups | +| **Registry** | Local Docker registry for air-gap image storage | +| **KOTS Admin Console** | Web UI for application configuration and lifecycle | + +You can customize the add-on selection and versions at [kurl.sh/add-ons](https://kurl.sh/add-ons). + +## How It All Fits Together + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Bare Linux VM │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ kURL-provisioned Kubernetes cluster │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ +│ │ │ Vaultwarden │ │ KOTS Admin │ │ Contour │ │ │ +│ │ │ (your app) │ │ Console │ │ (ingress) │ │ │ +│ │ │ port 80 │ │ port 8800 │ │ ports 80/443│ │ │ +│ │ └──────┬──────┘ └──────────────┘ └─────────────┘ │ │ +│ │ │ │ │ +│ │ ┌──────┴──────┐ ┌──────────────┐ ┌─────────────┐ │ │ +│ │ │ OpenEBS PV │ │ MinIO │ │ Registry │ │ │ +│ │ │ (vault data)│ │ (snapshots) │ │ (air-gap) │ │ │ +│ │ └─────────────┘ └──────────────┘ └─────────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +The KOTS Admin Console provides a config screen (defined in [`kots/kots-config.yaml`](kots/kots-config.yaml)) where the end-user sets their domain, admin token, database type, and SMTP settings — without needing to know Helm or YAML. + +## Prerequisites + +1. A [Replicated Vendor Portal](https://vendor.replicated.com/signup) account +2. The [Replicated CLI](https://docs.replicated.com/reference/replicated-cli-installing) (`replicated`) +3. `helm` CLI (v3.12+) +4. A release channel and customer configured in the Vendor Portal with kURL entitlement +5. CMX credits (for testing with Compatibility Matrix) + +## Step-by-Step: Build, Release, and Test + +### Step 1 — Package the Helm chart and create a release + +This packages the Vaultwarden Helm chart into a `.tgz`, copies it into the `kots/` directory alongside the KOTS manifests, and pushes everything to the Replicated Vendor Portal as a new release on the Unstable channel. + +```bash +cd applications/vaultwarden + +# Pull the Replicated SDK dependency +make update-dependencies + +# Package the chart and create a Replicated release +make release +``` + +**What happens under the hood:** +1. `helm package` builds `vaultwarden-1.0.0.tgz` +2. The `chartVersion` in `kots/vaultwarden-chart.yaml` is updated to match +3. `replicated release create` uploads the entire `kots/` directory — including the chart archive, the kURL installer spec, config screen, preflight checks, and support bundle spec — as a single release + +### Step 2 — Promote the release and prepare a customer + +In the Vendor Portal: + +1. **Promote** the release from Unstable to a test channel (or create a new channel) +2. **Create a customer** (or use an existing one) and assign them to the channel +3. Under the customer settings, **enable kURL** as an installation method +4. **Copy the customer's license ID** — you'll need it to create the CMX cluster + +### Step 3 — Create a kURL cluster with CMX + +CMX provisions a bare VM and runs the kURL installer automatically using your installer spec and the customer's license: + +```bash +make cmx-create LICENSE_ID= +``` + +This is equivalent to: +```bash +replicated cluster create \ + --distribution kurl \ + --instance-type r1.xlarge \ + --disk 100 \ + --license-id \ + --ttl 4h \ + --name vaultwarden-kurl +``` + +Monitor progress: +```bash +make cmx-status +``` + +The cluster takes a few minutes to provision. kURL downloads and installs all the add-ons defined in `kurl-installer.yaml`, then installs the KOTS Admin Console. + +### Step 4 — Access the KOTS Admin Console + +Once the cluster shows `running`, expose the admin console port: + +```bash +make cmx-expose-admin CLUSTER_ID= +``` + +This gives you a public URL to the KOTS Admin Console (port 8800). Open it in your browser and: + +1. Set an admin password +2. Upload the customer license file (download it from the Vendor Portal) +3. **Preflight checks run automatically** — these validate the cluster meets minimum requirements (Kubernetes version, CPU, memory, storage class). The checks are defined in [`kots/kots-preflight.yaml`](kots/kots-preflight.yaml). +4. **Configure the application** using the config screen: + - Set the domain where Vaultwarden will be reachable + - Optionally set an admin token for the `/admin` panel + - Choose SQLite (default) or PostgreSQL for the database + - Optionally configure SMTP for email notifications +5. **Deploy** — KOTS renders the Helm chart with your config values (via the mappings in [`kots/vaultwarden-chart.yaml`](kots/vaultwarden-chart.yaml)) and installs it + +### Step 5 — Verify the deployment + +You can shell into the cluster to inspect resources: + +```bash +make cmx-shell CLUSTER_ID= + +# Inside the cluster shell: +kubectl get pods +kubectl get svc +kubectl logs deployment/vaultwarden +``` + +Or run the automated smoke tests (from your local machine with kubeconfig): + +```bash +make cmx-kubeconfig CLUSTER_ID= +export KUBECONFIG=$(pwd)/kubeconfig +make test-smoke +``` + +The smoke tests verify: +- The `/alive` health endpoint responds +- The web vault UI is being served +- The `/api/config` Bitwarden-compatible API endpoint works + +### Step 6 — Clean up + +```bash +make cmx-delete CLUSTER_ID= +``` + +## Understanding the KOTS Manifests + +The `kots/` directory contains everything Replicated needs to distribute your application: + +| File | Kind | Purpose | +|------|------|---------| +| `kots-app.yaml` | `Application` | App metadata (name, icon, status informers) | +| `kots-config.yaml` | `Config` | Defines the configuration screen shown to users | +| `vaultwarden-chart.yaml` | `HelmChart` | Maps config values → Helm chart values | +| `kurl-installer.yaml` | `Installer` | kURL add-on selection and versions | +| `kots-preflight.yaml` | `Preflight` | Pre-install cluster validation checks | +| `kots-support-bundle.yaml` | `SupportBundle` | Diagnostic collection for troubleshooting | +| `k8s-app.yaml` | `Application` (k8s) | Kubernetes Application CRD metadata | +| `vaultwarden-1.0.0.tgz` | (chart archive) | Generated by `make package-and-update` | + +### Config → Helm value flow + +The KOTS config screen collects user input. The `HelmChart` CR maps those inputs to Helm values using Replicated template functions: + +``` +User enters "https://vault.acme.com" in the Domain field + ↓ +kots-config.yaml defines: name: domain, type: text + ↓ +vaultwarden-chart.yaml maps: vaultwarden.domain: repl{{ ConfigOption "domain" }} + ↓ +Helm renders deployment.yaml with env DOMAIN=https://vault.acme.com +``` + +### Preflight checks + +Preflight checks (in `kots-preflight.yaml`) run before installation to catch problems early: +- Kubernetes version ≥ 1.26 +- At least 2 CPU cores +- At least 4Gi memory +- A default StorageClass exists + +These use the [Troubleshoot](https://troubleshoot.sh) framework. If a check fails, the user sees the failure message in the admin console with guidance on how to fix it. + +### Support bundles + +When something goes wrong, users can generate a support bundle from the admin console. The spec in `kots-support-bundle.yaml` collects cluster info, resources, Vaultwarden logs, and runs analyzers to identify common issues. + +## Local Development (without CMX) + +You can develop and test the Helm chart locally without Replicated: + +```bash +# Lint the chart +make test-lint + +# Install on any Kubernetes cluster (Replicated SDK disabled) +make test-install + +# Run smoke tests +make test-smoke + +# Full sequence +make test-all +``` + +## File Structure + +``` +vaultwarden/ +├── Makefile # Build, release, CMX, and test targets +├── README.md +├── charts/ +│ └── vaultwarden/ # Helm chart +│ ├── Chart.yaml # Chart metadata + Replicated SDK dependency +│ ├── values.yaml # Default values +│ └── templates/ +│ ├── _helpers.tpl # Template helpers (name, labels, DB URL) +│ ├── deployment.yaml # Vaultwarden Deployment +│ ├── service.yaml # ClusterIP Service +│ ├── secret.yaml # Database URL, admin token, SMTP password +│ ├── pvc.yaml # Persistent storage for /data +│ ├── ingress.yaml # Optional Ingress +│ └── NOTES.txt # Post-install instructions +├── kots/ # Replicated / KOTS manifests +│ ├── kots-app.yaml # Application metadata +│ ├── kots-config.yaml # Config screen definition +│ ├── vaultwarden-chart.yaml # HelmChart CR (config → values mapping) +│ ├── kurl-installer.yaml # kURL embedded installer spec +│ ├── kots-preflight.yaml # Pre-install validation checks +│ ├── kots-support-bundle.yaml # Diagnostic collection spec +│ └── k8s-app.yaml # Kubernetes Application CR +└── tests/ + ├── helm/ + │ └── ci-values.yaml # Minimal values for CI/test installs + ├── requirements.txt # Python test dependencies + └── smoke_test.py # Automated health + API checks +``` diff --git a/applications/vaultwarden/charts/vaultwarden/Chart.yaml b/applications/vaultwarden/charts/vaultwarden/Chart.yaml new file mode 100644 index 00000000..b53c56cd --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +appVersion: 1.33.2 +dependencies: +- name: replicated + repository: oci://registry.replicated.com/library + version: 1.17.1 + condition: replicated.enabled +description: Vaultwarden - Unofficial Bitwarden-compatible server +icon: https://raw.githubusercontent.com/dani-garcia/vaultwarden/main/resources/vaultwarden-icon.svg +keywords: +- vaultwarden +- bitwarden +- password-manager +- secrets +name: vaultwarden +sources: +- https://github.com/dani-garcia/vaultwarden +type: application +version: 1.0.0 diff --git a/applications/vaultwarden/charts/vaultwarden/templates/NOTES.txt b/applications/vaultwarden/charts/vaultwarden/templates/NOTES.txt new file mode 100644 index 00000000..103117cd --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/templates/NOTES.txt @@ -0,0 +1,18 @@ +Vaultwarden has been deployed! + +{{- if .Values.ingress.enabled }} +Access Vaultwarden at: +{{- range .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }} +{{- end }} +{{- else }} +To access Vaultwarden, run: + kubectl port-forward svc/{{ include "vaultwarden.fullname" . }} 8080:{{ .Values.service.port }} + +Then open http://localhost:8080 in your browser. +{{- end }} + +{{- if .Values.vaultwarden.adminToken }} + +Admin panel is available at /admin +{{- end }} diff --git a/applications/vaultwarden/charts/vaultwarden/templates/_helpers.tpl b/applications/vaultwarden/charts/vaultwarden/templates/_helpers.tpl new file mode 100644 index 00000000..679bf23e --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "vaultwarden.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "vaultwarden.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "vaultwarden.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "vaultwarden.labels" -}} +helm.sh/chart: {{ include "vaultwarden.chart" . }} +{{ include "vaultwarden.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "vaultwarden.selectorLabels" -}} +app.kubernetes.io/name: {{ include "vaultwarden.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Build the DATABASE_URL environment variable based on database type. +*/}} +{{- define "vaultwarden.databaseUrl" -}} +{{- if eq .Values.vaultwarden.database.type "postgresql" -}} +postgresql://{{ .Values.vaultwarden.database.postgresUser }}:{{ .Values.vaultwarden.database.postgresPassword }}@{{ .Values.vaultwarden.database.postgresHost }}:{{ .Values.vaultwarden.database.postgresPort }}/{{ .Values.vaultwarden.database.postgresDatabase }} +{{- else -}} +/data/{{ .Values.vaultwarden.database.sqlitePath }} +{{- end }} +{{- end }} diff --git a/applications/vaultwarden/charts/vaultwarden/templates/deployment.yaml b/applications/vaultwarden/charts/vaultwarden/templates/deployment.yaml new file mode 100644 index 00000000..abeaf415 --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/templates/deployment.yaml @@ -0,0 +1,112 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "vaultwarden.fullname" . }} + labels: + {{- include "vaultwarden.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "vaultwarden.selectorLabels" . | nindent 6 }} + strategy: + type: Recreate + template: + metadata: + labels: + {{- include "vaultwarden.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: vaultwarden + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + {{- if .Values.vaultwarden.websocketEnabled }} + - name: websocket + containerPort: 3012 + protocol: TCP + {{- end }} + env: + - name: DOMAIN + value: {{ .Values.vaultwarden.domain | quote }} + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "vaultwarden.fullname" . }} + key: DATABASE_URL + {{- if .Values.vaultwarden.adminToken }} + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "vaultwarden.fullname" . }} + key: ADMIN_TOKEN + {{- end }} + - name: SIGNUPS_ALLOWED + value: {{ .Values.vaultwarden.signupsAllowed | quote }} + - name: INVITATIONS_ALLOWED + value: {{ .Values.vaultwarden.invitationsAllowed | quote }} + - name: SHOW_PASSWORD_HINT + value: {{ .Values.vaultwarden.showPasswordHint | quote }} + - name: WEBSOCKET_ENABLED + value: {{ .Values.vaultwarden.websocketEnabled | quote }} + {{- if .Values.vaultwarden.smtp.enabled }} + - name: SMTP_HOST + value: {{ .Values.vaultwarden.smtp.host | quote }} + - name: SMTP_PORT + value: {{ .Values.vaultwarden.smtp.port | quote }} + - name: SMTP_SECURITY + value: {{ .Values.vaultwarden.smtp.security | quote }} + - name: SMTP_FROM + value: {{ .Values.vaultwarden.smtp.from | quote }} + - name: SMTP_USERNAME + value: {{ .Values.vaultwarden.smtp.username | quote }} + - name: SMTP_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "vaultwarden.fullname" . }} + key: SMTP_PASSWORD + {{- end }} + volumeMounts: + - name: data + mountPath: /data + livenessProbe: + httpGet: + path: /alive + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /alive + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "vaultwarden.fullname" . }} + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/applications/vaultwarden/charts/vaultwarden/templates/ingress.yaml b/applications/vaultwarden/charts/vaultwarden/templates/ingress.yaml new file mode 100644 index 00000000..7768b49c --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "vaultwarden.fullname" . }} + labels: + {{- include "vaultwarden.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "vaultwarden.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/applications/vaultwarden/charts/vaultwarden/templates/pvc.yaml b/applications/vaultwarden/charts/vaultwarden/templates/pvc.yaml new file mode 100644 index 00000000..006d9275 --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "vaultwarden.fullname" . }} + labels: + {{- include "vaultwarden.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/applications/vaultwarden/charts/vaultwarden/templates/secret.yaml b/applications/vaultwarden/charts/vaultwarden/templates/secret.yaml new file mode 100644 index 00000000..87e5c2d2 --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/templates/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "vaultwarden.fullname" . }} + labels: + {{- include "vaultwarden.labels" . | nindent 4 }} +type: Opaque +stringData: + DATABASE_URL: {{ include "vaultwarden.databaseUrl" . | quote }} + {{- if .Values.vaultwarden.adminToken }} + ADMIN_TOKEN: {{ .Values.vaultwarden.adminToken | quote }} + {{- end }} + {{- if .Values.vaultwarden.smtp.enabled }} + SMTP_PASSWORD: {{ .Values.vaultwarden.smtp.password | quote }} + {{- end }} diff --git a/applications/vaultwarden/charts/vaultwarden/templates/service.yaml b/applications/vaultwarden/charts/vaultwarden/templates/service.yaml new file mode 100644 index 00000000..4fb98dd7 --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/templates/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "vaultwarden.fullname" . }} + labels: + {{- include "vaultwarden.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + {{- if .Values.vaultwarden.websocketEnabled }} + - port: {{ .Values.service.websocketPort }} + targetPort: websocket + protocol: TCP + name: websocket + {{- end }} + selector: + {{- include "vaultwarden.selectorLabels" . | nindent 4 }} diff --git a/applications/vaultwarden/charts/vaultwarden/values.yaml b/applications/vaultwarden/charts/vaultwarden/values.yaml new file mode 100644 index 00000000..4c8a0d53 --- /dev/null +++ b/applications/vaultwarden/charts/vaultwarden/values.yaml @@ -0,0 +1,87 @@ +# Default values for vaultwarden. + +replicaCount: 1 + +image: + registry: docker.io + repository: vaultwarden/server + tag: "" # Defaults to appVersion + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# Vaultwarden configuration +vaultwarden: + # The domain name for the Vaultwarden instance (e.g. https://vault.example.com) + domain: "" + # Admin panel token. Set to empty string to disable admin panel. + adminToken: "" + # Enable signups + signupsAllowed: true + # Enable invitations + invitationsAllowed: true + # Show password hints + showPasswordHint: true + # Enable websocket notifications + websocketEnabled: true + + # SMTP configuration for email notifications + smtp: + enabled: false + host: "" + port: 587 + security: starttls + from: "" + username: "" + password: "" + + # Database configuration + # Supported types: sqlite, postgresql + database: + type: sqlite + # SQLite data path (relative to /data) + sqlitePath: "db.sqlite3" + # PostgreSQL connection settings (used when type is postgresql) + postgresHost: "" + postgresPort: "5432" + postgresUser: "vaultwarden" + postgresPassword: "" + postgresDatabase: "vaultwarden" + +service: + type: ClusterIP + port: 80 + websocketPort: 3012 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: vault.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +persistence: + enabled: true + accessMode: ReadWriteOnce + size: 10Gi + storageClass: "" + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi + +nodeSelector: {} +tolerations: [] +affinity: {} + +replicated: + enabled: false diff --git a/applications/vaultwarden/kots/k8s-app.yaml b/applications/vaultwarden/kots/k8s-app.yaml new file mode 100644 index 00000000..7ef55f57 --- /dev/null +++ b/applications/vaultwarden/kots/k8s-app.yaml @@ -0,0 +1,8 @@ +apiVersion: app.k8s.io/v1beta1 +kind: Application +metadata: + name: "vaultwarden" +spec: + descriptor: + version: "1.0.0" + description: "Vaultwarden - Unofficial Bitwarden-compatible server" diff --git a/applications/vaultwarden/kots/kots-app.yaml b/applications/vaultwarden/kots/kots-app.yaml new file mode 100644 index 00000000..59632c66 --- /dev/null +++ b/applications/vaultwarden/kots/kots-app.yaml @@ -0,0 +1,15 @@ +apiVersion: kots.io/v1beta1 +kind: Application +metadata: + name: vaultwarden +spec: + title: Vaultwarden + icon: https://raw.githubusercontent.com/dani-garcia/vaultwarden/main/resources/vaultwarden-icon.svg + allowRollback: true + statusInformers: + - deployment/vaultwarden + ports: + - serviceName: vaultwarden + servicePort: 80 + localPort: 8080 + applicationUrl: "http://vaultwarden" diff --git a/applications/vaultwarden/kots/kots-config.yaml b/applications/vaultwarden/kots/kots-config.yaml new file mode 100644 index 00000000..4d66b9d3 --- /dev/null +++ b/applications/vaultwarden/kots/kots-config.yaml @@ -0,0 +1,102 @@ +--- +apiVersion: kots.io/v1beta1 +kind: Config +metadata: + name: config +spec: + groups: + - name: vaultwarden_settings + title: Vaultwarden Settings + items: + - name: domain + title: Domain + type: text + default: "https://vault.example.com" + required: true + description: The full URL where Vaultwarden will be accessible (e.g. https://vault.example.com) + - name: admin_token + title: Admin Token + type: password + required: false + description: Token for accessing the /admin panel. Leave empty to disable the admin panel. + - name: signups_allowed + title: Allow Signups + type: bool + default: "1" + description: Allow new users to register accounts + - name: invitations_allowed + title: Allow Invitations + type: bool + default: "1" + description: Allow existing users to invite new users + + - name: database_settings + title: Database Settings + items: + - name: database_type + title: Database Type + type: select_one + items: + - name: sqlite + title: SQLite (embedded) + - name: postgresql + title: PostgreSQL (external) + default: sqlite + description: SQLite is suitable for small deployments. PostgreSQL is recommended for production. + - name: postgres_host + title: PostgreSQL Host + type: text + default: "postgres:5432" + required: true + description: PostgreSQL host and port + when: 'repl{{ ConfigOptionEquals "database_type" "postgresql" }}' + - name: postgres_user + title: PostgreSQL User + type: text + default: "vaultwarden" + required: true + when: 'repl{{ ConfigOptionEquals "database_type" "postgresql" }}' + - name: postgres_password + title: PostgreSQL Password + type: password + required: true + when: 'repl{{ ConfigOptionEquals "database_type" "postgresql" }}' + - name: postgres_database + title: PostgreSQL Database + type: text + default: "vaultwarden" + required: true + when: 'repl{{ ConfigOptionEquals "database_type" "postgresql" }}' + + - name: smtp_settings + title: Email / SMTP Settings + items: + - name: smtp_enabled + title: Enable SMTP + type: bool + default: "0" + description: Enable email notifications and invitations + - name: smtp_host + title: SMTP Host + type: text + required: true + when: 'repl{{ ConfigOptionEquals "smtp_enabled" "1" }}' + - name: smtp_port + title: SMTP Port + type: text + default: "587" + required: true + when: 'repl{{ ConfigOptionEquals "smtp_enabled" "1" }}' + - name: smtp_from + title: SMTP From Address + type: text + required: true + when: 'repl{{ ConfigOptionEquals "smtp_enabled" "1" }}' + - name: smtp_username + title: SMTP Username + type: text + when: 'repl{{ ConfigOptionEquals "smtp_enabled" "1" }}' + - name: smtp_password + title: SMTP Password + type: password + when: 'repl{{ ConfigOptionEquals "smtp_enabled" "1" }}' diff --git a/applications/vaultwarden/kots/kots-preflight.yaml b/applications/vaultwarden/kots/kots-preflight.yaml new file mode 100644 index 00000000..003df1ad --- /dev/null +++ b/applications/vaultwarden/kots/kots-preflight.yaml @@ -0,0 +1,48 @@ +kind: Preflight +apiVersion: troubleshoot.sh/v1beta2 +metadata: + name: vaultwarden +spec: + collectors: + - clusterInfo: {} + - clusterResources: {} + analyzers: + - clusterVersion: + outcomes: + - fail: + when: "< 1.26.0" + message: Vaultwarden requires Kubernetes 1.26.0 or later. + uri: https://www.kubernetes.io + - warn: + when: "< 1.29.0" + message: Your cluster meets the minimum version of Kubernetes, but we recommend 1.29.0 or later. + uri: https://kubernetes.io + - pass: + message: Your cluster meets the recommended and required versions of Kubernetes. + - nodeResources: + checkName: Total CPU Cores in the cluster is 2 or greater + outcomes: + - fail: + when: "sum(cpuCapacity) < 2" + message: The cluster must contain at least 2 CPU cores. + - pass: + message: There are at least 2 CPU cores in the cluster. + - nodeResources: + checkName: Total memory in the cluster is 4Gi or greater + outcomes: + - fail: + when: "sum(memoryCapacity) < 4Gi" + message: The cluster must have at least 4Gi of memory. + - pass: + message: There is at least 4Gi of memory in the cluster. + - storageClass: + checkName: Check for default storage class + outcomes: + - fail: + message: > + No default storage class found. Vaultwarden needs persistent + storage for vault data. The kURL installer provisions OpenEBS + automatically, but if you are installing on an existing cluster + you must configure a default StorageClass. + - pass: + message: Default storage class found. diff --git a/applications/vaultwarden/kots/kots-support-bundle.yaml b/applications/vaultwarden/kots/kots-support-bundle.yaml new file mode 100644 index 00000000..ce97682f --- /dev/null +++ b/applications/vaultwarden/kots/kots-support-bundle.yaml @@ -0,0 +1,57 @@ +kind: SupportBundle +apiVersion: troubleshoot.sh/v1beta2 +metadata: + name: vaultwarden +spec: + collectors: + - clusterInfo: {} + - clusterResources: {} + - logs: + selector: + - app.kubernetes.io/name=vaultwarden + limits: + maxLines: 10000 + analyzers: + - clusterVersion: + outcomes: + - fail: + when: "< 1.26.0" + message: Vaultwarden requires Kubernetes 1.26.0 or later. + uri: https://www.kubernetes.io + - warn: + when: "< 1.29.0" + message: Your cluster meets the minimum version of Kubernetes, but we recommend 1.29.0 or later. + uri: https://kubernetes.io + - pass: + message: Your cluster meets the recommended and required versions of Kubernetes. + - nodeResources: + checkName: Total CPU Cores in the cluster is 2 or greater + outcomes: + - fail: + when: "sum(cpuCapacity) < 2" + message: The cluster must contain at least 2 CPU cores. + - pass: + message: There are at least 2 CPU cores in the cluster. + - nodeResources: + checkName: Total memory in the cluster is 4Gi or greater + outcomes: + - fail: + when: "sum(memoryCapacity) < 4Gi" + message: The cluster must have at least 4Gi of memory. + - pass: + message: There is at least 4Gi of memory in the cluster. + - storageClass: + checkName: Check for default storage class + outcomes: + - fail: + message: No default storage class found. + - pass: + message: Default storage class found. + - deploymentStatus: + name: vaultwarden + outcomes: + - fail: + when: "< 1" + message: The Vaultwarden deployment does not have any ready replicas. + - pass: + message: The Vaultwarden deployment has ready replicas. diff --git a/applications/vaultwarden/kots/kurl-installer.yaml b/applications/vaultwarden/kots/kurl-installer.yaml new file mode 100644 index 00000000..7c3ce454 --- /dev/null +++ b/applications/vaultwarden/kots/kurl-installer.yaml @@ -0,0 +1,23 @@ +apiVersion: cluster.kurl.sh/v1beta1 +kind: Installer +metadata: + name: vaultwarden +spec: + kubernetes: + version: 1.29.x + containerd: + version: 1.7.x + flannel: + version: 0.25.x + openebs: + version: 3.10.x + isLocalPVEnabled: true + localPVStorageClassName: local + minio: + version: 2024-09-22T00-33-43Z + kotsadm: + version: latest + registry: + version: 2.8.x + contour: + version: 1.30.x diff --git a/applications/vaultwarden/kots/vaultwarden-chart.yaml b/applications/vaultwarden/kots/vaultwarden-chart.yaml new file mode 100644 index 00000000..956cae7f --- /dev/null +++ b/applications/vaultwarden/kots/vaultwarden-chart.yaml @@ -0,0 +1,29 @@ +apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: vaultwarden +spec: + chart: + name: vaultwarden + chartVersion: 1.0.0 + values: + vaultwarden: + domain: repl{{ ConfigOption "domain" }} + adminToken: repl{{ ConfigOption "admin_token" }} + signupsAllowed: repl{{ ConfigOptionEquals "signups_allowed" "1" }} + invitationsAllowed: repl{{ ConfigOptionEquals "invitations_allowed" "1" }} + database: + type: repl{{ ConfigOption "database_type" }} + postgresHost: repl{{ ConfigOption "postgres_host" }} + postgresUser: repl{{ ConfigOption "postgres_user" }} + postgresPassword: repl{{ ConfigOption "postgres_password" }} + postgresDatabase: repl{{ ConfigOption "postgres_database" }} + smtp: + enabled: repl{{ ConfigOptionEquals "smtp_enabled" "1" }} + host: repl{{ ConfigOption "smtp_host" }} + port: repl{{ ConfigOption "smtp_port" }} + from: repl{{ ConfigOption "smtp_from" }} + username: repl{{ ConfigOption "smtp_username" }} + password: repl{{ ConfigOption "smtp_password" }} + replicated: + enabled: true diff --git a/applications/vaultwarden/tests/helm/ci-values.yaml b/applications/vaultwarden/tests/helm/ci-values.yaml new file mode 100644 index 00000000..5e2de873 --- /dev/null +++ b/applications/vaultwarden/tests/helm/ci-values.yaml @@ -0,0 +1,13 @@ +# CI test values - minimal configuration for linting and test installs +vaultwarden: + domain: "https://vault.test.local" + adminToken: "test-admin-token" + signupsAllowed: true + database: + type: sqlite + +persistence: + enabled: false + +replicated: + enabled: false diff --git a/applications/vaultwarden/tests/requirements.txt b/applications/vaultwarden/tests/requirements.txt new file mode 100644 index 00000000..f2293605 --- /dev/null +++ b/applications/vaultwarden/tests/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/applications/vaultwarden/tests/smoke_test.py b/applications/vaultwarden/tests/smoke_test.py new file mode 100644 index 00000000..0f47d2fd --- /dev/null +++ b/applications/vaultwarden/tests/smoke_test.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Smoke tests for the Vaultwarden Helm chart. + +Validates that Vaultwarden is running and its web vault and API are functional +after a Helm install or KOTS deployment. + +Usage: + python smoke_test.py [--namespace NAMESPACE] [--release RELEASE] +""" + +import argparse +import json +import logging +import socket +import subprocess +import sys +import time + +import requests + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _kubectl(args: list[str], namespace: str | None = None) -> str: + """Run a kubectl command and return stdout.""" + cmd = ["kubectl"] + if namespace: + cmd += ["-n", namespace] + cmd += args + log.debug("Running: %s", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return result.stdout.strip() + + +def discover_service( + namespace: str, + label_selector: str, + fallback_name: str, +) -> str: + """Find a service by label selector, falling back to a known name.""" + try: + out = _kubectl( + ["get", "svc", "-l", label_selector, "-o", "json"], + namespace=namespace, + ) + services = json.loads(out) + items = services.get("items", []) + if items: + name = items[0]["metadata"]["name"] + log.info("Discovered service %s via labels '%s'", name, label_selector) + return name + except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as exc: + log.warning("Label-based discovery failed (%s), using fallback: %s", exc, fallback_name) + return fallback_name + + +def _free_port() -> int: + """Return an available TCP port on localhost.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class PortForward: + """Context manager that runs ``kubectl port-forward`` in the background.""" + + def __init__(self, namespace: str, service: str, remote_port: int): + self.namespace = namespace + self.service = service + self.remote_port = remote_port + self.local_port = _free_port() + self._proc: subprocess.Popen | None = None + + def __enter__(self): + cmd = [ + "kubectl", "-n", self.namespace, + "port-forward", f"svc/{self.service}", + f"{self.local_port}:{self.remote_port}", + ] + log.info("Starting port-forward: %s", " ".join(cmd)) + self._proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + time.sleep(3) + if self._proc.poll() is not None: + stderr = self._proc.stderr.read().decode() if self._proc.stderr else "" + raise RuntimeError(f"port-forward exited immediately: {stderr}") + return self + + def __exit__(self, *_exc): + if self._proc and self._proc.poll() is None: + self._proc.terminate() + self._proc.wait(timeout=5) + + +def _retry(fn, description: str, retries: int = 10, interval: float = 3.0): + """Retry *fn* until it returns a truthy value or we run out of attempts.""" + for attempt in range(1, retries + 1): + log.info("[%d/%d] %s", attempt, retries, description) + try: + result = fn() + if result: + return result + except Exception as exc: + log.warning(" attempt %d failed: %s", attempt, exc) + if attempt < retries: + time.sleep(interval) + raise RuntimeError(f"All {retries} attempts failed: {description}") + + +# --------------------------------------------------------------------------- +# Component checks +# --------------------------------------------------------------------------- + +def check_alive(namespace: str, release: str) -> bool: + """Verify Vaultwarden /alive health endpoint is responding.""" + svc = discover_service( + namespace, + label_selector=f"app.kubernetes.io/name=vaultwarden,app.kubernetes.io/instance={release}", + fallback_name=release, + ) + with PortForward(namespace, svc, 80) as pf: + def _probe(): + url = f"http://127.0.0.1:{pf.local_port}/alive" + resp = requests.get(url, timeout=5) + resp.raise_for_status() + log.info("Vaultwarden /alive returned HTTP %d", resp.status_code) + return True + + _retry(_probe, "Checking Vaultwarden /alive endpoint") + log.info("Vaultwarden health check passed") + return True + + +def check_web_vault(namespace: str, release: str) -> bool: + """Verify the web vault UI is being served.""" + svc = discover_service( + namespace, + label_selector=f"app.kubernetes.io/name=vaultwarden,app.kubernetes.io/instance={release}", + fallback_name=release, + ) + with PortForward(namespace, svc, 80) as pf: + def _probe(): + url = f"http://127.0.0.1:{pf.local_port}/" + resp = requests.get(url, timeout=5) + resp.raise_for_status() + if "Vaultwarden" in resp.text or "Bitwarden" in resp.text or "bitwarden" in resp.text: + log.info("Web vault is serving the Vaultwarden UI") + return True + log.warning("Got HTTP 200 but page content does not look like Vaultwarden") + return True # Still accept a 200 response + + _retry(_probe, "Checking Vaultwarden web vault UI") + log.info("Web vault check passed") + return True + + +def check_api_config(namespace: str, release: str) -> bool: + """Verify the Bitwarden-compatible API config endpoint responds.""" + svc = discover_service( + namespace, + label_selector=f"app.kubernetes.io/name=vaultwarden,app.kubernetes.io/instance={release}", + fallback_name=release, + ) + with PortForward(namespace, svc, 80) as pf: + def _probe(): + url = f"http://127.0.0.1:{pf.local_port}/api/config" + resp = requests.get(url, timeout=5) + resp.raise_for_status() + data = resp.json() + log.info("API config: version=%s, server=%s", + data.get("version", "unknown"), + data.get("server", {}).get("name", "unknown")) + return True + + _retry(_probe, "Checking Vaultwarden /api/config endpoint") + log.info("API config check passed") + return True + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Vaultwarden Helm chart smoke tests") + parser.add_argument("--namespace", default="default", help="Kubernetes namespace") + parser.add_argument("--release", default="vaultwarden", help="Helm release name") + args = parser.parse_args() + + checks = [ + ("Health (/alive)", check_alive), + ("Web Vault UI", check_web_vault), + ("API Config", check_api_config), + ] + + failed = [] + for name, fn in checks: + log.info("--- Running check: %s ---", name) + try: + fn(args.namespace, args.release) + except Exception as exc: + log.error("FAIL: %s — %s", name, exc) + failed.append(name) + + print() + if failed: + print(f"FAILED checks: {', '.join(failed)}") + sys.exit(1) + else: + print("All smoke tests passed.") + sys.exit(0) + + +if __name__ == "__main__": + main()