From a1885cccf3a900aae23325b15227b30bd6bc9528 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:06:57 -0400 Subject: [PATCH 1/3] fix(endpoints): a workspace that does not carry the queried module is not the process's composition (#66) A composed module run from its own checkout, or a released tag resolved into CODEFLY_MODULE_CACHE, finds the producer's own workspace.codefly.yaml above the process. It declares the module under the producer's name (saas-starter in saas-starter-dev), not the composition's (saas), so selection refused every endpoint query with "cannot find module ", and services resolving their own endpoint at boot exited. ResolveNetworkInstance now trusts a found workspace only if it carries the queried module, by core's Workspace.Member (the lookup LoadModuleFromName resolves through). Otherwise the process is answered exactly as a workspace-free one: from the injected carrier, refusing absence with ErrEndpointCarrierAbsent; never the producer's declarations or native map. AGENTS.md was 205 lines on main and failed its 200-line budget test; the coverage-gate table it duplicated from the dual-module-change skill becomes a pointer to that skill. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 29 +++---- README.md | 11 +++ endpoint_foreign_workspace_test.go | 126 +++++++++++++++++++++++++++++ for.go | 20 ++++- 4 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 endpoint_foreign_workspace_test.go diff --git a/AGENTS.md b/AGENTS.md index 1b8e744..127241e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,16 +72,9 @@ Run each of those a second time from `workcontext/` — it is a separate module with its own `go.mod` and `go.sum`, so nothing at the root covers it. Lint finds the root `.golangci.yaml` by walking up, so it needs no config of its own. -Coverage thresholds differ by module and are not interchangeable: - -| Module | Gate | Where | -| --- | --- | --- | -| root | total ≥ 75%, per-file and per-package 0 | `.testcoverage.yaml` | -| `workcontext` | module total ≥ 75%, via `awk` in CI | `go.yml`, `coverage` job | - -The `workcontext` gate is on the **module total** across both its packages, so -`grpctransport` sitting low is carried by the verifier. Read the total, not a -package line. +Coverage gates differ by module and are not interchangeable — root +`.testcoverage.yaml`, `workcontext` an `awk` on the **module total** in `go.yml`. +The `dual-module-change` skill has the table and the command. ## Where things live @@ -115,14 +108,16 @@ see the skill below. `Init` pins that consumer once; endpoint resolution refuses identity drift. - **A deployed process has no workspace, so its carriers are authoritative.** The builder image ships the binary alone, including in local Kubernetes pods. - In any environment with no workspace - on disk, `ResolveNetworkInstance` reads the carrier keyed by the query's - canonical identity and refuses only absence (`ErrEndpointCarrierAbsent`) or a - malformed value: the composition judged eligibility at render and the mesh - enforces it. With a workspace, selection comes first in every environment. - No flag decides the path; the workspace's + In any environment with no workspace on disk, `ResolveNetworkInstance` reads + the carrier keyed by the query's canonical identity and refuses only absence + (`ErrEndpointCarrierAbsent`) or a malformed value: the composition judged + eligibility at render and the mesh enforces it. With a workspace, selection + comes first in every environment. No flag decides the path; the workspace's presence does. Native fallback requires a workspace and a `local` or unset - environment. + environment. A workspace counts only if it carries the queried module (core's + `Workspace.Member`): a module's own checkout and its cached release tag ship + the producer's workspace under the producer's name, so that process is + answered from its carriers too (#66). - **`CODEFLY__` is spelled in this repo, nowhere else.** A consumer needing a value gets a typed accessor here; the prefix constant lives in `runtime_value.go` for that reason. diff --git a/README.md b/README.md index cadf566..5b069a0 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,17 @@ the path; native fallback requires a workspace and a `local` or unset environmen A reference to an endpoint not named after its API must qualify the API with `.API(...)` there, since no declaration is on disk to supply it. +A workspace found above the process is the process's composition only if it +carries the queried module, by core's `Workspace.Member`. A composed module run +from its own checkout, or a released tag resolved into `CODEFLY_MODULE_CACHE`, +finds the producer's own `workspace.codefly.yaml`, which declares the module +under the producer's name (`saas-starter` in `saas-starter-dev`) rather than the +name the composition gave it (`saas`). That workspace is not consulted: the +process resolves from its injected carriers exactly as a workspace-free process +does, and refuses an absent one with `ErrEndpointCarrierAbsent`. It never +selects against the producer's declarations or computes a native address from +them. + Call `codefly.Init(ctx)` at boot to pin the calling module from the runtime identity. `Init` refuses a process whose runtime identity carrier is unset or blank with `ErrConsumerNotIdentified`. Every codefly-managed flow sets that diff --git a/endpoint_foreign_workspace_test.go b/endpoint_foreign_workspace_test.go new file mode 100644 index 0000000..a0cffc5 --- /dev/null +++ b/endpoint_foreign_workspace_test.go @@ -0,0 +1,126 @@ +package codefly_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" + "github.com/stretchr/testify/require" +) + +// A composition names the modules it composes; the module's own repository +// names it too, and the two need not agree. platform-core composes +// module-saas-starter as `saas`, while the repository's workspace knows it as +// `saas-starter` in `saas-starter-dev`. A process run from that repository — +// its checkout, or a released tag resolved into the module cache, which ships +// the same workspace file — finds the producer's workspace above it. That +// workspace does not carry the module the composition queries, so it is not +// the process's composition and the process is answered from the carriers the +// composition's CLI injected, exactly as a process with no workspace is. + +// producerWorkspace is the workspace module-saas-starter ships, scaled down: it +// declares the module under the repository's own name, never the composition's. +const producerWorkspace = "name: platform-starter-dev\nlayout: modules\nmodules:\n - name: platform-starter\n" + +// foreignWorkspaceLayouts are the two places a composed module's process runs +// from with the producer's workspace above it. Each returns the directory the +// process runs in. +var foreignWorkspaceLayouts = []struct { + name string + layout func(t *testing.T) string +}{ + {"module checkout", func(t *testing.T) string { + checkout := t.TempDir() + writeProducerModule(t, checkout) + return filepath.Join(checkout, "module", "services", carriedEndpoint.Service, "code") + }}, + {"module cache tag", func(t *testing.T) string { + tag := filepath.Join(t.TempDir(), "codefly-dev", "module-platform-starter", "v0.0.94") + writeProducerModule(t, tag) + return filepath.Join(tag, "module", "services", carriedEndpoint.Service, "code") + }}, +} + +func writeProducerModule(t *testing.T, root string) { + t.Helper() + writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), producerWorkspace) + writeFile(t, filepath.Join(root, "module", "module.codefly.yaml"), + "kind: module\nname: platform-starter\nservices:\n - name: "+carriedEndpoint.Service+"\n") + writeFile(t, filepath.Join(root, "module", "services", carriedEndpoint.Service, "service.codefly.yaml"), + "kind: service\nname: "+carriedEndpoint.Service+"\nversion: 0.0.0\nendpoints:\n - name: rest\n api: rest\n visibility: public\n") + writeFile(t, filepath.Join(root, "module", "services", carriedEndpoint.Service, "code", "main.go"), "package main\n") +} + +// prepareProcessUnderAForeignWorkspace boots the process as a workspace-free +// one, then moves it under the producer's workspace, and holds the premise: the +// workspace is found, and it would refuse the query by module name. +func prepareProcessUnderAForeignWorkspace(t *testing.T, environment string, layout func(*testing.T) string) { + t.Helper() + prepareProcessWithoutWorkspace(t, environment) + t.Chdir(layout(t)) + workspace, err := resources.FindWorkspaceUp(context.Background()) + require.NoError(t, err) + require.NotNil(t, workspace, "the test needs the producer's workspace above the process") + _, err = workspace.LoadModuleFromName(context.Background(), carriedEndpoint.Module) + require.ErrorContains(t, err, "cannot find module", "the producer's workspace must not carry the composition's module name") +} + +func TestAComposedModuleUnderItsOwnWorkspaceResolvesItsCarrier(t *testing.T) { + for _, layout := range foreignWorkspaceLayouts { + for _, environment := range []string{"local", "", "production"} { + for _, source := range []string{"runtime", "embedded"} { + for _, form := range referenceForms { + t.Run(layout.name+"/environment="+environment+"/source="+source+"/"+form.name, func(t *testing.T) { + prepareProcessUnderAForeignWorkspace(t, environment, layout.layout) + access := carrierFor("rest", "rest", "http://localhost:24601") + injectEndpointForTest(t, source, access) + query := form.query(queryCarriedService()) + instance, err := query.ResolveNetworkInstance() + require.NoError(t, err) + require.Equal(t, access.NetworkInstance.Address, instance.Address) + require.Equal(t, uint16(24601), instance.Port) + require.NotNil(t, query.NetworkInstance()) + }) + } + } + } + } +} + +// Neither the composition's workspace nor a carrier: the typed refusal, never +// the producer workspace's declarations and never a native address computed +// from it — even in local, where a carrying workspace would fall back. +func TestAComposedModuleUnderItsOwnWorkspaceRefusesAnAbsentCarrier(t *testing.T) { + for _, layout := range foreignWorkspaceLayouts { + for _, environment := range []string{"local", "", "production"} { + for _, form := range referenceForms { + t.Run(layout.name+"/environment="+environment+"/"+form.name, func(t *testing.T) { + prepareProcessUnderAForeignWorkspace(t, environment, layout.layout) + query := form.query(queryCarriedService()) + instance, err := query.ResolveNetworkInstance() + require.ErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) + require.ErrorContains(t, err, "platform/location-records/rest") + require.Nil(t, instance) + require.Nil(t, query.NetworkInstance()) + }) + } + } + } +} + +// A malformed carrier is refused as one, not hidden behind the producer +// workspace's "cannot find module". +func TestAComposedModuleUnderItsOwnWorkspaceRefusesAMalformedCarrier(t *testing.T) { + for _, layout := range foreignWorkspaceLayouts { + t.Run(layout.name, func(t *testing.T) { + prepareProcessUnderAForeignWorkspace(t, "local", layout.layout) + injectEndpointForTest(t, "runtime", carrierFor("rest", "rest", "localhost:not-a-port")) + instance, err := queryCarriedService().Endpoint("rest").API("rest").ResolveNetworkInstance() + require.ErrorContains(t, err, "malformed address") + require.NotErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) + require.Nil(t, instance) + }) + } +} diff --git a/for.go b/for.go index 2d5b37b..9bd31ed 100644 --- a/for.go +++ b/for.go @@ -114,6 +114,14 @@ var ErrEndpointCarrierAbsent = errors.New("codefly: no carrier for the endpoint // malformed one. It never computes a native address. No flag selects the // path: the presence of a workspace decides. Native fallback requires a // workspace and a local or unset environment. +// +// The workspace found above the process is only the process's composition if +// it carries the queried module. One that does not — a module run from its own +// checkout inside a larger composition, or a released tag resolved into the +// module cache, both of which carry the producer's own workspace under the +// producer's own module name — is answered exactly as a process with no +// workspace is: from the carriers, never from that workspace's declarations or +// native map. func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { if err := checkConsumerIdentity(q.consumerModule); err != nil { return nil, err @@ -126,7 +134,7 @@ func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { if err != nil { return nil, err } - if workspace == nil { + if workspace == nil || !carries(workspace, q.module) { return q.resolveFromAuthoritativeCarrier() } // The declaration decides eligibility before any carrier can supply an @@ -155,6 +163,16 @@ func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { return local, nil } +// carries reports whether workspace is the composition the queried module runs +// in, by core's own membership rule — the one LoadModuleFromName resolves +// through. A module's checkout and its cached release tag both ship the +// producer's workspace, which knows the module by the producer's name, not by +// the name the composition gave it; selecting against it refuses every query. +func carries(workspace *resources.Workspace, module string) bool { + _, carried := workspace.Member(module) + return carried +} + // resolveFromAuthoritativeCarrier answers a workspace-free process from its carriers // alone. The carrier is keyed by the query's canonical identity — the // declaration is not on disk to supply any part of it — and nothing is judged From de65fa93c5d97bff8feaf3535c986084f987ceb5 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:21:16 -0400 Subject: [PATCH 2/3] fix(endpoints): the workspace on disk counts only if it is the composition the runtime names (#66) Review of #68 (F1-F6). Carrying the queried module is not the same as being this process's composition. robin, annotations, documents, model and runtime carry the same name in their producers' workspaces as in platform-core, so those were still trusted: selection against the producer's declarations, and in local a native address derived from the producer's workspace name. And a solution's tag, whose workspace imports its platform by release, failed to load ("requires host resolution") before any carrier was read. internal/enclosing now decides it once for every reader. With CODEFLY__WORKSPACE injected (core's SetIdentity sets it for every agent-run service), a found workspace counts only if the file declares that name, and one that does not is never loaded. With nothing injected, no composition started the process and the workspace found is used as found. A counted workspace must still carry the queried module; one that does not is answered from the carriers, and an absent carrier's refusal keeps the modules it does carry. The same rule now decides the Configuration/Secret local fallback, the fixture packages Principal resolves, and the name Workspace() reports, which is read from the file without composing it. Tests: same-name producer and solution-tag layouts under an injected composition; a composition root carrying modules through workspaces: and solutions:, its native address under its own name, and the modules a typo's refusal names; the configuration, fixture and Workspace() readers. The pre-existing test that dereferenced a nil instance now requires it, so a mutation reports every failure instead of one panic. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 10 +- README.md | 38 +++-- codefly.go | 16 +-- codefly_test.go | 27 +++- endpoint_composition_root_test.go | 131 +++++++++++++++++ endpoint_foreign_workspace_test.go | 224 +++++++++++++++++++++-------- endpoint_identity_test.go | 3 + fixture/fixture.go | 9 +- fixture/fixture_test.go | 23 +++ for.go | 70 ++++++--- internal/enclosing/enclosing.go | 87 +++++++++++ scripts/allowed-imports.txt | 1 + 12 files changed, 533 insertions(+), 106 deletions(-) create mode 100644 endpoint_composition_root_test.go create mode 100644 internal/enclosing/enclosing.go diff --git a/AGENTS.md b/AGENTS.md index 127241e..a5bcd89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ The `dual-module-change` skill has the table and the command. | `authority.go` | authority-bearing values, read once at boot; a drift is refused, never reloaded | | `runtime_value.go` | `RuntimeValue`, for values the runtime injects directly | | `runtime_environment_file.go` | loading a runtime-written env file | -| `fixture.go` | the selected fixture, and resolving its principals by role | +| `fixture/` | the selected fixture, and resolving its principals by role | | `tls.go` | workload leaf certificates, reloaded on rotation | | `receipts/` | effect receipts: the store, the digest, the replay/conflict interceptor | | `receipts/grpctransport/`, `receipts/connecttransport/` | the two transport adapters, split so neither drags the other's dependency in | @@ -114,10 +114,10 @@ see the skill below. eligibility at render and the mesh enforces it. With a workspace, selection comes first in every environment. No flag decides the path; the workspace's presence does. Native fallback requires a workspace and a `local` or unset - environment. A workspace counts only if it carries the queried module (core's - `Workspace.Member`): a module's own checkout and its cached release tag ship - the producer's workspace under the producer's name, so that process is - answered from its carriers too (#66). + environment. A workspace that is not this process's composition counts as none + (`internal/enclosing`, #66): with `CODEFLY__WORKSPACE` injected only that name + counts, and another is never even loaded; it must also carry the queried + module. With nothing injected, the workspace found is used as found. - **`CODEFLY__` is spelled in this repo, nowhere else.** A consumer needing a value gets a typed accessor here; the prefix constant lives in `runtime_value.go` for that reason. diff --git a/README.md b/README.md index 5b069a0..fde3a4f 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,34 @@ the path; native fallback requires a workspace and a `local` or unset environmen A reference to an endpoint not named after its API must qualify the API with `.API(...)` there, since no declaration is on disk to supply it. -A workspace found above the process is the process's composition only if it -carries the queried module, by core's `Workspace.Member`. A composed module run -from its own checkout, or a released tag resolved into `CODEFLY_MODULE_CACHE`, -finds the producer's own `workspace.codefly.yaml`, which declares the module -under the producer's name (`saas-starter` in `saas-starter-dev`) rather than the -name the composition gave it (`saas`). That workspace is not consulted: the -process resolves from its injected carriers exactly as a workspace-free process -does, and refuses an absent one with `ErrEndpointCarrierAbsent`. It never -selects against the producer's declarations or computes a native address from -them. +A workspace found above the process counts only if it is the process's +composition. The runtime names the composition: every service `codefly run` +starts carries it as the injected workspace identity. A composed module run from +its own checkout, a released tag resolved into `CODEFLY_MODULE_CACHE`, or a +solution's tag finds its producer's own `workspace.codefly.yaml` first. That file +declares another name (`saas-starter-dev`, not `platform-core`), so it is treated +as no workspace at all. It is never loaded, which matters for a solution's tag +whose workspace imports its platform by release and cannot be composed outside +the CLI. Even when the producer names the module exactly as the composition does +(`robin` in its own repository and in `platform-core`), the process resolves +from its injected carriers, refuses an absent one with +`ErrEndpointCarrierAbsent`, and never selects against the producer's +declarations or computes a native address from its name. A workspace that does +match must also carry the queried module, by core's `Workspace.Member`: modules +it composes through `workspaces:` or `solutions:` count, and the native address +is computed under the composition's name. A module it does not carry is +answered from the carriers, and an absent carrier's refusal names the modules +the workspace does carry. + +With no injected identity, no composition started the process (a local tool, a +test, `go run`), and the workspace found is the only one there is: it is used +as found. + +The same rule decides every other read that falls back to the workspace on +disk: the local configuration and secret files behind `Configuration` and +`Secret`, the packages `fixture.Selected().Principal` resolves, and the name +`codefly.Workspace` reports when no identity is injected. That name is read from +the file, without composing it. Call `codefly.Init(ctx)` at boot to pin the calling module from the runtime identity. `Init` refuses a process whose runtime identity carrier is unset or diff --git a/codefly.go b/codefly.go index e6a115c..4ac5faa 100644 --- a/codefly.go +++ b/codefly.go @@ -13,6 +13,7 @@ import ( basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/wool" + "github.com/codefly-dev/sdk-go/internal/enclosing" ) var service string @@ -250,23 +251,22 @@ func Environment() string { // Workspace returns the Codefly workspace identity for the current process. // A runtime-injected identity is authoritative. Local tools that are not -// launched as a service fall back to the enclosing workspace resource, keeping -// product code independent from Codefly's environment-variable carriers. +// launched as a service fall back to the name the enclosing workspace file +// declares — read, not composed, so a workspace whose imports only the CLI can +// resolve still names itself — keeping product code independent from Codefly's +// environment-variable carriers. func Workspace(ctx context.Context) (string, error) { - if name := strings.TrimSpace(os.Getenv(resources.WorkspacePrefix)); name != "" { - return name, nil - } if ctx == nil { ctx = context.Background() } - workspace, err := resources.FindWorkspaceUp(ctx) + name, err := enclosing.Name(ctx) if err != nil { return "", fmt.Errorf("resolve Codefly workspace: %w", err) } - if workspace == nil || strings.TrimSpace(workspace.Name) == "" { + if name == "" { return "", fmt.Errorf("resolve Codefly workspace: no enclosing workspace") } - return workspace.Name, nil + return name, nil } // IsLocal reports whether the current Codefly environment is local. diff --git a/codefly_test.go b/codefly_test.go index 4f59c17..87bed87 100644 --- a/codefly_test.go +++ b/codefly_test.go @@ -248,7 +248,10 @@ endpoints: Service("records"). Endpoint("rest"). ResolveNetworkInstance() - assert.NoError(t, err) + // require, not assert: a nil instance must fail this test, not panic and + // abort the package run with every other failure unreported. + require.NoError(t, err) + require.NotNil(t, instance) expected := network.NativeFor( ctx, "sdk-endpoint-test", @@ -267,7 +270,8 @@ endpoints: Endpoint("rest"). NamingScope("agent-test"). ResolveNetworkInstance() - assert.NoError(t, err) + require.NoError(t, err) + require.NotNil(t, scoped) assert.NotEqual(t, instance.Port, scoped.Port) } @@ -332,6 +336,25 @@ func TestWorkspaceFallsBackToEnclosingResource(t *testing.T) { assert.Equal(t, "local-workspace", name) } +// A workspace that imports its platform by release can only be composed by the +// CLI. Its name is still its name: the fallback reads the file, it does not +// compose it, so a local tool under a solution's tag is not refused for an +// import it never needed. +func TestWorkspaceNamesAWorkspaceOnlyTheCLICanCompose(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), + "name: proposals\nlayout: modules\nworkspaces:\n - name: platform-core\n source: obin-ai/platform-core\n version: \"0.0.3\"\nmodules:\n - name: proposals\n path: .\n") + t.Chdir(root) + t.Setenv(resources.WorkspacePrefix, "") + _, err := resources.FindWorkspaceUp(t.Context()) + require.ErrorContains(t, err, "requires host resolution", "the premise: composing it fails here") + + name, err := codefly.Workspace(t.Context()) + + require.NoError(t, err) + assert.Equal(t, "proposals", name) +} + func TestEnvironmentReloadDropsRemovedValues(t *testing.T) { const key = "CODEFLY__WORKSPACE_CONFIGURATION__RELOAD__VALUE" t.Setenv(key, "present") diff --git a/endpoint_composition_root_test.go b/endpoint_composition_root_test.go new file mode 100644 index 0000000..4440086 --- /dev/null +++ b/endpoint_composition_root_test.go @@ -0,0 +1,131 @@ +package codefly_test + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/network" + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" + "github.com/stretchr/testify/require" +) + +// A composition root carries modules it does not author: a platform it imports +// through `workspaces:` and a solution it adds through `solutions:`. That root +// IS the process's composition — the runtime names it — so selection judges +// every query against it first, and the native map is computed from its name. +// This is the platform-obin topology: platform-core imported, core-solutions' +// solutions added. + +const compositionRoot = "platform-obin" + +const rootServiceDeclaration = `kind: service +name: %s +version: 0.0.0 +agent: + kind: codefly:service + name: go-grpc + version: 0.0.1 + publisher: codefly.dev +endpoints: + - name: rest + api: rest + visibility: private + - name: health + api: rest + visibility: public + exposure: none +` + +func writeRootModule(t *testing.T, dir, module, service string) { + t.Helper() + writeFile(t, filepath.Join(dir, "module.codefly.yaml"), "kind: module\nname: "+module+"\nservices:\n - name: "+service+"\n") + writeFile(t, filepath.Join(dir, "services", service, "service.codefly.yaml"), + fmt.Sprintf(rootServiceDeclaration, service)) +} + +// prepareCompositionRoot lays out platform-obin importing platform-core by path +// (which carries `producer`) and adding the solution `wiki`, and runs the +// consumer `client` at its root in local, with the given injected composition. +func prepareCompositionRoot(t *testing.T, injected string) { + t.Helper() + initEndpointConsumer(t, "client") + t.Setenv("CODEFLY__ENVIRONMENT", "local") + t.Setenv(resources.WorkspacePrefix, injected) + require.NoError(t, codefly.LoadEnvironmentVariables()) + root := t.TempDir() + writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), + "name: "+compositionRoot+"\nlayout: modules\nworkspaces:\n - name: platform-core\n path: platform-core\nsolutions:\n - name: wiki\n") + writeFile(t, filepath.Join(root, "platform-core", "workspace.codefly.yaml"), + "name: platform-core\nlayout: modules\nmodules:\n - name: producer\n") + writeRootModule(t, filepath.Join(root, "platform-core", "modules", "producer"), "producer", "records") + writeRootModule(t, filepath.Join(root, "modules", "wiki"), "wiki", "backend") + t.Chdir(root) + workspace, err := resources.FindWorkspaceUp(context.Background()) + require.NoError(t, err) + producer, carried := workspace.Member("producer") + require.True(t, carried) + require.Equal(t, "platform-core", producer.Workspace, "producer is carried through the import, not authored by the root") + wiki, carried := workspace.Member("wiki") + require.True(t, carried) + require.Equal(t, resources.MemberRoleSolution, wiki.Role, "wiki is carried as a solution") +} + +func TestACompositionRootSelectsModulesItComposes(t *testing.T) { + for _, injected := range []string{compositionRoot, ""} { + for _, carried := range []struct{ module, service string }{ + {"producer", "records"}, // through `workspaces:` + {"wiki", "backend"}, // through `solutions:` + } { + t.Run("composition="+injected+"/"+carried.module, func(t *testing.T) { + prepareCompositionRoot(t, injected) + // A carrier is present: only selection can refuse the query, so a + // refusal proves the root's declarations were judged first. + injectEndpointForTest(t, "runtime", &resources.EndpointAccess{ + Endpoint: &basev0.Endpoint{Module: carried.module, Service: carried.service, Name: "rest", Api: "rest"}, + NetworkInstance: &basev0.NetworkInstance{Address: "http://localhost:24601"}, + }) + instance, err := codefly.For(context.Background()).Module(carried.module).Service(carried.service). + Endpoint("rest").API("rest").ResolveNetworkInstance() + require.ErrorIs(t, err, resources.ErrEndpointNotReachable) + require.Nil(t, instance) + }) + } + } +} + +// In local with no carrier, the native address is the composition's: computed +// from the root's name, not from the imported workspace that authored the +// module. +func TestACompositionRootComputesTheNativeAddressUnderItsOwnName(t *testing.T) { + prepareCompositionRoot(t, compositionRoot) + ctx := context.Background() + instance, err := codefly.For(ctx).Module("producer").Service("records").Endpoint("health").API("rest").ResolveNetworkInstance() + require.NoError(t, err) + require.NotNil(t, instance) + expected := network.NativeFor(ctx, compositionRoot, "producer", "records", "", &basev0.Endpoint{Name: "health", Api: "rest"}) + require.Equal(t, expected.Address, instance.Address) + require.NotEqual(t, network.NativeFor(ctx, "platform-core", "producer", "records", "", &basev0.Endpoint{Name: "health", Api: "rest"}).Address, + instance.Address, "the premise: the imported workspace's name derives another address") +} + +// A module the composition does not carry — a typo at the root — is refused, +// and the refusal still says which modules the root does carry. +func TestACompositionRootNamesWhatItCarriesWhenAModuleIsUnknown(t *testing.T) { + for _, injected := range []string{compositionRoot, ""} { + t.Run("composition="+injected, func(t *testing.T) { + prepareCompositionRoot(t, injected) + instance, err := codefly.For(context.Background()).Module("prodcuer").Service("records"). + Endpoint("rest").API("rest").ResolveNetworkInstance() + require.ErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) + require.ErrorContains(t, err, "does not compose module ") + require.ErrorContains(t, err, "present: [") + require.ErrorContains(t, err, "producer") + require.ErrorContains(t, err, "wiki") + require.Nil(t, instance) + }) + } +} diff --git a/endpoint_foreign_workspace_test.go b/endpoint_foreign_workspace_test.go index a0cffc5..6999682 100644 --- a/endpoint_foreign_workspace_test.go +++ b/endpoint_foreign_workspace_test.go @@ -16,64 +16,138 @@ import ( // `saas-starter` in `saas-starter-dev`. A process run from that repository — // its checkout, or a released tag resolved into the module cache, which ships // the same workspace file — finds the producer's workspace above it. That -// workspace does not carry the module the composition queries, so it is not -// the process's composition and the process is answered from the carriers the -// composition's CLI injected, exactly as a process with no workspace is. - -// producerWorkspace is the workspace module-saas-starter ships, scaled down: it -// declares the module under the repository's own name, never the composition's. -const producerWorkspace = "name: platform-starter-dev\nlayout: modules\nmodules:\n - name: platform-starter\n" - -// foreignWorkspaceLayouts are the two places a composed module's process runs -// from with the producer's workspace above it. Each returns the directory the -// process runs in. -var foreignWorkspaceLayouts = []struct { - name string +// workspace is not the process's composition: the runtime names the +// composition (CODEFLY__WORKSPACE), and the producer's workspace declares +// another name. The process is answered from the carriers the composition's +// CLI injected, exactly as a process with no workspace is. + +// composition is the name the runtime injects, as platform-core's run does. +const composition = "platform-core" + +type foreignLayout struct { + name string + // layout writes the producer's tree and returns the directory the process + // runs in. layout func(t *testing.T) string -}{ + // premise holds what makes the layout a case worth testing: what trusting + // the found workspace would do. + premise func(t *testing.T) + // identified lists the injected compositions the layout is answered from + // carriers under. A layout listed only with an injected name is one that, + // with nothing injected, is the only workspace there is (see + // TestAProcessNoCompositionStartedUsesTheWorkspaceAsFound). + identified []string +} + +// premiseRefusesTheModule: the producer's workspace does not carry the +// composition's module name, so trusting it refuses every query (#66). +func premiseRefusesTheModule(t *testing.T) { + t.Helper() + workspace := foundWorkspace(t) + _, err := workspace.LoadModuleFromName(context.Background(), carriedEndpoint.Module) + require.ErrorContains(t, err, "cannot find module", "the producer's workspace must not carry the composition's module name") +} + +// premiseCarriesTheModule: the producer's workspace names the module exactly as +// the composition does (robin, annotations, documents, model and runtime in +// platform-core), so trusting it selects against the producer's declarations +// and computes a native address from the producer's workspace name. +func premiseCarriesTheModule(t *testing.T) { + t.Helper() + workspace := foundWorkspace(t) + _, carried := workspace.Member(carriedEndpoint.Module) + require.True(t, carried, "the producer's workspace must carry the composition's module name") + require.NotEqual(t, composition, workspace.Name) +} + +// premiseCannotLoad: a solution's tag imports its platform by release, which +// only the CLI can resolve, so loading the workspace fails before any carrier +// is read. +func premiseCannotLoad(t *testing.T) { + t.Helper() + _, err := resources.FindWorkspaceUp(context.Background()) + require.ErrorContains(t, err, "requires host resolution") +} + +func foundWorkspace(t *testing.T) *resources.Workspace { + t.Helper() + workspace, err := resources.FindWorkspaceUp(context.Background()) + require.NoError(t, err) + require.NotNil(t, workspace, "the test needs the producer's workspace above the process") + return workspace +} + +var foreignLayouts = []foreignLayout{ {"module checkout", func(t *testing.T) string { - checkout := t.TempDir() - writeProducerModule(t, checkout) - return filepath.Join(checkout, "module", "services", carriedEndpoint.Service, "code") - }}, + return writeRenamedProducer(t, t.TempDir()) + }, premiseRefusesTheModule, []string{composition, ""}}, {"module cache tag", func(t *testing.T) string { - tag := filepath.Join(t.TempDir(), "codefly-dev", "module-platform-starter", "v0.0.94") - writeProducerModule(t, tag) - return filepath.Join(tag, "module", "services", carriedEndpoint.Service, "code") - }}, + return writeRenamedProducer(t, filepath.Join(t.TempDir(), "codefly-dev", "module-platform-starter", "v0.0.94")) + }, premiseRefusesTheModule, []string{composition, ""}}, + {"same-name producer tag", func(t *testing.T) string { + root := filepath.Join(t.TempDir(), "obin-ai", "module-platform", "v0.1.22") + writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), + "name: platform-dev\nlayout: modules\nmodules:\n - name: "+carriedEndpoint.Module+"\n") + return writeProducerModule(t, filepath.Join(root, "modules", carriedEndpoint.Module), carriedEndpoint.Module) + }, premiseCarriesTheModule, []string{composition}}, + {"solution tag importing its platform by release", func(t *testing.T) string { + root := filepath.Join(t.TempDir(), "obin-ai", "core-solutions", "v0.0.17", "solutions", "proposals") + writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), + "name: proposals\nlayout: modules\nworkspaces:\n - name: platform-core\n source: obin-ai/platform-core\n version: \"0.0.3\"\nmodules:\n - name: proposals\n path: .\n") + return writeProducerModule(t, root, "proposals") + }, premiseCannotLoad, []string{"platform-obin"}}, } -func writeProducerModule(t *testing.T, root string) { +// writeRenamedProducer lays out the workspace module-saas-starter ships, scaled +// down: it declares the module under the repository's own name, never the +// composition's. +func writeRenamedProducer(t *testing.T, root string) string { t.Helper() - writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), producerWorkspace) - writeFile(t, filepath.Join(root, "module", "module.codefly.yaml"), - "kind: module\nname: platform-starter\nservices:\n - name: "+carriedEndpoint.Service+"\n") - writeFile(t, filepath.Join(root, "module", "services", carriedEndpoint.Service, "service.codefly.yaml"), - "kind: service\nname: "+carriedEndpoint.Service+"\nversion: 0.0.0\nendpoints:\n - name: rest\n api: rest\n visibility: public\n") - writeFile(t, filepath.Join(root, "module", "services", carriedEndpoint.Service, "code", "main.go"), "package main\n") + writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), + "name: platform-starter-dev\nlayout: modules\nmodules:\n - name: platform-starter\n path: module\n") + return writeProducerModule(t, filepath.Join(root, "module"), "platform-starter") +} + +// writeProducerModule writes a module declaring the carried service with a +// public rest endpoint, and returns the service's code directory. +func writeProducerModule(t *testing.T, dir, name string) string { + t.Helper() + writeFile(t, filepath.Join(dir, "module.codefly.yaml"), + "kind: module\nname: "+name+"\nservices:\n - name: "+carriedEndpoint.Service+"\n") + service := filepath.Join(dir, "services", carriedEndpoint.Service) + writeFile(t, filepath.Join(service, "service.codefly.yaml"), + "kind: service\nname: "+carriedEndpoint.Service+"\nversion: 0.0.0\nendpoints:\n - name: rest\n api: rest\n visibility: public\n exposure: public\n") + writeFile(t, filepath.Join(service, "configurations", "local", "api.env"), "URL=the-producers-own-value\n") + writeFile(t, filepath.Join(service, "code", "main.go"), "package main\n") + return filepath.Join(service, "code") } // prepareProcessUnderAForeignWorkspace boots the process as a workspace-free -// one, then moves it under the producer's workspace, and holds the premise: the -// workspace is found, and it would refuse the query by module name. -func prepareProcessUnderAForeignWorkspace(t *testing.T, environment string, layout func(*testing.T) string) { +// one in the composition the runtime names, then moves it under the producer's +// workspace, and holds the layout's premise. +func prepareProcessUnderAForeignWorkspace(t *testing.T, environment, injected string, layout foreignLayout) { t.Helper() prepareProcessWithoutWorkspace(t, environment) - t.Chdir(layout(t)) - workspace, err := resources.FindWorkspaceUp(context.Background()) - require.NoError(t, err) - require.NotNil(t, workspace, "the test needs the producer's workspace above the process") - _, err = workspace.LoadModuleFromName(context.Background(), carriedEndpoint.Module) - require.ErrorContains(t, err, "cannot find module", "the producer's workspace must not carry the composition's module name") + t.Setenv(resources.WorkspacePrefix, injected) + t.Chdir(layout.layout(t)) + layout.premise(t) +} + +func forEachForeignLayout(t *testing.T, run func(t *testing.T, injected string, layout foreignLayout)) { + for _, layout := range foreignLayouts { + for _, injected := range layout.identified { + t.Run(layout.name+"/composition="+injected, func(t *testing.T) { run(t, injected, layout) }) + } + } } func TestAComposedModuleUnderItsOwnWorkspaceResolvesItsCarrier(t *testing.T) { - for _, layout := range foreignWorkspaceLayouts { + forEachForeignLayout(t, func(t *testing.T, injected string, layout foreignLayout) { for _, environment := range []string{"local", "", "production"} { for _, source := range []string{"runtime", "embedded"} { for _, form := range referenceForms { - t.Run(layout.name+"/environment="+environment+"/source="+source+"/"+form.name, func(t *testing.T) { - prepareProcessUnderAForeignWorkspace(t, environment, layout.layout) + t.Run("environment="+environment+"/source="+source+"/"+form.name, func(t *testing.T) { + prepareProcessUnderAForeignWorkspace(t, environment, injected, layout) access := carrierFor("rest", "rest", "http://localhost:24601") injectEndpointForTest(t, source, access) query := form.query(queryCarriedService()) @@ -86,18 +160,19 @@ func TestAComposedModuleUnderItsOwnWorkspaceResolvesItsCarrier(t *testing.T) { } } } - } + }) } // Neither the composition's workspace nor a carrier: the typed refusal, never -// the producer workspace's declarations and never a native address computed -// from it — even in local, where a carrying workspace would fall back. +// the producer workspace's declarations, its load failure, or a native address +// computed from its name — even in local, where the composition's own +// workspace would fall back. func TestAComposedModuleUnderItsOwnWorkspaceRefusesAnAbsentCarrier(t *testing.T) { - for _, layout := range foreignWorkspaceLayouts { + forEachForeignLayout(t, func(t *testing.T, injected string, layout foreignLayout) { for _, environment := range []string{"local", "", "production"} { for _, form := range referenceForms { - t.Run(layout.name+"/environment="+environment+"/"+form.name, func(t *testing.T) { - prepareProcessUnderAForeignWorkspace(t, environment, layout.layout) + t.Run("environment="+environment+"/"+form.name, func(t *testing.T) { + prepareProcessUnderAForeignWorkspace(t, environment, injected, layout) query := form.query(queryCarriedService()) instance, err := query.ResolveNetworkInstance() require.ErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) @@ -107,20 +182,49 @@ func TestAComposedModuleUnderItsOwnWorkspaceRefusesAnAbsentCarrier(t *testing.T) }) } } - } + }) } -// A malformed carrier is refused as one, not hidden behind the producer -// workspace's "cannot find module". +// A malformed carrier is refused as one, not hidden behind anything the +// producer's workspace would have said. func TestAComposedModuleUnderItsOwnWorkspaceRefusesAMalformedCarrier(t *testing.T) { - for _, layout := range foreignWorkspaceLayouts { - t.Run(layout.name, func(t *testing.T) { - prepareProcessUnderAForeignWorkspace(t, "local", layout.layout) - injectEndpointForTest(t, "runtime", carrierFor("rest", "rest", "localhost:not-a-port")) - instance, err := queryCarriedService().Endpoint("rest").API("rest").ResolveNetworkInstance() - require.ErrorContains(t, err, "malformed address") - require.NotErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) - require.Nil(t, instance) - }) - } + forEachForeignLayout(t, func(t *testing.T, injected string, layout foreignLayout) { + prepareProcessUnderAForeignWorkspace(t, "local", injected, layout) + injectEndpointForTest(t, "runtime", carrierFor("rest", "rest", "localhost:not-a-port")) + instance, err := queryCarriedService().Endpoint("rest").API("rest").ResolveNetworkInstance() + require.ErrorContains(t, err, "malformed address") + require.NotErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) + require.Nil(t, instance) + }) +} + +// The producer's local configuration files are the producer's: a composed +// module's process never reads them, even when the producer names the module +// as the composition does. +func TestAComposedModuleUnderItsOwnWorkspaceReadsNoProducerConfiguration(t *testing.T) { + forEachForeignLayout(t, func(t *testing.T, injected string, layout foreignLayout) { + prepareProcessUnderAForeignWorkspace(t, "local", injected, layout) + value, err := queryCarriedService().Configuration("api", "url") + require.Error(t, err) + require.NotContains(t, err.Error(), "cannot find module", "absence, not the producer's module list") + require.NotContains(t, err.Error(), "requires host resolution", "absence, not the producer's load failure") + require.Empty(t, value) + }) +} + +// With nothing injected, no composition started the process, and the workspace +// above it is the only one there is: a local tool under a same-name producer +// reads that producer's declarations, as it did before composition identity +// was consulted. This is the boundary F1's rule draws, held so a change to it +// is a decision rather than a drift. +func TestAProcessNoCompositionStartedUsesTheWorkspaceAsFound(t *testing.T) { + layout := foreignLayouts[2] + require.Equal(t, "same-name producer tag", layout.name) + prepareProcessUnderAForeignWorkspace(t, "local", "", layout) + instance, err := queryCarriedService().Endpoint("rest").API("rest").ResolveNetworkInstance() + require.NoError(t, err, "selection and the native map answer from the workspace found") + require.NotNil(t, instance) + value, err := queryCarriedService().Configuration("api", "url") + require.NoError(t, err) + require.Equal(t, "the-producers-own-value", value) } diff --git a/endpoint_identity_test.go b/endpoint_identity_test.go index b106720..bcfa955 100644 --- a/endpoint_identity_test.go +++ b/endpoint_identity_test.go @@ -34,6 +34,9 @@ func initEndpointConsumer(t *testing.T, identity string) { codefly.IsolateConsumerIdentityForTest(t) t.Cleanup(func() { require.NoError(t, codefly.LoadEnvironmentVariables()) }) t.Setenv(resources.ModulePrefix, identity) + // No composition is injected unless a test injects one: an ambient value + // from a shell codefly started would decide which workspace counts. + t.Setenv(resources.WorkspacePrefix, "") if strings.TrimSpace(identity) != "" { _, err := codefly.Init(context.Background()) require.NoError(t, err) diff --git a/fixture/fixture.go b/fixture/fixture.go index 4737619..f8f9d90 100644 --- a/fixture/fixture.go +++ b/fixture/fixture.go @@ -19,6 +19,7 @@ import ( "github.com/codefly-dev/core/composition" "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/sdk-go/internal/enclosing" ) // Selection is the fixture the Codefly runtime selected for this @@ -66,11 +67,17 @@ func composedPackages(ctx context.Context) ([]*composition.PackageManifest, []st if ctx == nil { ctx = context.Background() } - workspace, err := resources.FindWorkspaceUp(ctx) + // Only the process's own composition says which packages it composes. A + // producer's workspace above a composed module's checkout or cached tag + // would resolve the producer's fixtures, not the composition's. + workspace, err := enclosing.Composition(ctx) if err != nil { return nil, nil, fmt.Errorf("resolve Codefly workspace: %w", err) } if workspace == nil { + if injected := enclosing.Injected(); injected != "" { + return nil, nil, fmt.Errorf("resolve Codefly workspace: no workspace on disk is the composition %q this process runs in", injected) + } return nil, nil, errors.New("resolve Codefly workspace: no enclosing workspace") } resolutions, err := workspace.ResolveModules(ctx) diff --git a/fixture/fixture_test.go b/fixture/fixture_test.go index 6f39666..6dc3a00 100644 --- a/fixture/fixture_test.go +++ b/fixture/fixture_test.go @@ -48,6 +48,7 @@ func composingWorkspace(t *testing.T) { writeFile(t, filepath.Join(root, "modules", "starter", "services", ".keep"), "") writeFile(t, filepath.Join(root, "modules", "plain", "services", ".keep"), "") t.Chdir(root) + t.Setenv(resources.WorkspacePrefix, "") } func TestFixturePrincipalResolvesByRole(t *testing.T) { @@ -132,6 +133,28 @@ func TestFixturePrincipalNamesPinnedModulesItCannotRead(t *testing.T) { "error here has meant a pinned module the SDK never read, not a wrong name") } +// The fixtures a process seeds are its composition's. Under a workspace that is +// not the composition the runtime names — a composed module's checkout or +// cached tag ships its producer's — the producer's packages are not read, even +// though they would resolve. +func TestFixturePrincipalReadsOnlyTheInjectedComposition(t *testing.T) { + composingWorkspace(t) + t.Setenv(resources.FixturePrefix, "dev-admin") + + t.Run("another composition", func(t *testing.T) { + t.Setenv(resources.WorkspacePrefix, "platform-core") + _, err := fixture.Selected().Principal(t.Context(), "super_admin") + require.Error(t, err) + assert.Contains(t, err.Error(), `no workspace on disk is the composition "platform-core"`) + }) + t.Run("this composition", func(t *testing.T) { + t.Setenv(resources.WorkspacePrefix, "solution") + principal, err := fixture.Selected().Principal(t.Context(), "super_admin") + require.NoError(t, err) + assert.Equal(t, "dev-admin", principal.ID) + }) +} + func writeFile(t *testing.T, path string, content string) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) diff --git a/for.go b/for.go index 9bd31ed..f2084fc 100644 --- a/for.go +++ b/for.go @@ -13,6 +13,7 @@ import ( "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/standards" "github.com/codefly-dev/core/wool" + "github.com/codefly-dev/sdk-go/internal/enclosing" ) type Query struct { @@ -115,13 +116,15 @@ var ErrEndpointCarrierAbsent = errors.New("codefly: no carrier for the endpoint // path: the presence of a workspace decides. Native fallback requires a // workspace and a local or unset environment. // -// The workspace found above the process is only the process's composition if -// it carries the queried module. One that does not — a module run from its own -// checkout inside a larger composition, or a released tag resolved into the -// module cache, both of which carry the producer's own workspace under the -// producer's own module name — is answered exactly as a process with no -// workspace is: from the carriers, never from that workspace's declarations or -// native map. +// The workspace found above the process is only consulted if it is the +// process's composition (internal/enclosing): its declared name is the one the +// runtime injected, and it carries the queried module. A module run from its +// own checkout, a released tag in the module cache and a solution's tag all +// ship the producer's workspace, under a name that is not the composition's; +// that workspace is never loaded, and the process is answered exactly as one +// with no workspace is — from the carriers, never from that workspace's +// declarations or native map. With no injected composition, the enclosing +// workspace is the only one there is and is used as found. func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { if err := checkConsumerIdentity(q.consumerModule); err != nil { return nil, err @@ -130,13 +133,16 @@ func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { if strings.TrimSpace(q.module) == "" || strings.TrimSpace(q.service) == "" { return nil, errors.New("module and service are required for endpoint selection") } - workspace, err := resources.FindWorkspaceUp(q.ctx) + workspace, err := enclosing.Composition(q.ctx) if err != nil { return nil, err } - if workspace == nil || !carries(workspace, q.module) { + if workspace == nil { return q.resolveFromAuthoritativeCarrier() } + if !carries(workspace, q.module) { + return q.resolveUncomposedFromCarrier(workspace) + } // The declaration decides eligibility before any carrier can supply an // address. An explicit API is a qualifier core must judge, not a bypass. selected, err := q.selectEndpoint(workspace, &resources.EndpointInformation{ @@ -163,16 +169,29 @@ func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { return local, nil } -// carries reports whether workspace is the composition the queried module runs -// in, by core's own membership rule — the one LoadModuleFromName resolves -// through. A module's checkout and its cached release tag both ship the -// producer's workspace, which knows the module by the producer's name, not by -// the name the composition gave it; selecting against it refuses every query. +// carries reports whether the composition carries the queried module, by core's +// own membership rule — the one LoadModuleFromName resolves through, which sees +// modules composed through `workspaces:` and `solutions:` and aliases. func carries(workspace *resources.Workspace, module string) bool { _, carried := workspace.Member(module) return carried } +// resolveUncomposedFromCarrier answers a query for a module the workspace on +// disk does not carry. With nothing injected to compare its name against, that +// workspace may be a producer's own, so its declarations are not judged and +// the carrier answers. An absent carrier keeps what the workspace did say — +// which modules it carries — because a mistyped module name at a composition +// root lands here too. +func (q *Query) resolveUncomposedFromCarrier(workspace *resources.Workspace) (*resources.NetworkInstance, error) { + instance, err := q.resolveFromAuthoritativeCarrier() + if errors.Is(err, ErrEndpointCarrierAbsent) { + return nil, fmt.Errorf("%w; workspace <%s> does not compose module <%s>, present: %v", + err, workspace.Name, q.module, workspace.ModulesNames()) + } + return instance, err +} + // resolveFromAuthoritativeCarrier answers a workspace-free process from its carriers // alone. The carrier is keyed by the query's canonical identity — the // declaration is not on disk to supply any part of it — and nothing is judged @@ -492,16 +511,24 @@ func (q *Query) localConfigurationValue(infoName string, key string, secret bool if q.service == "" { return "", w.NewError("service is not set") } - workspace, workspaceErr := resources.FindWorkspaceUp(q.ctx) + // Only the process's own composition holds its local configuration files: a + // producer's workspace above a composed module's checkout or cached tag + // holds the producer's, under a module name that may even match. + workspace, workspaceErr := enclosing.Composition(q.ctx) if workspaceErr != nil { return "", workspaceErr } if workspace == nil { - return "", w.NewError("workspace not found") + return "", w.NewError("no %s value for %s/%s in service %s: no workspace on disk is this process's composition", + localValueKind(secret), infoName, key, q.service) } var svc *resources.Service if q.module != "" { + if !carries(workspace, q.module) { + return "", w.NewError("no %s value for %s/%s in service %s: workspace <%s> does not compose module <%s>, present: %v", + localValueKind(secret), infoName, key, q.service, workspace.Name, q.module, workspace.ModulesNames()) + } mod, moduleErr := workspace.LoadModuleFromName(q.ctx, q.module) if moduleErr != nil { return "", moduleErr @@ -534,12 +561,15 @@ func (q *Query) localConfigurationValue(infoName string, key string, secret bool } } } - kind := "configuration" + return "", w.NewError("no %s value for %s/%s in service %s (env=%s)", + localValueKind(secret), infoName, key, q.service, envName) +} + +func localValueKind(secret bool) string { if secret { - kind = "secret" + return "secret" } - return "", w.NewError("no %s value for %s/%s in service %s (env=%s)", - kind, infoName, key, q.service, envName) + return "configuration" } func localConfigurationEnvironmentName() string { diff --git a/internal/enclosing/enclosing.go b/internal/enclosing/enclosing.go new file mode 100644 index 0000000..b5a1363 --- /dev/null +++ b/internal/enclosing/enclosing.go @@ -0,0 +1,87 @@ +// Package enclosing decides whether the workspace on disk above a process is +// that process's composition. Every SDK reader that falls back to the +// workspace — endpoint selection and the native map, the local configuration +// and secret files, the fixture packages, the workspace name — asks here, so +// the SDK has one answer to the question rather than one per reader. +package enclosing + +import ( + "context" + "os" + "strings" + + "github.com/codefly-dev/core/resources" +) + +// Injected is the composition the runtime started this process in, or "" when +// none did. Core's EnvironmentVariableManager.SetIdentity sets it for every +// agent-run service, so a process started by `codefly run` always carries it; +// a local tool, a test or `go run` does not. +func Injected() string { + return strings.TrimSpace(os.Getenv(resources.WorkspacePrefix)) +} + +// Composition returns the workspace above the process when it is the +// composition the process runs in, loaded and composed, and nil when there is +// none or the one found is someone else's. +// +// With an injected composition, a workspace counts only if it declares that +// name. A composed module's own checkout, a released tag in the module cache +// and a solution's tag all ship their producer's workspace file, which the +// process finds first and which says nothing about the composition it runs in. +// Such a workspace is never loaded: loading composes its imports, and a +// solution's tag imports its platform by release, which this SDK cannot +// resolve ("requires host resolution"). A workspace file that cannot be +// decoded cannot claim the name either. The caller then answers from what the +// composition injected, as it does for a process with no workspace at all. +// +// With nothing injected, no composition started the process, and the enclosing +// workspace is the only one there is: it is loaded as found, and a load failure +// is returned. +func Composition(ctx context.Context) (*resources.Workspace, error) { + dir, err := find(ctx) + if err != nil || dir == "" { + return nil, err + } + if injected := Injected(); injected != "" { + if name, ok := declaredName(ctx, dir); !ok || name != injected { + return nil, nil + } + } + return resources.LoadWorkspaceFromDir(ctx, dir) +} + +// Name is the composition's name: the injected one, else the name the +// enclosing workspace file declares. The file is decoded, not composed, so a +// workspace whose imports only the CLI can resolve still has a name. "" means +// neither exists. +func Name(ctx context.Context) (string, error) { + if injected := Injected(); injected != "" { + return injected, nil + } + dir, err := find(ctx) + if err != nil || dir == "" { + return "", err + } + name, _ := declaredName(ctx, dir) + return name, nil +} + +func find(ctx context.Context) (string, error) { + dir, err := resources.FindUp[resources.Workspace](ctx) + if err != nil || dir == nil { + return "", err + } + return *dir, nil +} + +// declaredName reads the name the workspace file declares, without composing +// it or validating anything else about it. +func declaredName(ctx context.Context, dir string) (string, bool) { + declared, err := resources.LoadFromDir[resources.Workspace](ctx, dir) + if err != nil || declared == nil { + return "", false + } + name := strings.TrimSpace(declared.Name) + return name, name != "" +} diff --git a/scripts/allowed-imports.txt b/scripts/allowed-imports.txt index 959e864..2996e24 100644 --- a/scripts/allowed-imports.txt +++ b/scripts/allowed-imports.txt @@ -67,6 +67,7 @@ root github.com/codefly-dev/core/runnable root github.com/codefly-dev/core/standards root github.com/codefly-dev/core/wool root github.com/codefly-dev/sdk-go/receipts +root github.com/codefly-dev/sdk-go/internal/enclosing root go/parser root go/token root google.golang.org/grpc From 10c84fb7de6e28db417dde527d013f2daace0c3d Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:52:20 -0400 Subject: [PATCH 3/3] fix(endpoints): an undecodable workspace file is reported, and the injected name is matched exactly (#66) Verification of #68 (N1, N2). Under an injected composition, a workspace file that does not decode cannot name itself, so it still does not count and the injected carrier answers. But when no carrier was injected either, that file may have been the composition's own, so enclosing.Composition now returns it as Unreadable and the refusal (endpoint, configuration fallback, fixture lookup) carries its decode error instead of dropping it. With no composition injected, the decode error is returned as before. Tests pin the exact-match rule (a case-folded name does not count) and the undecodable file in all three cases: injected with a carrier, injected without one, and nothing injected. Docs: `codefly test` injects the identity too; only a plain `go test` or `go run` runs without it. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 12 +++-- endpoint_composition_root_test.go | 15 +++++++ endpoint_foreign_workspace_test.go | 43 ++++++++++++++++++ fixture/fixture.go | 6 ++- for.go | 15 +++++-- internal/enclosing/enclosing.go | 70 ++++++++++++++++++++---------- 6 files changed, 130 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index fde3a4f..f16884b 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,15 @@ is computed under the composition's name. A module it does not carry is answered from the carriers, and an absent carrier's refusal names the modules the workspace does carry. -With no injected identity, no composition started the process (a local tool, a -test, `go run`), and the workspace found is the only one there is: it is used -as found. +`codefly run` and `codefly test` both inject that identity. With none injected, +no composition started the process (a local tool, a plain `go test` or `go +run`), and the workspace found is the only one there is: it is used as found. + +A workspace file that cannot be decoded cannot name itself, so under an +injected identity it does not count and the injected carrier still answers. If +no carrier was injected either, the refusal carries the decode error, since that +file may have been the composition's own. With no identity injected, the decode +error is returned as it always was. The same rule decides every other read that falls back to the workspace on disk: the local configuration and secret files behind `Configuration` and diff --git a/endpoint_composition_root_test.go b/endpoint_composition_root_test.go index 4440086..44381f3 100644 --- a/endpoint_composition_root_test.go +++ b/endpoint_composition_root_test.go @@ -129,3 +129,18 @@ func TestACompositionRootNamesWhatItCarriesWhenAModuleIsUnknown(t *testing.T) { }) } } + +// The injected name is compared as written. A workspace whose name differs from +// it only in case is another workspace: it is not judged, and the injected +// carrier answers, where the composition's own declarations would refuse. +func TestACompositionRootCountsOnlyUnderTheExactInjectedName(t *testing.T) { + prepareCompositionRoot(t, "PLATFORM-OBIN") + injectEndpointForTest(t, "runtime", &resources.EndpointAccess{ + Endpoint: &basev0.Endpoint{Module: "producer", Service: "records", Name: "rest", Api: "rest"}, + NetworkInstance: &basev0.NetworkInstance{Address: "http://localhost:24601"}, + }) + instance, err := codefly.For(context.Background()).Module("producer").Service("records"). + Endpoint("rest").API("rest").ResolveNetworkInstance() + require.NoError(t, err, "a case-folded match would have judged the private endpoint and refused it") + require.Equal(t, "http://localhost:24601", instance.Address) +} diff --git a/endpoint_foreign_workspace_test.go b/endpoint_foreign_workspace_test.go index 6999682..c68dde1 100644 --- a/endpoint_foreign_workspace_test.go +++ b/endpoint_foreign_workspace_test.go @@ -228,3 +228,46 @@ func TestAProcessNoCompositionStartedUsesTheWorkspaceAsFound(t *testing.T) { require.NoError(t, err) require.Equal(t, "the-producers-own-value", value) } + +// A workspace file that does not decode names nothing, so under an injected +// composition it does not count and the injected carrier answers. When nothing +// was injected for the query either, that file may have been the composition's +// own, and the refusal carries its decode error rather than dropping it. With +// no composition injected, the decode error is the answer, as it always was. +func TestAnUndecodableWorkspaceFileIsReportedNotTrusted(t *testing.T) { + const yamlError = "did not find expected node content" + prepare := func(t *testing.T, injected string) { + t.Helper() + prepareProcessWithoutWorkspace(t, "local") + t.Setenv(resources.WorkspacePrefix, injected) + root := t.TempDir() + writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), "name: "+composition+"\nmodules: [\n") + t.Chdir(root) + } + query := func() *codefly.Query { return queryCarriedService().Endpoint("rest").API("rest") } + + t.Run("injected, carrier present", func(t *testing.T) { + prepare(t, composition) + injectEndpointForTest(t, "runtime", carrierFor("rest", "rest", "http://localhost:24601")) + instance, err := query().ResolveNetworkInstance() + require.NoError(t, err, "an unreadable file is not loaded, so it cannot stop the carrier") + require.Equal(t, "http://localhost:24601", instance.Address) + }) + t.Run("injected, carrier absent", func(t *testing.T) { + prepare(t, composition) + instance, err := query().ResolveNetworkInstance() + require.ErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) + require.ErrorContains(t, err, "could not be read") + require.ErrorContains(t, err, yamlError) + require.Nil(t, instance) + _, err = queryCarriedService().Configuration("api", "url") + require.ErrorContains(t, err, yamlError, "the configuration fallback says why too") + }) + t.Run("nothing injected", func(t *testing.T) { + prepare(t, "") + instance, err := query().ResolveNetworkInstance() + require.ErrorContains(t, err, yamlError) + require.NotErrorIs(t, err, codefly.ErrEndpointCarrierAbsent) + require.Nil(t, instance) + }) +} diff --git a/fixture/fixture.go b/fixture/fixture.go index f8f9d90..4c8c1bf 100644 --- a/fixture/fixture.go +++ b/fixture/fixture.go @@ -70,10 +70,14 @@ func composedPackages(ctx context.Context) ([]*composition.PackageManifest, []st // Only the process's own composition says which packages it composes. A // producer's workspace above a composed module's checkout or cached tag // would resolve the producer's fixtures, not the composition's. - workspace, err := enclosing.Composition(ctx) + workspace, unreadable, err := enclosing.Composition(ctx) if err != nil { return nil, nil, fmt.Errorf("resolve Codefly workspace: %w", err) } + if unreadable != nil { + return nil, nil, fmt.Errorf("resolve Codefly workspace: the workspace file in %s could not be read, so it was not taken for the composition %q: %w", + unreadable.Dir, enclosing.Injected(), unreadable.Err) + } if workspace == nil { if injected := enclosing.Injected(); injected != "" { return nil, nil, fmt.Errorf("resolve Codefly workspace: no workspace on disk is the composition %q this process runs in", injected) diff --git a/for.go b/for.go index f2084fc..180c1fc 100644 --- a/for.go +++ b/for.go @@ -133,12 +133,17 @@ func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { if strings.TrimSpace(q.module) == "" || strings.TrimSpace(q.service) == "" { return nil, errors.New("module and service are required for endpoint selection") } - workspace, err := enclosing.Composition(q.ctx) + workspace, unreadable, err := enclosing.Composition(q.ctx) if err != nil { return nil, err } if workspace == nil { - return q.resolveFromAuthoritativeCarrier() + instance, carrierErr := q.resolveFromAuthoritativeCarrier() + if unreadable != nil && errors.Is(carrierErr, ErrEndpointCarrierAbsent) { + return nil, fmt.Errorf("%w; the workspace file in %s could not be read, so it was not taken for the composition %q: %w", + carrierErr, unreadable.Dir, enclosing.Injected(), unreadable.Err) + } + return instance, carrierErr } if !carries(workspace, q.module) { return q.resolveUncomposedFromCarrier(workspace) @@ -514,10 +519,14 @@ func (q *Query) localConfigurationValue(infoName string, key string, secret bool // Only the process's own composition holds its local configuration files: a // producer's workspace above a composed module's checkout or cached tag // holds the producer's, under a module name that may even match. - workspace, workspaceErr := enclosing.Composition(q.ctx) + workspace, unreadable, workspaceErr := enclosing.Composition(q.ctx) if workspaceErr != nil { return "", workspaceErr } + if unreadable != nil { + return "", w.NewError("no %s value for %s/%s in service %s: the workspace file in %s could not be read, so it was not taken for the composition %q: %s", + localValueKind(secret), infoName, key, q.service, unreadable.Dir, enclosing.Injected(), unreadable.Err) + } if workspace == nil { return "", w.NewError("no %s value for %s/%s in service %s: no workspace on disk is this process's composition", localValueKind(secret), infoName, key, q.service) diff --git a/internal/enclosing/enclosing.go b/internal/enclosing/enclosing.go index b5a1363..8c911bb 100644 --- a/internal/enclosing/enclosing.go +++ b/internal/enclosing/enclosing.go @@ -15,40 +15,56 @@ import ( // Injected is the composition the runtime started this process in, or "" when // none did. Core's EnvironmentVariableManager.SetIdentity sets it for every -// agent-run service, so a process started by `codefly run` always carries it; -// a local tool, a test or `go run` does not. +// agent-run service, so a process started by `codefly run` or `codefly test` +// carries it; a local tool, a plain `go test` or `go run` does not. func Injected() string { return strings.TrimSpace(os.Getenv(resources.WorkspacePrefix)) } +// Unreadable is a workspace file above the process that could not be decoded, +// so it could not name itself and did not count. A caller that then finds +// nothing injected for it reports this too: when that file was the +// composition's own, the decode error is the diagnosis. +type Unreadable struct { + Dir string + Err error +} + // Composition returns the workspace above the process when it is the // composition the process runs in, loaded and composed, and nil when there is // none or the one found is someone else's. // -// With an injected composition, a workspace counts only if it declares that -// name. A composed module's own checkout, a released tag in the module cache -// and a solution's tag all ship their producer's workspace file, which the -// process finds first and which says nothing about the composition it runs in. -// Such a workspace is never loaded: loading composes its imports, and a -// solution's tag imports its platform by release, which this SDK cannot -// resolve ("requires host resolution"). A workspace file that cannot be -// decoded cannot claim the name either. The caller then answers from what the -// composition injected, as it does for a process with no workspace at all. +// With an injected composition, a workspace counts only if it declares exactly +// that name — compared as written, as the runtime wrote it. A composed module's +// own checkout, a released tag in the module cache and a solution's tag all +// ship their producer's workspace file, which the process finds first and which +// says nothing about the composition it runs in. Such a workspace is never +// loaded: loading composes its imports, and a solution's tag imports its +// platform by release, which this SDK cannot resolve ("requires host +// resolution"). A workspace file that cannot be decoded cannot claim the name +// either; it is returned as Unreadable rather than loaded, so the caller can +// still answer from what the composition injected, and say why the file did +// not count when nothing was. // // With nothing injected, no composition started the process, and the enclosing // workspace is the only one there is: it is loaded as found, and a load failure -// is returned. -func Composition(ctx context.Context) (*resources.Workspace, error) { +// — an undecodable file included — is returned. +func Composition(ctx context.Context) (*resources.Workspace, *Unreadable, error) { dir, err := find(ctx) if err != nil || dir == "" { - return nil, err + return nil, nil, err } if injected := Injected(); injected != "" { - if name, ok := declaredName(ctx, dir); !ok || name != injected { - return nil, nil + name, decodeErr := declaredName(ctx, dir) + if decodeErr != nil { + return nil, &Unreadable{Dir: dir, Err: decodeErr}, nil + } + if name != injected { + return nil, nil, nil } } - return resources.LoadWorkspaceFromDir(ctx, dir) + workspace, err := resources.LoadWorkspaceFromDir(ctx, dir) + return workspace, nil, err } // Name is the composition's name: the injected one, else the name the @@ -63,7 +79,10 @@ func Name(ctx context.Context) (string, error) { if err != nil || dir == "" { return "", err } - name, _ := declaredName(ctx, dir) + name, err := declaredName(ctx, dir) + if err != nil { + return "", err + } return name, nil } @@ -76,12 +95,15 @@ func find(ctx context.Context) (string, error) { } // declaredName reads the name the workspace file declares, without composing -// it or validating anything else about it. -func declaredName(ctx context.Context, dir string) (string, bool) { +// it or validating anything else about it. A file that decodes with no name +// names nothing, which matches no injected composition. +func declaredName(ctx context.Context, dir string) (string, error) { declared, err := resources.LoadFromDir[resources.Workspace](ctx, dir) - if err != nil || declared == nil { - return "", false + if err != nil { + return "", err + } + if declared == nil { + return "", nil } - name := strings.TrimSpace(declared.Name) - return name, name != "" + return strings.TrimSpace(declared.Name), nil }