Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
51b3c62
WIP
bgshacklett Jun 1, 2025
40eddef
Move unattended.sh script into separate file
bgshacklett Jun 4, 2025
7e3fcb6
WIP
bgshacklett Jun 5, 2025
e5e92ab
download headless-apkovl using raw url
bgshacklett Jun 5, 2025
716e3a6
Move cmdline.txt and usercfg.txt setup to prep script
bgshacklett Jun 5, 2025
169b518
Move lbu commit
bgshacklett Jun 5, 2025
f64073a
Don't set up /dev/sda for now
bgshacklett Jun 5, 2025
d8dcb9e
remove superfluous `main` function
bgshacklett Jun 21, 2025
1efb350
Move K8s manifests to separate directory
bgshacklett Sep 1, 2025
339b77d
Add script for running alpine in qemu on raspi4b 'machine'
bgshacklett Sep 1, 2025
fb5997d
Set up untattended config files
bgshacklett Sep 1, 2025
b2b5f28
Set up makefile skeleton
bgshacklett Sep 1, 2025
5e6654e
Update .gitignore
bgshacklett Sep 1, 2025
4587d39
Update script name
bgshacklett Sep 2, 2025
f08b540
WIP
bgshacklett Sep 7, 2025
24bf297
Output unattended.sh script logging to console
bgshacklett Sep 7, 2025
0d8b5ef
Handle WPA_SUPPLICANT
bgshacklett Sep 7, 2025
00a9022
Rename main script
bgshacklett Sep 7, 2025
4e801c0
WIP
bgshacklett Sep 14, 2025
7e8eaf1
WIP
bgshacklett Sep 14, 2025
6940b84
WIP
bgshacklett Sep 15, 2025
83e5f17
WIP
bgshacklett Sep 15, 2025
e56b309
WIP
bgshacklett Sep 21, 2025
a9c0fc2
Initial working WIFI simulation
bgshacklett Sep 27, 2025
bb5568e
Finish what alpine-setup could not
bgshacklett Sep 27, 2025
0a9d401
Default to pi3 hardware until pi4 is more usable
bgshacklett Sep 27, 2025
a4e6b01
Re-Arrange variables
bgshacklett Sep 27, 2025
ee17c69
Specify $boot path outside of _populate_config
bgshacklett Sep 27, 2025
00ddd11
cleanup
bgshacklett Sep 27, 2025
6f0f7c5
clean up comments
bgshacklett Sep 27, 2025
8649df4
Prep for writing to SD Card
bgshacklett Sep 28, 2025
2c561b0
Fix: don't mangle backslashes in WIFI settings
bgshacklett Sep 28, 2025
257c854
Set up answer file; use usb0 for networking after install
bgshacklett Sep 28, 2025
d59a14f
Enable admin user
bgshacklett Sep 29, 2025
67efcd6
verbose output for downloads
bgshacklett Sep 29, 2025
3e74b3e
Work on populating SD card
bgshacklett Oct 4, 2025
a0e4640
make unmount and removal of loop devide more robust
bgshacklett Oct 5, 2025
8fa0aac
Don't run with_p1_sd as subshell
bgshacklett Oct 5, 2025
17b303b
cleanup
bgshacklett Oct 5, 2025
f610483
Improve make sdcard logic
bgshacklett Oct 5, 2025
770e5d0
add hostname to alpine-setup.any.conf.example
bgshacklett Oct 12, 2025
9ad5924
Remove answers.txt from repo; create example of same
bgshacklett Oct 12, 2025
d3aa975
Separate default answers files for RPI and QEMU
bgshacklett Oct 12, 2025
80cbb52
Found `-e` option. Rely on setup-alpine for most of setup
bgshacklett Oct 12, 2025
7330f9f
Update device requests
bgshacklett Oct 13, 2025
86cac53
Merge branch 'main' into alpine
bgshacklett May 15, 2026
bd14297
fix: address 12 review issues across shell scripts and config files
May 16, 2026
c297322
Add unattended.lib.sh; gate WiFi prompts on tty
bgshacklett May 17, 2026
a48f76d
Pin Alpine release + verify tarball checksum
bgshacklett May 17, 2026
12d5413
Add init-config to scaffold etc/ from extras/ templates
bgshacklett May 17, 2026
cdcf2a3
Add shellcheck CI + fix existing findings; drop dead sdcard stub
bgshacklett May 17, 2026
815b9f5
Fix four cubic-flagged runtime correctness issues
bgshacklett May 17, 2026
ce2b384
Address cubic-flagged security issues
bgshacklett May 17, 2026
a9aa2ee
Rewrite README around the Alpine image-build pipeline
bgshacklett May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: lint

on:
push:
branches: [main, alpine]
pull_request:

jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Run shellcheck
run: find scripts etc -type f -name '*.sh' -print0 | xargs -0 shellcheck
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.devrootfs/*
dist/*
build/*
**/*.orig
cache/*

etc/*
!etc/pre-network.d
!etc/unattended.sh
!etc/unattended.lib.sh
!etc/alpine.lock
!etc/unattended.exec.d
!etc/unattended.conf.d
etc/unattended.conf.d/*
!etc/unattended.conf.d/README.md
!**/README.md
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

58 changes: 58 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# This Makefile is a wrapper around scripts in the <repo_root>/scripts
# directory, meant to enable tab completion and generally make life a little
# easier. You do not need `make` to use the contents of this repo.


.PHONY: image
image:
@sudo -E scripts/alpine-rpi.sh make-image

.PHONY: populate-boot-qemu
populate-boot-qemu:
@sudo -E scripts/alpine-rpi.sh populate-boot-qemu

.PHONY: populate-config-qemu
populate-config-qemu:
@sudo -E scripts/alpine-rpi.sh populate-config-qemu

.PHONY: verify
verify:
@scripts/alpine-rpi.sh verify

.PHONY: qemu
qemu: image populate-boot-qemu populate-config-qemu

.PHONY: launch
launch:
@sudo -E scripts/alpine-rpi.sh launch

.PHONY: test
test:
@sudo -E scripts/alpine-rpi.sh test

.PHONY: populate-boot-sd
populate-boot-sd:
@sudo -E scripts/alpine-rpi.sh populate-boot-sd

.PHONY: populate-config-sd
populate-config-sd:
@sudo -E scripts/alpine-rpi.sh populate-config-sd

.PHONY: sdcard
sdcard: populate-boot-sd populate-config-sd

.PHONY: refresh-lock
refresh-lock:
@scripts/alpine-rpi.sh refresh-lock

.PHONY: init-config
init-config:
@scripts/alpine-rpi.sh init-config

.PHONY: lint
lint:
@find scripts etc -type f -name '*.sh' -print0 | xargs -0 shellcheck

.PHONY: clean
clean:
@scripts/alpine-rpi.sh clean
113 changes: 105 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,104 @@
# swimctl

A swimming pool automation controller
A swimming pool automation controller.

This repo contains two parallel concerns:

## Install
1. An **Alpine Linux image-build pipeline** for the Raspberry Pi
(`scripts/alpine-rpi.sh`, `etc/`, `Makefile`).
2. The **Kubernetes manifests and Helm values** used to deploy the
swimctl Node-RED app on a running Pi (`manifests/`, `values.yml`,
`Dockerfile`).

The build pipeline lives on the `alpine` branch and is the focus of this
README; the app-install steps live further down.

---

## Building the Alpine RPi image

### Requirements

The build host needs:

- `bash`, `parted`, `kpartx`, `losetup`, `mkfs.vfat`, `tar`
- `qemu-system-aarch64` (for the QEMU target)
- `wget`, `curl`, `sha256sum`
- `sudo` (loop device + mount operations are unavoidable)
- `shellcheck` (only for `make lint`)

### One-time setup

```sh
make init-config # scaffold etc/ from extras/ templates
$EDITOR etc/answers.txt etc/answers-qemu.txt # per-deployment knobs
$EDITOR etc/unattended.conf.d/alpine-setup.any.conf
echo "<your ssh pubkey>" > etc/authorized_keys # required for SSH after boot
export WIFI_SSID="..." WIFI_PASSWORD="..." # password must be >= 8 chars
```

`init-config` is idempotent — re-running it never overwrites files you've
edited. The files it creates are gitignored by default; commit them only if
you want your local defaults tracked.

### QEMU loop

```sh
make qemu # make-image + populate-boot-qemu + populate-config-qemu
make launch # boots in QEMU; SSH on localhost:5022 once up (Ctrl-a x to quit)
make clean # wipe dist/
```

### Real hardware (SD card)

```sh
export SD_DEV=/dev/sdX # actual SD reader; NVMe is refused on purpose
make sdcard # populate-boot-sd + populate-config-sd
```

### Pinning the Alpine release

`etc/alpine.lock` (generated by `make refresh-lock`) pins the Alpine
tarball name + sha256 and the `raspberrypi/firmware` commit used to fetch
DTBs. Builds against a given commit are bit-for-bit reproducible. Without
a lock file the script floats `latest-stable` and skips checksum
verification — same behavior as before locks existed.

```sh
make refresh-lock # bump lock to current latest-stable; commit the result
```

### Linting

```sh
make lint # shellcheck across scripts/ and etc/
```

CI runs the same check on every push and PR
(`.github/workflows/lint.yaml`).

### Suffix convention

Files under `etc/pre-network.d/`, `etc/unattended.exec.d/`, and
`etc/unattended.conf.d/` are selected by filename suffix:

- `*.any.<ext>` — installed for both QEMU and real-hardware builds
- `*.qemu.<ext>` — QEMU only
- `*.rpi.<ext>` — real hardware only

---

## Installing swimctl on a running Pi

Once you have a Pi running (built with the above, or any other RPi image),
these steps install the swimctl Node-RED app on top of k3s.

### Prepare the Raspberry Pi

> The paths below assume Raspberry Pi OS. On the Alpine image produced by
> this repo the cmdline is at the root of the FAT partition (`/cmdline.txt`)
> rather than `/boot/firmware/cmdline.txt`.

* Enable cgroups:

```
Expand All @@ -23,10 +115,10 @@ A swimming pool automation controller
sudo raspi-config nonint do_i2c 0
```


### Install k3s using k3sup (ketchup)

From a machine with access to the Pi:

```
export hostname='' # set hostname of Pi

Expand All @@ -50,15 +142,20 @@ kubectl --context "${hostname}" create secret generic swimctl \

Be sure to replace the placeholder variables with the correct values.

### Apply Static Manifests

## Apply Static Manifests

k --context "${hostname}" apply -f .

```
kubectl --context "${hostname}" apply -f ./manifests
```

### Helm Install

```
helm --kube-context "${hostname}" install node-red oci://ghcr.io/schwarzit/charts/node-red --values values.yml
```

### Update

## Update
```
helm --kube-context "${hostname}" upgrade node-red oci://ghcr.io/schwarzit/charts/node-red --values values.yml
```
12 changes: 12 additions & 0 deletions etc/alpine.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# etc/alpine.lock — pinned Alpine RPi build inputs.
# Generated by: ./scripts/alpine-rpi.sh refresh-lock
# Regenerate to pull in newer Alpine releases or firmware.

ALPINE_BRANCH=v3.23
ALPINE_TARBALL_NAME=alpine-rpi-3.23.0-aarch64.tar.gz
ALPINE_TARBALL_SHA256=fbbbb77f7269f4ca4beff01a2c8116f6668f92f6a8cd607c6ef0a9a89715a7a3

# raspberrypi/firmware is fetched via raw.githubusercontent.com, which is
# byte-deterministic per commit sha — the commit itself is the integrity
# guarantee, so no separate sha256 is recorded for the DTB.
RPI_FW_COMMIT=8fce67a9ec5668fb8d42d215c9ec4c199340bf41
Empty file added etc/authorized_keys
Empty file.
137 changes: 137 additions & 0 deletions etc/pre-network.d/10-setup-wifi.qemu.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/bin/sh
# shellcheck shell=dash

# shellcheck source=etc/unattended.lib.sh
. "$OVLPATH/unattended.lib.sh"


init_pre_setup_networking() {
set -x
dmesg | grep -E -i 'usb|cdc|rndis|eth'
lsmod | grep -E 'usbnet|cdc_ether|cdc_subset|rndis_host' || true
modprobe usbnet cdc_ether cdc_subset rndis_host # harmless if already present


# Set up initial network connectivity to retrieve required packages
ip link show
ip link set usb0 up

# continues only if a lease was obtained
udhcpc -i usb0 -n -q -t 5 -T 3

set +x
}


setup_prereqs() {
set -x
ntpd -n -q -p pool.ntp.org
date -u # sanity-check it's roughly correct now

# Temporarily point to HTTP so we can install CA certs. APK still verifies
# package signatures via /etc/apk/keys, but the index could be tampered with
# (e.g. served older vulnerable versions), so we flip back to HTTPS as soon
# as ca-certificates-bundle is in place.
echo 'http://dl-cdn.alpinelinux.org/alpine/latest-stable/main' >/etc/apk/repositories
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

apk update
apk add ca-certificates-bundle

# ensure BusyBox sees the right file (usually created by the pkg already)
ls -l /etc/ssl/cert.pem /etc/ssl/certs/ca-certificates.crt
# if /etc/ssl/cert.pem is missing for some reason:
ln -sf /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem

# Now that we have a trust store, switch to HTTPS for all subsequent apk ops.
echo 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/main' >/etc/apk/repositories
apk update

set +x
}


wait_for_wpa() { # iface timeout
iface="$1"
timeout="${2:-30}" # default: 30s
t=0
while [ "$t" -lt "$timeout" ]; do
state=$(wpa_cli -i "$iface" status 2>/dev/null | awk -F= '/^wpa_state=/{print $2}')
if [ "$state" = "COMPLETED" ]; then
echo "wpa_supplicant: authenticated on $iface"
return 0
fi
sleep 1
t=$((t + 1))
done
echo "wpa_supplicant: timeout waiting for authentication on $iface" >&2
return 1
}


set -x
init_pre_setup_networking
setup_prereqs
# ################################
# Setup wifi hardware simulation #
# ################################

# Add required packages
apk add --force-overwrite --no-cache iproute2-minimal iproute2
apk add --no-cache iw hostapd wpa_supplicant busybox-extras iptables

# Use full iproute2; you've got iproute2-minimal so /sbin/ip is fine
IP=/sbin/ip

# Radios
modprobe mac80211_hwsim radios=2

# “Router/AP” namespace owns wlan0 + usb0
# Take usb0 down and reconfigure it in an isolated network namespace
$IP link set usb0 down


# Make sure radios exist
_have_wlan=0
for _iface in /sys/class/net/wlan[0-9]*; do
[ -e "$_iface" ] && _have_wlan=1 && break
done
if [ "$_have_wlan" -eq 0 ]; then
echo "No wlan* found. Did modprobe mac80211_hwsim radios=2 succeed?"
exit 1
fi

# pick radios (as you already do)
WLAN_LIST="$($IP -o link show | awk -F': ' '{print $2}' | grep -E '^wlan[0-9]+$' | sort)"
AP_WLAN="$(echo "$WLAN_LIST" | sed -n '1p')" # e.g., wlan0
STA_WLAN="$(echo "$WLAN_LIST" | sed -n '2p')" # e.g., wlan1

if [ -z "$AP_WLAN" ] || [ -z "$STA_WLAN" ]; then
echo "Need two wlan* from hwsim; found: $WLAN_LIST"
exit 1
fi

# create namespace, move uplink with ip
$IP netns add ap 2>/dev/null || true
UPLINK="$($IP -o link show | awk -F': ' '{print $2}' \
| awk '/^usb0$/ {print; exit} /^eth[0-9]+$/ && !seen[$0]++ {print; exit}')"
[ -n "$UPLINK" ] || { echo "No uplink (usb0/ethX) found"; exit 1; }

$IP link set "$UPLINK" down
$IP link set "$UPLINK" netns ap

$IP link set "$AP_WLAN" down
AP_PHY="$(basename "$(readlink -f "/sys/class/net/$AP_WLAN/phy80211")")"
iw phy "$AP_PHY" set netns name ap


/sbin/ip netns exec ap /bin/sh -xc ". $OVLPATH/unattended.lib.sh; config_sim_ap $IP $OVLPATH"
$IP netns exec ap $IP -br link | sed 's/^/ ap : /'


$IP link set "$STA_WLAN" up
install -m600 "$OVLPATH/wpa_supplicant.conf" /etc/wpa_supplicant/wpa_supplicant.conf
# rc-service wpa_supplicant restart

_logger "hwsim up: STA (root ns, wlan1) ↔ AP (ap ns, wlan0) with NAT via usb0."

set +x
5 changes: 5 additions & 0 deletions etc/unattended.conf.d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Configuration Files for Headless Setup

Place configuration files here. They will be ignored by Git.

foo
Loading
Loading