package devcontainer import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "os/exec" "os/user" "path/filepath" "strconv" "strings" "time" "zlh-agent/internal/provcommon" "zlh-agent/internal/state" ) /* Dev container provisioning helpers. This file is intentionally small and boring. It exists to support idempotency and shared paths across dev container runtimes. */ 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"` } type ReadyInfo struct { Runtime string `json:"runtime"` ReadyAt string `json:"ready_at"` } // ReadyMarkerPath returns the absolute path to the ready marker file. func ReadyMarkerPath() string { return filepath.Join(MarkerDir, ReadyMarker) } // IsProvisioned returns true if the dev container has already been installed. func IsProvisioned() bool { _, err := os.Stat(ReadyMarkerPath()) return err == nil } func ReadReadyMarker() (*ReadyInfo, error) { raw, err := os.ReadFile(ReadyMarkerPath()) if err != nil { return nil, err } var info ReadyInfo if err := json.Unmarshal(raw, &info); err != nil { return nil, err } return &info, nil } // WriteReadyMarker records successful dev container provisioning. // This should be called by runtime installers AFTER all install steps succeed. func WriteReadyMarker(runtime string) error { if err := os.MkdirAll(MarkerDir, 0755); err != nil { return err } data := map[string]any{ "runtime": runtime, "ready_at": time.Now().UTC().Format(time.RFC3339), } raw, err := json.MarshalIndent(data, "", " ") if err != nil { return err } 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) log.Printf("[provision] action=load_catalog url=%s", url) 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) } log.Printf("[provision] action=load_catalog status=ok runtimes=%d", len(catalog.Runtimes)) 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 { log.Printf("[provision] action=validate_runtime runtime=%s version=%s status=ok", runtimeID, 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 }