Skip to content
Closed
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
34 changes: 24 additions & 10 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 removing unplanned outputs")

}

Expand Down Expand Up @@ -266,12 +268,11 @@ func generateSingleDeveloper(developerName string) {
}

func generateSystemManifests(cfg *config.BaseConfig, outputDir string) error {
// Create template renderer
renderer := templates.NewSystemRenderer(outputDir)
postRenderOpts := templates.NewPostRenderOptions(!noCleanup)
spec := templates.BuildSystemGenerationSpec(cfg, outputDir, postRenderOpts)

// Render all main templates
if err := renderer.RenderAll(cfg); err != nil {
return fmt.Errorf("failed to render templates: %w", err)
if err := generateManifests(spec); err != nil {
return err
}

fmt.Printf("🎉 Successfully generated system manifests\n")
Expand All @@ -281,19 +282,32 @@ func generateSystemManifests(cfg *config.BaseConfig, outputDir string) error {

// generateDeveloperManifests creates Kubernetes manifests for a developer
func generateDeveloperManifests(cfg *config.DevEnvConfig, outputDir string) error {
// Create template renderer
renderer := templates.NewDevRenderer(outputDir)
postRenderOpts := templates.NewPostRenderOptions(!noCleanup)
spec := templates.BuildDevGenerationSpec(cfg, outputDir, postRenderOpts)

// Render all main templates
if err := renderer.RenderAll(cfg); err != nil {
return fmt.Errorf("failed to render templates: %w", err)
if err := generateManifests(spec); err != nil {
return err
}

fmt.Printf("🎉 Successfully generated manifests for %s\n", cfg.Name)

return nil
}

func generateManifests[T config.BaseConfig | config.DevEnvConfig](spec templates.GenerationSpec[T]) error {
renderer := templates.NewRenderer(spec.OutputDir, spec.TemplateRoot, spec.Plan.TemplateNames, spec.Config)

if err := renderer.RenderAll(); err != nil {
return fmt.Errorf("failed to render templates: %w", err)
}

if err := templates.RunPostRender(spec.OutputDir, spec.Plan, spec.PostRenderOptions); err != nil {
return fmt.Errorf("failed to run post-render steps: %w", err)
}

return nil
}

// Helper function to print config summary
func printConfigSummary(cfg *config.DevEnvConfig) {
fmt.Printf("\nConfiguration Summary:\n")
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")
})

}
31 changes: 31 additions & 0 deletions internal/templates/generation_spec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package templates

import "github.com/nauticalab/devenv-engine/internal/config"

type GenerationSpec[T config.BaseConfig | config.DevEnvConfig] struct {
Config *T
Plan RenderPlan
OutputDir string
TemplateRoot string
PostRenderOptions PostRenderOptions
}

func BuildDevGenerationSpec(cfg *config.DevEnvConfig, outputDir string, postRenderOpts PostRenderOptions) GenerationSpec[config.DevEnvConfig] {
return GenerationSpec[config.DevEnvConfig]{
Config: cfg,
Plan: BuildDevRenderPlan(cfg),
OutputDir: outputDir,
TemplateRoot: "template_files/dev",
PostRenderOptions: postRenderOpts,
}
}

func BuildSystemGenerationSpec(cfg *config.BaseConfig, outputDir string, postRenderOpts PostRenderOptions) GenerationSpec[config.BaseConfig] {
return GenerationSpec[config.BaseConfig]{
Config: cfg,
Plan: BuildSystemRenderPlan(),
OutputDir: outputDir,
TemplateRoot: "template_files/system",
PostRenderOptions: postRenderOpts,
}
}
41 changes: 41 additions & 0 deletions internal/templates/plan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package templates

import "github.com/nauticalab/devenv-engine/internal/config"

var devBaseTemplates = []string{"statefulset", "service", "env-vars", "startup-scripts"}

var devOptionalTemplates = []string{"ingress"}

var devManagedTemplates = append(append([]string{}, devBaseTemplates...), devOptionalTemplates...)

var systemBaseTemplates = []string{"namespace"}

var systemManagedTemplates = append([]string{}, systemBaseTemplates...)

// RenderPlan defines template selection and ownership.
type RenderPlan struct {
TemplateNames []string
ManagedTemplates []string
}

// BuildDevRenderPlan computes the template set from config before rendering.
func BuildDevRenderPlan(cfg *config.DevEnvConfig) RenderPlan {
templateNames := append([]string{}, devBaseTemplates...)

if cfg != nil && cfg.ShouldRenderIngress() {
templateNames = append(templateNames, "ingress")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason why we don't use devOptionalTemplates here is because In the future devOptionalTemplates could contain more than ingress, however, the relevant line in BuildDevRenderPlan specifically checks whether ingress should be included in templateNames, and it will always be ingress.

}

return RenderPlan{
TemplateNames: templateNames,
ManagedTemplates: append([]string{}, devManagedTemplates...),
}
}

// BuildSystemRenderPlan computes the template set for system-level manifests.
func BuildSystemRenderPlan() RenderPlan {
return RenderPlan{
TemplateNames: append([]string{}, systemBaseTemplates...),
ManagedTemplates: append([]string{}, systemManagedTemplates...),
}
}
103 changes: 103 additions & 0 deletions internal/templates/plan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 := BuildDevRenderPlan(cfg)

assert.Equal(t, expectedDevTemplateNames(false), plan.TemplateNames)
assert.Equal(t, copyTemplateNames(devManagedTemplates), plan.ManagedTemplates)
})

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 := BuildDevRenderPlan(cfg)

assert.Equal(t, expectedDevTemplateNames(true), plan.TemplateNames)
assert.Equal(t, copyTemplateNames(devManagedTemplates), plan.ManagedTemplates)
})

t.Run("excludes ingress when hostName is missing", func(t *testing.T) {
cfg := &config.DevEnvConfig{HTTPPort: 8080}

plan := BuildDevRenderPlan(cfg)

assert.Equal(t, expectedDevTemplateNames(false), plan.TemplateNames)
assert.Equal(t, copyTemplateNames(devManagedTemplates), plan.ManagedTemplates)
})
}

func TestBuildDevRenderPlan_Contract(t *testing.T) {
t.Run("http disabled", func(t *testing.T) {
plan := BuildDevRenderPlan(&config.DevEnvConfig{})
assert.Equal(t, []string{"statefulset", "service", "env-vars", "startup-scripts"}, plan.TemplateNames)
assert.Equal(t, []string{"statefulset", "service", "env-vars", "startup-scripts", "ingress"}, plan.ManagedTemplates)
})

t.Run("http enabled", func(t *testing.T) {
plan := BuildDevRenderPlan(&config.DevEnvConfig{HTTPPort: 8080, BaseConfig: config.BaseConfig{HostName: "devenv.example.com"}})
assert.Equal(t, []string{"statefulset", "service", "env-vars", "startup-scripts", "ingress"}, plan.TemplateNames)
assert.Equal(t, []string{"statefulset", "service", "env-vars", "startup-scripts", "ingress"}, plan.ManagedTemplates)
})
}

func TestBuildSystemRenderPlan(t *testing.T) {
plan := BuildSystemRenderPlan()

assert.Equal(t, copyTemplateNames(systemBaseTemplates), plan.TemplateNames)
assert.Equal(t, copyTemplateNames(systemManagedTemplates), plan.ManagedTemplates)
}

func TestBuildSystemRenderPlan_Contract(t *testing.T) {
plan := BuildSystemRenderPlan()
assert.Equal(t, []string{"namespace"}, plan.TemplateNames)
assert.Equal(t, []string{"namespace"}, plan.ManagedTemplates)
}

func TestRenderPlans_TargetTemplatesAreManaged(t *testing.T) {
t.Run("dev plan", func(t *testing.T) {
plan := BuildDevRenderPlan(&config.DevEnvConfig{HTTPPort: 8080, BaseConfig: config.BaseConfig{HostName: "devenv.example.com"}})
requireTargetSubsetOfManaged(t, plan.TemplateNames, plan.ManagedTemplates)
})

t.Run("system plan", func(t *testing.T) {
plan := BuildSystemRenderPlan()
requireTargetSubsetOfManaged(t, plan.TemplateNames, plan.ManagedTemplates)
})
}

func requireTargetSubsetOfManaged(t *testing.T, targetTemplates []string, managedTemplates []string) {
t.Helper()

managedSet := make(map[string]struct{}, len(managedTemplates))
for _, templateName := range managedTemplates {
managedSet[templateName] = struct{}{}
}

for _, templateName := range targetTemplates {
_, ok := managedSet[templateName]
require.Truef(t, ok, "target template %q is not managed", templateName)
}
}

func expectedDevTemplateNames(includeOptional bool) []string {
templateNames := copyTemplateNames(devBaseTemplates)
if includeOptional {
templateNames = append(templateNames, devOptionalTemplates...)
}
return templateNames
}

func copyTemplateNames(templateNames []string) []string {
return append([]string{}, templateNames...)
}
Loading
Loading