Skip to content
139 changes: 139 additions & 0 deletions cmd/environment/configuration_profile.go
Original file line number Diff line number Diff line change
@@ -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 {

Check failure on line 21 in cmd/environment/configuration_profile.go

View workflow job for this annotation

GitHub Actions / lint

cyclomatic complexity 50 of func `materializeConfigurationProfile` is high (> 30) (gocyclo)
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)

Check failure on line 54 in cmd/environment/configuration_profile.go

View workflow job for this annotation

GitHub Actions / lint

shadow: declaration of "err" shadows declaration at line 25 (govet)
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() {

Check failure on line 91 in cmd/environment/configuration_profile.go

View workflow job for this annotation

GitHub Actions / lint

shadow: declaration of "err" shadows declaration at line 25 (govet)
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
}
136 changes: 136 additions & 0 deletions cmd/environment/configuration_profile_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
3 changes: 3 additions & 0 deletions cmd/environment/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
22 changes: 22 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<module>/<service>` 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`.
Expand Down
31 changes: 25 additions & 6 deletions pkg/environments/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading