feat: add FluxCD image automation, fix Preseem duplicate AP crash

Switch deployment pipeline from GitLab Agent kubectl to FluxCD image
automation. CI now only builds and pushes tagged images to Forgejo
registry; FluxCD detects new tags and updates the deployment manifest.

Fix Ecto.MultipleResultsError on /dashboard when a device has multiple
Preseem access point records by using limit(1) instead of Repo.get_by.
This commit is contained in:
Graham McIntire 2026-02-13 19:07:54 -06:00
parent c37ae76ceb
commit 8d00a04187
No known key found for this signature in database
10 changed files with 109 additions and 47 deletions

View file

@ -1,4 +1,4 @@
name: Build and Deploy name: Build
on: on:
push: push:
@ -33,10 +33,13 @@ jobs:
- name: Extract metadata - name: Extract metadata
id: meta id: meta
run: | run: |
echo "image=${{ secrets.REGISTRY_URL }}/${{ github.repository }}" >> $GITHUB_OUTPUT TIMESTAMP=$(date +%s)
echo "sha_tag=${{ secrets.REGISTRY_URL }}/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_OUTPUT SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "latest_tag=${{ secrets.REGISTRY_URL }}/${{ github.repository }}:latest" >> $GITHUB_OUTPUT IMAGE=${{ secrets.REGISTRY_URL }}/${{ github.repository }}
echo "cache_tag=${{ secrets.REGISTRY_URL }}/${{ github.repository }}:buildcache" >> $GITHUB_OUTPUT echo "image=${IMAGE}" >> $GITHUB_OUTPUT
echo "tag=${IMAGE}:main-${TIMESTAMP}-${SHORT_SHA}" >> $GITHUB_OUTPUT
echo "latest_tag=${IMAGE}:latest" >> $GITHUB_OUTPUT
echo "cache_tag=${IMAGE}:buildcache" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@ -45,41 +48,7 @@ jobs:
file: k8s/Dockerfile file: k8s/Dockerfile
push: true push: true
tags: | tags: |
${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.tag }}
${{ steps.meta.outputs.latest_tag }} ${{ steps.meta.outputs.latest_tag }}
cache-from: type=registry,ref=${{ steps.meta.outputs.cache_tag }} cache-from: type=registry,ref=${{ steps.meta.outputs.cache_tag }}
cache-to: type=registry,ref=${{ steps.meta.outputs.cache_tag }},mode=max cache-to: type=registry,ref=${{ steps.meta.outputs.cache_tag }},mode=max
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: production
steps:
- name: Set up kubectl
run: |
# Verify kubectl is available
kubectl version --client
- name: Configure kubectl
run: |
# Configure kubectl context
kubectl config get-contexts
kubectl config use-context towerops/towerops:home-cluster-agent
- name: Deploy to Kubernetes
run: |
# Set deployment timestamp (ISO 8601 format in UTC)
DEPLOY_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Deploying at $DEPLOY_TIMESTAMP"
# Deploy new version (migrations run on app start)
kubectl set image deployment/towerops \
towerops=${{ secrets.REGISTRY_URL }}/${{ github.repository }}:${{ github.sha }} \
-n towerops
kubectl set env deployment/towerops \
DEPLOY_TIMESTAMP=$DEPLOY_TIMESTAMP \
-n towerops
echo "Deployment initiated. Kubernetes will handle rollout asynchronously."

View file

@ -1 +1 @@
/nix/store/wvfi88hcjfw2fwbvdmcf1ahm8bfr6awr-pre-commit-config.json /nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json

12
flux/git-repository.yaml Normal file
View file

@ -0,0 +1,12 @@
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: towerops-web
namespace: flux-system
spec:
interval: 1m
url: https://git.mcintire.me/graham/towerops-web.git
ref:
branch: main
secretRef:
name: forgejo-git-credentials

14
flux/image-policy.yaml Normal file
View file

@ -0,0 +1,14 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: towerops-web
namespace: flux-system
spec:
imageRepositoryRef:
name: towerops-web
filterTags:
pattern: '^main-(?P<ts>[0-9]+)-[a-f0-9]+$'
extract: '$ts'
policy:
numerical:
order: asc

View file

@ -0,0 +1,10 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: towerops-web
namespace: flux-system
spec:
image: git.mcintire.me/graham/towerops-web
interval: 1m
secretRef:
name: forgejo-registry

View file

@ -0,0 +1,24 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: towerops-web
namespace: flux-system
spec:
interval: 1m
sourceRef:
kind: GitRepository
name: towerops-web
git:
checkout:
ref:
branch: main
commit:
author:
name: FluxCD
email: fluxcd@towerops.net
messageTemplate: 'chore: update towerops image to {{range .Changed.Changes}}{{.NewValue}}{{end}}'
push:
branch: main
update:
path: ./k8s
strategy: Setters

13
flux/kustomization.yaml Normal file
View file

@ -0,0 +1,13 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: towerops-app
namespace: flux-system
spec:
interval: 5m
sourceRef:
kind: GitRepository
name: towerops-web
path: ./k8s
prune: true
targetNamespace: towerops

View file

@ -24,7 +24,7 @@ spec:
priorityClassName: system-cluster-critical priorityClassName: system-cluster-critical
terminationGracePeriodSeconds: 90 # Allow 90s for graceful shutdown and connection draining terminationGracePeriodSeconds: 90 # Allow 90s for graceful shutdown and connection draining
imagePullSecrets: imagePullSecrets:
- name: gitlab-registry - name: forgejo-registry
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 65534 runAsUser: 65534
@ -33,8 +33,8 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: towerops - name: towerops
image: registry.gitlab.com/towerops/towerops:latest image: git.mcintire.me/graham/towerops-web:latest # {"$imagepolicy": "flux-system:towerops-web"}
imagePullPolicy: Always imagePullPolicy: IfNotPresent
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
runAsNonRoot: true runAsNonRoot: true
@ -75,8 +75,6 @@ spec:
valueFrom: valueFrom:
fieldRef: fieldRef:
fieldPath: metadata.namespace fieldPath: metadata.namespace
- name: DEPLOY_TIMESTAMP
value: "1970-01-01T00:00:00Z" # Placeholder, updated by GitLab CI on deploy
- name: ETCD_ENABLED - name: ETCD_ENABLED
value: "true" value: "true"
- name: RELEASE_DISTRIBUTION - name: RELEASE_DISTRIBUTION

View file

@ -12,7 +12,10 @@ defmodule Towerops.Preseem do
@doc "Get the Preseem AP linked to a specific device, or nil." @doc "Get the Preseem AP linked to a specific device, or nil."
def get_access_point_for_device(device_id) do def get_access_point_for_device(device_id) do
Repo.get_by(AccessPoint, device_id: device_id) AccessPoint
|> where(device_id: ^device_id)
|> limit(1)
|> Repo.one()
end end
@doc "List all Preseem APs for an organization." @doc "List all Preseem APs for an organization."

View file

@ -61,6 +61,25 @@ defmodule Towerops.PreseemTest do
result = Preseem.get_access_point_for_device(device.id) result = Preseem.get_access_point_for_device(device.id)
assert result.id == ap.id assert result.id == ap.id
end end
test "returns one AP when multiple are linked to same device", %{organization: org} do
device = device_fixture(%{organization_id: org.id})
_ap1 =
insert_access_point!(org, %{
device_id: device.id,
match_confidence: "auto_ip"
})
_ap2 =
insert_access_point!(org, %{
device_id: device.id,
match_confidence: "auto_mac"
})
result = Preseem.get_access_point_for_device(device.id)
assert %AccessPoint{} = result
end
end end
describe "list_access_points/1" do describe "list_access_points/1" do