3-14-26 updates
This commit is contained in:
parent
243a201a64
commit
6019d0bc1c
@ -32,6 +32,8 @@ const (
|
||||
shadowMetadataName = "metadata.json"
|
||||
MaxModUploadSize = 250 * 1024 * 1024
|
||||
MaxDataPackSize = 100 * 1024 * 1024
|
||||
MaxDevUploadSize = 250 * 1024 * 1024
|
||||
DevWorkspaceRoot = "/home/dev/workspace"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -96,6 +98,9 @@ type Meta struct {
|
||||
}
|
||||
|
||||
func RuntimeRoot(cfg *state.Config) string {
|
||||
if cfg != nil && strings.EqualFold(cfg.ContainerType, "dev") {
|
||||
return DevWorkspaceRoot
|
||||
}
|
||||
return mods.ResolveServerRoot(cfg)
|
||||
}
|
||||
|
||||
@ -229,7 +234,7 @@ func List(root, rel string) (ListResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Stat(root, rel string) (StatResponse, error) {
|
||||
func Stat(containerType, root, rel string) (StatResponse, error) {
|
||||
resolvedPath, responsePath, err := ResolveWorldPath(root, rel)
|
||||
if err != nil {
|
||||
return StatResponse{}, err
|
||||
@ -255,7 +260,7 @@ func Stat(root, rel string) (StatResponse, error) {
|
||||
Type: entryType,
|
||||
Size: info.Size(),
|
||||
Modified: info.ModTime().UTC().Format(time.RFC3339),
|
||||
IsWritable: isWritablePath(root, responsePath),
|
||||
IsWritable: isWritablePath(containerType, root, responsePath),
|
||||
HasBackup: hasBackup(root, responsePath),
|
||||
Source: sourceForPath(root, responsePath),
|
||||
}, nil
|
||||
@ -317,7 +322,7 @@ func OpenDownload(root, rel string) (*os.File, os.FileInfo, string, error) {
|
||||
return file, info, filepath.Base(resolvedPath), nil
|
||||
}
|
||||
|
||||
func Delete(root, rel string) (string, error) {
|
||||
func Delete(containerType, root, rel string) (string, error) {
|
||||
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -327,7 +332,7 @@ func Delete(root, rel string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isAllowedDelete(normalizedRel) {
|
||||
if !isAllowedDelete(containerType, normalizedRel) {
|
||||
return "", ErrDeleteDenied
|
||||
}
|
||||
|
||||
@ -364,7 +369,7 @@ func Delete(root, rel string) (string, error) {
|
||||
return normalizedRel, nil
|
||||
}
|
||||
|
||||
func Write(root, rel string, data []byte) (bool, error) {
|
||||
func Write(containerType, root, rel string, data []byte) (bool, error) {
|
||||
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return false, err
|
||||
@ -374,7 +379,7 @@ func Write(root, rel string, data []byte) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !isAllowedWrite(normalizedRel) {
|
||||
if !isAllowedWrite(containerType, normalizedRel) {
|
||||
return false, ErrWriteDenied
|
||||
}
|
||||
if len(data) > MaxWriteSize {
|
||||
@ -415,7 +420,7 @@ func Write(root, rel string, data []byte) (bool, error) {
|
||||
return backupCreated, nil
|
||||
}
|
||||
|
||||
func Revert(root, rel string) (string, error) {
|
||||
func Revert(containerType, root, rel string) (string, error) {
|
||||
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -425,7 +430,7 @@ func Revert(root, rel string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isAllowedWrite(normalizedRel) {
|
||||
if !isAllowedWrite(containerType, normalizedRel) {
|
||||
return "", ErrWriteDenied
|
||||
}
|
||||
|
||||
@ -466,7 +471,7 @@ func Revert(root, rel string) (string, error) {
|
||||
return normalizedRel, nil
|
||||
}
|
||||
|
||||
func Upload(root, rel string, r io.Reader, sizeLimit int64, overwrite bool) (int64, bool, error) {
|
||||
func Upload(containerType, root, rel string, r io.Reader, sizeLimit int64, overwrite bool) (int64, bool, error) {
|
||||
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
@ -476,7 +481,7 @@ func Upload(root, rel string, r io.Reader, sizeLimit int64, overwrite bool) (int
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
allowedLimit, ok := uploadLimitForPath(normalizedRel)
|
||||
allowedLimit, ok := uploadLimitForPath(containerType, normalizedRel)
|
||||
if !ok {
|
||||
return 0, false, ErrUploadDenied
|
||||
}
|
||||
@ -600,7 +605,10 @@ func isBinary(data []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllowedDelete(rel string) bool {
|
||||
func isAllowedDelete(containerType, rel string) bool {
|
||||
if strings.EqualFold(containerType, "dev") {
|
||||
return rel != ""
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
@ -615,7 +623,10 @@ func isAllowedDelete(rel string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedWrite(rel string) bool {
|
||||
func isAllowedWrite(containerType, rel string) bool {
|
||||
if strings.EqualFold(containerType, "dev") {
|
||||
return rel != ""
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 1 {
|
||||
return parts[0] == "server.properties"
|
||||
@ -632,7 +643,10 @@ func isAllowedWrite(rel string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func uploadLimitForPath(rel string) (int64, bool) {
|
||||
func uploadLimitForPath(containerType, rel string) (int64, bool) {
|
||||
if strings.EqualFold(containerType, "dev") {
|
||||
return MaxDevUploadSize, rel != ""
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
switch {
|
||||
case len(parts) == 2 && parts[0] == "mods" && strings.HasSuffix(strings.ToLower(parts[1]), ".jar"):
|
||||
@ -644,8 +658,8 @@ func uploadLimitForPath(rel string) (int64, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func isWritablePath(root, rel string) bool {
|
||||
if !isAllowedWrite(rel) {
|
||||
func isWritablePath(containerType, root, rel string) bool {
|
||||
if !isAllowedWrite(containerType, rel) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
agentfiles "zlh-agent/internal/files"
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
func HandleGameFilesList(w http.ResponseWriter, r *http.Request) {
|
||||
@ -18,7 +19,7 @@ func HandleGameFilesList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
_, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@ -37,12 +38,12 @@ func HandleGameFilesStat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
cfg, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := agentfiles.Stat(serverRoot, r.URL.Query().Get("path"))
|
||||
resp, err := agentfiles.Stat(cfg.ContainerType, serverRoot, r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
writeFilesError(w, err)
|
||||
return
|
||||
@ -56,7 +57,7 @@ func HandleGameFilesRead(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
_, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@ -75,7 +76,7 @@ func HandleGameFilesDownload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
_, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@ -109,7 +110,7 @@ func HandleGameFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
cfg, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@ -147,7 +148,7 @@ func HandleGameFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
size, overwritten, err := agentfiles.Upload(serverRoot, normalizedPath, part, 0, overwrite)
|
||||
size, overwritten, err := agentfiles.Upload(cfg.ContainerType, serverRoot, normalizedPath, part, 0, overwrite)
|
||||
part.Close()
|
||||
if err != nil {
|
||||
writeFilesError(w, err)
|
||||
@ -169,12 +170,12 @@ func HandleGameFilesRevert(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
cfg, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
revertedPath, err := agentfiles.Revert(serverRoot, r.URL.Query().Get("path"))
|
||||
revertedPath, err := agentfiles.Revert(cfg.ContainerType, serverRoot, r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
writeFilesError(w, err)
|
||||
return
|
||||
@ -186,12 +187,12 @@ func HandleGameFilesRevert(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handleGameFilesDelete(w http.ResponseWriter, r *http.Request) {
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
cfg, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deletedPath, err := agentfiles.Delete(serverRoot, r.URL.Query().Get("path"))
|
||||
deletedPath, err := agentfiles.Delete(cfg.ContainerType, serverRoot, r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
writeFilesError(w, err)
|
||||
return
|
||||
@ -203,7 +204,7 @@ func handleGameFilesDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handleGameFilesWrite(w http.ResponseWriter, r *http.Request) {
|
||||
_, serverRoot, ok := requireMinecraftGame(w)
|
||||
cfg, serverRoot, ok := requireFileContainer(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@ -220,7 +221,7 @@ func handleGameFilesWrite(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
backupCreated, err := agentfiles.Write(serverRoot, normalizedPath, data)
|
||||
backupCreated, err := agentfiles.Write(cfg.ContainerType, serverRoot, normalizedPath, data)
|
||||
if err != nil {
|
||||
writeFilesError(w, err)
|
||||
return
|
||||
@ -252,3 +253,19 @@ func writeFilesError(w http.ResponseWriter, err error) {
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func requireFileContainer(w http.ResponseWriter) (*state.Config, string, bool) {
|
||||
cfg, err := state.LoadConfig()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "no config loaded")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
switch strings.ToLower(cfg.ContainerType) {
|
||||
case "game", "dev":
|
||||
return cfg, agentfiles.RuntimeRoot(cfg), true
|
||||
default:
|
||||
writeJSONError(w, http.StatusBadRequest, "unsupported container type")
|
||||
return nil, "", false
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
mcstatus "zlh-agent/internal/minecraft"
|
||||
"zlh-agent/internal/provision"
|
||||
"zlh-agent/internal/provision/devcontainer"
|
||||
"zlh-agent/internal/provision/devcontainer/dotnet"
|
||||
"zlh-agent/internal/provision/devcontainer/go"
|
||||
"zlh-agent/internal/provision/devcontainer/java"
|
||||
"zlh-agent/internal/provision/devcontainer/node"
|
||||
@ -28,6 +29,8 @@ import (
|
||||
"zlh-agent/internal/version"
|
||||
)
|
||||
|
||||
const ReadinessTimeout = 60 * time.Second
|
||||
|
||||
/*
|
||||
--------------------------------------------------------------------------
|
||||
Helpers
|
||||
@ -56,7 +59,7 @@ func waitMinecraftReady(cfg *state.Config, phase string, started time.Time) erro
|
||||
}
|
||||
|
||||
lifecycleLog(cfg, phase, 1, started, "probe_begin")
|
||||
if err := mcstatus.WaitUntilReady(*cfg, 60*time.Second, 3*time.Second); err != nil {
|
||||
if err := mcstatus.WaitUntilReady(*cfg, ReadinessTimeout, 3*time.Second); err != nil {
|
||||
state.SetReadyState(false, "minecraft_ping", err.Error())
|
||||
lifecycleLog(cfg, phase, 1, started, "probe_timeout err=%v", err)
|
||||
return err
|
||||
@ -105,7 +108,7 @@ func ensureProvisioned(cfg *state.Config) error {
|
||||
|
||||
if cfg.ContainerType == "dev" {
|
||||
|
||||
if !devcontainer.IsProvisioned() {
|
||||
if !devcontainer.IsProvisioned() || !devcontainer.RuntimeInstalled(cfg.Runtime, cfg.Version) {
|
||||
if err := runProvisionPipeline(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -122,6 +125,8 @@ func ensureProvisioned(cfg *state.Config) error {
|
||||
err = goenv.Verify(*cfg)
|
||||
case "java":
|
||||
err = java.Verify(*cfg)
|
||||
case "dotnet":
|
||||
err = dotnet.Verify(*cfg)
|
||||
default:
|
||||
return fmt.Errorf("unsupported devcontainer runtime: %s", cfg.Runtime)
|
||||
}
|
||||
@ -192,12 +197,12 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
go func(c state.Config) {
|
||||
log.Println("[agent] async provision+start begin")
|
||||
log.Printf("[http] vmid=%d async provision+start begin", c.VMID)
|
||||
started := time.Now()
|
||||
lifecycleLog(&c, "config_async", 1, started, "begin")
|
||||
|
||||
if err := ensureProvisioned(&c); err != nil {
|
||||
log.Println("[agent] provision error:", err)
|
||||
log.Printf("[http] vmid=%d provision error: %v", c.VMID, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -206,7 +211,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
state.SetReadyState(false, "", "")
|
||||
lifecycleLog(&c, "start", 1, started, "start_requested")
|
||||
if err := system.StartServer(&c); err != nil {
|
||||
log.Println("[agent] start error:", err)
|
||||
log.Printf("[http] vmid=%d start error: %v", c.VMID, err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "start", 1, started, "start_failed err=%v", err)
|
||||
@ -237,7 +242,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if time.Now().After(propsDeadline) {
|
||||
err := fmt.Errorf("forge server.properties not found before timeout")
|
||||
log.Println("[agent] forge post-start error:", err)
|
||||
log.Printf("[http] vmid=%d forge post-start error: %v", c.VMID, err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
return
|
||||
@ -247,7 +252,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
_ = system.StopServer()
|
||||
if err := system.WaitForServerExit(20 * time.Second); err != nil {
|
||||
log.Println("[agent] forge stop wait error:", err)
|
||||
log.Printf("[http] vmid=%d forge stop wait error: %v", c.VMID, err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "forge_post", 1, started, "stop_wait_failed err=%v", err)
|
||||
@ -255,7 +260,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
|
||||
log.Println("[agent] forge post-start error:", err)
|
||||
log.Printf("[http] vmid=%d forge post-start error: %v", c.VMID, err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "forge_post", 1, started, "enforce_failed err=%v", err)
|
||||
@ -265,7 +270,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
state.SetState(state.StateStarting)
|
||||
state.SetReadyState(false, "", "")
|
||||
if err := system.StartServer(&c); err != nil {
|
||||
log.Println("[agent] restart error:", err)
|
||||
log.Printf("[http] vmid=%d restart error: %v", c.VMID, err)
|
||||
state.SetError(err)
|
||||
state.SetState(state.StateError)
|
||||
lifecycleLog(&c, "forge_post", 1, started, "restart_failed err=%v", err)
|
||||
@ -280,7 +285,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("[agent] async provision+start complete")
|
||||
log.Printf("[http] vmid=%d async provision+start complete", c.VMID)
|
||||
}(cfg)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -395,19 +400,38 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if t := state.GetLastReadyAt(); !t.IsZero() {
|
||||
readyAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
lastCrashTime := ""
|
||||
lastCrashExitCode := 0
|
||||
lastCrashSignal := 0
|
||||
lastCrashUptimeSeconds := int64(0)
|
||||
var lastCrashLogTail []string
|
||||
if crash := state.GetLastCrash(); crash != nil {
|
||||
if !crash.Time.IsZero() {
|
||||
lastCrashTime = crash.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
lastCrashExitCode = crash.ExitCode
|
||||
lastCrashSignal = crash.Signal
|
||||
lastCrashUptimeSeconds = crash.UptimeSeconds
|
||||
lastCrashLogTail = crash.LogTail
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"state": state.GetState(),
|
||||
"processRunning": processRunning,
|
||||
"ready": state.GetReady(),
|
||||
"readySource": state.GetReadySource(),
|
||||
"readyError": state.GetReadyError(),
|
||||
"lastReadyAt": readyAt,
|
||||
"installStep": state.GetInstallStep(),
|
||||
"crashCount": state.GetCrashCount(),
|
||||
"error": nil,
|
||||
"config": cfg,
|
||||
"timestamp": time.Now().Unix(),
|
||||
"state": state.GetState(),
|
||||
"processRunning": processRunning,
|
||||
"ready": state.GetReady(),
|
||||
"readySource": state.GetReadySource(),
|
||||
"readyError": state.GetReadyError(),
|
||||
"lastReadyAt": readyAt,
|
||||
"installStep": state.GetInstallStep(),
|
||||
"crashCount": state.GetCrashCount(),
|
||||
"lastCrashTime": lastCrashTime,
|
||||
"lastCrashExitCode": lastCrashExitCode,
|
||||
"lastCrashSignal": lastCrashSignal,
|
||||
"lastCrashUptimeSeconds": lastCrashUptimeSeconds,
|
||||
"lastCrashLogTail": lastCrashLogTail,
|
||||
"error": nil,
|
||||
"config": cfg,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := state.GetError(); err != nil {
|
||||
|
||||
@ -57,11 +57,11 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
|
||||
}
|
||||
|
||||
if sess.ptyFile != currentPTY {
|
||||
log.Printf("[ws] pty changed, destroying stale session: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||
log.Printf("[console] vmid=%d type=%s pty changed, destroying stale session", cfg.VMID, cfg.ContainerType)
|
||||
sess.destroy()
|
||||
} else {
|
||||
sess.touch()
|
||||
log.Printf("[ws] session reuse: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||
log.Printf("[console] vmid=%d type=%s session reuse", cfg.VMID, cfg.ContainerType)
|
||||
return sess, true, nil
|
||||
}
|
||||
}
|
||||
@ -87,7 +87,7 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
|
||||
sessions[key] = sess
|
||||
sessionMu.Unlock()
|
||||
|
||||
log.Printf("[ws] session created: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||
log.Printf("[console] vmid=%d type=%s session created", cfg.VMID, cfg.ContainerType)
|
||||
return sess, false, nil
|
||||
}
|
||||
|
||||
@ -106,7 +106,7 @@ func (s *consoleSession) addConn(conn *websocket.Conn, cc *consoleConn) *console
|
||||
}
|
||||
s.conns[conn] = cc
|
||||
s.lastActive = time.Now()
|
||||
log.Printf("[ws] conn add: vmid=%d type=%s conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
||||
log.Printf("[console] vmid=%d type=%s conn add conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
||||
return cc
|
||||
}
|
||||
|
||||
@ -120,7 +120,7 @@ func (s *consoleSession) removeConn(conn *websocket.Conn) int {
|
||||
safeCloseChan(cc.send)
|
||||
}
|
||||
s.lastActive = time.Now()
|
||||
log.Printf("[ws] conn remove: vmid=%d type=%s conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
||||
log.Printf("[console] vmid=%d type=%s conn remove conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
||||
return len(s.conns)
|
||||
}
|
||||
|
||||
@ -133,14 +133,14 @@ func (s *consoleSession) startReader() {
|
||||
if n > 0 {
|
||||
out := make([]byte, n)
|
||||
copy(out, buf[:n])
|
||||
log.Printf("[ws] pty read: vmid=%d bytes=%d", s.cfg.VMID, n)
|
||||
log.Printf("[console] vmid=%d pty read bytes=%d", s.cfg.VMID, n)
|
||||
s.broadcast(out)
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
log.Printf("[ws] pty read loop exit: vmid=%d err=EOF", s.cfg.VMID)
|
||||
log.Printf("[console] vmid=%d pty read loop exit err=EOF", s.cfg.VMID)
|
||||
} else {
|
||||
log.Printf("[ws] pty read loop exit: vmid=%d err=%v", s.cfg.VMID, err)
|
||||
log.Printf("[console] vmid=%d pty read loop exit err=%v", s.cfg.VMID, err)
|
||||
}
|
||||
s.destroy()
|
||||
return
|
||||
@ -194,7 +194,7 @@ func (s *consoleSession) scheduleCleanupIfIdle() {
|
||||
s.mu.Unlock()
|
||||
|
||||
if conns == 0 && lastActive.Equal(ts) {
|
||||
log.Printf("[ws] session cleanup: vmid=%d type=%s", s.cfg.VMID, s.cfg.ContainerType)
|
||||
log.Printf("[console] vmid=%d type=%s session cleanup", s.cfg.VMID, s.cfg.ContainerType)
|
||||
if s.cfg.ContainerType == "dev" {
|
||||
_ = system.StopDevShell()
|
||||
}
|
||||
@ -223,7 +223,7 @@ func (s *consoleSession) destroy() {
|
||||
delete(sessions, s.key)
|
||||
sessionMu.Unlock()
|
||||
|
||||
log.Printf("[ws] session destroyed: vmid=%d type=%s", s.cfg.VMID, s.cfg.ContainerType)
|
||||
log.Printf("[console] vmid=%d type=%s session destroyed", s.cfg.VMID, s.cfg.ContainerType)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -5,17 +5,10 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"zlh-agent/internal/provision/executil"
|
||||
"zlh-agent/internal/provision/markers"
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
func Install(cfg state.Config) error {
|
||||
const marker = "addon-codeserver"
|
||||
|
||||
if markers.IsPresent(marker) {
|
||||
return nil
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(
|
||||
executil.ScriptsRoot,
|
||||
"addons",
|
||||
@ -26,6 +19,5 @@ func Install(cfg state.Config) error {
|
||||
if err := executil.RunScript(scriptPath); err != nil {
|
||||
return fmt.Errorf("codeserver install failed: %w", err)
|
||||
}
|
||||
|
||||
return markers.Write(marker)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,9 +2,19 @@ package devcontainer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"zlh-agent/internal/provcommon"
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
/*
|
||||
@ -19,10 +29,32 @@ const (
|
||||
// MarkerDir is where devcontainer state is stored.
|
||||
MarkerDir = "/opt/zlh/.zlh"
|
||||
|
||||
// WorkspaceDir is the root working directory exposed to dev containers.
|
||||
WorkspaceDir = "/home/dev/workspace"
|
||||
|
||||
// Dev user environment
|
||||
DevUser = "dev"
|
||||
DevHome = "/home/dev"
|
||||
|
||||
// CatalogRelativePath is the artifact-server path to the dev runtime catalog.
|
||||
CatalogRelativePath = "devcontainer/_catalog.json"
|
||||
|
||||
// RuntimeRoot is where versioned dev runtimes are installed.
|
||||
RuntimeRoot = "/opt/zlh/runtimes"
|
||||
|
||||
// ReadyMarker is written after a dev container is fully provisioned.
|
||||
ReadyMarker = "devcontainer_ready.json"
|
||||
)
|
||||
|
||||
type Catalog struct {
|
||||
Runtimes []CatalogRuntime `json:"runtimes"`
|
||||
}
|
||||
|
||||
type CatalogRuntime struct {
|
||||
ID string `json:"id"`
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
|
||||
// ReadyMarkerPath returns the absolute path to the ready marker file.
|
||||
func ReadyMarkerPath() string {
|
||||
return filepath.Join(MarkerDir, ReadyMarker)
|
||||
@ -43,7 +75,7 @@ func WriteReadyMarker(runtime string) error {
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"runtime": runtime,
|
||||
"runtime": runtime,
|
||||
"ready_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
@ -54,3 +86,110 @@ func WriteReadyMarker(runtime string) error {
|
||||
|
||||
return os.WriteFile(ReadyMarkerPath(), raw, 0644)
|
||||
}
|
||||
|
||||
// EnsureWorkspace makes sure the dev workspace root exists before shell/filesystem access.
|
||||
func EnsureWorkspace() error {
|
||||
return os.MkdirAll(WorkspaceDir, 0755)
|
||||
}
|
||||
|
||||
func LoadCatalog() (*Catalog, error) {
|
||||
url := provcommon.BuildArtifactURL(CatalogRelativePath)
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch dev runtime catalog: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch dev runtime catalog: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dev runtime catalog: %w", err)
|
||||
}
|
||||
|
||||
var catalog Catalog
|
||||
if err := json.Unmarshal(raw, &catalog); err != nil {
|
||||
return nil, fmt.Errorf("parse dev runtime catalog: %w", err)
|
||||
}
|
||||
return &catalog, nil
|
||||
}
|
||||
|
||||
func ValidateRuntimeSelection(cfg state.Config) error {
|
||||
catalog, err := LoadCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtimeID := strings.ToLower(strings.TrimSpace(cfg.Runtime))
|
||||
version := strings.TrimSpace(cfg.Version)
|
||||
if runtimeID == "" {
|
||||
return fmt.Errorf("dev runtime missing")
|
||||
}
|
||||
if version == "" {
|
||||
return fmt.Errorf("dev runtime version missing")
|
||||
}
|
||||
|
||||
for _, runtime := range catalog.Runtimes {
|
||||
if strings.EqualFold(runtime.ID, runtimeID) {
|
||||
for _, candidate := range runtime.Versions {
|
||||
if candidate == version {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unsupported %s version: %s", runtimeID, version)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported dev runtime: %s", runtimeID)
|
||||
}
|
||||
|
||||
func RuntimeInstallDir(runtimeName, version string) string {
|
||||
return filepath.Join(RuntimeRoot, strings.ToLower(strings.TrimSpace(runtimeName)), strings.TrimSpace(version))
|
||||
}
|
||||
|
||||
func RuntimeInstalled(runtimeName, version string) bool {
|
||||
info, err := os.Stat(RuntimeInstallDir(runtimeName, version))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func RuntimeMarker(runtimeName, version string) string {
|
||||
return fmt.Sprintf("devcontainer-%s-%s", strings.ToLower(strings.TrimSpace(runtimeName)), strings.ReplaceAll(strings.TrimSpace(version), "/", "_"))
|
||||
}
|
||||
|
||||
func EnsureDevUserEnvironment() error {
|
||||
if _, err := user.Lookup(DevUser); err != nil {
|
||||
if err := exec.Command("useradd", "-m", "-s", "/bin/bash", DevUser).Run(); err != nil {
|
||||
return fmt.Errorf("create dev user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(WorkspaceDir, 0755); err != nil {
|
||||
return fmt.Errorf("create workspace: %w", err)
|
||||
}
|
||||
|
||||
u, err := user.Lookup(DevUser)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup dev user: %w", err)
|
||||
}
|
||||
uid, err := strconv.Atoi(u.Uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse dev uid: %w", err)
|
||||
}
|
||||
gid, err := strconv.Atoi(u.Gid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse dev gid: %w", err)
|
||||
}
|
||||
|
||||
if err := filepath.Walk(DevHome, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
return os.Chown(path, uid, gid)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("chown dev home: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"zlh-agent/internal/state"
|
||||
|
||||
"zlh-agent/internal/provision/devcontainer/dotnet"
|
||||
devgo "zlh-agent/internal/provision/devcontainer/go"
|
||||
"zlh-agent/internal/provision/devcontainer/java"
|
||||
"zlh-agent/internal/provision/devcontainer/node"
|
||||
@ -13,18 +14,36 @@ import (
|
||||
)
|
||||
|
||||
func Provision(cfg state.Config) error {
|
||||
if err := ValidateRuntimeSelection(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EnsureDevUserEnvironment(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime := strings.ToLower(cfg.Runtime)
|
||||
|
||||
var err error
|
||||
switch runtime {
|
||||
case "node":
|
||||
return node.Install(cfg)
|
||||
err = node.Install(cfg)
|
||||
case "python":
|
||||
return python.Install(cfg)
|
||||
err = python.Install(cfg)
|
||||
case "go":
|
||||
return devgo.Install(cfg)
|
||||
err = devgo.Install(cfg)
|
||||
case "java":
|
||||
return java.Install(cfg)
|
||||
err = java.Install(cfg)
|
||||
case "dotnet":
|
||||
err = dotnet.Install(cfg)
|
||||
default:
|
||||
return fmt.Errorf("unsupported dev container runtime: %s", runtime)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteReadyMarker(runtime); err != nil {
|
||||
return fmt.Errorf("write dev ready marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
45
internal/provision/devcontainer/dotnet/install.go
Normal file
45
internal/provision/devcontainer/dotnet/install.go
Normal file
@ -0,0 +1,45 @@
|
||||
package dotnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"zlh-agent/internal/provision/executil"
|
||||
"zlh-agent/internal/provision/markers"
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
func Install(cfg state.Config) error {
|
||||
marker := runtimeMarker(cfg.Version)
|
||||
if runtimeInstalled(cfg.Version) {
|
||||
if !markers.IsPresent(marker) {
|
||||
return markers.Write(marker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if markers.IsPresent(marker) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := executil.RunEmbeddedScript(
|
||||
"devcontainer/dotnet/install.sh",
|
||||
"RUNTIME=dotnet",
|
||||
"ARCHIVE_EXT=tar.gz",
|
||||
"RUNTIME_VERSION="+cfg.Version,
|
||||
); err != nil {
|
||||
return fmt.Errorf("dotnet devcontainer install failed: %w", err)
|
||||
}
|
||||
|
||||
return markers.Write(marker)
|
||||
}
|
||||
|
||||
func runtimeInstalled(version string) bool {
|
||||
info, err := os.Stat(filepath.Join("/opt/zlh/runtimes/dotnet", strings.TrimSpace(version)))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func runtimeMarker(version string) string {
|
||||
return "devcontainer-dotnet-" + strings.ReplaceAll(strings.TrimSpace(version), "/", "_")
|
||||
}
|
||||
23
internal/provision/devcontainer/dotnet/verify.go
Normal file
23
internal/provision/devcontainer/dotnet/verify.go
Normal file
@ -0,0 +1,23 @@
|
||||
package dotnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
const dotnetBin = "/opt/zlh/runtimes/dotnet/current/dotnet"
|
||||
|
||||
func Verify(cfg state.Config) error {
|
||||
if _, err := os.Stat(dotnetBin); err != nil {
|
||||
return fmt.Errorf("dotnet binary missing at %s", dotnetBin)
|
||||
}
|
||||
|
||||
if err := exec.Command(dotnetBin, "--info").Run(); err != nil {
|
||||
return fmt.Errorf("dotnet runtime not executable: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -2,6 +2,9 @@ package goenv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"zlh-agent/internal/provision/executil"
|
||||
"zlh-agent/internal/provision/markers"
|
||||
@ -9,8 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func Install(cfg state.Config) error {
|
||||
const marker = "devcontainer-go"
|
||||
|
||||
marker := runtimeMarker(cfg.Version)
|
||||
if runtimeInstalled(cfg.Version) {
|
||||
if !markers.IsPresent(marker) {
|
||||
return markers.Write(marker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if markers.IsPresent(marker) {
|
||||
return nil
|
||||
}
|
||||
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
|
||||
|
||||
return markers.Write(marker)
|
||||
}
|
||||
|
||||
func runtimeInstalled(version string) bool {
|
||||
info, err := os.Stat(filepath.Join("/opt/zlh/runtimes/go", strings.TrimSpace(version)))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func runtimeMarker(version string) string {
|
||||
return "devcontainer-go-" + strings.ReplaceAll(strings.TrimSpace(version), "/", "_")
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
const goBin = "/opt/zlh/runtime/go/bin/go"
|
||||
const goBin = "/opt/zlh/runtimes/go/bin/go"
|
||||
|
||||
func Verify(cfg state.Config) error {
|
||||
if _, err := os.Stat(goBin); err != nil {
|
||||
|
||||
@ -2,6 +2,9 @@ package java
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"zlh-agent/internal/provision/executil"
|
||||
"zlh-agent/internal/provision/markers"
|
||||
@ -9,8 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func Install(cfg state.Config) error {
|
||||
const marker = "devcontainer-java"
|
||||
|
||||
marker := runtimeMarker(cfg.Version)
|
||||
if runtimeInstalled(cfg.Version) {
|
||||
if !markers.IsPresent(marker) {
|
||||
return markers.Write(marker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if markers.IsPresent(marker) {
|
||||
return nil
|
||||
}
|
||||
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
|
||||
|
||||
return markers.Write(marker)
|
||||
}
|
||||
|
||||
func runtimeInstalled(version string) bool {
|
||||
info, err := os.Stat(filepath.Join("/opt/zlh/runtimes/java", strings.TrimSpace(version)))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func runtimeMarker(version string) string {
|
||||
return "devcontainer-java-" + strings.ReplaceAll(strings.TrimSpace(version), "/", "_")
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
const javaBin = "/opt/zlh/runtime/java/bin/java"
|
||||
const javaBin = "/opt/zlh/runtimes/java/bin/java"
|
||||
|
||||
func Verify(cfg state.Config) error {
|
||||
if _, err := os.Stat(javaBin); err != nil {
|
||||
|
||||
@ -2,6 +2,9 @@ package node
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"zlh-agent/internal/provision/executil"
|
||||
"zlh-agent/internal/provision/markers"
|
||||
@ -9,8 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func Install(cfg state.Config) error {
|
||||
const marker = "devcontainer-node"
|
||||
|
||||
marker := runtimeMarker(cfg.Version)
|
||||
if runtimeInstalled(cfg.Version) {
|
||||
if !markers.IsPresent(marker) {
|
||||
return markers.Write(marker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if markers.IsPresent(marker) {
|
||||
return nil
|
||||
}
|
||||
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
|
||||
|
||||
return markers.Write(marker)
|
||||
}
|
||||
|
||||
func runtimeInstalled(version string) bool {
|
||||
info, err := os.Stat(filepath.Join("/opt/zlh/runtimes/node", strings.TrimSpace(version)))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func runtimeMarker(version string) string {
|
||||
return "devcontainer-node-" + strings.ReplaceAll(strings.TrimSpace(version), "/", "_")
|
||||
}
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
nodeBin = "/opt/zlh/runtime/node/bin/node"
|
||||
npmBin = "/opt/zlh/runtime/node/bin/npm"
|
||||
nodeBin = "/opt/zlh/runtimes/node/bin/node"
|
||||
npmBin = "/opt/zlh/runtimes/node/bin/npm"
|
||||
)
|
||||
|
||||
func Verify(cfg state.Config) error {
|
||||
|
||||
@ -2,6 +2,9 @@ package python
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"zlh-agent/internal/provision/executil"
|
||||
"zlh-agent/internal/provision/markers"
|
||||
@ -9,8 +12,13 @@ import (
|
||||
)
|
||||
|
||||
func Install(cfg state.Config) error {
|
||||
const marker = "devcontainer-python"
|
||||
|
||||
marker := runtimeMarker(cfg.Version)
|
||||
if runtimeInstalled(cfg.Version) {
|
||||
if !markers.IsPresent(marker) {
|
||||
return markers.Write(marker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if markers.IsPresent(marker) {
|
||||
return nil
|
||||
}
|
||||
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
|
||||
|
||||
return markers.Write(marker)
|
||||
}
|
||||
|
||||
func runtimeInstalled(version string) bool {
|
||||
info, err := os.Stat(filepath.Join("/opt/zlh/runtimes/python", strings.TrimSpace(version)))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func runtimeMarker(version string) string {
|
||||
return "devcontainer-python-" + strings.ReplaceAll(strings.TrimSpace(version), "/", "_")
|
||||
}
|
||||
|
||||
@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
pythonBin = "/opt/zlh/runtime/python/bin/python3"
|
||||
pipBin = "/opt/zlh/runtime/python/bin/pip3"
|
||||
pythonBin = "/opt/zlh/runtimes/python/bin/python3"
|
||||
pipBin = "/opt/zlh/runtimes/python/bin/pip3"
|
||||
)
|
||||
|
||||
func Verify(cfg state.Config) error {
|
||||
|
||||
@ -4,19 +4,19 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"zlh-agent/internal/state"
|
||||
"zlh-agent/internal/provision/addons"
|
||||
"zlh-agent/internal/provision/devcontainer"
|
||||
"zlh-agent/internal/provision/minecraft"
|
||||
"zlh-agent/internal/provision/steam"
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
/*
|
||||
ProvisionAll — unified entrypoint for provisioning.
|
||||
IMPORTANT:
|
||||
- This function ONLY performs installation.
|
||||
- Validation/verification happens in ensureProvisioned().
|
||||
- state.Config is treated as immutable desired state.
|
||||
ProvisionAll — unified entrypoint for provisioning.
|
||||
IMPORTANT:
|
||||
- This function ONLY performs installation.
|
||||
- Validation/verification happens in ensureProvisioned().
|
||||
- state.Config is treated as immutable desired state.
|
||||
*/
|
||||
func ProvisionAll(cfg state.Config) error {
|
||||
|
||||
@ -133,6 +133,19 @@ func ProvisionAll(cfg state.Config) error {
|
||||
/* ---------------------------------------------------------
|
||||
ADDONS (OPTIONAL, ROLE-AGNOSTIC)
|
||||
--------------------------------------------------------- */
|
||||
if cfg.ContainerType == "dev" && cfg.EnableCodeServer {
|
||||
seen := false
|
||||
for _, addon := range cfg.Addons {
|
||||
if addon == "codeserver" {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
cfg.Addons = append(cfg.Addons, "codeserver")
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.Addons) > 0 {
|
||||
if err := addons.Provision(cfg); err != nil {
|
||||
return err
|
||||
|
||||
@ -22,13 +22,14 @@ type Config struct {
|
||||
|
||||
// Dev runtime (only for dev containers)
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Version string `json:"version"`
|
||||
|
||||
// OPTIONAL addons (role-agnostic)
|
||||
Addons []string `json:"addons,omitempty"`
|
||||
Addons []string `json:"addons,omitempty"`
|
||||
EnableCodeServer bool `json:"enable_code_server,omitempty"`
|
||||
|
||||
Game string `json:"game"`
|
||||
Variant string `json:"variant"`
|
||||
Version string `json:"version"`
|
||||
World string `json:"world"`
|
||||
Ports []int `json:"ports"`
|
||||
ArtifactPath string `json:"artifact_path"`
|
||||
@ -72,6 +73,7 @@ type agentStatus struct {
|
||||
lastError error
|
||||
crashCount int
|
||||
lastCrash time.Time
|
||||
lastCrashInfo *CrashInfo
|
||||
intentionalStop bool
|
||||
ready bool
|
||||
readySource string
|
||||
@ -79,11 +81,27 @@ type agentStatus struct {
|
||||
lastReadyAt time.Time
|
||||
}
|
||||
|
||||
type CrashInfo struct {
|
||||
Time time.Time `json:"time"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
Signal int `json:"signal"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
LogTail []string `json:"logTail"`
|
||||
}
|
||||
|
||||
var global = &agentStatus{
|
||||
state: StateIdle,
|
||||
lastChange: time.Now(),
|
||||
}
|
||||
|
||||
func stateLogf(format string, args ...any) {
|
||||
if cfg, err := LoadConfig(); err == nil && cfg != nil {
|
||||
log.Printf("[state] vmid=%d "+format, append([]any{cfg.VMID}, args...)...)
|
||||
return
|
||||
}
|
||||
log.Printf("[state] "+format, args...)
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
STATE GETTERS
|
||||
----------------------------------------------------------------------------*/
|
||||
@ -142,6 +160,21 @@ func GetLastReadyAt() time.Time {
|
||||
return global.lastReadyAt
|
||||
}
|
||||
|
||||
func GetLastCrash() *CrashInfo {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
|
||||
if global.lastCrashInfo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
copyInfo := *global.lastCrashInfo
|
||||
if global.lastCrashInfo.LogTail != nil {
|
||||
copyInfo.LogTail = append([]string(nil), global.lastCrashInfo.LogTail...)
|
||||
}
|
||||
return ©Info
|
||||
}
|
||||
|
||||
func IsIntentionalStop() bool {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
@ -157,7 +190,7 @@ func SetState(s AgentState) {
|
||||
defer global.mu.Unlock()
|
||||
|
||||
if global.state != s {
|
||||
log.Printf("[state] %s → %s\n", global.state, s)
|
||||
stateLogf("%s -> %s", global.state, s)
|
||||
global.state = s
|
||||
global.lastChange = time.Now()
|
||||
}
|
||||
@ -187,11 +220,27 @@ func ClearIntentionalStop() {
|
||||
global.intentionalStop = false
|
||||
}
|
||||
|
||||
func SetLastCrash(info *CrashInfo) {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
|
||||
if info == nil {
|
||||
global.lastCrashInfo = nil
|
||||
return
|
||||
}
|
||||
|
||||
copyInfo := *info
|
||||
if info.LogTail != nil {
|
||||
copyInfo.LogTail = append([]string(nil), info.LogTail...)
|
||||
}
|
||||
global.lastCrashInfo = ©Info
|
||||
}
|
||||
|
||||
func RecordCrash(err error) {
|
||||
global.mu.Lock()
|
||||
defer global.mu.Unlock()
|
||||
|
||||
log.Printf("[state] crash detected: %v", err)
|
||||
stateLogf("crash recorded err=%v", err)
|
||||
|
||||
global.state = StateCrashed
|
||||
global.lastError = err
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"zlh-agent/internal/provision"
|
||||
"zlh-agent/internal/provision/devcontainer"
|
||||
"zlh-agent/internal/runtime"
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
@ -19,9 +26,10 @@ import (
|
||||
----------------------------------------------------------------------------*/
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
serverCmd *exec.Cmd
|
||||
serverPTY *os.File
|
||||
mu sync.Mutex
|
||||
serverCmd *exec.Cmd
|
||||
serverPTY *os.File
|
||||
serverStartTime time.Time
|
||||
|
||||
devCmd *exec.Cmd
|
||||
devPTY *os.File
|
||||
@ -52,6 +60,7 @@ func StartServer(cfg *state.Config) error {
|
||||
|
||||
dir := provision.ServerDir(*cfg)
|
||||
startScript := filepath.Join(dir, "start.sh")
|
||||
log.Printf("[process] vmid=%d server start requested dir=%s", cfg.VMID, dir)
|
||||
|
||||
cmd := exec.Command("/bin/bash", startScript)
|
||||
cmd.Dir = dir
|
||||
@ -63,11 +72,13 @@ func StartServer(cfg *state.Config) error {
|
||||
|
||||
serverCmd = cmd
|
||||
serverPTY = ptmx
|
||||
serverStartTime = time.Now()
|
||||
|
||||
state.ClearIntentionalStop()
|
||||
state.SetState(state.StateRunning)
|
||||
state.SetError(nil)
|
||||
state.SetReadyState(false, "", "")
|
||||
log.Printf("[process] vmid=%d server process started", cfg.VMID)
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
@ -83,15 +94,27 @@ func StartServer(cfg *state.Config) error {
|
||||
state.ClearIntentionalStop()
|
||||
state.SetState(state.StateIdle)
|
||||
state.SetReadyState(false, "", "")
|
||||
log.Printf("[process] vmid=%d server exited after intentional stop", cfg.VMID)
|
||||
} else if err != nil {
|
||||
crashInfo := buildCrashInfo(cfg, err, serverStartTime)
|
||||
state.SetLastCrash(crashInfo)
|
||||
log.Printf("[process] server crashed vmid=%d exit_code=%d signal=%d uptime=%ds", cfg.VMID, crashInfo.ExitCode, crashInfo.Signal, crashInfo.UptimeSeconds)
|
||||
if len(crashInfo.LogTail) > 0 {
|
||||
log.Printf("[process] crash log tail:")
|
||||
for _, line := range lastLines(crashInfo.LogTail, 20) {
|
||||
log.Printf("[process] %s", line)
|
||||
}
|
||||
}
|
||||
state.RecordCrash(err)
|
||||
} else {
|
||||
state.SetState(state.StateIdle)
|
||||
state.SetReadyState(false, "", "")
|
||||
log.Printf("[process] vmid=%d server exited cleanly", cfg.VMID)
|
||||
}
|
||||
|
||||
serverCmd = nil
|
||||
serverPTY = nil
|
||||
serverStartTime = time.Time{}
|
||||
}()
|
||||
|
||||
return nil
|
||||
@ -109,6 +132,12 @@ func StopServer() error {
|
||||
return fmt.Errorf("server not running")
|
||||
}
|
||||
|
||||
cfg, _ := state.LoadConfig()
|
||||
if cfg != nil {
|
||||
log.Printf("[process] vmid=%d stop requested", cfg.VMID)
|
||||
} else {
|
||||
log.Printf("[process] stop requested")
|
||||
}
|
||||
state.SetState(state.StateStopping)
|
||||
state.MarkIntentionalStop()
|
||||
state.SetReadyState(false, "", "")
|
||||
@ -186,6 +215,9 @@ func StartDevShell() (*os.File, error) {
|
||||
if devPTY != nil && devCmd != nil {
|
||||
return devPTY, nil
|
||||
}
|
||||
if err := devcontainer.EnsureDevUserEnvironment(); err != nil {
|
||||
return nil, fmt.Errorf("prepare dev environment: %w", err)
|
||||
}
|
||||
|
||||
shell := "/bin/bash"
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
@ -198,7 +230,32 @@ func StartDevShell() (*os.File, error) {
|
||||
} else {
|
||||
cmd = exec.Command(shell, "-i")
|
||||
}
|
||||
cmd.Dir = "/opt"
|
||||
cmd.Dir = devcontainer.WorkspaceDir
|
||||
|
||||
devUser, err := user.Lookup(devcontainer.DevUser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup dev user: %w", err)
|
||||
}
|
||||
uid, err := strconv.Atoi(devUser.Uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dev uid: %w", err)
|
||||
}
|
||||
gid, err := strconv.Atoi(devUser.Gid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dev gid: %w", err)
|
||||
}
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+devcontainer.DevHome,
|
||||
"USER="+devcontainer.DevUser,
|
||||
"LOGNAME="+devcontainer.DevUser,
|
||||
"TERM=xterm-256color",
|
||||
)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uint32(uid),
|
||||
Gid: uint32(gid),
|
||||
},
|
||||
}
|
||||
|
||||
ptmx, err := runtime.CreatePTY(cmd)
|
||||
if err != nil {
|
||||
@ -302,3 +359,63 @@ func StopDevShell() error {
|
||||
state.SetState(state.StateIdle)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCrashInfo(cfg *state.Config, waitErr error, startedAt time.Time) *state.CrashInfo {
|
||||
exitCode, signal := extractExitDetails(waitErr)
|
||||
uptime := int64(0)
|
||||
if !startedAt.IsZero() {
|
||||
uptime = int64(time.Since(startedAt).Seconds())
|
||||
if uptime < 0 {
|
||||
uptime = 0
|
||||
}
|
||||
}
|
||||
|
||||
return &state.CrashInfo{
|
||||
Time: time.Now().UTC(),
|
||||
ExitCode: exitCode,
|
||||
Signal: signal,
|
||||
UptimeSeconds: uptime,
|
||||
LogTail: tailLogLines(cfg, 40),
|
||||
}
|
||||
}
|
||||
|
||||
func extractExitDetails(err error) (int, int) {
|
||||
exitCode := -1
|
||||
signal := 0
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return exitCode, signal
|
||||
}
|
||||
|
||||
exitCode = exitErr.ExitCode()
|
||||
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() {
|
||||
signal = int(status.Signal())
|
||||
}
|
||||
return exitCode, signal
|
||||
}
|
||||
|
||||
func tailLogLines(cfg *state.Config, maxLines int) []string {
|
||||
buf, err := TailLatestLog(cfg, 64*1024)
|
||||
if err != nil || len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawLines := bytes.Split(buf, []byte{'\n'})
|
||||
lines := make([]string, 0, len(rawLines))
|
||||
for _, raw := range rawLines {
|
||||
line := strings.TrimSpace(string(raw))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lastLines(lines, maxLines)
|
||||
}
|
||||
|
||||
func lastLines(lines []string, maxLines int) []string {
|
||||
if len(lines) <= maxLines {
|
||||
return append([]string(nil), lines...)
|
||||
}
|
||||
return append([]string(nil), lines[len(lines)-maxLines:]...)
|
||||
}
|
||||
|
||||
@ -6,55 +6,79 @@ echo "[code-server] starting install"
|
||||
# --------------------------------------------------
|
||||
# Config
|
||||
# --------------------------------------------------
|
||||
ADDON_ROOT="/opt/zlh/addons/code-server"
|
||||
ARTIFACT_DIR="/opt/zlh/addons/code-server"
|
||||
SERVICE_ROOT="/opt/zlh/services/code-server"
|
||||
ZLH_ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
|
||||
ARTIFACT_URL="${ZLH_ARTIFACT_BASE_URL%/}/addons/code-server/code-server.zip"
|
||||
ARTIFACT_TMP="/tmp/code-server.zip"
|
||||
MARKER="/opt/zlh/.zlh/addons/code-server.installed"
|
||||
PID_FILE="/opt/zlh/.zlh/addons/code-server.pid"
|
||||
LOG_FILE="/opt/zlh/logs/code-server.log"
|
||||
WORKSPACE_DIR="/home/dev/workspace"
|
||||
BIN="${SERVICE_ROOT}/bin/code-server"
|
||||
LINK_PATH="/usr/local/bin/code-server"
|
||||
|
||||
mkdir -p "$(dirname "${MARKER}")"
|
||||
mkdir -p "$(dirname "${LOG_FILE}")"
|
||||
|
||||
download_artifact() {
|
||||
echo "[code-server] downloading ${ARTIFACT_URL}"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL "${ARTIFACT_URL}" -o "${ARTIFACT_TMP}"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -O "${ARTIFACT_TMP}" "${ARTIFACT_URL}"
|
||||
else
|
||||
echo "[code-server][ERROR] curl or wget is required"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
extract_artifact() {
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -q "${ARTIFACT_TMP}" -d "${tmp_dir}"
|
||||
elif command -v bsdtar >/dev/null 2>&1; then
|
||||
bsdtar -xf "${ARTIFACT_TMP}" -C "${tmp_dir}"
|
||||
else
|
||||
echo "[code-server][ERROR] unzip or bsdtar is required"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
EXTRACTED_DIR="$(find "${tmp_dir}" -maxdepth 1 -type d -name 'code-server*' | head -n1)"
|
||||
if [ -z "${EXTRACTED_DIR}" ]; then
|
||||
echo "[code-server][ERROR] failed to locate extracted directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "${EXTRACTED_DIR}"/* "${SERVICE_ROOT}/"
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
|
||||
# --------------------------------------------------
|
||||
# Idempotency
|
||||
# --------------------------------------------------
|
||||
if [ -f "${MARKER}" ]; then
|
||||
echo "[code-server] already installed"
|
||||
exit 0
|
||||
if [ ! -x "${BIN}" ]; then
|
||||
download_artifact
|
||||
echo "[code-server] extracting ${ARTIFACT_TMP}"
|
||||
rm -rf "${SERVICE_ROOT}"
|
||||
mkdir -p "${SERVICE_ROOT}"
|
||||
extract_artifact
|
||||
chmod +x "${BIN}"
|
||||
ln -sfn "${BIN}" "${LINK_PATH}"
|
||||
fi
|
||||
|
||||
ARCHIVE="$(ls ${ARTIFACT_DIR}/code-server.* 2>/dev/null | head -n1)"
|
||||
if [ -z "${ARCHIVE}" ]; then
|
||||
echo "[code-server][ERROR] artifact not found"
|
||||
exit 1
|
||||
mkdir -p "${WORKSPACE_DIR}"
|
||||
|
||||
if [ -f "${PID_FILE}" ] && kill -0 "$(cat "${PID_FILE}")" 2>/dev/null; then
|
||||
echo "[code-server] already running"
|
||||
else
|
||||
rm -f "${PID_FILE}"
|
||||
nohup "${BIN}" --bind-addr 0.0.0.0:8080 "${WORKSPACE_DIR}" >"${LOG_FILE}" 2>&1 &
|
||||
echo $! > "${PID_FILE}"
|
||||
fi
|
||||
|
||||
echo "[code-server] extracting ${ARCHIVE}"
|
||||
mkdir -p "${ADDON_ROOT}"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
||||
case "${ARCHIVE}" in
|
||||
*.tar.gz)
|
||||
tar -xzf "${ARCHIVE}" -C "${TMP_DIR}"
|
||||
;;
|
||||
*.zip)
|
||||
unzip -q "${ARCHIVE}" -d "${TMP_DIR}"
|
||||
;;
|
||||
*)
|
||||
echo "[code-server][ERROR] unsupported archive format"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
EXTRACTED_DIR="$(find ${TMP_DIR} -maxdepth 1 -type d -name 'code-server*' | head -n1)"
|
||||
if [ -z "${EXTRACTED_DIR}" ]; then
|
||||
echo "[code-server][ERROR] failed to locate extracted directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "${EXTRACTED_DIR}"/* "${ADDON_ROOT}/"
|
||||
rm -rf "${TMP_DIR}"
|
||||
|
||||
chmod +x "${ADDON_ROOT}/bin/code-server"
|
||||
|
||||
touch "${MARKER}"
|
||||
rm -f "${ARTIFACT_TMP}"
|
||||
|
||||
echo "[code-server] install complete"
|
||||
|
||||
54
scripts/devcontainer/dotnet/install.sh
Normal file
54
scripts/devcontainer/dotnet/install.sh
Normal file
@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${RUNTIME_VERSION:?RUNTIME_VERSION required}"
|
||||
|
||||
RUNTIME="dotnet"
|
||||
RUNTIME_ROOT="/opt/zlh/runtimes/${RUNTIME}"
|
||||
DEST_DIR="${RUNTIME_ROOT}/${RUNTIME_VERSION}"
|
||||
CURRENT_LINK="${RUNTIME_ROOT}/current"
|
||||
ZLH_ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
|
||||
INSTALLER_URL="${ZLH_ARTIFACT_BASE_URL%/}/devcontainer/dotnet/dotnet-install.sh"
|
||||
INSTALLER_TMP="/tmp/dotnet-install.sh"
|
||||
|
||||
log() {
|
||||
echo "[zlh-installer:${RUNTIME}] $*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[zlh-installer:${RUNTIME}] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
download_installer() {
|
||||
log "Downloading ${INSTALLER_URL}"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL "${INSTALLER_URL}" -o "${INSTALLER_TMP}"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -O "${INSTALLER_TMP}" "${INSTALLER_URL}"
|
||||
else
|
||||
fail "curl or wget is required"
|
||||
fi
|
||||
chmod +x "${INSTALLER_TMP}"
|
||||
}
|
||||
|
||||
mkdir -p "${RUNTIME_ROOT}"
|
||||
|
||||
if [[ -d "${DEST_DIR}" ]]; then
|
||||
log "Version already installed at ${DEST_DIR}"
|
||||
else
|
||||
mkdir -p "${DEST_DIR}"
|
||||
download_installer
|
||||
log "Installing dotnet ${RUNTIME_VERSION} into ${DEST_DIR}"
|
||||
bash "${INSTALLER_TMP}" --channel "${RUNTIME_VERSION}" --install-dir "${DEST_DIR}"
|
||||
fi
|
||||
|
||||
ln -sfn "${DEST_DIR}" "${CURRENT_LINK}"
|
||||
cat >/etc/profile.d/zlh-dotnet.sh <<EOF
|
||||
export PATH="${CURRENT_LINK}:\$PATH"
|
||||
EOF
|
||||
chmod +x /etc/profile.d/zlh-dotnet.sh
|
||||
chmod -R 755 "${DEST_DIR}"
|
||||
rm -f "${INSTALLER_TMP}"
|
||||
|
||||
log "Install complete"
|
||||
@ -14,7 +14,7 @@ set -euo pipefail
|
||||
############################################
|
||||
|
||||
ZLH_ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
|
||||
ZLH_RUNTIME_ROOT="${ZLH_RUNTIME_ROOT:-/opt/zlh/runtime}"
|
||||
ZLH_RUNTIME_ROOT="${ZLH_RUNTIME_ROOT:-/opt/zlh/runtimes}"
|
||||
ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-${RUNTIME}}"
|
||||
|
||||
############################################
|
||||
|
||||
Loading…
Reference in New Issue
Block a user