zlh-agent/internal/provision/devcontainer/common.go
2026-03-15 11:06:08 +00:00

196 lines
4.9 KiB
Go

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"
)
/*
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"`
}
// 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
}
// 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)
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
}