Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 13 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand All @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions codefly.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 25 additions & 2 deletions codefly_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
}

Expand Down Expand Up @@ -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")
Expand Down
146 changes: 146 additions & 0 deletions endpoint_composition_root_test.go
Original file line number Diff line number Diff line change
@@ -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 <prodcuer>")
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)
}
Loading
Loading