From e4884580436fbc7d34aaa119212350f2dcb1bd53 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss Date: Fri, 23 Oct 2026 03:37:24 +0000 Subject: [PATCH 1/5] feat(environment): import isolated hosted configuration profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materialize portable authored inputs through the existing importer while refusing credential values and authored destination files. Preserve each environment’s own runnable bindings and carry its actual publication root in the generated cell inventory. --- cmd/environment/configuration_profile.go | 137 ++++++++++++++++++ cmd/environment/configuration_profile_test.go | 82 +++++++++++ cmd/environment/import.go | 3 + docs/commands.md | 14 ++ pkg/environments/environment.go | 3 + pkg/gitops/cell.go | 6 +- pkg/gitops/cell_test.go | 18 +++ pkg/gitops/cellfile/cellfile.go | 2 + 8 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 cmd/environment/configuration_profile.go create mode 100644 cmd/environment/configuration_profile_test.go diff --git a/cmd/environment/configuration_profile.go b/cmd/environment/configuration_profile.go new file mode 100644 index 000000000..941ddc3b8 --- /dev/null +++ b/cmd/environment/configuration_profile.go @@ -0,0 +1,137 @@ +package environment + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/core/shared" +) + +const profileMarker = "# GENERATED — do not edit.\n# codefly environment import: " + +// materializeConfigurationProfile keeps authored inputs separate from an +// environment's generated configuration, including its own runnable bindings. +// It never copies credential values, follows symlinks, or overwrites authored files. +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") + } + source := filepath.Join(configurationRoot, env.ConfigurationSource) + destination := filepath.Join(configurationRoot, target) + for _, path := range []string{source, destination} { + info, statErr := os.Lstat(path) + if os.IsNotExist(statErr) && path == destination { + continue + } + if statErr != nil || !info.IsDir() { + return fmt.Errorf("configuration profile %s must be an ordinary directory", filepath.Base(path)) + } + } + entries, err := os.ReadDir(source) + if err != nil { + return err + } + files := make(map[string][]byte) + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".env") { + continue + } + if entry.Name() == "runnable-bindings.env" { + return fmt.Errorf("configuration-source must not contain environment-specific runnable bindings") + } + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("configuration source %s must be a regular file", entry.Name()) + } + body, err := os.ReadFile(filepath.Join(source, entry.Name())) + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".secret.env") { + 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()) + } + } + } + output := filepath.Join(destination, entry.Name()) + if info, err := os.Lstat(output); err == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("generated configuration %s must be a regular file", entry.Name()) + } + existing, err := os.ReadFile(output) + if err != nil || !strings.HasPrefix(string(existing), profileMarker) { + return fmt.Errorf("refusing to overwrite authored configuration %s", entry.Name()) + } + } else if !os.IsNotExist(err) { + return err + } + files[output] = append([]byte(profileMarker+env.ConfigurationSource+"/"+entry.Name()+"\n"), body...) + } + var retired []string + previous, err := os.ReadDir(destination) + if err != nil && !os.IsNotExist(err) { + return err + } + for _, entry := range previous { + path := filepath.Join(destination, entry.Name()) + if _, current := files[path]; current || !strings.HasSuffix(entry.Name(), ".env") { + continue + } + 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 + } + if strings.HasPrefix(string(body), profileMarker+env.ConfigurationSource+"/") { + retired = append(retired, path) + } + } + if dryRun { + return nil + } + if err := os.MkdirAll(destination, 0o755); err != nil { + return err + } + for path, body := range files { + if err := shared.WriteFileAtomic(ctx, path, 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 000000000..03e2730ae --- /dev/null +++ b/cmd/environment/configuration_profile_test.go @@ -0,0 +1,82 @@ +package environment + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/environments" + "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, "platform.env")) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(body), profileMarker)) + require.Contains(t, string(body), "${endpoint:saas/accounts/authority}") + } + 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.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, "platform.env")) + 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 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 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 ae11bd9e6..e80424f34 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 90bd2589b..f85374642 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2216,6 +2216,20 @@ The merged file is still written, so the diagnostics can be read against it. #### `codefly environment show` +An imported environment may declare `configuration-source: hosted` and select +`configuration-profiles: [blue, local]`. Import materializes the authored +`configurations/hosted/*.env` inputs into the generated `configurations/blue/` +profile. Secret source files must contain empty placeholders. Import refuses +symlinks and authored destination files; dry-run writes nothing. Removed source +groups retire only import-owned files, 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. + Print the resolved `resources.Environment` as YAML (default) or `--json`. ```bash diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index d03cbc266..829f0eeaf 100644 --- a/pkg/environments/environment.go +++ b/pkg/environments/environment.go @@ -1308,6 +1308,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/gitops/cell.go b/pkg/gitops/cell.go index a9d27cee1..19570da9d 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 34e8a0503..359858978 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 dce2e78f5..5c969f7ab 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 From 4275a947c358122a5b361bc23d73434f693e6715 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss Date: Fri, 23 Oct 2026 03:45:32 +0000 Subject: [PATCH 2/5] fix(gitops): resolve configuration for module-qualified workloads --- docs/commands.md | 8 +++++-- pkg/environments/environment.go | 14 ++++++++++++ pkg/environments/environment_test.go | 16 ++++++++------ pkg/environments/workspace.go | 2 +- pkg/gitops/configuration_projection.go | 14 ++++++------ pkg/gitops/configuration_projection_test.go | 24 +++++++++++++++++++++ pkg/gitops/managed_identity.go | 2 +- 7 files changed, 62 insertions(+), 18 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index f85374642..1e3d896db 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2214,8 +2214,6 @@ 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. -#### `codefly environment show` - An imported environment may declare `configuration-source: hosted` and select `configuration-profiles: [blue, local]`. Import materializes the authored `configurations/hosted/*.env` inputs into the generated `configurations/blue/` @@ -2230,6 +2228,12 @@ than `deployments`. Consumers must use that root to discover the published modul 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. A qualified entry wins over the bare name. Unknown +qualified entries and ambiguous bare entries fail workspace validation. + +#### `codefly environment show` + Print the resolved `resources.Environment` as YAML (default) or `--json`. ```bash diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index 829f0eeaf..593ea2128 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 selects the qualified workload first, then a bare service entry. +// 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 + } + if module != "" { + if mapping, ok := c.Services[module+"/"+service]; ok { + return mapping.Values + } + } + return c.Services[service].Values +} + // EnvironmentServiceConfigMapping holds one service's resolved values keyed by // the configuration key that consumes them. type EnvironmentServiceConfigMapping struct { diff --git a/pkg/environments/environment_test.go b/pkg/environments/environment_test.go index 968829f9c..48d10d54c 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) } }) diff --git a/pkg/environments/workspace.go b/pkg/environments/workspace.go index caf001227..9cc782f9b 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/configuration_projection.go b/pkg/gitops/configuration_projection.go index 4b8db23d7..97a7b0bbf 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 d6d9e48cb..e44e43a5a 100644 --- a/pkg/gitops/configuration_projection_test.go +++ b/pkg/gitops/configuration_projection_test.go @@ -23,6 +23,30 @@ 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 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 ed9ac7a77..9300cdd06 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 From e0b96ba4569159a783faed701d560d9a51915a55 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss Date: Fri, 23 Oct 2026 04:12:05 +0000 Subject: [PATCH 3/5] fix(environment): reuse Core profile derivation for shared hosted inputs --- cmd/environment/configuration_profile.go | 108 +++++++++--------- cmd/environment/configuration_profile_test.go | 50 +++++++- docs/commands.md | 17 +-- 3 files changed, 112 insertions(+), 63 deletions(-) diff --git a/cmd/environment/configuration_profile.go b/cmd/environment/configuration_profile.go index 941ddc3b8..33f6d7154 100644 --- a/cmd/environment/configuration_profile.go +++ b/cmd/environment/configuration_profile.go @@ -9,14 +9,15 @@ import ( "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 keeps authored inputs separate from an -// environment's generated configuration, including its own runnable bindings. -// It never copies credential values, follows symlinks, or overwrites authored files. +// 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 @@ -38,73 +39,71 @@ func materializeConfigurationProfile(ctx context.Context, directory string, env if err != nil || resolved != configurationRoot { return fmt.Errorf("configuration profiles require an ordinary configurations directory") } - source := filepath.Join(configurationRoot, env.ConfigurationSource) destination := filepath.Join(configurationRoot, target) - for _, path := range []string{source, destination} { - info, statErr := os.Lstat(path) - if os.IsNotExist(statErr) && path == destination { - continue - } - if statErr != nil || !info.IsDir() { - return fmt.Errorf("configuration profile %s must be an ordinary directory", filepath.Base(path)) - } - } - entries, err := os.ReadDir(source) + layers, exists, err := configurations.ProfileLayers(ctx, root, "configurations", []string{env.ConfigurationSource}) if err != nil { return err } - files := make(map[string][]byte) - for _, entry := range entries { - if !strings.HasSuffix(entry.Name(), ".env") { - continue - } - if entry.Name() == "runnable-bindings.env" { - return fmt.Errorf("configuration-source must not contain environment-specific runnable bindings") + 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") } - info, err := entry.Info() - if err != nil || !info.Mode().IsRegular() { - return fmt.Errorf("configuration source %s must be a regular file", entry.Name()) + resolved, err := filepath.EvalSymlinks(layer) + if err != nil || resolved != layer { + return fmt.Errorf("configuration source must be an ordinary profile directory") } - body, err := os.ReadFile(filepath.Join(source, entry.Name())) + entries, err := os.ReadDir(layer) if err != nil { return err } - if strings.HasSuffix(entry.Name(), ".secret.env") { - 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()) - } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".env") && entry.Name() != configurations.ProfileDerivationFile { + continue } - } - output := filepath.Join(destination, entry.Name()) - if info, err := os.Lstat(output); err == nil { - if !info.Mode().IsRegular() { - return fmt.Errorf("generated configuration %s must be a regular file", 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()) } - existing, err := os.ReadFile(output) - if err != nil || !strings.HasPrefix(string(existing), profileMarker) { - return fmt.Errorf("refusing to overwrite authored configuration %s", 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()) + } + } } - } else if !os.IsNotExist(err) { - return err } - files[output] = append([]byte(profileMarker+env.ConfigurationSource+"/"+entry.Name()+"\n"), body...) } + 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 { - path := filepath.Join(destination, entry.Name()) - if _, current := files[path]; current || !strings.HasSuffix(entry.Name(), ".env") { + 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()) @@ -113,7 +112,11 @@ func materializeConfigurationProfile(ctx context.Context, directory string, env if err != nil { return err } - if strings.HasPrefix(string(body), profileMarker+env.ConfigurationSource+"/") { + 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) } } @@ -123,10 +126,9 @@ func materializeConfigurationProfile(ctx context.Context, directory string, env if err := os.MkdirAll(destination, 0o755); err != nil { return err } - for path, body := range files { - if err := shared.WriteFileAtomic(ctx, path, body, 0o644); 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 { diff --git a/cmd/environment/configuration_profile_test.go b/cmd/environment/configuration_profile_test.go index 03e2730ae..6ebcee99f 100644 --- a/cmd/environment/configuration_profile_test.go +++ b/cmd/environment/configuration_profile_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/core/configurations" "github.com/stretchr/testify/require" ) @@ -28,25 +29,68 @@ 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, "platform.env")) + 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), "${endpoint:saas/accounts/authority}") + 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, "platform.env")) + _, 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)) diff --git a/docs/commands.md b/docs/commands.md index 1e3d896db..a64f6628d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2215,13 +2215,16 @@ 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 materializes the authored -`configurations/hosted/*.env` inputs into the generated `configurations/blue/` -profile. Secret source files must contain empty placeholders. Import refuses -symlinks and authored destination files; dry-run writes nothing. Removed source -groups retire only import-owned files, 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. +`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: +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 From 6834702b69a724436e432d3d8acfbb1ff4c95cb9 Mon Sep 17 00:00:00 2001 From: crisfiorini-oss Date: Fri, 23 Oct 2026 04:20:55 +0000 Subject: [PATCH 4/5] fix(deploy): name the service whose build plan is refused --- pkg/orchestration/builder.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/orchestration/builder.go b/pkg/orchestration/builder.go index 60248a61b..d337c3379 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 { From 11991170aa6dd33ef0b4293ab138bd91937be22c Mon Sep 17 00:00:00 2001 From: crisfiorini-oss Date: Fri, 23 Oct 2026 04:51:30 +0000 Subject: [PATCH 5/5] fix: preserve imported resource values in qualified configuration --- cmd/environment/configuration_profile.go | 2 +- cmd/environment/configuration_profile_test.go | 10 ++++++++ docs/commands.md | 5 ++-- pkg/environments/environment.go | 24 ++++++++++--------- pkg/environments/environment_test.go | 22 +++++++++-------- pkg/gitops/configuration_projection_test.go | 17 +++++++++++++ 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/cmd/environment/configuration_profile.go b/cmd/environment/configuration_profile.go index 33f6d7154..c1dd849e5 100644 --- a/cmd/environment/configuration_profile.go +++ b/cmd/environment/configuration_profile.go @@ -61,7 +61,7 @@ func materializeConfigurationProfile(ctx context.Context, directory string, env } for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".env") && entry.Name() != configurations.ProfileDerivationFile { - continue + 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() { diff --git a/cmd/environment/configuration_profile_test.go b/cmd/environment/configuration_profile_test.go index 6ebcee99f..132b62cd6 100644 --- a/cmd/environment/configuration_profile_test.go +++ b/cmd/environment/configuration_profile_test.go @@ -108,6 +108,16 @@ func TestProfileImportRefusesCredentialValuesBeforeWriting(t *testing.T) { 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) { diff --git a/docs/commands.md b/docs/commands.md index a64f6628d..e82bc4206 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2219,7 +2219,8 @@ An imported environment may declare `configuration-source: hosted` and select 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: -secret files contain empty placeholders, symlinks are refused, and sources may +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. @@ -2232,7 +2233,7 @@ 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. A qualified entry wins over the bare name. Unknown +with same-named services. Qualified values override bare values per key. Unknown qualified entries and ambiguous bare entries fail workspace validation. #### `codefly environment show` diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index 593ea2128..a5c85d542 100644 --- a/pkg/environments/environment.go +++ b/pkg/environments/environment.go @@ -937,18 +937,18 @@ type EnvironmentServiceConfig struct { Services map[string]EnvironmentServiceConfigMapping `yaml:"services,omitempty"` } -// ValuesFor selects the qualified workload first, then a bare service entry. +// 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 != "" { - if mapping, ok := c.Services[module+"/"+service]; ok { - return mapping.Values - } + maps.Copy(values, c.Services[module+"/"+service].Values) } - return c.Services[service].Values + return values } // EnvironmentServiceConfigMapping holds one service's resolved values keyed by @@ -1184,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) + } } } } diff --git a/pkg/environments/environment_test.go b/pkg/environments/environment_test.go index 48d10d54c..9e6461e4d 100644 --- a/pkg/environments/environment_test.go +++ b/pkg/environments/environment_test.go @@ -792,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/gitops/configuration_projection_test.go b/pkg/gitops/configuration_projection_test.go index e44e43a5a..2baab6e83 100644 --- a/pkg/gitops/configuration_projection_test.go +++ b/pkg/gitops/configuration_projection_test.go @@ -47,6 +47,23 @@ func TestQualifiedConfigurationKeepsSameNamedWorkloadsIsolated(t *testing.T) { } } +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)