First try in deploying asciinema to kubernetes
Validate and deploy asciinema / Validate manifests (push) Successful in 13s
Validate and deploy asciinema / Deploy to Kubernetes (push) Successful in 36s

This commit is contained in:
2026-09-11 09:10:24 +02:00
parent c615abd845
commit 9d0e7f5c95
12 changed files with 1073 additions and 2 deletions
+143
View File
@@ -0,0 +1,143 @@
name: Validate and deploy asciinema
on:
push:
pull_request:
env:
KUBECTL_VERSION: v1.36.3
KUBERNETES_API: https://host.containers.internal:6443
KUBERNETES_TLS_SERVER_NAME: 192.168.0.2
NAMESPACE: asciinema
jobs:
validate:
name: Validate manifests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Validate scripts and Kubernetes resources
run: bash test.sh
deploy:
name: Deploy to Kubernetes
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Install kubectl
run: |
curl --fail --silent --show-error --location \
--output "${RUNNER_TEMP}/kubectl" \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
curl --fail --silent --show-error --location \
--output "${RUNNER_TEMP}/kubectl.sha256" \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check
chmod 0700 "${RUNNER_TEMP}/kubectl"
- name: Configure Kubernetes access
env:
KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }}
run: |
test -n "${KUBE_CONFIG_BASE64}"
printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig"
chmod 0600 "${RUNNER_TEMP}/kubeconfig"
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
"${RUNNER_TEMP}/kubectl" config set-cluster cluster \
--server="${KUBERNETES_API}" \
--tls-server-name="${KUBERNETES_TLS_SERVER_NAME}"
- name: Validate desired state on the server
run: |
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
"${RUNNER_TEMP}/kubectl" apply \
--dry-run=server \
--validate=false \
--filename infrastructure.yaml
"${RUNNER_TEMP}/kubectl" apply \
--dry-run=server \
--validate=false \
--filename asciinema.yaml
- name: Apply and verify
run: |
set -euo pipefail
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
"${RUNNER_TEMP}/kubectl" apply \
--validate=false \
--filename infrastructure.yaml
for attempt in {1..120}; do
IFS='|' read -r generation observed desired current ready updated <<< "$(
"${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" \
get statefulset/postgresql \
--output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.currentReplicas}|{.status.readyReplicas}|{.status.updatedReplicas}'
)"
echo "PostgreSQL ${attempt}/120: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${current}/${desired} current, ${ready}/${desired} ready"
if [[ "${observed}" == "${generation}" \
&& "${updated}" == "${desired}" \
&& "${current}" == "${desired}" \
&& "${ready}" == "${desired}" ]]; then
break
fi
if [[ "${attempt}" == 120 ]]; then
echo "PostgreSQL rollout did not complete within ten minutes" >&2
exit 1
fi
sleep 5
done
"${RUNNER_TEMP}/kubectl" apply \
--validate=false \
--filename asciinema.yaml
# ConfigMap and Secret-backed environment variables are read only at
# process start, so every successful deployment recreates the sole pod.
"${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" \
rollout restart deployment/asciinema
for attempt in {1..120}; do
IFS='|' read -r generation observed desired updated ready available unavailable image <<< "$(
"${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" \
get deployment/asciinema \
--output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.readyReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}|{.spec.template.spec.containers[0].image}'
)"
echo "asciinema ${attempt}/120: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${ready}/${desired} ready, ${available}/${desired} available, image ${image}"
if [[ "${observed}" == "${generation}" \
&& "${updated}" == "${desired}" \
&& "${ready}" == "${desired}" \
&& "${available}" == "${desired}" \
&& ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then
break
fi
if [[ "${attempt}" == 120 ]]; then
echo "asciinema rollout did not complete within ten minutes" >&2
exit 1
fi
sleep 5
done
for attempt in {1..30}; do
if curl --fail --silent --show-error --max-time 15 \
https://asciinema.brunner.ninja/ >/dev/null; then
echo "asciinema deployment is healthy"
exit 0
fi
echo "Public health check ${attempt}/30 failed; retrying"
sleep 5
done
echo "Public health check did not pass" >&2
exit 1
+5
View File
@@ -0,0 +1,5 @@
# Never commit generated cluster credentials or private environment files.
kubeconfig
*-kubeconfig
*.secret.yaml
.env
+124 -2
View File
@@ -1,3 +1,125 @@
# asciinema-deployment
# asciinema on Kubernetes
Deployment files for asciinema in my cluster
Production deployment for `https://asciinema.brunner.ninja` in the `asciinema`
namespace.
## Durable data and dependencies
asciinema has two durable data sets:
- A dedicated PostgreSQL 17 StatefulSet stores users, recording metadata,
searchable terminal text, and stream state. Its 8 Gi RBD claim uses the
retained `rook-ceph-block-ec` StorageClass.
- The private Rook Ceph RGW bucket `asciinema` stores uploaded asciicast files.
Downloads use expiring pre-signed URLs through `https://s3.brunner.ninja`.
The public RGW ingress rejects ExAws signatures for some mutating methods, so
`custom.exs` uses the cluster-local RGW Service for server-side writes, moves,
and deletes while generating browser-visible pre-signed GET URLs with the
public endpoint. This mirrors the internal/public endpoint split used by other
S3-backed applications in the cluster.
The application needs neither Redis nor Valkey. Its cache and temporary upload
workspace are bounded `emptyDir` volumes and may be discarded when the pod
moves. PostgreSQL's dedicated PVC and the RGW pool are the durable storage and
both must be backed up.
A PVC mounted at `/var/lib/asciinema` would also work instead of S3, but it
would couple the pod to a filesystem volume and make future replicas and file
serving less flexible. Since this cluster already operates RGW, S3 is the
simpler production fit.
## Install
Run with the administrator kubeconfig for this cluster:
```sh
./install.sh
```
The script creates and preserves random database, Phoenix signing, and Erlang
cookie values in `asciinema/asciinema-secrets`. It provisions a dedicated Rook
object user, copies only that user's generated credentials into the application
namespace, initializes the dedicated PostgreSQL instance and RGW bucket, then
applies and verifies the application.
No secret values are committed or printed. Re-running the script preserves the
application secrets and reconciles the database password and Rook credentials.
`asciinema.brunner.ninja` is a CNAME to `brunner.ninja`, and the existing
wildcard certificate covers the hostname.
## First login and hardening
Sign-ups are initially enabled so the first account can be created; upstream
makes that first account an administrator. Login links are sent through the
existing `smtp-relay.gmail.com` relay. If mail delivery is unavailable, find
the generated one-time URL in the application logs:
```sh
kubectl logs deployment/asciinema -n asciinema | grep 'url from email'
```
After creating the account, set `SIGN_UP_DISABLED` to `"true"` in
`asciinema.yaml`, apply it, and wait for the rollout. CLI uploads already
require authentication, and new recordings and streams default to unlisted.
The login-gated admin panel is available at `/admin`; the unauthenticated
dedicated admin port is not exposed by the Service.
Configure asciinema CLI 3.x with:
```sh
export ASCIINEMA_SERVER_URL=https://asciinema.brunner.ninja
asciinema auth
```
For asciinema CLI 2.x, use `ASCIINEMA_API_URL` instead.
## Validate
```sh
./test.sh
```
The test checks the scripts, enforces the pinned server image and storage
architecture, and validates all Kubernetes resources with kubeconform.
## Gitea Actions deployment
The workflow in `.gitea/workflows/deploy.yml` validates every push and pull
request. A successful push to `main` performs server-side dry runs, reconciles
the dedicated PostgreSQL Service and StatefulSet plus the asciinema ConfigMaps,
Deployment, Service, and Ingress, waits for both workloads, and checks the
public HTTPS endpoint.
CI deliberately does not run `install.sh` or apply `namespace.yaml`,
`bootstrap.yaml`, or `ceph-object-user.yaml`. Those administrator-only resources
create credentials, storage, the bucket, and the initial workload. CI cannot
read Secrets, create Jobs, change PVCs, modify the Namespace, or access Rook.
If a managed runtime resource is deleted, CI fails instead of recreating it;
restore it with `./install.sh`.
Bootstrap the restricted deployment identity once:
```sh
./create-ci-kubeconfig.sh
```
For remote cluster access through the usual SSH host:
```sh
KUBECTL_SSH_HOST=arschrock ./create-ci-kubeconfig.sh
```
Store the script's single base64 output line as the Gitea repository Actions
secret `KUBE_CONFIG_BASE64`. Protect `main`, require the validation job before
merging, and push a harmless change to verify the first deployment.
Delete `asciinema/asciinema-deployer-token` and the Gitea Actions secret to
revoke CI access. Running `create-ci-kubeconfig.sh` again recreates the token.
The Role is intentionally name-scoped, but Kubernetes RBAC cannot constrain
individual fields of a Deployment or StatefulSet patch. A malicious change
merged into `main` could alter the pod specifications and expose Secrets already
referenced by those workloads. Branch protection and review remain part of the
security boundary; admission policy would be needed for field-level controls.
+257
View File
@@ -0,0 +1,257 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: asciinema
namespace: asciinema
labels:
app.kubernetes.io/name: asciinema
data:
URL_HOST: asciinema.brunner.ninja
URL_SCHEME: https
TZ: Europe/Vienna
DB_POOL_SIZE: "5"
S3_BUCKET: asciinema
# Mutating requests bypass the ingress because RGW rejects some ExAws
# DELETE/HEAD signatures after they pass through the public proxy.
S3_ENDPOINT: http://rook-ceph-rgw-my-store.rook-ceph.svc.cluster.local
# The custom adapter uses this endpoint only when producing browser-visible
# pre-signed GET URLs.
S3_PUBLIC_ENDPOINT: https://s3.brunner.ninja
S3_REGION: keller
SMTP_HOST: smtp-relay.gmail.com
SMTP_PORT: "587"
SMTP_TLS: if_available
MAIL_FROM_ADDRESS: asciinema@brunner.ninja
MAIL_REPLY_TO_ADDRESS: daniel@brunner.ninja
CONTACT_EMAIL_ADDRESS: daniel@brunner.ninja
ADMIN_PANEL_ON_MAIN_ENDPOINT: "true"
# The image defaults this to true. Keep the unauthenticated dedicated admin
# listener on loopback; administrators use the authenticated /admin route.
ADMIN_BIND_ALL: "false"
UPLOAD_AUTH_REQUIRED: "true"
UNREGISTERED_UPLOAD_COUNT_LIMIT: "0"
DEFAULT_RECORDING_VISIBILITY: unlisted
DEFAULT_STREAM_VISIBILITY: unlisted
# Change this to "true" immediately after creating the first account if the
# instance should remain single-user.
SIGN_UP_DISABLED: "false"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: asciinema-custom-config
namespace: asciinema
labels:
app.kubernetes.io/name: asciinema
data:
custom.exs: |
import Config
# asciinema has one S3 endpoint setting for both internal operations and
# browser-facing pre-signed URLs. Ceph RGW in this cluster needs separate
# endpoints, so delegate all mutations to the upstream adapter and only
# customize URL generation.
defmodule Asciinema.FileStore.SplitEndpointS3 do
@behaviour Asciinema.FileStore
alias ExAws.{Config, S3}
@impl true
def uri(path) do
endpoint = System.fetch_env!("S3_PUBLIC_ENDPOINT") |> URI.parse()
config =
:s3
|> Config.new()
|> Map.put(:scheme, "#{endpoint.scheme}://")
|> Map.put(:host, endpoint.host)
|> Map.put(:port, endpoint.port)
{:ok, url} =
S3.presigned_url(config, :get, bucket(), "uploads/" <> path)
url
end
@impl true
defdelegate put_file(dst_path, src_local_path, content_type),
to: Asciinema.FileStore.S3
@impl true
defdelegate move_file(from_path, to_path), to: Asciinema.FileStore.S3
@impl true
defdelegate delete_file(path), to: Asciinema.FileStore.S3
defp bucket, do: System.fetch_env!("S3_BUCKET")
end
config :asciinema, Asciinema.FileStore,
adapter: Asciinema.FileStore.SplitEndpointS3
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: asciinema
namespace: asciinema
labels:
app.kubernetes.io/name: asciinema
spec:
replicas: 1
# Live stream state is process-local. A Recreate update avoids briefly
# routing a stream's WebSockets to two independent server processes.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: asciinema
template:
metadata:
labels:
app.kubernetes.io/name: asciinema
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 0
fsGroup: 0
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: asciinema
image: ghcr.io/asciinema/asciinema-server:20260626@sha256:0474b2186be46b23f5e16dbd4319727dbb68f27a303705fbe632821bf524b313
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
name: asciinema
env:
- name: HOME
value: /tmp
- name: CACHE_PATH
value: /var/cache/asciinema
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: database-url
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: secret-key-base
- name: RELEASE_COOKIE
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: release-cookie
- name: S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: s3-access-key-id
- name: S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: s3-secret-access-key
ports:
- name: http
containerPort: 4000
protocol: TCP
startupProbe:
httpGet:
path: /
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
readinessProbe:
httpGet:
path: /
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 256Mi
ephemeral-storage: 128Mi
limits:
cpu: "1"
memory: 1Gi
ephemeral-storage: 3Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
volumeMounts:
- name: cache
mountPath: /var/cache/asciinema
- name: custom-config
mountPath: /opt/app/etc/custom.exs
subPath: custom.exs
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: cache
emptyDir:
sizeLimit: 2Gi
- name: custom-config
configMap:
name: asciinema-custom-config
- name: tmp
emptyDir:
sizeLimit: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: asciinema
namespace: asciinema
labels:
app.kubernetes.io/name: asciinema
spec:
selector:
app.kubernetes.io/name: asciinema
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: asciinema
namespace: asciinema
labels:
app.kubernetes.io/name: asciinema
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: asciinema.brunner.ninja
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: asciinema
port:
name: http
+61
View File
@@ -0,0 +1,61 @@
apiVersion: batch/v1
kind: Job
metadata:
name: asciinema-bootstrap
namespace: asciinema
labels:
app.kubernetes.io/name: asciinema
app.kubernetes.io/component: bootstrap
spec:
backoffLimit: 5
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app.kubernetes.io/name: asciinema
app.kubernetes.io/component: bootstrap
spec:
restartPolicy: OnFailure
automountServiceAccountToken: false
containers:
- name: bucket
image: amazon/aws-cli:2.28.22
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
- >-
aws --endpoint-url "$S3_ENDPOINT" s3api head-bucket --bucket asciinema >/dev/null 2>&1 ||
aws --endpoint-url "$S3_ENDPOINT" s3api create-bucket --bucket asciinema >/dev/null
env:
- name: S3_ENDPOINT
value: http://rook-ceph-rgw-my-store.rook-ceph.svc.cluster.local
- name: AWS_DEFAULT_REGION
value: keller
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: s3-access-key-id
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: s3-secret-access-key
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 250m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
+10
View File
@@ -0,0 +1,10 @@
# This is an infrastructure prerequisite. Rook creates the matching credentials
# Secret in rook-ceph; install.sh copies only this user's keys to default.
apiVersion: ceph.rook.io/v1
kind: CephObjectStoreUser
metadata:
name: asciinema
namespace: rook-ceph
spec:
store: my-store
displayName: asciinema recordings
+91
View File
@@ -0,0 +1,91 @@
# Bootstrap once with an administrator identity. CI can update only the named
# existing runtime resources in the asciinema namespace. It cannot access
# Secrets, Jobs, the Namespace, PVCs, or the Rook object user and bucket.
apiVersion: v1
kind: ServiceAccount
metadata:
name: asciinema-deployer
namespace: asciinema
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: asciinema-deployer
namespace: asciinema
rules:
- apiGroups:
- ""
resources:
- configmaps
resourceNames:
- asciinema
- asciinema-custom-config
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
- services
resourceNames:
- asciinema
- postgresql
verbs:
- get
- patch
- update
- apiGroups:
- apps
resources:
- deployments
resourceNames:
- asciinema
verbs:
- get
- patch
- update
- apiGroups:
- apps
resources:
- statefulsets
resourceNames:
- postgresql
verbs:
- get
- patch
- update
- apiGroups:
- networking.k8s.io
resources:
- ingresses
resourceNames:
- asciinema
verbs:
- get
- patch
- update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: asciinema-deployer
namespace: asciinema
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: asciinema-deployer
subjects:
- kind: ServiceAccount
name: asciinema-deployer
namespace: asciinema
---
apiVersion: v1
kind: Secret
metadata:
name: asciinema-deployer-token
namespace: asciinema
annotations:
kubernetes.io/service-account.name: asciinema-deployer
type: kubernetes.io/service-account-token
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
namespace=${NAMESPACE:-asciinema}
service_account=${SERVICE_ACCOUNT:-asciinema-deployer}
secret=${TOKEN_SECRET:-asciinema-deployer-token}
ssh_host=${KUBECTL_SSH_HOST:-}
ssh_config=${SSH_CONFIG:-}
cluster_kubectl() {
if [[ -n "${ssh_host}" ]]; then
if [[ -n "${ssh_config}" ]]; then
ssh -F "${ssh_config}" "${ssh_host}" kubectl "$@"
else
ssh "${ssh_host}" kubectl "$@"
fi
else
kubectl "$@"
fi
}
if [[ -n "${ssh_host}" ]]; then
cluster_kubectl apply --filename=- < "${project_dir}/ci-deployer.yaml" >&2
else
cluster_kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2
fi
for attempt in {1..30}; do
if cluster_kubectl --namespace "${namespace}" get secret "${secret}" \
--output=jsonpath='{.data.token}' 2>/dev/null | grep -q .; then
break
fi
if [[ "${attempt}" == 30 ]]; then
echo "Timed out waiting for the service-account token" >&2
exit 1
fi
sleep 1
done
workdir=$(mktemp --directory)
trap 'rm -rf "${workdir}"' EXIT HUP INT TERM
server=$(cluster_kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')
tls_server_name=$(cluster_kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.tls-server-name}')
cluster_kubectl --namespace "${namespace}" get secret "${secret}" \
--output=jsonpath='{.data.ca\.crt}' | base64 --decode > "${workdir}/ca.crt"
token=$(cluster_kubectl --namespace "${namespace}" get secret "${secret}" \
--output=jsonpath='{.data.token}' | base64 --decode)
export KUBECONFIG="${workdir}/config"
cluster_options=(
--server="${server}"
--certificate-authority="${workdir}/ca.crt"
--embed-certs=true
)
if [[ -n "${tls_server_name}" ]]; then
cluster_options+=(--tls-server-name="${tls_server_name}")
fi
kubectl config set-cluster cluster "${cluster_options[@]}" >/dev/null
kubectl config set-credentials "${service_account}" --token="${token}" >/dev/null
kubectl config set-context asciinema \
--cluster=cluster \
--user="${service_account}" \
--namespace="${namespace}" >/dev/null
kubectl config use-context asciinema >/dev/null
echo "Store the following single line as the Gitea Actions secret KUBE_CONFIG_BASE64." >&2
echo "Treat it as a password; do not commit it." >&2
base64 --wrap=0 "${KUBECONFIG}"
printf '\n'
+143
View File
@@ -0,0 +1,143 @@
apiVersion: v1
kind: Service
metadata:
name: postgresql
namespace: asciinema
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: asciinema
spec:
clusterIP: None
selector:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: asciinema
ports:
- name: postgresql
port: 5432
targetPort: postgresql
protocol: TCP
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgresql
namespace: asciinema
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: asciinema
spec:
serviceName: postgresql
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: asciinema
template:
metadata:
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: asciinema
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 120
securityContext:
runAsNonRoot: true
runAsUser: 70
runAsGroup: 70
fsGroup: 70
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: postgresql
image: postgres:17.6-alpine@sha256:ef257d85f76e48da1c64832459b59fcaba1a4dac97bf5d7450c77753542eee94
imagePullPolicy: IfNotPresent
env:
- name: POSTGRES_DB
value: asciinema
- name: POSTGRES_USER
value: asciinema
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: asciinema-secrets
key: database-password
- name: POSTGRES_INITDB_ARGS
value: --auth-host=scram-sha-256
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- name: postgresql
containerPort: 5432
protocol: TCP
startupProbe:
exec:
command:
- pg_isready
- --username=asciinema
- --dbname=asciinema
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
readinessProbe:
exec:
command:
- pg_isready
- --username=asciinema
- --dbname=asciinema
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
exec:
command:
- pg_isready
- --username=asciinema
- --dbname=asciinema
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
resources:
requests:
cpu: 50m
memory: 128Mi
ephemeral-storage: 64Mi
limits:
cpu: "1"
memory: 1Gi
ephemeral-storage: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: run
mountPath: /var/run/postgresql
- name: tmp
mountPath: /tmp
volumes:
- name: run
emptyDir:
medium: Memory
sizeLimit: 16Mi
- name: tmp
emptyDir:
sizeLimit: 64Mi
volumeClaimTemplates:
- metadata:
name: data
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/part-of: asciinema
spec:
accessModes:
- ReadWriteOnce
storageClassName: rook-ceph-block-ec
resources:
requests:
storage: 8Gi
Executable
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
namespace=asciinema
rook_namespace=rook-ceph
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
secret_name=asciinema-secrets
rook_secret=rook-ceph-object-user-my-store-asciinema
temp_dir=$(mktemp --directory)
trap 'rm -rf "${temp_dir}"' EXIT HUP INT TERM
chmod 0700 "${temp_dir}"
for command in kubectl jq openssl base64; do
if ! command -v "${command}" >/dev/null 2>&1; then
echo "Required command not found: ${command}" >&2
exit 1
fi
done
kubectl apply --filename "${script_dir}/namespace.yaml"
kubectl apply --filename "${script_dir}/ceph-object-user.yaml"
echo "Waiting for Rook to provision the asciinema S3 credentials..."
for _ in $(seq 1 60); do
if kubectl get secret "${rook_secret}" --namespace "${rook_namespace}" >/dev/null 2>&1; then
break
fi
sleep 2
done
kubectl get secret "${rook_secret}" --namespace "${rook_namespace}" >/dev/null
read_secret_file() {
local source_namespace=$1 source_secret=$2 source_key=$3 destination=$4
kubectl get secret "${source_secret}" --namespace "${source_namespace}" --output=json \
| jq --raw-output --join-output --arg key "${source_key}" '.data[$key]' \
| base64 --decode > "${destination}"
test -s "${destination}"
}
if kubectl get secret "${secret_name}" --namespace "${namespace}" >/dev/null 2>&1; then
read_secret_file "${namespace}" "${secret_name}" database-password "${temp_dir}/database-password"
read_secret_file "${namespace}" "${secret_name}" secret-key-base "${temp_dir}/secret-key-base"
read_secret_file "${namespace}" "${secret_name}" release-cookie "${temp_dir}/release-cookie"
else
openssl rand -hex 32 | tr -d '\r\n' > "${temp_dir}/database-password"
openssl rand -hex 64 | tr -d '\r\n' > "${temp_dir}/secret-key-base"
openssl rand -hex 32 | tr -d '\r\n' > "${temp_dir}/release-cookie"
fi
# Environment variables preserve newlines stored in Secret values, whereas
# command substitution below strips them. Normalize values created by older
# versions of this installer so the PostgreSQL container and DATABASE_URL see
# the exact same password.
for key in database-password secret-key-base release-cookie; do
value=$(<"${temp_dir}/${key}")
printf '%s' "${value}" > "${temp_dir}/${key}"
done
# Rook owns and may rotate these credentials. Refresh the namespaced copy on
# every installation while preserving the database and application secrets.
read_secret_file "${rook_namespace}" "${rook_secret}" AccessKey "${temp_dir}/s3-access-key-id"
read_secret_file "${rook_namespace}" "${rook_secret}" SecretKey "${temp_dir}/s3-secret-access-key"
database_password=$(<"${temp_dir}/database-password")
printf 'postgresql://asciinema:%s@postgresql.asciinema.svc.cluster.local:5432/asciinema?pool_size=5' \
"${database_password}" > "${temp_dir}/database-url"
kubectl create secret generic "${secret_name}" \
--namespace "${namespace}" \
--from-file="database-password=${temp_dir}/database-password" \
--from-file="database-url=${temp_dir}/database-url" \
--from-file="secret-key-base=${temp_dir}/secret-key-base" \
--from-file="release-cookie=${temp_dir}/release-cookie" \
--from-file="s3-access-key-id=${temp_dir}/s3-access-key-id" \
--from-file="s3-secret-access-key=${temp_dir}/s3-secret-access-key" \
--dry-run=client \
--output=yaml | kubectl apply --filename=-
kubectl apply --dry-run=server --filename "${script_dir}/infrastructure.yaml"
kubectl apply --filename "${script_dir}/infrastructure.yaml"
kubectl rollout status statefulset/postgresql --namespace "${namespace}" --timeout=10m
# Jobs are immutable and bucket bootstrap must be repeatable for restores.
# Only this exact, disposable Job is replaced.
kubectl delete job asciinema-bootstrap --namespace "${namespace}" --ignore-not-found
kubectl apply --filename "${script_dir}/bootstrap.yaml"
if ! kubectl wait --for=condition=complete job/asciinema-bootstrap \
--namespace "${namespace}" --timeout=5m; then
kubectl logs job/asciinema-bootstrap --namespace "${namespace}" --all-containers=true || true
exit 1
fi
kubectl apply --dry-run=server --filename "${script_dir}/asciinema.yaml"
kubectl apply --filename "${script_dir}/asciinema.yaml"
# envFrom values and Secret-backed environment variables are read only when a
# pod starts. Recreate the sole replica so config changes and rotated RGW keys
# take effect on every successful installer run.
kubectl rollout restart deployment/asciinema --namespace "${namespace}"
kubectl rollout status deployment/asciinema --namespace "${namespace}" --timeout=10m
echo
echo "asciinema is running for https://asciinema.brunner.ninja."
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: asciinema
labels:
app.kubernetes.io/name: asciinema
Executable
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
bash -n "${project_dir}/install.sh"
bash -n "${project_dir}/create-ci-kubeconfig.sh"
bash -n "${project_dir}/test.sh"
image=$(sed -n 's|^[[:space:]]*image: \(ghcr.io/asciinema/asciinema-server[^[:space:]]*\)$|\1|p' \
"${project_dir}/asciinema.yaml")
if [[ -z "${image}" || "${image}" == *:latest || "${image}" != *@sha256:* ]]; then
echo "The asciinema server image must use a non-latest tag pinned by digest." >&2
exit 1
fi
postgres_image=$(sed -n 's|^[[:space:]]*image: \(postgres:[^[:space:]]*\)$|\1|p' \
"${project_dir}/infrastructure.yaml")
if [[ -z "${postgres_image}" || "${postgres_image}" == *:latest || "${postgres_image}" != *@sha256:* ]]; then
echo "The PostgreSQL image must use a non-latest tag pinned by digest." >&2
exit 1
fi
if ! grep -Fq 'storageClassName: rook-ceph-block-ec' "${project_dir}/infrastructure.yaml"; then
echo "The dedicated PostgreSQL claim must use retained Ceph block storage." >&2
exit 1
fi
if grep -Eqi 'redis|valkey' \
"${project_dir}/asciinema.yaml" "${project_dir}/bootstrap.yaml"; then
echo "asciinema should not use Redis/Valkey." >&2
exit 1
fi
validate_file() {
local manifest=$1
shift
if command -v kubeconform >/dev/null 2>&1; then
kubeconform -strict -summary "$@" "${manifest}"
elif command -v docker >/dev/null 2>&1; then
docker run --rm --interactive \
ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c \
-strict -summary "$@" < "${manifest}"
else
echo "Required command not found: install kubeconform or Docker" >&2
exit 1
fi
}
validate_file "${project_dir}/asciinema.yaml"
validate_file "${project_dir}/bootstrap.yaml"
validate_file "${project_dir}/infrastructure.yaml"
validate_file "${project_dir}/namespace.yaml"
validate_file "${project_dir}/ceph-object-user.yaml" -ignore-missing-schemas
validate_file "${project_dir}/ci-deployer.yaml"
echo "asciinema scripts and Kubernetes resources are valid for ${image}."