diff --git a/cmd/fresh.go b/cmd/fresh.go index 29e2cbb..1494fe0 100644 --- a/cmd/fresh.go +++ b/cmd/fresh.go @@ -9,7 +9,7 @@ import ( ) var freshConfigPath, freshOrganization, onboardConfigPath, onboardFrom string -var onboardAppName, onboardAppRepoURL, onboardImageRepository string +var onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy string var freshCreateOrganization, freshEnableDelivery, freshYes bool var freshMode string @@ -54,6 +54,7 @@ func init() { appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override") appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override") appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override") + appOnboardCmd.Flags().StringVar(&onboardBuildStrategy, "build-strategy", "", "Build strategy override: static or runtime") _ = appOnboardCmd.MarkFlagRequired("config") _ = appOnboardCmd.MarkFlagRequired("from") } @@ -87,6 +88,9 @@ func runAppOnboard(_ *cobra.Command, _ []string) error { if onboardImageRepository != "" { cfg.Delivery.ImageRepository = onboardImageRepository } + if onboardBuildStrategy != "" { + cfg.Delivery.BuildStrategy = onboardBuildStrategy + } cfg, err = resolveAppOnboarding(cfg) if err != nil { return err diff --git a/cmd/fresh_test.go b/cmd/fresh_test.go index 19523d1..7891895 100644 --- a/cmd/fresh_test.go +++ b/cmd/fresh_test.go @@ -65,11 +65,11 @@ func TestBootstrapInitAppliesFluxDefaultsBeforeFreshValidation(t *testing.T) { func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) { originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfigPath, originalFrom := onboardConfigPath, onboardFrom - originalName, originalRepo, originalImage := onboardAppName, onboardAppRepoURL, onboardImageRepository + originalName, originalRepo, originalImage, originalBuildStrategy := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy t.Cleanup(func() { loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard onboardConfigPath, onboardFrom = originalConfigPath, originalFrom - onboardAppName, onboardAppRepoURL, onboardImageRepository = originalName, originalRepo, originalImage + onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = originalName, originalRepo, originalImage, originalBuildStrategy }) loadAppOnboardConfig = func(string) (config.Config, error) { return config.Config{}, nil } resolveAppOnboarding = func(config.Config) (config.Config, error) { return config.Config{}, errors.New("incomplete delivery") } @@ -78,7 +78,7 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) { return nil } onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" - onboardAppName, onboardAppRepoURL, onboardImageRepository = "", "", "" + onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = "", "", "", "" if err := runAppOnboard(nil, nil); err == nil { t.Fatal("onboarding accepted invalid configuration") } @@ -87,25 +87,25 @@ func TestAppOnboardValidatesConfigBeforeExternalWork(t *testing.T) { func TestAppOnboardPassesOnlyValidatedConfigAndCheckout(t *testing.T) { originalConfig, originalResolve, originalOnboard := loadAppOnboardConfig, resolveAppOnboarding, onboardApp originalConfigPath, originalFrom := onboardConfigPath, onboardFrom - originalName, originalRepo, originalImage := onboardAppName, onboardAppRepoURL, onboardImageRepository + originalName, originalRepo, originalImage, originalBuildStrategy := onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy t.Cleanup(func() { loadAppOnboardConfig, resolveAppOnboarding, onboardApp = originalConfig, originalResolve, originalOnboard onboardConfigPath, onboardFrom = originalConfigPath, originalFrom - onboardAppName, onboardAppRepoURL, onboardImageRepository = originalName, originalRepo, originalImage + onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = originalName, originalRepo, originalImage, originalBuildStrategy }) cfg := config.Config{Delivery: config.DeliveryConfig{AppRepoURL: "https://git.example.test/new-org/app.git", AppRepoRef: "main"}} loadAppOnboardConfig = func(string) (config.Config, error) { return cfg, nil } resolveAppOnboarding = func(got config.Config) (config.Config, error) { return got, nil } calls := 0 onboardApp = func(got config.Config, checkout string) error { - if checkout != "app-checkout" || got.Delivery.AppName != "fixture" || got.Delivery.AppRepoURL != "https://git.example.test/new-org/fixture.git" || got.Delivery.ImageRepository != "registry.example.test/new-org/fixture" { + if checkout != "app-checkout" || got.Delivery.AppName != "fixture" || got.Delivery.AppRepoURL != "https://git.example.test/new-org/fixture.git" || got.Delivery.ImageRepository != "registry.example.test/new-org/fixture" || got.Delivery.BuildStrategy != "runtime" { t.Fatal("onboarding used the wrong checkout or config") } calls++ return nil } onboardConfigPath, onboardFrom = "private.yaml", "app-checkout" - onboardAppName, onboardAppRepoURL, onboardImageRepository = "fixture", "https://git.example.test/new-org/fixture.git", "registry.example.test/new-org/fixture" + onboardAppName, onboardAppRepoURL, onboardImageRepository, onboardBuildStrategy = "fixture", "https://git.example.test/new-org/fixture.git", "registry.example.test/new-org/fixture", "runtime" if err := runAppOnboard(nil, nil); err != nil || calls != 1 { t.Fatalf("runAppOnboard() = %v, calls = %d", err, calls) } diff --git a/internal/assets/templates/delivery-pipeline.yaml.tmpl b/internal/assets/templates/delivery-pipeline.yaml.tmpl index 61689dc..9b5bf39 100644 --- a/internal/assets/templates/delivery-pipeline.yaml.tmpl +++ b/internal/assets/templates/delivery-pipeline.yaml.tmpl @@ -635,7 +635,7 @@ spec: operator: notin values: [closed] taskRef: - name: maidn-node-static-image + name: {{ if eq .BuildStrategy "static" }}maidn-node-static-image{{ else }}maidn-node-runtime-image{{ end }} params: - name: url value: {{ quote .AppRepoURL }} @@ -643,10 +643,12 @@ spec: value: $(params.git-revision) - name: image value: $(params.image) +{{ if eq .BuildStrategy "static" }} - name: output-directory value: {{ quote .BuildOutputDirectory }} - name: build-configuration value: {{ quote .BuildConfiguration }} +{{ end }} - name: update-preview runAfter: [build-layer] when: diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 952f5ff..66ee076 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -624,6 +624,7 @@ type appDeliveryTemplateConfig struct { AppRepoRef string ProductionBranch string ImageRepository string + BuildStrategy string BuildOutputDirectory string BuildConfiguration string ForgejoBaseURL string @@ -693,6 +694,9 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) { if !deliveryAppName.MatchString(cfg.Delivery.AppName) { return nil, errors.New("delivery appName must be a lowercase DNS label") } + if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" { + return nil, errors.New("delivery buildStrategy must be static or runtime") + } appRepository, err := deliveryRepository(cfg.Git.BaseURL, cfg.Delivery.AppRepoURL) if err != nil { return nil, err @@ -704,7 +708,7 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) { values := appDeliveryTemplateConfig{ AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, - BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, + BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, } for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch} { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 4de135d..df8088d 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -112,13 +112,13 @@ func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) cfg := config.Config{ Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, - Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"}, + Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"}, } content, err := renderAppDelivery(cfg) if err != nil { t.Fatal(err) } - for _, expected := range []string{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} { + for _, expected := range []string{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "name: output-directory\n value: \"dist/web-ui\"", "name: build-configuration\n value: \"production\"", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} { if !strings.Contains(string(content), expected) { t.Fatalf("generated delivery does not contain %q", expected) } @@ -130,11 +130,33 @@ func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) } } +func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) { + cfg := config.Config{ + Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, + Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, + Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "runtime", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"}, + } + content, err := renderAppDelivery(cfg) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"maidn-node-runtime-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)"} { + if !strings.Contains(string(content), expected) { + t.Fatalf("generated runtime delivery does not contain %q", expected) + } + } + for _, unexpected := range []string{"maidn-node-static-image", "name: output-directory", "name: build-configuration"} { + if strings.Contains(string(content), unexpected) { + t.Fatalf("generated runtime delivery contains static-only %q", unexpected) + } + } +} + func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.T) { cfg := config.Config{ Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"}, Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"}, - Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production"}, + Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production"}, } content, err := renderAppDelivery(cfg) if err != nil { diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go index d51ce62..e52929e 100644 --- a/internal/bootstrap/onboard_test.go +++ b/internal/bootstrap/onboard_test.go @@ -32,7 +32,7 @@ func onboardingConfig() config.Config { Flux: config.FluxConfig{Branch: "main", RepoName: "cluster", ManifestsRepo: "manifests", ClusterDomain: "example.test"}, Delivery: config.DeliveryConfig{ AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org-2/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", - ImageRepository: "registry.example.test/test-org-2/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production", + ImageRepository: "registry.example.test/test-org-2/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/", }, } diff --git a/internal/config/config.go b/internal/config/config.go index f2e4197..da21ab1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -215,6 +215,9 @@ func validateRepositoryURL(value string) error { } func applyDefaults(cfg *Config) { + if cfg.Delivery.BuildStrategy == "" { + cfg.Delivery.BuildStrategy = "static" + } if cfg.ClusterID == "" { cfg.ClusterID = cfg.Talos.Cluster.Name } @@ -317,6 +320,9 @@ func applyDefaults(cfg *Config) { } func applyDeliveryDefaults(cfg *Config) { + if cfg.Delivery.BuildStrategy == "" { + cfg.Delivery.BuildStrategy = "static" + } if cfg.Delivery.AppRepoRef == "" { cfg.Delivery.AppRepoRef = cfg.Flux.Branch } @@ -542,6 +548,9 @@ func ValidateDelivery(cfg Config) error { if !regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`).MatchString(cfg.Delivery.AppName) { return errors.New("delivery appName must be a lowercase DNS label of at most 47 characters") } + if cfg.Delivery.BuildStrategy != "static" && cfg.Delivery.BuildStrategy != "runtime" { + return errors.New("delivery buildStrategy must be static or runtime") + } if cfg.Delivery.ProductionBranch == cfg.Delivery.AppRepoRef || !validDeliveryBranch(cfg.Delivery.ProductionBranch) { return errors.New("delivery productionBranch must be a valid branch distinct from appRepoRef") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 93f1181..bd6bcef 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -17,7 +17,7 @@ func validConfig(t *testing.T) Config { Templates: TemplateConfig{TalosRepoURL: "https://git.example.test/talos.git", TalosRepoRef: "main", CICDRepoURL: "https://git.example.test/template.git", CICDRepoRef: "main", ManifestsRepoURL: "https://git.example.test/manifests.git", ManifestsRepoRef: "main"}, Cilium: CiliumConfig{TrafficInterface: "eth1", LoadBalancerStart: "192.168.45.19", LoadBalancerEnd: "192.168.45.30"}, DemocraticCSI: DemocraticCSIConfig{TrueNASAPIKey: "api-key", TrueNASHost: "truenas.example.test", TargetPortal: "truenas.example.test:3260", ShareHost: "truenas.example.test", DatasetParentNFS: "pool/kubernetes/nfs/v", DatasetSnapshotsNFS: "pool/kubernetes/nfs/s", AllowedNetworks: "192.168.45.0/24", NameSuffix: "-test", PortalGroup: "1", InitiatorGroup: "1"}, - Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, + Delivery: DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/test-org/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/test-org/web-ui", BuildStrategy: "static", BuildOutputDirectory: "dist", BuildConfiguration: "production", WebhookHostname: "tekton.example.test", WebhookPath: "/"}, Talos: TalosConfig{ RepoDirName: "talos", TerraformDir: "terraform", GeneratedDir: "generated", ConfigFileName: "terraform.tfvars", Proxmox: TalosProxmoxConfig{APIURL: "https://proxmox.example.test:8006", APITokenID: "id", APITokenSecret: "secret"}, @@ -64,6 +64,7 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T cfg.Delivery.AppRepoRef = "" cfg.Delivery.BuildOutputDirectory = "" cfg.Delivery.BuildConfiguration = "" + cfg.Delivery.BuildStrategy = "" cfg.Delivery.WebhookHostname = "" cfg.Delivery.WebhookPath = "" @@ -78,11 +79,25 @@ func TestResolveDeliveryAppliesDefaultsOnlyForExplicitAppOperations(t *testing.T if err != nil { t.Fatal(err) } - if !delivery.Delivery.Configured() || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" { + if !delivery.Delivery.Configured() || delivery.Delivery.BuildStrategy != "static" || delivery.Delivery.AppRepoRef != "main" || delivery.Delivery.BuildOutputDirectory != "dist" || delivery.Delivery.BuildConfiguration != "production" || delivery.Delivery.WebhookURL() != "https://tekton.example.test/" { t.Fatalf("ResolveDelivery() did not apply the complete delivery contract: %#v", delivery.Delivery) } } +func TestValidateDeliveryBuildStrategy(t *testing.T) { + cfg := validConfig(t) + for _, strategy := range []string{"static", "runtime"} { + cfg.Delivery.BuildStrategy = strategy + if err := ValidateDelivery(cfg); err != nil { + t.Fatalf("ValidateDelivery() rejected %q: %v", strategy, err) + } + } + cfg.Delivery.BuildStrategy = "container" + if err := ValidateDelivery(cfg); err == nil || !strings.Contains(err.Error(), "buildStrategy") { + t.Fatalf("ValidateDelivery() accepted invalid build strategy: %v", err) + } +} + func TestValidateDeliveryRequiresCompleteConfig(t *testing.T) { cfg := validConfig(t) cfg.Delivery.ImageRepository = "" diff --git a/internal/config/types.go b/internal/config/types.go index 8a08612..a01d0b2 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -45,6 +45,7 @@ type DeliveryConfig struct { AppRepoRef string `yaml:"appRepoRef"` ProductionBranch string `yaml:"productionBranch"` ImageRepository string `yaml:"imageRepository"` + BuildStrategy string `yaml:"buildStrategy"` BuildOutputDirectory string `yaml:"buildOutputDirectory"` BuildConfiguration string `yaml:"buildConfiguration"` TektonDashboardURL string `yaml:"tektonDashboardUrl,omitempty"`