diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index 1303e184..b62ae80b 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -33,6 +33,32 @@ var GitOpsCmd = &cobra.Command{ Short: "Render, publish, observe, and recover reviewed GitOps promotions", } +var gitOpsCellCmd = &cobra.Command{ + Use: "cell", + Short: "Derive the cell file from published module trees without rebuilding images", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + ctx, done := common.NewContext() + defer done() + if err := common.ResolvePinnedModules(ctx); err != nil { + return err + } + workspace, err := common.LoadWorkspace(ctx) + if err != nil { + return err + } + env, err := orchestration.SelectEnvironment(workspace, gitOpsEnv) + if err != nil { + return err + } + cell, err := gitops.RenderPublishedCell(ctx, workspace, env) + if err == nil { + printCell(&cell) + } + return err + }, +} + var gitOpsRenderCmd = &cobra.Command{ Use: "render [module]", Short: "Render and validate a module-owned manifest tree", @@ -605,8 +631,8 @@ var ( ) func init() { - GitOpsCmd.AddCommand(gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd) - for _, command := range []*cobra.Command{gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd} { + GitOpsCmd.AddCommand(gitOpsCellCmd, gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd) + for _, command := range []*cobra.Command{gitOpsCellCmd, gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd} { command.Flags().StringVar(&gitOpsEnv, "env", "local", "Environment to promote") } // The delivery verbs — the ones that render a manifest tree from the diff --git a/cmd/environment/configuration_profile.go b/cmd/environment/configuration_profile.go new file mode 100644 index 00000000..3dc933a3 --- /dev/null +++ b/cmd/environment/configuration_profile.go @@ -0,0 +1,164 @@ +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") + } + if err = validateConfigurationSources(layers, destination); err != nil { + return err + } + confined, err := os.OpenRoot(configurationRoot) + if err != nil { + return err + } + defer confined.Close() + retired, err := importedProfileRetirements(confined, target) + if err != nil { + return err + } + output := filepath.Join(destination, configurations.ProfileDerivationFile) + if dryRun { + return nil + } + if err := confined.MkdirAll(target, 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 := confined.Remove(path); err != nil { + return err + } + } + return nil +} + +func validateConfigurationSources(layers []string, destination string) error { + for _, layer := range layers { + if layer == destination { + return fmt.Errorf("configuration-source must not derive from its destination") + } + resolved, resolveErr := filepath.EvalSymlinks(layer) + if resolveErr != nil || resolved != layer { + return fmt.Errorf("configuration source must be an ordinary profile directory") + } + entries, err := os.ReadDir(layer) + if err != nil { + return err + } + confined, err := os.OpenRoot(layer) + if err != nil { + return err + } + defer confined.Close() + 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 := confined.ReadFile(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()) + } + } + } + } + } + return nil +} + +func importedProfileRetirements(confined *os.Root, target string) ([]string, error) { + if info, err := confined.Lstat(target); err == nil && !info.IsDir() { + return nil, fmt.Errorf("generated profile must be an ordinary directory") + } else if err != nil && !os.IsNotExist(err) { + return nil, err + } + var retired []string + previous, err := os.ReadDir(filepath.Join(confined.Name(), target)) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + for _, entry := range previous { + if !strings.HasSuffix(entry.Name(), ".env") && entry.Name() != configurations.ProfileDerivationFile { + continue + } + path := filepath.Join(target, entry.Name()) + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() { + return nil, fmt.Errorf("generated configuration %s must be a regular file", entry.Name()) + } + body, err := confined.ReadFile(filepath.Join(target, entry.Name())) + if err != nil { + return nil, err + } + owned := strings.HasPrefix(string(body), profileMarker) + if !owned && entry.Name() != "runnable-bindings.env" { + return nil, fmt.Errorf("refusing to overwrite authored configuration %s", entry.Name()) + } + if entry.Name() != configurations.ProfileDerivationFile && owned { + retired = append(retired, path) + } + } + return retired, nil +} diff --git a/cmd/environment/configuration_profile_test.go b/cmd/environment/configuration_profile_test.go new file mode 100644 index 00000000..e388c537 --- /dev/null +++ b/cmd/environment/configuration_profile_test.go @@ -0,0 +1,177 @@ +package environment + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/core/configurations" + "github.com/stretchr/testify/require" +) + +func TestImportProductProfileIntoExistingPlacement(t *testing.T) { + root := profileSource(t) + workspace := "name: acme\nlayout: modules\nenvironments:\n- name: azure\n namespace: acme\n service-identity:\n services:\n model/model:\n principal: applied-client\n" + require.NoError(t, os.WriteFile(filepath.Join(root, "workspace.codefly.yaml"), []byte(workspace), 0o644)) + opts := importOptions{configurationSource: "hosted", configurationProfiles: []string{"azure", "local"}, dryRun: true} + doImport(t, root, opts) + require.Equal(t, workspace, readFile(t, filepath.Join(root, "workspace.codefly.yaml"))) + _, err := os.Stat(filepath.Join(root, "configurations", "azure")) + require.True(t, os.IsNotExist(err)) + opts.dryRun = false + doImport(t, root, opts) + env := loadWorkspace(t, root).FindEnvironment("azure") + require.Equal(t, "hosted", env.ConfigurationSource) + require.Equal(t, []string{"azure", "local"}, env.ConfigurationProfiles) + require.Contains(t, readFile(t, filepath.Join(root, "workspace.codefly.yaml")), "principal: applied-client") + profile := filepath.Join(root, "configurations", "azure", configurations.ProfileDerivationFile) + require.Contains(t, readFile(t, profile), "derives-from: hosted") + // A later placement refresh keeps and regenerates the product selection. + require.NoError(t, os.Remove(profile)) + doImport(t, root, importOptions{}) + require.Contains(t, readFile(t, profile), "derives-from: hosted") +} + +func TestImportProductProfileRefusesCoordinateConflict(t *testing.T) { + for _, declared := range []string{ + `"configuration-source":"other"`, + `"configuration-profile":"other"`, + `"configuration-profiles":["other"]`, + } { + t.Run(declared, func(t *testing.T) { + root := profileSource(t) + workspace := "name: acme\nlayout: modules\n" + require.NoError(t, os.WriteFile(filepath.Join(root, "workspace.codefly.yaml"), []byte(workspace), 0o644)) + opts := importOptions{dir: root, envName: "azure", configurationSource: "hosted", configurationProfiles: []string{"azure", "local"}, contractData: []byte(`{"schema":"codefly/coordinate/v1","environment":{"name":"azure",` + declared + `}}`), stdout: &bytes.Buffer{}} + require.Error(t, runImport(t.Context(), &opts)) + require.Equal(t, workspace, readFile(t, filepath.Join(root, "workspace.codefly.yaml"))) + }) + } +} + +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..b2f12a1d 100644 --- a/cmd/environment/import.go +++ b/cmd/environment/import.go @@ -65,6 +65,8 @@ entries are preserved, comments included. Read from stdin with if err != nil { return err } + configurationSource, _ := cmd.Flags().GetString("configuration-source") + configurationProfiles, _ := cmd.Flags().GetStringSlice("configuration-profiles") dir, err := resources.FindUp[resources.Workspace](ctx) if err != nil || dir == nil { @@ -72,12 +74,14 @@ entries are preserved, comments included. Read from stdin with } opts := importOptions{ - dir: *dir, - envName: args[0], - contractData: data, - namespace: importNamespace, - namespaceSet: cmd.Flags().Changed("namespace"), - dryRun: importDryRun, + dir: *dir, + envName: args[0], + contractData: data, + namespace: importNamespace, + namespaceSet: cmd.Flags().Changed("namespace"), + dryRun: importDryRun, + configurationSource: configurationSource, + configurationProfiles: configurationProfiles, // UTC so the provenance timestamp does not vary by the operator's // machine timezone and churn the diff on re-import across a team. now: time.Now().UTC(), @@ -133,14 +137,16 @@ func readContract(stdin io.Reader, path string) ([]byte, error) { } type importOptions struct { - dir string - envName string - contractData []byte - namespace string - namespaceSet bool - dryRun bool - now time.Time - stdout io.Writer + dir string + envName string + contractData []byte + namespace string + namespaceSet bool + dryRun bool + now time.Time + stdout io.Writer + configurationSource string + configurationProfiles []string } // runImport parses the contract, merges its owned fields into the named @@ -164,6 +170,30 @@ func runImport(ctx context.Context, opts *importOptions) error { return err } declaredEnvironment := yamledit.MapValue(declaration, "environment") + // Product profiles are owned by the consuming workspace. A placement-only + // producer need not acquire product configuration to import its target. + if opts.configurationSource != "" { + if existing := contract.Environment.ConfigurationSource; existing != "" && existing != opts.configurationSource { + return fmt.Errorf("--configuration-source conflicts with the coordinate declaration") + } + contract.Environment.ConfigurationSource = opts.configurationSource + node, encodeErr := yamledit.Encode(opts.configurationSource) + if encodeErr != nil { + return encodeErr + } + yamledit.SetMapValue(declaredEnvironment, "configuration-source", node) + } + if len(opts.configurationProfiles) > 0 { + if contract.Environment.ConfigurationProfile != "" || len(contract.Environment.ConfigurationProfiles) > 0 { + return fmt.Errorf("--configuration-profiles cannot replace coordinate-declared profiles") + } + contract.Environment.ConfigurationProfiles = opts.configurationProfiles + node, encodeErr := yamledit.Encode(opts.configurationProfiles) + if encodeErr != nil { + return encodeErr + } + yamledit.SetMapValue(declaredEnvironment, "configuration-profiles", node) + } file := filepath.Join(opts.dir, resources.WorkspaceConfigurationName) original, err := os.ReadFile(file) @@ -220,9 +250,16 @@ func runImport(ctx context.Context, opts *importOptions) error { if err != nil { return err } - if _, err := environments.FromWorkspace(merged); err != nil { + if _, err = environments.FromWorkspace(merged); err != nil { return fmt.Errorf("merged environment declarations: %w", err) } + selected, err := environments.FromRuntime(merged.FindEnvironment(opts.envName)) + if err != nil { + return err + } + if err := materializeConfigurationProfile(ctx, opts.dir, selected, opts.dryRun); err != nil { + return err + } if opts.dryRun { diff, derr := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ A: difflib.SplitLines(string(original)), @@ -430,6 +467,8 @@ func stampProvenance(envNode *yaml.Node, contract *environments.CoordinateContra func init() { importCmd.Flags().String("coordinate-contract", "", "Path to a codefly/coordinate/v1 descriptor, or - for stdin") + importCmd.Flags().String("configuration-source", "", "Authored product profile inherited by the imported environment") + importCmd.Flags().StringSlice("configuration-profiles", nil, "Product configuration profile chain when absent from the coordinate declaration") importCmd.Flags().String("cell-contract", "", "Former spelling of --coordinate-contract") _ = importCmd.Flags().MarkDeprecated("cell-contract", "use --coordinate-contract") importCmd.Flags().StringVar(&importNamespace, "namespace", "", "Assert the namespace declared by the producer") diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9cca565f..7cdaff72 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2047,6 +2047,7 @@ codefly deploy gitops Subcommands: +- [`codefly deploy gitops cell`](#codefly-deploy-gitops-cell) - [`codefly deploy gitops observe`](#codefly-deploy-gitops-observe) - [`codefly deploy gitops plan`](#codefly-deploy-gitops-plan) - [`codefly deploy gitops publish`](#codefly-deploy-gitops-publish) @@ -2055,6 +2056,20 @@ Subcommands: - [`codefly deploy gitops rollback`](#codefly-deploy-gitops-rollback) - [`codefly deploy gitops snapshot`](#codefly-deploy-gitops-snapshot) +## `codefly deploy gitops cell` + +Derive the cell file from published module trees without rebuilding images + +``` +codefly deploy gitops cell [flags] +``` + +Flags: + +``` + --env string Environment to promote (default "local") +``` + ## `codefly deploy gitops observe` Verify Argo CD reconciled the reviewed Git revision and store evidence @@ -2515,9 +2530,11 @@ codefly environment import --coordinate-contract Flags: ``` - --coordinate-contract string Path to a codefly/coordinate/v1 descriptor, or - for stdin - --dry-run Print the unified diff of workspace.codefly.yaml and write nothing - --namespace string Assert the namespace declared by the producer + --configuration-profiles strings Product configuration profile chain when absent from the coordinate declaration + --configuration-source string Authored product profile inherited by the imported environment + --coordinate-contract string Path to a codefly/coordinate/v1 descriptor, or - for stdin + --dry-run Print the unified diff of workspace.codefly.yaml and write nothing + --namespace string Assert the namespace declared by the producer ``` ## `codefly environment show` diff --git a/docs/commands.md b/docs/commands.md index 90bd2589..2e32f9f7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2214,6 +2214,41 @@ 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. + +For a placement-only coordinate, the product may select its own source at import: + +```bash +codefly environment import azure --coordinate-contract coordinate.json \ + --configuration-source hosted-azure --configuration-profiles azure,local +``` + +These options use the same importer and profile inheritance. A source conflicting +with the coordinate is refused; an explicit profile chain cannot replace a chain +or single profile declared by the coordinate. Without a profile chain, the target +defaults to the environment name. Later placement-only imports preserve the +product selection and regenerate its inherited profile. + +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/delivery/signing/cloud_kms.go b/pkg/delivery/signing/cloud_kms.go index 6e92ab6d..80675ece 100644 --- a/pkg/delivery/signing/cloud_kms.go +++ b/pkg/delivery/signing/cloud_kms.go @@ -44,6 +44,7 @@ const kmsScope = "https://www.googleapis.com/auth/cloudkms" const kmsVersionShape = "projects//locations//keyRings//cryptoKeys//cryptoKeyVersions/" var kmsEnumName = regexp.MustCompile(`^[A-Z0-9_]{1,64}$`) +var googleCredentialStatus = regexp.MustCompile(`^oauth2/google(?:/externalaccount)?: status code ([1-5][0-9]{2}):`) // CloudKMS is the optional adapter the handbook decision admits behind the // same custody interface as Vault Transit: public metadata and an @@ -209,7 +210,14 @@ func (s *CloudKMS) request(method, operation string, body []byte, result any) er if err != nil { var exchange *oauth2.RetrieveError if errors.As(err, &exchange) && exchange.Response != nil { - return fmt.Errorf("the Google credential was not exchanged for a Cloud KMS token (HTTP %d)", exchange.Response.StatusCode) + return kmsCredentialRefusal(strconv.Itoa(exchange.Response.StatusCode), string(exchange.Body)) + } + // Google's impersonation/STS token sources return an untyped error + // containing the response body. Retain only its known status prefix. + for cause := err; cause != nil; cause = errors.Unwrap(cause) { + if status := googleCredentialStatus.FindStringSubmatch(cause.Error()); status != nil { + return kmsCredentialRefusal(status[1], strings.TrimPrefix(cause.Error(), status[0])) + } } return fmt.Errorf("the Cloud KMS %s request failed", stage) } @@ -223,6 +231,28 @@ func (s *CloudKMS) request(method, operation string, body []byte, result any) er return nil } +// Only named Google error reasons may leave a credential response; messages and +// metadata can echo credentials or request bodies. +func kmsCredentialRefusal(status, body string) error { + message := fmt.Sprintf("the Google credential was not exchanged for a Cloud KMS token (HTTP %s)", status) + var response struct { + Error struct { + Details []struct { + Reason string `json:"reason"` + } `json:"details"` + } `json:"error"` + } + if len(body) <= 65536 && json.Unmarshal([]byte(body), &response) == nil { + for _, detail := range response.Error.Details { + switch detail.Reason { + case "ACCESS_TOKEN_SCOPE_INSUFFICIENT", "IAM_PERMISSION_DENIED", "SERVICE_DISABLED", "SECURITY_POLICY_VIOLATED": + return fmt.Errorf("%s: %s", message, detail.Reason) + } + } + } + return errors.New(message) +} + // kmsHost admits only Google's API hosts, where the ambient credential's // bearer token belongs, and loopback for a fake. func kmsHost(address string) bool { diff --git a/pkg/delivery/signing/cloud_kms_test.go b/pkg/delivery/signing/cloud_kms_test.go index d1f03d6d..cab905ec 100644 --- a/pkg/delivery/signing/cloud_kms_test.go +++ b/pkg/delivery/signing/cloud_kms_test.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "net/http" @@ -257,6 +258,34 @@ func TestCloudKMSRefusalsNeverExposeToken(t *testing.T) { } } +type failedKMSCredential struct{ err error } + +func (c failedKMSCredential) Token() (*oauth2.Token, error) { return nil, c.err } + +func TestCloudKMSCredentialRefusalsKeepStatusWithoutResponseBody(t *testing.T) { + for _, prefix := range []string{"oauth2/google", "oauth2/google/externalaccount"} { + credential := failedKMSCredential{errors.New(prefix + ": status code 403: private-test-token")} + _, err := NewCloudKMS(kmsLookup("http://localhost"), credential) + if err == nil || !strings.Contains(err.Error(), "HTTP 403") || strings.Contains(err.Error(), "private-test-token") { + t.Fatalf("unsafe or unhelpful credential refusal: %v", err) + } + } +} + +func TestCloudKMSCredentialReasonsAreAllowlisted(t *testing.T) { + for _, reason := range []string{"ACCESS_TOKEN_SCOPE_INSUFFICIENT", "IAM_PERMISSION_DENIED", "private-test-token"} { + body := `{"error":{"message":"private-test-token","details":[{"reason":"` + reason + `"}]}}` + credential := failedKMSCredential{errors.New("oauth2/google: status code 403: " + body)} + _, err := NewCloudKMS(kmsLookup("http://localhost"), credential) + if err == nil || strings.Contains(err.Error(), "private-test-token") || !strings.Contains(err.Error(), "HTTP 403") { + t.Fatalf("unsafe or unhelpful refusal: %v", err) + } + if reason != "private-test-token" && !strings.Contains(err.Error(), reason) { + t.Fatalf("known reason omitted: %v", err) + } + } +} + func TestCloudKMSRejectsUnsafeConfiguration(t *testing.T) { for _, endpoint := range []string{"http://example.test", "http://192.0.2.1", "ftp://localhost", "https://private-test-token@example.test", "https://example.test?token=private-test-token", // The bearer token belongs to Google's hosts only. diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index d03cbc26..1554fc13 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 { @@ -1094,6 +1108,18 @@ type SecretScope struct { Service string } +// MappingFor selects a module-qualified declaration before the unqualified fallback, +// matching service configuration and identity selection. +func (s *EnvironmentServiceSecrets) MappingFor(module, service string) EnvironmentServiceSecretMapping { + if s == nil { + return EnvironmentServiceSecretMapping{} + } + if mapping, ok := s.Services[module+"/"+service]; module != "" && ok { + return mapping + } + return s.Services[service] +} + // RemoteRef locates one secret key of a service in the remote store. An explicit // per-service RemoteKeys entry wins; else the service's own Defaults template; // else the environment-wide Defaults template; else the "/" store @@ -1102,7 +1128,7 @@ type SecretScope struct { // fallback path, the same as an environment that declares nothing. func (s *EnvironmentServiceSecrets) RemoteRef(scope SecretScope, key string) EnvironmentSecretRemoteRef { if s != nil { - mapping := s.Services[scope.Service] + mapping := s.MappingFor(scope.Module, scope.Service) if remote, ok := mapping.RemoteKeys[key]; ok { return remote } @@ -1170,13 +1196,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 +1336,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..7d0836e8 100644 --- a/pkg/environments/environment_test.go +++ b/pkg/environments/environment_test.go @@ -90,9 +90,11 @@ 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"), + "qualified service-secrets": serviceSecretsDeclaration("saas/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 +114,11 @@ 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"), + "qualified service-secrets": serviceSecretsDeclaration("wrong/accounts"), + "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 +130,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 +794,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..337b9e99 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 { 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..305d329f 100644 --- a/pkg/gitops/cell.go +++ b/pkg/gitops/cell.go @@ -98,16 +98,39 @@ func RenderCell(ctx context.Context, workspace *resources.Workspace, env *enviro return result, nil } +// RenderPublishedCell inventories the committed publication trees without rebuilding +// images or rewriting their signatures. Used when binding an assembled deployment branch. +func RenderPublishedCell(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) (CellResult, error) { + if workspace == nil || env == nil { + return CellResult{}, errors.New("a cell is rendered for a workspace and an environment") + } + root, err := cellRoot(workspace, env) + if err != nil { + return CellResult{}, err + } + file, result, err := deriveCellFrom(ctx, workspace, env, filepath.Join(root, "modules")) + if err != nil { + return CellResult{}, err + } + return result, writeCell(&file, result.Path) +} + // deriveCell is RenderCell without the write: the cell file the rendered // trees and the composition describe, and the path it belongs at. func deriveCell(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) (cellfile.File, CellResult, error) { + return deriveCellFrom(ctx, workspace, env, "") +} + +func deriveCellFrom(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, modulesDir string) (cellfile.File, CellResult, error) { if workspace == nil || env == nil { return cellfile.File{}, CellResult{}, errors.New("a cell is rendered for a workspace and an environment") } if env.Host == nil { return cellfile.File{}, CellResult{}, fmt.Errorf("environment %s declares no host, so it has no cell to describe", env.Name) } - modulesDir := filepath.Dir(moduleRenderDestination(workspace, "module")) + if modulesDir == "" { + modulesDir = filepath.Dir(moduleRenderDestination(workspace, "module")) + } entries, err := os.ReadDir(modulesDir) if errors.Is(err, fs.ErrNotExist) { return cellfile.File{}, CellResult{}, fmt.Errorf("no module has been rendered under %s", modulesDir) @@ -197,7 +220,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..d4cda910 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. @@ -192,6 +210,30 @@ func renderCellTreeWith(t *testing.T, options *RenderOptions, service, environme // container port the mesh authorizes and the Service port an edge route uses, // who the composition says calls them, the ingress into them, the bindings // the environment declares, and the egress a service is granted. +func TestPublishedCellUsesPublishedTreesAndRefusesMissingModules(t *testing.T) { + workspace := writeCellWorkspace(t) + env := selectedEnvironment(t, workspace, "staging") + renderCellTree(t, workspace, "shop", "api", "acme-shop", "staging") + renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "staging") + env.Gitops = &environments.EnvironmentGitops{RepoURL: "https://github.com/example/product", Branch: "deploy", Path: "deployments/published"} + publication := filepath.Join(workspace.Dir(), env.Gitops.Path) + require.NoError(t, os.MkdirAll(publication, 0o755)) + require.NoError(t, os.Rename(filepath.Join(workspace.Dir(), "deployments/modules"), filepath.Join(publication, "modules"))) + result, err := RenderPublishedCell(context.Background(), workspace, env) + require.NoError(t, err) + require.Equal(t, []string{"billing", "shop"}, result.Modules) + before, err := os.ReadFile(result.Path) + require.NoError(t, err) + _, err = RenderCell(context.Background(), workspace, env) + require.ErrorContains(t, err, "no module has been rendered") + require.NoError(t, os.RemoveAll(filepath.Join(publication, "modules/billing"))) + _, err = RenderPublishedCell(context.Background(), workspace, env) + require.ErrorContains(t, err, "billing") + after, err := os.ReadFile(result.Path) + require.NoError(t, err) + require.Equal(t, before, after, "incomplete publication must leave the last complete cell unchanged") +} + func TestCellFileInventoriesEveryRenderedWorkload(t *testing.T) { workspace := writeCellWorkspace(t) env := selectedEnvironment(t, workspace, "staging") 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..45e45e97 100644 --- a/pkg/gitops/configuration_projection.go +++ b/pkg/gitops/configuration_projection.go @@ -26,13 +26,16 @@ 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 + refs = env.ServiceSecrets.MappingFor(module, service).RemoteKeys } if len(values) == 0 && len(refs) == 0 { return nil @@ -184,13 +187,10 @@ 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 + refs = env.ServiceSecrets.MappingFor(scope.Module, service.Name).RemoteKeys } identity, err := soleWorkloadIdentity(scope.Module, service.Name, consumedManagedServices(service, scope.Module, env), env) if err != nil { 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/configuration_templates.go b/pkg/gitops/configuration_templates.go index cd79daa6..feb3a005 100644 --- a/pkg/gitops/configuration_templates.go +++ b/pkg/gitops/configuration_templates.go @@ -196,7 +196,7 @@ func producerPrimitiveRemote( // The producer's own projection takes its per-service override the same // way (serviceSecretProjection), so this is the store its ExternalSecret // actually reads from. - if override := secrets.Services[service].SecretStore; override != nil { + if override := secrets.MappingFor(module, service).SecretStore; override != nil { store = *override } } 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/gitops/publish.go b/pkg/gitops/publish.go index 00edd3c6..07ae76a2 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -2134,6 +2134,7 @@ func openOrUpdatePullRequest(ctx context.Context, prepared *preparedRepository, Head: github.Ptr(prepared.plan.PromotionBranch), Base: github.Ptr(prepared.plan.BaseBranch), Body: github.Ptr(body), + Draft: github.Ptr(true), }) if err != nil { return "", 0, fmt.Errorf("open promotion pull request: %w", err) diff --git a/pkg/gitops/secret_projection.go b/pkg/gitops/secret_projection.go index 8476ba82..9d81e525 100644 --- a/pkg/gitops/secret_projection.go +++ b/pkg/gitops/secret_projection.go @@ -140,7 +140,7 @@ func serviceSecretProjection(scope unitScope, service string, secrets *environme return nil, nil } namespace := scope.Namespace - mapping := secrets.Services[service] + mapping := secrets.MappingFor(scope.Module, service) // A service may resolve from a different store than the environment default, // mirroring EnvironmentManagedSecretReference's per-reference store; without // this the per-service secret-store declaration would load, validate, and then diff --git a/pkg/gitops/secret_projection_test.go b/pkg/gitops/secret_projection_test.go index c1188a65..5a53cfd1 100644 --- a/pkg/gitops/secret_projection_test.go +++ b/pkg/gitops/secret_projection_test.go @@ -248,6 +248,35 @@ func TestServiceSecretProjectionDefaultsAndOverridesRemoteKeys(t *testing.T) { } } +func TestServiceSecretProjectionQualifiedMappingDoesNotLeakAcrossModules(t *testing.T) { + secrets := azureServiceSecrets() + secrets.Services["saas/accounts"] = environments.EnvironmentServiceSecretMapping{ + SecretStore: &environments.EnvironmentSecretStoreReference{Name: "signing", Kind: "ClusterSecretStore"}, + RemoteKeys: map[string]environments.EnvironmentSecretRemoteRef{ + "workos-client-secret": {Key: "accounts-signing", Property: "wrapped"}, + }, + Defaults: &environments.EnvironmentSecretRemoteRef{Key: "{module}-{service}", Property: "{key}"}, + RefreshInterval: "5m", + } + for _, module := range []string{"saas", "other", "saas"} { + projection, err := serviceSecretProjection(unitScope{Namespace: "product-" + module, Module: module}, + "accounts", secrets, []string{"workos-api-key", "workos-client-secret"}) + if err != nil { + t.Fatal(err) + } + if module == "saas" { + if projection.Spec.SecretStoreRef.Name != "signing" || projection.Spec.RefreshInterval != "5m" || + projection.Spec.Data[0].RemoteRef.Key != "saas-accounts" || + projection.Spec.Data[1].RemoteRef != (externalSecretRemote{Key: "accounts-signing", Property: "wrapped"}) { + t.Fatalf("qualified mapping lost: %+v", projection.Spec) + } + } else if projection.Spec.SecretStoreRef.Name != "azure-keyvault-prod" || + projection.Spec.Data[1].RemoteRef.Key != "workos/prod/client-secret" { + t.Fatalf("qualified mapping leaked to another module: %+v", projection.Spec) + } + } +} + func TestServiceSecretProjectionRendersNothingWithoutStoreOrKeys(t *testing.T) { if projection, err := serviceSecretProjection(unitScope{Namespace: "payments"}, "accounts", nil, []string{"api-key"}); err != nil || projection != nil { t.Fatalf("nil store rendered %v (err %v)", projection, err) 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 { diff --git a/pkg/orchestration/image_cache.go b/pkg/orchestration/image_cache.go index 61d17cfa..dadf722d 100644 --- a/pkg/orchestration/image_cache.go +++ b/pkg/orchestration/image_cache.go @@ -643,17 +643,15 @@ func hashContextEntry(hasher hash.Hash, relative, path string, entry fs.DirEntry writeImageCacheRecord(hasher, "symlink", relative, filepath.ToSlash(target)) return nil case info.IsDir(): - writeImageCacheRecord(hasher, "directory", relative) + writeImageCacheRecord(hasher, "directory", relative, strconv.FormatUint(uint64(info.Mode().Perm()), 8)) return nil case !info.Mode().IsRegular(): writeImageCacheRecord(hasher, "special", relative, info.Mode().String()) return nil } - executable := "0" - if info.Mode().Perm()&0o111 != 0 { - executable = "1" - } - writeImageCacheRecord(hasher, "file", relative, executable, strconv.FormatInt(info.Size(), 10)) + // COPY preserves read and traversal permissions too: identical bytes can + // still be unreadable to the image's non-root user. + writeImageCacheRecord(hasher, "file", relative, strconv.FormatUint(uint64(info.Mode().Perm()), 8), strconv.FormatInt(info.Size(), 10)) file, err := os.Open(path) if err != nil { return err diff --git a/pkg/orchestration/image_cache_test.go b/pkg/orchestration/image_cache_test.go index c9fc3aca..b9d6c957 100644 --- a/pkg/orchestration/image_cache_test.go +++ b/pkg/orchestration/image_cache_test.go @@ -316,6 +316,12 @@ func TestEveryContextChangeMovesTheImageIdentity(t *testing.T) { "mode changed": func(t *testing.T, harness *imageCacheHarness) { require.NoError(t, os.Chmod(filepath.Join(harness.serviceDir, "code", "main.go"), 0o755)) }, + "file readability changed": func(t *testing.T, harness *imageCacheHarness) { + require.NoError(t, os.Chmod(filepath.Join(harness.serviceDir, "code", "main.go"), 0o600)) + }, + "directory traversal changed": func(t *testing.T, harness *imageCacheHarness) { + require.NoError(t, os.Chmod(filepath.Join(harness.serviceDir, "code"), 0o700)) + }, "file replaced by a symlink": func(t *testing.T, harness *imageCacheHarness) { path := filepath.Join(harness.serviceDir, "code", "main.go") require.NoError(t, os.Remove(path))