diff --git a/cmd/environment/configuration_profile.go b/cmd/environment/configuration_profile.go new file mode 100644 index 00000000..c1dd849e --- /dev/null +++ b/cmd/environment/configuration_profile.go @@ -0,0 +1,139 @@ +package environment + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/core/configurations" + "github.com/codefly-dev/core/shared" +) + +const profileMarker = "# GENERATED — do not edit.\n# codefly environment import: " + +// materializeConfigurationProfile uses Core's profile derivation: shared inputs +// stay authored once, while each environment owns its generated runnable bindings. +// Validate the complete inherited source before writing its generated declaration. +func materializeConfigurationProfile(ctx context.Context, directory string, env *environments.Environment, dryRun bool) error { + if env.ConfigurationSource == "" { + return nil + } + target, err := env.ConfigurationProfileName() + if err != nil { + return err + } + label := regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + if !label.MatchString(env.ConfigurationSource) || !label.MatchString(target) || target == env.ConfigurationSource { + return fmt.Errorf("configuration-source and destination must be distinct profile names") + } + root, err := filepath.EvalSymlinks(directory) + if err != nil { + return err + } + configurationRoot := filepath.Join(root, "configurations") + resolved, err := filepath.EvalSymlinks(configurationRoot) + if err != nil || resolved != configurationRoot { + return fmt.Errorf("configuration profiles require an ordinary configurations directory") + } + destination := filepath.Join(configurationRoot, target) + layers, exists, err := configurations.ProfileLayers(ctx, root, "configurations", []string{env.ConfigurationSource}) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("configuration-source profile does not exist") + } + for _, layer := range layers { + if layer == destination { + return fmt.Errorf("configuration-source must not derive from its destination") + } + resolved, err := filepath.EvalSymlinks(layer) + if err != nil || resolved != layer { + return fmt.Errorf("configuration source must be an ordinary profile directory") + } + entries, err := os.ReadDir(layer) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".env") && entry.Name() != configurations.ProfileDerivationFile { + return fmt.Errorf("configuration-source supports only flat .env inputs and profile derivation metadata: %s", entry.Name()) + } + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("configuration source %s must be a regular file", entry.Name()) + } + if entry.Name() == "runnable-bindings.env" { + return fmt.Errorf("configuration-source must not contain environment-specific runnable bindings") + } + if strings.HasSuffix(entry.Name(), ".secret.env") { + body, err := os.ReadFile(filepath.Join(layer, entry.Name())) + if err != nil { + return err + } + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + _, value, found := strings.Cut(line, "=") + if !found || strings.TrimSpace(value) != "" { + return fmt.Errorf("configuration-source secret file %s must contain empty placeholders only", entry.Name()) + } + } + } + } + } + if info, err := os.Lstat(destination); err == nil && !info.IsDir() { + return fmt.Errorf("generated profile must be an ordinary directory") + } else if err != nil && !os.IsNotExist(err) { + return err + } + output := filepath.Join(destination, configurations.ProfileDerivationFile) + var retired []string + previous, err := os.ReadDir(destination) + if err != nil && !os.IsNotExist(err) { + return err + } + for _, entry := range previous { + if !strings.HasSuffix(entry.Name(), ".env") && entry.Name() != configurations.ProfileDerivationFile { + continue + } + path := filepath.Join(destination, entry.Name()) + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("generated configuration %s must be a regular file", entry.Name()) + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + owned := strings.HasPrefix(string(body), profileMarker) + if !owned && entry.Name() != "runnable-bindings.env" { + return fmt.Errorf("refusing to overwrite authored configuration %s", entry.Name()) + } + if path != output && owned { + retired = append(retired, path) + } + } + if dryRun { + return nil + } + if err := os.MkdirAll(destination, 0o755); err != nil { + return err + } + body := profileMarker + env.ConfigurationSource + "\nderives-from: " + env.ConfigurationSource + "\n" + if err := shared.WriteFileAtomic(ctx, output, []byte(body), 0o644); err != nil { + return err + } + for _, path := range retired { + if err := os.Remove(path); err != nil { + return err + } + } + return nil +} diff --git a/cmd/environment/configuration_profile_test.go b/cmd/environment/configuration_profile_test.go new file mode 100644 index 00000000..132b62cd --- /dev/null +++ b/cmd/environment/configuration_profile_test.go @@ -0,0 +1,136 @@ +package environment + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/core/configurations" + "github.com/stretchr/testify/require" +) + +func profileSource(t *testing.T) string { + t.Helper() + root := t.TempDir() + directory := filepath.Join(root, "configurations", "hosted") + require.NoError(t, os.MkdirAll(directory, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(directory, "platform.env"), []byte("endpoint=${endpoint:saas/accounts/authority}\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(directory, "internal-auth.secret.env"), []byte("TOKEN=\n"), 0o644)) + return root +} + +func profileEnvironment(name string) *environments.Environment { + return &environments.Environment{Name: name, ConfigurationSource: "hosted", ConfigurationProfiles: []string{name, "local"}} +} + +func TestImportedProfilesSeparateSharedInputsAndEnvironmentOutputs(t *testing.T) { + root := profileSource(t) + for _, name := range []string{"blue", "green"} { + require.NoError(t, materializeConfigurationProfile(t.Context(), root, profileEnvironment(name), false)) + body, err := os.ReadFile(filepath.Join(root, "configurations", name, configurations.ProfileDerivationFile)) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(body), profileMarker)) + require.Contains(t, string(body), "derives-from: hosted") + provided, err := configurations.LoadProfileConfigurations(t.Context(), root, "configurations", []string{name}) + require.NoError(t, err) + values := map[string]string{} + for _, info := range provided.Infos { + for _, value := range info.ConfigurationValues { + values[info.Name+"/"+value.Key] = value.Value + } + } + require.Equal(t, "${endpoint:saas/accounts/authority}", values["platform/endpoint"]) + } + blue := filepath.Join(root, "configurations", "blue") + green := filepath.Join(root, "configurations", "green") + require.NoError(t, os.WriteFile(filepath.Join(blue, "runnable-bindings.env"), []byte("# generated by runnable-bindings\nBLUE=resolved-blue\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(blue, "platform.env"), []byte(profileMarker+"hosted/platform.env\nendpoint=legacy-copy\n"), 0o644)) + require.NoError(t, os.Remove(filepath.Join(root, "configurations", "hosted", "platform.env"))) + require.NoError(t, materializeConfigurationProfile(t.Context(), root, profileEnvironment("blue"), false)) + _, err := os.Stat(filepath.Join(blue, "platform.env")) + require.True(t, os.IsNotExist(err), "removed source groups retire only import-owned outputs") + _, err = os.Stat(filepath.Join(green, configurations.ProfileDerivationFile)) + require.NoError(t, err, "importing blue never rewrites green") + body, err := os.ReadFile(filepath.Join(blue, "runnable-bindings.env")) + require.NoError(t, err) + require.Contains(t, string(body), "BLUE=resolved-blue", "the runnable generator owns this file") +} + +func TestImportedProfileUsesCoreStageDerivationAndValidatesInheritedSecrets(t *testing.T) { + root := profileSource(t) + source := filepath.Join(root, "configurations", "hosted-development") + require.NoError(t, os.Mkdir(source, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(source, configurations.ProfileDerivationFile), []byte("derives-from: hosted\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(source, "legal.env"), []byte("ENTITY=Development fixture\n"), 0o644)) + env := profileEnvironment("blue") + env.ConfigurationSource = "hosted-development" + require.NoError(t, materializeConfigurationProfile(t.Context(), root, env, false)) + provided, err := configurations.LoadProfileConfigurations(t.Context(), root, "configurations", []string{"blue", "local"}) + require.NoError(t, err) + groups := map[string]bool{} + for _, info := range provided.Infos { + groups[info.Name] = true + } + require.True(t, groups["platform"]) + require.True(t, groups["legal"]) + require.NoError(t, materializeConfigurationProfile(t.Context(), root, profileEnvironment("production"), false)) + production, err := configurations.LoadProfileConfigurations(t.Context(), root, "configurations", []string{"production", "local"}) + require.NoError(t, err) + for _, info := range production.Infos { + require.NotEqual(t, "legal", info.Name) + } + require.NoError(t, os.WriteFile(filepath.Join(root, "configurations", "hosted", "internal-auth.secret.env"), []byte("TOKEN=fixture-value\n"), 0o600)) + env = profileEnvironment("green") + env.ConfigurationSource = "hosted-development" + err = materializeConfigurationProfile(t.Context(), root, env, false) + require.ErrorContains(t, err, "empty placeholders only") + require.NotContains(t, err.Error(), "fixture-value") + _, err = os.Stat(filepath.Join(root, "configurations", "green")) + require.True(t, os.IsNotExist(err)) +} + +func TestProfileImportDryRunCreatesNothing(t *testing.T) { + root := profileSource(t) + require.NoError(t, materializeConfigurationProfile(t.Context(), root, profileEnvironment("blue"), true)) + _, err := os.Stat(filepath.Join(root, "configurations", "blue")) + require.True(t, os.IsNotExist(err)) +} + +func TestProfileImportRefusesCredentialValuesBeforeWriting(t *testing.T) { + root := profileSource(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "configurations", "hosted", "internal-auth.secret.env"), []byte("TOKEN=fixture-value\n"), 0o600)) + err := materializeConfigurationProfile(t.Context(), root, profileEnvironment("blue"), false) + require.ErrorContains(t, err, "empty placeholders only") + require.NotContains(t, err.Error(), "fixture-value") + _, err = os.Stat(filepath.Join(root, "configurations", "blue")) + require.True(t, os.IsNotExist(err)) +} + +func TestProfileImportRefusesUnvalidatedInheritedInputFormats(t *testing.T) { + root := profileSource(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "configurations", "hosted", "credentials.secret.yaml"), []byte("token: fixture-value\n"), 0o600)) + err := materializeConfigurationProfile(t.Context(), root, profileEnvironment("blue"), false) + require.ErrorContains(t, err, "only flat .env inputs") + require.NotContains(t, err.Error(), "fixture-value") + _, err = os.Stat(filepath.Join(root, "configurations", "blue")) + require.True(t, os.IsNotExist(err)) +} + +func TestProfileImportRefusesAuthoredOrSymlinkedFiles(t *testing.T) { + for _, symlink := range []bool{false, true} { + t.Run(map[bool]string{false: "authored", true: "symlink"}[symlink], func(t *testing.T) { + root := profileSource(t) + directory := filepath.Join(root, "configurations", "blue") + require.NoError(t, os.Mkdir(directory, 0o755)) + path := filepath.Join(directory, "platform.env") + if symlink { + require.NoError(t, os.Symlink(filepath.Join(root, "configurations", "hosted", "platform.env"), path)) + } else { + require.NoError(t, os.WriteFile(path, []byte("authored=true\n"), 0o644)) + } + require.Error(t, materializeConfigurationProfile(t.Context(), root, profileEnvironment("blue"), false)) + }) + } +} diff --git a/cmd/environment/import.go b/cmd/environment/import.go index ae11bd9e..e80424f3 100644 --- a/cmd/environment/import.go +++ b/cmd/environment/import.go @@ -223,6 +223,9 @@ func runImport(ctx context.Context, opts *importOptions) error { if _, err := environments.FromWorkspace(merged); err != nil { return fmt.Errorf("merged environment declarations: %w", err) } + if err := materializeConfigurationProfile(ctx, opts.dir, env, opts.dryRun); err != nil { + return err + } if opts.dryRun { diff, derr := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ A: difflib.SplitLines(string(original)), diff --git a/docs/commands.md b/docs/commands.md index 90bd2589..e82bc420 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2214,6 +2214,28 @@ readiness validation runs for the selected environment; when it reports the workspace is not ready, the command prints the diagnostics and exits non-zero. The merged file is still written, so the diagnostics can be read against it. +An imported environment may declare `configuration-source: hosted` and select +`configuration-profiles: [blue, local]`. Import generates a `profile.codefly.yaml` +in `configurations/blue/` deriving from `hosted`, using Core's profile inheritance. +The profile chain selects the first directory at each location; inheritance adds +shared groups and per-profile values. Import validates every inherited source: +only flat `.env` inputs and profile derivation metadata are accepted; secret +files contain empty placeholders, symlinks are refused, and sources may +not contain environment-specific runnable bindings. Dry-run writes nothing. +Import refuses authored destination files and retires only its own earlier copied +outputs, preserving the environment's separately generated runnable bindings. +Run `codefly generate runnable-bindings --env blue` after import so addresses are +resolved for blue rather than copied from a sibling. + +The cell inventory also carries `source.path` when publication uses a root other +than `deployments`. Consumers must use that root to discover the published module +trees. The local render directory remains a qualification tree; `publish` writes +the environment's declared publication path. + +`service-config.services` accepts `/` keys for compositions +with same-named services. Qualified values override bare values per key. Unknown +qualified entries and ambiguous bare entries fail workspace validation. + #### `codefly environment show` Print the resolved `resources.Environment` as YAML (default) or `--json`. diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index d03cbc26..a5c85d54 100644 --- a/pkg/environments/environment.go +++ b/pkg/environments/environment.go @@ -937,6 +937,20 @@ type EnvironmentServiceConfig struct { Services map[string]EnvironmentServiceConfigMapping `yaml:"services,omitempty"` } +// ValuesFor overlays qualified values on the bare service's values, per key. +// Workspace validation refuses a bare entry shared by multiple modules. +func (c *EnvironmentServiceConfig) ValuesFor(module, service string) map[string]string { + if c == nil { + return nil + } + values := make(map[string]string) + maps.Copy(values, c.Services[service].Values) + if module != "" { + maps.Copy(values, c.Services[module+"/"+service].Values) + } + return values +} + // EnvironmentServiceConfigMapping holds one service's resolved values keyed by // the configuration key that consumes them. type EnvironmentServiceConfigMapping struct { @@ -1170,13 +1184,15 @@ func (env *Environment) validateServiceKeyCollisions() error { return nil } for name, config := range env.ServiceConfig.Services { - secrets, declared := env.ServiceSecrets.Services[name] - if !declared { - continue + secretNames := []string{name} + if _, bare, qualified := strings.Cut(name, "/"); qualified { + secretNames = append(secretNames, bare) } - for key := range config.Values { - if _, collides := secrets.RemoteKeys[key]; collides { - return fmt.Errorf("service %q declares %q as both a service-config value and a service-secrets remote key", name, key) + for _, secretName := range secretNames { + for key := range config.Values { + if _, collides := env.ServiceSecrets.Services[secretName].RemoteKeys[key]; collides { + return fmt.Errorf("service %q declares %q as both a service-config value and a service-secrets remote key", name, key) + } } } } @@ -1308,6 +1324,9 @@ type Environment struct { // to ConfigurationProfile: each configuration location reads the first // profile it holds (resources.Environment.ConfigurationProfiles). ConfigurationProfiles []string `yaml:"configuration-profiles,omitempty"` + // ConfigurationSource is an authored workspace profile materialized by import + // into the first selected profile. The destination contains generated files. + ConfigurationSource string `yaml:"configuration-source,omitempty"` // RunnableScopeSelections answers owner-declared slots per binding key // (the same MODULE__OPERATION key generate runnable-bindings emits). diff --git a/pkg/environments/environment_test.go b/pkg/environments/environment_test.go index 968829f9..9e6461e4 100644 --- a/pkg/environments/environment_test.go +++ b/pkg/environments/environment_test.go @@ -90,9 +90,10 @@ func serviceIdentityDeclaration(serviceName string) string { // service passes the graph check. func TestValidateEnvironmentsAllowsKnownService(t *testing.T) { for name, declaration := range map[string]string{ - "service-secrets": serviceSecretsDeclaration("accounts"), - "service-config": serviceConfigDeclaration("accounts"), - "service-identity": serviceIdentityDeclaration("accounts"), + "service-secrets": serviceSecretsDeclaration("accounts"), + "service-config": serviceConfigDeclaration("accounts"), + "qualified service-config": serviceConfigDeclaration("saas/accounts"), + "service-identity": serviceIdentityDeclaration("accounts"), } { t.Run(name, func(t *testing.T) { ctx := context.Background() @@ -112,9 +113,10 @@ func TestValidateEnvironmentsAllowsKnownService(t *testing.T) { // projection time; the graph check must reject it. func TestValidateEnvironmentsRejectsUnknownService(t *testing.T) { for name, declaration := range map[string]string{ - "service-secrets": serviceSecretsDeclaration("accunts"), - "service-config": serviceConfigDeclaration("accunts"), - "service-identity": serviceIdentityDeclaration("accunts"), + "service-secrets": serviceSecretsDeclaration("accunts"), + "service-config": serviceConfigDeclaration("accunts"), + "qualified service-config": serviceConfigDeclaration("wrong/accounts"), + "service-identity": serviceIdentityDeclaration("accunts"), } { t.Run(name, func(t *testing.T) { ctx := context.Background() @@ -126,7 +128,7 @@ func TestValidateEnvironmentsRejectsUnknownService(t *testing.T) { if err == nil { t.Fatal("expected ValidateEnvironments to reject unknown service") } - if !strings.Contains(err.Error(), "accunts") || !strings.Contains(err.Error(), name) { + if !strings.Contains(err.Error(), strings.TrimPrefix(name, "qualified ")) || (!strings.Contains(err.Error(), "accunts") && !strings.Contains(err.Error(), "wrong/accounts")) { t.Fatalf("error = %v, want it to name the block and the unknown service", err) } }) @@ -790,16 +792,18 @@ environments: DATABASE_HOST: accounts/prod/host ` } - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, resources.WorkspaceConfigurationName), []byte(workspaceWith("accounts")), 0o644); err != nil { - t.Fatal(err) - } - _, err := loadDeploymentWorkspace(context.Background(), root) - if err == nil { - t.Fatal("expected load to refuse a key declared as both a value and a secret") - } - if !strings.Contains(err.Error(), "DATABASE_HOST") || !strings.Contains(err.Error(), "accounts") { - t.Fatalf("error = %v, want it to name the service and the key", err) + for _, name := range []string{"accounts", "saas/accounts"} { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, resources.WorkspaceConfigurationName), []byte(workspaceWith(name)), 0o644); err != nil { + t.Fatal(err) + } + _, err := loadDeploymentWorkspace(context.Background(), root) + if err == nil { + t.Fatal("expected load to refuse a key declared as both a value and a secret") + } + if !strings.Contains(err.Error(), "DATABASE_HOST") || !strings.Contains(err.Error(), name) { + t.Fatalf("error = %v, want it to name the service and the key", err) + } } other := t.TempDir() diff --git a/pkg/environments/workspace.go b/pkg/environments/workspace.go index caf00122..9cc782f9 100644 --- a/pkg/environments/workspace.go +++ b/pkg/environments/workspace.go @@ -226,7 +226,7 @@ func ValidateWorkspace(ctx context.Context, workspace *resources.Workspace) erro // A module-qualified service-identity key names exactly one // service of that module, or nothing (a typo that would leave // the workload on the default identity with no report). - if _, service, qualified := strings.Cut(name, "/"); qualified && (block == "service-identity" || block == "service-mesh") { + if _, service, qualified := strings.Cut(name, "/"); qualified && (block == "service-identity" || block == "service-mesh" || block == "service-config") { if !slices.Contains(byName[service], name) { return w.Wrap(fmt.Errorf("environment %q %s references unknown service %q", env.Name, block, name)) } diff --git a/pkg/gitops/cell.go b/pkg/gitops/cell.go index a9d27cee..19570da9 100644 --- a/pkg/gitops/cell.go +++ b/pkg/gitops/cell.go @@ -197,7 +197,11 @@ func cellSource(ctx context.Context, workspace *resources.Workspace, env *enviro if len(revision) != 40 { return nil } - return &cellfile.Source{Repo: env.Gitops.RepoURL, Revision: revision, Branch: env.Gitops.Branch} + source := &cellfile.Source{Repo: env.Gitops.RepoURL, Revision: revision, Branch: env.Gitops.Branch} + if path, err := validateRelativePath(env.Gitops.Path); err == nil && path != "" && path != DefaultGitopsPath { + source.Path = filepath.ToSlash(path) + } + return source } // cellNamespace derives one module's namespace entry from its rendered tree diff --git a/pkg/gitops/cell_test.go b/pkg/gitops/cell_test.go index 34e8a050..35985897 100644 --- a/pkg/gitops/cell_test.go +++ b/pkg/gitops/cell_test.go @@ -24,6 +24,24 @@ func cellServiceYAML(name string) string { ` } +func TestCellSourceCarriesTheEnvironmentPublicationPath(t *testing.T) { + workspace := writeCellWorkspace(t) + runGit(t, workspace.Dir(), "init", "--initial-branch=main") + runGit(t, workspace.Dir(), "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "source") + for _, path := range []string{"deployments", "deployments/environments/blue"} { + env := &environments.Environment{Gitops: &environments.EnvironmentGitops{ + RepoURL: "https://github.com/obin-ai/platform-obin.git", Branch: "main", Path: path, + }} + source := cellSource(context.Background(), workspace, env) + require.NotNil(t, source) + if path == DefaultGitopsPath { + require.Empty(t, source.Path, "retained cell files preserve their default publication path") + } else { + require.Equal(t, path, source.Path, "infra must read the same root that publish writes") + } + } +} + // exposedCellServiceYAML is cellServiceYAML for the service whose `http` // endpoint the staging environment routes an ingress to: it is public and // states its exposure, or the route is refused. diff --git a/pkg/gitops/cellfile/cellfile.go b/pkg/gitops/cellfile/cellfile.go index dce2e78f..5c969f7a 100644 --- a/pkg/gitops/cellfile/cellfile.go +++ b/pkg/gitops/cellfile/cellfile.go @@ -51,6 +51,8 @@ type Source struct { Repo string `yaml:"repo"` Revision string `yaml:"revision"` Branch string `yaml:"branch"` + // Path is the publication root containing modules/. Absent means deployments. + Path string `yaml:"path,omitempty"` } // Namespace is one module's namespace: what runs in it, the external reach it diff --git a/pkg/gitops/configuration_projection.go b/pkg/gitops/configuration_projection.go index 4b8db23d..97a7b0bb 100644 --- a/pkg/gitops/configuration_projection.go +++ b/pkg/gitops/configuration_projection.go @@ -26,10 +26,13 @@ func projectConfigurationValues(ctx context.Context, root, service string, env * } func projectConfigurationValuesInTree(ctx context.Context, tree *renderRoot, root, service string, env *environments.Environment) error { - values := map[string]string{} - if env.ServiceConfig != nil { - values = env.ServiceConfig.Services[service].Values + module, bare, qualified := strings.Cut(service, "/") + if qualified { + service = bare + } else { + module = "" } + values := env.ServiceConfig.ValuesFor(module, service) refs := map[string]environments.EnvironmentSecretRemoteRef{} if env.ServiceSecrets != nil { refs = env.ServiceSecrets.Services[service].RemoteKeys @@ -184,10 +187,7 @@ func validateProjectedConfiguration(root string, service *resources.Service, env } func validateProjectedConfigurationInTree(tree *renderRoot, root string, service *resources.Service, env *environments.Environment, scope unitScope) error { - values := map[string]string{} - if env.ServiceConfig != nil { - values = env.ServiceConfig.Services[service.Name].Values - } + values := env.ServiceConfig.ValuesFor(scope.Module, service.Name) refs := map[string]environments.EnvironmentSecretRemoteRef{} if env.ServiceSecrets != nil { refs = env.ServiceSecrets.Services[service.Name].RemoteKeys diff --git a/pkg/gitops/configuration_projection_test.go b/pkg/gitops/configuration_projection_test.go index d6d9e48c..2baab6e8 100644 --- a/pkg/gitops/configuration_projection_test.go +++ b/pkg/gitops/configuration_projection_test.go @@ -23,6 +23,47 @@ func injectionContract(t *testing.T) *environments.Environment { return &contract.Environment } +func TestQualifiedConfigurationKeepsSameNamedWorkloadsIsolated(t *testing.T) { + env := &environments.Environment{Name: "blue", Namespace: "product", ServiceConfig: &environments.EnvironmentServiceConfig{ + Services: map[string]environments.EnvironmentServiceConfigMapping{ + "wiki/backend": {Values: map[string]string{"PUBLIC_URL": "https://wiki.example"}}, + "proposals/backend": {Values: map[string]string{"PUBLIC_URL": "https://proposals.example"}}, + }, + }} + for _, module := range []string{"wiki", "proposals", "other"} { + t.Run(module, func(t *testing.T) { + root := t.TempDir() + writeConsumerTree(t, root, env.Name, env.Namespace, "backend", "declared.example") + scope := scopeOf(env) + scope.Module = module + require.NoError(t, projectServiceConfiguration(t.Context(), testRenderRoot(t, root), root, &resources.Service{Name: "backend"}, env, scope, serviceInjection{})) + values := containerEnvironment(t, buildOverlay(t, root, env.Name)) + if module == "other" { + require.NotContains(t, values, "PUBLIC_URL") + } else { + require.Equal(t, "https://"+module+".example", values["PUBLIC_URL"]["value"]) + } + }) + } +} + +func TestQualifiedConfigurationRetainsImportedResourceValues(t *testing.T) { + env := &environments.Environment{Name: "blue", Namespace: "product", ServiceConfig: &environments.EnvironmentServiceConfig{ + Services: map[string]environments.EnvironmentServiceConfigMapping{ + "accounts": {Values: map[string]string{"BACKEND": "managed", "PUBLIC_URL": "https://default.example"}}, + "saas/accounts": {Values: map[string]string{"PUBLIC_URL": "https://app.example"}}, + }, + }} + root := t.TempDir() + writeConsumerTree(t, root, env.Name, env.Namespace, "accounts", "declared.example") + scope := scopeOf(env) + scope.Module = "saas" + require.NoError(t, projectServiceConfiguration(t.Context(), testRenderRoot(t, root), root, &resources.Service{Name: "accounts"}, env, scope, serviceInjection{})) + values := containerEnvironment(t, buildOverlay(t, root, env.Name)) + require.Equal(t, "managed", values["BACKEND"]["value"]) + require.Equal(t, "https://app.example", values["PUBLIC_URL"]["value"]) +} + func TestActualInfraBaseValuesReachRenderedWorkload(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "environments", "testdata", "coordinates", "infra-base-lodestar.json")) require.NoError(t, err) diff --git a/pkg/gitops/managed_identity.go b/pkg/gitops/managed_identity.go index ed9ac7a7..9300cdd0 100644 --- a/pkg/gitops/managed_identity.go +++ b/pkg/gitops/managed_identity.go @@ -29,7 +29,7 @@ func projectServiceConfiguration(ctx context.Context, tree *renderRoot, root str if injection, err = artifactCacheInjection(service, env, scope, injection); err != nil { return err } - if err := projectConfigurationValuesInTree(ctx, tree, root, service.Name, env); err != nil { + if err := projectConfigurationValuesInTree(ctx, tree, root, resources.ServiceUnique(scope.Module, service.Name), env); err != nil { return fmt.Errorf("project service %s configuration: %w", service.Name, err) } // Before the secret projection: the derived secretKeyRefs are part of what diff --git a/pkg/orchestration/builder.go b/pkg/orchestration/builder.go index 60248a61..d337c337 100644 --- a/pkg/orchestration/builder.go +++ b/pkg/orchestration/builder.go @@ -338,6 +338,7 @@ func (b *Builder) Plan(ctx context.Context) (*OutputProperty, error) { OutputDirectory: outputDir, }) if err != nil { + w.Warn("cannot ask the agent for its image build plan", wool.Field("service", b.instance.Unique()), wool.ErrField(err)) return nil, w.Wrapf(err, "cannot call build") } if resp == nil {