-
Notifications
You must be signed in to change notification settings - Fork 145
Expand file tree
/
Copy pathmerge.go
More file actions
697 lines (626 loc) · 32.8 KB
/
Copy pathmerge.go
File metadata and controls
697 lines (626 loc) · 32.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
package config
import (
"context"
"fmt"
"io"
"strconv"
"strings"
)
// DebugConfig holds debug configuration for a component.
type DebugConfig struct {
Port int
}
// ChartFlags holds chart-related configuration.
type ChartFlags struct {
ChartPath string
Chart string
ChartVersion string
SkipDependencyUpdate bool
RepoRoot string
// ChartRootOverlays lists chart-root overlay files to apply, in order.
// Each name resolves to <chartPath>/values-<name>.yaml if the file exists.
// Common values: "enterprise" (CVE-patched images), "digest" (SHA256 pins),
// "latest", "local", "bitnami-legacy".
// In the matrix runner, "digest" is always included; "enterprise" is added
// when the ci-test-config entry has enterprise: true.
ChartRootOverlays []string
}
// DeploymentFlags holds deployment-related configuration.
type DeploymentFlags struct {
Namespace string
NamespacePrefix string // Prefix to prepend to namespace (e.g., "distribution" for EKS)
Release string
Scenario string // Single scenario or comma-separated list
Scenarios []string // Parsed list of scenarios (populated by Validate)
ScenarioPath string
Platform string
Flow string
Timeout int // Timeout in minutes for Helm deployment
DeleteNamespaceFirst bool
RenderTemplates bool
RenderOutputDir string
ExtraValues []string
// Extra helm arguments for advanced use cases (e.g., upgrade flows).
// These are appended to the helm command after all other arguments.
ExtraHelmArgs []string
// Extra --set pairs for helm (e.g., {"orchestration.upgrade.allowPreReleaseImages": "true"}).
ExtraHelmSets map[string]string
}
// IngressFlags holds ingress-related configuration.
type IngressFlags struct {
IngressSubdomain string
IngressBaseDomain string
IngressHostname string
}
// ResolveIngressHostname returns the resolved ingress hostname.
// If IngressHostname is set, it takes precedence (full override).
// Otherwise, IngressSubdomain is appended to IngressBaseDomain.
func (f *IngressFlags) ResolveIngressHostname() string {
if f.IngressHostname != "" {
return f.IngressHostname
}
if f.IngressSubdomain != "" && f.IngressBaseDomain != "" {
return f.IngressSubdomain + "." + f.IngressBaseDomain
}
return ""
}
// AuthFlags holds authentication/Keycloak configuration.
type AuthFlags struct {
Auth string
KeycloakHost string
KeycloakProtocol string
KeycloakRealm string
}
// DockerFlags holds Docker registry configuration.
type DockerFlags struct {
DockerUsername string
DockerPassword string
EnsureDockerRegistry bool
DockerHubUsername string
DockerHubPassword string
EnsureDockerHub bool
// SkipDockerLogin skips the `docker login` step inside the deployer.
// The matrix runner sets this to true after performing docker login once
// before parallel dispatch — preventing concurrent keychain writes.
SkipDockerLogin bool
}
// SecretsFlags holds secrets-related configuration.
type SecretsFlags struct {
ExternalSecrets bool
ExternalSecretsStore string
VaultSecretMapping string
AutoGenerateSecrets bool
UseVaultBackedSecrets bool
}
// DebugFlags holds JVM debug configuration.
type DebugFlags struct {
DebugComponents map[string]DebugConfig // Components to enable JVM debugging for, with their ports
DebugPort int // Default JVM debug port (used when no port specified)
DebugSuspend bool // Suspend JVM on startup until debugger attaches
}
// TestFlags holds test execution configuration.
type TestFlags struct {
RunIntegrationTests bool // Run integration tests after deployment
RunE2ETests bool // Run e2e tests after deployment
RunAllTests bool // Run both integration and e2e tests after deployment
TestExclude string // Pipe-separated regex for test suites to exclude (passed as --grep-invert to Playwright)
OutputTestEnv bool // Generate .env file for E2E tests after deployment
OutputTestEnvPath string // Path for the test .env file output
KubeContext string
}
// SelectionFlags holds selection + composition model flags.
type SelectionFlags struct {
Identity string // Identity selection: keycloak, oidc, basic, hybrid
Persistence string // Persistence selection: elasticsearch, opensearch, rdbms, rdbms-oracle
TestPlatform string // Test platform selection: gke, eks, openshift
Features []string // Feature selections: multitenancy, rba, documentstore
QA bool // Enable QA configuration (test users, etc.)
ImageTags bool // Enable image tag overrides from env vars
UpgradeFlow bool // Enable upgrade flow configuration
InfraType string // Infrastructure pool type (e.g., preemptible, distroci, standard, arm)
}
// HasExplicitSelectionConfig returns true if any selection + composition flags were explicitly set.
func (f *SelectionFlags) HasExplicitSelectionConfig() bool {
return f.Identity != "" || f.Persistence != "" || f.TestPlatform != "" || len(f.Features) > 0 || f.QA || f.ImageTags || f.UpgradeFlow
}
// DeprecatedFlags holds deprecated layered values flags (backward compat).
type DeprecatedFlags struct {
ValuesAuth string // DEPRECATED: use Identity instead
ValuesBackend string // DEPRECATED: use Persistence instead
ValuesFeatures []string // DEPRECATED: use Features instead
ValuesQA bool // DEPRECATED: use QA instead
ValuesInfra string // DEPRECATED: use TestPlatform instead
}
// HasExplicitLayeredConfig returns true if any deprecated layered values flags were explicitly set.
// Deprecated: Use SelectionFlags.HasExplicitSelectionConfig instead.
func (f *DeprecatedFlags) HasExplicitLayeredConfig() bool {
return f.ValuesAuth != "" || f.ValuesBackend != "" || len(f.ValuesFeatures) > 0 || f.ValuesQA || f.ValuesInfra != ""
}
// IndexPrefixFlags holds Elasticsearch/OpenSearch index prefix configuration.
type IndexPrefixFlags struct {
OptimizeIndexPrefix string
OrchestrationIndexPrefix string
TasklistIndexPrefix string
OperateIndexPrefix string
}
// RuntimeFlags holds all CLI flag values that can be merged with config.
// Fields are grouped into composed sub-structs by domain concern.
type RuntimeFlags struct {
Chart ChartFlags
Deployment DeploymentFlags
Ingress IngressFlags
Auth AuthFlags
Docker DockerFlags
Secrets SecretsFlags
Debug DebugFlags
Test TestFlags
Selection SelectionFlags
Deprecated DeprecatedFlags
Index IndexPrefixFlags
// Cross-cutting / runtime fields that don't belong to a single domain.
LogLevel string
EnvFile string
Interactive bool
// ChangedFlags tracks which CLI flags were explicitly set by the user.
// When populated, merge functions will not overwrite these flags with
// config-file values. This is essential for boolean flags whose zero
// value (false) is a valid explicit choice (e.g., --skip-dependency-update=false).
ChangedFlags map[string]bool
// PreInstallHooks are functions called by the deployer after namespace and
// registry secrets are set up but before helm upgrade/install. This allows
// callers (e.g., the matrix runner) to create K8s resources that must
// exist at install time but cannot be created earlier because the namespace
// may not yet exist or may be recreated by DeleteNamespaceFirst.
PreInstallHooks []func(ctx context.Context) error
// PostInfraHooks are functions called by the deployer after companion
// charts (the external infrastructure: PostgreSQL, Elasticsearch, Keycloak,
// …) are deployed and ready, but before the main Camunda chart is
// installed/upgraded. Used to act on freshly-provisioned infrastructure —
// e.g. migrating data from a prior release's bundled backends onto the
// companion services before the chart switches over to them.
PostInfraHooks []func(ctx context.Context) error
// PostDeployHooks are functions called by the deployer after helm
// upgrade/install completes successfully but before the deployment result
// is returned. Used to apply scenario-specific resources whose CRDs are
// only installed by the chart itself (e.g., the Gateway API
// ProxySettingsPolicy for gateway-keycloak).
PostDeployHooks []func(ctx context.Context) error
// ExtraEnv holds per-entry environment variables that are merged into the
// isolated env map by buildScenarioEnv before values.Process() runs.
// This avoids process-global os.Setenv races when multiple OIDC entries
// run concurrently — each entry carries its own VENOM_CLIENT_ID and
// CONNECTORS_CLIENT_ID in an isolated map instead of relying on os.Setenv.
ExtraEnv map[string]string
// CompanionCharts are Helm charts to deploy as separate releases in the
// same namespace before the main Camunda chart. Each entry specifies the
// chart path, release name, and optional values file.
CompanionCharts []CompanionChart
// OnPhase is called when the deployment transitions to a new phase
// (e.g., "deploying", "testing"). Used by the matrix status display to
// show fine-grained progress. Nil disables the callback.
OnPhase func(phase string)
// ITOutputWriter, when non-nil, replaces os.Stdout/os.Stderr for
// integration test script output. Used by the matrix runner to redirect
// IT output to a per-entry log file instead of polluting the terminal.
ITOutputWriter io.Writer
// E2EOutputWriter, when non-nil, replaces os.Stdout/os.Stderr for
// e2e test script output. Used by the matrix runner to redirect
// e2e output to a per-entry log file instead of polluting the terminal.
E2EOutputWriter io.Writer
}
// CompanionChart represents a Helm chart to deploy as a separate release
// before the main Camunda chart. Used to deploy infrastructure dependencies
// (e.g., OpenSearch) in the same namespace.
type CompanionChart struct {
// ChartRef is the Helm chart reference — either a repo/chart name
// (e.g., "opensearch/opensearch") or an absolute local path.
ChartRef string
// Version is the chart version to install (e.g., "3.6.0").
// Empty means use the latest version (for remote) or ignore (for local).
Version string
// ReleaseName is the Helm release name for this companion chart.
ReleaseName string
// ValuesFile is the absolute path to a values file. Empty means use chart defaults.
ValuesFile string
// EnvVars is the explicit allowlist of environment variable names to
// substitute in ValuesFile before deploying. Only these names are expanded;
// all other $-tokens are left intact. Empty means no substitution.
EnvVars []string
// RepoName is the Helm repository name to register before installing
// (e.g., "opensearch"). Empty means no repo registration is needed.
RepoName string
// RepoURL is the Helm repository URL
// (e.g., "https://opensearch-project.github.io/helm-charts/").
RepoURL string
}
// ParseDebugFlag parses a debug flag value in the format "component" or "component:port".
// Returns the component name and port (using defaultPort if not specified).
func ParseDebugFlag(value string, defaultPort int) (string, int, error) {
parts := strings.SplitN(value, ":", 2)
component := strings.ToLower(strings.TrimSpace(parts[0]))
if component == "" {
return "", 0, fmt.Errorf("empty component name")
}
port := defaultPort
if len(parts) == 2 {
var err error
port, err = strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return "", 0, fmt.Errorf("invalid port %q: %w", parts[1], err)
}
if port < 1 || port > 65535 {
return "", 0, fmt.Errorf("port %d out of range (1-65535)", port)
}
}
return component, port, nil
}
// ApplyActiveDeployment merges active deployment and root config into runtime flags.
func ApplyActiveDeployment(rc *RootConfig, active string, flags *RuntimeFlags) error {
if rc == nil || rc.Deployments == nil {
return applyRootDefaults(rc, flags)
}
// Auto-select if exactly one deployment exists
if strings.TrimSpace(active) == "" && len(rc.Deployments) == 1 {
for name := range rc.Deployments {
active = name
}
}
if strings.TrimSpace(active) == "" {
return applyRootDefaults(rc, flags)
}
dep, ok := rc.Deployments[active]
if !ok {
return fmt.Errorf("active deployment %q not found in config", active)
}
// Apply deployment-specific values — string fields are skipped if the
// user explicitly set the corresponding flag on the CLI.
changed := flags.ChangedFlags
MergeStringField(&flags.Chart.ChartPath, dep.ChartPath, rc.ChartPath, changed, "chart-path")
MergeStringField(&flags.Chart.Chart, dep.Chart, rc.Chart, changed, "chart")
MergeStringField(&flags.Chart.ChartVersion, dep.Version, rc.Version, changed, "version")
MergeStringField(&flags.Deployment.Namespace, dep.Namespace, rc.Namespace, changed, "namespace")
MergeStringField(&flags.Deployment.NamespacePrefix, dep.NamespacePrefix, rc.NamespacePrefix, changed, "namespace-prefix")
MergeStringField(&flags.Deployment.Release, dep.Release, rc.Release, changed, "release")
MergeStringField(&flags.Deployment.Scenario, dep.Scenario, rc.Scenario, changed, "scenario")
MergeStringField(&flags.Auth.Auth, dep.Auth, rc.Auth, changed, "auth")
MergeStringField(&flags.Deployment.Platform, dep.Platform, rc.Platform, changed, "platform")
MergeStringField(&flags.LogLevel, dep.LogLevel, rc.LogLevel, changed, "log-level")
MergeStringField(&flags.Deployment.Flow, dep.Flow, rc.Flow, changed, "flow")
MergeStringField(&flags.EnvFile, dep.EnvFile, rc.EnvFile, changed, "env-file")
MergeStringField(&flags.Secrets.VaultSecretMapping, dep.VaultSecretMapping, rc.VaultSecretMapping, changed, "vault-secret-mapping")
MergeStringField(&flags.Docker.DockerUsername, dep.DockerUsername, rc.DockerUsername, changed, "docker-username")
MergeStringField(&flags.Docker.DockerPassword, dep.DockerPassword, rc.DockerPassword, changed, "docker-password")
MergeStringField(&flags.Docker.DockerHubUsername, dep.DockerHubUsername, rc.DockerHubUsername, changed, "dockerhub-username")
MergeStringField(&flags.Docker.DockerHubPassword, dep.DockerHubPassword, rc.DockerHubPassword, changed, "dockerhub-password")
MergeStringField(&flags.Deployment.RenderOutputDir, dep.RenderOutputDir, rc.RenderOutputDir, changed, "render-output-dir")
MergeStringField(&flags.Chart.RepoRoot, dep.RepoRoot, rc.RepoRoot, changed, "repo-root")
// ChartRootOverlays: merge from config's ValuesPreset (comma-separated string → []string).
// CLI --values-preset sets ChartRootOverlays directly; config files provide ValuesPreset as a string.
if !(changed != nil && changed["values-preset"]) && len(flags.Chart.ChartRootOverlays) == 0 {
if presetStr := FirstNonEmpty(dep.ValuesPreset, rc.ValuesPreset); presetStr != "" {
for _, p := range strings.Split(presetStr, ",") {
if t := strings.TrimSpace(p); t != "" {
flags.Chart.ChartRootOverlays = append(flags.Chart.ChartRootOverlays, t)
}
}
}
}
MergeStringField(&flags.Auth.KeycloakRealm, dep.KeycloakRealm, rc.KeycloakRealm, changed, "keycloak-realm")
MergeStringField(&flags.Index.OptimizeIndexPrefix, dep.OptimizeIndexPrefix, rc.OptimizeIndexPrefix, changed, "optimize-index-prefix")
MergeStringField(&flags.Index.OrchestrationIndexPrefix, dep.OrchestrationIndexPrefix, rc.OrchestrationIndexPrefix, changed, "orchestration-index-prefix")
MergeStringField(&flags.Index.TasklistIndexPrefix, dep.TasklistIndexPrefix, rc.TasklistIndexPrefix, changed, "tasklist-index-prefix")
MergeStringField(&flags.Index.OperateIndexPrefix, dep.OperateIndexPrefix, rc.OperateIndexPrefix, changed, "operate-index-prefix")
MergeStringField(&flags.Test.KubeContext, dep.KubeContext, rc.KubeContext, changed, "kube-context")
MergeStringField(&flags.Ingress.IngressHostname, dep.IngressHost, rc.IngressHost, changed, "ingress-hostname")
MergeStringField(&flags.Ingress.IngressSubdomain, dep.IngressSubdomain, rc.IngressSubdomain, changed, "ingress-subdomain")
MergeStringField(&flags.Ingress.IngressBaseDomain, dep.IngressBaseDomain, rc.IngressBaseDomain, changed, "ingress-base-domain")
MergeStringField(&flags.Secrets.ExternalSecretsStore, "", "", changed, "external-secrets-store") // No config file support yet
// ScenarioPath special handling — has multiple config sources
if !(changed != nil && changed["scenario-path"]) && strings.TrimSpace(flags.Deployment.ScenarioPath) == "" {
flags.Deployment.ScenarioPath = FirstNonEmpty(dep.ScenarioPath, dep.ScenarioRoot, rc.ScenarioPath, rc.ScenarioRoot)
}
// Boolean fields - skip if the user explicitly set the flag on the CLI
MergeBoolField(&flags.Secrets.ExternalSecrets, dep.ExternalSecrets, rc.ExternalSecrets, changed, "external-secrets")
MergeBoolField(&flags.Chart.SkipDependencyUpdate, dep.SkipDependencyUpdate, rc.SkipDependencyUpdate, changed, "skip-dependency-update")
MergeBoolField(&flags.Interactive, dep.Interactive, rc.Interactive, changed, "interactive")
MergeBoolField(&flags.Secrets.AutoGenerateSecrets, dep.AutoGenerateSecrets, rc.AutoGenerateSecrets, changed, "auto-generate-secrets")
MergeBoolField(&flags.Deployment.DeleteNamespaceFirst, dep.DeleteNamespace, rc.DeleteNamespace, changed, "delete-namespace")
MergeBoolField(&flags.Docker.EnsureDockerRegistry, dep.EnsureDockerRegistry, rc.EnsureDockerRegistry, changed, "ensure-docker-registry")
MergeBoolField(&flags.Docker.EnsureDockerHub, dep.EnsureDockerHub, rc.EnsureDockerHub, changed, "ensure-docker-hub")
MergeBoolField(&flags.Deployment.RenderTemplates, dep.RenderTemplates, rc.RenderTemplates, changed, "render-templates")
// Test execution flags
MergeBoolField(&flags.Test.RunIntegrationTests, dep.RunIntegrationTests, rc.RunIntegrationTests, changed, "test-it")
MergeBoolField(&flags.Test.RunE2ETests, dep.RunE2ETests, rc.RunE2ETests, changed, "test-e2e")
// Selection + composition model fields
MergeStringField(&flags.Selection.Identity, dep.Identity, rc.Identity, changed, "identity")
MergeStringField(&flags.Selection.Persistence, dep.Persistence, rc.Persistence, changed, "persistence")
MergeStringField(&flags.Selection.TestPlatform, dep.TestPlatform, rc.TestPlatform, changed, "test-platform")
MergeBoolField(&flags.Selection.QA, dep.QA, rc.QA, changed, "qa")
MergeBoolField(&flags.Selection.ImageTags, dep.ImageTags, rc.ImageTags, changed, "image-tags")
MergeBoolField(&flags.Selection.UpgradeFlow, dep.UpgradeFlow, rc.UpgradeFlow, changed, "upgrade-flow")
MergeStringSliceField(&flags.Selection.Features, dep.Features, rc.Features)
// Slice fields
MergeStringSliceField(&flags.Deployment.ExtraValues, dep.ExtraValues, rc.ExtraValues)
// Keycloak
MergeStringField(&flags.Auth.KeycloakHost, "", rc.Keycloak.Host, changed, "keycloak-host")
MergeStringField(&flags.Auth.KeycloakProtocol, "", rc.Keycloak.Protocol, changed, "keycloak-protocol")
return nil
}
// applyRootDefaults applies only root-level defaults when no deployment is active.
func applyRootDefaults(rc *RootConfig, flags *RuntimeFlags) error {
if rc == nil {
return nil
}
changed := flags.ChangedFlags
MergeStringField(&flags.Chart.ChartPath, "", rc.ChartPath, changed, "chart-path")
MergeStringField(&flags.Chart.Chart, "", rc.Chart, changed, "chart")
MergeStringField(&flags.Chart.ChartVersion, "", rc.Version, changed, "version")
MergeStringField(&flags.Deployment.Namespace, "", rc.Namespace, changed, "namespace")
MergeStringField(&flags.Deployment.NamespacePrefix, "", rc.NamespacePrefix, changed, "namespace-prefix")
MergeStringField(&flags.Deployment.Release, "", rc.Release, changed, "release")
MergeStringField(&flags.Deployment.Scenario, "", rc.Scenario, changed, "scenario")
MergeStringField(&flags.Deployment.ScenarioPath, "", FirstNonEmpty(rc.ScenarioPath, rc.ScenarioRoot), changed, "scenario-path")
MergeStringField(&flags.Auth.Auth, "", rc.Auth, changed, "auth")
MergeStringField(&flags.Deployment.Platform, "", rc.Platform, changed, "platform")
MergeStringField(&flags.LogLevel, "", rc.LogLevel, changed, "log-level")
MergeStringField(&flags.Deployment.Flow, "", rc.Flow, changed, "flow")
MergeStringField(&flags.EnvFile, "", rc.EnvFile, changed, "env-file")
MergeStringField(&flags.Secrets.VaultSecretMapping, "", rc.VaultSecretMapping, changed, "vault-secret-mapping")
MergeStringField(&flags.Docker.DockerUsername, "", rc.DockerUsername, changed, "docker-username")
MergeStringField(&flags.Docker.DockerPassword, "", rc.DockerPassword, changed, "docker-password")
MergeStringField(&flags.Docker.DockerHubUsername, "", rc.DockerHubUsername, changed, "dockerhub-username")
MergeStringField(&flags.Docker.DockerHubPassword, "", rc.DockerHubPassword, changed, "dockerhub-password")
MergeStringField(&flags.Deployment.RenderOutputDir, "", rc.RenderOutputDir, changed, "render-output-dir")
MergeStringField(&flags.Chart.RepoRoot, "", rc.RepoRoot, changed, "repo-root")
// ChartRootOverlays: merge from root config's ValuesPreset (comma-separated string → []string).
if !(changed != nil && changed["values-preset"]) && len(flags.Chart.ChartRootOverlays) == 0 {
if presetStr := rc.ValuesPreset; presetStr != "" {
for _, p := range strings.Split(presetStr, ",") {
if t := strings.TrimSpace(p); t != "" {
flags.Chart.ChartRootOverlays = append(flags.Chart.ChartRootOverlays, t)
}
}
}
}
MergeStringField(&flags.Auth.KeycloakRealm, "", rc.KeycloakRealm, changed, "keycloak-realm")
MergeStringField(&flags.Index.OptimizeIndexPrefix, "", rc.OptimizeIndexPrefix, changed, "optimize-index-prefix")
MergeStringField(&flags.Index.OrchestrationIndexPrefix, "", rc.OrchestrationIndexPrefix, changed, "orchestration-index-prefix")
MergeStringField(&flags.Index.TasklistIndexPrefix, "", rc.TasklistIndexPrefix, changed, "tasklist-index-prefix")
MergeStringField(&flags.Index.OperateIndexPrefix, "", rc.OperateIndexPrefix, changed, "operate-index-prefix")
MergeStringField(&flags.Test.KubeContext, "", rc.KubeContext, changed, "kube-context")
MergeStringField(&flags.Ingress.IngressHostname, "", rc.IngressHost, changed, "ingress-hostname")
MergeStringField(&flags.Ingress.IngressSubdomain, "", rc.IngressSubdomain, changed, "ingress-subdomain")
MergeStringField(&flags.Ingress.IngressBaseDomain, "", rc.IngressBaseDomain, changed, "ingress-base-domain")
MergeStringField(&flags.Secrets.ExternalSecretsStore, "", "", changed, "external-secrets-store") // No config file support yet
MergeBoolField(&flags.Secrets.ExternalSecrets, nil, rc.ExternalSecrets, changed, "external-secrets")
MergeBoolField(&flags.Chart.SkipDependencyUpdate, nil, rc.SkipDependencyUpdate, changed, "skip-dependency-update")
MergeBoolField(&flags.Interactive, nil, rc.Interactive, changed, "interactive")
MergeBoolField(&flags.Secrets.AutoGenerateSecrets, nil, rc.AutoGenerateSecrets, changed, "auto-generate-secrets")
MergeBoolField(&flags.Deployment.DeleteNamespaceFirst, nil, rc.DeleteNamespace, changed, "delete-namespace")
MergeBoolField(&flags.Docker.EnsureDockerRegistry, nil, rc.EnsureDockerRegistry, changed, "ensure-docker-registry")
MergeBoolField(&flags.Docker.EnsureDockerHub, nil, rc.EnsureDockerHub, changed, "ensure-docker-hub")
MergeBoolField(&flags.Deployment.RenderTemplates, nil, rc.RenderTemplates, changed, "render-templates")
// Test execution flags
MergeBoolField(&flags.Test.RunIntegrationTests, nil, rc.RunIntegrationTests, changed, "test-it")
MergeBoolField(&flags.Test.RunE2ETests, nil, rc.RunE2ETests, changed, "test-e2e")
// Selection + composition model fields
MergeStringField(&flags.Selection.Identity, "", rc.Identity, changed, "identity")
MergeStringField(&flags.Selection.Persistence, "", rc.Persistence, changed, "persistence")
MergeStringField(&flags.Selection.TestPlatform, "", rc.TestPlatform, changed, "test-platform")
MergeBoolField(&flags.Selection.QA, nil, rc.QA, changed, "qa")
MergeBoolField(&flags.Selection.ImageTags, nil, rc.ImageTags, changed, "image-tags")
MergeBoolField(&flags.Selection.UpgradeFlow, nil, rc.UpgradeFlow, changed, "upgrade-flow")
MergeStringSliceField(&flags.Selection.Features, nil, rc.Features)
MergeStringSliceField(&flags.Deployment.ExtraValues, nil, rc.ExtraValues)
MergeStringField(&flags.Auth.KeycloakHost, "", rc.Keycloak.Host, changed, "keycloak-host")
MergeStringField(&flags.Auth.KeycloakProtocol, "", rc.Keycloak.Protocol, changed, "keycloak-protocol")
return nil
}
// Validate performs validation on the merged runtime flags.
// cfgRes is optional — when provided, validation errors include context about
// which config files were searched and whether one was loaded.
func Validate(flags *RuntimeFlags, cfgRes ...*ConfigResolution) error {
var res *ConfigResolution
if len(cfgRes) > 0 {
res = cfgRes[0]
}
// Ensure at least one of chart-path or chart is provided
if flags.Chart.ChartPath == "" && flags.Chart.Chart == "" {
return missingFieldError("chart not configured", "chartPath", "--chart-path or --chart", res)
}
// Validate --version compatibility
if strings.TrimSpace(flags.Chart.ChartVersion) != "" && strings.TrimSpace(flags.Chart.Chart) == "" && strings.TrimSpace(flags.Chart.ChartPath) != "" {
return fmt.Errorf("--version requires --chart to be set; do not combine --version with only --chart-path")
}
if strings.TrimSpace(flags.Chart.ChartVersion) != "" && strings.TrimSpace(flags.Chart.Chart) == "" && strings.TrimSpace(flags.Chart.ChartPath) == "" {
return fmt.Errorf("--version requires --chart to be set")
}
// Validate required runtime identifiers
if strings.TrimSpace(flags.Deployment.Namespace) == "" {
return missingFieldError("namespace not set", "namespace", "-n/--namespace", res)
}
if strings.TrimSpace(flags.Deployment.Release) == "" {
return missingFieldError("release not set", "release", "-r/--release", res)
}
// Migrate deprecated flags before checking selection config
flags.MigrateDeprecatedFlags()
if strings.TrimSpace(flags.Deployment.Scenario) == "" {
// Scenario is optional when selection flags fully describe the deployment
if flags.HasExplicitSelectionConfig() {
// Synthesize a scenario name from the selection flags so downstream
// code (ScenarioContext, temp dirs, realm names, etc.) has an identifier.
flags.Deployment.Scenario = synthesizeScenarioName(flags)
} else {
return missingFieldError(
"scenario not set; provide --scenario or use selection flags (--identity, --persistence, etc.)",
"scenario", "-s/--scenario or --identity/--persistence", res)
}
}
// Parse scenarios from comma-separated string
flags.Deployment.Scenarios = parseScenarios(flags.Deployment.Scenario)
if len(flags.Deployment.Scenarios) == 0 {
return fmt.Errorf("no valid scenarios found in %q", flags.Deployment.Scenario)
}
// Validate ingress configuration
// --ingress-hostname is mutually exclusive with --ingress-subdomain and --ingress-base-domain
if flags.Ingress.IngressHostname != "" && (flags.Ingress.IngressSubdomain != "" || flags.Ingress.IngressBaseDomain != "") {
return fmt.Errorf("--ingress-hostname cannot be used with --ingress-subdomain or --ingress-base-domain; use either --ingress-hostname OR --ingress-subdomain with --ingress-base-domain")
}
if flags.Ingress.IngressSubdomain != "" && flags.Ingress.IngressBaseDomain == "" {
return fmt.Errorf("--ingress-base-domain is required when using --ingress-subdomain; valid values: %s", strings.Join(ValidIngressBaseDomains, ", "))
}
if flags.Ingress.IngressBaseDomain != "" {
if !IsValidIngressBaseDomain(flags.Ingress.IngressBaseDomain) {
return fmt.Errorf("--ingress-base-domain must be one of: %s", strings.Join(ValidIngressBaseDomains, ", "))
}
}
return nil
}
// parseScenarios splits a comma-separated scenario string into a slice.
func parseScenarios(scenario string) []string {
var scenarios []string
for _, s := range strings.Split(scenario, ",") {
s = strings.TrimSpace(s)
if s != "" {
scenarios = append(scenarios, s)
}
}
return scenarios
}
// IsValidIngressBaseDomain checks if the given domain is in the allowed list.
func IsValidIngressBaseDomain(domain string) bool {
for _, valid := range ValidIngressBaseDomains {
if domain == valid {
return true
}
}
return false
}
// HasExplicitSelectionConfig returns true if any selection + composition flags were explicitly set.
// Delegates to SelectionFlags.
func (f *RuntimeFlags) HasExplicitSelectionConfig() bool {
return f.Selection.HasExplicitSelectionConfig()
}
// HasExplicitLayeredConfig returns true if any deprecated layered values flags were explicitly set.
// Deprecated: Use HasExplicitSelectionConfig instead.
func (f *RuntimeFlags) HasExplicitLayeredConfig() bool {
return f.Deprecated.HasExplicitLayeredConfig()
}
// MigrateDeprecatedFlags copies deprecated layered values flags to the new selection fields.
// This is called during validation to ensure backward compatibility.
func (f *RuntimeFlags) MigrateDeprecatedFlags() {
// Only migrate if new fields are not already set
if f.Selection.Identity == "" && f.Deprecated.ValuesAuth != "" {
f.Selection.Identity = f.Deprecated.ValuesAuth
}
if f.Selection.Persistence == "" && f.Deprecated.ValuesBackend != "" {
f.Selection.Persistence = f.Deprecated.ValuesBackend
}
if f.Selection.TestPlatform == "" && f.Deprecated.ValuesInfra != "" {
f.Selection.TestPlatform = f.Deprecated.ValuesInfra
}
if len(f.Selection.Features) == 0 && len(f.Deprecated.ValuesFeatures) > 0 {
// Filter out features that are now in other categories
for _, feature := range f.Deprecated.ValuesFeatures {
switch feature {
case "rdbms", "rdbms-external", "rdbms-oracle":
// These moved to persistence - only set if persistence not already set
if f.Selection.Persistence == "" {
f.Selection.Persistence = feature
}
case "upgrade":
// This is now a separate flag
f.Selection.UpgradeFlow = true
default:
f.Selection.Features = append(f.Selection.Features, feature)
}
}
}
if !f.Selection.QA && f.Deprecated.ValuesQA {
f.Selection.QA = true
}
}
// ResolveIngressHostname returns the resolved ingress hostname.
// Delegates to IngressFlags.
func (f *RuntimeFlags) ResolveIngressHostname() string {
return f.Ingress.ResolveIngressHostname()
}
// EffectiveNamespace returns the namespace with the prefix applied if set.
// If NamespacePrefix is set, returns "prefix-namespace", otherwise just "namespace".
func (f *RuntimeFlags) EffectiveNamespace() string {
if f.Deployment.NamespacePrefix != "" && f.Deployment.Namespace != "" {
return f.Deployment.NamespacePrefix + "-" + f.Deployment.Namespace
}
return f.Deployment.Namespace
}
// missingFieldError builds an actionable error message for a missing required
// field. When a ConfigResolution is available, the message explains whether a
// config file was found and, if not, which paths were searched.
func missingFieldError(what, configKey, flagHint string, res *ConfigResolution) error {
var b strings.Builder
fmt.Fprintf(&b, "%s", what)
if res != nil && !res.Found {
b.WriteString("\n\n No config file found. Searched:\n")
for _, p := range res.Searched {
fmt.Fprintf(&b, " - %s\n", p)
}
b.WriteString("\n To fix, either:\n")
fmt.Fprintf(&b, " - Create .camunda-deploy.yaml in the repo root (run: deploy-camunda config create <name>)\n")
fmt.Fprintf(&b, " - Provide %s on the command line", flagHint)
} else if res != nil {
fmt.Fprintf(&b, "\n\n Config loaded from: %s\n", res.Path)
fmt.Fprintf(&b, "\n To fix, either:\n")
fmt.Fprintf(&b, " - Set '%s' in your config file or active deployment\n", configKey)
fmt.Fprintf(&b, " - Provide %s on the command line", flagHint)
} else {
fmt.Fprintf(&b, "; provide %s or set '%s' in the active deployment/root config", flagHint, configKey)
}
return fmt.Errorf("%s", b.String())
}
// synthesizeScenarioName builds a human-readable scenario identifier from the
// selection flags. This is used when --scenario is omitted but selection flags
// fully describe the deployment (e.g., --identity keycloak --persistence elasticsearch
// produces "keycloak-elasticsearch").
func synthesizeScenarioName(flags *RuntimeFlags) string {
var parts []string
if flags.Selection.Identity != "" {
parts = append(parts, flags.Selection.Identity)
}
if flags.Selection.Persistence != "" {
parts = append(parts, flags.Selection.Persistence)
}
if flags.Selection.TestPlatform != "" {
parts = append(parts, flags.Selection.TestPlatform)
}
for _, f := range flags.Selection.Features {
parts = append(parts, f)
}
if flags.Selection.QA {
parts = append(parts, "qa")
}
if flags.Selection.UpgradeFlow {
parts = append(parts, "upgrade")
}
if len(parts) == 0 {
return "custom"
}
return strings.Join(parts, "-")
}
// LoadAndMerge loads config from the given path and merges the active deployment into flags.
// If configPath is empty, it resolves the default config location.
// The includeEnv parameter controls whether environment variable overrides are applied.
// The returned ConfigResolution describes where the config was found (or not).
func LoadAndMerge(configPath string, includeEnv bool, flags *RuntimeFlags) (*RootConfig, *ConfigResolution, error) {
res, err := ResolvePath(configPath)
if err != nil {
return nil, nil, err
}
rc, err := Read(res.Path, includeEnv)
if err != nil {
return nil, res, err
}
if err := ApplyActiveDeployment(rc, rc.Current, flags); err != nil {
return nil, res, err
}
return rc, res, nil
}