Publish Factorio deployment as a Helm chart
Validate, publish and promote Factorio chart / chart (push) Failing after 10s

This commit is contained in:
feedc0de committed 2026-10-27 07:16:05 +01:00
1 parent a165094c8e
commit bf806c886c
19 files changed
+397 -252

No files matched your search

+82
View File
@@ -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
-55
View File
@@ -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
+14
View File
@@ -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
+6
View File
@@ -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
+59 -90
View File
@@ -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.
+1 -9
View File
@@ -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"
+1 -1
View File
@@ -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
+56 -53
View File
@@ -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
+5 -15
View File
@@ -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 "$@"
+10 -10
View File
@@ -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:
+1 -2
View File
@@ -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}" "$@"
+19
View File
@@ -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"
+29
View File
@@ -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 }}
+12
View File
@@ -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 }}
+1
View File
@@ -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
+12 -14
View File
@@ -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)
+8 -3
View File
@@ -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)
+50
View File
@@ -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" }
}
}
+31
View File
@@ -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: {}