feat(deploy): add one-line bootstrap installer and container image
bootstrap.sh auto-detects the host package manager (apt/dnf) and installs whatever is missing: Docker, k3s, and PostgreSQL. It builds and imports the felis image, opens pg_hba to the pod CIDR, runs migrations, and applies the rendered control-plane bundle, leaving Web disabled pending 'felis setup'. The Dockerfile builds the distroless felis image; deploy/crd holds the MinecraftServer CRD.
This commit is contained in:
4 files changed
+869
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
# Keep the felis image build context tiny: the binary is pure Go (only
|
||||||
|
# internal/store/migrations/*.sql is embedded), so none of the web/plugin/doc
|
||||||
|
# assets are needed and shipping them would bloat the context and slow `docker
|
||||||
|
# build` on a small host.
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
node_modules
|
||||||
|
panel
|
||||||
|
plugins
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
deploy
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.gitignore
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
felis
|
||||||
|
felis.exe
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Felis control-plane image.
|
||||||
|
#
|
||||||
|
# Builds the single multi-call `felis` binary (api / operator / migrate / reaper
|
||||||
|
# / restore / manifests / setup) as a static, CGO-free executable and ships it on
|
||||||
|
# a distroless base. Two contracts the rendered Deployments depend on:
|
||||||
|
#
|
||||||
|
# 1. The binary lives on PATH at /usr/local/bin/felis, because the bundle
|
||||||
|
# invokes it by bare name (`command: ["felis", ...]` in workloads.go). PATH
|
||||||
|
# is pinned explicitly so this holds regardless of base-image defaults.
|
||||||
|
# 2. The image is meant to be imported into a local containerd (k3s ctr import)
|
||||||
|
# and referenced by a NON-:latest tag (e.g. felis:demo). k8s then resolves
|
||||||
|
# the default IfNotPresent pull policy against the imported image instead of
|
||||||
|
# trying to pull it from a registry that does not exist yet.
|
||||||
|
#
|
||||||
|
# deploy/bootstrap.sh also extracts this same binary onto the host (docker cp)
|
||||||
|
# so `felis migrate up` and the `felis setup` TUI run with the identical build.
|
||||||
|
|
||||||
|
FROM golang:1.26 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
|
||||||
|
# Prime the module cache first so source-only edits do not re-download deps.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
# Guarantee bare `felis` resolves no matter what PATH the base image ships.
|
||||||
|
ENV PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
COPY --from=build /out/felis /usr/local/bin/felis
|
||||||
|
# distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins
|
||||||
|
# runAsUser 1000 at deploy time, and a static binary needs no /etc/passwd entry,
|
||||||
|
# so either uid runs the same binary from a read-only root filesystem.
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["felis"]
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Felis one-line bootstrap installer.
|
||||||
|
#
|
||||||
|
# curl -fsSL <raw-url>/deploy/bootstrap.sh | sudo bash
|
||||||
|
#
|
||||||
|
# Brings a fresh single-node Linux host from nothing to a running Felis control
|
||||||
|
# plane: it installs whatever is missing (picking dnf or apt by OS), provisions a
|
||||||
|
# swap file on tiny hosts, then configures Docker, k3s and PostgreSQL, builds and
|
||||||
|
# imports the felis image, runs database migrations and applies the rendered
|
||||||
|
# install bundle (CRD + namespaces + RBAC + NetworkPolicies + control-plane
|
||||||
|
# Deployments + in-cluster registry).
|
||||||
|
#
|
||||||
|
# By design it stops short of serving the web panel. After it finishes you run
|
||||||
|
# `felis setup` on the host (a TUI) to create the Owner account; the SysAdmin web
|
||||||
|
# surface only unlocks once Web Zero-Trust is configured. See deploy/README.md.
|
||||||
|
#
|
||||||
|
# The script is idempotent: re-running it converges rather than duplicating, and
|
||||||
|
# generated secrets are persisted to /etc/felis/secrets.env so reruns reuse them.
|
||||||
|
#
|
||||||
|
# Tunables (export before running to override the demo defaults):
|
||||||
|
# FELIS_REPO_URL git URL to build from (default: the upstream repo)
|
||||||
|
# FELIS_REF branch/tag/sha (default: main)
|
||||||
|
# FELIS_IMAGE local image tag (default: felis:demo — never :latest)
|
||||||
|
# FELIS_ROOT_DOMAIN deployment root domain (default: <node-ip>.nip.io)
|
||||||
|
# FELIS_EGRESS_MODE loadbalancer|nodeport (default: nodeport — no MetalLB on a demo box)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration & constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FELIS_REPO_URL="${FELIS_REPO_URL:-https://github.com/MliroLirrorsIngenuity/Felis.git}"
|
||||||
|
FELIS_REF="${FELIS_REF:-main}"
|
||||||
|
FELIS_IMAGE="${FELIS_IMAGE:-felis:demo}"
|
||||||
|
FELIS_EGRESS_MODE="${FELIS_EGRESS_MODE:-nodeport}"
|
||||||
|
|
||||||
|
CONTROL_NS="felis"
|
||||||
|
MINECRAFT_NS="minecraft"
|
||||||
|
BUILD_NS="felis-build"
|
||||||
|
POD_CIDR="10.42.0.0/16" # k3s default cluster CIDR
|
||||||
|
DB_NAME="felis"
|
||||||
|
DB_USER="felis"
|
||||||
|
REGISTRY_URL="registry.felis.svc:5000"
|
||||||
|
|
||||||
|
STATE_DIR="/etc/felis"
|
||||||
|
SECRETS_ENV="${STATE_DIR}/secrets.env"
|
||||||
|
SRC_DIR="/opt/felis/src"
|
||||||
|
HOST_BIN="/usr/local/bin/felis"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log() { printf '\033[1;36m[felis]\033[0m %s\n' "$*"; }
|
||||||
|
ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
kube() { k3s kubectl "$@"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 0. Privilege & host facts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
log "re-executing under sudo"
|
||||||
|
exec sudo -E bash "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
detect_os() {
|
||||||
|
[ -r /etc/os-release ] || die "cannot read /etc/os-release; unsupported host"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/os-release
|
||||||
|
OS_ID="${ID:-unknown}"
|
||||||
|
OS_VERSION="${VERSION_ID:-unknown}"
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
PKG="apt"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
PKG="dnf"
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
PKG="yum"
|
||||||
|
else
|
||||||
|
die "no supported package manager (apt/dnf/yum) found on ${OS_ID} ${OS_VERSION}"
|
||||||
|
fi
|
||||||
|
log "host: ${PRETTY_NAME:-$OS_ID $OS_VERSION} (package manager: ${PKG})"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_node_ip() {
|
||||||
|
NODE_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
|
||||||
|
[ -n "${NODE_IP:-}" ] || NODE_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
[ -n "${NODE_IP:-}" ] || die "could not determine this host's primary IPv4 address"
|
||||||
|
FELIS_ROOT_DOMAIN="${FELIS_ROOT_DOMAIN:-${NODE_IP}.nip.io}"
|
||||||
|
log "node IP: ${NODE_IP} root domain: ${FELIS_ROOT_DOMAIN}"
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg_install() {
|
||||||
|
case "$PKG" in
|
||||||
|
apt) DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" ;;
|
||||||
|
dnf) dnf install -y "$@" ;;
|
||||||
|
yum) yum install -y "$@" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg_refresh_once() {
|
||||||
|
[ -n "${_PKG_REFRESHED:-}" ] && return 0
|
||||||
|
case "$PKG" in
|
||||||
|
apt) DEBIAN_FRONTEND=noninteractive apt-get update -y ;;
|
||||||
|
dnf|yum) : ;; # dnf/yum refresh metadata on demand
|
||||||
|
esac
|
||||||
|
_PKG_REFRESHED=1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Swap — k3s + Postgres + a Go build will OOM on a <2 GiB box without it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ensure_swap() {
|
||||||
|
local mem_kb swap_kb
|
||||||
|
mem_kb="$(awk '/^MemTotal:/{print $2}' /proc/meminfo)"
|
||||||
|
swap_kb="$(awk '/^SwapTotal:/{print $2}' /proc/meminfo)"
|
||||||
|
if [ "${swap_kb:-0}" -gt 0 ]; then
|
||||||
|
ok "swap already present ($((swap_kb/1024)) MiB)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "${mem_kb:-0}" -ge 2097152 ]; then
|
||||||
|
ok "RAM $((mem_kb/1024)) MiB is sufficient; skipping swap"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "low RAM ($((mem_kb/1024)) MiB) and no swap — creating a 2 GiB swap file"
|
||||||
|
if ! fallocate -l 2G /swapfile 2>/dev/null; then
|
||||||
|
dd if=/dev/zero of=/swapfile bs=1M count=2048 status=none
|
||||||
|
fi
|
||||||
|
chmod 600 /swapfile
|
||||||
|
mkswap /swapfile >/dev/null
|
||||||
|
swapon /swapfile
|
||||||
|
grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||||
|
ok "2 GiB swap active"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Base packages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
install_base() {
|
||||||
|
pkg_refresh_once
|
||||||
|
pkg_install curl ca-certificates git openssl
|
||||||
|
ok "base tools present"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Docker (used only to build & export the felis image; k3s uses containerd)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
install_docker() {
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
ok "docker already installed"
|
||||||
|
else
|
||||||
|
log "installing docker via get.docker.com"
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
fi
|
||||||
|
systemctl enable --now docker
|
||||||
|
ok "docker running"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. k3s — single node, trimmed for RAM. NetworkPolicy stays ENABLED on purpose:
|
||||||
|
# Felis's minecraft fence (default-deny + allow-rcon/allow-game) is a core
|
||||||
|
# security claim, so we must NOT pass --disable-network-policy.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
install_k3s() {
|
||||||
|
if command -v k3s >/dev/null 2>&1; then
|
||||||
|
ok "k3s already installed"
|
||||||
|
else
|
||||||
|
log "installing k3s (no traefik/servicelb/metrics-server)"
|
||||||
|
curl -sfL https://get.k3s.io | \
|
||||||
|
INSTALL_K3S_EXEC="--disable traefik --disable servicelb --disable metrics-server --write-kubeconfig-mode 644" \
|
||||||
|
sh -
|
||||||
|
fi
|
||||||
|
systemctl enable --now k3s
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||||
|
log "waiting for the node to become Ready"
|
||||||
|
local i
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if kube get nodes 2>/dev/null | grep -q ' Ready '; then
|
||||||
|
ok "k3s node Ready"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
kube get nodes || true
|
||||||
|
die "k3s node did not become Ready in time"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Source + image build + host binary + containerd import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
fetch_source() {
|
||||||
|
if [ -n "${FELIS_SKIP_FETCH:-}" ]; then
|
||||||
|
[ -d "$SRC_DIR" ] || die "FELIS_SKIP_FETCH set but ${SRC_DIR} does not exist"
|
||||||
|
ok "skipping fetch; using pre-staged source at ${SRC_DIR}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -d "${SRC_DIR}/.git" ]; then
|
||||||
|
log "updating source in ${SRC_DIR}"
|
||||||
|
git -C "$SRC_DIR" fetch --depth 1 origin "$FELIS_REF"
|
||||||
|
git -C "$SRC_DIR" checkout -f FETCH_HEAD
|
||||||
|
else
|
||||||
|
log "cloning ${FELIS_REPO_URL} (${FELIS_REF})"
|
||||||
|
mkdir -p "$(dirname "$SRC_DIR")"
|
||||||
|
git clone --depth 1 --branch "$FELIS_REF" "$FELIS_REPO_URL" "$SRC_DIR" 2>/dev/null \
|
||||||
|
|| git clone "$FELIS_REPO_URL" "$SRC_DIR"
|
||||||
|
fi
|
||||||
|
ok "source ready at ${SRC_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_image() {
|
||||||
|
systemctl start docker
|
||||||
|
log "building ${FELIS_IMAGE} (this compiles the Go binary; first run is slow)"
|
||||||
|
docker build -t "$FELIS_IMAGE" "$SRC_DIR"
|
||||||
|
|
||||||
|
log "extracting the felis binary onto the host (${HOST_BIN})"
|
||||||
|
local cid
|
||||||
|
cid="$(docker create "$FELIS_IMAGE")"
|
||||||
|
docker cp "${cid}:/usr/local/bin/felis" "$HOST_BIN"
|
||||||
|
docker rm "$cid" >/dev/null
|
||||||
|
chmod 0755 "$HOST_BIN"
|
||||||
|
|
||||||
|
log "importing ${FELIS_IMAGE} into k3s containerd"
|
||||||
|
docker save "$FELIS_IMAGE" | k3s ctr images import -
|
||||||
|
|
||||||
|
# Reclaim the ~150 MiB the docker daemon holds; reruns restart it on demand.
|
||||||
|
systemctl stop docker docker.socket 2>/dev/null || true
|
||||||
|
ok "image built, binary on host, image imported"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. PostgreSQL on the host (apt/dnf). felis-api pods reach it at <node-ip>:5432;
|
||||||
|
# migrations run from the host binary against 127.0.0.1.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
install_postgres() {
|
||||||
|
if command -v psql >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q '^postgresql'; then
|
||||||
|
ok "postgresql already installed"
|
||||||
|
else
|
||||||
|
log "installing postgresql"
|
||||||
|
case "$PKG" in
|
||||||
|
apt) pkg_install postgresql ;;
|
||||||
|
dnf) pkg_install postgresql-server postgresql ;;
|
||||||
|
yum) pkg_install postgresql-server postgresql ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
# RHEL-family ships an uninitialised data dir.
|
||||||
|
if [ "$PKG" != "apt" ] && [ ! -f /var/lib/pgsql/data/PG_VERSION ]; then
|
||||||
|
log "initialising postgresql data directory"
|
||||||
|
if command -v postgresql-setup >/dev/null 2>&1; then
|
||||||
|
postgresql-setup --initdb || /usr/bin/postgresql-setup initdb
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
systemctl enable --now postgresql
|
||||||
|
ok "postgresql running"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_postgres() {
|
||||||
|
local cfg hba
|
||||||
|
cfg="$(sudo -u postgres psql -tAc 'SHOW config_file;' 2>/dev/null || true)"
|
||||||
|
hba="$(sudo -u postgres psql -tAc 'SHOW hba_file;' 2>/dev/null || true)"
|
||||||
|
[ -n "$cfg" ] && [ -n "$hba" ] || die "could not query postgresql config/hba file paths"
|
||||||
|
|
||||||
|
# Listen on all interfaces (applied on restart). ALTER SYSTEM is idempotent.
|
||||||
|
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER SYSTEM SET listen_addresses = '*';" >/dev/null
|
||||||
|
|
||||||
|
# Allow the host loopback, the pod CIDR, and the node IP (covers SNAT either way).
|
||||||
|
local line
|
||||||
|
for line in \
|
||||||
|
"host ${DB_NAME} ${DB_USER} 127.0.0.1/32 scram-sha-256" \
|
||||||
|
"host ${DB_NAME} ${DB_USER} ${POD_CIDR} scram-sha-256" \
|
||||||
|
"host ${DB_NAME} ${DB_USER} ${NODE_IP}/32 scram-sha-256" ; do
|
||||||
|
grep -qF "$line" "$hba" || echo "$line" >> "$hba"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Role + database (idempotent), and (re)set the password to our generated one.
|
||||||
|
sudo -u postgres psql -v ON_ERROR_STOP=1 <<SQL >/dev/null
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${DB_USER}') THEN
|
||||||
|
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${DB_PASSWORD}';
|
||||||
|
SQL
|
||||||
|
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then
|
||||||
|
sudo -u postgres createdb -O "$DB_USER" "$DB_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl restart postgresql
|
||||||
|
ok "postgresql configured (listen=*, role/db '${DB_NAME}', pg_hba opened to pods)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Secrets + felis.toml (pod variant reaches Postgres at the node IP; host
|
||||||
|
# variant at 127.0.0.1 for migrations)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
load_or_make_secrets() {
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
chmod 0700 "$STATE_DIR"
|
||||||
|
if [ -f "$SECRETS_ENV" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$SECRETS_ENV"
|
||||||
|
ok "reusing persisted secrets from ${SECRETS_ENV}"
|
||||||
|
fi
|
||||||
|
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -hex 24)}"
|
||||||
|
SERVICE_TOKEN="${SERVICE_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
|
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
|
||||||
|
umask 077
|
||||||
|
cat > "$SECRETS_ENV" <<EOF
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
SERVICE_TOKEN=${SERVICE_TOKEN}
|
||||||
|
SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
EOF
|
||||||
|
chmod 0600 "$SECRETS_ENV"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_felis_toml() {
|
||||||
|
local target="$1" db_host="$2"
|
||||||
|
cat > "$target" <<EOF
|
||||||
|
# Generated by deploy/bootstrap.sh — do not edit by hand; rerun the installer.
|
||||||
|
[server]
|
||||||
|
listen = "0.0.0.0:8080"
|
||||||
|
root_domain = "${FELIS_ROOT_DOMAIN}"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
url = "postgres://${DB_USER}:${DB_PASSWORD}@${db_host}:5432/${DB_NAME}?sslmode=disable"
|
||||||
|
|
||||||
|
[k8s]
|
||||||
|
namespace = "${MINECRAFT_NS}"
|
||||||
|
egress_mode = "${FELIS_EGRESS_MODE}"
|
||||||
|
|
||||||
|
[registry]
|
||||||
|
url = "${REGISTRY_URL}"
|
||||||
|
build_namespace = "${BUILD_NS}"
|
||||||
|
|
||||||
|
[archive]
|
||||||
|
store = "tarLocal"
|
||||||
|
local_path = "/var/lib/felis/archives"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
admin_hostname = "admin.${FELIS_ROOT_DOMAIN}"
|
||||||
|
panel_hostname = "panel.${FELIS_ROOT_DOMAIN}"
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Migrate + deploy bundle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
run_migrations() {
|
||||||
|
write_felis_toml "${STATE_DIR}/felis.host.toml" "127.0.0.1"
|
||||||
|
log "running database migrations (host binary -> 127.0.0.1)"
|
||||||
|
"$HOST_BIN" migrate up -config "${STATE_DIR}/felis.host.toml"
|
||||||
|
ok "migrations applied"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_bundle() {
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||||
|
write_felis_toml "${STATE_DIR}/felis.pod.toml" "${NODE_IP}"
|
||||||
|
|
||||||
|
log "applying MinecraftServer CRD"
|
||||||
|
kube apply -f "${SRC_DIR}/deploy/crd/"
|
||||||
|
|
||||||
|
log "ensuring namespaces"
|
||||||
|
local ns
|
||||||
|
for ns in "$CONTROL_NS" "$MINECRAFT_NS" "$BUILD_NS"; do
|
||||||
|
kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f -
|
||||||
|
done
|
||||||
|
|
||||||
|
log "provisioning felis-config + felis-service-token secrets (out-of-band, never in the bundle)"
|
||||||
|
kube -n "$CONTROL_NS" create secret generic felis-config \
|
||||||
|
--from-file=felis.toml="${STATE_DIR}/felis.pod.toml" \
|
||||||
|
--dry-run=client -o yaml | kube apply -f -
|
||||||
|
kube -n "$CONTROL_NS" create secret generic felis-service-token \
|
||||||
|
--from-literal=token="${SERVICE_TOKEN}" \
|
||||||
|
--dry-run=client -o yaml | kube apply -f -
|
||||||
|
|
||||||
|
log "rendering + applying the control-plane bundle"
|
||||||
|
"$HOST_BIN" manifests \
|
||||||
|
--felis-image "$FELIS_IMAGE" \
|
||||||
|
--velocity-cidr "${NODE_IP}/32" \
|
||||||
|
| kube apply -f -
|
||||||
|
|
||||||
|
log "waiting for control-plane rollouts"
|
||||||
|
local d
|
||||||
|
for d in $(kube -n "$CONTROL_NS" get deploy -o name); do
|
||||||
|
kube -n "$CONTROL_NS" rollout status "$d" --timeout=180s || warn "rollout not complete: $d"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
summary() {
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||||
|
echo
|
||||||
|
ok "Felis control plane deployed."
|
||||||
|
echo
|
||||||
|
kube -n "$CONTROL_NS" get pods -o wide || true
|
||||||
|
echo
|
||||||
|
log "Web is intentionally NOT enabled yet."
|
||||||
|
log "Next: run 'sudo felis setup' on this host to create the Owner account."
|
||||||
|
log "The SysAdmin web surface unlocks only after Web Zero-Trust is configured."
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
detect_os
|
||||||
|
detect_node_ip
|
||||||
|
ensure_swap
|
||||||
|
install_base
|
||||||
|
load_or_make_secrets
|
||||||
|
install_docker
|
||||||
|
install_k3s
|
||||||
|
fetch_source
|
||||||
|
build_image
|
||||||
|
install_postgres
|
||||||
|
configure_postgres
|
||||||
|
run_migrations
|
||||||
|
deploy_bundle
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
---
|
||||||
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
|
kind: CustomResourceDefinition
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
controller-gen.kubebuilder.io/version: v0.17.3
|
||||||
|
name: minecraftservers.felis.lolicon.best
|
||||||
|
spec:
|
||||||
|
group: felis.lolicon.best
|
||||||
|
names:
|
||||||
|
kind: MinecraftServer
|
||||||
|
listKind: MinecraftServerList
|
||||||
|
plural: minecraftservers
|
||||||
|
singular: minecraftserver
|
||||||
|
scope: Namespaced
|
||||||
|
versions:
|
||||||
|
- name: v1alpha1
|
||||||
|
schema:
|
||||||
|
openAPIV3Schema:
|
||||||
|
description: |-
|
||||||
|
MinecraftServer is the lifecycle source-of-truth for a single managed
|
||||||
|
Minecraft server (spec §4). The operator reconciles the StatefulSet, Service
|
||||||
|
and PVC from this object; readiness is gated exclusively on an RCON probe.
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
description: |-
|
||||||
|
APIVersion defines the versioned schema of this representation of an object.
|
||||||
|
Servers should convert recognized schemas to the latest internal value, and
|
||||||
|
may reject unrecognized values.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
description: |-
|
||||||
|
Kind is a string value representing the REST resource this object represents.
|
||||||
|
Servers may infer this from the endpoint the client submits requests to.
|
||||||
|
Cannot be updated.
|
||||||
|
In CamelCase.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
spec:
|
||||||
|
description: MinecraftServerSpec is the desired state (spec §4 spec.*).
|
||||||
|
properties:
|
||||||
|
args:
|
||||||
|
description: Args are extra arguments appended after the jar.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
autostartPolicy:
|
||||||
|
description: AutostartPolicy controls who may wake the server (spec
|
||||||
|
§8).
|
||||||
|
enum:
|
||||||
|
- public
|
||||||
|
- allowlist
|
||||||
|
- ownerOnly
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
description: Description is free-form operator/owner notes.
|
||||||
|
type: string
|
||||||
|
desiredState:
|
||||||
|
description: DesiredState toggles the server up or down (default Stopped).
|
||||||
|
enum:
|
||||||
|
- Running
|
||||||
|
- Stopped
|
||||||
|
type: string
|
||||||
|
displayName:
|
||||||
|
description: DisplayName is the human-facing name shown in the panel
|
||||||
|
and MOTD.
|
||||||
|
type: string
|
||||||
|
env:
|
||||||
|
description: Env are extra environment variables injected into the
|
||||||
|
server container.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
EnvVar is a name/value pair injected into the server container. It is a
|
||||||
|
deliberately narrow subset of corev1.EnvVar (no valueFrom) so the CRD cannot
|
||||||
|
be used to exfiltrate arbitrary cluster secrets.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
fallbackServer:
|
||||||
|
description: |-
|
||||||
|
FallbackServer is the Velocity server name traffic routes to while this
|
||||||
|
server is stopped or starting.
|
||||||
|
type: string
|
||||||
|
idle:
|
||||||
|
description: Idle configures empty-server auto-stop (spec §8).
|
||||||
|
properties:
|
||||||
|
autoStopEnabled:
|
||||||
|
description: AutoStopEnabled turns on idle auto-stop.
|
||||||
|
type: boolean
|
||||||
|
emptySecondsBeforeStop:
|
||||||
|
description: |-
|
||||||
|
EmptySecondsBeforeStop is how long the server may sit empty before the
|
||||||
|
operator scales it down.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
image:
|
||||||
|
description: Image is the fully-qualified container image (loader-agnostic).
|
||||||
|
type: string
|
||||||
|
jar:
|
||||||
|
description: |-
|
||||||
|
Jar is the server jar path/name inside the image, if the entrypoint
|
||||||
|
needs it explicitly.
|
||||||
|
type: string
|
||||||
|
javaFlags:
|
||||||
|
description: JavaFlags are additional JVM flags (e.g. Aikar's flags).
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
javaMemory:
|
||||||
|
description: JavaMemory is the heap sizing passed as -Xmx/-Xms (e.g.
|
||||||
|
"4G").
|
||||||
|
type: string
|
||||||
|
lifecycle:
|
||||||
|
description: Lifecycle tunes graceful shutdown (spec §7).
|
||||||
|
properties:
|
||||||
|
preStopSaveAndStop:
|
||||||
|
description: PreStopSaveAndStop enables the operator-injected
|
||||||
|
RCON save+stop preStop.
|
||||||
|
type: boolean
|
||||||
|
terminationGracePeriodSeconds:
|
||||||
|
description: TerminationGracePeriodSeconds is the pod grace period
|
||||||
|
(default 300).
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
motd:
|
||||||
|
description: Motd holds the per-phase MOTD strings surfaced to status
|
||||||
|
pings.
|
||||||
|
properties:
|
||||||
|
failed:
|
||||||
|
type: string
|
||||||
|
running:
|
||||||
|
type: string
|
||||||
|
starting:
|
||||||
|
type: string
|
||||||
|
stopped:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
onlineMode:
|
||||||
|
description: |-
|
||||||
|
OnlineMode mirrors server.properties online-mode. Wake/claim semantics
|
||||||
|
only hold when the Velocity proxy enforces online-mode=true (spec §8).
|
||||||
|
type: boolean
|
||||||
|
rcon:
|
||||||
|
description: |-
|
||||||
|
Rcon configures the RCON endpoint the operator probes for readiness and
|
||||||
|
uses for graceful shutdown (spec §5, §7).
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
description: Enabled must be true for readiness probing and graceful
|
||||||
|
shutdown.
|
||||||
|
type: boolean
|
||||||
|
port:
|
||||||
|
description: Port is the RCON TCP port (default 25575).
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
secretRef:
|
||||||
|
description: SecretRef points at the Secret holding the RCON password.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
reaperExempt:
|
||||||
|
description: ReaperExempt opts this server out of the world reaper
|
||||||
|
entirely (spec §18).
|
||||||
|
type: boolean
|
||||||
|
resources:
|
||||||
|
description: Resources are the container resource requests/limits.
|
||||||
|
properties:
|
||||||
|
claims:
|
||||||
|
description: |-
|
||||||
|
Claims lists the names of resources, defined in spec.resourceClaims,
|
||||||
|
that are used by this container.
|
||||||
|
|
||||||
|
This is an alpha field and requires enabling the
|
||||||
|
DynamicResourceAllocation feature gate.
|
||||||
|
|
||||||
|
This field is immutable. It can only be set for containers.
|
||||||
|
items:
|
||||||
|
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: |-
|
||||||
|
Name must match the name of one entry in pod.spec.resourceClaims of
|
||||||
|
the Pod where this field is used. It makes that resource available
|
||||||
|
inside a container.
|
||||||
|
type: string
|
||||||
|
request:
|
||||||
|
description: |-
|
||||||
|
Request is the name chosen for a request in the referenced claim.
|
||||||
|
If empty, everything from the claim is made available, otherwise
|
||||||
|
only the result of this request.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- name
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
limits:
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||||
|
x-kubernetes-int-or-string: true
|
||||||
|
description: |-
|
||||||
|
Limits describes the maximum amount of compute resources allowed.
|
||||||
|
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||||
|
type: object
|
||||||
|
requests:
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||||
|
x-kubernetes-int-or-string: true
|
||||||
|
description: |-
|
||||||
|
Requests describes the minimum amount of compute resources required.
|
||||||
|
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
|
||||||
|
otherwise to an implementation-defined value. Requests cannot exceed Limits.
|
||||||
|
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
startup:
|
||||||
|
description: Startup bounds how long Starting may last before Failed
|
||||||
|
(spec §5).
|
||||||
|
properties:
|
||||||
|
readinessTimeoutSeconds:
|
||||||
|
description: ReadinessTimeoutSeconds is the budget for the first
|
||||||
|
successful RCON probe.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
timeoutSeconds:
|
||||||
|
description: TimeoutSeconds is the overall budget before the server
|
||||||
|
is marked Failed.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
storage:
|
||||||
|
description: Storage configures the world PVC.
|
||||||
|
properties:
|
||||||
|
retainOnDelete:
|
||||||
|
description: RetainOnDelete keeps the PVC when the MinecraftServer
|
||||||
|
is deleted.
|
||||||
|
type: boolean
|
||||||
|
size:
|
||||||
|
description: Size is the requested PVC capacity (e.g. "10Gi").
|
||||||
|
type: string
|
||||||
|
storageClassName:
|
||||||
|
description: StorageClassName selects the StorageClass; empty
|
||||||
|
uses the default.
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
subdomain:
|
||||||
|
description: |-
|
||||||
|
Subdomain is the per-server label under the deployment zone. It is the
|
||||||
|
only routing identity; the operator never hardcodes the parent domain.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- image
|
||||||
|
- subdomain
|
||||||
|
type: object
|
||||||
|
status:
|
||||||
|
description: MinecraftServerStatus is the observed state (spec §4 status.*).
|
||||||
|
properties:
|
||||||
|
conditions:
|
||||||
|
description: Conditions are the standard metav1 conditions (Ready,
|
||||||
|
RconReached, ...).
|
||||||
|
items:
|
||||||
|
description: Condition contains details for one aspect of the current
|
||||||
|
state of this API Resource.
|
||||||
|
properties:
|
||||||
|
lastTransitionTime:
|
||||||
|
description: |-
|
||||||
|
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||||
|
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
description: |-
|
||||||
|
message is a human readable message indicating details about the transition.
|
||||||
|
This may be an empty string.
|
||||||
|
maxLength: 32768
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
description: |-
|
||||||
|
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||||
|
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||||
|
with respect to the current state of the instance.
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
reason:
|
||||||
|
description: |-
|
||||||
|
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||||
|
Producers of specific condition types may define expected values and meanings for this field,
|
||||||
|
and whether the values are considered a guaranteed API.
|
||||||
|
The value should be a CamelCase string.
|
||||||
|
This field may not be empty.
|
||||||
|
maxLength: 1024
|
||||||
|
minLength: 1
|
||||||
|
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
description: status of the condition, one of True, False, Unknown.
|
||||||
|
enum:
|
||||||
|
- "True"
|
||||||
|
- "False"
|
||||||
|
- Unknown
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||||
|
maxLength: 316
|
||||||
|
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- lastTransitionTime
|
||||||
|
- message
|
||||||
|
- reason
|
||||||
|
- status
|
||||||
|
- type
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
endpoint:
|
||||||
|
description: Endpoint is where the proxy should route traffic.
|
||||||
|
properties:
|
||||||
|
address:
|
||||||
|
description: Address is the host:port the proxy should dial.
|
||||||
|
type: string
|
||||||
|
mode:
|
||||||
|
description: Mode is "direct" or "fallback".
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
liveMotd:
|
||||||
|
description: LiveMotd is the MOTD currently advertised for the active
|
||||||
|
phase.
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
description: ObservedGeneration is the spec generation this status
|
||||||
|
reflects.
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
phase:
|
||||||
|
description: Phase is the coarse lifecycle phase.
|
||||||
|
type: string
|
||||||
|
players:
|
||||||
|
description: Players is the last observed player count.
|
||||||
|
properties:
|
||||||
|
max:
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
online:
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- max
|
||||||
|
- online
|
||||||
|
type: object
|
||||||
|
ready:
|
||||||
|
description: |-
|
||||||
|
Ready is true only after a successful RCON probe (loader-agnostic; a
|
||||||
|
status ping is never sufficient — spec §5).
|
||||||
|
type: boolean
|
||||||
|
readySignalAt:
|
||||||
|
description: ReadySignalAt is when the first RCON probe succeeded.
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
served: true
|
||||||
|
storage: true
|
||||||
|
subresources:
|
||||||
|
status: {}
|
||||||
Reference in new issue
Block a user