Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you find out if explicitly checking for nil is the standard best practice or it's more common to just leave it unchecked unless you are going to do some speicial nil handling.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I took the leaner approach for now and removed the explicit nil checks from HasHTTPPort() and HasHostName().

The reason I originally included them was to make these predicate helpers nil-safe, so a nil *DevEnvConfig would be interpreted as “not configured” instead of panicking. That can be a reasonable pattern for small boolean helpers, but in our current codepath we only call them on real configs, and we weren’t relying on any special nil behavior. If you’d prefer these helpers to be intentionally nil-safe as part of their contract, I’m happy to add that back consistently.

}

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

}
50 changes: 50 additions & 0 deletions internal/templates/plan.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if RenderPlan entirely consists of TemlateNames, don't you think it might be sufficient to just pass that around instead of further wrapping it in a struct?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Since RenderPlan had been reduced to a thin wrapper around TemplateNames, I removed the wrapper and now pass []string directly.

In the same spirit of keeping the codebase lean and adding functionality only when needed, I also removed the no-op post_render placeholder. The generation pipeline now only contains the active steps we use today: cleanup, template selection, and rendering.

}

// 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...)
}
86 changes: 86 additions & 0 deletions internal/templates/plan_test.go
Original file line number Diff line number Diff line change
@@ -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...)
}
13 changes: 13 additions & 0 deletions internal/templates/post_render.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions internal/templates/post_render_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading