agent update 12-21-25

This commit is contained in:
jester 2025-12-21 22:11:44 +00:00
parent 146737cf50
commit 8645548c4a
17 changed files with 312 additions and 251 deletions

View File

@ -48,7 +48,6 @@ func runProvisionPipeline(cfg *state.Config) error {
return err
}
// Extra Minecraft verification ONLY for Minecraft
if strings.ToLower(cfg.Game) == "minecraft" {
if err := minecraft.VerifyMinecraftInstallWithRepair(*cfg); err != nil {
state.SetError(err)
@ -67,39 +66,40 @@ func runProvisionPipeline(cfg *state.Config) error {
----------------------------------------------------------------------------*/
func ensureProvisioned(cfg *state.Config) error {
/* ---------------------------------------------------------
DEV CONTAINERS (FIRST-CLASS)
--------------------------------------------------------- */
if cfg.ContainerType == "dev" {
if cfg.ContainerType == "dev" {
// If not provisioned yet, install
if !devcontainer.IsProvisioned() {
return runProvisionPipeline(cfg)
}
if !devcontainer.IsProvisioned() {
if err := runProvisionPipeline(cfg); err != nil {
return err
}
}
// Verify runtime
switch strings.ToLower(cfg.Runtime) {
var err error
case "node":
return node.Verify(*cfg)
switch strings.ToLower(cfg.Runtime) {
case "node":
err = node.Verify(*cfg)
case "python":
err = python.Verify(*cfg)
case "go":
err = goenv.Verify(*cfg)
case "java":
err = java.Verify(*cfg)
default:
return fmt.Errorf("unsupported devcontainer runtime: %s", cfg.Runtime)
}
case "python":
return python.Verify(*cfg)
if err != nil {
return err
}
case "go":
return goenv.Verify(*cfg)
// ✅ DEV READY = RUNNING
state.SetState(state.StateRunning)
state.SetError(nil)
return nil
}
case "java":
return java.Verify(*cfg)
default:
return fmt.Errorf("unsupported devcontainer runtime: %s", cfg.Runtime)
}
}
/* ---------------------------------------------------------
GAME SERVERS (EXISTING LOGIC)
--------------------------------------------------------- */
dir := provision.ServerDir(*cfg)
game := strings.ToLower(cfg.Game)
variant := strings.ToLower(cfg.Variant)
@ -109,12 +109,10 @@ func ensureProvisioned(cfg *state.Config) error {
isSteam := provision.IsSteamGame(game)
isForgeLike := variant == "forge" || variant == "neoforge"
// ---------- STEAM GAMES ALWAYS REQUIRE PROVISION ----------
if isSteam {
return runProvisionPipeline(cfg)
}
// ---------- FORGE / NEOFORGE ----------
if isForgeLike {
runSh := filepath.Join(dir, "run.sh")
libraries := filepath.Join(dir, "libraries")
@ -122,11 +120,9 @@ func ensureProvisioned(cfg *state.Config) error {
if fileExists(runSh) && dirExists(libraries) {
return nil
}
return runProvisionPipeline(cfg)
}
// ---------- VANILLA / PAPER / PURPUR / FABRIC ----------
jar := filepath.Join(dir, "server.jar")
if fileExists(jar) {
return nil
@ -165,14 +161,54 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
return
}
// Dev containers may not start a server process
if c.ContainerType != "dev" {
if err := system.StartServer(&c); err != nil {
log.Println("[agent] start error:", err)
state.SetError(err)
state.SetState(state.StateError)
return
}
// -------------------------------------------------
// FORGE / NEOFORGE: wait → stop → patch → restart
// -------------------------------------------------
game := strings.ToLower(c.Game)
variant := strings.ToLower(c.Variant)
if game == "minecraft" && (variant == "forge" || variant == "neoforge") {
deadline := time.Now().Add(5 * time.Minute)
for {
if state.GetState() == state.StateRunning {
break
}
if time.Now().After(deadline) {
err := fmt.Errorf("forge did not reach running state")
log.Println("[agent]", err)
state.SetError(err)
state.SetState(state.StateError)
return
}
time.Sleep(2 * time.Second)
}
_ = system.StopServer()
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
log.Println("[agent] forge post-start error:", err)
state.SetError(err)
state.SetState(state.StateError)
return
}
if err := system.StartServer(&c); err != nil {
log.Println("[agent] restart error:", err)
state.SetError(err)
state.SetState(state.StateError)
return
}
}
}
log.Println("[agent] async provision+start complete")
@ -184,7 +220,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
}
/* --------------------------------------------------------------------------
/start manual UI start
/start
----------------------------------------------------------------------------*/
func handleStart(w http.ResponseWriter, r *http.Request) {
cfg, err := state.LoadConfig()
@ -245,7 +281,7 @@ func handleRestart(w http.ResponseWriter, r *http.Request) {
}
/* --------------------------------------------------------------------------
/status API polls this
/status
----------------------------------------------------------------------------*/
func handleStatus(w http.ResponseWriter, r *http.Request) {
cfg, _ := state.LoadConfig()

View File

@ -2,7 +2,6 @@ package goenv
import (
"fmt"
"path/filepath"
"zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers"
@ -16,14 +15,9 @@ func Install(cfg state.Config) error {
return nil
}
scriptPath := filepath.Join(
executil.ScriptsRoot,
"devcontainer",
"go",
"install.sh",
)
if err := executil.RunScript(scriptPath); err != nil {
if err := executil.RunEmbeddedScript(
"devcontainer/go/install.sh",
); err != nil {
return fmt.Errorf("go devcontainer install failed: %w", err)
}

View File

@ -2,14 +2,22 @@ package goenv
import (
"fmt"
"os"
"os/exec"
"zlh-agent/internal/state"
)
const goBin = "/opt/zlh/runtime/go/bin/go"
func Verify(cfg state.Config) error {
if _, err := exec.LookPath("go"); err != nil {
return fmt.Errorf("go binary not found in PATH")
if _, err := os.Stat(goBin); err != nil {
return fmt.Errorf("go binary missing at %s", goBin)
}
if err := exec.Command(goBin, "version").Run(); err != nil {
return fmt.Errorf("go runtime not executable: %w", err)
}
return nil
}

View File

@ -2,7 +2,6 @@ package java
import (
"fmt"
"path/filepath"
"zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers"
@ -10,21 +9,17 @@ import (
)
func Install(cfg state.Config) error {
const marker = "devcontainer-java"
if markers.IsPresent("devcontainer-java") {
if markers.IsPresent(marker) {
return nil
}
scriptPath := filepath.Join(
executil.ScriptsRoot,
"devcontainer",
"java",
"install.sh",
)
if err := executil.RunScript(scriptPath); err != nil {
if err := executil.RunEmbeddedScript(
"devcontainer/java/install.sh",
); err != nil {
return fmt.Errorf("java devcontainer install failed: %w", err)
}
return markers.Write("devcontainer-java")
return markers.Write(marker)
}

View File

@ -2,14 +2,22 @@ package java
import (
"fmt"
"os"
"os/exec"
"zlh-agent/internal/state"
)
const javaBin = "/opt/zlh/runtime/java/bin/java"
func Verify(cfg state.Config) error {
if _, err := exec.LookPath("java"); err != nil {
return fmt.Errorf("java binary not found in PATH")
if _, err := os.Stat(javaBin); err != nil {
return fmt.Errorf("java binary missing at %s", javaBin)
}
if err := exec.Command(javaBin, "-version").Run(); err != nil {
return fmt.Errorf("java runtime not executable: %w", err)
}
return nil
}

View File

@ -17,7 +17,7 @@ func Install(cfg state.Config) error {
// Execute embedded installer (mirrors game server model)
if err := executil.RunEmbeddedScript(
"scripts/devcontainer/node/install.sh",
"devcontainer/node/install.sh",
); err != nil {
return fmt.Errorf("node devcontainer install failed: %w", err)
}

View File

@ -2,21 +2,34 @@ package node
import (
"fmt"
"os"
"os/exec"
"zlh-agent/internal/state"
)
const (
nodeBin = "/opt/zlh/runtime/node/bin/node"
npmBin = "/opt/zlh/runtime/node/bin/npm"
)
func Verify(cfg state.Config) error {
// Version is optional at verify-time; existence is authoritative
if _, err := exec.LookPath("node"); err != nil {
return fmt.Errorf("node binary not found in PATH")
// Node must exist
if _, err := os.Stat(nodeBin); err != nil {
return fmt.Errorf("node binary missing at %s", nodeBin)
}
if _, err := exec.LookPath("npm"); err != nil {
return fmt.Errorf("npm binary not found in PATH")
// Node must execute
if err := exec.Command(nodeBin, "-v").Run(); err != nil {
return fmt.Errorf("node runtime not executable: %w", err)
}
// npm must exist (installation completeness)
if _, err := os.Stat(npmBin); err != nil {
return fmt.Errorf("npm missing at %s", npmBin)
}
// Do NOT execute npm here — it is PATH-dependent by design
return nil
}

View File

@ -2,7 +2,6 @@ package python
import (
"fmt"
"path/filepath"
"zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers"
@ -16,14 +15,9 @@ func Install(cfg state.Config) error {
return nil
}
scriptPath := filepath.Join(
executil.ScriptsRoot,
"devcontainer",
"python",
"install.sh",
)
if err := executil.RunScript(scriptPath); err != nil {
if err := executil.RunEmbeddedScript(
"devcontainer/python/install.sh",
); err != nil {
return fmt.Errorf("python devcontainer install failed: %w", err)
}

View File

@ -2,17 +2,34 @@ package python
import (
"fmt"
"os"
"os/exec"
"zlh-agent/internal/state"
)
const (
pythonBin = "/opt/zlh/runtime/python/bin/python3"
pipBin = "/opt/zlh/runtime/python/bin/pip3"
)
func Verify(cfg state.Config) error {
if _, err := exec.LookPath("python3"); err != nil {
return fmt.Errorf("python3 binary not found in PATH")
// python3 must exist
if _, err := os.Stat(pythonBin); err != nil {
return fmt.Errorf("python3 binary missing at %s", pythonBin)
}
if _, err := exec.LookPath("pip3"); err != nil {
return fmt.Errorf("pip3 binary not found in PATH")
// python3 must execute
if err := exec.Command(pythonBin, "--version").Run(); err != nil {
return fmt.Errorf("python3 runtime not executable: %w", err)
}
// pip must exist (completeness check only)
if _, err := os.Stat(pipBin); err != nil {
return fmt.Errorf("pip3 missing at %s", pipBin)
}
// Do NOT execute pip (PATH + env dependent)
return nil
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"zlh-agent/scripts"
)
@ -12,7 +13,8 @@ import (
// RunEmbeddedScript executes an embedded script via bash by piping its contents to stdin.
// This mirrors RunScript's stdout/stderr behavior without requiring any files on disk.
func RunEmbeddedScript(path string) error {
data, err := scripts.FS.ReadFile(path)
normalized := normalizeEmbeddedPath(path)
data, err := scripts.FS.ReadFile(normalized)
if err != nil {
return fmt.Errorf("embedded script not found: %s", path)
}
@ -25,4 +27,8 @@ func RunEmbeddedScript(path string) error {
cmd.Stderr = os.Stderr
return cmd.Run()
}
}
func normalizeEmbeddedPath(path string) string {
return strings.TrimPrefix(path, "scripts/")
}

View File

@ -0,0 +1,21 @@
package executil
import (
"testing"
"zlh-agent/scripts"
)
func TestNormalizeEmbeddedPath(t *testing.T) {
paths := []string{
"devcontainer/node/install.sh",
"scripts/devcontainer/node/install.sh",
}
for _, path := range paths {
normalized := normalizeEmbeddedPath(path)
if _, err := scripts.FS.ReadFile(normalized); err != nil {
t.Fatalf("expected embedded script to resolve for %q (normalized to %q): %v", path, normalized, err)
}
}
}

View File

@ -0,0 +1,53 @@
package minecraft
import (
"fmt"
"os"
"path/filepath"
"strings"
"zlh-agent/internal/state"
)
/*
EnforceForgeServerProperties
- Runs AFTER first Forge/NeoForge server start
- Ensures Velocity-compatible settings
- Requires restart to take effect
*/
func EnforceForgeServerProperties(cfg state.Config) error {
variant := strings.ToLower(cfg.Variant)
if variant != "forge" && variant != "neoforge" {
return nil
}
// Forge working directory is authoritative
serverDir := filepath.Join("/opt/zlh/minecraft", variant, "world")
propsPath := filepath.Join(serverDir, "server.properties")
data, err := os.ReadFile(propsPath)
if err != nil {
return fmt.Errorf("read server.properties: %w", err)
}
lines := strings.Split(string(data), "\n")
found := false
for i, l := range lines {
if strings.HasPrefix(l, "online-mode=") {
lines[i] = "online-mode=false"
found = true
}
}
if !found {
lines = append(lines, "online-mode=false")
}
out := strings.Join(lines, "\n")
if err := os.WriteFile(propsPath, []byte(out), 0644); err != nil {
return fmt.Errorf("write server.properties: %w", err)
}
return nil
}

View File

@ -1,51 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[go] starting go devcontainer install"
RUNTIME="go"
ARCHIVE_FILE="go-${DEST_VERSION}.tar.gz"
# --------------------------------------------------
# Config
# --------------------------------------------------
RUNTIME_ROOT="/opt/zlh/runtime/go"
ARTIFACT_ROOT="/opt/zlh/devcontainer/go"
VERSION="${RUNTIME_VERSION:-1.25}"
ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
DEST_DIR="${RUNTIME_ROOT}/${DEST_VERSION}"
ARCHIVE_FILE="go-${DEST_VERSION}.tar.gz"
URL="${ARTIFACT_BASE_URL%/}/devcontainer/go/${DEST_VERSION}/${ARCHIVE_FILE}"
SRC_DIR="${ARTIFACT_ROOT}/${VERSION}"
DEST_DIR="${RUNTIME_ROOT}/${VERSION}"
SYMLINK="${RUNTIME_ROOT}/current"
mkdir -p "${RUNTIME_ROOT}"
# --------------------------------------------------
# Guards
# --------------------------------------------------
if [ ! -d "${SRC_DIR}" ]; then
echo "[go][ERROR] artifact directory not found: ${SRC_DIR}"
exit 1
fi
if [ -x "${DEST_DIR}/bin/go" ]; then
echo "[go] go ${VERSION} already installed"
else
ARCHIVE="$(ls ${SRC_DIR}/go*.linux-amd64.tar.gz 2>/dev/null | head -n1)"
if [ -z "${ARCHIVE}" ]; then
echo "[go][ERROR] no Go archive found in ${SRC_DIR}"
exit 1
fi
echo "[go] installing go ${VERSION}"
if [ ! -d "${DEST_DIR}" ]; then
curl -fL "${URL}" -o /tmp/${ARCHIVE_FILE}
mkdir -p "${DEST_DIR}"
tar -xzf "${ARCHIVE}" -C "${DEST_DIR}" --strip-components=1
tar -xf /tmp/${ARCHIVE_FILE} -C "${DEST_DIR}" --strip-components=1
fi
# --------------------------------------------------
# Symlink current
# --------------------------------------------------
ln -sfn "${DEST_DIR}" "${SYMLINK}"
ln -sfn "${DEST_DIR}" "${RUNTIME_ROOT}/current"
ln -sfn "${DEST_DIR}/bin" "${RUNTIME_ROOT}/bin"
# --------------------------------------------------
# Verify
# --------------------------------------------------
"${SYMLINK}/bin/go" version
echo "[go] go ${VERSION} install complete"
cat >/etc/profile.d/zlh-go.sh <<'EOF'
export PATH="/opt/zlh/runtime/go/bin:$PATH"
EOF
chmod +x /etc/profile.d/zlh-go.sh

View File

@ -1,62 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[java] starting java devcontainer install"
RUNTIME="java"
ARCHIVE_FILE="jdk-${DEST_VERSION}.tar.gz"
# --------------------------------------------------
# Config
# --------------------------------------------------
RUNTIME_ROOT="/opt/zlh/runtime/java"
ARTIFACT_ROOT="/opt/zlh/devcontainer/java"
ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
DEST_DIR="${RUNTIME_ROOT}/${DEST_VERSION}"
ARCHIVE_FILE="jdk-${DEST_VERSION}.tar.gz"
URL="${ARTIFACT_BASE_URL%/}/devcontainer/java/${DEST_VERSION}/${ARCHIVE_FILE}"
VERSION="${RUNTIME_VERSION:-17}"
mkdir -p "${RUNTIME_ROOT}"
SRC_DIR="${ARTIFACT_ROOT}/${VERSION}"
DEST_DIR="${RUNTIME_ROOT}/${VERSION}"
SYMLINK="${RUNTIME_ROOT}/current"
# --------------------------------------------------
# Guards
# --------------------------------------------------
if [ ! -d "${SRC_DIR}" ]; then
echo "[java][ERROR] artifact directory not found: ${SRC_DIR}"
exit 1
fi
if [ -x "${DEST_DIR}/bin/java" ]; then
echo "[java] java ${VERSION} already installed"
else
ARCHIVE="$(ls ${SRC_DIR}/*.tar.gz 2>/dev/null | head -n1)"
if [ -z "${ARCHIVE}" ]; then
echo "[java][ERROR] no Java archive found in ${SRC_DIR}"
exit 1
fi
echo "[java] installing java ${VERSION}"
if [ ! -d "${DEST_DIR}" ]; then
curl -fL "${URL}" -o /tmp/${ARCHIVE_FILE}
mkdir -p "${DEST_DIR}"
TMP_DIR="$(mktemp -d)"
tar -xzf "${ARCHIVE}" -C "${TMP_DIR}"
# Java archives contain a single root dir
EXTRACTED_DIR="$(find ${TMP_DIR} -maxdepth 1 -type d -name 'jdk*' | head -n1)"
if [ -z "${EXTRACTED_DIR}" ]; then
echo "[java][ERROR] failed to locate extracted jdk directory"
exit 1
fi
mv "${EXTRACTED_DIR}"/* "${DEST_DIR}/"
rm -rf "${TMP_DIR}"
tar -xf /tmp/${ARCHIVE_FILE} -C "${DEST_DIR}" --strip-components=1
fi
# --------------------------------------------------
# Symlink current
# --------------------------------------------------
ln -sfn "${DEST_DIR}" "${SYMLINK}"
ln -sfn "${DEST_DIR}" "${RUNTIME_ROOT}/current"
ln -sfn "${DEST_DIR}/bin" "${RUNTIME_ROOT}/bin"
# --------------------------------------------------
# Verify
# --------------------------------------------------
"${SYMLINK}/bin/java" -version
echo "[java] java ${VERSION} install complete"
cat >/etc/profile.d/zlh-java.sh <<'EOF'
export PATH="/opt/zlh/runtime/java/bin:$PATH"
EOF
chmod +x /etc/profile.d/zlh-java.sh

View File

@ -1,40 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
RUNTIME_ROOT="/opt/zlh/runtime/node"
ARTIFACT_ROOT="/opt/zlh/devcontainer/node"
RUNTIME="node"
RUNTIME_ROOT="/opt/zlh/runtime/${RUNTIME}"
DEST_VERSION="${RUNTIME_VERSION:?RUNTIME_VERSION required}"
VERSION="${RUNTIME_VERSION:-24}"
ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
ARCHIVE_FILE="${RUNTIME}-${DEST_VERSION}.tar.xz"
URL="${ARTIFACT_BASE_URL%/}/devcontainer/${RUNTIME}/${DEST_VERSION}/${ARCHIVE_FILE}"
ARCHIVE="node-v${VERSION}.*-linux-x64.tar.xz"
SRC_DIR="${ARTIFACT_ROOT}/${VERSION}"
DEST_DIR="${RUNTIME_ROOT}/${VERSION}"
echo "[node] Installing Node.js version ${VERSION}"
DEST_DIR="${RUNTIME_ROOT}/${DEST_VERSION}"
echo "[${RUNTIME}] Installing ${RUNTIME} ${DEST_VERSION}"
# Ensure runtime root exists
mkdir -p "${RUNTIME_ROOT}"
# Idempotency check
if [ -d "${DEST_DIR}" ]; then
echo "[node] Node ${VERSION} already installed"
else
ARCHIVE_PATH=$(ls "${SRC_DIR}/${ARCHIVE}" 2>/dev/null || true)
if [ -z "${ARCHIVE_PATH}" ]; then
echo "[node][ERROR] Artifact not found for Node ${VERSION}"
exit 1
fi
echo "[node] Extracting ${ARCHIVE_PATH}"
if [ ! -d "${DEST_DIR}" ]; then
curl -fL "${URL}" -o /tmp/${ARCHIVE_FILE}
mkdir -p "${DEST_DIR}"
tar -xJf "${ARCHIVE_PATH}" -C "${DEST_DIR}" --strip-components=1
tar -xf /tmp/${ARCHIVE_FILE} -C "${DEST_DIR}" --strip-components=1
fi
# Update stable symlink
ln -sfn "${DEST_DIR}" "${RUNTIME_ROOT}/current"
ln -sfn "${DEST_DIR}/bin" "${RUNTIME_ROOT}/bin"
cat >/etc/profile.d/zlh-${RUNTIME}.sh <<EOF
export PATH="/opt/zlh/runtime/${RUNTIME}/bin:\$PATH"
EOF
chmod +x /etc/profile.d/zlh-${RUNTIME}.sh
# Permissions sanity
chmod -R 755 "${DEST_DIR}"
echo "[node] Node ${VERSION} installed successfully"

View File

@ -1,72 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[python] starting python devcontainer install"
RUNTIME="python"
ARCHIVE_FILE="python-${DEST_VERSION}.tar.xz"
# --------------------------------------------------
# Config
# --------------------------------------------------
RUNTIME_ROOT="/opt/zlh/runtime/python"
ARTIFACT_ROOT="/opt/zlh/devcontainer/python"
VERSION="${RUNTIME_VERSION:-3.12}"
ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
SRC_DIR="${ARTIFACT_ROOT}/${VERSION}"
DEST_DIR="${RUNTIME_ROOT}/${VERSION}"
SYMLINK="${RUNTIME_ROOT}/current"
DEST_DIR="${RUNTIME_ROOT}/${DEST_VERSION}"
TMP_DIR="/tmp/zlh-python-install"
ARCHIVE_FILE="python-${DEST_VERSION}.tar.xz"
URL="${ARTIFACT_BASE_URL%/}/devcontainer/python/${DEST_VERSION}/${ARCHIVE_FILE}"
# --------------------------------------------------
# Guards
# --------------------------------------------------
if [ ! -d "${SRC_DIR}" ]; then
echo "[python][ERROR] artifact directory not found: ${SRC_DIR}"
exit 1
fi
echo "[python] Installing Python ${DEST_VERSION}"
if [ -x "${DEST_DIR}/bin/python3" ]; then
echo "[python] python ${VERSION} already installed"
mkdir -p "${RUNTIME_ROOT}"
# Idempotency
if [ -d "${DEST_DIR}" ]; then
echo "[python] Python ${DEST_VERSION} already installed at ${DEST_DIR}"
else
ARCHIVE="$(ls ${SRC_DIR}/Python-*.tgz 2>/dev/null | head -n1)"
if [ -z "${ARCHIVE}" ]; then
echo "[python][ERROR] no Python archive found in ${SRC_DIR}"
echo "[python] Downloading ${URL}"
rm -rf "${TMP_DIR}"
mkdir -p "${TMP_DIR}"
if command -v curl >/dev/null 2>&1; then
curl -fL "${URL}" -o "${TMP_DIR}/${ARCHIVE_FILE}"
elif command -v wget >/dev/null 2>&1; then
wget -O "${TMP_DIR}/${ARCHIVE_FILE}" "${URL}"
else
echo "[python][ERROR] curl or wget is required"
exit 1
fi
echo "[python] building python ${VERSION}"
echo "[python] Extracting ${ARCHIVE_FILE} -> ${DEST_DIR}"
mkdir -p "${DEST_DIR}"
BUILD_DIR="$(mktemp -d)"
tar -xzf "${ARCHIVE}" -C "${BUILD_DIR}"
PY_SRC="$(find ${BUILD_DIR} -maxdepth 1 -type d -name 'Python-*' | head -n1)"
if [ -z "${PY_SRC}" ]; then
echo "[python][ERROR] failed to locate extracted python source"
exit 1
fi
cd "${PY_SRC}"
./configure \
--prefix="${DEST_DIR}" \
--enable-optimizations \
--with-ensurepip=install
make -j"$(nproc)"
make install
cd /
rm -rf "${BUILD_DIR}"
tar -xf "${TMP_DIR}/${ARCHIVE_FILE}" -C "${DEST_DIR}" --strip-components=1
fi
# --------------------------------------------------
# Symlink current
# --------------------------------------------------
ln -sfn "${DEST_DIR}" "${SYMLINK}"
# Stable symlinks (same model as Node)
ln -sfn "${DEST_DIR}" "${RUNTIME_ROOT}/current"
ln -sfn "${DEST_DIR}/bin" "${RUNTIME_ROOT}/bin"
# --------------------------------------------------
# Verify
# --------------------------------------------------
"${SYMLINK}/bin/python3" --version
"${SYMLINK}/bin/pip3" --version
# System-wide PATH export
cat >/etc/profile.d/zlh-python.sh <<'EOF'
export PATH="/opt/zlh/runtime/python/bin:$PATH"
EOF
echo "[python] python ${VERSION} install complete"
chmod +x /etc/profile.d/zlh-python.sh
# Permissions sanity
chmod -R 755 "${DEST_DIR}"
echo "[python] Python ${DEST_VERSION} installed successfully"

BIN
zlh-agent

Binary file not shown.