346 lines
9.2 KiB
Go
Executable File
346 lines
9.2 KiB
Go
Executable File
package agenthttp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"zlh-agent/internal/provision"
|
|
"zlh-agent/internal/provision/devcontainer"
|
|
"zlh-agent/internal/provision/devcontainer/go"
|
|
"zlh-agent/internal/provision/devcontainer/java"
|
|
"zlh-agent/internal/provision/devcontainer/node"
|
|
"zlh-agent/internal/provision/devcontainer/python"
|
|
"zlh-agent/internal/provision/minecraft"
|
|
"zlh-agent/internal/state"
|
|
"zlh-agent/internal/system"
|
|
)
|
|
|
|
/* --------------------------------------------------------------------------
|
|
Helpers
|
|
----------------------------------------------------------------------------*/
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
func dirExists(path string) bool {
|
|
s, err := os.Stat(path)
|
|
return err == nil && s.IsDir()
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
Shared provision pipeline (installer + Minecraft verify)
|
|
----------------------------------------------------------------------------*/
|
|
func runProvisionPipeline(cfg *state.Config) error {
|
|
state.SetState(state.StateInstalling)
|
|
state.SetInstallStep("provision_all")
|
|
|
|
if err := provision.ProvisionAll(*cfg); err != nil {
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
return err
|
|
}
|
|
|
|
if strings.ToLower(cfg.Game) == "minecraft" {
|
|
if err := minecraft.VerifyMinecraftInstallWithRepair(*cfg); err != nil {
|
|
state.SetError(err)
|
|
state.SetState(state.StateError)
|
|
return fmt.Errorf("minecraft verification failed: %w", err)
|
|
}
|
|
}
|
|
|
|
state.SetInstallStep("")
|
|
state.SetState(state.StateIdle)
|
|
return nil
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
ensureProvisioned() — idempotent, unified
|
|
----------------------------------------------------------------------------*/
|
|
func ensureProvisioned(cfg *state.Config) error {
|
|
|
|
if cfg.ContainerType == "dev" {
|
|
|
|
if !devcontainer.IsProvisioned() {
|
|
if err := runProvisionPipeline(cfg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
var err error
|
|
|
|
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)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// ✅ DEV READY = RUNNING
|
|
state.SetState(state.StateRunning)
|
|
state.SetError(nil)
|
|
return nil
|
|
}
|
|
|
|
|
|
dir := provision.ServerDir(*cfg)
|
|
game := strings.ToLower(cfg.Game)
|
|
variant := strings.ToLower(cfg.Variant)
|
|
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
|
|
isSteam := provision.IsSteamGame(game)
|
|
isForgeLike := variant == "forge" || variant == "neoforge"
|
|
|
|
if isSteam {
|
|
return runProvisionPipeline(cfg)
|
|
}
|
|
|
|
if isForgeLike {
|
|
runSh := filepath.Join(dir, "run.sh")
|
|
libraries := filepath.Join(dir, "libraries")
|
|
|
|
if fileExists(runSh) && dirExists(libraries) {
|
|
return nil
|
|
}
|
|
return runProvisionPipeline(cfg)
|
|
}
|
|
|
|
jar := filepath.Join(dir, "server.jar")
|
|
if fileExists(jar) {
|
|
return nil
|
|
}
|
|
|
|
return runProvisionPipeline(cfg)
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/config — the REAL provisioning trigger (async)
|
|
----------------------------------------------------------------------------*/
|
|
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, _ := io.ReadAll(r.Body)
|
|
|
|
var cfg state.Config
|
|
if err := json.Unmarshal(body, &cfg); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := state.SaveConfig(&cfg); err != nil {
|
|
http.Error(w, "save config failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
go func(c state.Config) {
|
|
log.Println("[agent] async provision+start begin")
|
|
|
|
if err := ensureProvisioned(&c); err != nil {
|
|
log.Println("[agent] provision error:", err)
|
|
return
|
|
}
|
|
|
|
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")
|
|
}(cfg)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = w.Write([]byte(`{"ok": true, "state": "installing"}`))
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/start
|
|
----------------------------------------------------------------------------*/
|
|
func handleStart(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := state.LoadConfig()
|
|
if err != nil {
|
|
http.Error(w, "no config: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if cfg.ContainerType == "dev" {
|
|
http.Error(w, "dev containers do not support manual start", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := system.StartServer(cfg); err != nil {
|
|
http.Error(w, "start error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/stop
|
|
----------------------------------------------------------------------------*/
|
|
func handleStop(w http.ResponseWriter, r *http.Request) {
|
|
if err := system.StopServer(); err != nil {
|
|
http.Error(w, "stop error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/restart
|
|
----------------------------------------------------------------------------*/
|
|
func handleRestart(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := state.LoadConfig()
|
|
if err != nil {
|
|
http.Error(w, "no config", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if cfg.ContainerType == "dev" {
|
|
http.Error(w, "dev containers do not support restart", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_ = system.StopServer()
|
|
|
|
if err := system.StartServer(cfg); err != nil {
|
|
http.Error(w, "restart error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/status
|
|
----------------------------------------------------------------------------*/
|
|
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
cfg, _ := state.LoadConfig()
|
|
|
|
resp := map[string]any{
|
|
"state": state.GetState(),
|
|
"installStep": state.GetInstallStep(),
|
|
"crashCount": state.GetCrashCount(),
|
|
"error": nil,
|
|
"config": cfg,
|
|
"timestamp": time.Now().Unix(),
|
|
}
|
|
|
|
if err := state.GetError(); err != nil {
|
|
resp["error"] = err.Error()
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
/console/command
|
|
----------------------------------------------------------------------------*/
|
|
func handleSendCommand(w http.ResponseWriter, r *http.Request) {
|
|
cmd := r.URL.Query().Get("cmd")
|
|
if cmd == "" {
|
|
http.Error(w, "cmd required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := system.SendConsoleCommand(cmd); err != nil {
|
|
http.Error(w, "command error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
/* --------------------------------------------------------------------------
|
|
Router
|
|
----------------------------------------------------------------------------*/
|
|
func NewMux() *http.ServeMux {
|
|
m := http.NewServeMux()
|
|
|
|
m.HandleFunc("/config", handleConfig)
|
|
m.HandleFunc("/start", handleStart)
|
|
m.HandleFunc("/stop", handleStop)
|
|
m.HandleFunc("/restart", handleRestart)
|
|
m.HandleFunc("/status", handleStatus)
|
|
m.HandleFunc("/console/command", handleSendCommand)
|
|
|
|
registerWebSocket(m)
|
|
|
|
m.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("ok"))
|
|
})
|
|
|
|
log.Println("[agent] routes registered")
|
|
return m
|
|
}
|