463 lines
21 KiB
Bash
463 lines
21 KiB
Bash
|
|
#!/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
|
||
|
|
# `<ip> <name>` 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" <<MARKER
|
||
|
|
image_ref=${FLEET_AGENT_IMAGE_REF}
|
||
|
|
image_digest=${current_digest:-unknown}
|
||
|
|
installed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||
|
|
MARKER
|
||
|
|
chmod 0644 "${AGENT_IMAGE_MARK}.new"
|
||
|
|
mv "${AGENT_IMAGE_MARK}.new" "${AGENT_IMAGE_MARK}"
|
||
|
|
echo " wrote ${AGENT_IMAGE_MARK} (digest=${current_digest:-unknown})"
|
||
|
|
fi
|
||
|
|
|
||
|
|
bold "==> [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" <<TOML
|
||
|
|
# Auto-generated by install-fleet_agent.sh — do not edit by hand.
|
||
|
|
node_id = "${FLEET_AGENT_NODE_ID}"
|
||
|
|
declared_cpu = ${detected_cpu}
|
||
|
|
declared_ram_gb = ${detected_ram}
|
||
|
|
declared_disk_gb = ${detected_disk}
|
||
|
|
heartbeat_seconds = ${FLEET_AGENT_HEARTBEAT_S}
|
||
|
|
daemon_url = "${FLEET_AGENT_DAEMON_URL}"
|
||
|
|
TOML
|
||
|
|
chown "${FLEET_AGENT_SYSTEM_USER}:${FLEET_AGENT_SYSTEM_USER}" "${agent_config}.new"
|
||
|
|
chmod 0644 "${agent_config}.new"
|
||
|
|
# Append channel_token_path when the token is being written now OR when an
|
||
|
|
# existing token file is already on disk. This preserves the auth config
|
||
|
|
# across binary-only updates that run without FLEET_AGENT_CHANNEL_TOKEN set.
|
||
|
|
local token_file="${AGENT_ETC}/channel.token"
|
||
|
|
if [ -n "${FLEET_AGENT_CHANNEL_TOKEN:-}" ] || [ -f "${token_file}" ]; then
|
||
|
|
printf '\nchannel_token_path = "/etc/fleet-agent/channel.token"\n' >> "${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}" <<EOF
|
||
|
|
[Unit]
|
||
|
|
Description=libre-forge fleet-agent (node-side claim binder + heartbeat reporter)
|
||
|
|
Documentation=https://github.com/anthropics/libre-forge
|
||
|
|
After=network-online.target
|
||
|
|
Wants=network-online.target
|
||
|
|
|
||
|
|
[Service]
|
||
|
|
Type=simple
|
||
|
|
User=${FLEET_AGENT_SYSTEM_USER}
|
||
|
|
Group=${FLEET_AGENT_SYSTEM_USER}
|
||
|
|
Environment=FLEET_AGENT_CONFIG=${AGENT_ETC}/config.toml
|
||
|
|
ExecStart=${AGENT_BIN}
|
||
|
|
ExecReload=/bin/kill -HUP \$MAINPID
|
||
|
|
Restart=on-failure
|
||
|
|
RestartSec=5s
|
||
|
|
|
||
|
|
# Hardening. CAP_CHOWN + CAP_DAC_OVERRIDE + CAP_FOWNER: write and chown
|
||
|
|
# /etc/buildkit/tls files (cert_install.rs). CAP_SETUID + CAP_SETGID: required
|
||
|
|
# in the bounding set so the setuid sudo binary can escalate to root for
|
||
|
|
# `systemctl reload/restart buildkitd`; without them the kernel strips SETUID
|
||
|
|
# from the executable's permitted set before sudo can change UID. The sudoers
|
||
|
|
# NOPASSWD rule (fleet-agent-buildkitd) limits what root can do from this path.
|
||
|
|
# NoNewPrivileges is intentionally absent.
|
||
|
|
ProtectSystem=strict
|
||
|
|
ProtectHome=true
|
||
|
|
PrivateTmp=true
|
||
|
|
CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETUID CAP_SETGID
|
||
|
|
AmbientCapabilities=CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER
|
||
|
|
ReadWritePaths=${AGENT_ETC} /etc/buildkit/tls
|
||
|
|
|
||
|
|
[Install]
|
||
|
|
WantedBy=multi-user.target
|
||
|
|
EOF
|
||
|
|
|
||
|
|
# Sudoers rule: allow fleet-agent to reload/restart buildkitd so that
|
||
|
|
# new TLS material is picked up without manual intervention (cert_install.rs
|
||
|
|
# tries plain systemctl first, then sudo — see sudoers entry comment).
|
||
|
|
install -d -m 0750 /etc/sudoers.d
|
||
|
|
cat > /etc/sudoers.d/fleet-agent-buildkitd <<SUDOERS
|
||
|
|
# Allow fleet-agent to reload/restart buildkitd after TLS cert rotation.
|
||
|
|
${FLEET_AGENT_SYSTEM_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl reload buildkitd, /usr/bin/systemctl restart buildkitd
|
||
|
|
SUDOERS
|
||
|
|
chmod 0440 /etc/sudoers.d/fleet-agent-buildkitd
|
||
|
|
visudo -c -f /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 <<POLKIT
|
||
|
|
polkit.addRule(function(action, subject) {
|
||
|
|
if (action.id === "org.freedesktop.systemd1.manage-units" &&
|
||
|
|
subject.user === "${FLEET_AGENT_SYSTEM_USER}" &&
|
||
|
|
(action.lookup("verb") === "reload" || action.lookup("verb") === "restart") &&
|
||
|
|
action.lookup("unit") === "buildkitd.service") {
|
||
|
|
return polkit.Result.YES;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
POLKIT
|
||
|
|
echo " polkit rule written: /etc/polkit-1/rules.d/50-fleet-agent-buildkit.rules"
|
||
|
|
fi
|
||
|
|
|
||
|
|
systemctl daemon-reload
|
||
|
|
systemctl enable fleet-agent
|
||
|
|
systemctl restart fleet-agent
|
||
|
|
|
||
|
|
bold "==> [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
|