diff --git a/helm/latr/Chart.yaml b/helm/latr/Chart.yaml index aebeb33..3477778 100644 --- a/helm/latr/Chart.yaml +++ b/helm/latr/Chart.yaml @@ -5,7 +5,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 0.1.0 +version: 0.2.0 # This is the version number of the application being deployed. appVersion: "1.0.0" diff --git a/helm/latr/README.md b/helm/latr/README.md index d1fbace..2f2fc05 100644 --- a/helm/latr/README.md +++ b/helm/latr/README.md @@ -17,6 +17,23 @@ latr is an automation tool that manages and automatically rotates Linode API tok - A valid Linode API token - Container image available at `ghcr.io/linode-obs/latr` +## Modes + +| Mode | When | Resources | +|------|------|-----------| +| **Single-instance** (default) | `accounts: []` | One Deployment (current / simple installs) | +| **Multi-account** | `accounts` non-empty | **One helm release** → **N Deployments**, one ConfigMap/Secret per account | + +Multi-account keeps **one** helm release (and typically one Argo Application) while running a separate latr pod per Linode account you manage. Set `accounts[].name` yourself (e.g. `personal-account`, `work-account`). Names become Deployment/ConfigMap/Secret names, so they must be valid **DNS-1123 labels** (lowercase alphanumeric and `-`, start/end alphanumeric, ≤63 characters). There is no public “customer id” field you must look up. + +See `examples/values-multi-account.yaml`. + +```bash +helm template latr ./helm/latr -f helm/latr/examples/values-multi-account.yaml +``` + +Per account, prefer `configFiles` (map of filename → YAML text), e.g. `process.yaml` + `tokens.yaml`. latr is started with `-config /config/*` (Go `filepath.Glob`, no shell). + ## Installing the Chart ### Basic Installation @@ -45,7 +62,8 @@ config: scopes: "*" storage: - type: "vault" - path: "secret/data/linode/tokens/production" + # path relative to vault.mount_path (no mount/data prefix) + path: "linode/tokens/production" secrets: linodeToken: "your-linode-token" diff --git a/helm/latr/examples/values-multi-account.yaml b/helm/latr/examples/values-multi-account.yaml new file mode 100644 index 0000000..cce1468 --- /dev/null +++ b/helm/latr/examples/values-multi-account.yaml @@ -0,0 +1,75 @@ +# Example multi-account values for one helm release / one Argo Application. +# Renders two Deployments: personal-account and work-account. +# +# Names are operator-chosen labels for Linode accounts you manage — not a +# special "customer id" field from the public API. +# +# helm template latr ./helm/latr -f helm/latr/examples/values-multi-account.yaml + +image: + repository: ghcr.io/linode-obs/latr + tag: "0.3.0" + +resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi + +accounts: + - name: personal-account + secrets: + linodeToken: "REDACTED-bootstrap-or-avp" + vaultRoleId: "REDACTED" + vaultSecretId: "REDACTED" + configFiles: + process.yaml: | + daemon: + mode: daemon + check_interval: 6h + dry_run: false + rotation: + threshold_percent: 10 + vault: + address: https://vault.example.com:8200 + mount_path: secret + role_id: ${VAULT_ROLE_ID} + secret_id: ${VAULT_SECRET_ID} + observability: + log_level: info + tokens.yaml: | + tokens: + - label: homelab-api + team: personal + validity: 180d + scopes: "*" + storage: + - type: vault + path: linode/tokens/homelab-api + key: token + action: replace + + - name: work-account + secrets: + existingSecret: latr-work-account-creds + configFiles: + process.yaml: | + daemon: + mode: daemon + check_interval: 6h + vault: + address: https://vault.example.com:8200 + mount_path: secret + role_id: ${VAULT_ROLE_ID} + secret_id: ${VAULT_SECRET_ID} + tokens.yaml: | + tokens: + - label: ci-readonly + team: platform + validity: 90d + scopes: "linodes:read_only" + storage: + - type: vault + path: linode/tokens/ci-readonly diff --git a/helm/latr/templates/NOTES.txt b/helm/latr/templates/NOTES.txt index ef44b0e..1471aae 100644 --- a/helm/latr/templates/NOTES.txt +++ b/helm/latr/templates/NOTES.txt @@ -7,6 +7,19 @@ To learn more about the release, try: $ helm status {{ .Release.Name }} -n {{ .Release.Namespace }} $ helm get all {{ .Release.Name }} -n {{ .Release.Namespace }} +{{- if eq (include "latr.multiAccount" .) "true" }} + +Multi-account mode: {{ len .Values.accounts }} account(s) → one Deployment each +(named from accounts[].name, e.g. personal-account, work-account). + +{{- range .Values.accounts }} + - {{ include "latr.accountFullname" (dict "root" $ "account" .) }} +{{- end }} + + $ kubectl get deploy -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }} + +{{- else }} + {{- if not .Values.secrets.existingSecret }} {{- if or (not .Values.secrets.linodeToken) (not .Values.secrets.vaultRoleId) (not .Values.secrets.vaultSecretId) }} @@ -17,47 +30,34 @@ Please ensure you set the following values: - secrets.vaultRoleId - secrets.vaultSecretId -You can update the secrets using: - - $ helm upgrade {{ .Release.Name }} {{ .Chart.Name }} \ - --set secrets.linodeToken="your-linode-token" \ - --set secrets.vaultRoleId="your-vault-role-id" \ - --set secrets.vaultSecretId="your-vault-secret-id" - -Alternatively, create a values file with your secrets and upgrade: - - $ helm upgrade {{ .Release.Name }} {{ .Chart.Name }} -f your-secrets.yaml - {{- end }} {{- end }} {{- if not .Values.config.vault.address }} +{{- if not .Values.configFiles }} WARNING: Vault address is not configured! -Please set config.vault.address to your Vault server URL: - - $ helm upgrade {{ .Release.Name }} {{ .Chart.Name }} \ - --set config.vault.address="https://vault.example.com:8200" +Please set config.vault.address to your Vault server URL. +{{- end }} {{- end }} -{{- if eq (len .Values.config.tokens) 0 }} +{{- if and (eq (len .Values.config.tokens) 0) (not .Values.configFiles) }} WARNING: No tokens are configured! -Please configure at least one token in config.tokens. -See the values.yaml file for an example configuration. +Please configure at least one token in config.tokens or use configFiles. {{- end }} -To view the logs: + $ kubectl get deployment {{ include "latr.fullname" . }} -n {{ .Release.Namespace }} - $ kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "latr.name" . }} -f +{{- end }} -To check the deployment status: +To view logs: - $ kubectl get deployment {{ include "latr.fullname" . }} -n {{ .Release.Namespace }} + $ kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "latr.name" . }} -f For more information about latr, visit: https://github.com/linode-obs/latr diff --git a/helm/latr/templates/_helpers.tpl b/helm/latr/templates/_helpers.tpl index 5ee35dd..95fb576 100644 --- a/helm/latr/templates/_helpers.tpl +++ b/helm/latr/templates/_helpers.tpl @@ -62,7 +62,7 @@ Create the name of the service account to use {{- end }} {{/* -Create the name of the secret to use +Create the name of the secret to use (single-instance mode) */}} {{- define "latr.secretName" -}} {{- if .Values.secrets.existingSecret }} @@ -79,3 +79,91 @@ Image name {{- $tag := .Values.image.tag | default .Chart.AppVersion }} {{- printf "%s:%s" .Values.image.repository $tag }} {{- end }} + +{{/* +Multi-account mode: true when accounts list is non-empty. +*/}} +{{- define "latr.multiAccount" -}} +{{- if and .Values.accounts (gt (len .Values.accounts) 0) }}true{{- else }}false{{- end }} +{{- end }} + +{{/* +Account workload name. Context: dict "root" $ "account" $account + +Prefer accounts[].name (e.g. personal-account, work-account). Optional +accounts[].id is only used when name is omitted (resource name latr-). + +Names are used as Kubernetes resource names (Deployment/ConfigMap/Secret) and +must be valid DNS-1123 labels: lowercase alphanumeric, '-' allowed, must start +and end with alphanumeric, max 63 characters. +*/}} +{{- define "latr.accountFullname" -}} +{{- $account := .account -}} +{{- $name := "" -}} +{{- if $account.name -}} +{{- $name = $account.name | trunc 63 | trimSuffix "-" -}} +{{- else if $account.id -}} +{{- $name = printf "latr-%s" ($account.id | toString) | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- fail "accounts[] entries require .name (recommended) or .id" -}} +{{- end -}} +{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" $name) -}} +{{- fail (printf "accounts[] name %q is not a valid DNS-1123 label (lowercase alphanumeric and '-', max 63, start/end alphanumeric)" $name) -}} +{{- end -}} +{{- $name -}} +{{- end }} + +{{/* +Checksum material for one account's config only (avoids rolling all pods when +another account's config changes). Context: dict "root" $ "account" $account +*/}} +{{- define "latr.accountConfigChecksum" -}} +{{- $account := .account -}} +{{- if $account.configFiles -}} +{{- toYaml $account.configFiles -}} +{{- else if $account.config -}} +{{- toYaml $account.config -}} +{{- end -}} +{{- end }} + +{{/* +Checksum material for one account's secrets only. +Context: dict "root" $ "account" $account +*/}} +{{- define "latr.accountSecretChecksum" -}} +{{- toYaml (.account.secrets | default dict) -}} +{{- end }} + +{{/* +Account selector labels. Context: dict "root" $ "account" $account +*/}} +{{- define "latr.accountSelectorLabels" -}} +app.kubernetes.io/name: {{ include "latr.name" .root }} +app.kubernetes.io/instance: {{ .root.Release.Name }} +app.kubernetes.io/component: {{ include "latr.accountFullname" . }} +{{- end }} + +{{/* +Account common labels. Context: dict "root" $ "account" $account +*/}} +{{- define "latr.accountLabels" -}} +helm.sh/chart: {{ include "latr.chart" .root }} +{{ include "latr.accountSelectorLabels" . }} +{{- if .root.Chart.AppVersion }} +app.kubernetes.io/version: {{ .root.Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .root.Release.Service }} +{{- end }} + +{{/* +Account secret name. Context: dict "root" $ "account" $account +*/}} +{{- define "latr.accountSecretName" -}} +{{- $account := .account -}} +{{- $secrets := $account.secrets | default dict -}} +{{- if $secrets.existingSecret -}} +{{- $secrets.existingSecret -}} +{{- else -}} +{{- include "latr.accountFullname" . -}} +{{- end -}} +{{- end }} diff --git a/helm/latr/templates/configmap.yaml b/helm/latr/templates/configmap.yaml index f21fb36..47dae1e 100644 --- a/helm/latr/templates/configmap.yaml +++ b/helm/latr/templates/configmap.yaml @@ -1,3 +1,4 @@ +{{- if ne (include "latr.multiAccount" .) "true" }} apiVersion: v1 kind: ConfigMap metadata: @@ -5,6 +6,12 @@ metadata: labels: {{- include "latr.labels" . | nindent 4 }} data: + {{- if .Values.configFiles }} + {{- range $name, $content := .Values.configFiles }} + {{ $name }}: | + {{- $content | nindent 4 }} + {{- end }} + {{- else }} config.yaml: | daemon: mode: {{ .Values.config.daemon.mode }} @@ -29,3 +36,30 @@ data: tokens: {{- toYaml .Values.config.tokens | nindent 6 }} + {{- end }} +{{- end }} +{{- if eq (include "latr.multiAccount" .) "true" }} +{{- range $account := .Values.accounts }} +{{- $ctx := dict "root" $ "account" $account }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "latr.accountFullname" $ctx }} + labels: + {{- include "latr.accountLabels" $ctx | nindent 4 }} +data: + {{- if $account.configFiles }} + {{- range $name, $content := $account.configFiles }} + {{ $name }}: | + {{- $content | nindent 4 }} + {{- end }} + {{- else if $account.config }} + {{/* Expect latr-native YAML keys (snake_case). Prefer configFiles for multi-file layouts. */}} + config.yaml: | + {{- toYaml $account.config | nindent 4 }} + {{- else }} + {{- fail (printf "account %s: set configFiles or config" (include "latr.accountFullname" $ctx)) }} + {{- end }} +{{- end }} +{{- end }} diff --git a/helm/latr/templates/deployment.yaml b/helm/latr/templates/deployment.yaml index 3bcb429..2b4f6dd 100644 --- a/helm/latr/templates/deployment.yaml +++ b/helm/latr/templates/deployment.yaml @@ -1,3 +1,4 @@ +{{- if ne (include "latr.multiAccount" .) "true" }} apiVersion: apps/v1 kind: Deployment metadata: @@ -40,7 +41,11 @@ spec: - /usr/local/bin/latr args: - -config + {{- if .Values.configFiles }} + - /config/* + {{- else }} - /config/config.yaml + {{- end }} env: - name: LINODE_TOKEN valueFrom: @@ -96,3 +101,114 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} +{{- end }} +{{- if eq (include "latr.multiAccount" .) "true" }} +{{- range $account := .Values.accounts }} +{{- $ctx := dict "root" $ "account" $account }} +{{- $secrets := $account.secrets | default dict }} +{{- $resources := $account.resources | default $.Values.resources }} +{{- $env := $account.env | default $.Values.env }} +{{- $replicaCount := dig "replicaCount" $.Values.replicaCount $account }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "latr.accountFullname" $ctx }} + labels: + {{- include "latr.accountLabels" $ctx | nindent 4 }} +spec: + replicas: {{ $replicaCount }} + selector: + matchLabels: + {{- include "latr.accountSelectorLabels" $ctx | nindent 6 }} + template: + metadata: + annotations: + # Hash only this account's config/secrets so other accounts do not roll. + checksum/config: {{ include "latr.accountConfigChecksum" $ctx | sha256sum }} + checksum/secret: {{ include "latr.accountSecretChecksum" $ctx | sha256sum }} + {{- with ($account.podAnnotations | default $.Values.podAnnotations) }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "latr.accountLabels" $ctx | nindent 8 }} + {{- with ($account.podLabels | default $.Values.podLabels) }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with $.Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "latr.serviceAccountName" $ }} + securityContext: + {{- toYaml $.Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ $.Chart.Name }} + securityContext: + {{- toYaml $.Values.securityContext | nindent 12 }} + image: {{ include "latr.image" $ }} + imagePullPolicy: {{ $.Values.image.pullPolicy }} + command: + - /usr/local/bin/latr + args: + - -config + {{- if $account.configFiles }} + # filepath.Glob — no shell required; merges all mounted config files + - /config/* + {{- else }} + - /config/config.yaml + {{- end }} + env: + - name: LINODE_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "latr.accountSecretName" $ctx }} + key: linode-token + - name: VAULT_ROLE_ID + valueFrom: + secretKeyRef: + name: {{ include "latr.accountSecretName" $ctx }} + key: vault-role-id + - name: VAULT_SECRET_ID + valueFrom: + secretKeyRef: + name: {{ include "latr.accountSecretName" $ctx }} + key: vault-secret-id + {{- with $env }} + {{- toYaml . | nindent 8 }} + {{- end }} + resources: + {{- toYaml $resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /config + readOnly: true + - name: tmp + mountPath: /tmp + {{- with ($account.volumeMounts | default $.Values.volumeMounts) }} + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "latr.accountFullname" $ctx }} + - name: tmp + emptyDir: {} + {{- with ($account.volumes | default $.Values.volumes) }} + {{- toYaml . | nindent 6 }} + {{- end }} + {{- with $.Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $.Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $.Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} +{{- end }} diff --git a/helm/latr/templates/poddisruptionbudget.yaml b/helm/latr/templates/poddisruptionbudget.yaml index 19bfc8b..0095a47 100644 --- a/helm/latr/templates/poddisruptionbudget.yaml +++ b/helm/latr/templates/poddisruptionbudget.yaml @@ -1,4 +1,4 @@ -{{- if .Values.podDisruptionBudget.enabled }} +{{- if and .Values.podDisruptionBudget.enabled (ne (include "latr.multiAccount" .) "true") }} apiVersion: policy/v1 kind: PodDisruptionBudget metadata: @@ -16,3 +16,25 @@ spec: matchLabels: {{- include "latr.selectorLabels" . | nindent 6 }} {{- end }} +{{- if and .Values.podDisruptionBudget.enabled (eq (include "latr.multiAccount" .) "true") }} +{{- range $account := .Values.accounts }} +{{- $ctx := dict "root" $ "account" $account }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "latr.accountFullname" $ctx }} + labels: + {{- include "latr.accountLabels" $ctx | nindent 4 }} +spec: + {{- if $.Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ $.Values.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if $.Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ $.Values.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "latr.accountSelectorLabels" $ctx | nindent 6 }} +{{- end }} +{{- end }} diff --git a/helm/latr/templates/secret.yaml b/helm/latr/templates/secret.yaml index 38e48e5..081ca1e 100644 --- a/helm/latr/templates/secret.yaml +++ b/helm/latr/templates/secret.yaml @@ -1,3 +1,4 @@ +{{- if ne (include "latr.multiAccount" .) "true" }} {{- if not .Values.secrets.existingSecret }} apiVersion: v1 kind: Secret @@ -11,3 +12,24 @@ stringData: vault-role-id: {{ .Values.secrets.vaultRoleId | quote }} vault-secret-id: {{ .Values.secrets.vaultSecretId | quote }} {{- end }} +{{- end }} +{{- if eq (include "latr.multiAccount" .) "true" }} +{{- range $account := .Values.accounts }} +{{- $ctx := dict "root" $ "account" $account }} +{{- $secrets := $account.secrets | default dict }} +{{- if not $secrets.existingSecret }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "latr.accountFullname" $ctx }} + labels: + {{- include "latr.accountLabels" $ctx | nindent 4 }} +type: Opaque +stringData: + linode-token: {{ $secrets.linodeToken | default "" | quote }} + vault-role-id: {{ $secrets.vaultRoleId | default "" | quote }} + vault-secret-id: {{ $secrets.vaultSecretId | default "" | quote }} +{{- end }} +{{- end }} +{{- end }} diff --git a/helm/latr/values.yaml b/helm/latr/values.yaml index 93bb88b..48be57a 100644 --- a/helm/latr/values.yaml +++ b/helm/latr/values.yaml @@ -152,3 +152,69 @@ envFrom: [] # name: my-secret # - configMapRef: # name: my-configmap + +# --------------------------------------------------------------------------- +# Multi-account mode (one helm release / one Argo Application → N Deployments) +# --------------------------------------------------------------------------- +# When accounts is non-empty, the chart creates one Deployment + ConfigMap + +# Secret (unless existingSecret) per entry. Single-instance values above are +# ignored for workloads (serviceAccount is still shared). +# +# Name each Linode account workload you manage (e.g. personal-account, +# work-account). Names are used as Kubernetes resource names and must be valid +# DNS-1123 labels: lowercase alphanumeric, hyphens allowed, must start and end +# with an alphanumeric character, max 63 chars. There is no special public +# "customer id" field — pick names that fit your environment. +# +# Prefer one Argo Application for the whole release (not one Application per +# account) if you care about Application count in your cluster. +# +# accounts: [] +# +# accounts: +# - name: personal-account # DNS-1123 label → Deployment/ConfigMap/Secret name +# # id: "optional-opaque-id" # only if name omitted → latr- (must still be DNS-1123) +# secrets: +# # Prefer existingSecret + external secret injection in production +# existingSecret: "" +# linodeToken: "" +# vaultRoleId: "" +# vaultSecretId: "" +# # Preferred: multi-file config (process settings + token lists) +# configFiles: +# process.yaml: | +# vault: +# address: https://vault.example.com:8200 +# mount_path: secret +# role_id: ${VAULT_ROLE_ID} +# secret_id: ${VAULT_SECRET_ID} +# daemon: +# mode: daemon +# check_interval: 6h +# tokens.yaml: | +# tokens: +# - label: my-api +# team: platform +# validity: 180d +# scopes: "*" +# storage: +# - type: vault +# path: linode/tokens/my-api +# # Alternative: single embedded config object (latr YAML / snake_case keys) +# # config: +# # daemon: { mode: daemon, check_interval: 6h, dry_run: false } +# # vault: { address: "...", mount_path: secret } +# # tokens: [] +# # env: [] +# # resources: {} +# # volumeMounts: [] +# # volumes: [] +accounts: [] + +# Single-instance multi-file config (optional). When set in single-instance mode, +# files are mounted under /config and latr is started with -config /config/* +# configFiles: {} +# process.yaml: | +# ... +# tokens.yaml: | +# ...