#!/bin/bash # install-fleet_agent.sh — taskserv installer for libre-forge fleet-agent. # Runs on the target Debian/Ubuntu host as root. Idempotent. # # Order on the server: fleet_base → (acme_certd | lian_node) → fleet_agent. # Hard precondition: fleet_base must have installed /etc/fleet/age.key. # # Inputs (env file, rendered from workspace NCL by deploy.just): # FLEET_AGENT_NODE_ID — logical node id ("UNSET" rejected) # FLEET_AGENT_TARGET_SERVER — physical hostname (informational) # FLEET_AGENT_IMAGE_REF — OCI ref carrying the agent binary # FLEET_AGENT_DAEMON_URL — WebSocket endpoint of fleet-daemon (adr-010) # FLEET_AGENT_DECLARED_* — static cpu/ram_gb/disk_gb (heartbeat capacity) # FLEET_AGENT_HEARTBEAT_S — heartbeat cadence (default 15s) # FLEET_AGENT_SYSTEM_USER — system user name (default "fleet-agent") set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" for f in env-fleet_agent _credentials.env; do if [ -f "${SCRIPT_DIR}/${f}" ]; then # shellcheck source=/dev/null source "${SCRIPT_DIR}/${f}" fi done : "${FLEET_AGENT_NODE_ID:?FLEET_AGENT_NODE_ID must be set}" : "${FLEET_AGENT_IMAGE_REF:?FLEET_AGENT_IMAGE_REF must be set}" : "${FLEET_AGENT_DAEMON_URL:?FLEET_AGENT_DAEMON_URL must be set}" FLEET_AGENT_DECLARED_CPU="${FLEET_AGENT_DECLARED_CPU:-4}" FLEET_AGENT_DECLARED_RAM_GB="${FLEET_AGENT_DECLARED_RAM_GB:-8}" FLEET_AGENT_DECLARED_DISK_GB="${FLEET_AGENT_DECLARED_DISK_GB:-80}" FLEET_AGENT_HEARTBEAT_S="${FLEET_AGENT_HEARTBEAT_S:-15}" FLEET_AGENT_SYSTEM_USER="${FLEET_AGENT_SYSTEM_USER:-fleet-agent}" bold() { printf "\033[1m%s\033[0m\n" "$*"; } fatal() { printf "\033[31mFATAL:\033[0m %s\n" "$*" >&2; exit 1; } warn() { printf "\033[33mWARN :\033[0m %s\n" "$*" >&2; } [ "$(id -u)" -eq 0 ] || fatal "must run as root" [ "$FLEET_AGENT_NODE_ID" != "UNSET" ] || fatal "FLEET_AGENT_NODE_ID is sentinel 'UNSET' — caller must override" # fleet_base precondition: the age key must already be on disk. [ -r /etc/fleet/age.key ] || fatal "/etc/fleet/age.key missing — install fleet_base first" ACTION="${1:-install}" AGENT_BIN="/usr/local/bin/fleet-agent" AGENT_ETC="/etc/fleet-agent" AGENT_UNIT="/etc/systemd/system/fleet-agent.service" # Image install marker. Written after a successful pull+install; consulted at # the start of every re-install to skip the costly OCI pull when the requested # ref already matches what's on disk. PRVNG_FORCE / PROVISIONING_FORCE bypasses # the cache and always re-pulls. AGENT_IMAGE_MARK="/etc/fleet/fleet-agent.image.ref" # Resolve PRVNG_/PROVISIONING_ aliases (mirrors the fleet_daemon helper). case "$(printf '%s' "${PROVISIONING_FORCE:-${PRVNG_FORCE:-}}" | tr '[:upper:]' '[:lower:]')" in 1|true|yes) FORCE=true ;; *) FORCE=false ;; esac # Locate the fleet-agent binary inside whatever `oras pull` writes. # Container-image layout (lamina output): nested under usr/local/bin/. # Plain-artifact layout (future): top-level fleet-agent file. # Layered tarball case: scan the pulled dir tree. _extract_agent_binary() { local pull_dir="$1" local candidates=( "${pull_dir}/usr/local/bin/fleet-agent" "${pull_dir}/fleet-agent" ) for c in "${candidates[@]}"; do if [ -f "$c" ]; then echo "$c" return 0 fi done # Fallback: scan for any executable named fleet-agent. local found found=$(find "${pull_dir}" -name 'fleet-agent' -type f 2>/dev/null | head -1 || true) if [ -n "$found" ]; then echo "$found" return 0 fi return 1 } # Container-image fallback: when `oras pull` skips all layers (because they # lack `org.opencontainers.image.title` annotations, i.e. they are docker/OCI # image layers not flat-artifact files), fall back to `oras copy --to-oci-layout` # and extract every gzipped tar blob into a flat tree. This recovers the binary # regardless of which stage's layer contains it. # # IMPORTANT: this function is called via $(...) so its stdout becomes a file # path. Every subordinate command (oras copy, tar, etc.) MUST redirect its # stdout to stderr (>&2) so only `_extract_agent_binary`'s final echo reaches # the caller. _extract_via_oci_layout() { local image_ref="$1" local pull_dir="$2" local layout_dir="${pull_dir}/layout" local extract_dir="${pull_dir}/extract" local _plat _plat="linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" mkdir -p "${layout_dir}" "${extract_dir}" DOCKER_CONFIG=/etc/fleet/docker \ oras copy "${image_ref}" --platform "${_plat}" --to-oci-layout "${layout_dir}:fleet-agent" >&2 \ || return 1 local blob # Process blobs smallest-first: the application binary lives in the topmost # (smallest) layer; base image layers are large (Debian + Rust = 100-200 MB # compressed). Stopping as soon as the binary appears skips those entirely. # SHA-256 hex filenames never contain spaces so the ls subshell is safe. for blob in $(ls -rS "${layout_dir}/blobs/sha256/" 2>/dev/null \ | awk -v d="${layout_dir}/blobs/sha256" '{print d"/"$0}'); do [ -f "${blob}" ] || continue # Detect gzip magic bytes (1f 8b) — config/manifest blobs are JSON, skip. if [ "$(head -c 2 "${blob}" | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then tar -xzf "${blob}" -C "${extract_dir}" 2>/dev/null || true _extract_agent_binary "${extract_dir}" >/dev/null 2>&1 && break fi done _extract_agent_binary "${extract_dir}" } cmd_install() { bold "==> [1/6] Ensure system packages" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq ca-certificates # The fleet_agent taskserv ASSUMES oras is already on the host. lian_node # installs it; if this host doesn't have lian_node, the operator must # provide an oras binary via the host's standard PATH. command -v oras >/dev/null 2>&1 || fatal "oras not found on PATH — install lian_node first or provision oras separately" # Resolver setup: the fleet_agent deploy owns the node's DNS-resolution # contract. When FLEET_AGENT_HOSTS_ENTRIES is set (newline-separated # ` ` lines), they are written to /etc/hosts under a managed # section. Idempotent (re-running re-writes the section; clearing the env # to empty removes the managed section from disk on next install — the # strip always runs, the append is conditional on entries being present). bold "==> [1.5/6] /etc/hosts entries managed by fleet-agent" local marker_begin="# fleet-agent managed entries — begin (do not edit by hand)" local marker_end="# fleet-agent managed entries — end" # Strip any previous managed section atomically, regardless of whether # new entries follow. local tmp tmp=$(mktemp) awk -v b="$marker_begin" -v e="$marker_end" \ 'BEGIN{skip=0} $0==b{skip=1; next} $0==e{skip=0; next} skip==0{print}' \ /etc/hosts > "$tmp" if [ -n "${FLEET_AGENT_HOSTS_ENTRIES:-}" ]; then { cat "$tmp" echo "$marker_begin" printf '%s\n' "$FLEET_AGENT_HOSTS_ENTRIES" echo "$marker_end" } > /etc/hosts.new chmod 0644 /etc/hosts.new mv /etc/hosts.new /etc/hosts rm -f "$tmp" echo " /etc/hosts managed section refreshed:" printf '%s\n' "$FLEET_AGENT_HOSTS_ENTRIES" | sed 's/^/ /' else chmod 0644 "$tmp" mv "$tmp" /etc/hosts echo " /etc/hosts managed section removed (no entries declared — resolving via system DNS)" fi bold "==> [2/6] System user ${FLEET_AGENT_SYSTEM_USER}" if id "${FLEET_AGENT_SYSTEM_USER}" >/dev/null 2>&1; then echo " user ${FLEET_AGENT_SYSTEM_USER} already exists" else useradd --system --no-create-home --shell /usr/sbin/nologin "${FLEET_AGENT_SYSTEM_USER}" echo " created system user ${FLEET_AGENT_SYSTEM_USER}" fi # The agent writes + chowns buildkitd TLS material at runtime (cert_install.rs). # Requirements: # - buildkit group must exist (chown root:buildkit target group) # - fleet-agent must be a secondary member (allows chown :buildkit from non-root with CAP_CHOWN) # - /etc/buildkit/tls must exist before the unit starts (ProtectSystem=strict # creates readonly bind-mounts for all of /etc; ReadWritePaths punches holes # only for directories that exist at bind-mount time) groupadd -f buildkit usermod -aG buildkit "${FLEET_AGENT_SYSTEM_USER}" install -d -m 0755 /etc/buildkit install -d -m 0750 -o root -g buildkit /etc/buildkit/tls echo " buildkit group ensured; ${FLEET_AGENT_SYSTEM_USER} added; /etc/buildkit/tls pre-created" bold "==> [3/6] Pull binary from ${FLEET_AGENT_IMAGE_REF}" # Idempotency check: compare requested ref's manifest digest to the marker # file. If equal and no FORCE → skip the pull+extract entirely. The binary, # creds, unit, hosts entries from this run are still re-validated below; only # the costly OCI fetch is avoided. local current_digest="" local skip_pull=false if [ "$FORCE" != "true" ] && [ -r "$AGENT_IMAGE_MARK" ] && [ -x "$AGENT_BIN" ]; then # Marker present + binary on disk → check if remote digest still matches. local marker_ref marker_digest marker_ref=$(awk -F= '/^image_ref=/{print $2; exit}' "$AGENT_IMAGE_MARK") marker_digest=$(awk -F= '/^image_digest=/{print $2; exit}' "$AGENT_IMAGE_MARK") if [ "$marker_ref" = "$FLEET_AGENT_IMAGE_REF" ] && [ -n "$marker_digest" ]; then current_digest=$(DOCKER_CONFIG=/etc/fleet/docker \ oras manifest fetch --descriptor "$FLEET_AGENT_IMAGE_REF" 2>/dev/null \ | sed -n 's/.*"digest":"\([^"]*\)".*/\1/p' | head -1 || true) if [ -n "$current_digest" ] && [ "$current_digest" = "$marker_digest" ]; then skip_pull=true echo " ↩ cache hit: ${marker_digest}" echo " (marker /etc/fleet/fleet-agent.image.ref matches remote — PRVNG_FORCE=true to bypass)" else echo " digest drift detected — remote=${current_digest:-unknown}, marker=${marker_digest}" fi fi elif [ "$FORCE" = "true" ]; then echo " [force] bypassing image marker — pulling fresh" fi if [ "$skip_pull" = "true" ]; then echo " binary already installed at expected digest — skipping pull+extract" else # Stop the running unit (if any) so we can swap the binary atomically. if systemctl is-active --quiet fleet-agent 2>/dev/null; then echo " stopping fleet-agent to swap binary" systemctl stop fleet-agent fi local pull_tmp pull_tmp=$(mktemp -d) # Honour /etc/containers/auth.json (set up by fleet_base) for the OCI pull. # Try flat-artifact pull first (cheapest path, works when the image was # pushed with per-file annotations). # Pass --platform so oras resolves the correct sub-manifest from an OCI # image index without touching unreachable peer-platform manifests (Zot # does not serve manifest-by-digest for manifests that have no direct tag). local _OCI_PLATFORM _OCI_PLATFORM="linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" DOCKER_CONFIG=/etc/fleet/docker \ oras pull "${FLEET_AGENT_IMAGE_REF}" --platform "${_OCI_PLATFORM}" -o "${pull_tmp}" \ || { rm -rf "${pull_tmp}"; fatal "oras pull ${FLEET_AGENT_IMAGE_REF} failed"; } local extracted extracted=$(_extract_agent_binary "${pull_tmp}" 2>/dev/null || true) if [ -z "${extracted}" ]; then # `oras pull` ran but skipped all layers because the image is a Dockerfile- # built container image (lamina/cargo-chef/distroless output) — its layers # don't carry `org.opencontainers.image.title` annotations. Fall back to # `oras copy --to-oci-layout` + extract every gzipped tar blob. echo " flat-artifact pull yielded no binary — falling back to OCI layout extraction" extracted=$(_extract_via_oci_layout "${FLEET_AGENT_IMAGE_REF}" "${pull_tmp}") \ || { rm -rf "${pull_tmp}"; fatal "fleet-agent binary not found after OCI layout extraction"; } fi install -m 0755 -o root -g root "${extracted}" "${AGENT_BIN}" rm -rf "${pull_tmp}" echo " installed ${AGENT_BIN}" # Write the install marker. Resolve current_digest now if we didn't earlier. if [ -z "$current_digest" ]; then current_digest=$(DOCKER_CONFIG=/etc/fleet/docker \ oras manifest fetch --descriptor "$FLEET_AGENT_IMAGE_REF" 2>/dev/null \ | sed -n 's/.*"digest":"\([^"]*\)".*/\1/p' | head -1 || true) fi install -d -m 0755 /etc/fleet umask 077 cat > "${AGENT_IMAGE_MARK}.new" < [4/6] Config TOML at ${AGENT_ETC}/config.toml (adr-010: no nats, no creds file)" install -d -m 0750 -o "${FLEET_AGENT_SYSTEM_USER}" -g "${FLEET_AGENT_SYSTEM_USER}" "${AGENT_ETC}" # Auto-detect node specs from /proc + df. NCL `declared` is a fallback only. local detected_cpu detected_ram detected_disk detected_cpu=$(nproc 2>/dev/null || echo "${FLEET_AGENT_DECLARED_CPU}") detected_ram=$(awk '/^MemTotal:/ { kb=$2; gb=int((kb + 1048575) / 1048576); print gb; exit }' /proc/meminfo 2>/dev/null || echo "${FLEET_AGENT_DECLARED_RAM_GB}") detected_disk=$(df -BG --output=size / 2>/dev/null | tail -1 | tr -d ' G' || echo "${FLEET_AGENT_DECLARED_DISK_GB}") echo " auto-detected: cpu=${detected_cpu} ram_gb=${detected_ram} disk_gb=${detected_disk}" echo " (NCL declared was: cpu=${FLEET_AGENT_DECLARED_CPU} ram_gb=${FLEET_AGENT_DECLARED_RAM_GB} disk_gb=${FLEET_AGENT_DECLARED_DISK_GB} — auto wins)" local agent_config="${AGENT_ETC}/config.toml" install -d -m 0755 "${AGENT_ETC}" cat > "${agent_config}.new" <> "${agent_config}.new" fi mv "${agent_config}.new" "${agent_config}" echo " wrote ${agent_config}" # Stage channel bootstrap token — same token the daemon validates. if [ -n "${FLEET_AGENT_CHANNEL_TOKEN:-}" ]; then printf '%s' "${FLEET_AGENT_CHANNEL_TOKEN}" > "${token_file}.new" chown "${FLEET_AGENT_SYSTEM_USER}:${FLEET_AGENT_SYSTEM_USER}" "${token_file}.new" chmod 0400 "${token_file}.new" mv "${token_file}.new" "${token_file}" echo " wrote ${token_file} (mode 0400)" elif [ -f "${token_file}" ]; then echo " channel.token already on disk — keeping existing token" else echo " WARN: FLEET_AGENT_CHANNEL_TOKEN absent — channel.token not staged; channel will be unauthenticated" fi bold "==> [5/6] systemd unit ${AGENT_UNIT}" cat > "${AGENT_UNIT}" < /etc/sudoers.d/fleet-agent-buildkitd </dev/null \ && echo " sudoers rule written: /etc/sudoers.d/fleet-agent-buildkitd" \ || { warn "sudoers syntax check failed — removing invalid rule"; rm -f /etc/sudoers.d/fleet-agent-buildkitd; } # Polkit rule (supplementary, best-effort when polkit is installed). if [ -d /etc/polkit-1/rules.d ]; then cat > /etc/polkit-1/rules.d/50-fleet-agent-buildkit.rules < [6/6] Startup verification" # Best-effort: wait up to 30s for the unit to come up cleanly. Connectivity # to the daemon's WebSocket is established asynchronously after start. local waited=0 while [ "${waited}" -lt 30 ]; do if systemctl is-active --quiet fleet-agent; then echo " fleet-agent active after ${waited}s" break fi sleep 2 waited=$((waited + 2)) done systemctl is-active --quiet fleet-agent \ || { systemctl status fleet-agent --no-pager || true; fatal "fleet-agent did not come up within 30s"; } bold "==> Done" echo " node_id : ${FLEET_AGENT_NODE_ID}" echo " daemon_url : ${FLEET_AGENT_DAEMON_URL}" echo " declared : cpu=${FLEET_AGENT_DECLARED_CPU} ram_gb=${FLEET_AGENT_DECLARED_RAM_GB} disk_gb=${FLEET_AGENT_DECLARED_DISK_GB}" echo " heartbeat_s : ${FLEET_AGENT_HEARTBEAT_S}" echo " binary : ${AGENT_BIN}" echo " unit : ${AGENT_UNIT}" } cmd_status() { echo "=== systemd ===" systemctl status fleet-agent --no-pager || true echo "=== binary ===" if [ -x "${AGENT_BIN}" ]; then echo " ${AGENT_BIN} present" else echo " ${AGENT_BIN} MISSING" fi echo "=== config ===" local cfg="${AGENT_ETC}/config.toml" if [ -f "${cfg}" ]; then stat -c ' %n owner=%U:%G mode=%a size=%s' "${cfg}" grep "^daemon_url" "${cfg}" || true else echo " ${cfg} MISSING" fi echo "=== buildkit TLS dir ===" if [ -d /etc/buildkit/tls ]; then stat -c ' %n owner=%U:%G mode=%a' /etc/buildkit/tls ls -la /etc/buildkit/tls/ 2>/dev/null || true else echo " /etc/buildkit/tls MISSING — run install to pre-create" fi echo "=== polkit rule ===" [ -f /etc/polkit-1/rules.d/50-fleet-agent-buildkit.rules ] \ && echo " 50-fleet-agent-buildkit.rules present" \ || echo " 50-fleet-agent-buildkit.rules MISSING — buildkitd reload will not work without manual restart" echo "=== age key (fleet_base) ===" [ -r /etc/fleet/age.key ] && echo " /etc/fleet/age.key present" || echo " /etc/fleet/age.key MISSING" } cmd_uninstall() { systemctl disable --now fleet-agent 2>/dev/null || true rm -f "${AGENT_UNIT}" systemctl daemon-reload rm -f "${AGENT_BIN}" rm -f "${AGENT_ETC}/channel.token" rmdir --ignore-fail-on-non-empty "${AGENT_ETC}" 2>/dev/null || true rm -f /etc/polkit-1/rules.d/50-fleet-agent-buildkit.rules rm -f /etc/sudoers.d/fleet-agent-buildkitd # Preserve the system user, buildkit group membership, and /etc/buildkit/tls — # buildkitd owns the dir and the group; fleet-agent just uses them. echo "uninstalled (system user ${FLEET_AGENT_SYSTEM_USER} preserved; /etc/fleet/age.key preserved)" } case "$ACTION" in install) cmd_install ;; status) cmd_status ;; uninstall) cmd_uninstall ;; *) fatal "unknown action: $ACTION (install|status|uninstall)" ;; esac