Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
23 changes: 21 additions & 2 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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
Expand All @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 16 additions & 17 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}

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

Expand Down
Loading
Loading