diff --git a/.gitignore b/.gitignore index 1d18d15..a95f4b0 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,10 @@ Thumbs.db *.tmp *.temp +# Helm values (contain environment-specific config) +chart/values.yaml +chart/values-secret.yaml + # Melange / apko build artifacts packages/ melange.rsa diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..13b26a7 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: apcupsd-client +description: FastAPI + Redis UPS monitoring dashboard +type: application +version: 1.0.0 +appVersion: "1.0.0" diff --git a/chart/SETUP.md b/chart/SETUP.md new file mode 100644 index 0000000..09e6e1a --- /dev/null +++ b/chart/SETUP.md @@ -0,0 +1,110 @@ +# Helm Deployment Setup + +## Prerequisites + +- helm v3 +- sops +- age (for key generation) +- kubectl with cluster access + +## 1. Install helm-secrets plugin + +The plugin must register as `getter/v1` type so it works via the `secrets://` protocol handler. + +```bash +# Install helm-secrets as a getter/downloader plugin +helm plugin install https://github.com/jkroepke/helm-secrets --verify=false +``` + +Verify it registered correctly: + +```bash +helm plugin list +``` + +Expected output: + +``` +NAME VERSION TYPE ... +secrets 4.x.x getter/v1 ... +``` + +The `getter/v1` type means helm-secrets acts as a protocol handler. +Use `secrets://` prefix on encrypted value files instead of `helm secrets `: + +```bash +# Correct usage with getter/v1 +helm install my-release ./chart -f values.yaml -f secrets://values-secret.yaml + +# NOT: helm secrets install ... (that requires wrapper type, not getter) +``` + +## 2. Generate an age key + +```bash +age-keygen -o ~/.config/sops/age/keys.txt +``` + +Note the public key from the output (starts with `age1...`). + +## 3. Configure sops + +Create `.sops.yaml` in the repo root: + +```yaml +creation_rules: + - path_regex: values-secret\.yaml$ + age: age1yourpublickeyhere +``` + +## 4. Create values files + +```bash +cp chart/values.yaml.example chart/values.yaml +cp chart/values-secret.yaml.example chart/values-secret.yaml +``` + +Edit `chart/values.yaml` with your environment config (non-sensitive). + +Edit `chart/values-secret.yaml` with sensitive values: + +```bash +# Generate a session secret +python -c "import secrets; print(secrets.token_urlsafe(32))" + +# Generate an admin password hash +python -c "from passlib.hash import argon2; print(argon2.hash('yourpassword'))" +``` + +## 5. Encrypt secrets + +```bash +sops -e -i chart/values-secret.yaml +``` + +To edit later: + +```bash +sops chart/values-secret.yaml # opens in $EDITOR, re-encrypts on save +``` + +Or decrypt/encrypt in place: + +```bash +sops -d -i chart/values-secret.yaml # decrypt +# edit the file +sops -e -i chart/values-secret.yaml # re-encrypt +``` + +## 6. Deploy + +```bash +./deploy.sh install # first time +./deploy.sh upgrade # subsequent deploys +./deploy.sh diff # preview changes (requires helm-diff plugin) +./deploy.sh template # render manifests locally +./deploy.sh status # show running pods/services +./deploy.sh logs # tail app logs +./deploy.sh restart # rolling restart +./deploy.sh destroy # uninstall +``` diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..e50f847 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,20 @@ +{{- define "app.labels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/part-of: {{ .Chart.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "app.selectorLabels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +{{- end -}} + +{{- define "redis.labels" -}} +app.kubernetes.io/name: redis +app.kubernetes.io/part-of: {{ .Chart.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "redis.selectorLabels" -}} +app.kubernetes.io/name: redis +{{- end -}} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml new file mode 100644 index 0000000..8a805dc --- /dev/null +++ b/chart/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-config + namespace: {{ .Values.namespace }} + labels: + {{- include "app.labels" . | nindent 4 }} +data: + {{- range $key, $val := .Values.config }} + {{ $key }}: {{ $val | quote }} + {{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..ee13236 --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Chart.Name }} + namespace: {{ .Values.namespace }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.app.replicas }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "app.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "app.selectorLabels" . | nindent 8 }} + spec: + securityContext: + runAsUser: {{ .Values.app.securityContext.runAsUser }} + runAsGroup: {{ .Values.app.securityContext.runAsGroup }} + runAsNonRoot: true + fsGroup: {{ .Values.app.securityContext.runAsGroup }} + containers: + - name: web + image: {{ .Values.app.image }} + ports: + - containerPort: {{ .Values.app.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Chart.Name }}-config + - secretRef: + name: {{ .Chart.Name }}-secret + livenessProbe: + httpGet: + path: {{ .Values.app.probes.liveness.path }} + port: {{ .Values.app.port }} + initialDelaySeconds: {{ .Values.app.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.app.probes.liveness.periodSeconds }} + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: {{ .Values.app.probes.readiness.path }} + port: {{ .Values.app.port }} + initialDelaySeconds: {{ .Values.app.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.app.probes.readiness.periodSeconds }} + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.app.resources | nindent 12 }} + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/chart/templates/redis-service.yaml b/chart/templates/redis-service.yaml new file mode 100644 index 0000000..33e01cd --- /dev/null +++ b/chart/templates/redis-service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: {{ .Values.namespace }} + labels: + {{- include "redis.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "redis.selectorLabels" . | nindent 4 }} + ports: + - name: redis + protocol: TCP + port: {{ .Values.redis.port }} + targetPort: {{ .Values.redis.port }} +{{- end }} diff --git a/k8s/redis-statefulset.yaml b/chart/templates/redis-statefulset.yaml similarity index 64% rename from k8s/redis-statefulset.yaml rename to chart/templates/redis-statefulset.yaml index 7008a0e..a98b3d1 100644 --- a/k8s/redis-statefulset.yaml +++ b/chart/templates/redis-statefulset.yaml @@ -1,29 +1,29 @@ +{{- if .Values.redis.enabled }} apiVersion: apps/v1 kind: StatefulSet metadata: name: redis - namespace: apcupsd + namespace: {{ .Values.namespace }} labels: - app.kubernetes.io/name: redis - app.kubernetes.io/part-of: apcupsd-client + {{- include "redis.labels" . | nindent 4 }} spec: serviceName: redis replicas: 1 selector: matchLabels: - app.kubernetes.io/name: redis + {{- include "redis.selectorLabels" . | nindent 6 }} template: metadata: labels: - app.kubernetes.io/name: redis + {{- include "redis.selectorLabels" . | nindent 8 }} spec: securityContext: - runAsUser: 999 - runAsGroup: 999 - fsGroup: 999 + runAsUser: {{ .Values.redis.securityContext.runAsUser }} + runAsGroup: {{ .Values.redis.securityContext.runAsGroup }} + fsGroup: {{ .Values.redis.securityContext.runAsGroup }} containers: - name: redis - image: redis:7-alpine + image: {{ .Values.redis.image }} command: - redis-server - --appendonly @@ -31,7 +31,7 @@ spec: - --appendfsync - everysec ports: - - containerPort: 6379 + - containerPort: {{ .Values.redis.port }} protocol: TCP livenessProbe: exec: @@ -52,12 +52,7 @@ spec: timeoutSeconds: 3 failureThreshold: 3 resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "128Mi" - cpu: "250m" + {{- toYaml .Values.redis.resources | nindent 12 }} volumeMounts: - name: redis-data mountPath: /data @@ -69,4 +64,5 @@ spec: - ReadWriteOnce resources: requests: - storage: 1Gi + storage: {{ .Values.redis.storage }} +{{- end }} diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml new file mode 100644 index 0000000..f6b5917 --- /dev/null +++ b/chart/templates/secret.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Chart.Name }}-secret + namespace: {{ .Values.namespace }} + labels: + {{- include "app.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $key, $val := .Values.secrets }} + {{ $key }}: {{ $val | quote }} + {{- end }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..5badff5 --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + namespace: {{ .Values.namespace }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "app.selectorLabels" . | nindent 4 }} + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.app.port }} diff --git a/chart/values-secret.yaml.example b/chart/values-secret.yaml.example new file mode 100644 index 0000000..fa0ac28 --- /dev/null +++ b/chart/values-secret.yaml.example @@ -0,0 +1,7 @@ +secrets: + # Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" + SESSION_SECRET: "" + # Generate with: python -c "from passlib.hash import argon2; print(argon2.hash('yourpassword'))" + ADMIN_PASSWORD_HASH: "" + # Optional SMTP password + SMTP_PASSWORD: "" diff --git a/chart/values.yaml.example b/chart/values.yaml.example new file mode 100644 index 0000000..946921a --- /dev/null +++ b/chart/values.yaml.example @@ -0,0 +1,56 @@ +namespace: apcupsd + +app: + image: ghcr.io/k2patel/apcupsd-client:latest + replicas: 1 + port: 8000 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" + securityContext: + runAsUser: 10001 + runAsGroup: 10001 + probes: + liveness: + path: /healthz + initialDelaySeconds: 15 + periodSeconds: 30 + readiness: + path: /readyz + initialDelaySeconds: 5 + periodSeconds: 10 + +service: + type: LoadBalancer + port: 80 + +redis: + enabled: true + image: redis:7-alpine + port: 6379 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "250m" + storage: 1Gi + securityContext: + runAsUser: 999 + runAsGroup: 999 + +config: + ADMIN_USERNAME: "admin" + REDIS_URL: "redis://redis:6379/0" + ALLOW_PRIVATE_IPS: "true" + TRUST_PROXY: "false" + LOG_LEVEL: "INFO" + RATE_LIMIT_ENABLED: "true" + SESSION_MAX_AGE_SECONDS: "1209600" + TZ: "UTC" + UPS_CONFIG_PATH: "/config/ups.yaml" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..c05bd16 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy apcupsd-client via Helm with sops-encrypted secrets +# Usage: ./deploy.sh [install|upgrade|diff|destroy|status|logs|restart] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHART_DIR="${SCRIPT_DIR}/chart" +RELEASE="apcupsd-client" +NAMESPACE="apcupsd" +VALUES="${CHART_DIR}/values.yaml" +SECRETS="secrets://${CHART_DIR}/values-secret.yaml" +ACTION="${1:-upgrade}" + +# Preflight checks +for cmd in helm sops; do + if ! command -v "$cmd" &>/dev/null; then + echo "ERROR: $cmd not found in PATH" >&2 + exit 1 + fi +done + +# Verify helm-secrets plugin is available +if ! helm plugin list | grep -q secrets; then + echo "ERROR: helm-secrets plugin not installed. Install with: helm plugin install https://github.com/jkroepke/helm-secrets" >&2 + exit 1 +fi + +# Check if secrets file exists +if [[ ! -f "${CHART_DIR}/values-secret.yaml" ]]; then + echo "ERROR: values-secret.yaml not found. Copy values-secret.yaml.example and encrypt with sops." >&2 + exit 1 +fi + +case "$ACTION" in + install) + echo "--- Installing ${RELEASE} ---" + helm install "$RELEASE" "$CHART_DIR" \ + -n "$NAMESPACE" --create-namespace \ + -f "$VALUES" \ + -f "$SECRETS" + echo "--- Install complete ---" + kubectl -n "$NAMESPACE" get pods + ;; + + upgrade) + echo "--- Upgrading ${RELEASE} ---" + helm upgrade "$RELEASE" "$CHART_DIR" \ + -n "$NAMESPACE" --create-namespace --install \ + -f "$VALUES" \ + -f "$SECRETS" + echo "--- Upgrade complete ---" + kubectl -n "$NAMESPACE" get pods + ;; + + diff) + if ! helm plugin list | grep -q diff; then + echo "ERROR: helm-diff plugin not installed. Install with: helm plugin install https://github.com/databus23/helm-diff" >&2 + exit 1 + fi + helm diff upgrade "$RELEASE" "$CHART_DIR" \ + -n "$NAMESPACE" \ + -f "$VALUES" \ + -f "$SECRETS" || true + ;; + + template) + helm template "$RELEASE" "$CHART_DIR" \ + -n "$NAMESPACE" \ + -f "$VALUES" \ + -f "$SECRETS" + ;; + + destroy) + echo "--- Uninstalling ${RELEASE} ---" + helm uninstall "$RELEASE" -n "$NAMESPACE" --ignore-not-found + echo "--- Destroyed ---" + ;; + + status) + kubectl -n "$NAMESPACE" get all + ;; + + logs) + kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=apcupsd-client -f --tail=50 + ;; + + restart) + kubectl -n "$NAMESPACE" rollout restart deployment/apcupsd-client + kubectl -n "$NAMESPACE" rollout status deployment/apcupsd-client --timeout=120s + ;; + + *) + echo "Usage: $0 {install|upgrade|diff|template|destroy|status|logs|restart}" >&2 + exit 1 + ;; +esac diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml deleted file mode 100644 index c2324c3..0000000 --- a/k8s/configmap.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: apcupsd-client-config - namespace: apcupsd - labels: - app.kubernetes.io/name: apcupsd-client - app.kubernetes.io/part-of: apcupsd-client -data: - REDIS_URL: "redis://redis:6379/0" - LOG_LEVEL: "info" - TZ: "UTC" - ALLOW_PRIVATE_IPS: "true" - TRUST_PROXY: "true" - RATE_LIMIT_ENABLED: "true" - ADMIN_USERNAME: "admin" - SESSION_MAX_AGE_SECONDS: "86400" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml deleted file mode 100644 index ee31917..0000000 --- a/k8s/deployment.yaml +++ /dev/null @@ -1,71 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: apcupsd-client - namespace: apcupsd - labels: - app.kubernetes.io/name: apcupsd-client - app.kubernetes.io/part-of: apcupsd-client -spec: - replicas: 1 - strategy: - type: RollingUpdate - rollingUpdate: - maxUnavailable: 0 - maxSurge: 1 - selector: - matchLabels: - app.kubernetes.io/name: apcupsd-client - template: - metadata: - labels: - app.kubernetes.io/name: apcupsd-client - spec: - securityContext: - runAsUser: 10001 - runAsGroup: 10001 - runAsNonRoot: true - fsGroup: 10001 - containers: - - name: web - image: ghcr.io/k2patel/apcupsd-client:latest - ports: - - containerPort: 8000 - protocol: TCP - envFrom: - - configMapRef: - name: apcupsd-client-config - - secretRef: - name: apcupsd-client-secret - livenessProbe: - httpGet: - path: /healthz - port: 8000 - initialDelaySeconds: 15 - periodSeconds: 30 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /readyz - port: 8000 - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - resources: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "256Mi" - cpu: "500m" - securityContext: - readOnlyRootFilesystem: true - allowPrivilegeEscalation: false - volumeMounts: - - name: tmp - mountPath: /tmp - volumes: - - name: tmp - emptyDir: {} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml deleted file mode 100644 index 1552ade..0000000 --- a/k8s/kustomization.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - namespace.yaml - - configmap.yaml - - secret.yaml - - redis-statefulset.yaml - - redis-service.yaml - - deployment.yaml - - service.yaml diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml deleted file mode 100644 index 12d715c..0000000 --- a/k8s/namespace.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: apcupsd - labels: - app.kubernetes.io/part-of: apcupsd-client diff --git a/k8s/redis-service.yaml b/k8s/redis-service.yaml deleted file mode 100644 index 8117ca6..0000000 --- a/k8s/redis-service.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: redis - namespace: apcupsd - labels: - app.kubernetes.io/name: redis - app.kubernetes.io/part-of: apcupsd-client -spec: - type: ClusterIP - selector: - app.kubernetes.io/name: redis - ports: - - name: redis - protocol: TCP - port: 6379 - targetPort: 6379 diff --git a/k8s/secret.yaml b/k8s/secret.yaml deleted file mode 100644 index 8a39968..0000000 --- a/k8s/secret.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: apcupsd-client-secret - namespace: apcupsd - labels: - app.kubernetes.io/name: apcupsd-client - app.kubernetes.io/part-of: apcupsd-client -type: Opaque -stringData: - SESSION_SECRET: "CHANGE_ME_TO_A_RANDOM_SECRET" - # Optional — uncomment and set as needed: - # SMTP_PASSWORD: "" - # ADMIN_PASSWORD_HASH: "" diff --git a/k8s/service.yaml b/k8s/service.yaml deleted file mode 100644 index f1c9b3a..0000000 --- a/k8s/service.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: apcupsd-client - namespace: apcupsd - labels: - app.kubernetes.io/name: apcupsd-client - app.kubernetes.io/part-of: apcupsd-client -spec: - type: LoadBalancer - selector: - app.kubernetes.io/name: apcupsd-client - ports: - - name: http - protocol: TCP - port: 10280 - targetPort: 8000