Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Cobra owns this complete generated reference; authored command docs remain visible.
/docs/cli-reference.md linguist-generated=true
5 changes: 5 additions & 0 deletions .github/workflows/generate-qualification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ jobs:
"$RUNNER_TEMP/generate-bin/buf" --version
echo "$RUNNER_TEMP/generate-bin" >> "$GITHUB_PATH"
- name: Run tagged and qualification tests in isolated processes
env:
# Core uses this read-only token for release lookups and downloads.
# Each test has its own CODEFLY_HOME, so unauthenticated lookups
# otherwise exhaust the runner's shared GitHub API quota.
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/test-generate-isolated.sh "${{ matrix.shard }}" "${{ strategy.job-total }}"
- name: Retain test output, timings and conformance receipts
if: always()
Expand Down
37 changes: 24 additions & 13 deletions cmd/environment/configuration_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package environment
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -55,22 +56,27 @@ func materializeConfigurationProfile(ctx context.Context, directory string, env
}
}
output := filepath.Join(destination, configurations.ProfileDerivationFile)
retired, err := retiredGeneratedConfigurations(destination, output)
confined, err := os.OpenRoot(configurationRoot)
if err != nil {
return err
}
defer confined.Close()
retired, err := retiredGeneratedConfigurations(confined, target)
if err != nil {
return err
}
if dryRun {
return nil
}
if err := os.MkdirAll(destination, 0o755); err != 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 := os.Remove(path); err != nil {
if err := confined.Remove(path); err != nil {
return err
}
}
Expand All @@ -89,7 +95,12 @@ func validateConfigurationSourceLayer(layer, destination string) error {
if err != nil || resolved != layer {
return fmt.Errorf("configuration source must be an ordinary profile directory")
}
entries, err := os.ReadDir(layer)
confined, err := os.OpenRoot(layer)
if err != nil {
return err
}
defer confined.Close()
entries, err := fs.ReadDir(confined.FS(), ".")
if err != nil {
return err
}
Expand All @@ -108,7 +119,7 @@ func validateConfigurationSourceLayer(layer, destination string) error {
return fmt.Errorf("configuration-source must not contain environment-specific runnable bindings")
}
if strings.HasSuffix(entry.Name(), ".secret.env") {
if err := validateSecretPlaceholders(filepath.Join(layer, entry.Name()), entry.Name()); err != nil {
if err := validateSecretPlaceholders(confined, entry.Name()); err != nil {
return err
}
}
Expand All @@ -118,8 +129,8 @@ func validateConfigurationSourceLayer(layer, destination string) error {

// validateSecretPlaceholders refuses a source secret file whose keys carry a value:
// a configuration source declares which secrets exist, never what they hold.
func validateSecretPlaceholders(path, name string) error {
body, err := os.ReadFile(path)
func validateSecretPlaceholders(confined *os.Root, name string) error {
body, err := confined.ReadFile(name)
if err != nil {
return err
}
Expand All @@ -139,13 +150,13 @@ func validateSecretPlaceholders(path, name string) error {
// retiredGeneratedConfigurations lists the generated files an earlier import left in
// the destination, which a re-import removes; it refuses to overwrite an authored one
// and a destination that is not an ordinary directory.
func retiredGeneratedConfigurations(destination, output string) ([]string, error) {
if info, err := os.Lstat(destination); err == nil && !info.IsDir() {
func retiredGeneratedConfigurations(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
}
previous, err := os.ReadDir(destination)
previous, err := fs.ReadDir(confined.FS(), target)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
Expand All @@ -154,20 +165,20 @@ func retiredGeneratedConfigurations(destination, output string) ([]string, error
if !strings.HasSuffix(entry.Name(), ".env") && entry.Name() != configurations.ProfileDerivationFile {
continue
}
path := filepath.Join(destination, entry.Name())
path := filepath.Join(target, entry.Name())
info, infoErr := entry.Info()
if infoErr != nil || !info.Mode().IsRegular() {
return nil, fmt.Errorf("generated configuration %s must be a regular file", entry.Name())
}
body, readErr := os.ReadFile(path)
body, readErr := confined.ReadFile(path)
if readErr != nil {
return nil, readErr
}
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 path != output && owned {
if entry.Name() != configurations.ProfileDerivationFile && owned {
retired = append(retired, path)
}
}
Expand Down
41 changes: 41 additions & 0 deletions cmd/environment/configuration_profile_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package environment

import (
"bytes"
"os"
"path/filepath"
"strings"
Expand All @@ -11,6 +12,46 @@ import (
"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()
Expand Down
87 changes: 65 additions & 22 deletions cmd/environment/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ full delivery.intent.yaml is not interpreted.`,
if err != nil {
return err
}
configurationSource, _ := cmd.Flags().GetString("configuration-source")
configurationProfiles, _ := cmd.Flags().GetStringSlice("configuration-profiles")

productPath, err := cmd.Flags().GetString("product-intent")
if err != nil {
Expand All @@ -108,18 +110,23 @@ full delivery.intent.yaml is not interpreted.`,
}

opts := importOptions{
dir: *dir,
envName: args[0],
contractData: data,
productIntentData: productData,
namespace: importNamespace,
namespaceSet: cmd.Flags().Changed("namespace"),
dryRun: importDryRun,
dir: *dir,
envName: args[0],
contractData: data,
productIntentData: productData,
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(),
stdout: cmd.OutOrStdout(),
}
if err := common.ResolvePinnedModules(ctx); err != nil {
return err
}
if err := runImport(ctx, &opts); err != nil {
return err
}
Expand Down Expand Up @@ -170,15 +177,17 @@ func readContract(stdin io.Reader, path string) ([]byte, error) {
}

type importOptions struct {
dir string
envName string
contractData []byte
productIntentData []byte
namespace string
namespaceSet bool
dryRun bool
now time.Time
stdout io.Writer
dir string
envName string
contractData []byte
productIntentData []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
Expand All @@ -202,6 +211,9 @@ func runImport(ctx context.Context, opts *importOptions) error {
return err
}
declaredEnvironment := yamledit.MapValue(declaration, "environment")
if profileErr := applyConfigurationProfiles(opts, contract, declaredEnvironment); profileErr != nil {
return profileErr
}

file := filepath.Join(opts.dir, resources.WorkspaceConfigurationName)
original, err := os.ReadFile(file)
Expand Down Expand Up @@ -267,14 +279,14 @@ 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.Select(merged, opts.envName)
if err != nil {
return err
}
if intent != nil {
selected, err := environments.Select(merged, opts.envName)
if err != nil {
return err
}
workspace, err := resources.LoadWorkspaceFromDir(ctx, opts.dir)
if err != nil {
return err
Expand All @@ -286,7 +298,7 @@ func runImport(ctx context.Context, opts *importOptions) error {
return fmt.Errorf("product intent: %w", err)
}
}
if err := materializeConfigurationProfile(ctx, opts.dir, env, opts.dryRun); err != nil {
if err := materializeConfigurationProfile(ctx, opts.dir, selected, opts.dryRun); err != nil {
return err
}
if opts.dryRun {
Expand Down Expand Up @@ -608,9 +620,40 @@ 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("product-intent", "", "Authored product decisions: environments.<env>.exclude-services and service-secrets generate / may-be-empty")
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")
importCmd.Flags().BoolVar(&importDryRun, "dry-run", false, "Print the unified diff of workspace.codefly.yaml and write nothing")
}

func applyConfigurationProfiles(opts *importOptions, contract *environments.CoordinateContract, declaredEnvironment *yaml.Node) error {
// 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)
}

return nil
}
10 changes: 6 additions & 4 deletions docs/cli-reference.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,19 @@ 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 staging --coordinate-contract coordinate.json \
--configuration-source hosted --configuration-profiles staging,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
Expand Down
5 changes: 4 additions & 1 deletion docs/module-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ digest and media type, verbatim. A deployed render of a release declaring
none builds the bundle from the release's own source, as it builds the
release's images: `npm ci && npm run build` in the remote's npm project
(`services/fe-remote`, else the component root), inside a digest-pinned Node
image, on the committed tree (HEAD) of the repository the component is
image, using the same environment-selected, host-networked BuildKit builder and
registry mirrors as service images. This reuses the existing builder provisioner;
the default Docker bridge can otherwise lose the operator's DNS path during npm
installation. The build runs on the committed tree (HEAD) of the repository the component is
released from: nothing untracked or ignored reaches the builder (nor do
uncommitted edits), and the module cache is never written. A component in no
repository builds from its own directory alone. It archives `services/fe-remote/dist` in core's
Expand Down
Loading
Loading