feat: add distribution and fleet management — CI/CD, OCI, metrics, ARM64 (Phase 5)
Some checks failed
CI / Go Tests (push) Has been cancelled
CI / Build Go Binaries (amd64, linux, linux-amd64) (push) Has been cancelled
CI / Build Go Binaries (arm64, linux, linux-arm64) (push) Has been cancelled
CI / Shellcheck (push) Has been cancelled

- Gitea Actions CI pipeline: Go tests, build, shellcheck on push/PR
- Gitea Actions release pipeline: full build + artifact upload on version tags
- OCI container image builder for registry-based OS distribution
- Zero-dependency Prometheus metrics endpoint (kubesolo_os_info, boot,
  memory, update status) with 10 tests
- USB provisioning tool for air-gapped deployments with cloud-init injection
- ARM64 cross-compilation support (TARGET_ARCH env var + build-cross.sh)
- Updated build scripts to accept TARGET_ARCH for both amd64 and arm64
- New Makefile targets: oci-image, build-cross

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 11:36:53 -06:00
parent 49a37e30e8
commit 456aa8eb5b
12 changed files with 1206 additions and 7 deletions

View File

@@ -1,15 +1,19 @@
#!/bin/bash
# build-cloudinit.sh — Compile the cloud-init binary as a static Linux binary
#
# Environment:
# TARGET_ARCH Target architecture (default: amd64, also supports: arm64)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CACHE_DIR="${CACHE_DIR:-$PROJECT_ROOT/build/cache}"
CLOUDINIT_SRC="$PROJECT_ROOT/cloud-init"
TARGET_ARCH="${TARGET_ARCH:-amd64}"
OUTPUT="$CACHE_DIR/kubesolo-cloudinit"
echo "==> Building cloud-init binary..."
echo "==> Building cloud-init binary (linux/$TARGET_ARCH)..."
if ! command -v go >/dev/null 2>&1; then
echo "ERROR: Go is not installed. Install Go 1.22+ to build cloud-init."
@@ -28,9 +32,9 @@ go test ./... -count=1 || {
exit 1
}
# Build static binary for Linux amd64
echo " Compiling (CGO_ENABLED=0 GOOS=linux GOARCH=amd64)..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
# Build static binary
echo " Compiling (CGO_ENABLED=0 GOOS=linux GOARCH=$TARGET_ARCH)..."
CGO_ENABLED=0 GOOS=linux GOARCH="$TARGET_ARCH" go build \
-ldflags='-s -w' \
-o "$OUTPUT" \
./cmd/

103
build/scripts/build-cross.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
# build-cross.sh — Cross-compile KubeSolo OS Go binaries for multiple architectures
#
# Builds static binaries for amd64 and arm64 (or a single target).
# This is used by CI/CD and for ARM64 device support.
#
# Usage:
# build/scripts/build-cross.sh # Build both amd64 + arm64
# build/scripts/build-cross.sh --arch amd64 # Build amd64 only
# build/scripts/build-cross.sh --arch arm64 # Build arm64 only
# build/scripts/build-cross.sh --skip-tests # Skip Go tests (for CI where tests run separately)
#
# Output:
# build/cache/kubesolo-update-linux-{amd64,arm64}
# build/cache/kubesolo-cloudinit-linux-{amd64,arm64}
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CACHE_DIR="${CACHE_DIR:-$PROJECT_ROOT/build/cache}"
# Defaults
ARCHES="amd64 arm64"
SKIP_TESTS=false
# Parse args
while [ $# -gt 0 ]; do
case "$1" in
--arch)
ARCHES="${2:?--arch requires a value (amd64 or arm64)}"
shift 2
;;
--skip-tests)
SKIP_TESTS=true
shift
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
mkdir -p "$CACHE_DIR"
echo "=== KubeSolo OS Cross-Compilation ==="
echo " Architectures: $ARCHES"
echo ""
# Run tests once (not per-arch, since Go tests are arch-independent)
if [ "$SKIP_TESTS" = false ]; then
echo "--- Running cloud-init tests ---"
(cd "$PROJECT_ROOT/cloud-init" && go test ./... -count=1) || {
echo "ERROR: Cloud-init tests failed" >&2
exit 1
}
echo "--- Running update agent tests ---"
(cd "$PROJECT_ROOT/update" && go test ./... -count=1) || {
echo "ERROR: Update agent tests failed" >&2
exit 1
}
echo ""
fi
# Build for each architecture
for ARCH in $ARCHES; do
echo "=== Building for linux/$ARCH ==="
# Cloud-init binary
CLOUDINIT_OUT="$CACHE_DIR/kubesolo-cloudinit-linux-$ARCH"
echo "--- cloud-init → $CLOUDINIT_OUT ---"
(cd "$PROJECT_ROOT/cloud-init" && \
CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" \
go build -ldflags='-s -w' -o "$CLOUDINIT_OUT" ./cmd/)
echo " Size: $(ls -lh "$CLOUDINIT_OUT" | awk '{print $5}')"
# Update agent binary
UPDATE_OUT="$CACHE_DIR/kubesolo-update-linux-$ARCH"
echo "--- update agent → $UPDATE_OUT ---"
(cd "$PROJECT_ROOT/update" && \
CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" \
go build -ldflags='-s -w' -o "$UPDATE_OUT" .)
echo " Size: $(ls -lh "$UPDATE_OUT" | awk '{print $5}')"
# Create symlink for default arch (amd64)
if [ "$ARCH" = "amd64" ]; then
ln -sf "kubesolo-cloudinit-linux-$ARCH" "$CACHE_DIR/kubesolo-cloudinit"
ln -sf "kubesolo-update-linux-$ARCH" "$CACHE_DIR/kubesolo-update"
fi
echo ""
done
echo "=== Cross-compilation complete ==="
echo ""
echo "Binaries:"
for ARCH in $ARCHES; do
echo " linux/$ARCH:"
echo " $CACHE_DIR/kubesolo-cloudinit-linux-$ARCH"
echo " $CACHE_DIR/kubesolo-update-linux-$ARCH"
done
echo ""

View File

@@ -3,15 +3,19 @@
#
# Builds a static Linux binary for the update agent.
# Output: build/cache/kubesolo-update
#
# Environment:
# TARGET_ARCH Target architecture (default: amd64, also supports: arm64)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
UPDATE_DIR="$PROJECT_ROOT/update"
CACHE_DIR="$PROJECT_ROOT/build/cache"
TARGET_ARCH="${TARGET_ARCH:-amd64}"
OUTPUT="$CACHE_DIR/kubesolo-update"
echo "=== Building KubeSolo Update Agent ==="
echo "=== Building KubeSolo Update Agent (linux/$TARGET_ARCH) ==="
# Ensure output dir exists
mkdir -p "$CACHE_DIR"
@@ -22,7 +26,7 @@ echo "--- Running tests ---"
# Build static binary
echo "--- Compiling static binary ---"
(cd "$UPDATE_DIR" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
(cd "$UPDATE_DIR" && CGO_ENABLED=0 GOOS=linux GOARCH="$TARGET_ARCH" \
go build -ldflags='-s -w' -o "$OUTPUT" .)
SIZE=$(ls -lh "$OUTPUT" | awk '{print $5}')

155
build/scripts/create-oci-image.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/bash
# create-oci-image.sh — Package KubeSolo OS as an OCI container image
#
# Creates an OCI image containing the kernel and initramfs, suitable for
# distribution via container registries (Docker Hub, GHCR, Quay, etc.).
#
# The OCI image is a minimal scratch-based image containing:
# /vmlinuz — kernel
# /kubesolo-os.gz — initramfs
# /version — version string
# /metadata.json — build metadata
#
# Usage:
# build/scripts/create-oci-image.sh [--registry REGISTRY] [--push]
#
# Examples:
# build/scripts/create-oci-image.sh
# build/scripts/create-oci-image.sh --registry ghcr.io/portainer --push
# build/scripts/create-oci-image.sh --registry docker.io/portainer --push
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VERSION="$(cat "$PROJECT_ROOT/VERSION")"
OUTPUT_DIR="$PROJECT_ROOT/output"
# Defaults
REGISTRY=""
IMAGE_NAME="kubesolo-os"
PUSH=false
ARCH="${ARCH:-amd64}"
# Parse args
while [ $# -gt 0 ]; do
case "$1" in
--registry) REGISTRY="$2"; shift 2 ;;
--push) PUSH=true; shift ;;
--arch) ARCH="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# Build full image tag
if [ -n "$REGISTRY" ]; then
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${VERSION}"
LATEST_TAG="${REGISTRY}/${IMAGE_NAME}:latest"
else
FULL_IMAGE="${IMAGE_NAME}:${VERSION}"
LATEST_TAG="${IMAGE_NAME}:latest"
fi
echo "==> Building OCI image: $FULL_IMAGE"
# Check for required files
VMLINUZ="$OUTPUT_DIR/vmlinuz"
INITRAMFS="$OUTPUT_DIR/kubesolo-os.gz"
# If individual files don't exist, try to extract from ISO
if [ ! -f "$VMLINUZ" ] || [ ! -f "$INITRAMFS" ]; then
ISO="$OUTPUT_DIR/kubesolo-os-${VERSION}.iso"
if [ -f "$ISO" ]; then
echo " Extracting from ISO..."
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
# Extract kernel and initramfs from ISO
xorriso -osirrox on -indev "$ISO" -extract /boot/vmlinuz "$TMPDIR/vmlinuz" 2>/dev/null || \
bsdtar -xf "$ISO" -C "$TMPDIR" boot/vmlinuz boot/kubesolo-os.gz 2>/dev/null || true
# Try common paths
for kpath in "$TMPDIR/boot/vmlinuz" "$TMPDIR/vmlinuz"; do
[ -f "$kpath" ] && VMLINUZ="$kpath" && break
done
for ipath in "$TMPDIR/boot/kubesolo-os.gz" "$TMPDIR/kubesolo-os.gz"; do
[ -f "$ipath" ] && INITRAMFS="$ipath" && break
done
fi
fi
if [ ! -f "$VMLINUZ" ] || [ ! -f "$INITRAMFS" ]; then
echo "ERROR: Required files not found:"
echo " vmlinuz: $VMLINUZ"
echo " kubesolo-os.gz: $INITRAMFS"
echo ""
echo "Run 'make iso' or 'make initramfs' first."
exit 1
fi
# Create build context
OCI_BUILD="$OUTPUT_DIR/oci-build"
rm -rf "$OCI_BUILD"
mkdir -p "$OCI_BUILD"
cp "$VMLINUZ" "$OCI_BUILD/vmlinuz"
cp "$INITRAMFS" "$OCI_BUILD/kubesolo-os.gz"
echo "$VERSION" > "$OCI_BUILD/version"
# Create metadata
cat > "$OCI_BUILD/metadata.json" << EOF
{
"name": "KubeSolo OS",
"version": "$VERSION",
"arch": "$ARCH",
"build_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"vmlinuz_sha256": "$(sha256sum "$OCI_BUILD/vmlinuz" | cut -d' ' -f1)",
"initramfs_sha256": "$(sha256sum "$OCI_BUILD/kubesolo-os.gz" | cut -d' ' -f1)"
}
EOF
# Create Dockerfile
cat > "$OCI_BUILD/Dockerfile" << 'DOCKERFILE'
FROM scratch
LABEL org.opencontainers.image.title="KubeSolo OS"
LABEL org.opencontainers.image.description="Immutable Kubernetes OS for edge/IoT"
LABEL org.opencontainers.image.vendor="Portainer"
LABEL org.opencontainers.image.source="https://github.com/portainer/kubesolo-os"
COPY vmlinuz /vmlinuz
COPY kubesolo-os.gz /kubesolo-os.gz
COPY version /version
COPY metadata.json /metadata.json
DOCKERFILE
# Build OCI image
echo " Building..."
docker build \
--platform "linux/${ARCH}" \
-t "$FULL_IMAGE" \
-t "$LATEST_TAG" \
-f "$OCI_BUILD/Dockerfile" \
"$OCI_BUILD"
echo " Built: $FULL_IMAGE"
echo " Size: $(docker image inspect "$FULL_IMAGE" --format='{{.Size}}' | awk '{printf "%.1f MB", $1/1024/1024}')"
# Push if requested
if [ "$PUSH" = true ]; then
echo " Pushing to registry..."
docker push "$FULL_IMAGE"
docker push "$LATEST_TAG"
echo " Pushed: $FULL_IMAGE"
echo " Pushed: $LATEST_TAG"
fi
# Cleanup
rm -rf "$OCI_BUILD"
echo ""
echo "==> OCI image ready: $FULL_IMAGE"
echo ""
echo "Usage:"
echo " # Pull and extract on target machine:"
echo " docker create --name kubesolo-extract $FULL_IMAGE"
echo " docker cp kubesolo-extract:/vmlinuz ./vmlinuz"
echo " docker cp kubesolo-extract:/kubesolo-os.gz ./kubesolo-os.gz"
echo " docker rm kubesolo-extract"