Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions charts/iron-control/.helmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Patterns to ignore when building packages.
.DS_Store
.git/
.gitignore
.vscode/
.idea/
*.swp
*.bak
*.tmp
*.orig
*~
6 changes: 6 additions & 0 deletions charts/iron-control/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: v2
name: iron-control
description: Iron Control web server and Solid Queue background job workers
type: application
version: 0.1.0
appVersion: "latest"
96 changes: 96 additions & 0 deletions charts/iron-control/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Iron Control Helm Chart

Deploys Iron Control on Kubernetes with two workloads:

- **web**: the Puma web server (fronted by Thruster), with a Service, optional Ingress, and `/up` health probes.
- **jobs**: the Solid Queue supervisor (`bin/jobs`), which runs background workers and the recurring-job scheduler from `config/recurring.yml`.

Database migrations run as a Helm pre-install/pre-upgrade hook Job (`rails db:prepare`). Web pods bypass the image entrypoint's migration step, so rollouts do not race migrations.

The app uses Solid Queue, Solid Cache, and Solid Cable, so the only external dependency is PostgreSQL. No Redis is required.

## Prerequisites

- An external PostgreSQL server reachable from the cluster, with four databases owned by the `iron_control` role: `iron_control_production`, `iron_control_production_cache`, `iron_control_production_queue`, `iron_control_production_cable`. The migration Job creates them if the role has `CREATEDB`.
- The Iron Control container image in a registry the cluster can pull from.

## Installing

```sh
helm install iron-control charts/iron-control \
--set image.repository=ghcr.io/ironsh/iron-control \
--set image.tag=v1.2.3 \
--set database.host=postgres.example.internal \
--set secrets.existingSecret=iron-control-secrets
```

### Secrets

The app requires five secret environment variables. Provide them either via a pre-created Secret (recommended) or inline values.

With `secrets.existingSecret`, the named Secret must contain these keys:

| Key | Purpose |
|-----|---------|
| `RAILS_MASTER_KEY` | Rails credentials encryption |
| `IRON_CONTROL_DATABASE_PASSWORD` | Password for the `iron_control` Postgres role |
| `IRON_CONTROL_AR_ENCRYPTION_PRIMARY_KEY` | ActiveRecord encryption |
| `IRON_CONTROL_AR_ENCRYPTION_DETERMINISTIC_KEY` | ActiveRecord encryption |
| `IRON_CONTROL_AR_ENCRYPTION_KEY_DERIVATION_SALT` | ActiveRecord encryption |

Optional bootstrap keys (`IRON_CONTROL_INITIAL_USER_EMAIL`, `IRON_CONTROL_INITIAL_USER_PASSWORD`, `IRON_CONTROL_INITIAL_API_KEY`) can be added to the same Secret to create the first user on boot.

Without `existingSecret`, set all five `secrets.values.*` entries and the chart renders its own Secret. That Secret is hook-annotated so the migration Job can read it on first install. Two caveats: `helm uninstall` does not delete hook resources, and the secret values live in Helm release history. Prefer `existingSecret` in production (it also works with external-secrets operators).

## Values

| Value | Default | Description |
|-------|---------|-------------|
| `image.repository` | `""` (required) | Container image repository |
| `image.tag` | chart `appVersion` | Image tag |
| `image.pullPolicy` | `IfNotPresent` | Pull policy |
| `imagePullSecrets` | `[]` | Pull secrets for private registries |
| `database.host` | `""` (required) | Postgres hostname (`IRON_CONTROL_DB_HOST`) |
| `database.port` | `5432` | Postgres port (`IRON_CONTROL_DB_PORT`) |
| `secrets.existingSecret` | `""` | Name of a pre-created Secret (see above) |
| `secrets.values.*` | `""` | Inline secret values, used when `existingSecret` is unset |
| `bootstrap.*` | `""` | Optional first-boot user/API key (chart-managed Secret only) |
| `config.logLevel` | `info` | `RAILS_LOG_LEVEL` |
| `config.webConcurrency` | `1` | Puma worker processes (`WEB_CONCURRENCY`) |
| `config.railsMaxThreads` | `3` | Puma threads / AR pool (`RAILS_MAX_THREADS`) |
| `config.jobConcurrency` | `1` | Solid Queue worker processes (`IRON_CONTROL_JOB_CONCURRENCY`) |
| `extraEnv` / `extraEnvFrom` | `[]` | Extra env / envFrom for all workloads |
| `web.replicas` | `2` | Web pod count |
| `web.containerPort` | `8080` | Thruster listen port (`HTTP_PORT`); kept above 1024 because the image runs as a non-root user |
| `web.resources` | requests 250m/512Mi, limit 1Gi | Web resources |
| `web.{startup,readiness,liveness}Probe` | enabled | `/up` probes |
| `web.extraEnv` | `[]` | Extra env for web pods |
| `jobs.replicas` | `1` | Jobs pod count (Solid Queue locks; >1 is safe but rarely needed) |
| `jobs.resources` | requests 250m/512Mi, limit 1Gi | Jobs resources |
| `jobs.extraEnv` | `[]` | Extra env for jobs pods |
| `migrations.enabled` | `true` | Run `rails db:prepare` as a hook Job |
| `migrations.backoffLimit` | `1` | Job retries |
| `migrations.activeDeadlineSeconds` | `600` | Job timeout |
| `service.type` / `service.port` | `ClusterIP` / `80` | Web Service |
| `ingress.*` | disabled | Standard Ingress options |
| `serviceAccount.*` | created | ServiceAccount for web and jobs pods |
| `podSecurityContext` / `containerSecurityContext` | non-root, no caps | Security defaults |
| `web.podAnnotations`, `nodeSelector`, `tolerations`, `affinity` (also under `jobs`) | empty | Scheduling knobs |

## Design Notes

- **Migrations**: the image entrypoint runs `db:prepare` when started with the default server command. The web Deployment overrides `command` to skip that, so migrations only run in the hook Job. The migration Job runs before the release's regular resources exist, so it uses the namespace default ServiceAccount.
- **Jobs rollout strategy** is `Recreate` to avoid doubled recurring-job scheduler capacity during a rollout. Solid Queue's locking makes overlap safe, so this is for predictability, not correctness.
- **Do not set `IRON_CONTROL_SOLID_QUEUE_IN_PUMA`** via `extraEnv`. It would run the job supervisor inside every web pod in addition to the jobs Deployment.
- **No probes on jobs pods**: the Solid Queue supervisor restarts crashed workers, and if the supervisor itself exits the container dies and Kubernetes restarts it. Add an exec probe via your own tooling if needed.
- **Not included** (bring your own if needed): HorizontalPodAutoscaler (target the web Deployment; autoscaling jobs is rarely useful since the scheduler runs there), PodDisruptionBudget, NetworkPolicy, persistent storage (Active Storage local-disk uploads are not supported by this chart).

## Verifying a Render

```sh
helm lint charts/iron-control
helm template iron-control charts/iron-control \
--set image.repository=ghcr.io/ironsh/iron-control \
--set database.host=pg \
--set secrets.existingSecret=iron-control-secrets
```
34 changes: 34 additions & 0 deletions charts/iron-control/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Iron Control has been deployed.

{{- if .Values.migrations.enabled }}
Database migrations ran as a pre-install/pre-upgrade hook Job
({{ include "iron-control.fullname" . }}-migrate).
{{- end }}

{{- if .Values.ingress.enabled }}

The web UI is exposed via Ingress at:
{{- range .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else }}

To reach the web UI locally:

kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "iron-control.fullname" . }}-web 8080:{{ .Values.service.port }}

then open http://localhost:8080
{{- end }}

{{- if .Values.secrets.existingSecret }}

Secrets are read from the existing Secret "{{ .Values.secrets.existingSecret }}".
Make sure it contains: RAILS_MASTER_KEY, IRON_CONTROL_DATABASE_PASSWORD,
IRON_CONTROL_AR_ENCRYPTION_PRIMARY_KEY, IRON_CONTROL_AR_ENCRYPTION_DETERMINISTIC_KEY,
IRON_CONTROL_AR_ENCRYPTION_KEY_DERIVATION_SALT.
{{- else }}

Secrets are managed by the chart in the hook-annotated Secret
"{{ include "iron-control.fullname" . }}". Note: hook resources are not removed
by `helm uninstall`; delete the Secret manually if you tear down the release.
{{- end }}
74 changes: 74 additions & 0 deletions charts/iron-control/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{{- define "iron-control.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "iron-control.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 }}

{{- define "iron-control.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "iron-control.labels" -}}
helm.sh/chart: {{ include "iron-control.chart" . }}
{{ include "iron-control.selectorLabels" . }}
{{- with .Chart.AppVersion }}
app.kubernetes.io/version: {{ . | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "iron-control.selectorLabels" -}}
app.kubernetes.io/name: {{ include "iron-control.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{- define "iron-control.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "iron-control.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

{{- define "iron-control.secretName" -}}
{{- default (include "iron-control.fullname" .) .Values.secrets.existingSecret }}
{{- end }}

{{- define "iron-control.image" -}}
{{- printf "%s:%s" (required "image.repository is required" .Values.image.repository) (default .Chart.AppVersion .Values.image.tag) }}
{{- end }}

{{/* Env entries shared by the web, jobs, and migration workloads. */}}
{{- define "iron-control.commonEnv" -}}
- name: IRON_CONTROL_DB_HOST
value: {{ required "database.host is required" .Values.database.host | quote }}
- name: IRON_CONTROL_DB_PORT
value: {{ .Values.database.port | quote }}
- name: RAILS_LOG_LEVEL
value: {{ .Values.config.logLevel | quote }}
- name: RAILS_MAX_THREADS
value: {{ .Values.config.railsMaxThreads | quote }}
{{- with .Values.extraEnv }}
{{ toYaml . }}
{{- end }}
{{- end }}

{{/* envFrom entries shared by the web, jobs, and migration workloads. */}}
{{- define "iron-control.commonEnvFrom" -}}
- secretRef:
name: {{ include "iron-control.secretName" . }}
{{- with .Values.extraEnvFrom }}
{{ toYaml . }}
{{- end }}
{{- end }}
35 changes: 35 additions & 0 deletions charts/iron-control/templates/ingress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "iron-control.fullname" . }}
labels:
{{- include "iron-control.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "iron-control.fullname" $ }}-web
port:
name: http
{{- end }}
{{- end }}
{{- end }}
69 changes: 69 additions & 0 deletions charts/iron-control/templates/jobs-deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "iron-control.fullname" . }}-jobs
labels:
{{- include "iron-control.labels" . | nindent 4 }}
app.kubernetes.io/component: jobs
spec:
replicas: {{ .Values.jobs.replicas }}
# Recreate avoids doubled recurring-job scheduler capacity mid-rollout.
strategy:
type: Recreate
selector:
matchLabels:
{{- include "iron-control.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: jobs
template:
metadata:
annotations:
{{- if not .Values.secrets.existingSecret }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- end }}
{{- with .Values.jobs.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "iron-control.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: jobs
{{- with .Values.jobs.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "iron-control.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: jobs
image: {{ include "iron-control.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["./bin/jobs"]
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
- name: IRON_CONTROL_JOB_CONCURRENCY
value: {{ .Values.config.jobConcurrency | quote }}
{{- include "iron-control.commonEnv" . | nindent 12 }}
{{- with .Values.jobs.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
envFrom:
{{- include "iron-control.commonEnvFrom" . | nindent 12 }}
resources:
{{- toYaml .Values.jobs.resources | nindent 12 }}
{{- with .Values.jobs.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.jobs.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.jobs.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
51 changes: 51 additions & 0 deletions charts/iron-control/templates/migrate-job.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{{- if .Values.migrations.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "iron-control.fullname" . }}-migrate
labels:
{{- include "iron-control.labels" . | nindent 4 }}
app.kubernetes.io/component: migrations
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "0"
# Failed Jobs are kept for debugging and replaced on the next attempt.
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
backoffLimit: {{ .Values.migrations.backoffLimit }}
activeDeadlineSeconds: {{ .Values.migrations.activeDeadlineSeconds }}
template:
metadata:
{{- with .Values.migrations.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "iron-control.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: migrations
spec:
restartPolicy: Never
# No serviceAccountName: this hook runs before the release's regular
# resources (including the chart ServiceAccount) exist.
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: migrate
image: {{ include "iron-control.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
args: ["./bin/rails", "db:prepare"]
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
{{- include "iron-control.commonEnv" . | nindent 12 }}
envFrom:
{{- include "iron-control.commonEnvFrom" . | nindent 12 }}
{{- with .Values.migrations.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
Loading