Skip to content
Open
50 changes: 41 additions & 9 deletions cmd/devenv/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var (
configDir string // Input directory for developer configs
dryRun bool
allDevs bool
noCleanup bool
)

var generateCmd = &cobra.Command{
Expand Down Expand Up @@ -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")

}

Expand Down Expand Up @@ -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
}
Expand Down
37 changes: 15 additions & 22 deletions internal/config/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down
62 changes: 9 additions & 53 deletions internal/config/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
})
}

Expand Down
15 changes: 15 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,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.
Expand Down
16 changes: 16 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,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
}

Expand Down
70 changes: 70 additions & 0 deletions internal/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,73 @@ func TestValidateDevEnvConfig_VolumeMountPaths(t *testing.T) {
assert.Contains(t, err.Error(), "ContainerPath")
})
}

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")
})

}
Loading
Loading