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
5 changes: 4 additions & 1 deletion cmd/doctor_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,10 @@ func checkServiceConfigurationSources(ctx context.Context, env *environments.Env
// module's, is reported and returns nil. Duplicate keys within one
// configuration are flagged as for a service directory.
func loadWorkspaceConfigurations(ctx context.Context, ws *resources.Workspace, env *resources.Environment, relCfgDir string, report *workspaceReadinessReport) *configurations.WorkspaceConfigurations {
provided, err := configurations.ReadWorkspaceConfigurations(ctx, ws, env)
// The same acquisition a run supplies, so the doctor reports what a run
// would deliver for a bound module artifact rather than core's
// "requires a resolver" refusal.
provided, err := configurations.ReadWorkspaceConfigurations(ctx, ws, env, orchestration.ModuleArtifactResolverFor(ws))
if err != nil {
code := codeConfigurationInvalid
if errors.Is(err, configurations.ErrConfigurationConflict) {
Expand Down
3 changes: 2 additions & 1 deletion cmd/environment/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/codefly-dev/cli/cmd/common"
"github.com/codefly-dev/cli/pkg/cli/yamledit"
"github.com/codefly-dev/cli/pkg/environments"
"github.com/codefly-dev/cli/pkg/orchestration"
"github.com/codefly-dev/core/resources"
"github.com/codefly-dev/core/shared"
"github.com/pmezard/go-difflib/difflib"
Expand Down Expand Up @@ -281,7 +282,7 @@ func runImport(ctx context.Context, opts *importOptions) error {
if err := environments.ValidateEnvironmentExclusions(ctx, workspace, selected); err != nil {
return fmt.Errorf("product intent: %w", err)
}
if err := environments.ValidateSecretPolicy(ctx, workspace, selected, intent.ServiceSecrets); err != nil {
if err := environments.ValidateSecretPolicy(ctx, workspace, selected, intent.ServiceSecrets, orchestration.ModuleArtifactResolverFor(workspace)); err != nil {
return fmt.Errorf("product intent: %w", err)
}
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/generate/runnable_bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/codefly-dev/cli/cmd/common"
"github.com/codefly-dev/cli/pkg/cli"
"github.com/codefly-dev/cli/pkg/environments"
"github.com/codefly-dev/cli/pkg/orchestration"
"github.com/codefly-dev/cli/pkg/remotenetwork"
runnablespkg "github.com/codefly-dev/cli/pkg/runnables"
"github.com/codefly-dev/core/composition"
Expand Down Expand Up @@ -655,6 +656,10 @@ func newEndpointResolver(ctx context.Context, workspace *resources.Workspace, en
if err != nil {
return nil, err
}
// The same acquisition every other delivery and check load supplies: a
// bound module artifact reaching this manager must be resolved from the
// selected module, not refused as unresolvable (module_artifact.go).
reader.WithModuleArtifactResolver(orchestration.ModuleArtifactResolverFor(workspace))
manager.WithLoader(reader)
if resolver.remote, err = remotenetwork.NewRemoteManager(ctx, manager); err != nil {
return nil, err
Expand Down
94 changes: 85 additions & 9 deletions cmd/generate/runnables.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
Expand Down Expand Up @@ -159,6 +160,9 @@ type derivation struct {
files map[string][]byte
publication *modulecontract.Contract
publicationErr error
// publicationAbsent means the module publishes no contract AT ALL, as
// distinct from publishing one that could not be read.
publicationAbsent bool
// release is the module's own release version, empty for a module that
// declares no package manifest.
release string
Expand Down Expand Up @@ -271,6 +275,13 @@ func deriveRunnablesIn(ctx context.Context, workspaceName, workspaceDir string,
// Load the selected owner's declaration once. A module with no operations
// still produces an empty index; an operation cannot publish without it.
derived.publication, derived.publicationErr = modulecontract.Load(module.Dir())
// Whether the module HAS a declaration is a different question from
// whether it could be read, and only an existence observation answers it.
// Core's loader reads through os.Root, which reports a dangling in-root
// symlink as fs.ErrNotExist — so the error alone cannot tell "this module
// publishes no contract" from "its contract is a broken link". Core keeps
// owning the content; this is only the entry.
derived.publicationAbsent = derived.publicationErr != nil && !publicationEntryExists(module.Dir())

var endpoints []composition.APIContractEndpoint
if catalog != nil {
Expand Down Expand Up @@ -546,16 +557,60 @@ func (d *derivation) audience() string {
// publication principal is independent of the producer checkout's module name;
// an explicitly declared operation must agree, never be silently rewritten.
func (d *derivation) record(endpoint *composition.APIContractEndpoint, name string, pkg *basev0.RunnablePackage, spec *corerunnable.OperationSpec, origin derivedOperation) error {
if d.publicationErr != nil {
return fmt.Errorf("runnable %s/%s/%s needs its owner's module contract: %w", endpoint.Service, endpoint.Endpoint, name, d.publicationErr)
// An unreadable or malformed contract is never absence. A module that
// authored one and got it wrong must be told so, whatever the operation —
// turning a schema skew, a bad field or a dangling link into "no module
// publication applies" would admit exactly the declarations the strict
// reader exists to refuse.
if d.publicationErr != nil && !d.publicationAbsent {
return fmt.Errorf("runnable %s/%s/%s cannot read its owner's module contract: %w", endpoint.Service, endpoint.Endpoint, name, d.publicationErr)
}
if d.publication == nil || !slices.ContainsFunc(d.publication.Destinations, func(destination modulecontract.Destination) bool {
return destination.Service == endpoint.Service && destination.Endpoint == endpoint.Endpoint
}) {
return fmt.Errorf("module contract does not publish runnable destination %s/%s", endpoint.Service, endpoint.Endpoint)
}
if spec.Audience != d.publication.Principal {
return fmt.Errorf("runnable %s/%s/%s audience %q differs from module contract principal %q", endpoint.Service, endpoint.Endpoint, name, spec.Audience, d.publication.Principal)
switch {
case d.publicationErr == nil:
// A real contract exists, so the module's publication applies to every
// operation it serves — including an explicit one. Its destinations are
// the endpoints it exposes, and its principal is the one audience its
// credentials are issued to: an explicit audience that differs from it
// is a conflict in the module's own declarations, not host permission,
// and is refused rather than reinterpreted.
if !slices.ContainsFunc(d.publication.Destinations, func(destination modulecontract.Destination) bool {
return destination.Service == endpoint.Service && destination.Endpoint == endpoint.Endpoint
}) {
return fmt.Errorf("module contract does not publish runnable destination %s/%s", endpoint.Service, endpoint.Endpoint)
}
if spec.Audience != d.publication.Principal {
return fmt.Errorf("runnable %s/%s/%s audience %q differs from module contract principal %q", endpoint.Service, endpoint.Endpoint, name, spec.Audience, d.publication.Principal)
}
case !origin.explicit:
// Absent, and this operation's audience was DERIVED.
// No contract at all, and this operation's audience was DERIVED — core
// defaults it from ServiceOwner.Audience, which this generation fills
// from the module publication. With no publication there is no
// principal to derive from, so the audience would be empty: refuse,
// naming what is missing.
return fmt.Errorf("runnable %s/%s/%s derives its audience by default and so needs its owner's module contract: %w", endpoint.Service, endpoint.Endpoint, name, d.publicationErr)
default:
// No contract, and the method carries the operation option — which is
// what core's OperationSpec.Declared marks: the OPTION's presence, not
// the presence of an audience in it. So the audience is the author's
// own, read from that option rather than derived from any module
// principal: a host surface a module reaches without being installed as
// one. Core does not require it to equal a principal, and requiring a
// contract here demanded a module identity the module does not have.
//
// Core's OperationFromMethod validates the audience before it sets
// Declared, so an annotated method with an empty audience does not
// reach here; the check is kept anyway, because this branch is the one
// place an audience is accepted without a principal to compare it
// against, and it must not be the one that lets an empty one through.
//
// The served-target, scope and lookup checks upstream are unchanged, so
// an unserved method or a missing paired Lookup still refuses, and the
// explicit audience and scopes are carried through exactly as core
// derived them.
if spec.Audience == "" {
return fmt.Errorf("runnable %s/%s/%s carries the operation option but declares no audience, and its owner publishes no module contract", endpoint.Service, endpoint.Endpoint, name)
}
}
operation := pkg.GetServiceOperations()[0]
packageBytes, err := runnablespkg.CanonicalJSON(pkg)
Expand Down Expand Up @@ -708,3 +763,24 @@ func kebab(name string) string {
func methodName(fullMethod string) string {
return fullMethod[strings.LastIndex(fullMethod, "/")+1:]
}

// publicationEntryExists reports whether the module directory holds a
// module-contract entry, whatever the state of what it points at.
//
// Lstat, not Stat: a dangling symlink IS a declaration the module authored,
// and following it would report the same "not found" that Core's loader
// already reports — which is exactly the conflation this exists to break.
// Confined through os.Root like every other read of a composed module's tree,
// and any failure to observe is read as "present", so an unobservable entry
// refuses rather than passing as absence.
func publicationEntryExists(moduleDir string) bool {
root, err := os.OpenRoot(moduleDir)
if err != nil {
return true
}
defer func() { _ = root.Close() }()
if _, err := root.Lstat(modulecontract.FileName); err != nil {
return !errors.Is(err, fs.ErrNotExist)
}
return true
}
113 changes: 108 additions & 5 deletions cmd/generate/runnables_audience_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ func TestRunnableAudienceRefusalsLeaveOutputUnchanged(t *testing.T) {
edit func(*testing.T, string)
want string
}{
{"missing", func(t *testing.T, dir string) {
require.NoError(t, os.Remove(filepath.Join(dir, modulecontract.FileName)))
}, "needs its owner's module contract"},
// A contract that exists and cannot be read is never absence: a module
// that authored one and got it wrong is told so, whatever kind of
// operation it serves.
{"malformed", func(t *testing.T, dir string) {
writeFixtureFile(t, filepath.Join(dir, modulecontract.FileName), []byte("schema: codefly/module-contract/v1\nprincipal: documents\nunknown: true\n"))
}, "needs its owner's module contract"},
}, "cannot read its owner's module contract"},
{"uncovered destination", func(t *testing.T, dir string) {
saveRunnablePublication(t, dir, "documents.ingestion", modulecontract.Destination{ID: "other", Service: "runtime-worker", Endpoint: "other", Kind: "module"})
}, "does not publish runnable destination runtime-worker/grpc"},
Expand All @@ -54,7 +54,7 @@ func TestRunnableAudienceRefusalsLeaveOutputUnchanged(t *testing.T) {
writeFixtureFile(t, outside, data)
require.NoError(t, os.Remove(filepath.Join(dir, modulecontract.FileName)))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, modulecontract.FileName)))
}, "needs its owner's module contract"},
}, "cannot read its owner's module contract"},
} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
Expand All @@ -80,3 +80,106 @@ func TestRunnableAudienceChecksOpenAPIPublication(t *testing.T) {
require.ErrorContains(t, RunnablesCmd.RunE(RunnablesCmd, []string{"releases"}), "differs from module contract principal")
require.Equal(t, before, snapshotTree(t, moduleDir))
}

// TestRunnableExplicitAudienceNeedsNoModuleContract is the SaaS case, and the
// behaviour that replaces a universal gate.
//
// The author annotated the method's audience explicitly (core sets
// OperationSpec.Declared from the method option), so the audience is theirs —
// a host surface the module reaches without being installed as one. Core does
// not require it to equal any module principal, and demanding a module
// contract here demanded an identity the module does not have. Generation
// proceeds and carries the explicit audience, its scopes and its lookup
// through unchanged.
func TestRunnableExplicitAudienceNeedsNoModuleContract(t *testing.T) {
ctx := context.Background()
root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(conformingOperation())), "0.1.0")
t.Chdir(root)
resetRunnablesFlags(t)
require.NoError(t, os.Remove(filepath.Join(moduleDir, modulecontract.FileName)))

require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"}))

data, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(runnablespkg.DerivedDir),
"runtime-worker", "grpc", "ApplyText", runnablespkg.OperationFileName))
require.NoError(t, err)
var operation map[string]any
require.NoError(t, json.Unmarshal(data, &operation))
require.Equal(t, "documents.ingestion", operation["audience"], "the explicit audience is preserved, not derived")
require.Equal(t, "/documents.ingest.v1.IngestionService/LookupText", operation["lookup_method"], "and its lookup")
require.NotEmpty(t, operation["invoke_scopes"], "and its scopes")
require.NotEmpty(t, operation["lookup_scopes"])
}

// TestRunnableDefaultAudienceStillNeedsTheModuleContract keeps the waiver
// narrow. A method with no explicit audience takes core's default from
// ServiceOwner.Audience, which this generation fills from the module
// publication — so with no contract there is no principal to derive from, and
// it refuses by name rather than emitting an empty audience.
func TestRunnableDefaultAudienceStillNeedsTheModuleContract(t *testing.T) {
ctx := context.Background()
file := ingestionFile(conformingOperation(), hopPolicyMethod("CountText", "documents", "read"))
root, moduleDir := saveRunnableFixture(t, ctx, withHopPolicy(t, descriptorSet(t, file)), "0.1.0")
t.Chdir(root)
resetRunnablesFlags(t)
require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"}))
before := snapshotTree(t, moduleDir)

require.NoError(t, os.Remove(filepath.Join(moduleDir, modulecontract.FileName)))
err := RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})
require.ErrorContains(t, err, "derives its audience by default and so needs its owner's module contract")
require.Equal(t, before, snapshotTree(t, moduleDir), "a refusal leaves the output unchanged")
}

// TestRunnableDanglingModuleContractIsNotAbsence closes the gap between "no
// declaration" and "a declaration that cannot be read".
//
// Core's loader reads through os.Root, and a dangling symlink INSIDE the module
// reports fs.ErrNotExist — the same error as a module that published nothing.
// Treating that as absence would let a broken declaration take the explicit-
// audience path, which is the one path that accepts an audience with no
// principal to check it against. The entry exists, so generation refuses and
// writes nothing.
func TestRunnableDanglingModuleContractIsNotAbsence(t *testing.T) {
ctx := context.Background()
root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(conformingOperation())), "0.1.0")
t.Chdir(root)
resetRunnablesFlags(t)
require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"}))
before := snapshotTree(t, moduleDir)

// A link to a sibling that does not exist: in-root, so os.Root resolves it
// rather than refusing the escape, and finds nothing.
contract := filepath.Join(moduleDir, modulecontract.FileName)
require.NoError(t, os.Remove(contract))
require.NoError(t, os.Symlink("absent.module.contract.codefly.yaml", contract))
entry, err := os.Lstat(contract)
require.NoError(t, err, "the declaration entry exists")
require.NotZero(t, entry.Mode()&os.ModeSymlink)
_, statErr := os.Stat(contract)
require.True(t, os.IsNotExist(statErr), "and what it points at does not")

err = RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})
require.ErrorContains(t, err, "cannot read its owner's module contract")
require.Equal(t, before, snapshotTree(t, moduleDir), "a refusal leaves the output unchanged")
}

// TestRunnableAnnotatedMethodWithNoAudienceRefuses pins the floor under the
// explicit branch. Core's OperationFromMethod validates the audience before it
// sets Declared, so this should be unreachable through core — which is the
// point: the branch that accepts an audience without a principal must not be
// the one that accepts an empty one, whatever reaches it.
func TestRunnableAnnotatedMethodWithNoAudienceRefuses(t *testing.T) {
ctx := context.Background()
operation := conformingOperation()
operation.Audience = ""
root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(operation)), "0.1.0")
t.Chdir(root)
resetRunnablesFlags(t)
require.NoError(t, os.Remove(filepath.Join(moduleDir, modulecontract.FileName)))
before := snapshotTree(t, moduleDir)

require.Error(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"}),
"an annotated method with no audience cannot publish without a principal")
require.Equal(t, before, snapshotTree(t, moduleDir), "and writes nothing")
}
35 changes: 35 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,41 @@ run selected (`--env`), the excluded root's file included. A writer handed no
environment refuses by name rather than writing a document no reader could
find.

**What `--output-env` can represent, and what it refuses.** The file is raw
`KEY=value` lines, and the SDK reader that imports it
(`LoadRuntimeEnvironmentFile`) scans lines, strips one trailing CR, cuts at the
first `=`, and refuses the **whole** artifact if any name is not `CODEFLY__`
plus at least one character of `A`–`Z`, `0`–`9` or `_`. So before its first
write the run holds the complete output batch — own, workspace, dependency and
runtime configuration, `--set` and derived overrides, identity, fixture and
endpoint records alike — to exactly that grammar, and refuses any record it
cannot represent:

- a name outside the reader's grammar (no `CODEFLY__` prefix, no suffix,
lowercase, hyphen, whitespace, non-ASCII, or carrying `=`, CR, LF or NUL);
- a **value** containing LF, CR or NUL, whichever record it belongs to. A value
carrying a line break is the reason the rule covers every record and not just
the interesting ones: the reader would read its tail as a further `KEY=value`
line, so an unrelated note could substitute a value the run never offered.

Empty values, and values holding one or many `=`, remain representable — the
reader splits on the first separator only. A refused batch leaves an absent
file absent and an existing one byte-identical; a malformed record already in
an existing file is refused rather than rewritten under this run's name.
Direct process delivery is unaffected and still carries a value's exact bytes,
line breaks included: this is a limit of the raw file format, not of
configuration.

Two consequences worth stating plainly. An override whose key is not a
`CODEFLY__` carrier (`--set FOO=bar`) now refuses the export rather than
writing a file the reader would reject wholesale. And a **structured
configuration document** cannot be exported this way at all: its carrier is a
prefix plus a lowercase hex SHA256, which that grammar does not admit, so the
reader has never been able to load such a file. The CLI now says so before
writing instead of reporting success. Making it exportable needs an uppercase
document carrier in core, or a reader in the SDK that accepts this one; neither
is the CLI's to decide.

**The delivery host's federation surface.** In a host environment the native
run hands the service `host.delivery` names, when it declares the `federation`
workspace configuration, the three keys that turn its delivery surface on,
Expand Down
Loading
Loading