diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..23767df --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,82 @@ +name: Validate, publish and promote Factorio chart +on: + push: + branches: [main] + pull_request: +concurrency: + group: factorio-chart-${{ gitea.ref }} + cancel-in-progress: false +jobs: + chart: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: azure/setup-helm@v4 + with: + version: v4.3.0 + - name: Validate chart and application behavior + env: + FACTORIO_SMOKE_TEST: 'true' + run: | + python3 -m pip install --target "${RUNNER_TEMP}/python" 'PyYAML==6.0.3' + export PYTHONPATH="${RUNNER_TEMP}/python" + echo "PYTHONPATH=${RUNNER_TEMP}/python" >> "${GITHUB_ENV}" + ./test.sh + ./render.sh --namespace factorio > "${RUNNER_TEMP}/factorio.yaml" + python3 tests/validate.py "${RUNNER_TEMP}/factorio.yaml" + curl -fsSL https://github.com/yannh/kubeconform/releases/download/v0.7.0/kubeconform-linux-amd64.tar.gz \ + | tar -xz -C "${RUNNER_TEMP}" kubeconform + "${RUNNER_TEMP}/kubeconform" -strict -ignore-missing-schemas -summary "${RUNNER_TEMP}/factorio.yaml" + - name: Select chart release version + if: gitea.ref == 'refs/heads/main' + run: echo "CHART_VERSION=$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}" + - name: Package and publish chart + if: gitea.ref == 'refs/heads/main' + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_USERNAME" && test -n "$PACKAGE_TOKEN" + helm package . --destination "${RUNNER_TEMP}" --version "${CHART_VERSION}" + curl --fail --silent --show-error --user "$PACKAGE_USERNAME:$PACKAGE_TOKEN" \ + --request POST --upload-file "${RUNNER_TEMP}/factorio-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Confirm this is still current main + id: current-main + if: gitea.ref == 'refs/heads/main' + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if test "$(git rev-parse FETCH_HEAD)" = "$COMMIT_SHA"; then + echo 'promote=true' >> "${GITEA_OUTPUT}" + else + echo 'promote=false' >> "${GITEA_OUTPUT}" + fi + - uses: actions/checkout@v4 + if: steps.current-main.outputs.promote == 'true' + with: + repository: feedc0de/homelab-gitops + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote only Factorio's chart version + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh factorio "${CHART_VERSION}" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add bootstrap/factorio.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy Factorio chart ${CHART_VERSION}" + for attempt in 1 2 3; do + git push origin HEAD:main && exit 0 + test "$attempt" = 3 && exit 1 + git fetch origin main + git rebase origin/main + done diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index 16fbc61..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Test and deploy Factorio -on: - push: - pull_request: -concurrency: - group: factorio-${{ gitea.ref }} - cancel-in-progress: false -jobs: - validate: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - python3 -m pip install --target "${RUNNER_TEMP}/python" 'PyYAML==6.0.3' - echo "PYTHONPATH=${RUNNER_TEMP}/python" >> "$GITHUB_ENV" - curl -fsSL https://dl.k8s.io/release/v1.35.3/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl - chmod 0755 /usr/local/bin/kubectl - - name: Test policy and boot the actual game configuration - env: - FACTORIO_SMOKE_TEST: 'true' - run: ./test.sh - - name: Validate Kubernetes schemas - run: | - ./render.sh | docker run --rm -i ghcr.io/yannh/kubeconform:v0.7.0 -strict -ignore-missing-schemas -summary - for file in namespace.yaml ci-deployer.yaml; do - printf '%s\n' '---' - cat "$file" - done | docker run --rm -i ghcr.io/yannh/kubeconform:v0.7.0 -strict -summary - deploy: - if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' - needs: [validate] - runs-on: ubuntu-latest - timeout-minutes: 35 - steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - python3 -m pip install --target "${RUNNER_TEMP}/python" 'PyYAML==6.0.3' - echo "PYTHONPATH=${RUNNER_TEMP}/python" >> "$GITHUB_ENV" - curl -fsSL https://dl.k8s.io/release/v1.35.3/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl - chmod 0755 /usr/local/bin/kubectl - - name: Deploy using the dedicated Factorio service account - env: - KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} - FACTORIO_CI: 'true' - run: | - set -eu - export KUBECONFIG="${RUNNER_TEMP}/factorio-kubeconfig" - trap 'rm -f "$KUBECONFIG"' EXIT - umask 077 - printf '%s' "$KUBE_CONFIG_BASE64" | base64 -d > "$KUBECONFIG" - kubectl config set-cluster cluster --server=https://host.containers.internal:6443 --tls-server-name=192.168.0.2 - ./install.sh diff --git a/.helmignore b/.helmignore new file mode 100644 index 0000000..ccea23c --- /dev/null +++ b/.helmignore @@ -0,0 +1,14 @@ +.git/ +.gitea/ +.github/ +tests/ +ci-deployer.yaml +create-ci-kubeconfig.sh +install.sh +bootstrap.sh +render.sh +test.sh +scripts/chart-version.sh +scripts/prepare-deploy.py +kustomization.yaml +namespace.yaml diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..a0d1788 --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: factorio +description: Portable Factorio dedicated server with metrics and monitoring +type: application +version: 0.0.0 +appVersion: 2.0.77 diff --git a/README.md b/README.md index d809481..b034bc4 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,78 @@ -# Factorio team server +# Factorio team server chart -Achievement-safe Factorio 2.0.77 on Kubernetes. Public upstream images, a fresh -Ceph RBD world, Prometheus metrics, and the provisioned Grafana dashboard: -https://grafana.brunner.ninja/d/factorio-server +This repository maintains a portable Helm chart for the upstream Factorio +dedicated-server image. The chart includes the single-server Deployment, +services, persistent-data contract, runtime configuration, probes, player +collector, node-exporter textfile metrics, ServiceMonitor, Prometheus alerts, +and Grafana dashboard. It does not build or publish a custom game image. -Connect on the LAN to `192.168.0.17:34197` (UDP). The existing WAN UDP -forwarding on `newgw` is retained. -Offline/unverified accounts are allowed (`require_user_verification: false`). -The world uses the base game, without Space Age or gameplay mods. No Lua console -commands are issued. The player collector only uses `/players online`, which -preserves achievements. Production, science, UPS and other Lua-only statistics -are intentionally excluded. The server pauses when empty; autosaves occur every -five minutes with ten rotating slots. Public server-list registration is off. +The homelab deployment's cluster-specific values are maintained separately in +[homelab-gitops](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/factorio/values.yaml). -## Placement and maintenance +The chart defaults to the Factorio release in `Chart.yaml.appVersion`; the +player collector uses the same release. The node-exporter image is independently +selected in `values.yaml`. Site-specific IPs, router annotations, node placement, +storage claim, dashboard namespace, and cluster sizing belong in the consumer's +values file. This repository contains no homelab addresses or hostnames. -Factorio uses the same scheduling model as Garry's Mod: node affinity gives -**arschrock** the highest preference, while the scheduler may use an Odroid if -arschrock is unavailable, cordoned, or cannot satisfy the Pod's resource -request. There is no custom placement controller or periodic placement Pod. -Kubernetes does not automatically move an already-running Pod back merely -because arschrock later becomes preferable again. +## Use the published chart -Readiness and startup probes check authenticated RCON, the save directory and -its writability; liveness allows five minutes for slow saves before restarting -a hung game. +```sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install factorio brunner.ninja/factorio \ + --namespace factorio --create-namespace --wait --timeout 30m \ + --set persistence.size=16Gi +``` -**Current infrastructure limitation:** arschrock is the sole API server, -scheduler and etcd host. A complete outage cannot trigger rescheduling until -the control plane is reachable. No control-plane configuration is changed here. -Ceph must also be healthy and able to detach/fence the old RBD attachment before -a new writer can start. This project never force-deletes pods or attachments to -bypass storage safety. +The default chart creates a PVC. To adopt an existing claim, set +`persistence.existingClaim` to its name. Supply your own Service annotations, +external IPs, resources, affinity, tolerations, and dashboard location through +values; these settings are intentionally not baked into the chart defaults. -## Deploy and upgrade +RCON credentials are read from an existing Secret named by +`credentials.secretName` (default `factorio-runtime`). The chart never creates +or stores the password. The Secret must contain the `rcon-password` key. -Requirements: kubectl, Python 3 with PyYAML, and the configured homelab context. -`bootstrap.sh` is the one-time administrator path: namespace, limited RBAC, -random RCON secret, PVC and initial resources. Run `install.sh` thereafter. -Both work from any directory, validate on the API before applying, and wait for -rollout. Routine installs leave PVCs untouched. +## Upgrade Factorio yourself -Change the server image in **factorio.yaml** and commit to **main**. Kustomize -copies that one image value to the player collector. The pipeline validates -manifests, validates the arschrock scheduling preference, starts the actual server with the actual -rendered scripts/settings, tests probes and save/restart persistence, then -applies only the pre-created named resources. No custom game image is built. -The dashboard and scripts are deployed in the same pipeline. Configuration -checksums trigger a restart when mounted game scripts/settings change. +For an application release such as `2.1.23`: -Recreate prevents simultaneous old/new servers. Shutdown requests a save and -allows 180 seconds. Startup copies existing save ZIPs into `upgrade-backups/` -on the same PVC before the new binary loads them, retaining five startup -snapshots. This protects against upgrade conversion but is **not** an off-volume -backup. No automatic downgrade is attempted: older game binaries may not load a -newer save. To roll back, stop the server and restore an appropriate snapshot, -then deploy the matching older image. +1. Set `appVersion` in `Chart.yaml` to `2.1.23`. Do not edit `version`; CI derives + the chart package version from Git tags with `git describe --tags --long`. +2. Run `./test.sh`, inspect the diff, and commit the change to `main`. +3. Tag that commit with the exact application version, for example + `git tag v2.1.23`, then push the commit and tag with + `git push origin main v2.1.23`. +4. Gitea Actions validates and publishes the chart to the public indexed Helm + repository, then updates only Factorio's chart `targetRevision` in + `homelab-gitops`. Argo CD applies the new release; no manual GitOps edit or + Kubernetes credential is needed. -The original default/factorio-pvc world and its backing PV were explicitly -retired on 2026-10-17. Fresh data lives in factorio/factorio-data (16 GiB). -Routine install/CD never deletes or recreates this storage. +The chart version at an application tag is the application version. A later +chart-only commit becomes a unique `git describe` version such as +`2.1.23-1-g41627abcdef`. GitOps selects the exact promoted chart version. -## CI access +## Data and runtime behavior -Create `KUBE_CONFIG_BASE64` from `./create-ci-kubeconfig.sh` and upload its output -directly as a Gitea Actions repository secret; never commit it. The credential -belongs to `system:serviceaccount:factorio:factorio-deployer`, not an administrator. -It can get/patch/update/watch only the named Deployment, Services, ConfigMaps -and monitoring resources, read pod/event diagnostics in the isolated -factorio namespace, and update only `default/factorio-dashboard`. It cannot read -or list Secrets, modify PVCs, delete resources, modify nodes or grant RBAC. -Bootstrap creates these objects because resourceNames cannot constrain create. +One replica and a `Recreate` strategy prevent overlapping game servers on the +same world. Shutdown requests a save and allows 180 seconds. Startup snapshots +existing save ZIPs into `upgrade-backups/` on the same PVC before the new binary +loads them, retaining five snapshots. These snapshots help with game-version +rollback but are not an off-volume backup. Older Factorio binaries may not load +a save converted by a newer release, so restore a matching snapshot before +rolling back. -The runner currently reaches the API at host.containers.internal:6443 with TLS -server name 192.168.0.2, matching the other homelab pipelines. Update that CI -endpoint when the control plane moves. Local commands use the existing context -without kubeconfig or context overrides. +The readiness and startup probes check authenticated RCON and writable save +storage. Liveness allows time for long saves. The player collector uses only +`/players online` to preserve achievements; failed RCON queries omit player +counts and report the server as unavailable. RCON is not exposed by either +Service. -## Monitoring and tests - -The Grafana dashboard shows RAM, CPU, player count, RCON responsiveness, disk -space, network throughput, restarts, active host, and scrape latency. Metrics -are a textfile collector served by upstream node-exporter. Failed RCON queries -report server_up=0 and omit player counts rather than fabricate zero players. -Prometheus alerts cover unavailable server/exporter, stale player collection, -restarts and nearly full save storage. -Neither Service exposes RCON. Its random password is stored only in a Secret -and the server's private config file; upstream shell tracing is suppressed. +## Test and local install ```sh ./test.sh -FACTORIO_SMOKE_TEST=true ./test.sh # requires Docker +FACTORIO_SMOKE_TEST=true ./test.sh # uses Podman to boot the actual image +./install.sh --values /path/to/your-values.yaml ``` - -Tests consume the rendered deployment; they do not maintain a second copy of -its image, environment, scripts or server settings. - -## OLSR / Funkfeuer access - -Connect directly to **10.12.4.170:34197/UDP** from a client with an OLSR route -to dahoam (arschrock). The Service declares this existing host address in -`externalIPs`; Cilium forwards traffic from the funkfeuer interface to the -current ready game pod. No socat process or additional host NAT rules are used. -The existing internet forwarding and LAN address remain available. - -The client must actually route 10.12.4.170 through OLSR; this setting cannot -force the client's route or guarantee mesh latency. The entry point requires -dahoam to be online even when the game pod is on an Odroid. diff --git a/bootstrap.sh b/bootstrap.sh index 1bd761e..5709c87 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,20 +1,12 @@ #!/usr/bin/env bash -# One-time administrator bootstrap. Routine deployments use install.sh and limited RBAC. +# One-time administrator bootstrap for the existing namespace and runtime Secret. set -euo pipefail root=$(cd -- "$(dirname -- "$0")" && pwd) [[ $(kubectl config current-context) == kubernetes-admin@kubernetes ]] || { echo 'Wrong Kubernetes context' >&2; exit 1; } -kubectl apply --dry-run=server -f "$root/namespace.yaml" kubectl apply -f "$root/namespace.yaml" -for file in ci-deployer.yaml; do - kubectl apply --dry-run=server -f "$root/$file" - kubectl apply -f "$root/$file" -done if ! kubectl -n factorio get secret factorio-runtime >/dev/null 2>&1; then python3 - <<'PY' | kubectl create -f - import base64,json,secrets print(json.dumps({'apiVersion':'v1','kind':'Secret','metadata':{'name':'factorio-runtime','namespace':'factorio'},'type':'Opaque','data':{'rcon-password':base64.b64encode(secrets.token_urlsafe(32).encode()).decode()}})) PY fi -"$root/render.sh" | kubectl apply --dry-run=server -f - -"$root/render.sh" | kubectl apply -f - -"$root/install.sh" diff --git a/dashboard.yaml b/dashboard.yaml index ded89d6..cc4fdc3 100644 --- a/dashboard.yaml +++ b/dashboard.yaml @@ -3,7 +3,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: factorio-dashboard - namespace: default + namespace: {{ .Values.dashboard.namespace | default .Release.Namespace }} labels: grafana_dashboard: '1' app.kubernetes.io/name: factorio diff --git a/factorio.yaml b/factorio.yaml index fd08068..bfd3f05 100644 --- a/factorio.yaml +++ b/factorio.yaml @@ -2,7 +2,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: factorio - namespace: factorio + namespace: {{ .Release.Namespace }} labels: &id001 app.kubernetes.io/name: factorio spec: @@ -16,6 +16,8 @@ spec: template: metadata: labels: *id001 + annotations: + factorio.brunner.ninja/config-revision: {{ printf "%s%s%s%s%s%s" (.Files.Get "scripts/start.sh") (.Files.Get "scripts/health.sh") (.Files.Get "scripts/collect.sh") (.Files.Get "scripts/collector-health.sh") (.Files.Get "server-settings.json") (.Files.Get "server-adminlist.json") | sha256sum }} spec: automountServiceAccountToken: false terminationGracePeriodSeconds: 180 @@ -24,31 +26,17 @@ spec: type: RuntimeDefault fsGroup: 845 fsGroupChangePolicy: OnRootMismatch + {{ with .Values.affinity }} affinity: - nodeAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 100 - preference: - matchExpressions: - - key: kubernetes.io/hostname - operator: In - values: - - arschrock + {{ toYaml . | nindent 8 }} + {{ end }} + {{ with .Values.tolerations }} tolerations: - - key: node-role.kubernetes.io/control-plane - operator: Exists - effect: NoSchedule - - key: node.kubernetes.io/not-ready - operator: Exists - effect: NoExecute - tolerationSeconds: 300 - - key: node.kubernetes.io/unreachable - operator: Exists - effect: NoExecute - tolerationSeconds: 300 + {{ toYaml . | nindent 8 }} + {{ end }} containers: - name: factorio - image: docker.io/factoriotools/factorio:2.0.77 + image: docker.io/factoriotools/factorio:{{ .Chart.AppVersion }} command: - /scripts/start.sh env: @@ -86,14 +74,10 @@ spec: - /bin/sh - -c - timeout 90 rcon /server-save || true + {{ with .Values.resources.factorio }} resources: - requests: - cpu: '1' - memory: 2Gi - ephemeral-storage: 256Mi - limits: - memory: 12Gi - ephemeral-storage: 2Gi + {{ toYaml . | nindent 10 }} + {{ end }} securityContext: allowPrivilegeEscalation: false volumeMounts: @@ -109,7 +93,7 @@ spec: mountPath: /credentials readOnly: true - name: player-collector - image: factorio-image-from-server + image: docker.io/factoriotools/factorio:{{ .Chart.AppVersion }} command: - /scripts/collect.sh env: @@ -128,12 +112,10 @@ spec: timeoutSeconds: 5 failureThreshold: 3 livenessProbe: *id004 + {{ with .Values.resources.collector }} resources: - requests: - cpu: 10m - memory: 16Mi - limits: - memory: 64Mi + {{ toYaml . | nindent 10 }} + {{ end }} securityContext: &id007 allowPrivilegeEscalation: false runAsNonRoot: true @@ -153,7 +135,7 @@ spec: - name: metrics mountPath: /metrics - name: exporter - image: docker.io/prom/node-exporter:v1.10.2 + image: {{ .Values.exporter.image.repository }}:{{ .Values.exporter.image.tag }} args: - --web.listen-address=:9772 - --collector.disable-defaults @@ -175,12 +157,10 @@ spec: timeoutSeconds: 5 failureThreshold: 3 livenessProbe: *id006 + {{ with .Values.resources.exporter }} resources: - requests: - cpu: 10m - memory: 24Mi - limits: - memory: 128Mi + {{ toYaml . | nindent 10 }} + {{ end }} securityContext: *id007 volumeMounts: - name: metrics @@ -189,7 +169,7 @@ spec: volumes: - name: data persistentVolumeClaim: - claimName: factorio-data + claimName: {{ .Values.persistence.existingClaim | default "factorio-data" }} - name: scripts configMap: name: factorio-scripts @@ -199,59 +179,82 @@ spec: name: factorio-settings - name: credentials secret: - secretName: factorio-runtime + secretName: {{ .Values.credentials.secretName }} defaultMode: 256 - name: metrics emptyDir: sizeLimit: 16Mi - name: rcon-collector secret: - secretName: factorio-runtime + secretName: {{ .Values.credentials.secretName }} defaultMode: 288 items: - key: rcon-password path: rconpw +{{ if not .Values.persistence.existingClaim }} --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: factorio-data - namespace: factorio + namespace: {{ .Release.Namespace }} spec: accessModes: - ReadWriteOnce resources: requests: - storage: 16Gi - storageClassName: rook-ceph-block + storage: {{ .Values.persistence.size | quote }} + {{ with .Values.persistence.storageClassName }} + storageClassName: {{ . | quote }} + {{ end }} +{{ end }} --- apiVersion: v1 kind: Service metadata: name: factorio - namespace: factorio + namespace: {{ .Release.Namespace }} labels: &id001 app.kubernetes.io/name: factorio + {{ with .Values.service.annotations }} annotations: - mikrotik.brunner.ninja/router: newgw - mikrotik.brunner.ninja/forwardings: '[{"name":"game-udp","servicePort":"game-udp","dstAddressList":"WANs"}]' + {{ toYaml . | nindent 4 }} + {{ end }} spec: type: LoadBalancer - loadBalancerIP: 192.168.0.17 ports: - name: game-udp port: 34197 targetPort: game-udp protocol: UDP selector: *id001 +--- +apiVersion: v1 +kind: Service +metadata: + name: factorio-funkfeuer + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: factorio +spec: + type: ClusterIP + {{ with .Values.funkfeuerService.externalIPs }} externalIPs: - - 10.12.4.170 + {{ toYaml . | nindent 4 }} + {{ end }} + ports: + - name: game-udp + port: 34197 + targetPort: game-udp + protocol: UDP + selector: + app.kubernetes.io/name: factorio --- apiVersion: v1 kind: Service metadata: name: factorio-metrics - namespace: factorio + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: factorio app.kubernetes.io/component: metrics diff --git a/install.sh b/install.sh index 1563968..94cd4f8 100755 --- a/install.sh +++ b/install.sh @@ -1,18 +1,8 @@ #!/usr/bin/env bash set -euo pipefail root=$(cd -- "$(dirname -- "$0")" && pwd) -if [[ ${FACTORIO_CI:-false} != true ]]; then - [[ $(kubectl config current-context) == kubernetes-admin@kubernetes ]] || { echo 'Wrong Kubernetes context' >&2; exit 1; } -fi -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT -"$root/render.sh" > "$work/rendered.yaml" -python3 "$root/scripts/prepare-deploy.py" "$work/rendered.yaml" > "$work/apply.yaml" -kubectl apply --dry-run=server -f "$work/apply.yaml" -kubectl apply -f "$work/apply.yaml" -if ! kubectl -n factorio rollout status deployment/factorio --timeout=1800s; then - kubectl -n factorio get deployment factorio -o yaml - kubectl -n factorio get pods -o wide - kubectl -n factorio get events --sort-by=.lastTimestamp - exit 1 -fi +release=${FACTORIO_RELEASE:-factorio} +namespace=${FACTORIO_NAMESPACE:-factorio} +[[ $(kubectl config current-context) == kubernetes-admin@kubernetes ]] || { echo 'Wrong Kubernetes context' >&2; exit 1; } +helm upgrade --install "$release" "$root" --namespace "$namespace" \ + --create-namespace --wait --timeout 30m "$@" diff --git a/monitoring.yaml b/monitoring.yaml index da57c8d..4e73efa 100644 --- a/monitoring.yaml +++ b/monitoring.yaml @@ -2,7 +2,7 @@ apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: factorio - namespace: factorio + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: factorio spec: @@ -19,7 +19,7 @@ apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: name: factorio - namespace: factorio + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: factorio spec: @@ -27,22 +27,22 @@ spec: - name: factorio rules: - alert: FactorioUnavailable - expr: kube_deployment_status_replicas_available{namespace="factorio",deployment="factorio"} - < 1 or absent(kube_deployment_status_replicas_available{namespace="factorio",deployment="factorio"}) + expr: kube_deployment_status_replicas_available{namespace="{{ .Release.Namespace }}",deployment="factorio"} + < 1 or absent(kube_deployment_status_replicas_available{namespace="{{ .Release.Namespace }}",deployment="factorio"}) for: 5m labels: severity: warning annotations: summary: Factorio has no ready server - alert: FactorioMetricsUnavailable - expr: up{namespace="factorio",service="factorio-metrics"} == 0 or absent(up{namespace="factorio",service="factorio-metrics"}) + expr: up{namespace="{{ .Release.Namespace }}",service="factorio-metrics"} == 0 or absent(up{namespace="{{ .Release.Namespace }}",service="factorio-metrics"}) for: 5m labels: severity: warning annotations: summary: Factorio metrics collection is failing - alert: FactorioRestarting - expr: sum by (container) (increase(kube_pod_container_status_restarts_total{namespace="factorio"}[15m])) + expr: sum by (container) (increase(kube_pod_container_status_restarts_total{namespace="{{ .Release.Namespace }}"}[15m])) > 2 for: 5m labels: @@ -50,8 +50,8 @@ spec: annotations: summary: Factorio container is repeatedly restarting - alert: FactorioStorageAlmostFull - expr: kubelet_volume_stats_available_bytes{namespace="factorio",persistentvolumeclaim="factorio-data"} - / kubelet_volume_stats_capacity_bytes{namespace="factorio",persistentvolumeclaim="factorio-data"} + expr: kubelet_volume_stats_available_bytes{namespace="{{ .Release.Namespace }}",persistentvolumeclaim="factorio-data"} + / kubelet_volume_stats_capacity_bytes{namespace="{{ .Release.Namespace }}",persistentvolumeclaim="factorio-data"} < 0.15 for: 10m labels: @@ -59,14 +59,14 @@ spec: annotations: summary: Factorio save storage has less than 15% free - alert: FactorioGameNotResponding - expr: factorio_server_up{namespace="factorio"} == 0 or absent(factorio_server_up{namespace="factorio"}) + expr: factorio_server_up{namespace="{{ .Release.Namespace }}"} == 0 or absent(factorio_server_up{namespace="{{ .Release.Namespace }}"}) for: 3m labels: severity: warning annotations: summary: Factorio is not answering authenticated player queries - alert: FactorioPlayerCollectorStale - expr: time() - factorio_collector_last_poll_timestamp_seconds{namespace="factorio"} + expr: time() - factorio_collector_last_poll_timestamp_seconds{namespace="{{ .Release.Namespace }}"} > 60 for: 2m labels: diff --git a/render.sh b/render.sh index 1eab666..ac4acef 100755 --- a/render.sh +++ b/render.sh @@ -1,5 +1,4 @@ #!/bin/sh set -eu root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -kubectl kustomize "$root" -cat "$root/dashboard.yaml" +helm template factorio "$root" --namespace "${FACTORIO_NAMESPACE:-factorio}" "$@" diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..69c6bf3 --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +description=$(git describe --tags --long --match 'v[0-9]*' --dirty) +case "$description" in + *-dirty) echo 'Refusing to package a dirty tree' >&2; exit 1 ;; +esac +version=${description#v} +base=${version%%-[0-9]*} +app_version=$(sed -n 's/^appVersion:[[:space:]]*//p' Chart.yaml | tr -d '"') +test -n "$app_version" || { echo 'Chart.yaml appVersion is missing' >&2; exit 1; } +test "$base" = "$app_version" || { + echo "Git release $base does not match Chart.yaml appVersion $app_version" >&2 + exit 1 +} +case "$version" in + *-0-g*) version=${version%%-0-g*} ;; +esac +printf '%s\n' "$version" diff --git a/templates/configmaps.yaml b/templates/configmaps.yaml new file mode 100644 index 0000000..686f261 --- /dev/null +++ b/templates/configmaps.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: factorio-scripts + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: factorio +data: + start.sh: | +{{ .Files.Get "scripts/start.sh" | indent 4 }} + health.sh: | +{{ .Files.Get "scripts/health.sh" | indent 4 }} + collect.sh: | +{{ .Files.Get "scripts/collect.sh" | indent 4 }} + collector-health.sh: | +{{ .Files.Get "scripts/collector-health.sh" | indent 4 }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: factorio-settings + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: factorio +data: + server-settings.json: | +{{ .Files.Get "server-settings.json" | indent 4 }} + server-adminlist.json: | +{{ .Files.Get "server-adminlist.json" | indent 4 }} diff --git a/templates/resources.yaml b/templates/resources.yaml new file mode 100644 index 0000000..c33aabf --- /dev/null +++ b/templates/resources.yaml @@ -0,0 +1,12 @@ +{{ tpl (.Files.Get "factorio.yaml") . }} +--- +{{ tpl (.Files.Get "monitoring.yaml") . }} +--- +{{- if .Values.dashboard.enabled }} +{{- $dashboard := .Files.Get "dashboard.yaml" }} +{{- $dashboard = replace "namespace=\\\"factorio\\\"" (printf "namespace=\\\"%s\\\"" .Release.Namespace) $dashboard }} +{{- range list "version" "container" "node" }} +{{- $dashboard = replace (printf "{{%s}}" .) (printf "{{ \"{{\" }}%s{{ \"}}\" }}" .) $dashboard }} +{{- end }} +{{ tpl $dashboard . }} +{{- end }} diff --git a/test.sh b/test.sh index 28a3bb2..482fd85 100755 --- a/test.sh +++ b/test.sh @@ -6,6 +6,7 @@ for script in ./*.sh scripts/*.sh; do bash -n "$script"; done python3 -m unittest discover -s tests -v ./render.sh > /tmp/factorio-rendered.yaml python3 tests/validate.py /tmp/factorio-rendered.yaml +helm lint . if [[ ${FACTORIO_SMOKE_TEST:-false} == true ]]; then python3 tests/smoke.py /tmp/factorio-rendered.yaml fi diff --git a/tests/smoke.py b/tests/smoke.py index 872f754..42926ef 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -1,4 +1,4 @@ -"""Boot the rendered image/configuration via Docker, including remote Docker sockets.""" +"""Boot the rendered image/configuration via Podman.""" import secrets import subprocess import sys @@ -13,7 +13,7 @@ c = next(c for c in d['spec']['template']['spec']['containers'] if c['name'] == cms = {d['metadata']['name']: d['data'] for d in docs if d['kind'] == 'ConfigMap'} run = lambda *a, **kw: subprocess.run(a, check=True, **kw) volume = 'factorio-smoke-' + secrets.token_hex(6) -run('docker', 'volume', 'create', volume, stdout=subprocess.DEVNULL) +run('podman', 'volume', 'create', volume, stdout=subprocess.DEVNULL) container = None try: with tempfile.TemporaryDirectory() as directory: @@ -26,40 +26,38 @@ try: file.write_text(value) file.chmod(0o755 if key.endswith('.sh') else 0o644) (root / 'credentials/rcon-password').write_text(secrets.token_urlsafe(32)) - args = ['docker', 'create'] + args = ['podman', 'create'] for env in c['env']: args += ['--env', env['name'] + '=' + env['value']] data_mount = next(m['mountPath'] for m in c['volumeMounts'] if m['name'] == 'data') args += ['--volume', f'{volume}:{data_mount}'] args += ['--entrypoint', c['command'][0], c['image'], *c['command'][1:]] container = subprocess.check_output(args, text=True).strip() - # docker cp works when the daemon is outside the CI job container; - # bind-mounting the job container's /tmp would silently mount a different directory. for mount in c['volumeMounts']: if mount['name'] != 'data': - run('docker', 'cp', str(root / mount['name']), container + ':' + mount['mountPath']) - run('docker', 'start', container, stdout=subprocess.DEVNULL) + run('podman', 'cp', str(root / mount['name']), container + ':' + mount['mountPath']) + run('podman', 'start', container, stdout=subprocess.DEVNULL) def ready(): command = c['readinessProbe']['exec']['command'] for _ in range(90): - if subprocess.run(['docker', 'exec', container, *command], stdout=subprocess.DEVNULL, + if subprocess.run(['podman', 'exec', container, *command], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15).returncode == 0: return time.sleep(2) raise RuntimeError('Server did not pass the production readiness probe') ready() - run('docker', 'exec', container, 'rcon', '/server-save') - run('docker', 'stop', '--time', str(d['spec']['template']['spec']['terminationGracePeriodSeconds']), + run('podman', 'exec', container, 'rcon', '/server-save') + run('podman', 'stop', '--time', str(d['spec']['template']['spec']['terminationGracePeriodSeconds']), container, stdout=subprocess.DEVNULL) - run('docker', 'start', container, stdout=subprocess.DEVNULL) + run('podman', 'start', container, stdout=subprocess.DEVNULL) ready() - run('docker', 'exec', container, 'sh', '-c', + run('podman', 'exec', container, 'sh', '-c', 'test -n "$(find "$1/saves" -name "*.zip")" && test -n "$(find "$1/upgrade-backups" -name "*.zip")"', 'sh', data_mount) print('Actual image passed RCON readiness, persistent save, restart, and upgrade-backup checks') finally: if container: - run('docker', 'rm', '--force', container, stdout=subprocess.DEVNULL) - run('docker', 'volume', 'rm', volume, stdout=subprocess.DEVNULL) + run('podman', 'rm', '--force', container, stdout=subprocess.DEVNULL) + run('podman', 'volume', 'rm', volume, stdout=subprocess.DEVNULL) diff --git a/tests/validate.py b/tests/validate.py index dbd7a7f..5cd2179 100644 --- a/tests/validate.py +++ b/tests/validate.py @@ -8,12 +8,17 @@ assert d['spec']['replicas']==1 spec=d['spec']['template']['spec'] assert not spec['automountServiceAccountToken'] for c in spec['containers']: - assert all(p in c for p in ('startupProbe','readinessProbe','livenessProbe','resources')), c['name'] + assert all(p in c for p in ('startupProbe','readinessProbe','livenessProbe')), c['name'] + if 'resources' in c: + assert isinstance(c['resources'], dict), c['name'] assert ':' in c['image'] and not c['image'].endswith(':latest') -preference=spec['affinity']['nodeAffinity']['preferredDuringSchedulingIgnoredDuringExecution'] -assert preference == [{'weight': 100, 'preference': {'matchExpressions': [{'key': 'kubernetes.io/hostname', 'operator': 'In', 'values': ['arschrock']}]}}] +assert 'affinity' not in spec +assert 'tolerations' not in spec for service in (d for d in resources if d['kind']=='Service'): assert all(p['port']!=27015 for p in service['spec']['ports']) +funkfeuer=next(s for s in resources if s['kind']=='Service' and s['metadata']['name']=='factorio-funkfeuer') +assert 'externalIPs' not in funkfeuer['spec'] +assert 'loadBalancerIP' not in next(s for s in resources if s['kind']=='Service' and s['metadata']['name']=='factorio')['spec'] for cm in (d for d in resources if d['kind']=='ConfigMap'): for name,value in cm.get('data',{}).items(): if name.endswith('.json'): json.loads(value) diff --git a/values.schema.json b/values.schema.json new file mode 100644 index 0000000..6d718f1 --- /dev/null +++ b/values.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "exporter": { + "type": "object", + "properties": { + "image": { + "type": "object", + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string", "minLength": 1 } + }, + "required": ["repository", "tag"] + } + }, + "required": ["image"] + }, + "persistence": { + "type": "object", + "properties": { + "existingClaim": { "type": "string" }, + "storageClassName": { "type": "string" }, + "size": { "type": "string", "minLength": 1 } + } + }, + "credentials": { + "type": "object", + "properties": { "secretName": { "type": "string", "minLength": 1 } }, + "required": ["secretName"] + }, + "service": { "type": "object" }, + "funkfeuerService": { + "type": "object", + "properties": { + "externalIPs": { "type": "array", "items": { "type": "string" } } + } + }, + "dashboard": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "namespace": { "type": "string" } + } + }, + "affinity": { "type": "object" }, + "tolerations": { "type": "array" }, + "resources": { "type": "object" } + } +} diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..de0f414 --- /dev/null +++ b/values.yaml @@ -0,0 +1,31 @@ +exporter: + image: + repository: docker.io/prom/node-exporter + tag: v1.12.1 + +# The existing game save is supplied through persistence.existingClaim. +persistence: + existingClaim: "" + storageClassName: "" + size: 16Gi + +credentials: + secretName: factorio-runtime + +service: + annotations: {} + +funkfeuerService: + externalIPs: [] + +dashboard: + enabled: true + namespace: "" + +affinity: {} +tolerations: [] + +resources: + factorio: {} + collector: {} + exporter: {}