diff --git a/cmd/doctor_workspace.go b/cmd/doctor_workspace.go index 6717e468..11f54387 100644 --- a/cmd/doctor_workspace.go +++ b/cmd/doctor_workspace.go @@ -1098,6 +1098,13 @@ func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env // whose references are checked below, never from a name exemption. if checked != nil { for _, info := range checked.Infos { + // A group this invocation supplies is not ambiguous, whoever + // supplies it: core reports fixture-composition ambiguous + // because every composed module ships a fragment of it, and + // the merge of those fragments is what the resolution + // supplies. Keyed on what was supplied rather than on a name, + // so a second generated group needs no second exemption. + delete(provided.Ambiguous, info.Name) if _, authored := byName[info.Name]; authored { continue } diff --git a/cmd/doctor_workspace_fixture_composition_test.go b/cmd/doctor_workspace_fixture_composition_test.go new file mode 100644 index 00000000..ecdbe193 --- /dev/null +++ b/cmd/doctor_workspace_fixture_composition_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/fixturefragments" + "github.com/stretchr/testify/require" +) + +const fixtureFragmentHead = "schema: codefly.dev/authority/fixture-composition/v1\n" + +// twoFragmentWorkspace composes two modules from OUTSIDE the workspace tree — +// as a pinned module materializes, which is what platform-core does — each +// shipping a fixture-composition fragment, which is what every module composed +// since cli#1197 does, plus a service that declares the group as the local +// authority does. An in-repo module's configurations belong to the consuming +// workspace root, so only a foreign module can offer a group at all. +func twoFragmentWorkspace(t *testing.T, extra map[string]string) string { + t.Helper() + authority, documents := filepath.Join(t.TempDir(), "authority"), filepath.Join(t.TempDir(), "documents") + files := map[string]string{ + "workspace.codefly.yaml": "name: two-fragments\nlayout: modules\nmodules:\n - name: authority\n path: " + authority + + "\n - name: documents\n path: " + documents + "\nenvironments:\n - name: local\n", + } + for rel, content := range map[string]string{ + "module.codefly.yaml": "kind: module\nname: authority\nservices:\n - name: local\n", + "services/local/service.codefly.yaml": testServiceYAML("local", fixturefragments.Group), + "configurations/local/" + fragmentFile: fixtureFragmentHead, + } { + writeFileUnder(t, authority, rel, content) + } + for rel, content := range map[string]string{ + "module.codefly.yaml": "kind: module\nname: documents\nservices:\n - name: documents\n", + "services/documents/service.codefly.yaml": testServiceYAML("documents"), + "configurations/local/" + fragmentFile: fixtureFragmentHead + `roles: + - organization: acme + name: viewer + scopes: + - {kind: documents, actions: [read]} +`, + } { + writeFileUnder(t, documents, rel, content) + } + for k, v := range extra { + switch { + case strings.HasPrefix(k, "authority/"): + writeFileUnder(t, authority, strings.TrimPrefix(k, "authority/"), v) + case strings.HasPrefix(k, "documents/"): + writeFileUnder(t, documents, strings.TrimPrefix(k, "documents/"), v) + default: + files[k] = v + } + } + return writeTestWorkspace(t, files) +} + +const fragmentFile = fixturefragments.Group + ".yaml" + +func writeFileUnder(t *testing.T, dir, rel, content string) { + t.Helper() + p := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(content), 0o644)) +} + +// Two composed modules shipping a fixture-composition fragment is the design, +// not a conflict: the run merges them. Readiness must agree with the run. +func TestDoctorWorkspaceAcceptsAFragmentFromEveryComposedModule(t *testing.T) { + dir := twoFragmentWorkspace(t, nil) + report := runReadiness(t, workspaceReadinessOptions{dir: dir}) + require.Equal(t, readinessStatusReady, report.Status, reportJSON(t, report)) + requireNoCode(t, report, codeConfigurationDuplicate) + requireNoCode(t, report, codeConfigurationMissing) +} + +// Every other workspace configuration keeps the single-provider rule. +func TestDoctorWorkspaceStillRefusesTwoProvidersOfAnOrdinaryGroup(t *testing.T) { + dir := twoFragmentWorkspace(t, map[string]string{ + "authority/services/local/service.codefly.yaml": testServiceYAML("local", fixturefragments.Group, "engine"), + "authority/configurations/local/engine.env": "MODE=fast\n", + "documents/configurations/local/engine.env": "MODE=slow\n", + }) + report := runReadiness(t, workspaceReadinessOptions{dir: dir}) + require.Equal(t, readinessStatusNotReady, report.Status, reportJSON(t, report)) + d := requireCode(t, report, codeConfigurationDuplicate, "fail") + require.True(t, strings.Contains(d.Message, "engine"), d.Message) + require.False(t, strings.Contains(d.Message, fixturefragments.Group), d.Message) +} diff --git a/cmd/run/native_delivery_schedule_test.go b/cmd/run/native_delivery_schedule_test.go index 3d3279ec..d973b16f 100644 --- a/cmd/run/native_delivery_schedule_test.go +++ b/cmd/run/native_delivery_schedule_test.go @@ -15,6 +15,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" @@ -250,20 +251,28 @@ func TestALegacyHTTPDeliveryRefusedWith503IsRetriedUntilAccepted(t *testing.T) { // At the deadline the error carries why the host is still not serving and the // last refusal it answered. func TestANativeDeliveryAtTheDeadlineNamesTheReasonAndTheLastRefusal(t *testing.T) { - refusing := deliverTo(t, serveComposition(t, &refusingComposition{refusals: []codes.Code{codes.Unavailable, codes.Unavailable, codes.Unavailable, codes.Unavailable}})) - ready := true - delivery := testDelivery(func(context.Context) error { - if ready { - ready = false - return nil - } - return errors.New("the host is not ready: saas/auth-gateway: lifecycle: runtime start has not completed") - }, refusing) - delivery.schedule.Deadline = 30 * time.Millisecond - err := delivery.run(context.Background()) - require.ErrorContains(t, err, "configured bound 30ms") - require.ErrorContains(t, err, "saas/auth-gateway: lifecycle: runtime start has not completed") - require.ErrorContains(t, err, "refused with Unavailable") + refusing := deliverTo(t, serveComposition(t, &refusingComposition{refusals: []codes.Code{codes.Unavailable}})) + // Obtain the real client's refusal before entering virtual time. Socket + // scheduling must not consume the short deadline used to test retry state. + ctx, cancel := context.WithTimeout(context.Background(), testDeliverySchedule.Deadline) + defer cancel() + refusal := refusing(ctx) + require.ErrorContains(t, refusal, "refused with Unavailable") + synctest.Test(t, func(t *testing.T) { + ready := true + delivery := testDelivery(func(context.Context) error { + if ready { + ready = false + return nil + } + return errors.New("the host is not ready: saas/auth-gateway: lifecycle: runtime start has not completed") + }, func(context.Context) error { return refusal }) + delivery.schedule.Deadline = 30 * time.Millisecond + err := delivery.run(context.Background()) + require.ErrorContains(t, err, "configured bound 30ms") + require.ErrorContains(t, err, "saas/auth-gateway: lifecycle: runtime start has not completed") + require.ErrorContains(t, err, "refused with Unavailable") + }) } // fakeLauncherFlow answers readiness per service from ready, and counts the diff --git a/docs/orchestration.md b/docs/orchestration.md index b489e4f6..1d9b5ff6 100644 --- a/docs/orchestration.md +++ b/docs/orchestration.md @@ -1260,3 +1260,18 @@ process that binds a loopback address on the port after that probe, and a routed module that crashes or is withdrawn mid-run, whose port the hop keeps routing to. `--temporary-ports` derives no address, so a reference that needs one is refused before the hop starts. + +### Composed fixture fragments + +When a service declares `fixture-composition`, the shared workspace configuration +reader supplies one document merged from every composed module's fragment using +Authority's fixture-composition library. This includes modules inside the workspace +and foreign modules. Doctor, CI and run planning use that reader; a single module's +resolved default does not substitute for the full merge. Duplicate grants or +installations fail the merge even when their source documents are identical. +Other workspace groups retain Core's ordinary provider ambiguity rules. + +An explicit workspace document retains precedence, including a document written +by `codefly generate fixture-composition`. Generation and the read-only provider +share the same fragment discovery and merge. Direct Core configuration reads do +not synthesize this CLI-owned group; consumers that need it use the shared reader. diff --git a/pkg/fixturefragments/fixturefragments.go b/pkg/fixturefragments/fixturefragments.go index 93c31239..c5566dc5 100644 --- a/pkg/fixturefragments/fixturefragments.go +++ b/pkg/fixturefragments/fixturefragments.go @@ -88,34 +88,10 @@ func Prepare(ctx context.Context, workspace *resources.Workspace, env *environme return nil, err } target := filepath.Join(workspace.Dir(), "configurations", profile, Group+".yaml") - modules, err := workspace.LoadModules(ctx) + sources, err := fragments(ctx, workspace, profiles) 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 @@ -142,6 +118,46 @@ func Prepare(ctx context.Context, workspace *resources.Workspace, env *environme return &Prepared{Target: target, Sources: names, Content: content.Bytes()}, nil } +// fragments is every composed module's fragment, read from the module's own +// directory under the first of profiles it has a directory for. +// +// A module whose configuration directory IS the workspace's own is skipped: +// in a flat layout that directory holds the merged document, which is the +// output, never a fragment. +func fragments(ctx context.Context, workspace *resources.Workspace, profiles []string) ([]fixturecomposition.Source, error) { + workspaceDir, found, err := configurations.ProfileDirectory(ctx, workspace.Dir(), "configurations", profiles) + if err != nil { + return nil, err + } + if !found { + workspaceDir = "" + } + modules, err := workspace.LoadModules(ctx) + if err != nil { + return nil, err + } + var sources []fixturecomposition.Source + for _, module := range modules { + dir, exists, dirErr := configurations.ProfileDirectory(ctx, module.Dir(), "configurations", profiles) + if dirErr != nil { + return nil, fmt.Errorf("module %s: %w", module.Name, dirErr) + } + if !exists || (workspaceDir != "" && resources.SameDir(dir, workspaceDir)) { + continue + } + path := filepath.Join(dir, Group+".yaml") + 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}) + } + return sources, 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. @@ -152,6 +168,23 @@ func sourceName(workspace *resources.Workspace, module, path string) string { return fmt.Sprintf("module %s (%s)", module, path) } +// Merged is the merged document alone, for a caller that supplies the group +// rather than writing the file: the same merge Prepare performs, without the +// generated header, so what a reader is handed and what `codefly generate +// fixture-composition` writes come from one place, including a schema-only +// document when no module ships a fragment. +func Merged(ctx context.Context, workspace *resources.Workspace, profiles []string) ([]byte, error) { + sources, err := fragments(ctx, workspace, profiles) + if err != nil { + return nil, err + } + merged, err := fixturecomposition.Merge(sources) + if err != nil { + return nil, err + } + return fixturecomposition.Encode(merged) +} + // Write writes the document unless the workspace holds a hand-written one, // which it never overwrites (ErrHandWritten). It reports whether the file // changed. diff --git a/pkg/orchestration/fixture_composition.go b/pkg/orchestration/fixture_composition.go new file mode 100644 index 00000000..490d60cb --- /dev/null +++ b/pkg/orchestration/fixture_composition.go @@ -0,0 +1,74 @@ +package orchestration + +import ( + "context" + "maps" + "slices" + + "github.com/codefly-dev/core/configurations" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + + "github.com/codefly-dev/cli/pkg/fixturefragments" +) + +// mergedFixtureComposition supplies the Authority-owned merge unless the +// workspace itself supplies the document. A module's resolved default is still +// a fragment, even when Core sees just one provider or identical providers. +func mergedFixtureComposition(ctx context.Context, workspace *resources.Workspace, env *resources.Environment, rootNames []string) (*basev0.Configuration, error) { + if slices.Contains(rootNames, fixturefragments.Group) { + return nil, nil + } + profiles, err := env.ConfigurationProfileNames() + if err != nil { + return nil, err + } + // Core overlays an explicit workspace document onto a single module's + // default without classifying that group as a root-delivered group. Read + // the root's profile through Core to preserve that explicit declaration too. + own, err := configurations.LoadProfileConfigurations(ctx, workspace.Dir(), "configurations", profiles) + if err != nil { + return nil, err + } + for _, info := range own.Infos { + if info.GetName() == fixturefragments.Group { + return nil, nil + } + } + declared, err := fixturefragments.Declared(ctx, workspace) + if err != nil || !declared { + return nil, err + } + document, err := fixturefragments.Merged(ctx, workspace, profiles) + if err != nil || document == nil { + return nil, err + } + return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{ + Name: fixturefragments.Group, + Data: &basev0.ConfigurationData{Kind: "yaml", Content: document}, + }}}, nil +} + +// AmbiguousWorkspaceConfigurations is core's set minus every group this reader +// supplies itself. A group with a provider is not ambiguous, whoever the +// provider is: core reports fixture-composition ambiguous because two composed +// modules carry a fragment, and the merge of those fragments is this reader's +// answer to exactly that. Stated as a property of what was supplied rather +// than as a name, so a second generated group needs no second exemption. +func (r *WorkspaceConfigurationReader) AmbiguousWorkspaceConfigurations() map[string]error { + ambiguous := r.ConfigurationInformationLocalReader.AmbiguousWorkspaceConfigurations() + if len(ambiguous) == 0 { + return ambiguous + } + supplied := map[string]bool{} + for _, info := range workspaceConfigurationInfos(r.combined) { + supplied[info.GetName()] = true + } + out := maps.Clone(ambiguous) + for name := range ambiguous { + if supplied[name] { + delete(out, name) + } + } + return out +} diff --git a/pkg/orchestration/fixture_composition_test.go b/pkg/orchestration/fixture_composition_test.go new file mode 100644 index 00000000..4729cc61 --- /dev/null +++ b/pkg/orchestration/fixture_composition_test.go @@ -0,0 +1,216 @@ +package orchestration + +import ( + "context" + "os" + "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 fragmentHead = "schema: codefly.dev/authority/fixture-composition/v1\n" + +// fragmentWorkspace composes two modules from OUTSIDE the workspace tree, as a +// pinned module materializes, each shipping a fixture-composition fragment — +// what every module composed since #1197 does — with authority/local declaring +// the group. An in-repo module's configurations belong to the consuming +// workspace root, so only a foreign module offers a group at all. +func fragmentWorkspace(t *testing.T, own string) *resources.Workspace { + t.Helper() + authority, documents := filepath.Join(t.TempDir(), "authority"), filepath.Join(t.TempDir(), "documents") + service := "name: %s\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\n" + write := func(dir, rel, content string) { + p := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(content), 0o600)) + } + write(authority, "module.codefly.yaml", "name: authority\nservices:\n - name: local\n") + write(authority, "services/local/service.codefly.yaml", + strings.Replace(service, "%s", "local", 1)+"workspace-configuration-dependencies: ["+fixturefragments.Group+"]\n") + write(authority, "configurations/local/"+fixturefragments.Group+".yaml", fragmentHead) + write(documents, "module.codefly.yaml", "name: documents\nservices:\n - name: documents\n") + write(documents, "services/documents/service.codefly.yaml", strings.Replace(service, "%s", "documents", 1)) + write(documents, "configurations/local/"+fixturefragments.Group+".yaml", fragmentHead+`roles: + - organization: acme + name: viewer + scopes: + - {kind: documents, actions: [read]} +`) + files := map[string]string{ + "workspace.codefly.yaml": "name: fragments\nlayout: modules\nmodules:\n - name: authority\n path: " + authority + + "\n - name: documents\n path: " + documents + "\n", + } + if own != "" { + files["configurations/local/"+fixturefragments.Group+".yaml"] = own + } + return writeTempWorkspace(t, files) +} + +func readerFor(t *testing.T, workspace *resources.Workspace) *WorkspaceConfigurationReader { + t.Helper() + reader, err := NewWorkspaceConfigurationReader(context.Background(), workspace) + require.NoError(t, err) + require.NoError(t, reader.Load(context.Background(), resources.LocalEnvironment())) + return reader +} + +func fixtureGroups(reader *WorkspaceConfigurationReader) []string { + var out []string + for _, info := range workspaceConfigurationInfos(reader.Configurations()) { + if info.GetName() == fixturefragments.Group { + out = append(out, string(info.GetData().GetContent())) + } + } + return out +} + +// fixture-composition is the one multi-provider workspace configuration: every +// composed module ships a fragment and the run merges them. Core reports the +// second provider as an ambiguity, which is right for every other group and +// wrong for this one, so the shared reader supplies the merge — once, and no +// longer ambiguous for anything reading through it. +func TestTheReaderSuppliesTheMergeOfEveryModulesFragment(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + reader := readerFor(t, fragmentWorkspace(t, "")) + + groups := fixtureGroups(reader) + require.Len(t, groups, 1, "the merge is supplied exactly once") + doc, err := fixturecomposition.Decode([]byte(groups[0])) + require.NoError(t, err) + require.Len(t, doc.Roles, 1) + require.Equal(t, "acme/viewer", doc.Roles[0].Key()) + require.Equal(t, "documents", doc.Roles[0].Scopes[0].Kind) + + require.Contains(t, reader.ConfigurationInformationLocalReader.AmbiguousWorkspaceConfigurations(), fixturefragments.Group, + "the premise: core reports the second provider as an ambiguity, which is what this resolves") + require.NotContains(t, reader.AmbiguousWorkspaceConfigurations(), fixturefragments.Group, + "a group this reader supplies is not ambiguous") +} + +// A workspace that answers for itself keeps its answer: `codefly generate +// fixture-composition` writes that file and a run writes it before it loads +// any configuration, and core's precedence then applies untouched. +func TestTheWorkspacesOwnDocumentWins(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + own := fragmentHead + `roles: + - organization: acme + name: admin + scopes: + - {kind: documents.collection, actions: [read]} +` + reader := readerFor(t, fragmentWorkspace(t, own)) + groups := fixtureGroups(reader) + require.Len(t, groups, 1) + require.Equal(t, own, groups[0], "the workspace's own document is what is provided") + require.NotContains(t, reader.AmbiguousWorkspaceConfigurations(), fixturefragments.Group) +} + +// Every other workspace configuration keeps the single-provider rule: two +// modules defining one group differently stays ambiguous, through this reader +// as through core. +func TestAnOrdinaryGroupTwoModulesDisagreeOnStaysAmbiguous(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + workspace := fragmentWorkspace(t, "") + modules, err := workspace.LoadModules(context.Background()) + require.NoError(t, err) + for i, module := range modules { + require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), "configurations/local/engine.env"), + []byte("MODE=m"+string(rune('a'+i))+"\n"), 0o600)) + } + reader := readerFor(t, workspace) + require.Contains(t, reader.AmbiguousWorkspaceConfigurations(), "engine") + require.NotContains(t, reader.AmbiguousWorkspaceConfigurations(), fixturefragments.Group) + require.ErrorContains(t, reader.AmbiguousWorkspaceConfigurations()["engine"], "provided by composed modules") +} + +func TestIdenticalFragmentsMustStillConflict(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + ws := fragmentWorkspace(t, "") + modules, err := ws.LoadModules(context.Background()) + require.NoError(t, err) + var doc []byte + for _, m := range modules { + if m.Name == "documents" { + doc, err = os.ReadFile(filepath.Join(m.Dir(), "configurations/local/fixture-composition.yaml")) + require.NoError(t, err) + } + } + for _, m := range modules { + require.NoError(t, os.WriteFile(filepath.Join(m.Dir(), "configurations/local/fixture-composition.yaml"), doc, 0600)) + } + _, err = fixturefragments.Prepare(context.Background(), ws, &environments.Environment{Name: "local"}) + require.ErrorIs(t, err, fixturecomposition.ErrConflict, "run generation rejects the duplicate") + reader, err := NewWorkspaceConfigurationReader(context.Background(), ws) + require.NoError(t, err) + require.ErrorIs(t, reader.Load(context.Background(), resources.LocalEnvironment()), fixturecomposition.ErrConflict, "read-only consumers must reject the same duplicate") +} + +func TestLocalFragmentMustJoinForeignFragment(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + external := fragmentWorkspace(t, "") + modules, err := external.LoadModules(context.Background()) + require.NoError(t, err) + var authority string + for _, m := range modules { + if m.Name == "authority" { + authority = m.Dir() + } + } + ws := writeTempWorkspace(t, map[string]string{ + "workspace.codefly.yaml": "name: mixed\nlayout: modules\nmodules:\n - name: authority\n path: " + authority + "\n - name: product\n", + "modules/product/module.codefly.yaml": "name: product\nservices: []\n", + "modules/product/configurations/local/fixture-composition.yaml": fragmentHead + "roles: [{organization: acme, name: viewer, scopes: [{kind: product, actions: [read]}]}]\n", + }) + prepared, err := fixturefragments.Prepare(context.Background(), ws, &environments.Environment{Name: "local"}) + require.NoError(t, err) + expected, err := fixturecomposition.Decode(prepared.Content) + require.NoError(t, err) + require.Len(t, expected.Roles, 1) + groups := fixtureGroups(readerFor(t, ws)) + require.Len(t, groups, 1) + got, err := fixturecomposition.Decode([]byte(groups[0])) + require.NoError(t, err) + require.Equal(t, expected, got, "run and read-only consumers must see the same merged grants") +} + +func TestNoFragmentsMatchesGeneratedEmptyDocument(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + ws := fragmentWorkspace(t, "") + modules, err := ws.LoadModules(context.Background()) + require.NoError(t, err) + for _, m := range modules { + require.NoError(t, os.Remove(filepath.Join(m.Dir(), "configurations/local/fixture-composition.yaml"))) + } + prepared, err := fixturefragments.Prepare(context.Background(), ws, &environments.Environment{Name: "local"}) + require.NoError(t, err) + expected, err := fixturecomposition.Decode(prepared.Content) + require.NoError(t, err) + groups := fixtureGroups(readerFor(t, ws)) + require.Len(t, groups, 1, "run generation supplies an empty valid document, so read-only consumers must too") + got, err := fixturecomposition.Decode([]byte(groups[0])) + require.NoError(t, err) + require.Equal(t, expected, got) +} + +// A structured workspace override still belongs to the module for Core's +// delivery scoping; that must not cause the fixture provider to replace it. +func TestWorkspaceDocumentOverridesAnUnambiguousModuleFragment(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home")) + own := fragmentHead + "roles: [{organization: acme, name: admin, scopes: [{kind: product, actions: [read]}]}]\n" + ws := fragmentWorkspace(t, own) + modules, err := ws.LoadModules(context.Background()) + require.NoError(t, err) + for _, module := range modules { + require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), "configurations/local/fixture-composition.yaml"), []byte(fragmentHead), 0o600)) + } + reader := readerFor(t, ws) + require.NotContains(t, reader.CompositionRootWorkspaceConfigurationNames(), fixturefragments.Group, "Core retains the composed delivery scope") + require.Equal(t, []string{own}, fixtureGroups(reader)) +} diff --git a/pkg/orchestration/hop_configurations.go b/pkg/orchestration/hop_configurations.go index f33d52c4..d22357f2 100644 --- a/pkg/orchestration/hop_configurations.go +++ b/pkg/orchestration/hop_configurations.go @@ -8,12 +8,14 @@ import ( "strings" "github.com/codefly-dev/authority/libraries/hop/go/routing" + "github.com/codefly-dev/cli/pkg/fixturefragments" "github.com/codefly-dev/cli/pkg/methodpolicy" "github.com/codefly-dev/core/composition" "github.com/codefly-dev/core/configurations" modulecontract "github.com/codefly-dev/core/contracts/module" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" + "google.golang.org/protobuf/proto" ) const ( @@ -64,6 +66,29 @@ func (r *WorkspaceConfigurationReader) Load(ctx context.Context, env *resources. if group != nil { r.combined = append(r.combined, group) } + // The one fixture-composition document of this composition, merged from + // the composed modules' fragments: the group is multi-provider by design, + // and this is its provider where the workspace has no answer of its own + // (fixture_composition.go). + fixture, err := mergedFixtureComposition(ctx, r.workspace, env, r.CompositionRootWorkspaceConfigurationNames()) + if err != nil { + return err + } + if fixture != nil { + // Replace any module default with the complete merge. Core may have + // resolved a single fragment (or identical fragments) without ambiguity. + for i, config := range r.combined { + if config.GetOrigin() != resources.ConfigurationWorkspace { + continue + } + replacement := proto.CloneOf(config) + replacement.Infos = slices.DeleteFunc(replacement.Infos, func(info *basev0.ConfigurationInformation) bool { + return info.GetName() == fixturefragments.Group + }) + r.combined[i] = replacement + } + r.combined = append(r.combined, fixture) + } return nil }