From 6019d0bc1cecde26138ab535be66111b4ec1ccd4 Mon Sep 17 00:00:00 2001 From: jester Date: Sun, 15 Mar 2026 11:06:08 +0000 Subject: [PATCH] 3-14-26 updates --- internal/files/files.go | 44 ++++-- internal/handlers/files.go | 43 ++++-- internal/http/agent.go | 66 +++++--- internal/http/console_sessions.go | 20 +-- .../provision/addons/codeserver/install.go | 10 +- internal/provision/devcontainer/common.go | 141 +++++++++++++++++- .../provision/devcontainer/devcontainer.go | 27 +++- .../provision/devcontainer/dotnet/install.go | 45 ++++++ .../provision/devcontainer/dotnet/verify.go | 23 +++ internal/provision/devcontainer/go/install.go | 21 ++- internal/provision/devcontainer/go/verify.go | 2 +- .../provision/devcontainer/java/install.go | 21 ++- .../provision/devcontainer/java/verify.go | 2 +- .../provision/devcontainer/node/install.go | 21 ++- .../provision/devcontainer/node/verify.go | 4 +- .../provision/devcontainer/python/install.go | 21 ++- .../provision/devcontainer/python/verify.go | 4 +- internal/provision/provision.go | 25 +++- internal/state/state.go | 57 ++++++- internal/system/process.go | 125 +++++++++++++++- scripts/addons/codeserver/install.sh | 100 ++++++++----- scripts/devcontainer/dotnet/install.sh | 54 +++++++ scripts/devcontainer/lib/common.sh | 2 +- 23 files changed, 738 insertions(+), 140 deletions(-) create mode 100644 internal/provision/devcontainer/dotnet/install.go create mode 100644 internal/provision/devcontainer/dotnet/verify.go create mode 100644 scripts/devcontainer/dotnet/install.sh diff --git a/internal/files/files.go b/internal/files/files.go index 16581b6..8eb414d 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -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 } diff --git a/internal/handlers/files.go b/internal/handlers/files.go index 2178de1..6df3aff 100644 --- a/internal/handlers/files.go +++ b/internal/handlers/files.go @@ -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 + } +} diff --git a/internal/http/agent.go b/internal/http/agent.go index 826208d..14ea382 100755 --- a/internal/http/agent.go +++ b/internal/http/agent.go @@ -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 { diff --git a/internal/http/console_sessions.go b/internal/http/console_sessions.go index 3566d7b..374abc4 100644 --- a/internal/http/console_sessions.go +++ b/internal/http/console_sessions.go @@ -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) }) } diff --git a/internal/provision/addons/codeserver/install.go b/internal/provision/addons/codeserver/install.go index 996ee6b..fb93821 100644 --- a/internal/provision/addons/codeserver/install.go +++ b/internal/provision/addons/codeserver/install.go @@ -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 } diff --git a/internal/provision/devcontainer/common.go b/internal/provision/devcontainer/common.go index 1814234..114b773 100644 --- a/internal/provision/devcontainer/common.go +++ b/internal/provision/devcontainer/common.go @@ -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 +} diff --git a/internal/provision/devcontainer/devcontainer.go b/internal/provision/devcontainer/devcontainer.go index 22ef350..2dc2dde 100644 --- a/internal/provision/devcontainer/devcontainer.go +++ b/internal/provision/devcontainer/devcontainer.go @@ -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 } diff --git a/internal/provision/devcontainer/dotnet/install.go b/internal/provision/devcontainer/dotnet/install.go new file mode 100644 index 0000000..6c28ab5 --- /dev/null +++ b/internal/provision/devcontainer/dotnet/install.go @@ -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), "/", "_") +} diff --git a/internal/provision/devcontainer/dotnet/verify.go b/internal/provision/devcontainer/dotnet/verify.go new file mode 100644 index 0000000..bacb0f4 --- /dev/null +++ b/internal/provision/devcontainer/dotnet/verify.go @@ -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 +} diff --git a/internal/provision/devcontainer/go/install.go b/internal/provision/devcontainer/go/install.go index fd6ab73..cff27b0 100644 --- a/internal/provision/devcontainer/go/install.go +++ b/internal/provision/devcontainer/go/install.go @@ -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), "/", "_") +} diff --git a/internal/provision/devcontainer/go/verify.go b/internal/provision/devcontainer/go/verify.go index 68a1e6c..2e6f9ad 100644 --- a/internal/provision/devcontainer/go/verify.go +++ b/internal/provision/devcontainer/go/verify.go @@ -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 { diff --git a/internal/provision/devcontainer/java/install.go b/internal/provision/devcontainer/java/install.go index 81f1a41..bf06707 100644 --- a/internal/provision/devcontainer/java/install.go +++ b/internal/provision/devcontainer/java/install.go @@ -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), "/", "_") +} diff --git a/internal/provision/devcontainer/java/verify.go b/internal/provision/devcontainer/java/verify.go index 8bb2f4e..bdc1ad8 100644 --- a/internal/provision/devcontainer/java/verify.go +++ b/internal/provision/devcontainer/java/verify.go @@ -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 { diff --git a/internal/provision/devcontainer/node/install.go b/internal/provision/devcontainer/node/install.go index fa410d2..cddf4ba 100644 --- a/internal/provision/devcontainer/node/install.go +++ b/internal/provision/devcontainer/node/install.go @@ -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), "/", "_") +} diff --git a/internal/provision/devcontainer/node/verify.go b/internal/provision/devcontainer/node/verify.go index a3611c6..f714268 100644 --- a/internal/provision/devcontainer/node/verify.go +++ b/internal/provision/devcontainer/node/verify.go @@ -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 { diff --git a/internal/provision/devcontainer/python/install.go b/internal/provision/devcontainer/python/install.go index bed2e56..3c96467 100644 --- a/internal/provision/devcontainer/python/install.go +++ b/internal/provision/devcontainer/python/install.go @@ -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), "/", "_") +} diff --git a/internal/provision/devcontainer/python/verify.go b/internal/provision/devcontainer/python/verify.go index eb6f5e6..ca43b3c 100644 --- a/internal/provision/devcontainer/python/verify.go +++ b/internal/provision/devcontainer/python/verify.go @@ -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 { diff --git a/internal/provision/provision.go b/internal/provision/provision.go index f407713..50e9e31 100644 --- a/internal/provision/provision.go +++ b/internal/provision/provision.go @@ -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 diff --git a/internal/state/state.go b/internal/state/state.go index 1f65fc3..46d9356 100755 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -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 diff --git a/internal/system/process.go b/internal/system/process.go index ed2e182..39d4d11 100755 --- a/internal/system/process.go +++ b/internal/system/process.go @@ -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:]...) +} diff --git a/scripts/addons/codeserver/install.sh b/scripts/addons/codeserver/install.sh index c7d4ac3..26fc0da 100644 --- a/scripts/addons/codeserver/install.sh +++ b/scripts/addons/codeserver/install.sh @@ -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" diff --git a/scripts/devcontainer/dotnet/install.sh b/scripts/devcontainer/dotnet/install.sh new file mode 100644 index 0000000..7891f5d --- /dev/null +++ b/scripts/devcontainer/dotnet/install.sh @@ -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 <