ops: add CI boot-image releases and Unifi PXE rollout
This commit is contained in:
parent
043b1730dc
commit
be26313225
9 changed files with 720 additions and 0 deletions
276
.forgejo/workflows/deploy-runner-images.yml
Normal file
276
.forgejo/workflows/deploy-runner-images.yml
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
name: deploy-runner-images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [boot-v*]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: "Release tag override (manual runs only)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
publish_release:
|
||||||
|
description: "Publish artifacts to Forgejo release (true/false)"
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
build_x86_64_netboot:
|
||||||
|
description: "Build x86_64 netboot tarball (true/false)"
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
build_x86_64_iso:
|
||||||
|
description: "Build x86_64 installer ISO (true/false)"
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: runner-image-deploy-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
if: ${{ github.server_url != 'https://codeberg.org' }}
|
||||||
|
runs-on: codeberg-medium-lazy
|
||||||
|
steps:
|
||||||
|
- name: Fetch source (no git required)
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
|
||||||
|
echo "error: missing github.token"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "error: curl is required"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! command -v tar >/dev/null 2>&1; then
|
||||||
|
echo "error: tar is required"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITHUB_SHA:-}" ]]; then
|
||||||
|
echo "error: missing GITHUB_SHA"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITHUB_SERVER_URL:-}" ]]; then
|
||||||
|
echo "error: missing GITHUB_SERVER_URL"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then
|
||||||
|
echo "error: missing GITHUB_REPOSITORY"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf .repo
|
||||||
|
mkdir -p .repo
|
||||||
|
curl -fsSL -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz?rev=${GITHUB_SHA}" \
|
||||||
|
-o .repo/src.tgz
|
||||||
|
tar -xzf .repo/src.tgz -C .repo --strip-components=1
|
||||||
|
rm -f .repo/src.tgz
|
||||||
|
|
||||||
|
- name: Bootstrap Nix
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! command -v nix >/dev/null 2>&1; then
|
||||||
|
curl -fsSL https://nixos.org/nix/install -o /tmp/install-nix.sh
|
||||||
|
sh /tmp/install-nix.sh --no-daemon --yes
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$HOME/.nix-profile/etc/profile.d/nix.sh" ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "$HOME/.nix-profile/etc/profile.d/nix.sh"
|
||||||
|
fi
|
||||||
|
if [[ -d "$HOME/.nix-profile/bin" ]]; then
|
||||||
|
echo "PATH=$HOME/.nix-profile/bin:$PATH" >> "$GITHUB_ENV"
|
||||||
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
|
fi
|
||||||
|
nix --version
|
||||||
|
|
||||||
|
- name: Resolve build plan
|
||||||
|
id: plan
|
||||||
|
env:
|
||||||
|
INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||||
|
INPUT_PUBLISH_RELEASE: ${{ github.event.inputs.publish_release }}
|
||||||
|
INPUT_BUILD_X86_64_NETBOOT: ${{ github.event.inputs.build_x86_64_netboot }}
|
||||||
|
INPUT_BUILD_X86_64_ISO: ${{ github.event.inputs.build_x86_64_iso }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
bool_norm() {
|
||||||
|
local raw
|
||||||
|
raw="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
case "${raw}" in
|
||||||
|
''|true|1|yes|y|on) echo "true" ;;
|
||||||
|
false|0|no|n|off) echo "false" ;;
|
||||||
|
*)
|
||||||
|
echo "error: invalid boolean value '${1}'" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
short_sha="${GITHUB_SHA:0:12}"
|
||||||
|
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
|
||||||
|
release_tag="${GITHUB_REF#refs/tags/}"
|
||||||
|
else
|
||||||
|
release_tag="${INPUT_RELEASE_TAG:-}"
|
||||||
|
if [[ -z "${release_tag}" ]]; then
|
||||||
|
release_tag="boot-${short_sha}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "${release_tag}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||||
|
echo "error: release tag contains unsupported characters: ${release_tag}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
publish_release="$(bool_norm "${INPUT_PUBLISH_RELEASE:-true}")"
|
||||||
|
build_x86_64_netboot="$(bool_norm "${INPUT_BUILD_X86_64_NETBOOT:-true}")"
|
||||||
|
build_x86_64_iso="$(bool_norm "${INPUT_BUILD_X86_64_ISO:-true}")"
|
||||||
|
|
||||||
|
if [[ "${build_x86_64_netboot}" != "true" && "${build_x86_64_iso}" != "true" ]]; then
|
||||||
|
echo "error: at least one image build must be enabled" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
artifact_suffix="${short_sha}-${GITHUB_RUN_NUMBER:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
|
||||||
|
echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "publish_release=${publish_release}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_x86_64_netboot=${build_x86_64_netboot}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_x86_64_iso=${build_x86_64_iso}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "artifact_suffix=${artifact_suffix}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build runner boot images
|
||||||
|
env:
|
||||||
|
BUILD_X86_64_NETBOOT: ${{ steps.plan.outputs.build_x86_64_netboot }}
|
||||||
|
BUILD_X86_64_ISO: ${{ steps.plan.outputs.build_x86_64_iso }}
|
||||||
|
ARTIFACT_SUFFIX: ${{ steps.plan.outputs.artifact_suffix }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd .repo
|
||||||
|
|
||||||
|
if [[ -f "$HOME/.nix-profile/etc/profile.d/nix.sh" ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "$HOME/.nix-profile/etc/profile.d/nix.sh"
|
||||||
|
fi
|
||||||
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
|
|
||||||
|
artifacts_dir="$PWD/.artifacts"
|
||||||
|
rm -rf "${artifacts_dir}"
|
||||||
|
mkdir -p "${artifacts_dir}"
|
||||||
|
|
||||||
|
nix_args=(--accept-flake-config --extra-experimental-features "nix-command flakes")
|
||||||
|
|
||||||
|
if [[ "${BUILD_X86_64_NETBOOT}" == "true" ]]; then
|
||||||
|
nix build "${nix_args[@]}" \
|
||||||
|
.#nixosConfigurations.ec-runner-x86_64-netboot.config.system.build.netboot \
|
||||||
|
-o result-netboot-x86_64
|
||||||
|
tar -C result-netboot-x86_64 \
|
||||||
|
-czf "${artifacts_dir}/ec-runner-x86_64-netboot-${ARTIFACT_SUFFIX}.tar.gz" \
|
||||||
|
kernel initrd netboot.ipxe
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${BUILD_X86_64_ISO}" == "true" ]]; then
|
||||||
|
nix build "${nix_args[@]}" \
|
||||||
|
.#nixosConfigurations.ec-runner-x86_64-iso.config.system.build.isoImage \
|
||||||
|
-o result-iso-x86_64
|
||||||
|
iso_source=""
|
||||||
|
if [[ -f result-iso-x86_64 ]]; then
|
||||||
|
iso_source="result-iso-x86_64"
|
||||||
|
else
|
||||||
|
iso_source="$(find -L result-iso-x86_64 -type f -name '*.iso' | head -n 1 || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${iso_source}" ]]; then
|
||||||
|
echo "error: could not locate ISO output from result-iso-x86_64" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
cp -f "${iso_source}" "${artifacts_dir}/ec-runner-x86_64-iso-${ARTIFACT_SUFFIX}.iso"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! find "${artifacts_dir}" -maxdepth 1 -type f | grep -q .; then
|
||||||
|
echo "error: no image artifacts were produced" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "${artifacts_dir}"
|
||||||
|
sha256sum -- * > SHA256SUMS.txt
|
||||||
|
ls -lh
|
||||||
|
)
|
||||||
|
|
||||||
|
- name: Publish artifacts to Forgejo release
|
||||||
|
if: ${{ steps.plan.outputs.publish_release == 'true' }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
RELEASE_TAG: ${{ steps.plan.outputs.release_tag }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd .repo
|
||||||
|
|
||||||
|
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
|
||||||
|
echo "error: missing github.token"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITHUB_SERVER_URL:-}" ]]; then
|
||||||
|
echo "error: missing GITHUB_SERVER_URL"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then
|
||||||
|
echo "error: missing GITHUB_REPOSITORY"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_base="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
release_json="$(curl -fsSL \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
"${api_base}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -z "${release_json}" ]]; then
|
||||||
|
payload="$(cat <<JSON
|
||||||
|
{
|
||||||
|
"tag_name": "${RELEASE_TAG}",
|
||||||
|
"name": "Boot images ${RELEASE_TAG}",
|
||||||
|
"body": "Automated runner boot image build from ${GITHUB_SHA}.",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)"
|
||||||
|
release_json="$(curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
"${api_base}/releases" \
|
||||||
|
-d "${payload}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_id=""
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
release_id="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"${release_json}" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${release_id}" ]]; then
|
||||||
|
release_id="$(printf '%s' "${release_json}" \
|
||||||
|
| sed -nE 's/.*"id"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' \
|
||||||
|
| head -n 1)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${release_id}" ]]; then
|
||||||
|
echo "error: failed to resolve release id for ${RELEASE_TAG}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
for asset_path in .artifacts/*; do
|
||||||
|
[[ -f "${asset_path}" ]] || continue
|
||||||
|
asset_name="$(basename "${asset_path}")"
|
||||||
|
curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H "content-type: application/octet-stream" \
|
||||||
|
--data-binary @"${asset_path}" \
|
||||||
|
"${api_base}/releases/${release_id}/assets?name=${asset_name}" >/dev/null
|
||||||
|
echo "uploaded: ${asset_name}"
|
||||||
|
done
|
||||||
|
|
@ -53,6 +53,12 @@ Git hosting topology:
|
||||||
cat docs/GIT_HOSTING.md
|
cat docs/GIT_HOSTING.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
NUC PXE rollout (Unifi + ProxyDHCP):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat docs/NUC_UNIFI_NETBOOT.md
|
||||||
|
```
|
||||||
|
|
||||||
## WebTransport Watch (MoQ)
|
## WebTransport Watch (MoQ)
|
||||||
|
|
||||||
Publish (node -> Cloudflare relay):
|
Publish (node -> Cloudflare relay):
|
||||||
|
|
|
||||||
102
docs/NUC_UNIFI_NETBOOT.md
Normal file
102
docs/NUC_UNIFI_NETBOOT.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# NUC Fleet Netboot (Unifi + ProxyDHCP)
|
||||||
|
|
||||||
|
This runbook provisions x86_64 NUCs from runner netboot artifacts without USB image flashing.
|
||||||
|
|
||||||
|
It uses:
|
||||||
|
|
||||||
|
- Unifi DHCP for IP leases.
|
||||||
|
- Local `dnsmasq` ProxyDHCP for PXE/iPXE bootfile logic.
|
||||||
|
- Local HTTP + TFTP service for boot artifacts.
|
||||||
|
|
||||||
|
## Why ProxyDHCP
|
||||||
|
|
||||||
|
iPXE commonly needs two boot stages:
|
||||||
|
|
||||||
|
1. firmware PXE -> `ipxe.efi`
|
||||||
|
2. iPXE -> `netboot.ipxe`
|
||||||
|
|
||||||
|
If DHCP always returns `ipxe.efi`, clients can loop forever. ProxyDHCP handles stage-specific boot responses cleanly while leaving Unifi as the DHCP lease server.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Linux boot server on the same VLAN/L2 domain as the NUCs.
|
||||||
|
- Unifi network with normal DHCP enabled.
|
||||||
|
- Local DNS record on that VLAN: `boot.every.channel -> <boot-server-ip>`.
|
||||||
|
- `curl`, `tar`, `python3`, `dnsmasq` installed on the boot server.
|
||||||
|
- Runner netboot artifact already published to Forgejo Releases (or available as a local tarball).
|
||||||
|
|
||||||
|
## 1) Stage artifacts
|
||||||
|
|
||||||
|
From repository root on the boot server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/netboot-stage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional inputs:
|
||||||
|
|
||||||
|
- `EVERY_CHANNEL_NETBOOT_RELEASE_TAG=boot-v2026.02.28`
|
||||||
|
- `EVERY_CHANNEL_NETBOOT_TARBALL=/path/to/ec-runner-x86_64-netboot-....tar.gz`
|
||||||
|
- `EVERY_CHANNEL_FORGE_TOKEN=<token>` for private releases
|
||||||
|
- `EVERY_CHANNEL_NETBOOT_HOSTNAME=boot.every.channel`
|
||||||
|
|
||||||
|
This stages:
|
||||||
|
|
||||||
|
- `tmp/netboot/http/{kernel,initrd,netboot.ipxe}`
|
||||||
|
- `tmp/netboot/tftp/ipxe.efi`
|
||||||
|
|
||||||
|
## 2) Serve HTTP + TFTP + ProxyDHCP
|
||||||
|
|
||||||
|
Example (replace values for your VLAN):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo \
|
||||||
|
EVERY_CHANNEL_NETBOOT_LISTEN_IP=10.20.30.2 \
|
||||||
|
EVERY_CHANNEL_NETBOOT_INTERFACE=eth0 \
|
||||||
|
EVERY_CHANNEL_NETBOOT_PROXY_SUBNET=10.20.30.0/24 \
|
||||||
|
EVERY_CHANNEL_NETBOOT_HOSTNAME=boot.every.channel \
|
||||||
|
./scripts/netboot-serve.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Keep this process running during provisioning.
|
||||||
|
- Do not set Unifi DHCP bootfile options while this proxy mode is active.
|
||||||
|
- Ensure `boot.every.channel` resolves to the boot server IP from NUC clients.
|
||||||
|
|
||||||
|
## 3) Unifi / NUC settings
|
||||||
|
|
||||||
|
Unifi:
|
||||||
|
|
||||||
|
- Keep DHCP enabled for the provisioning VLAN.
|
||||||
|
- Leave DHCP boot/TFTP overrides unset when using `netboot-serve.sh`.
|
||||||
|
- Create/verify local DNS host override: `boot.every.channel -> <boot-server-ip>`.
|
||||||
|
|
||||||
|
NUC BIOS:
|
||||||
|
|
||||||
|
- Enable UEFI network boot (IPv4 PXE).
|
||||||
|
- Disable Legacy/CSM if possible.
|
||||||
|
- Put network boot before disk for first install cycle.
|
||||||
|
|
||||||
|
## 4) Provision the fleet
|
||||||
|
|
||||||
|
1. Boot each NUC on the provisioning VLAN.
|
||||||
|
2. PXE will chainload into iPXE and then runner `netboot.ipxe`.
|
||||||
|
3. Complete install/bootstrap flow on each node.
|
||||||
|
4. After successful install, switch boot order back to local disk.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Symptom: iPXE loop (`ipxe.efi` repeatedly)
|
||||||
|
- Cause: static DHCP bootfile without iPXE-aware logic.
|
||||||
|
- Fix: use ProxyDHCP flow (`netboot-serve.sh`) or set conditional DHCP rules.
|
||||||
|
- Symptom: NUC gets IP but never downloads boot artifacts
|
||||||
|
- Verify firewall allows UDP 67/68, UDP 69, and TCP 8080 between NUCs and boot server.
|
||||||
|
- Symptom: no `dnsmasq` offers seen
|
||||||
|
- Verify `EVERY_CHANNEL_NETBOOT_INTERFACE` and `EVERY_CHANNEL_NETBOOT_PROXY_SUBNET`.
|
||||||
|
|
||||||
|
## Security / networking
|
||||||
|
|
||||||
|
- Tailscale is not required for provisioning.
|
||||||
|
- Keep the provisioning VLAN isolated from regular clients.
|
||||||
|
- Stop `netboot-serve.sh` when rollout is complete.
|
||||||
|
|
@ -50,6 +50,36 @@ Build an aarch64 SD image:
|
||||||
nix build .#nixosConfigurations.ec-runner-aarch64-sdimage.config.system.build.sdImage
|
nix build .#nixosConfigurations.ec-runner-aarch64-sdimage.config.system.build.sdImage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## CI Deploy (Forgejo Releases)
|
||||||
|
|
||||||
|
Boot images can be built and published from CI via:
|
||||||
|
|
||||||
|
- `.forgejo/workflows/deploy-runner-images.yml`
|
||||||
|
|
||||||
|
Triggers:
|
||||||
|
|
||||||
|
- Manual: `workflow_dispatch`
|
||||||
|
- Tags: `boot-v*` (for example `boot-v2026.02.28`)
|
||||||
|
|
||||||
|
Manual inputs (all optional):
|
||||||
|
|
||||||
|
- `release_tag` (defaults to `boot-<short-sha>`)
|
||||||
|
- `publish_release` (`true`/`false`, default `true`)
|
||||||
|
- `build_x86_64_netboot` (`true`/`false`, default `true`)
|
||||||
|
- `build_x86_64_iso` (`true`/`false`, default `true`)
|
||||||
|
|
||||||
|
Published assets are attached to the resolved Forgejo release tag and include:
|
||||||
|
|
||||||
|
- x86_64 netboot bundle (`kernel`, `initrd`, `netboot.ipxe`) as `.tar.gz`
|
||||||
|
- x86_64 installer `.iso`
|
||||||
|
- `SHA256SUMS.txt`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- CI image publish is disabled on the Codeberg mirror host.
|
||||||
|
- Current CI scope is x86_64 targets; aarch64 image builds remain local/manual unless an aarch64-capable runner is added.
|
||||||
|
- For multi-NUC PXE rollout on Unifi networks, use `docs/NUC_UNIFI_NETBOOT.md`.
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
After building, artifacts will be in `./result` (a symlink into the Nix store).
|
After building, artifacts will be in `./result` (a symlink into the Nix store).
|
||||||
|
|
|
||||||
35
evolution/proposals/ECP-0081-ci-boot-image-deploy.md
Normal file
35
evolution/proposals/ECP-0081-ci-boot-image-deploy.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# ECP-0081: CI Boot Image Deployment to Forgejo Releases
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Runner boot artifacts (netboot/ISO/SD) are currently built by hand from local Linux/Nix environments. That slows repeatable infra rollout and weakens traceability from commit to runnable images.
|
||||||
|
|
||||||
|
The constitution requires infrastructure definitions to live in-repo and stay independently operable. Boot image publication should follow the same Forgejo-primary CI model used for web deploys, while mirror hosts remain distribution-only.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. Add a dedicated Forgejo Actions workflow to build runner boot images from flake outputs in CI.
|
||||||
|
2. Trigger this workflow on:
|
||||||
|
- manual dispatch (`workflow_dispatch`), and
|
||||||
|
- release-style tags (`boot-v*`).
|
||||||
|
3. Publish built artifacts to Forgejo Releases on the primary host using repository-scoped API calls and the workflow token.
|
||||||
|
4. Exclude Codeberg mirror runs using the existing server guard (`github.server_url != 'https://codeberg.org'`).
|
||||||
|
5. Start with x86_64 image targets in CI (`netboot`, `iso`) to keep runtime/runner requirements explicit and reversible.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Keep boot image builds fully manual. Rejected because operator discipline alone does not provide consistent provenance.
|
||||||
|
- Push images to external object storage first. Rejected for now because Forgejo Releases are already part of the controlled primary platform.
|
||||||
|
- Build all architectures in one CI pass immediately. Rejected because runner architecture availability is not guaranteed and would make initial rollout brittle.
|
||||||
|
|
||||||
|
## Rollout / teardown plan
|
||||||
|
|
||||||
|
- Rollout:
|
||||||
|
- merge workflow + docs,
|
||||||
|
- run a manual dispatch to verify artifact publication,
|
||||||
|
- optionally create `boot-v*` tags for versioned image drops.
|
||||||
|
- Teardown:
|
||||||
|
- disable or delete the boot-image workflow,
|
||||||
|
- continue using local/manual `nix build` paths from `docs/RUNNER_IMAGES.md`.
|
||||||
36
evolution/proposals/ECP-0082-unifi-pxe-runner-rollout.md
Normal file
36
evolution/proposals/ECP-0082-unifi-pxe-runner-rollout.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# ECP-0082: Unifi PXE Rollout Path for Runner Images
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Runner netboot artifacts now publish from CI, but there is no repository-native operating path for fleet provisioning on common prosumer networks (for example Unifi VLANs).
|
||||||
|
|
||||||
|
Unifi DHCP can expose next-server/bootfile settings, but iPXE chainloading often requires conditional bootfile behavior to avoid loops (`ipxe.efi` first stage, script second stage). Not all controller setups expose that cleanly.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. Add first-party scripts for local netboot staging and serving:
|
||||||
|
- stage x86_64 netboot artifacts from Forgejo Releases (or local tarball),
|
||||||
|
- stage iPXE UEFI binary for TFTP,
|
||||||
|
- run HTTP + TFTP + ProxyDHCP via `dnsmasq` for deterministic chainloading.
|
||||||
|
2. Keep Unifi DHCP as the IP authority; use ProxyDHCP only to supply bootfile logic.
|
||||||
|
3. Document a concrete NUC rollout sequence for same-VLAN provisioning.
|
||||||
|
4. Keep dependencies minimal (`curl`, `tar`, `python3`, `dnsmasq`) and avoid requiring image flashing workflows.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Require Unifi DHCP conditional iPXE rules. Rejected because controller capabilities vary and misconfiguration risks boot loops.
|
||||||
|
- Keep manual USB-only provisioning. Rejected because it increases labor for multi-node rollout.
|
||||||
|
- Add a heavy provisioning stack (MAAS/Foreman/Kickstart integration). Rejected as too much operational overhead for current scale.
|
||||||
|
|
||||||
|
## Rollout / teardown plan
|
||||||
|
|
||||||
|
- Rollout:
|
||||||
|
- merge scripts/docs,
|
||||||
|
- run `netboot-stage` on the boot server,
|
||||||
|
- run `netboot-serve` on the NUC VLAN and boot hosts via PXE.
|
||||||
|
- Teardown:
|
||||||
|
- stop `netboot-serve`,
|
||||||
|
- remove staged artifacts under `tmp/netboot`,
|
||||||
|
- continue with ISO+USB fallback path.
|
||||||
6
justfile
6
justfile
|
|
@ -12,6 +12,12 @@ git-hosting:
|
||||||
git-mirrors:
|
git-mirrors:
|
||||||
./scripts/git-push-mirrors.sh
|
./scripts/git-push-mirrors.sh
|
||||||
|
|
||||||
|
netboot-stage:
|
||||||
|
./scripts/netboot-stage.sh
|
||||||
|
|
||||||
|
netboot-serve:
|
||||||
|
./scripts/netboot-serve.sh
|
||||||
|
|
||||||
test-core:
|
test-core:
|
||||||
cargo test -p ec-core -p ec-crypto -p ec-moq -p ec-iroh -p ec-linux-iptv
|
cargo test -p ec-core -p ec-crypto -p ec-moq -p ec-iroh -p ec-linux-iptv
|
||||||
|
|
||||||
|
|
|
||||||
99
scripts/netboot-serve.sh
Executable file
99
scripts/netboot-serve.sh
Executable file
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${root}"
|
||||||
|
|
||||||
|
netboot_root="${EVERY_CHANNEL_NETBOOT_ROOT:-tmp/netboot}"
|
||||||
|
http_dir="${netboot_root}/http"
|
||||||
|
tftp_dir="${netboot_root}/tftp"
|
||||||
|
|
||||||
|
listen_ip="${EVERY_CHANNEL_NETBOOT_LISTEN_IP:-}"
|
||||||
|
interface_name="${EVERY_CHANNEL_NETBOOT_INTERFACE:-}"
|
||||||
|
proxy_subnet="${EVERY_CHANNEL_NETBOOT_PROXY_SUBNET:-}"
|
||||||
|
netboot_hostname="${EVERY_CHANNEL_NETBOOT_HOSTNAME:-}"
|
||||||
|
http_port="${EVERY_CHANNEL_NETBOOT_HTTP_PORT:-8080}"
|
||||||
|
dnsmasq_port="${EVERY_CHANNEL_NETBOOT_DNS_PORT:-0}"
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
local name="$1"
|
||||||
|
if ! command -v "${name}" >/dev/null 2>&1; then
|
||||||
|
echo "error: required command not found: ${name}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
need_cmd dnsmasq
|
||||||
|
need_cmd python3
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "error: netboot-serve requires root (TFTP + ProxyDHCP ports)." >&2
|
||||||
|
echo "hint: run with sudo and pass env vars, for example:" >&2
|
||||||
|
echo " sudo EVERY_CHANNEL_NETBOOT_LISTEN_IP=10.20.30.2 EVERY_CHANNEL_NETBOOT_INTERFACE=eth0 EVERY_CHANNEL_NETBOOT_PROXY_SUBNET=10.20.30.0/24 EVERY_CHANNEL_NETBOOT_HOSTNAME=boot.every.channel ./scripts/netboot-serve.sh" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${listen_ip}" ]]; then
|
||||||
|
echo "error: set EVERY_CHANNEL_NETBOOT_LISTEN_IP (boot server IP on NUC VLAN)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${interface_name}" ]]; then
|
||||||
|
echo "error: set EVERY_CHANNEL_NETBOOT_INTERFACE (interface on NUC VLAN)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${proxy_subnet}" ]]; then
|
||||||
|
echo "error: set EVERY_CHANNEL_NETBOOT_PROXY_SUBNET (for example 10.20.30.0/24)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ -z "${netboot_hostname}" ]]; then
|
||||||
|
netboot_hostname="${listen_ip}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for required in "${http_dir}/kernel" "${http_dir}/initrd" "${http_dir}/netboot.ipxe" "${tftp_dir}/ipxe.efi"; do
|
||||||
|
if [[ ! -f "${required}" ]]; then
|
||||||
|
echo "error: missing required staged file: ${required}" >&2
|
||||||
|
echo "hint: run ./scripts/netboot-stage.sh first" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
run_dir="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${http_pid:-}" ]] && kill -0 "${http_pid}" >/dev/null 2>&1; then
|
||||||
|
kill "${http_pid}" >/dev/null 2>&1 || true
|
||||||
|
wait "${http_pid}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "${run_dir}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
cat > "${run_dir}/dnsmasq.conf" <<EOF
|
||||||
|
port=${dnsmasq_port}
|
||||||
|
bind-interfaces
|
||||||
|
interface=${interface_name}
|
||||||
|
listen-address=${listen_ip}
|
||||||
|
log-dhcp
|
||||||
|
enable-tftp
|
||||||
|
tftp-root=${tftp_dir}
|
||||||
|
dhcp-range=${proxy_subnet},proxy
|
||||||
|
dhcp-userclass=set:ipxe,iPXE
|
||||||
|
dhcp-match=set:efi64,option:client-arch,7
|
||||||
|
dhcp-match=set:efi64,option:client-arch,9
|
||||||
|
dhcp-option=66,${netboot_hostname}
|
||||||
|
dhcp-boot=tag:!ipxe,tag:efi64,ipxe.efi
|
||||||
|
dhcp-boot=tag:ipxe,tag:efi64,http://${netboot_hostname}:${http_port}/netboot.ipxe
|
||||||
|
dhcp-boot=tag:!ipxe,ipxe.efi
|
||||||
|
dhcp-boot=tag:ipxe,http://${netboot_hostname}:${http_port}/netboot.ipxe
|
||||||
|
EOF
|
||||||
|
|
||||||
|
python3 -m http.server "${http_port}" --bind "${listen_ip}" --directory "${http_dir}" >/tmp/every-channel-netboot-http.log 2>&1 &
|
||||||
|
http_pid="$!"
|
||||||
|
|
||||||
|
echo "ok: HTTP serving ${http_dir} on http://${listen_ip}:${http_port}/"
|
||||||
|
echo "ok: advertised netboot host: ${netboot_hostname}"
|
||||||
|
echo "ok: TFTP serving ${tftp_dir} on ${listen_ip}:69"
|
||||||
|
echo "ok: ProxyDHCP active for ${proxy_subnet} on interface ${interface_name}"
|
||||||
|
echo "ok: Use normal Unifi DHCP for IP assignment; do not configure Unifi DHCP bootfile while proxy mode is active."
|
||||||
|
echo
|
||||||
|
echo "Press Ctrl+C to stop."
|
||||||
|
dnsmasq --no-daemon --conf-file="${run_dir}/dnsmasq.conf"
|
||||||
130
scripts/netboot-stage.sh
Executable file
130
scripts/netboot-stage.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${root}"
|
||||||
|
|
||||||
|
forge_host="${EVERY_CHANNEL_FORGE_HOST:-https://forge.every.channel}"
|
||||||
|
forge_repo="${EVERY_CHANNEL_FORGE_REPO:-every-channel/every.channel}"
|
||||||
|
release_tag="${EVERY_CHANNEL_NETBOOT_RELEASE_TAG:-}"
|
||||||
|
local_tarball="${EVERY_CHANNEL_NETBOOT_TARBALL:-}"
|
||||||
|
out_root="${EVERY_CHANNEL_NETBOOT_ROOT:-tmp/netboot}"
|
||||||
|
ipxe_efi_url="${EVERY_CHANNEL_IPXE_EFI_URL:-https://boot.ipxe.org/snponly.efi}"
|
||||||
|
netboot_hostname="${EVERY_CHANNEL_NETBOOT_HOSTNAME:-boot.every.channel}"
|
||||||
|
http_port="${EVERY_CHANNEL_NETBOOT_HTTP_PORT:-8080}"
|
||||||
|
token="${EVERY_CHANNEL_FORGE_TOKEN:-${FORGE_TOKEN:-${CODEBERG_TOKEN:-}}}"
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
local name="$1"
|
||||||
|
if ! command -v "${name}" >/dev/null 2>&1; then
|
||||||
|
echo "error: required command not found: ${name}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
need_cmd curl
|
||||||
|
need_cmd tar
|
||||||
|
need_cmd python3
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${tmp_dir}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
archive_path="${tmp_dir}/netboot.tar.gz"
|
||||||
|
release_asset_url=""
|
||||||
|
|
||||||
|
if [[ -n "${local_tarball}" ]]; then
|
||||||
|
if [[ ! -f "${local_tarball}" ]]; then
|
||||||
|
echo "error: netboot tarball not found: ${local_tarball}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
cp -f "${local_tarball}" "${archive_path}"
|
||||||
|
else
|
||||||
|
api_base="${forge_host%/}/api/v1/repos/${forge_repo}"
|
||||||
|
release_endpoint="${api_base}/releases/latest"
|
||||||
|
if [[ -n "${release_tag}" ]]; then
|
||||||
|
release_endpoint="${api_base}/releases/tags/${release_tag}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
auth_args=()
|
||||||
|
if [[ -n "${token}" ]]; then
|
||||||
|
auth_args=(-H "Authorization: token ${token}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_json="${tmp_dir}/release.json"
|
||||||
|
curl -fsSL "${auth_args[@]}" "${release_endpoint}" -o "${release_json}"
|
||||||
|
|
||||||
|
release_asset_url="$(
|
||||||
|
python3 - "${release_json}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
assets = data.get("assets", [])
|
||||||
|
candidates = []
|
||||||
|
for asset in assets:
|
||||||
|
name = asset.get("name", "")
|
||||||
|
if name.startswith("ec-runner-x86_64-netboot-") and name.endswith(".tar.gz"):
|
||||||
|
candidates.append(asset)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Pick newest by release ordering if API already sorted; otherwise prefer largest id.
|
||||||
|
chosen = sorted(candidates, key=lambda x: x.get("id", 0))[-1]
|
||||||
|
print(chosen.get("browser_download_url", ""))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "${release_asset_url}" ]]; then
|
||||||
|
echo "error: unable to find x86_64 netboot asset in release" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL "${auth_args[@]}" -o "${archive_path}" "${release_asset_url}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
http_dir="${out_root}/http"
|
||||||
|
tftp_dir="${out_root}/tftp"
|
||||||
|
rm -rf "${http_dir}"
|
||||||
|
mkdir -p "${http_dir}" "${tftp_dir}"
|
||||||
|
|
||||||
|
tar -xzf "${archive_path}" -C "${http_dir}"
|
||||||
|
|
||||||
|
for required in kernel initrd netboot.ipxe; do
|
||||||
|
if [[ ! -f "${http_dir}/${required}" ]]; then
|
||||||
|
echo "error: extracted netboot bundle is missing ${required}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -fsSL -o "${tftp_dir}/ipxe.efi" "${ipxe_efi_url}"
|
||||||
|
cp -f "${http_dir}/netboot.ipxe" "${tftp_dir}/netboot.ipxe"
|
||||||
|
|
||||||
|
cat > "${tftp_dir}/bootstrap.ipxe" <<'EOF'
|
||||||
|
#!ipxe
|
||||||
|
dhcp
|
||||||
|
chain http://__NETBOOT_HOST__:__HTTP_PORT__/netboot.ipxe
|
||||||
|
EOF
|
||||||
|
sed -i.bak \
|
||||||
|
-e "s#__NETBOOT_HOST__#${netboot_hostname}#g" \
|
||||||
|
-e "s#__HTTP_PORT__#${http_port}#g" \
|
||||||
|
"${tftp_dir}/bootstrap.ipxe"
|
||||||
|
rm -f "${tftp_dir}/bootstrap.ipxe.bak"
|
||||||
|
|
||||||
|
echo "ok: staged netboot content"
|
||||||
|
echo "ok: http root: ${http_dir}"
|
||||||
|
echo "ok: tftp root: ${tftp_dir}"
|
||||||
|
echo "ok: netboot hostname: ${netboot_hostname}"
|
||||||
|
echo "ok: netboot http port: ${http_port}"
|
||||||
|
if [[ -n "${release_asset_url}" ]]; then
|
||||||
|
echo "ok: source asset: ${release_asset_url}"
|
||||||
|
else
|
||||||
|
echo "ok: source asset: ${local_tarball}"
|
||||||
|
fi
|
||||||
|
echo "hint: run sudo ./scripts/netboot-serve.sh to expose HTTP+TFTP+ProxyDHCP"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue