Publish Factorio deployment as a Helm chart
Validate, publish and promote Factorio chart / chart (push) Failing after 10s
Validate, publish and promote Factorio chart / chart (push) Failing after 10s
This commit is contained in:
1 parent
a165094c8e
commit
bf806c886c
19 files changed
+397
-252
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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}" "$@"
|
||||
Executable
+19
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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: {}
|
||||
Reference in new issue
Block a user