diff --git a/AGENTS.md b/AGENTS.md index 1b8e744..a5bcd89 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 @@ -92,7 +85,7 @@ package line. | `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 | @@ -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 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 cadf566..f16884b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,41 @@ 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 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. + +`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 +`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 blank with `ErrConsumerNotIdentified`. Every codefly-managed flow sets that 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..44381f3 --- /dev/null +++ b/endpoint_composition_root_test.go @@ -0,0 +1,146 @@ +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) + }) + } +} + +// 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 new file mode 100644 index 0000000..c68dde1 --- /dev/null +++ b/endpoint_foreign_workspace_test.go @@ -0,0 +1,273 @@ +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 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 { + return writeRenamedProducer(t, t.TempDir()) + }, premiseRefusesTheModule, []string{composition, ""}}, + {"module cache tag", func(t *testing.T) string { + 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"}}, +} + +// 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"), + "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 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.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) { + 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("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()) + 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, 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) { + forEachForeignLayout(t, func(t *testing.T, injected string, layout foreignLayout) { + for _, environment := range []string{"local", "", "production"} { + for _, form := range referenceForms { + 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) + 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 anything the +// producer's workspace would have said. +func TestAComposedModuleUnderItsOwnWorkspaceRefusesAMalformedCarrier(t *testing.T) { + 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) +} + +// 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/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..4c8c1bf 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,21 @@ 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, 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) + } 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 2d5b37b..180c1fc 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 { @@ -114,6 +115,16 @@ 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 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 @@ -122,12 +133,20 @@ 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, 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) } // The declaration decides eligibility before any carrier can supply an // address. An explicit API is a qualifier core must judge, not a bypass. @@ -155,6 +174,29 @@ func (q *Query) ResolveNetworkInstance() (*resources.NetworkInstance, error) { return local, nil } +// 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 @@ -474,16 +516,28 @@ 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, 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("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 @@ -516,12 +570,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..8c911bb --- /dev/null +++ b/internal/enclosing/enclosing.go @@ -0,0 +1,109 @@ +// 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` 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 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 +// — 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, nil, err + } + if injected := Injected(); injected != "" { + name, decodeErr := declaredName(ctx, dir) + if decodeErr != nil { + return nil, &Unreadable{Dir: dir, Err: decodeErr}, nil + } + if name != injected { + return nil, nil, nil + } + } + workspace, err := resources.LoadWorkspaceFromDir(ctx, dir) + return workspace, nil, err +} + +// 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, err := declaredName(ctx, dir) + if err != nil { + return "", err + } + 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. 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 { + return "", err + } + if declared == nil { + return "", nil + } + return strings.TrimSpace(declared.Name), nil +} 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