diff --git a/cmd/doctor_workspace_hop_test.go b/cmd/doctor_workspace_hop_test.go index 25ab320f..0b9c54d7 100644 --- a/cmd/doctor_workspace_hop_test.go +++ b/cmd/doctor_workspace_hop_test.go @@ -67,9 +67,34 @@ func TestDoctorWorkspaceRecognizesGeneratedHopRoutes(t *testing.T) { } } +// A composition that names no module for the hop is READY: the declaring +// service receives hop-routes with an empty ROUTES, which the doctor reads +// from the same resolved inventory the run resolves (checkConfigurationSources +// takes generated groups from there, "never from a name exemption"), so +// nothing reports the group missing. +// +// This replaces a baselined refusal: `{"no selected modules", …, +// codeConfigurationMissing, "hop-routes"}` in the table below asserted that +// this composition fails the doctor. That refusal was the symptom of the +// delivery gap, not a rule — a host that stands a hop only on some +// compositions (saas's gateway on a cell) declares the group always and must +// start where it stands no hop — so the case is replaced by this one rather +// than kept green. +func TestDoctorWorkspaceAcceptsAHopThatSelectsNoModule(t *testing.T) { + dir := doctorHopWorkspace(t) + require.NoError(t, os.Remove(filepath.Join(dir, "configurations/local/hop.env"))) + before := snapshotTree(t, dir) + for _, opts := range []workspaceReadinessOptions{{dir: dir}, {dir: dir, service: "authority/hop"}} { + report := runReadiness(t, opts) + require.Equal(t, readinessStatusReady, report.Status, reportJSON(t, report)) + requireNoCode(t, report, codeConfigurationMissing) + requireNoCode(t, report, codeConfigurationDirMissing) + } + require.Equal(t, before, snapshotTree(t, dir), "doctor must not materialize an empty route table") +} + func TestDoctorWorkspaceStillRefusesInvalidHopInputs(t *testing.T) { for _, tc := range []struct{ name, path, content, code, reason string }{ - {"no selected modules", "configurations/local/hop.env", "", codeConfigurationMissing, "hop-routes"}, {"missing catalog", "modules/records/" + composition.APIContractCatalogFileName, "", codeConfigurationInvalid, "not checked"}, {"bad principal", "modules/records/module.contract.codefly.yaml", "schema: codefly/module-contract/v1\nprincipal: other\n", codeConfigurationInvalid, "principal"}, {"authored routes", "configurations/local/hop-routes.env", "ROUTES={}\n", codeConfigurationInvalid, "remove its authored configuration"}, diff --git a/cmd/generate.go b/cmd/generate.go index 075fe18d..2c22ff69 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -19,6 +19,7 @@ func init() { GenerateCmd.AddCommand(generate.PermissionsCmd) GenerateCmd.AddCommand(generate.RunnablesCmd) GenerateCmd.AddCommand(generate.RunnableBindingsCmd) + GenerateCmd.AddCommand(generate.FixtureCompositionCmd) GenerateCmd.AddCommand(generate.ClientCmd) GenerateCmd.AddCommand(generate.TenantOverlaysCmd) } diff --git a/cmd/generate/fixture_composition.go b/cmd/generate/fixture_composition.go new file mode 100644 index 00000000..7c5515a2 --- /dev/null +++ b/cmd/generate/fixture_composition.go @@ -0,0 +1,75 @@ +package generate + +import ( + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/fixturefragments" + "github.com/spf13/cobra" +) + +var fixtureCompositionEnv string + +// FixtureCompositionCmd writes the local authority's fixture-composition +// document for one environment, as `codefly run` does before the authority +// boots. +var FixtureCompositionCmd = &cobra.Command{ + Use: "fixture-composition", + Short: "Merge every composed module's fixture-composition fragment into the workspace's document", + Long: `Merge the fixture-composition fragment every module of the workspace ships +(configurations//` + fixturefragments.Group + `.yaml in the module's own directory) +into the workspace's configurations//` + fixturefragments.Group + `.yaml, the document +the local authority reads. + +Each module states, once, what the local authority's standard fixture roles may +do at its kinds; the composition's own module states only what is its own (a +collection its seed writes). Every grant of a role on one kind, every +installation and every stand-in module has exactly one source: two fragments +stating the same one are refused, naming both. The rules are the authority's +(codefly-dev/authority libraries/fixture-composition). + +` + "`codefly run`" + ` writes the same document before the local authority boots, so a +developer never runs this first. The written file says it is generated in its +first line; a hand-written document is never overwritten, and this command +refuses it by name. + +Examples: + codefly generate fixture-composition + codefly generate fixture-composition --env local`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + ctx, done := common.NewContext() + defer done() + // Fragments ship inside the composed modules, so a pinned module is + // materialized first, as a run does. + workspace, err := common.LoadWorkspaceWithPinnedModules(ctx) + if err != nil { + return err + } + env, err := environments.Select(workspace, fixtureCompositionEnv) + if err != nil { + return err + } + prepared, err := fixturefragments.Prepare(ctx, workspace, env) + if err != nil { + return err + } + changed, err := fixturefragments.Write(prepared) + if err != nil { + return err + } + verb := "is current" + if changed { + verb = "written" + } + cli.Header(1, "%s %s, from %d fragment(s)", prepared.Target, verb, len(prepared.Sources)) + for _, source := range prepared.Sources { + cli.Info(" %s", source) + } + return nil + }, +} + +func init() { + FixtureCompositionCmd.Flags().StringVar(&fixtureCompositionEnv, "env", "local", "environment whose configuration profile the document is written for") +} diff --git a/cmd/run/fixture_composition.go b/cmd/run/fixture_composition.go new file mode 100644 index 00000000..7d3f2831 --- /dev/null +++ b/cmd/run/fixture_composition.go @@ -0,0 +1,44 @@ +package run + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/codefly-dev/core/resources" + + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/fixturefragments" +) + +// provisionFixtureComposition writes, before a run loads its configuration, +// the local authority's fixture-composition document: the merge of the +// fragment every module of the workspace ships (fixturefragments), which +// `codefly generate fixture-composition` writes on its own. Nothing is written +// unless a service declares the group. A conflict between two fragments +// refuses the run naming both. A hand-written document is never overwritten: +// the run reads it as it stands and says that no module's fragment is applied. +func provisionFixtureComposition(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) error { + declared, err := fixturefragments.Declared(ctx, workspace) + if err != nil || !declared { + return err + } + prepared, err := fixturefragments.Prepare(ctx, workspace, env) + if err != nil { + return fmt.Errorf("%s: %w", fixturefragments.Group, err) + } + changed, err := fixturefragments.Write(prepared) + if errors.Is(err, fixturefragments.ErrHandWritten) { + cli.Warning("%v. This run reads it as it stands: no module's fragment is applied", err) + return nil + } + if err != nil { + return err + } + if changed { + cli.Info("merged %s from %d fragment(s): %s", prepared.Target, len(prepared.Sources), strings.Join(prepared.Sources, ", ")) + } + return nil +} diff --git a/cmd/run/fixture_composition_test.go b/cmd/run/fixture_composition_test.go new file mode 100644 index 00000000..39e6f9a5 --- /dev/null +++ b/cmd/run/fixture_composition_test.go @@ -0,0 +1,58 @@ +package run + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/fixturefragments" +) + +// A run writes the merged fixture-composition document before anything reads +// the configuration, and leaves a hand-written one as it stands. +func TestARunMergesTheFixtureCompositionAndKeepsAHandWrittenOne(t *testing.T) { + dir := t.TempDir() + head := "schema: codefly.dev/authority/fixture-composition/v1\n" + for name, content := range map[string]string{ + "workspace.codefly.yaml": "name: playground\nlayout: modules\nmodules:\n - name: authority\n - name: store\nenvironments:\n - name: local\n", + "modules/authority/module.codefly.yaml": "kind: module\nname: authority\nservices:\n - name: local\n", + "modules/authority/services/local/service.codefly.yaml": "name: local\nversion: 0.1.0\nworkspace-configuration-dependencies:\n - " + fixturefragments.Group + "\n", + "modules/store/module.codefly.yaml": "kind: module\nname: store\n", + "modules/store/configurations/local/" + fixturefragments.Group + ".yaml": head + "roles: [{organization: acme, name: viewer, scopes: [{kind: documents, actions: [read]}]}]\n", + } { + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + } + cmd := exec.Command("git", "init", "-q") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git init: %s", out) + ctx := context.Background() + workspace, err := resources.LoadWorkspaceFromDir(ctx, dir) + require.NoError(t, err) + env := &environments.Environment{Name: "local"} + target := filepath.Join(dir, "configurations", "local", fixturefragments.Group+".yaml") + + handWritten := head + "# the playground's own copy\n" + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + require.NoError(t, os.WriteFile(target, []byte(handWritten), 0o600)) + require.NoError(t, provisionFixtureComposition(ctx, workspace, env)) + kept, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, handWritten, string(kept)) + + require.NoError(t, os.Remove(target)) + require.NoError(t, provisionFixtureComposition(ctx, workspace, env)) + written, err := os.ReadFile(target) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(written), "# Generated by codefly"), string(written)) + require.Contains(t, string(written), "kind: documents") +} diff --git a/cmd/run/service.go b/cmd/run/service.go index 394d74e8..976deda7 100644 --- a/cmd/run/service.go +++ b/cmd/run/service.go @@ -893,6 +893,12 @@ func newRunFlow(ctx context.Context, workspace *resources.Workspace, module *res return nil, w.Wrap(err) } + // The local authority's fixture-composition document is merged from the + // composed modules' fragments before anything reads the configuration. + if fixtureErr := provisionFixtureComposition(ctx, workspace, env); fixtureErr != nil { + return nil, w.Wrap(fixtureErr) + } + // Parse remote services remoteServices, err := parseRemote(workspace, remotes) if err != nil { diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b8e410f9..75b1df39 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2731,6 +2731,7 @@ Subcommands: - [`codefly generate client`](#codefly-generate-client) - [`codefly generate contracts`](#codefly-generate-contracts) +- [`codefly generate fixture-composition`](#codefly-generate-fixture-composition) - [`codefly generate permissions`](#codefly-generate-permissions) - [`codefly generate proto`](#codefly-generate-proto) - [`codefly generate runnable-bindings`](#codefly-generate-runnable-bindings) @@ -2816,6 +2817,43 @@ Flags: --permissions also generate typed permission constants; with --check, verify their freshness ``` +## `codefly generate fixture-composition` + +Merge every composed module's fixture-composition fragment into the workspace's document + +``` +Merge the fixture-composition fragment every module of the workspace ships +(configurations//fixture-composition.yaml in the module's own directory) +into the workspace's configurations//fixture-composition.yaml, the document +the local authority reads. + +Each module states, once, what the local authority's standard fixture roles may +do at its kinds; the composition's own module states only what is its own (a +collection its seed writes). Every grant of a role on one kind, every +installation and every stand-in module has exactly one source: two fragments +stating the same one are refused, naming both. The rules are the authority's +(codefly-dev/authority libraries/fixture-composition). + +`codefly run` writes the same document before the local authority boots, so a +developer never runs this first. The written file says it is generated in its +first line; a hand-written document is never overwritten, and this command +refuses it by name. + +Examples: + codefly generate fixture-composition + codefly generate fixture-composition --env local +``` + +``` +codefly generate fixture-composition [flags] +``` + +Flags: + +``` + --env string environment whose configuration profile the document is written for (default "local") +``` + ## `codefly generate permissions` Generate typed Go and TypeScript permissions from published contracts diff --git a/go.mod b/go.mod index fd78cd43..513ccffe 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026143932-67b85b14a942 github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261026143932-67b85b14a942 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 + github.com/codefly-dev/authority/libraries/fixture-composition/go v0.0.0-20261027171336-5178f01696e3 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855 github.com/codefly-dev/core v0.19.7-0.20261026194550-a8599bd3991a github.com/codefly-dev/golor v0.1.3 diff --git a/go.sum b/go.sum index 1b8b7e3f..47505b10 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-202610261439 github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261026143932-67b85b14a942/go.mod h1:n+JVNRyv2dp7BIwUoKYn3TcjN7YpZmxMhBrWp14wsKQ= github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 h1:PnhgA9wkib67bJnc5hkhkc80J2ch5uYiq+9/NiD/XFU= github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/fixture-composition/go v0.0.0-20261027171336-5178f01696e3 h1:UGR5uQAPBO9YtTFCDWEfXeTgKBYU8vwSFaqza3gT9+U= +github.com/codefly-dev/authority/libraries/fixture-composition/go v0.0.0-20261027171336-5178f01696e3/go.mod h1:RxoEMLDVDjAdTwHA7cDVQ9CwYGEuLjTMsf8HdPaHrho= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855 h1:XmPltOHirB97RopD8WIJe9bwt1wkHAzngKoj8k2XI8U= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855/go.mod h1:rfn41uiORQ36RrOeFEjXHPLC9NUi0j14AoYJtcovsn4= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE= diff --git a/pkg/fixturefragments/fixturefragments.go b/pkg/fixturefragments/fixturefragments.go new file mode 100644 index 00000000..93c31239 --- /dev/null +++ b/pkg/fixturefragments/fixturefragments.go @@ -0,0 +1,176 @@ +// Package fixturefragments builds the local authority's fixture-composition +// document for a run: the merge of the fragment every module of the workspace +// ships next to its code. Each module states, once, what the local +// authority's standard fixture roles may do at its kinds +// (/configurations//fixture-composition.yaml); the +// composition's own module states only what is the composition's own (a +// collection its seed writes). The rules of the document and of the merge are +// the authority's (codefly-dev/authority libraries/fixture-composition): this +// package finds the fragments and writes the result where a service that +// declares the group reads it, as the workspace's own document. +package fixturefragments + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + fixturecomposition "github.com/codefly-dev/authority/libraries/fixture-composition/go" + "github.com/codefly-dev/core/configurations" + "github.com/codefly-dev/core/resources" + + "github.com/codefly-dev/cli/pkg/environments" +) + +// Group is the workspace configuration group the merged document is written +// as, and the file name of every fragment. +const Group = fixturecomposition.Name + +// generatedMarker starts the first line of every document this package +// writes; a file that does not start with it is hand-written and is never +// overwritten. +const generatedMarker = "# Generated by codefly from every composed module's fixture-composition fragment." + +// ErrHandWritten is a workspace document that does not carry the generated +// marker: a composition's hand-written copy, which a merge never overwrites. +var ErrHandWritten = errors.New("the workspace's fixture-composition document is hand-written") + +// Prepared is one environment's merged document and where it goes. +type Prepared struct { + // Target is the workspace's own configurations//fixture-composition.yaml. + Target string + // Sources names every fragment merged, sorted. + Sources []string + // Content is the document, generated marker first. + Content []byte +} + +// Declared reports whether a service of the workspace declares the group as a +// workspace configuration dependency: only then is there a reader for it. +func Declared(ctx context.Context, workspace *resources.Workspace) (bool, error) { + refs, err := workspace.LoadServiceWithModules(ctx) + if err != nil { + return false, err + } + for _, ref := range refs { + service, err := workspace.LoadService(ctx, ref) + if err != nil { + return false, err + } + if slices.Contains(service.WorkspaceConfigurationDependencies, Group) { + return true, nil + } + } + return false, nil +} + +// Prepare merges the fragment of every module of the workspace for the +// environment's configuration profiles. A module's fragment is read from the +// module's own directory, under the first profile of the chain that the +// module has a directory for, as the module's other configurations are. A +// conflict between two fragments is refused naming both. +func Prepare(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) (*Prepared, error) { + profile, err := env.ConfigurationProfileName() + if err != nil { + return nil, err + } + resource, err := env.Resource() + if err != nil { + return nil, err + } + profiles, err := resource.ConfigurationProfileNames() + if err != nil { + return nil, err + } + target := filepath.Join(workspace.Dir(), "configurations", profile, Group+".yaml") + modules, err := workspace.LoadModules(ctx) + if err != nil { + return nil, err + } + var sources []fixturecomposition.Source + for _, module := range modules { + dir, found, dirErr := configurations.ProfileDirectory(ctx, module.Dir(), "configurations", profiles) + if dirErr != nil { + return nil, fmt.Errorf("module %s: %w", module.Name, dirErr) + } + if !found { + continue + } + path := filepath.Join(dir, Group+".yaml") + // A module at the workspace root (a flat layout) has the output as its + // own configuration: it is what is written, never a fragment. + if resources.SameDir(filepath.Dir(path), filepath.Dir(target)) { + continue + } + data, readErr := os.ReadFile(path) + if errors.Is(readErr, os.ErrNotExist) { + continue + } + if readErr != nil { + return nil, fmt.Errorf("module %s: %w", module.Name, readErr) + } + sources = append(sources, fixturecomposition.Source{Name: sourceName(workspace, module.Name, path), Data: data}) + } + merged, err := fixturecomposition.Merge(sources) + if err != nil { + return nil, err + } + encoded, err := fixturecomposition.Encode(merged) + if err != nil { + return nil, err + } + names := make([]string, 0, len(sources)) + for _, source := range sources { + names = append(names, source.Name) + } + slices.Sort(names) + var content bytes.Buffer + content.WriteString(generatedMarker + " DO NOT EDIT: change a module's fragment.\n") + content.WriteString("# `codefly run` writes it before the local authority boots; `codefly generate fixture-composition` writes it on its own.\n") + if len(names) == 0 { + content.WriteString("# No composed module ships a fragment.\n") + } + for _, name := range names { + content.WriteString("# " + name + "\n") + } + content.Write(encoded) + return &Prepared{Target: target, Sources: names, Content: content.Bytes()}, nil +} + +// sourceName is how a fragment is named in a conflict and in the document: +// the module, and where its fragment is, relative to the workspace when it is +// inside it. +func sourceName(workspace *resources.Workspace, module, path string) string { + if rel, err := filepath.Rel(workspace.Dir(), path); err == nil && !strings.HasPrefix(rel, "..") { + path = rel + } + return fmt.Sprintf("module %s (%s)", module, path) +} + +// Write writes the document unless the workspace holds a hand-written one, +// which it never overwrites (ErrHandWritten). It reports whether the file +// changed. +func Write(prepared *Prepared) (bool, error) { + existing, err := os.ReadFile(prepared.Target) + switch { + case errors.Is(err, os.ErrNotExist): + case err != nil: + return false, err + case !bytes.HasPrefix(existing, []byte(generatedMarker)): + return false, fmt.Errorf("%w: %s; move each module's grants into that module's fragment and what is the composition's own into its own module's, then delete it", ErrHandWritten, prepared.Target) + case bytes.Equal(existing, prepared.Content): + return false, nil + } + if err := os.MkdirAll(filepath.Dir(prepared.Target), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(prepared.Target, prepared.Content, 0o600); err != nil { + return false, err + } + return true, nil +} diff --git a/pkg/fixturefragments/fixturefragments_test.go b/pkg/fixturefragments/fixturefragments_test.go new file mode 100644 index 00000000..203f2ff7 --- /dev/null +++ b/pkg/fixturefragments/fixturefragments_test.go @@ -0,0 +1,163 @@ +package fixturefragments_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + fixturecomposition "github.com/codefly-dev/authority/libraries/fixture-composition/go" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/fixturefragments" +) + +const head = "schema: codefly.dev/authority/fixture-composition/v1\n" + +// composition is a workspace composing the authority (whose local service +// declares the group), a store and a chat module, and its own playground +// module; files adds or replaces files under it. +func composition(t *testing.T, files map[string]string) *resources.Workspace { + t.Helper() + dir := t.TempDir() + base := map[string]string{ + "workspace.codefly.yaml": "name: playground\nlayout: modules\nmodules:\n - name: authority\n - name: store\n - name: chat\n - name: playground\nenvironments:\n - name: local\n", + "modules/authority/module.codefly.yaml": "kind: module\nname: authority\nservices:\n - name: local\n", + "modules/authority/services/local/service.codefly.yaml": "name: local\nversion: 0.1.0\nworkspace-configuration-dependencies:\n - " + fixturefragments.Group + "\n", + "modules/store/module.codefly.yaml": "kind: module\nname: store\n", + "modules/chat/module.codefly.yaml": "kind: module\nname: chat\n", + "modules/playground/module.codefly.yaml": "kind: module\nname: playground\n", + "modules/authority/configurations/local/" + fixturefragments.Group + ".yaml": head, + "modules/store/configurations/local/" + fixturefragments.Group + ".yaml": head + `roles: + - organization: acme + name: viewer + scopes: + - {kind: documents, actions: [read]} + - organization: acme + name: admin + scopes: + - {kind: documents.collection, actions: [read, admin]} +`, + "modules/chat/configurations/local/" + fixturefragments.Group + ".yaml": head + `roles: + - organization: acme + name: viewer + scopes: + - {kind: robin.conversations, actions: [read]} +`, + "modules/playground/configurations/local/" + fixturefragments.Group + ".yaml": head + `roles: + - organization: acme + name: viewer + scopes: + - kind: documents.collection + actions: [read] + resources: [{id: "derived\x1fplayground"}] +`, + } + for name, content := range files { + base[name] = content + } + for name, content := range base { + if content == "" { + continue + } + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + } + cmd := exec.Command("git", "init", "-q") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git init: %s", out) + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), dir) + require.NoError(t, err) + return workspace +} + +// Two modules' fragments and the composition's own merge into the workspace's +// document, which says it is generated and reads back as the merge; a second +// write changes nothing. +func TestTheRunMergesEveryComposedModulesFragment(t *testing.T) { + ctx := context.Background() + workspace := composition(t, nil) + declared, err := fixturefragments.Declared(ctx, workspace) + require.NoError(t, err) + require.True(t, declared) + + prepared, err := fixturefragments.Prepare(ctx, workspace, &environments.Environment{Name: "local"}) + require.NoError(t, err) + require.Equal(t, filepath.Join(workspace.Dir(), "configurations", "local", fixturefragments.Group+".yaml"), prepared.Target) + require.Equal(t, []string{ + "module authority (modules/authority/configurations/local/fixture-composition.yaml)", + "module chat (modules/chat/configurations/local/fixture-composition.yaml)", + "module playground (modules/playground/configurations/local/fixture-composition.yaml)", + "module store (modules/store/configurations/local/fixture-composition.yaml)", + }, prepared.Sources) + + changed, err := fixturefragments.Write(prepared) + require.NoError(t, err) + require.True(t, changed) + written, err := os.ReadFile(prepared.Target) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(written), "# Generated by codefly"), string(written)) + doc, err := fixturecomposition.Decode(written) + require.NoError(t, err) + var viewer []string + for _, role := range doc.Roles { + if role.Key() == "acme/viewer" { + for _, scope := range role.Scopes { + viewer = append(viewer, scope.Kind) + } + } + } + require.Equal(t, []string{"robin.conversations", "documents.collection", "documents"}, viewer) + + changed, err = fixturefragments.Write(prepared) + require.NoError(t, err) + require.False(t, changed) +} + +// A composition restating a module's grant is refused naming both fragments. +func TestAConflictBetweenFragmentsNamesBoth(t *testing.T) { + workspace := composition(t, map[string]string{ + "modules/playground/configurations/local/" + fixturefragments.Group + ".yaml": head + `roles: + - organization: acme + name: admin + scopes: + - {kind: documents.collection, actions: [read]} +`, + }) + _, err := fixturefragments.Prepare(context.Background(), workspace, &environments.Environment{Name: "local"}) + require.ErrorIs(t, err, fixturecomposition.ErrConflict) + require.Contains(t, err.Error(), "acme/admin's grant on documents.collection is stated by module playground (modules/playground/configurations/local/fixture-composition.yaml) and by module store (modules/store/configurations/local/fixture-composition.yaml)") +} + +// A hand-written workspace document is never overwritten. +func TestAHandWrittenDocumentIsNeverOverwritten(t *testing.T) { + handWritten := head + "# the playground's own copy\n" + workspace := composition(t, map[string]string{ + "configurations/local/" + fixturefragments.Group + ".yaml": handWritten, + }) + prepared, err := fixturefragments.Prepare(context.Background(), workspace, &environments.Environment{Name: "local"}) + require.NoError(t, err) + _, err = fixturefragments.Write(prepared) + require.True(t, errors.Is(err, fixturefragments.ErrHandWritten), err) + require.Contains(t, err.Error(), prepared.Target) + kept, err := os.ReadFile(prepared.Target) + require.NoError(t, err) + require.Equal(t, handWritten, string(kept)) +} + +// A workspace no service of which declares the group gets nothing written. +func TestNothingIsDeclaredWithoutTheAuthority(t *testing.T) { + workspace := composition(t, map[string]string{ + "modules/authority/services/local/service.codefly.yaml": "name: local\nversion: 0.1.0\n", + }) + declared, err := fixturefragments.Declared(context.Background(), workspace) + require.NoError(t, err) + require.False(t, declared) +} diff --git a/pkg/orchestration/hop_configurations.go b/pkg/orchestration/hop_configurations.go index 36a9e6b1..f33d52c4 100644 --- a/pkg/orchestration/hop_configurations.go +++ b/pkg/orchestration/hop_configurations.go @@ -16,7 +16,12 @@ import ( "github.com/codefly-dev/core/resources" ) -const hopRoutesGroup = "hop-routes" +const ( + hopRoutesGroup = "hop-routes" + // hopRoutesKey is the one value of that group: the route table, or empty + // where the composition names no module for the hop. + hopRoutesKey = "ROUTES" +) // WorkspaceConfigurationReader adds a generated, non-root group to the ordinary Core // reader. Both planning and delivery see its unresolved endpoint references, @@ -73,7 +78,7 @@ func hopRoutingSnapshot(config *basev0.Configuration) string { for _, info := range config.GetInfos() { if info.GetName() == hopRoutesGroup { for _, value := range info.GetConfigurationValues() { - if value.GetKey() == "ROUTES" { + if value.GetKey() == hopRoutesKey { return value.GetValue() } } @@ -100,9 +105,6 @@ func generatedHopRoutes(ctx context.Context, workspace *resources.Workspace, con } } } - if listed == "" { - return nil, nil - } services, err := workspace.LoadServices(ctx) if err != nil { return nil, err @@ -112,6 +114,19 @@ func generatedHopRoutes(ctx context.Context, workspace *resources.Workspace, con }) { return nil, nil } + // A declared group that resolves to nothing is an error in Core's reader + // (GetWorkspaceDependenciesConfigurations), raised from the DECLARATION at + // runtime-init, before any of the service's code runs — so a host that + // stands a hop on some compositions and not others cannot degrade, and + // cannot stop declaring the group either, since generation requires a + // declaring service. It is handed the EMPTY view instead: nothing decodes + // an empty ROUTES (a host stands its hop only where the hop group names + // modules), the routing snapshot of an empty value is the same as of no + // group at all, so no run is told its routes changed, and a composition + // that does name modules still receives the routes generated below. + if listed == "" { + return emptyHopRoutes(), nil + } routes := routing.Routing{Schema: routing.RoutingSchema} modules := strings.Split(listed, ",") for i := range modules { @@ -139,7 +154,15 @@ func generatedHopRoutes(ctx context.Context, workspace *resources.Workspace, con if err != nil { return nil, err } - return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{Name: hopRoutesGroup, ConfigurationValues: []*basev0.ConfigurationValue{{Key: "ROUTES", Value: string(raw)}}}}}, nil + return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{Name: hopRoutesGroup, ConfigurationValues: []*basev0.ConfigurationValue{{Key: hopRoutesKey, Value: string(raw)}}}}}, nil +} + +// emptyHopRoutes is the hop-routes group holding no route: what a service +// declaring the group receives where the composition names no module for it. +func emptyHopRoutes() *basev0.Configuration { + return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{ + Name: hopRoutesGroup, ConfigurationValues: []*basev0.ConfigurationValue{{Key: hopRoutesKey, Value: ""}}, + }}} } func publishedHopEndpoints(ctx context.Context, module *resources.Module) ([]routing.RouteEndpoint, error) { diff --git a/pkg/orchestration/hop_rpc_routes_test.go b/pkg/orchestration/hop_rpc_routes_test.go index 87e25281..5d37f15e 100644 --- a/pkg/orchestration/hop_rpc_routes_test.go +++ b/pkg/orchestration/hop_rpc_routes_test.go @@ -146,3 +146,62 @@ func TestHopRoutesRefuseForeignMethodsTheyCannotPlace(t *testing.T) { _, err = hopRoutesOf(t, rpcHopWorkspace(t, []string{"chat", "host"}, nil)) require.NoError(t, err) } + +// A host that stands a hop on some compositions and not others declares +// hop-routes always: saas's gateway is the hop on a cell and stands none where +// the composition names no module for it. Core's reader raises "no +// configuration found" from the DECLARATION, at runtime-init before any of the +// service's code runs, so the group must be delivered — EMPTY — rather than +// omitted. A composition that does name modules still receives its routes, and +// a workspace no service of which declares the group still receives nothing. +func TestHopRoutesAreDeliveredEmptyWhenNoModuleIsNamed(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + workspace := rpcHopWorkspace(t, []string{"chat"}, nil) + hop := func(modules string) []*basev0.Configuration { + return []*basev0.Configuration{{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{ + Name: "hop", ConfigurationValues: []*basev0.ConfigurationValue{{Key: "MODULES", Value: modules}}, + }}}} + } + + // No module named, and no hop group at all: the declaring service is handed + // the group with an empty ROUTES, so its declaration resolves. + for name, configs := range map[string][]*basev0.Configuration{ + "empty hop/MODULES": hop(""), + "no hop group": nil, + } { + t.Run(name, func(t *testing.T) { + group, err := generatedHopRoutes(context.Background(), workspace, configs) + require.NoError(t, err) + require.NotNil(t, group, "a declared group delivered as nothing is refused by core's reader at runtime-init") + require.Len(t, group.GetInfos(), 1) + require.Equal(t, hopRoutesGroup, group.GetInfos()[0].GetName()) + values := group.GetInfos()[0].GetConfigurationValues() + require.Len(t, values, 1) + require.Equal(t, "ROUTES", values[0].GetKey()) + require.Empty(t, values[0].GetValue()) + // The group defines a value, so `codefly doctor workspace` reads it + // from the same resolved inventory as the run instead of reporting + // the group missing, and the routing snapshot is unchanged from no + // group at all, so no run is told its routes moved. + require.Empty(t, hopRoutingSnapshot(group)) + }) + } + + // Naming a module still generates that module's routes. + routes, err := hopRoutesOf(t, workspace) + require.NoError(t, err) + require.Len(t, routes.Endpoints, 2) + + // A workspace no service of which declares the group receives nothing, + // empty or not: there is no reader to deliver it to. + none := writeTempWorkspace(t, map[string]string{ + "workspace.codefly.yaml": "name: routes\nlayout: modules\nmodules:\n - name: authority\n", + "modules/authority/module.codefly.yaml": "name: authority\nservices:\n - name: hop\n", + "modules/authority/services/hop/service.codefly.yaml": "name: hop\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\n", + }) + for _, configs := range [][]*basev0.Configuration{hop(""), hop("owner"), nil} { + group, groupErr := generatedHopRoutes(context.Background(), none, configs) + require.NoError(t, groupErr) + require.Nil(t, group) + } +}