3-14-26 updates

This commit is contained in:
jester 2026-03-15 11:06:08 +00:00
parent 243a201a64
commit 6019d0bc1c
23 changed files with 738 additions and 140 deletions

View File

@ -32,6 +32,8 @@ const (
shadowMetadataName = "metadata.json" shadowMetadataName = "metadata.json"
MaxModUploadSize = 250 * 1024 * 1024 MaxModUploadSize = 250 * 1024 * 1024
MaxDataPackSize = 100 * 1024 * 1024 MaxDataPackSize = 100 * 1024 * 1024
MaxDevUploadSize = 250 * 1024 * 1024
DevWorkspaceRoot = "/home/dev/workspace"
) )
var ( var (
@ -96,6 +98,9 @@ type Meta struct {
} }
func RuntimeRoot(cfg *state.Config) string { func RuntimeRoot(cfg *state.Config) string {
if cfg != nil && strings.EqualFold(cfg.ContainerType, "dev") {
return DevWorkspaceRoot
}
return mods.ResolveServerRoot(cfg) return mods.ResolveServerRoot(cfg)
} }
@ -229,7 +234,7 @@ func List(root, rel string) (ListResponse, error) {
}, nil }, nil
} }
func Stat(root, rel string) (StatResponse, error) { func Stat(containerType, root, rel string) (StatResponse, error) {
resolvedPath, responsePath, err := ResolveWorldPath(root, rel) resolvedPath, responsePath, err := ResolveWorldPath(root, rel)
if err != nil { if err != nil {
return StatResponse{}, err return StatResponse{}, err
@ -255,7 +260,7 @@ func Stat(root, rel string) (StatResponse, error) {
Type: entryType, Type: entryType,
Size: info.Size(), Size: info.Size(),
Modified: info.ModTime().UTC().Format(time.RFC3339), Modified: info.ModTime().UTC().Format(time.RFC3339),
IsWritable: isWritablePath(root, responsePath), IsWritable: isWritablePath(containerType, root, responsePath),
HasBackup: hasBackup(root, responsePath), HasBackup: hasBackup(root, responsePath),
Source: sourceForPath(root, responsePath), Source: sourceForPath(root, responsePath),
}, nil }, nil
@ -317,7 +322,7 @@ func OpenDownload(root, rel string) (*os.File, os.FileInfo, string, error) {
return file, info, filepath.Base(resolvedPath), nil 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)) resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil { if err != nil {
return "", err return "", err
@ -327,7 +332,7 @@ func Delete(root, rel string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if !isAllowedDelete(normalizedRel) { if !isAllowedDelete(containerType, normalizedRel) {
return "", ErrDeleteDenied return "", ErrDeleteDenied
} }
@ -364,7 +369,7 @@ func Delete(root, rel string) (string, error) {
return normalizedRel, nil 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)) resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil { if err != nil {
return false, err return false, err
@ -374,7 +379,7 @@ func Write(root, rel string, data []byte) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
if !isAllowedWrite(normalizedRel) { if !isAllowedWrite(containerType, normalizedRel) {
return false, ErrWriteDenied return false, ErrWriteDenied
} }
if len(data) > MaxWriteSize { if len(data) > MaxWriteSize {
@ -415,7 +420,7 @@ func Write(root, rel string, data []byte) (bool, error) {
return backupCreated, nil 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)) resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil { if err != nil {
return "", err return "", err
@ -425,7 +430,7 @@ func Revert(root, rel string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if !isAllowedWrite(normalizedRel) { if !isAllowedWrite(containerType, normalizedRel) {
return "", ErrWriteDenied return "", ErrWriteDenied
} }
@ -466,7 +471,7 @@ func Revert(root, rel string) (string, error) {
return normalizedRel, nil 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)) resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil { if err != nil {
return 0, false, err return 0, false, err
@ -476,7 +481,7 @@ func Upload(root, rel string, r io.Reader, sizeLimit int64, overwrite bool) (int
if err != nil { if err != nil {
return 0, false, err return 0, false, err
} }
allowedLimit, ok := uploadLimitForPath(normalizedRel) allowedLimit, ok := uploadLimitForPath(containerType, normalizedRel)
if !ok { if !ok {
return 0, false, ErrUploadDenied return 0, false, ErrUploadDenied
} }
@ -600,7 +605,10 @@ func isBinary(data []byte) bool {
return false return false
} }
func isAllowedDelete(rel string) bool { func isAllowedDelete(containerType, rel string) bool {
if strings.EqualFold(containerType, "dev") {
return rel != ""
}
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
if len(parts) != 2 { if len(parts) != 2 {
return false 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, "/") parts := strings.Split(rel, "/")
if len(parts) == 1 { if len(parts) == 1 {
return parts[0] == "server.properties" 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, "/") parts := strings.Split(rel, "/")
switch { switch {
case len(parts) == 2 && parts[0] == "mods" && strings.HasSuffix(strings.ToLower(parts[1]), ".jar"): 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 { func isWritablePath(containerType, root, rel string) bool {
if !isAllowedWrite(rel) { if !isAllowedWrite(containerType, rel) {
return false return false
} }

View File

@ -10,6 +10,7 @@ import (
"strings" "strings"
agentfiles "zlh-agent/internal/files" agentfiles "zlh-agent/internal/files"
"zlh-agent/internal/state"
) )
func HandleGameFilesList(w http.ResponseWriter, r *http.Request) { func HandleGameFilesList(w http.ResponseWriter, r *http.Request) {
@ -18,7 +19,7 @@ func HandleGameFilesList(w http.ResponseWriter, r *http.Request) {
return return
} }
_, serverRoot, ok := requireMinecraftGame(w) _, serverRoot, ok := requireFileContainer(w)
if !ok { if !ok {
return return
} }
@ -37,12 +38,12 @@ func HandleGameFilesStat(w http.ResponseWriter, r *http.Request) {
return return
} }
_, serverRoot, ok := requireMinecraftGame(w) cfg, serverRoot, ok := requireFileContainer(w)
if !ok { if !ok {
return 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 { if err != nil {
writeFilesError(w, err) writeFilesError(w, err)
return return
@ -56,7 +57,7 @@ func HandleGameFilesRead(w http.ResponseWriter, r *http.Request) {
return return
} }
_, serverRoot, ok := requireMinecraftGame(w) _, serverRoot, ok := requireFileContainer(w)
if !ok { if !ok {
return return
} }
@ -75,7 +76,7 @@ func HandleGameFilesDownload(w http.ResponseWriter, r *http.Request) {
return return
} }
_, serverRoot, ok := requireMinecraftGame(w) _, serverRoot, ok := requireFileContainer(w)
if !ok { if !ok {
return return
} }
@ -109,7 +110,7 @@ func HandleGameFilesUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
_, serverRoot, ok := requireMinecraftGame(w) cfg, serverRoot, ok := requireFileContainer(w)
if !ok { if !ok {
return return
} }
@ -147,7 +148,7 @@ func HandleGameFilesUpload(w http.ResponseWriter, r *http.Request) {
continue 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() part.Close()
if err != nil { if err != nil {
writeFilesError(w, err) writeFilesError(w, err)
@ -169,12 +170,12 @@ func HandleGameFilesRevert(w http.ResponseWriter, r *http.Request) {
return return
} }
_, serverRoot, ok := requireMinecraftGame(w) cfg, serverRoot, ok := requireFileContainer(w)
if !ok { if !ok {
return 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 { if err != nil {
writeFilesError(w, err) writeFilesError(w, err)
return return
@ -186,12 +187,12 @@ func HandleGameFilesRevert(w http.ResponseWriter, r *http.Request) {
} }
func handleGameFilesDelete(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 { if !ok {
return 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 { if err != nil {
writeFilesError(w, err) writeFilesError(w, err)
return return
@ -203,7 +204,7 @@ func handleGameFilesDelete(w http.ResponseWriter, r *http.Request) {
} }
func handleGameFilesWrite(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 { if !ok {
return return
} }
@ -220,7 +221,7 @@ func handleGameFilesWrite(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusBadRequest, "invalid request body") writeJSONError(w, http.StatusBadRequest, "invalid request body")
return return
} }
backupCreated, err := agentfiles.Write(serverRoot, normalizedPath, data) backupCreated, err := agentfiles.Write(cfg.ContainerType, serverRoot, normalizedPath, data)
if err != nil { if err != nil {
writeFilesError(w, err) writeFilesError(w, err)
return return
@ -252,3 +253,19 @@ func writeFilesError(w http.ResponseWriter, err error) {
writeJSONError(w, http.StatusInternalServerError, 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
}
}

View File

@ -16,6 +16,7 @@ import (
mcstatus "zlh-agent/internal/minecraft" mcstatus "zlh-agent/internal/minecraft"
"zlh-agent/internal/provision" "zlh-agent/internal/provision"
"zlh-agent/internal/provision/devcontainer" "zlh-agent/internal/provision/devcontainer"
"zlh-agent/internal/provision/devcontainer/dotnet"
"zlh-agent/internal/provision/devcontainer/go" "zlh-agent/internal/provision/devcontainer/go"
"zlh-agent/internal/provision/devcontainer/java" "zlh-agent/internal/provision/devcontainer/java"
"zlh-agent/internal/provision/devcontainer/node" "zlh-agent/internal/provision/devcontainer/node"
@ -28,6 +29,8 @@ import (
"zlh-agent/internal/version" "zlh-agent/internal/version"
) )
const ReadinessTimeout = 60 * time.Second
/* /*
-------------------------------------------------------------------------- --------------------------------------------------------------------------
Helpers Helpers
@ -56,7 +59,7 @@ func waitMinecraftReady(cfg *state.Config, phase string, started time.Time) erro
} }
lifecycleLog(cfg, phase, 1, started, "probe_begin") 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()) state.SetReadyState(false, "minecraft_ping", err.Error())
lifecycleLog(cfg, phase, 1, started, "probe_timeout err=%v", err) lifecycleLog(cfg, phase, 1, started, "probe_timeout err=%v", err)
return err return err
@ -105,7 +108,7 @@ func ensureProvisioned(cfg *state.Config) error {
if cfg.ContainerType == "dev" { if cfg.ContainerType == "dev" {
if !devcontainer.IsProvisioned() { if !devcontainer.IsProvisioned() || !devcontainer.RuntimeInstalled(cfg.Runtime, cfg.Version) {
if err := runProvisionPipeline(cfg); err != nil { if err := runProvisionPipeline(cfg); err != nil {
return err return err
} }
@ -122,6 +125,8 @@ func ensureProvisioned(cfg *state.Config) error {
err = goenv.Verify(*cfg) err = goenv.Verify(*cfg)
case "java": case "java":
err = java.Verify(*cfg) err = java.Verify(*cfg)
case "dotnet":
err = dotnet.Verify(*cfg)
default: default:
return fmt.Errorf("unsupported devcontainer runtime: %s", cfg.Runtime) 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) { 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() started := time.Now()
lifecycleLog(&c, "config_async", 1, started, "begin") lifecycleLog(&c, "config_async", 1, started, "begin")
if err := ensureProvisioned(&c); err != nil { if err := ensureProvisioned(&c); err != nil {
log.Println("[agent] provision error:", err) log.Printf("[http] vmid=%d provision error: %v", c.VMID, err)
return return
} }
@ -206,7 +211,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
state.SetReadyState(false, "", "") state.SetReadyState(false, "", "")
lifecycleLog(&c, "start", 1, started, "start_requested") lifecycleLog(&c, "start", 1, started, "start_requested")
if err := system.StartServer(&c); err != nil { 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.SetError(err)
state.SetState(state.StateError) state.SetState(state.StateError)
lifecycleLog(&c, "start", 1, started, "start_failed err=%v", err) 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) { if time.Now().After(propsDeadline) {
err := fmt.Errorf("forge server.properties not found before timeout") 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.SetError(err)
state.SetState(state.StateError) state.SetState(state.StateError)
return return
@ -247,7 +252,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
_ = system.StopServer() _ = system.StopServer()
if err := system.WaitForServerExit(20 * time.Second); err != nil { 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.SetError(err)
state.SetState(state.StateError) state.SetState(state.StateError)
lifecycleLog(&c, "forge_post", 1, started, "stop_wait_failed err=%v", err) 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 { 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.SetError(err)
state.SetState(state.StateError) state.SetState(state.StateError)
lifecycleLog(&c, "forge_post", 1, started, "enforce_failed err=%v", err) 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.SetState(state.StateStarting)
state.SetReadyState(false, "", "") state.SetReadyState(false, "", "")
if err := system.StartServer(&c); err != nil { 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.SetError(err)
state.SetState(state.StateError) state.SetState(state.StateError)
lifecycleLog(&c, "forge_post", 1, started, "restart_failed err=%v", err) 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) }(cfg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -395,6 +400,20 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
if t := state.GetLastReadyAt(); !t.IsZero() { if t := state.GetLastReadyAt(); !t.IsZero() {
readyAt = t.UTC().Format(time.RFC3339) 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{ resp := map[string]any{
"state": state.GetState(), "state": state.GetState(),
@ -405,6 +424,11 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
"lastReadyAt": readyAt, "lastReadyAt": readyAt,
"installStep": state.GetInstallStep(), "installStep": state.GetInstallStep(),
"crashCount": state.GetCrashCount(), "crashCount": state.GetCrashCount(),
"lastCrashTime": lastCrashTime,
"lastCrashExitCode": lastCrashExitCode,
"lastCrashSignal": lastCrashSignal,
"lastCrashUptimeSeconds": lastCrashUptimeSeconds,
"lastCrashLogTail": lastCrashLogTail,
"error": nil, "error": nil,
"config": cfg, "config": cfg,
"timestamp": time.Now().Unix(), "timestamp": time.Now().Unix(),

View File

@ -57,11 +57,11 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
} }
if sess.ptyFile != currentPTY { 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() sess.destroy()
} else { } else {
sess.touch() 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 return sess, true, nil
} }
} }
@ -87,7 +87,7 @@ func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
sessions[key] = sess sessions[key] = sess
sessionMu.Unlock() 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 return sess, false, nil
} }
@ -106,7 +106,7 @@ func (s *consoleSession) addConn(conn *websocket.Conn, cc *consoleConn) *console
} }
s.conns[conn] = cc s.conns[conn] = cc
s.lastActive = time.Now() 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 return cc
} }
@ -120,7 +120,7 @@ func (s *consoleSession) removeConn(conn *websocket.Conn) int {
safeCloseChan(cc.send) safeCloseChan(cc.send)
} }
s.lastActive = time.Now() 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) return len(s.conns)
} }
@ -133,14 +133,14 @@ func (s *consoleSession) startReader() {
if n > 0 { if n > 0 {
out := make([]byte, n) out := make([]byte, n)
copy(out, buf[: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) s.broadcast(out)
} }
if err != nil { if err != nil {
if err == io.EOF { 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 { } 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() s.destroy()
return return
@ -194,7 +194,7 @@ func (s *consoleSession) scheduleCleanupIfIdle() {
s.mu.Unlock() s.mu.Unlock()
if conns == 0 && lastActive.Equal(ts) { 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" { if s.cfg.ContainerType == "dev" {
_ = system.StopDevShell() _ = system.StopDevShell()
} }
@ -223,7 +223,7 @@ func (s *consoleSession) destroy() {
delete(sessions, s.key) delete(sessions, s.key)
sessionMu.Unlock() 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)
}) })
} }

View File

@ -5,17 +5,10 @@ import (
"path/filepath" "path/filepath"
"zlh-agent/internal/provision/executil" "zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers"
"zlh-agent/internal/state" "zlh-agent/internal/state"
) )
func Install(cfg state.Config) error { func Install(cfg state.Config) error {
const marker = "addon-codeserver"
if markers.IsPresent(marker) {
return nil
}
scriptPath := filepath.Join( scriptPath := filepath.Join(
executil.ScriptsRoot, executil.ScriptsRoot,
"addons", "addons",
@ -26,6 +19,5 @@ func Install(cfg state.Config) error {
if err := executil.RunScript(scriptPath); err != nil { if err := executil.RunScript(scriptPath); err != nil {
return fmt.Errorf("codeserver install failed: %w", err) return fmt.Errorf("codeserver install failed: %w", err)
} }
return nil
return markers.Write(marker)
} }

View File

@ -2,9 +2,19 @@ package devcontainer
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http"
"os" "os"
"os/exec"
"os/user"
"path/filepath" "path/filepath"
"strconv"
"strings"
"time" "time"
"zlh-agent/internal/provcommon"
"zlh-agent/internal/state"
) )
/* /*
@ -19,10 +29,32 @@ const (
// MarkerDir is where devcontainer state is stored. // MarkerDir is where devcontainer state is stored.
MarkerDir = "/opt/zlh/.zlh" 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 is written after a dev container is fully provisioned.
ReadyMarker = "devcontainer_ready.json" 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. // ReadyMarkerPath returns the absolute path to the ready marker file.
func ReadyMarkerPath() string { func ReadyMarkerPath() string {
return filepath.Join(MarkerDir, ReadyMarker) return filepath.Join(MarkerDir, ReadyMarker)
@ -54,3 +86,110 @@ func WriteReadyMarker(runtime string) error {
return os.WriteFile(ReadyMarkerPath(), raw, 0644) 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
}

View File

@ -6,6 +6,7 @@ import (
"zlh-agent/internal/state" "zlh-agent/internal/state"
"zlh-agent/internal/provision/devcontainer/dotnet"
devgo "zlh-agent/internal/provision/devcontainer/go" devgo "zlh-agent/internal/provision/devcontainer/go"
"zlh-agent/internal/provision/devcontainer/java" "zlh-agent/internal/provision/devcontainer/java"
"zlh-agent/internal/provision/devcontainer/node" "zlh-agent/internal/provision/devcontainer/node"
@ -13,18 +14,36 @@ import (
) )
func Provision(cfg state.Config) error { 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) runtime := strings.ToLower(cfg.Runtime)
var err error
switch runtime { switch runtime {
case "node": case "node":
return node.Install(cfg) err = node.Install(cfg)
case "python": case "python":
return python.Install(cfg) err = python.Install(cfg)
case "go": case "go":
return devgo.Install(cfg) err = devgo.Install(cfg)
case "java": case "java":
return java.Install(cfg) err = java.Install(cfg)
case "dotnet":
err = dotnet.Install(cfg)
default: default:
return fmt.Errorf("unsupported dev container runtime: %s", runtime) 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
} }

View 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), "/", "_")
}

View 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
}

View File

@ -2,6 +2,9 @@ package goenv
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings"
"zlh-agent/internal/provision/executil" "zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers" "zlh-agent/internal/provision/markers"
@ -9,8 +12,13 @@ import (
) )
func Install(cfg state.Config) error { 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) { if markers.IsPresent(marker) {
return nil return nil
} }
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
return markers.Write(marker) 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), "/", "_")
}

View File

@ -8,7 +8,7 @@ import (
"zlh-agent/internal/state" "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 { func Verify(cfg state.Config) error {
if _, err := os.Stat(goBin); err != nil { if _, err := os.Stat(goBin); err != nil {

View File

@ -2,6 +2,9 @@ package java
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings"
"zlh-agent/internal/provision/executil" "zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers" "zlh-agent/internal/provision/markers"
@ -9,8 +12,13 @@ import (
) )
func Install(cfg state.Config) error { 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) { if markers.IsPresent(marker) {
return nil return nil
} }
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
return markers.Write(marker) 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), "/", "_")
}

View File

@ -8,7 +8,7 @@ import (
"zlh-agent/internal/state" "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 { func Verify(cfg state.Config) error {
if _, err := os.Stat(javaBin); err != nil { if _, err := os.Stat(javaBin); err != nil {

View File

@ -2,6 +2,9 @@ package node
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings"
"zlh-agent/internal/provision/executil" "zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers" "zlh-agent/internal/provision/markers"
@ -9,8 +12,13 @@ import (
) )
func Install(cfg state.Config) error { 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) { if markers.IsPresent(marker) {
return nil return nil
} }
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
return markers.Write(marker) 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), "/", "_")
}

View File

@ -9,8 +9,8 @@ import (
) )
const ( const (
nodeBin = "/opt/zlh/runtime/node/bin/node" nodeBin = "/opt/zlh/runtimes/node/bin/node"
npmBin = "/opt/zlh/runtime/node/bin/npm" npmBin = "/opt/zlh/runtimes/node/bin/npm"
) )
func Verify(cfg state.Config) error { func Verify(cfg state.Config) error {

View File

@ -2,6 +2,9 @@ package python
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings"
"zlh-agent/internal/provision/executil" "zlh-agent/internal/provision/executil"
"zlh-agent/internal/provision/markers" "zlh-agent/internal/provision/markers"
@ -9,8 +12,13 @@ import (
) )
func Install(cfg state.Config) error { 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) { if markers.IsPresent(marker) {
return nil return nil
} }
@ -26,3 +34,12 @@ func Install(cfg state.Config) error {
return markers.Write(marker) 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), "/", "_")
}

View File

@ -9,8 +9,8 @@ import (
) )
const ( const (
pythonBin = "/opt/zlh/runtime/python/bin/python3" pythonBin = "/opt/zlh/runtimes/python/bin/python3"
pipBin = "/opt/zlh/runtime/python/bin/pip3" pipBin = "/opt/zlh/runtimes/python/bin/pip3"
) )
func Verify(cfg state.Config) error { func Verify(cfg state.Config) error {

View File

@ -4,11 +4,11 @@ import (
"fmt" "fmt"
"strings" "strings"
"zlh-agent/internal/state"
"zlh-agent/internal/provision/addons" "zlh-agent/internal/provision/addons"
"zlh-agent/internal/provision/devcontainer" "zlh-agent/internal/provision/devcontainer"
"zlh-agent/internal/provision/minecraft" "zlh-agent/internal/provision/minecraft"
"zlh-agent/internal/provision/steam" "zlh-agent/internal/provision/steam"
"zlh-agent/internal/state"
) )
/* /*
@ -133,6 +133,19 @@ func ProvisionAll(cfg state.Config) error {
/* --------------------------------------------------------- /* ---------------------------------------------------------
ADDONS (OPTIONAL, ROLE-AGNOSTIC) 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 len(cfg.Addons) > 0 {
if err := addons.Provision(cfg); err != nil { if err := addons.Provision(cfg); err != nil {
return err return err

View File

@ -22,13 +22,14 @@ type Config struct {
// Dev runtime (only for dev containers) // Dev runtime (only for dev containers)
Runtime string `json:"runtime,omitempty"` Runtime string `json:"runtime,omitempty"`
Version string `json:"version"`
// OPTIONAL addons (role-agnostic) // 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"` Game string `json:"game"`
Variant string `json:"variant"` Variant string `json:"variant"`
Version string `json:"version"`
World string `json:"world"` World string `json:"world"`
Ports []int `json:"ports"` Ports []int `json:"ports"`
ArtifactPath string `json:"artifact_path"` ArtifactPath string `json:"artifact_path"`
@ -72,6 +73,7 @@ type agentStatus struct {
lastError error lastError error
crashCount int crashCount int
lastCrash time.Time lastCrash time.Time
lastCrashInfo *CrashInfo
intentionalStop bool intentionalStop bool
ready bool ready bool
readySource string readySource string
@ -79,11 +81,27 @@ type agentStatus struct {
lastReadyAt time.Time 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{ var global = &agentStatus{
state: StateIdle, state: StateIdle,
lastChange: time.Now(), 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 STATE GETTERS
----------------------------------------------------------------------------*/ ----------------------------------------------------------------------------*/
@ -142,6 +160,21 @@ func GetLastReadyAt() time.Time {
return global.lastReadyAt 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 &copyInfo
}
func IsIntentionalStop() bool { func IsIntentionalStop() bool {
global.mu.Lock() global.mu.Lock()
defer global.mu.Unlock() defer global.mu.Unlock()
@ -157,7 +190,7 @@ func SetState(s AgentState) {
defer global.mu.Unlock() defer global.mu.Unlock()
if global.state != s { if global.state != s {
log.Printf("[state] %s → %s\n", global.state, s) stateLogf("%s -> %s", global.state, s)
global.state = s global.state = s
global.lastChange = time.Now() global.lastChange = time.Now()
} }
@ -187,11 +220,27 @@ func ClearIntentionalStop() {
global.intentionalStop = false 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 = &copyInfo
}
func RecordCrash(err error) { func RecordCrash(err error) {
global.mu.Lock() global.mu.Lock()
defer global.mu.Unlock() defer global.mu.Unlock()
log.Printf("[state] crash detected: %v", err) stateLogf("crash recorded err=%v", err)
global.state = StateCrashed global.state = StateCrashed
global.lastError = err global.lastError = err

View File

@ -1,15 +1,22 @@
package system package system
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"os/user"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"zlh-agent/internal/provision" "zlh-agent/internal/provision"
"zlh-agent/internal/provision/devcontainer"
"zlh-agent/internal/runtime" "zlh-agent/internal/runtime"
"zlh-agent/internal/state" "zlh-agent/internal/state"
) )
@ -22,6 +29,7 @@ var (
mu sync.Mutex mu sync.Mutex
serverCmd *exec.Cmd serverCmd *exec.Cmd
serverPTY *os.File serverPTY *os.File
serverStartTime time.Time
devCmd *exec.Cmd devCmd *exec.Cmd
devPTY *os.File devPTY *os.File
@ -52,6 +60,7 @@ func StartServer(cfg *state.Config) error {
dir := provision.ServerDir(*cfg) dir := provision.ServerDir(*cfg)
startScript := filepath.Join(dir, "start.sh") 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 := exec.Command("/bin/bash", startScript)
cmd.Dir = dir cmd.Dir = dir
@ -63,11 +72,13 @@ func StartServer(cfg *state.Config) error {
serverCmd = cmd serverCmd = cmd
serverPTY = ptmx serverPTY = ptmx
serverStartTime = time.Now()
state.ClearIntentionalStop() state.ClearIntentionalStop()
state.SetState(state.StateRunning) state.SetState(state.StateRunning)
state.SetError(nil) state.SetError(nil)
state.SetReadyState(false, "", "") state.SetReadyState(false, "", "")
log.Printf("[process] vmid=%d server process started", cfg.VMID)
go func() { go func() {
err := cmd.Wait() err := cmd.Wait()
@ -83,15 +94,27 @@ func StartServer(cfg *state.Config) error {
state.ClearIntentionalStop() state.ClearIntentionalStop()
state.SetState(state.StateIdle) state.SetState(state.StateIdle)
state.SetReadyState(false, "", "") state.SetReadyState(false, "", "")
log.Printf("[process] vmid=%d server exited after intentional stop", cfg.VMID)
} else if err != nil { } 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) state.RecordCrash(err)
} else { } else {
state.SetState(state.StateIdle) state.SetState(state.StateIdle)
state.SetReadyState(false, "", "") state.SetReadyState(false, "", "")
log.Printf("[process] vmid=%d server exited cleanly", cfg.VMID)
} }
serverCmd = nil serverCmd = nil
serverPTY = nil serverPTY = nil
serverStartTime = time.Time{}
}() }()
return nil return nil
@ -109,6 +132,12 @@ func StopServer() error {
return fmt.Errorf("server not running") 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.SetState(state.StateStopping)
state.MarkIntentionalStop() state.MarkIntentionalStop()
state.SetReadyState(false, "", "") state.SetReadyState(false, "", "")
@ -186,6 +215,9 @@ func StartDevShell() (*os.File, error) {
if devPTY != nil && devCmd != nil { if devPTY != nil && devCmd != nil {
return devPTY, nil return devPTY, nil
} }
if err := devcontainer.EnsureDevUserEnvironment(); err != nil {
return nil, fmt.Errorf("prepare dev environment: %w", err)
}
shell := "/bin/bash" shell := "/bin/bash"
if _, err := os.Stat(shell); err != nil { if _, err := os.Stat(shell); err != nil {
@ -198,7 +230,32 @@ func StartDevShell() (*os.File, error) {
} else { } else {
cmd = exec.Command(shell, "-i") 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) ptmx, err := runtime.CreatePTY(cmd)
if err != nil { if err != nil {
@ -302,3 +359,63 @@ func StopDevShell() error {
state.SetState(state.StateIdle) state.SetState(state.StateIdle)
return nil 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:]...)
}

View File

@ -6,55 +6,79 @@ echo "[code-server] starting install"
# -------------------------------------------------- # --------------------------------------------------
# Config # Config
# -------------------------------------------------- # --------------------------------------------------
ADDON_ROOT="/opt/zlh/addons/code-server" SERVICE_ROOT="/opt/zlh/services/code-server"
ARTIFACT_DIR="/opt/zlh/addons/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" 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 "${MARKER}")"
mkdir -p "$(dirname "${LOG_FILE}")"
# -------------------------------------------------- download_artifact() {
# Idempotency echo "[code-server] downloading ${ARTIFACT_URL}"
# -------------------------------------------------- if command -v curl >/dev/null 2>&1; then
if [ -f "${MARKER}" ]; then curl -fL "${ARTIFACT_URL}" -o "${ARTIFACT_TMP}"
echo "[code-server] already installed" elif command -v wget >/dev/null 2>&1; then
exit 0 wget -O "${ARTIFACT_TMP}" "${ARTIFACT_URL}"
fi else
echo "[code-server][ERROR] curl or wget is required"
ARCHIVE="$(ls ${ARTIFACT_DIR}/code-server.* 2>/dev/null | head -n1)"
if [ -z "${ARCHIVE}" ]; then
echo "[code-server][ERROR] artifact not found"
exit 1 exit 1
fi fi
}
echo "[code-server] extracting ${ARCHIVE}" extract_artifact() {
mkdir -p "${ADDON_ROOT}" local tmp_dir
tmp_dir="$(mktemp -d)"
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
case "${ARCHIVE}" in EXTRACTED_DIR="$(find "${tmp_dir}" -maxdepth 1 -type d -name 'code-server*' | head -n1)"
*.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 if [ -z "${EXTRACTED_DIR}" ]; then
echo "[code-server][ERROR] failed to locate extracted directory" echo "[code-server][ERROR] failed to locate extracted directory"
exit 1 exit 1
fi fi
mv "${EXTRACTED_DIR}"/* "${ADDON_ROOT}/" mv "${EXTRACTED_DIR}"/* "${SERVICE_ROOT}/"
rm -rf "${TMP_DIR}" rm -rf "${tmp_dir}"
}
chmod +x "${ADDON_ROOT}/bin/code-server" # --------------------------------------------------
# Idempotency
# --------------------------------------------------
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
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
touch "${MARKER}" touch "${MARKER}"
rm -f "${ARTIFACT_TMP}"
echo "[code-server] install complete" echo "[code-server] install complete"

View 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"

View File

@ -14,7 +14,7 @@ set -euo pipefail
############################################ ############################################
ZLH_ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}" 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}}" ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-${RUNTIME}}"
############################################ ############################################