diff --git a/cmd/devenv/generate.go b/cmd/devenv/generate.go index 2324141..37fcc9e 100644 --- a/cmd/devenv/generate.go +++ b/cmd/devenv/generate.go @@ -31,6 +31,7 @@ var ( configDir string // Input directory for developer configs dryRun bool allDevs bool + noCleanup bool ) var generateCmd = &cobra.Command{ @@ -75,6 +76,7 @@ func init() { generateCmd.Flags().StringVar(&configDir, "config-dir", "./developers", "Directory containing developer configuration files") generateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be generated without creating files") generateCmd.Flags().BoolVar(&allDevs, "all-developers", false, "Generate manifests for all developers") + generateCmd.Flags().BoolVar(&noCleanup, "no-cleanup", false, "Preserve files from previous runs instead of deleting prior generated manifests before rendering") } @@ -266,30 +268,60 @@ func generateSingleDeveloper(developerName string) { } func generateSystemManifests(cfg *config.BaseConfig, outputDir string) error { - // Create template renderer - renderer := templates.NewSystemRenderer(outputDir) + if !noCleanup { + if err := cleanupTemplateOutputs(outputDir, templates.SystemCleanupScope()); err != nil { + return fmt.Errorf("failed to clean output directory: %w", err) + } + } + + plan := templates.BuildSystemRenderPlan() + renderer := templates.NewSystemRenderer(outputDir, cfg, plan.TemplateNames) - // Render all main templates - if err := renderer.RenderAll(cfg); err != nil { + if err := renderer.RenderAll(); err != nil { return fmt.Errorf("failed to render templates: %w", err) } - fmt.Printf("🎉 Successfully generated system manifests\n") + if err := templates.RunPostRender(outputDir, plan, templates.NewPostRenderOptions()); err != nil { + return fmt.Errorf("failed to run post-render steps: %w", err) + } + fmt.Printf("🎉 Successfully generated system manifests\n") return nil } // generateDeveloperManifests creates Kubernetes manifests for a developer func generateDeveloperManifests(cfg *config.DevEnvConfig, outputDir string) error { - // Create template renderer - renderer := templates.NewDevRenderer(outputDir) + if !noCleanup { + if err := cleanupTemplateOutputs(outputDir, templates.DevCleanupScope()); err != nil { + return fmt.Errorf("failed to clean output directory: %w", err) + } + } + + plan, err := templates.BuildDevRenderPlan(cfg) + if err != nil { + return fmt.Errorf("failed to build render plan: %w", err) + } + renderer := templates.NewDevRenderer(outputDir, cfg, plan.TemplateNames) - // Render all main templates - if err := renderer.RenderAll(cfg); err != nil { + if err := renderer.RenderAll(); err != nil { return fmt.Errorf("failed to render templates: %w", err) } + if err := templates.RunPostRender(outputDir, plan, templates.NewPostRenderOptions()); err != nil { + return fmt.Errorf("failed to run post-render steps: %w", err) + } + fmt.Printf("🎉 Successfully generated manifests for %s\n", cfg.Name) + return nil +} + +func cleanupTemplateOutputs(outputDir string, templateNames []string) error { + for _, templateName := range templateNames { + outputPath := filepath.Join(outputDir, templateName+".yaml") + if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) { + return err + } + } return nil } diff --git a/internal/config/parser.go b/internal/config/parser.go index 3c048c3..fd1c9bd 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -10,22 +10,21 @@ import ( ) // LoadGlobalConfig loads the global configuration file (devenv.yaml) from the config directory. -// Returns a BaseConfig pre-populated with system defaults. If the global config file exists, -// YAML values override the defaults. If the file doesn't exist, returns defaults without error. +// devenv.yaml is mandatory: the function returns an error if the file is absent. +// When the file exists, YAML values are unmarshalled on top of system defaults so that +// any field not explicitly set in the file retains its built-in default value. func LoadGlobalConfig(configDir string) (*BaseConfig, error) { globalConfigPath := filepath.Join(configDir, "devenv.yaml") // Start with system defaults globalConfig := NewBaseConfigWithDefaults() - // Check if global config file exists - if _, err := os.Stat(globalConfigPath); os.IsNotExist(err) { - return &globalConfig, nil // Return defaults if file doesn't exist - } - - // Read the global config file + // devenv.yaml is required — fail fast with an actionable message if it is missing. data, err := os.ReadFile(globalConfigPath) if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("shared config file not found: %s\n\ndevenv.yaml is required. Create it in %s to define shared settings (image, namespace, hostName, auth, etc.).", globalConfigPath, configDir) + } return nil, fmt.Errorf("failed to read global config file %s: %w", globalConfigPath, err) } @@ -48,20 +47,17 @@ func LoadDeveloperConfig(configDir, developerName string) (*DevEnvConfig, error) developerDir := filepath.Join(configDir, developerName) configPath := filepath.Join(developerDir, "devenv-config.yaml") - // Check if the config file exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil, fmt.Errorf("configuration file not found: %s", configPath) - } + // Create empty config (no defaults) + var config DevEnvConfig - // Read the file data, err := os.ReadFile(configPath) if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("configuration file not found: %s", configPath) + } return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) } - // Create empty config (no defaults) - var config DevEnvConfig - // Parse the YAML if err := yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to parse YAML in %s: %w", configPath, err) @@ -91,14 +87,11 @@ func LoadDeveloperConfigWithBaseConfig(configDir, developerName string, baseConf developerDir := filepath.Join(configDir, developerName) configPath := filepath.Join(developerDir, "devenv-config.yaml") - // Check if the config file exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil, fmt.Errorf("configuration file not found: %s", configPath) - } - - // Read the file data, err := os.ReadFile(configPath) if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("configuration file not found: %s", configPath) + } return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) } diff --git a/internal/config/parser_test.go b/internal/config/parser_test.go index 5f49fca..e7498a0 100644 --- a/internal/config/parser_test.go +++ b/internal/config/parser_test.go @@ -57,33 +57,13 @@ resources: assert.Equal(t, 0, cfg.Resources.GPU) // default GPU unchanged }) - t.Run("global config file does not exist -> system defaults", func(t *testing.T) { + t.Run("global config file does not exist -> error", func(t *testing.T) { tempDir := t.TempDir() cfg, err := LoadGlobalConfig(tempDir) - require.NoError(t, err) - - // Top-level defaults - assert.Equal(t, "ubuntu:22.04", cfg.Image) - assert.True(t, cfg.InstallHomebrew) - assert.False(t, cfg.ClearLocalPackages) - assert.False(t, cfg.ClearVSCodeCache) - assert.Equal(t, "/opt/venv/bin", cfg.PythonBinPath) - assert.Equal(t, 1000, cfg.UID) - - // Canonical resource defaults (CPU millicores, Memory Mi) - assert.Equal(t, int(2), cfg.Resources.CPU) // 2 cores - assert.Equal(t, string("8Gi"), cfg.Resources.Memory) // 8Gi - assert.Equal(t, "20Gi", cfg.Resources.Storage) - assert.Equal(t, 0, cfg.Resources.GPU) - - // Slices are non-nil and empty - assert.NotNil(t, cfg.Packages.APT) - assert.Len(t, cfg.Packages.APT, 0) - assert.NotNil(t, cfg.Packages.Python) - assert.Len(t, cfg.Packages.Python, 0) - assert.NotNil(t, cfg.Volumes) - assert.Len(t, cfg.Volumes, 0) + require.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "devenv.yaml is required") }) t.Run("invalid YAML in global config -> error", func(t *testing.T) { @@ -295,38 +275,14 @@ git: assert.Equal(t, developerDir, cfg.DeveloperDir) }) - t.Run("user config with no global config", func(t *testing.T) { + t.Run("user config with no global config -> error", func(t *testing.T) { tempDir := t.TempDir() - // Only user config (no devenv.yaml) - developerDir := filepath.Join(tempDir, "alice") - require.NoError(t, os.MkdirAll(developerDir, 0o755)) - - userConfigYAML := `name: alice -sshPublicKey: "ssh-rsa AAAAB3NzaC1yc2E alice@example.com" -installHomebrew: false -` - require.NoError(t, os.WriteFile(filepath.Join(developerDir, "devenv-config.yaml"), []byte(userConfigYAML), 0o644)) - - // Global = system defaults (no file present) + // devenv.yaml is mandatory; loading without it must fail before developer config is attempted. globalCfg, err := LoadGlobalConfig(tempDir) - require.NoError(t, err) - - cfg, err := LoadDeveloperConfigWithBaseConfig(tempDir, "alice", globalCfg) - require.NoError(t, err) - - // Defaults + user overrides - assert.Equal(t, "alice", cfg.Name) - assert.Equal(t, "ubuntu:22.04", cfg.Image) // system default - assert.False(t, cfg.InstallHomebrew) // user override - assert.False(t, cfg.ClearLocalPackages) // system default - assert.Equal(t, "/opt/venv/bin", cfg.PythonBinPath) // system default - - // Canonical resource defaults and formatted getters - assert.Equal(t, int(2), cfg.Resources.CPU) // default 2 cores - assert.Equal(t, string("8Gi"), cfg.Resources.Memory) // default 8Gi - assert.Equal(t, "2000m", cfg.CPU()) - assert.Equal(t, "8Gi", cfg.Memory()) + require.Error(t, err) + assert.Nil(t, globalCfg) + assert.Contains(t, err.Error(), "devenv.yaml is required") }) } diff --git a/internal/config/types.go b/internal/config/types.go index 7dbd14e..0e21210 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -22,6 +22,9 @@ type BaseConfig struct { // Storage configuration Volumes []VolumeMount `yaml:"volumes,omitempty" validate:"dive"` + // HomeDirMountBase is the host path prefix under which per-developer home + // and linuxbrew volumes are created (e.g. /mnt/devenv → /mnt/devenv//homedir). + HomeDirMountBase string `yaml:"homeDirMountBase,omitempty" validate:"omitempty,mount_path"` // Access configuration SSHPublicKey any `yaml:"sshPublicKey,omitempty" validate:"omitempty,ssh_keys"` // Can be string or []string @@ -30,7 +33,7 @@ type BaseConfig struct { InstallHomebrew bool `yaml:"installHomebrew,omitempty"` ClearLocalPackages bool `yaml:"clearLocalPackages,omitempty"` ClearVSCodeCache bool `yaml:"clearVSCodeCache,omitempty"` - PythonBinPath string `yaml:"pythonBinPath,omitempty" validate:"omitempty,min=1"` + PythonBinPath string `yaml:"pythonBinPath,omitempty" validate:"omitempty,mount_path"` HostName string `yaml:"hostName,omitempty" validate:"omitempty,min=1,hostname"` EnableAuth bool `yaml:"enableAuth,omitempty"` AuthURL string `yaml:"authURL,omitempty" validate:"omitempty,min=1,url"` @@ -80,7 +83,7 @@ type GitRepo struct { Branch string `yaml:"branch,omitempty" validate:"omitempty,min=1"` Tag string `yaml:"tag,omitempty" validate:"omitempty,min=1"` CommitHash string `yaml:"commitHash,omitempty" validate:"omitempty,min=1"` - Directory string `yaml:"directory,omitempty" validate:"omitempty,min=1,filepath"` + Directory string `yaml:"directory,omitempty" validate:"omitempty,mount_path"` } // ResourceConfig represents resource allocation @@ -128,6 +131,7 @@ func NewBaseConfigWithDefaults() BaseConfig { }, GitRepos: []GitRepo{}, // Empty slice - no default git repositories Volumes: []VolumeMount{}, // Empty slice - no default volumes + HomeDirMountBase: "/mnt/devenv", // Default host path prefix for home/linuxbrew volumes Namespace: "devenv", // Default namespace EnvironmentName: "development", // Default environment name } @@ -222,6 +226,21 @@ func (c *DevEnvConfig) NodePort() int { return c.SSHPort } +// HasHTTPPort reports whether HTTP exposure is configured. +func (c *DevEnvConfig) HasHTTPPort() bool { + return c != nil && c.HTTPPort != 0 +} + +// HasHostName reports whether a non-empty ingress hostname is configured. +func (c *DevEnvConfig) HasHostName() bool { + return c != nil && strings.TrimSpace(c.HostName) != "" +} + +// ShouldRenderIngress reports whether ingress can be safely rendered. +func (c *DevEnvConfig) ShouldRenderIngress() bool { + return c.HasHTTPPort() && c.HasHostName() +} + // VolumeMounts returns the configured volume mount specifications. // Returns the slice of VolumeMount configurations for binding local directories // into the developer environment container. diff --git a/internal/config/validation.go b/internal/config/validation.go index cabf662..a8f1ce9 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -224,9 +224,6 @@ func ValidateDevEnvConfig(config *DevEnvConfig) error { if err := validate.Struct(config); err != nil { return formatValidationError(err) } - if err := validatePythonBinPathAbsolute(config.PythonBinPath); err != nil { - return err - } // Require ≥1 SSH public key with valid format. sshKeys, err := config.GetSSHKeys() @@ -250,6 +247,22 @@ func ValidateDevEnvConfig(config *DevEnvConfig) error { return fmt.Errorf("gpu must be >= 0") } + if config.HasHTTPPort() && !config.HasHostName() { + return fmt.Errorf("hostName is required when httpPort is set") + } + + if config.EnableAuth && config.SkipAuth { + return fmt.Errorf("enableAuth and skipAuth cannot both be true") + } + + if config.EnableAuth && strings.TrimSpace(config.AuthURL) == "" { + return fmt.Errorf("authURL is required when enableAuth is true") + } + + if config.EnableAuth && strings.TrimSpace(config.AuthSignIn) == "" { + return fmt.Errorf("authSignIn is required when enableAuth is true") + } + return nil } @@ -259,20 +272,6 @@ func ValidateBaseConfig(config *BaseConfig) error { if err := validate.Struct(config); err != nil { return formatValidationError(err) } - if err := validatePythonBinPathAbsolute(config.PythonBinPath); err != nil { - return err - } - return nil -} - -func validatePythonBinPathAbsolute(p string) error { - p = strings.TrimSpace(p) - if p == "" { - return nil - } - if !path.IsAbs(p) { - return fmt.Errorf("pythonBinPath must be an absolute path, got %q", p) - } return nil } diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 08d5a4f..a38e710 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -253,8 +253,8 @@ func TestValidateBaseConfig_PythonBinPathMustBeAbsolute(t *testing.T) { bad := &BaseConfig{PythonBinPath: "usr/bin"} err := ValidateBaseConfig(bad) require.Error(t, err) - assert.Contains(t, err.Error(), "pythonBinPath") - assert.Contains(t, err.Error(), "absolute path") + assert.Contains(t, err.Error(), "PythonBinPath") + assert.Contains(t, err.Error(), "absolute mount path") } func TestValidateDevEnvConfig_PythonBinPathMustBeAbsolute(t *testing.T) { @@ -276,8 +276,8 @@ func TestValidateDevEnvConfig_PythonBinPathMustBeAbsolute(t *testing.T) { } err := ValidateDevEnvConfig(bad) require.Error(t, err) - assert.Contains(t, err.Error(), "pythonBinPath") - assert.Contains(t, err.Error(), "absolute path") + assert.Contains(t, err.Error(), "PythonBinPath") + assert.Contains(t, err.Error(), "absolute mount path") } func TestValidator_MountPath(t *testing.T) { @@ -347,3 +347,104 @@ func TestValidateDevEnvConfig_VolumeMountPaths(t *testing.T) { assert.Contains(t, err.Error(), "ContainerPath") }) } + +func TestValidateDevEnvConfig_GitRepoDirectory(t *testing.T) { + newCfg := func(directory string) *DevEnvConfig { + return &DevEnvConfig{ + Name: "alice", + BaseConfig: BaseConfig{ + SSHPublicKey: "ssh-ed25519 AAAAB3NzaC1lZDI1NTE5AAAA user@host", + GitRepos: []GitRepo{ + { + URL: "https://github.com/example/repo", + Directory: directory, + }, + }, + }, + } + } + + t.Run("accepts absolute path", func(t *testing.T) { + require.NoError(t, ValidateDevEnvConfig(newCfg("/home/user/repos/myrepo"))) + }) + + t.Run("accepts empty directory (optional field)", func(t *testing.T) { + require.NoError(t, ValidateDevEnvConfig(newCfg(""))) + }) + + t.Run("rejects relative path", func(t *testing.T) { + err := ValidateDevEnvConfig(newCfg("repos/myrepo")) + require.Error(t, err) + assert.Contains(t, err.Error(), "Directory") + }) +} + +func TestValidateDevEnvConfig_IngressDependencies(t *testing.T) { + newCfg := func() *DevEnvConfig { + return &DevEnvConfig{ + Name: "alice", + BaseConfig: BaseConfig{ + SSHPublicKey: "ssh-ed25519 AAAAB3NzaC1lZDI1NTE5AAAA user@host", + }, + } + } + + t.Run("requires hostName when httpPort is set", func(t *testing.T) { + cfg := newCfg() + cfg.HTTPPort = 8080 + + err := ValidateDevEnvConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "hostName is required when httpPort is set") + }) + + t.Run("allows httpPort with hostName", func(t *testing.T) { + cfg := newCfg() + cfg.HTTPPort = 8080 + cfg.HostName = "devenv.example.com" + + require.NoError(t, ValidateDevEnvConfig(cfg)) + }) + + t.Run("requires authURL when enableAuth is true", func(t *testing.T) { + cfg := newCfg() + cfg.EnableAuth = true + cfg.AuthSignIn = "https://auth.example.com/start" + + err := ValidateDevEnvConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "authURL is required when enableAuth is true") + }) + + t.Run("requires authSignIn when enableAuth is true", func(t *testing.T) { + cfg := newCfg() + cfg.EnableAuth = true + cfg.AuthURL = "https://auth.example.com/auth" + + err := ValidateDevEnvConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "authSignIn is required when enableAuth is true") + }) + + t.Run("allows enableAuth with auth URLs", func(t *testing.T) { + cfg := newCfg() + cfg.EnableAuth = true + cfg.AuthURL = "https://auth.example.com/auth" + cfg.AuthSignIn = "https://auth.example.com/start" + + require.NoError(t, ValidateDevEnvConfig(cfg)) + }) + + t.Run("rejects enableAuth with skipAuth", func(t *testing.T) { + cfg := newCfg() + cfg.EnableAuth = true + cfg.SkipAuth = true + cfg.AuthURL = "https://auth.example.com/auth" + cfg.AuthSignIn = "https://auth.example.com/start" + + err := ValidateDevEnvConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "enableAuth and skipAuth cannot both be true") + }) + +} diff --git a/internal/templates/plan.go b/internal/templates/plan.go new file mode 100644 index 0000000..e8ba985 --- /dev/null +++ b/internal/templates/plan.go @@ -0,0 +1,50 @@ +package templates + +import ( + "fmt" + + "github.com/nauticalab/devenv-engine/internal/config" +) + +var devTemplates = []string{"statefulset", "service", "env-vars", "startup-scripts", "ingress"} + +var systemTemplates = []string{"namespace"} + +// RenderPlan defines template selection for a render pass. +type RenderPlan struct { + TemplateNames []string +} + +// BuildDevRenderPlan computes the template set from config before rendering. +func BuildDevRenderPlan(cfg *config.DevEnvConfig) (RenderPlan, error) { + if cfg == nil { + return RenderPlan{}, fmt.Errorf("BuildDevRenderPlan requires non-nil config") + } + + templateNames := make([]string, 0, len(devTemplates)) + for _, templateName := range devTemplates { + if templateName == "ingress" && !cfg.ShouldRenderIngress() { + continue + } + templateNames = append(templateNames, templateName) + } + + return RenderPlan{ + TemplateNames: templateNames, + }, nil +} + +// BuildSystemRenderPlan computes the template set for system-level manifests. +func BuildSystemRenderPlan() RenderPlan { + return RenderPlan{ + TemplateNames: append([]string{}, systemTemplates...), + } +} + +func DevCleanupScope() []string { + return append([]string{}, devTemplates...) +} + +func SystemCleanupScope() []string { + return append([]string{}, systemTemplates...) +} diff --git a/internal/templates/plan_test.go b/internal/templates/plan_test.go new file mode 100644 index 0000000..d2d676f --- /dev/null +++ b/internal/templates/plan_test.go @@ -0,0 +1,86 @@ +package templates + +import ( + "testing" + + "github.com/nauticalab/devenv-engine/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildDevRenderPlan(t *testing.T) { + t.Run("excludes ingress when HTTP port is unset", func(t *testing.T) { + cfg := &config.DevEnvConfig{} + + plan, err := BuildDevRenderPlan(cfg) + require.NoError(t, err) + + assert.Equal(t, expectedDevTemplateNames(false), plan.TemplateNames) + }) + + t.Run("includes ingress when HTTP port is set", func(t *testing.T) { + cfg := &config.DevEnvConfig{HTTPPort: 8080, BaseConfig: config.BaseConfig{HostName: "devenv.example.com"}} + + plan, err := BuildDevRenderPlan(cfg) + require.NoError(t, err) + + assert.Equal(t, expectedDevTemplateNames(true), plan.TemplateNames) + }) + + t.Run("excludes ingress when hostName is missing", func(t *testing.T) { + cfg := &config.DevEnvConfig{HTTPPort: 8080} + + plan, err := BuildDevRenderPlan(cfg) + require.NoError(t, err) + + assert.Equal(t, expectedDevTemplateNames(false), plan.TemplateNames) + }) +} + +func TestBuildDevRenderPlan_NilConfigReturnsError(t *testing.T) { + _, err := BuildDevRenderPlan(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "BuildDevRenderPlan requires non-nil config") +} + +func TestBuildDevRenderPlan_Contract(t *testing.T) { + t.Run("http disabled", func(t *testing.T) { + plan, err := BuildDevRenderPlan(&config.DevEnvConfig{}) + require.NoError(t, err) + assert.Equal(t, []string{"statefulset", "service", "env-vars", "startup-scripts"}, plan.TemplateNames) + }) + + t.Run("http enabled", func(t *testing.T) { + plan, err := BuildDevRenderPlan(&config.DevEnvConfig{HTTPPort: 8080, BaseConfig: config.BaseConfig{HostName: "devenv.example.com"}}) + require.NoError(t, err) + assert.Equal(t, []string{"statefulset", "service", "env-vars", "startup-scripts", "ingress"}, plan.TemplateNames) + }) +} + +func TestBuildSystemRenderPlan(t *testing.T) { + plan := BuildSystemRenderPlan() + + assert.Equal(t, copyTemplateNames(systemTemplates), plan.TemplateNames) +} + +func TestBuildSystemRenderPlan_Contract(t *testing.T) { + plan := BuildSystemRenderPlan() + assert.Equal(t, []string{"namespace"}, plan.TemplateNames) +} + +func TestTemplateScopes(t *testing.T) { + assert.Equal(t, []string{"statefulset", "service", "env-vars", "startup-scripts", "ingress"}, DevCleanupScope()) + assert.Equal(t, []string{"namespace"}, SystemCleanupScope()) +} + +func expectedDevTemplateNames(includeOptional bool) []string { + templateNames := []string{"statefulset", "service", "env-vars", "startup-scripts"} + if includeOptional { + templateNames = append(templateNames, "ingress") + } + return templateNames +} + +func copyTemplateNames(templateNames []string) []string { + return append([]string{}, templateNames...) +} diff --git a/internal/templates/post_render.go b/internal/templates/post_render.go new file mode 100644 index 0000000..23b5104 --- /dev/null +++ b/internal/templates/post_render.go @@ -0,0 +1,13 @@ +package templates + +// PostRenderOptions reserves space for future post-render behaviors. +type PostRenderOptions struct{} + +func NewPostRenderOptions() PostRenderOptions { + return PostRenderOptions{} +} + +// RunPostRender executes post-render steps for a completed render pass. +func RunPostRender(outputDir string, plan RenderPlan, opts PostRenderOptions) error { + return nil +} diff --git a/internal/templates/post_render_test.go b/internal/templates/post_render_test.go new file mode 100644 index 0000000..fecfd04 --- /dev/null +++ b/internal/templates/post_render_test.go @@ -0,0 +1,12 @@ +package templates + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunPostRender_NoOp(t *testing.T) { + err := RunPostRender(t.TempDir(), RenderPlan{TemplateNames: []string{"namespace"}}, NewPostRenderOptions()) + require.NoError(t, err) +} diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go index 665b9be..faee8b3 100644 --- a/internal/templates/renderer.go +++ b/internal/templates/renderer.go @@ -12,11 +12,6 @@ import ( "github.com/nauticalab/devenv-engine/internal/config" ) -var devTemplatesToRender = []string{"statefulset", "service", "env-vars", - "startup-scripts", "ingress"} - -var systemTemplatesToRender = []string{"namespace"} - // Embed all devTemplates and scripts at compile time // //go:embed template_files @@ -27,22 +22,26 @@ type Renderer[T config.BaseConfig | config.DevEnvConfig] struct { outputDir string templateRoot string targetTemplates []string + config *T } -// NewRenderer creates a new template renderer -func NewDevRenderer(outputDir string) *Renderer[config.DevEnvConfig] { - return NewRendererWithFS[config.DevEnvConfig](outputDir, "template_files/dev", devTemplatesToRender) +// NewDevRenderer is a convenience wrapper for dev-specific render tests and +// direct callers. +func NewDevRenderer(outputDir string, cfg *config.DevEnvConfig, templateNames []string) *Renderer[config.DevEnvConfig] { + return NewRenderer[config.DevEnvConfig](outputDir, "template_files/dev", templateNames, cfg) } -func NewSystemRenderer(outputDir string) *Renderer[config.BaseConfig] { - return NewRendererWithFS[config.BaseConfig](outputDir, "template_files/system", systemTemplatesToRender) +// NewSystemRenderer is a convenience wrapper for system-specific direct callers. +func NewSystemRenderer(outputDir string, cfg *config.BaseConfig, templateNames []string) *Renderer[config.BaseConfig] { + return NewRenderer[config.BaseConfig](outputDir, "template_files/system", templateNames, cfg) } -func NewRendererWithFS[T config.BaseConfig | config.DevEnvConfig](outputDir string, templateRoot string, targetTemplates []string) *Renderer[T] { +func NewRenderer[T config.BaseConfig | config.DevEnvConfig](outputDir string, templateRoot string, targetTemplates []string, cfg *T) *Renderer[T] { return &Renderer[T]{ outputDir: outputDir, templateRoot: templateRoot, targetTemplates: targetTemplates, + config: cfg, } } @@ -85,7 +84,7 @@ func templateFuncs(templateRoot string) template.FuncMap { } } -func (r *Renderer[T]) RenderTemplate(templateName string, config *T) error { +func (r *Renderer[T]) RenderTemplate(templateName string) error { // Get the template content from embedded files templateContent, err := templates.ReadFile(filepath.Join(r.templateRoot, fmt.Sprintf("manifests/%s.tmpl", templateName))) if err != nil { @@ -115,7 +114,7 @@ func (r *Renderer[T]) RenderTemplate(templateName string, config *T) error { defer outputFile.Close() // Execute template with DevEnvConfig - simple and clean! - if err := tmpl.Execute(outputFile, config); err != nil { + if err := tmpl.Execute(outputFile, r.config); err != nil { return fmt.Errorf("failed to render template %s: %w", templateName, err) } @@ -123,11 +122,12 @@ func (r *Renderer[T]) RenderTemplate(templateName string, config *T) error { return nil } -func (r *Renderer[T]) RenderAll(config *T) error { +func (r *Renderer[T]) RenderAll() error { for _, templateName := range r.targetTemplates { - if err := r.RenderTemplate(templateName, config); err != nil { + if err := r.RenderTemplate(templateName); err != nil { return fmt.Errorf("failed to render template %s: %w", templateName, err) } } + return nil } diff --git a/internal/templates/renderer_test.go b/internal/templates/renderer_test.go index 2d9053c..a10019f 100644 --- a/internal/templates/renderer_test.go +++ b/internal/templates/renderer_test.go @@ -24,9 +24,11 @@ func TestRenderTemplate(t *testing.T) { "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7... testuser@example.com", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... testuser2@example.com", }, - UID: 2000, - Image: "ubuntu:22.04", + UID: 2000, + Image: "ubuntu:22.04", Namespace: "devenv-test", + HostName: "devenv.example.com", + HomeDirMountBase: "/mnt/devenv", Packages: config.PackageConfig{ Python: []string{"numpy", "pandas"}, APT: []string{"vim", "curl"}, @@ -66,10 +68,12 @@ func TestRenderTemplate(t *testing.T) { tempDir := t.TempDir() // Create renderer - renderer := NewDevRenderer(tempDir) + plan, err := BuildDevRenderPlan(testConfig) + require.NoError(t, err) + renderer := NewDevRenderer(tempDir, testConfig, plan.TemplateNames) // Render template - err := renderer.RenderTemplate(templateName, testConfig) + err = renderer.RenderTemplate(templateName) require.NoError(t, err, "Failed to render template %s", templateName) // Read the generated output @@ -105,36 +109,104 @@ func TestRenderTemplate(t *testing.T) { // TestRenderAll tests the RenderAll function that renders all templates func TestRenderAll(t *testing.T) { - // Create minimal test configuration - testConfig := &config.DevEnvConfig{ - Name: "minimal", - BaseConfig: config.BaseConfig{ - SSHPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7... minimal@example.com", - Namespace: "devenv-test", - }, - SSHPort: 30002, - } + t.Run("includes ingress when HTTP port is set", func(t *testing.T) { + testConfig := &config.DevEnvConfig{ + Name: "minimal", + BaseConfig: config.BaseConfig{ + SSHPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7... minimal@example.com", + Namespace: "devenv-test", + HostName: "devenv.example.com", + }, + SSHPort: 30002, + HTTPPort: 8080, + } - tempDir := t.TempDir() - renderer := NewDevRenderer(tempDir) + tempDir := t.TempDir() + plan, err := BuildDevRenderPlan(testConfig) + require.NoError(t, err) + renderer := NewDevRenderer(tempDir, testConfig, plan.TemplateNames) - // Test RenderAll - err := renderer.RenderAll(testConfig) - require.NoError(t, err, "RenderAll should not return error") + err = renderer.RenderAll() + require.NoError(t, err, "RenderAll should not return error") - // Verify all expected files were created - expectedFiles := []string{"statefulset.yaml", "service.yaml", "env-vars.yaml", "startup-scripts.yaml", "ingress.yaml"} + expectedFiles := templateNamesToFiles(plan.TemplateNames) - for _, filename := range expectedFiles { - filePath := filepath.Join(tempDir, filename) - _, err := os.Stat(filePath) - assert.NoError(t, err, "Expected file %s should exist", filename) + for _, filename := range expectedFiles { + filePath := filepath.Join(tempDir, filename) + _, err := os.Stat(filePath) + assert.NoError(t, err, "Expected file %s should exist", filename) - // Verify file is not empty - content, err := os.ReadFile(filePath) + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.NotEmpty(t, content, "File %s should not be empty", filename) + } + }) + + t.Run("skips ingress when HTTP port is unset", func(t *testing.T) { + testConfig := &config.DevEnvConfig{ + Name: "minimal", + BaseConfig: config.BaseConfig{ + SSHPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7... minimal@example.com", + Namespace: "devenv-test", + }, + SSHPort: 30002, + } + + tempDir := t.TempDir() + plan, err := BuildDevRenderPlan(testConfig) require.NoError(t, err) - assert.NotEmpty(t, content, "File %s should not be empty", filename) + renderer := NewDevRenderer(tempDir, testConfig, plan.TemplateNames) + + err = renderer.RenderAll() + require.NoError(t, err, "RenderAll should not return error") + + expectedFiles := templateNamesToFiles(plan.TemplateNames) + for _, filename := range expectedFiles { + filePath := filepath.Join(tempDir, filename) + _, err := os.Stat(filePath) + assert.NoError(t, err, "Expected file %s should exist", filename) + } + + _, err = os.Stat(filepath.Join(tempDir, "ingress.yaml")) + assert.ErrorIs(t, err, os.ErrNotExist, "ingress.yaml should not be generated without HTTP port") + }) + + t.Run("preserves stale ingress when render fails", func(t *testing.T) { + testConfig := &config.DevEnvConfig{ + Name: "minimal", + BaseConfig: config.BaseConfig{ + SSHPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7... minimal@example.com", + Namespace: "devenv-test", + }, + SSHPort: 30002, + } + + tempDir := t.TempDir() + staleIngress := filepath.Join(tempDir, "ingress.yaml") + require.NoError(t, os.WriteFile(staleIngress, []byte("stale"), 0o644)) + + renderer := NewRenderer[config.DevEnvConfig]( + tempDir, + "template_files/dev", + []string{"nonexistent-template"}, + testConfig, + ) + + err := renderer.RenderAll() + require.Error(t, err) + + content, readErr := os.ReadFile(staleIngress) + require.NoError(t, readErr) + assert.Equal(t, "stale", string(content), "stale ingress.yaml should be preserved when render fails") + }) +} + +func templateNamesToFiles(templateNames []string) []string { + files := make([]string, 0, len(templateNames)) + for _, templateName := range templateNames { + files = append(files, templateName+".yaml") } + return files } // TestRenderTemplate_ErrorCases tests error handling in template rendering @@ -148,17 +220,23 @@ func TestRenderTemplate_ErrorCases(t *testing.T) { t.Run("invalid template name", func(t *testing.T) { tempDir := t.TempDir() - renderer := NewDevRenderer(tempDir) + plan, err := BuildDevRenderPlan(testConfig) + require.NoError(t, err) + renderer := NewDevRenderer(tempDir, testConfig, plan.TemplateNames) - err := renderer.RenderTemplate("nonexistent", testConfig) + err = renderer.RenderTemplate("nonexistent") assert.Error(t, err, "Should return error for invalid template") }) t.Run("invalid output directory", func(t *testing.T) { - // Use a path that can't be created (assuming /root is not writable in test) - renderer := NewDevRenderer("/root/impossible/path") + // Make the parent path a file so MkdirAll fails deterministically. + parentFile := filepath.Join(t.TempDir(), "not-a-directory") + require.NoError(t, os.WriteFile(parentFile, []byte("x"), 0o644)) + plan, err := BuildDevRenderPlan(testConfig) + require.NoError(t, err) + renderer := NewDevRenderer(filepath.Join(parentFile, "child"), testConfig, plan.TemplateNames) - err := renderer.RenderTemplate("configmap", testConfig) + err = renderer.RenderTemplate("env-vars") assert.Error(t, err, "Should return error for invalid output directory") }) } diff --git a/internal/templates/template_files/dev/manifests/statefulset.tmpl b/internal/templates/template_files/dev/manifests/statefulset.tmpl index 6cae7cb..1d97899 100644 --- a/internal/templates/template_files/dev/manifests/statefulset.tmpl +++ b/internal/templates/template_files/dev/manifests/statefulset.tmpl @@ -113,11 +113,11 @@ spec: volumes: - name: dev-storage hostPath: - path: /mnt/devenv/{{.Name}}/homedir + path: {{.HomeDirMountBase}}/{{.Name}}/homedir type: DirectoryOrCreate - name: dev-linuxbrew hostPath: - path: /mnt/devenv/{{.Name}}/linuxbrew + path: {{.HomeDirMountBase}}/{{.Name}}/linuxbrew type: DirectoryOrCreate - name: startup-scripts configMap: diff --git a/internal/templates/testdata/golden/ingress.yaml b/internal/templates/testdata/golden/ingress.yaml index 4d0c5ba..d8b00e6 100644 --- a/internal/templates/testdata/golden/ingress.yaml +++ b/internal/templates/testdata/golden/ingress.yaml @@ -10,7 +10,7 @@ metadata: spec: ingressClassName: nginx rules: - - host: testuser. + - host: testuser.devenv.example.com http: paths: - path: / @@ -22,5 +22,5 @@ spec: name: http tls: - hosts: - - "*." + - "*.devenv.example.com" secretName: http-testuser-tls diff --git a/internal/templates/testdata/golden/statefulset.yaml b/internal/templates/testdata/golden/statefulset.yaml index 33e7ae9..290fcf5 100644 --- a/internal/templates/testdata/golden/statefulset.yaml +++ b/internal/templates/testdata/golden/statefulset.yaml @@ -94,7 +94,7 @@ spec: type: DirectoryOrCreate - name: dev-linuxbrew hostPath: - path: /mnt/devenv/testuser/linuxbrew + path: /mnt/devenv/testuser/linuxbrew type: DirectoryOrCreate - name: startup-scripts configMap: