Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions cmd/deploy/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions cmd/environment/configuration_profile.go
Original file line number Diff line number Diff line change
@@ -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
}
177 changes: 177 additions & 0 deletions cmd/environment/configuration_profile_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Loading