Skip to content
Merged
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
7 changes: 7 additions & 0 deletions cmd/doctor_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
91 changes: 91 additions & 0 deletions cmd/doctor_workspace_fixture_composition_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
37 changes: 23 additions & 14 deletions cmd/run/native_delivery_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
83 changes: 58 additions & 25 deletions pkg/fixturefragments/fixturefragments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions pkg/orchestration/fixture_composition.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading