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
15 changes: 13 additions & 2 deletions cmd/generate/runnable_bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ owner must still serve it.
In workspace.codefly.yaml, environments[].runnable-scope-selections maps each
binding key (MODULE__OPERATION) to Core ScopeSelection messages: slot, invoke,
and lookup. Every required slot needs exact resource kinds, actions and ids;
missing selections, wildcards and overlapping kinds are refused. Tool exposure
is carried from the owner. See docs/runnable.md for the selection YAML.
missing selections, wildcards and overlapping kinds are refused. Every
operation is a tool; its presentation and effect are carried from the derived
policy. See docs/runnable.md for the selection YAML.

An environment that installs an operation for no resource answers its key with
an explicitly empty list, KEY: []. That operation is declined: its key is absent
Expand Down Expand Up @@ -528,6 +529,15 @@ func operationSpec(declared *runnablespkg.Operation, spelling string, codes core
return nil, fmt.Errorf("completion %q is not a completion mode; %s and %s are the two",
declared.Completion, basev0.RunnableExecution_COMPLETION_CALL, basev0.RunnableExecution_COMPLETION_SUBMIT)
}
var recovery basev0.RunnableExecution_Recovery
if declared.Recovery != "" {
value, known := basev0.RunnableExecution_Recovery_value[declared.Recovery]
if !known || basev0.RunnableExecution_Recovery(value) == basev0.RunnableExecution_RECOVERY_UNKNOWN {
return nil, fmt.Errorf("recovery %q is not a recovery; %s and %s are the two",
declared.Recovery, basev0.RunnableExecution_RECOVERY_RECOMPUTE, basev0.RunnableExecution_RECOVERY_RECEIPT)
}
recovery = basev0.RunnableExecution_Recovery(value)
}
return &corerunnable.OperationSpec{
Method: spelling,
Codes: codes,
Expand All @@ -545,6 +555,7 @@ func operationSpec(declared *runnablespkg.Operation, spelling string, codes core
Completion: basev0.RunnableExecution_Completion(completion),
Tool: declared.Tool,
RequiredScopeSlots: declared.RequiredScopeSlots,
Recovery: recovery,
}, nil
}

Expand Down
44 changes: 38 additions & 6 deletions cmd/generate/runnable_scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func slotOperation(fixed bool) *runnablev0.Operation {
}
declared.RequiredScopeSlots = []*runnablev0.ScopeSlot{{Name: "source", RequiredActions: []string{"invoke", "read"}, Lookup: true}}
declared.Tool = &runnablev0.ToolExposure{Name: "invoke_source", Description: "Invoke a selected source.", Effect: runnablev0.ToolExposure_EFFECT_MUTATION}
declared.Recovery = basev0.RunnableExecution_RECOVERY_RECEIPT
return declared
}

Expand Down Expand Up @@ -75,13 +76,27 @@ func TestOperationDocumentRoundTripsEveryCoreField(t *testing.T) {
require.Equal(t, coreFields.Len()+1, reflect.TypeFor[runnablespkg.Operation]().NumField())
}

// undeclarableFields are Core fields an operation may never declare: Core
// refuses the policy that sets one, so the document has nothing to carry and
// the fixture cannot populate it. Each is earned by a test proving the refusal
// reaches this command, never by being hard to populate.
var undeclarableFields = map[protoreflect.FullName]string{
// An owner is the authority's to select for an installation (core#845);
// TestRunnablesRefusesADeclaredResourceOwner proves the refusal.
"codefly.base.v0.WorkScopeV1.resource_owner_id": "never declared",
}

// Descend into every message, including repeated scopes and slots. A future
// nested field must be populated before proto.Equal can prove it survived.
func requireEveryMessageFieldPopulated(t *testing.T, message protoreflect.Message) {
t.Helper()
fields := message.Descriptor().Fields()
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
if _, undeclarable := undeclarableFields[field.FullName()]; undeclarable {
require.Falsef(t, message.Has(field), "%s is never declared; leave it out of the fixture", field.FullName())
continue
}
require.Truef(t, message.Has(field), "populate %s in the round-trip fixture", field.FullName())
if field.Message() == nil {
continue
Expand All @@ -102,6 +117,20 @@ func requireEveryMessageFieldPopulated(t *testing.T, message protoreflect.Messag
}
}

// A policy that declares a field Core never lets an operation declare is
// refused by the derivation with Core's reason, so the document's silence on
// that field is the contract rather than a loss.
func TestRunnablesRefusesADeclaredResourceOwner(t *testing.T) {
declared := slotOperation(true)
declared.InvokeScopes[0].ResourceOwnerId = "principal-u"
root, _ := saveConnectRunnableFixture(t, context.Background(), descriptorSet(t, ingestionFile(declared)), "0.1.0")
t.Chdir(root)
resetRunnablesFlags(t)
err := RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})
require.ErrorContains(t, err, `selects resource owner "principal-u"`)
require.ErrorContains(t, err, undeclarableFields["codefly.base.v0.WorkScopeV1.resource_owner_id"])
}

func TestOperationDocumentRefusesUnknownNestedCoreFields(t *testing.T) {
for _, data := range []string{
`{"tool":{"name":"invoke_source","new_authority":true}}`,
Expand Down Expand Up @@ -147,11 +176,11 @@ func verifyResolvedBinding(t *testing.T, value string, tool *runnablev0.ToolExpo
require.NoError(t, err)
require.NoError(t, corerunnable.VerifyPrepared(prepared))
require.Empty(t, prepared.Policy.RequiredScopeSlots)
// Every runnable is a tool: one whose policy names none is presented
// from its operation.
exposure, err := corerunnable.ToolFromPrepared(prepared)
if tool == nil {
require.ErrorIs(t, err, corerunnable.ErrNotATool)
} else {
require.NoError(t, err)
require.NoError(t, err)
if tool != nil {
require.True(t, proto.Equal(tool, exposure))
}
// The host receipt and the caller binding consume the SAME resolution.
Expand Down Expand Up @@ -200,9 +229,12 @@ func TestGenerateRunnableBindingsResolvesThreeOperationFixtures(t *testing.T) {
switch {
case strings.HasSuffix(operation.Entry.Method, "/FixedOnly"):
// The old path wrote the declared policy directly. Its canonical
// output stays byte-identical, not just semantically equivalent.
// output stays byte-identical, not just semantically equivalent,
// beside the tool presentation every runnable now carries.
require.NotNil(t, prepared.Policy.Tool)
baseline := proto.CloneOf(prepared)
baseline.Policy = fixed
baseline.Policy = proto.CloneOf(fixed)
baseline.Policy.Tool = prepared.Policy.Tool
before, err := corerunnable.EncodePrepared(baseline)
require.NoError(t, err)
require.Equal(t, string(before), values[key])
Expand Down
48 changes: 47 additions & 1 deletion cmd/generate/runnable_source_operation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/codefly-dev/cli/pkg/environments"
Expand Down Expand Up @@ -104,7 +105,13 @@ func TestGenerateRealSourceOperations(t *testing.T) {
key := RunnableBindingKey("saas-starter", operation.Entry.Name)
value := values[key]
prepared := verifyResolvedBinding(t, value, nil)
require.True(t, proto.Equal(published[operation.Entry.Method].Contract, prepared.Contract), "prepared contract matches the producer's committed runnable package")
// The producer's committed package predates the wire spelling (core
// #843: JSON names, 64-bit integers as strings); module-saas-starter
// regenerates to it. Until then the two agree field for field once the
// committed one is re-spelled, which is what this holds.
require.True(t, proto.Equal(operation.Package.GetContract(), prepared.Contract), "prepared contract is the derived one")
requireSameModuloWireSpelling(t, published[operation.Entry.Method].GetContract().GetInput().GetFields(), prepared.Contract.GetInput().GetFields())
requireSameModuloWireSpelling(t, published[operation.Entry.Method].GetContract().GetOutput().GetFields(), prepared.Contract.GetOutput().GetFields())
require.Equal(t, "connect", prepared.Operation.Endpoint)
require.Len(t, prepared.Policy.InvokeScopes, 1)
require.True(t, proto.Equal(sourceSelection().Invoke[0], prepared.Policy.InvokeScopes[0]))
Expand All @@ -115,3 +122,42 @@ func TestGenerateRealSourceOperations(t *testing.T) {
require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"saas-starter"}))
require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil))
}

// requireSameModuloWireSpelling holds a contract committed before the wire
// spelling to the one derived now: the same members in the same order, each
// named by its JSON name, a 64-bit integer as a STRING of format int64 or
// uint64 and nothing else, every non-required key optional, array items held
// the same way. A stopgap: it goes when module-saas-starter regenerates.
func requireSameModuloWireSpelling(t *testing.T, committed, derived []*basev0.RunnableField) {
t.Helper()
require.Len(t, derived, len(committed))
for i, before := range committed {
requireFieldModuloWireSpelling(t, before, derived[i], true)
}
}

func requireFieldModuloWireSpelling(t *testing.T, before, after *basev0.RunnableField, member bool) {
t.Helper()
if member {
parts := strings.Split(before.GetName(), "_")
for j := 1; j < len(parts); j++ {
if parts[j] != "" {
parts[j] = strings.ToUpper(parts[j][:1]) + parts[j][1:]
}
}
require.Equal(t, strings.Join(parts, ""), after.GetName())
require.True(t, after.GetOptional(), "%s: proto3 JSON may omit every non-required key", after.GetName())
}
if before.GetType() == after.GetType() {
require.Equal(t, before.GetFormat(), after.GetFormat(), "%s", after.GetName())
} else {
require.Equal(t, basev0.RunnableField_INTEGER, before.GetType(), "%s", before.GetName())
require.Equal(t, basev0.RunnableField_STRING, after.GetType(), "%s", before.GetName())
require.Contains(t, []string{corerunnable.FormatInt64, corerunnable.FormatUint64}, after.GetFormat(), "%s", before.GetName())
}
requireSameModuloWireSpelling(t, before.GetFields(), after.GetFields())
require.Equal(t, before.GetItems() == nil, after.GetItems() == nil, "%s items", after.GetName())
if before.GetItems() != nil {
requireFieldModuloWireSpelling(t, before.GetItems(), after.GetItems(), false)
}
}
115 changes: 96 additions & 19 deletions cmd/generate/runnables.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
runnablespkg "github.com/codefly-dev/cli/pkg/runnables"
"github.com/codefly-dev/core/composition"
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0"
"github.com/codefly-dev/core/resources"
corerunnable "github.com/codefly-dev/core/runnable"
"github.com/codefly-dev/core/shared"
Expand All @@ -35,32 +36,37 @@ var (
// published contracts mark as an operation.
var RunnablesCmd = &cobra.Command{
Use: "runnables [module]",
Short: "Derive SERVICE-facility Runnable packages from the methods a module's contracts mark",
Short: "Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods",
Long: `Derive a Runnable package for every gRPC method a module's published contracts
mark with the codefly.runnable.v0.operation option.
serve behind the authority hop. Every Runnable is a tool.

A unary, idempotent method an owner service already publishes becomes a
Runnable by derivation, not by authoring: the method option says which methods
are operations and under what execution policy, and the message descriptors say
what the contract is. Nothing is written twice.
A method that declares its authority policy (authority.hop.v1.policy) is an
operation with nothing added: a policy requiring only "read" derives a
read-only tool that is safe to re-run and carries no receipt; anything else
derives a mutation resolved by its effect receipt through the SDK's generic
lookup. The codefly.runnable.v0.operation option overrides those defaults, and
codefly.runnable.v0.exclude keeps a method from every agent. The message
descriptors say what the contract is, by their proto3 JSON. Nothing is written
twice.

The input is what ` + "`codefly generate contracts`" + ` already wrote — each endpoint's
served procedures in the API catalog and its serialized FileDescriptorSet.
Run that first. Only served methods carrying the option are derived; their
paired Lookup must be served on the same endpoint. No flag names a method.
Run that first. Only served methods are derived; a declared Lookup must be
served on the same endpoint. No flag names a method.

For each marked method this writes, under contracts/runnables:

<service>/<endpoint>/<Method>/runnable-package.json the canonical package
<service>/<endpoint>/<Method>/operation.json its execution policy and authority
index.json one row per operation

The tree is fully owned: a method that no longer carries the option loses its
directory. A module with no marked method writes an empty index, not an error.
The tree is fully owned: a method that is no longer an operation loses its
directory. A module with no operation writes an empty index, not an error.

A streaming method, a payload outside the bounded schema profile, or an option
core refuses is named and skipped, and the command exits non-zero after
reporting every one of them.
A method derived by default that cannot be one — a stream, a policy requiring
nothing, a payload proto3 JSON cannot carry — is listed and left a plain call.
A method carrying the option that core refuses is named and skipped, and the
command exits non-zero after reporting every one of them.

--check is the CI drift gate: it regenerates into a temporary directory and
prints a unified diff of what differs, without writing anything.
Expand Down Expand Up @@ -118,9 +124,7 @@ Examples:
if err = writeDerivedTree(ctx, derived.files, output); err != nil {
return err
}
for _, entry := range derived.index.Operations {
cli.Info("runnables: %s %s %s", entry.Name, entry.Method, entry.Digest)
}
derived.report()
cli.Header(1, "Derived %d runnable operation(s)", len(derived.index.Operations))
return nil
},
Expand All @@ -144,6 +148,11 @@ type derivation struct {
// give one release a different digest per machine.
workspaceDir string
refusals []string
// notices are methods a default derivation left plain calls, and why.
notices []string
// origins says, per operation name, whether the owner declared it and
// what its tool's effect is.
origins map[string]derivedOperation
}

// refusal reports every method that could not be derived. They are collected
Expand All @@ -153,10 +162,56 @@ func (d *derivation) refusal() error {
if len(d.refusals) == 0 {
return nil
}
return fmt.Errorf("%d method(s) carry the operation option but cannot be derived:\n %s",
return fmt.Errorf("%d method(s) cannot be derived as runnable operations:\n %s",
len(d.refusals), strings.Join(d.refusals, "\n "))
}

// derivedOperation is what the report says of one operation: where it came
// from and what it does.
type derivedOperation struct {
explicit bool
effect string
}

// report says what the run made of the module, so an owner learns which of
// its methods became tools without reading the tree: each operation with its
// origin and effect, the methods left plain calls and why, and — when any
// operation was derived by default — how to keep a method from every agent.
//
// One method published on several endpoints (gRPC and Connect) is several
// operations but one method, so the summary counts and names methods, each
// once, and a notice is printed once per method.
func (d *derivation) report() {
var defaults, mutations []string
for i := range d.index.Operations {
entry := &d.index.Operations[i]
operation := d.origins[entry.Name]
origin := "option"
if !operation.explicit {
origin = "default"
defaults = appendOnce(defaults, entry.Method)
if operation.effect == runnablev0.ToolExposure_EFFECT_MUTATION.String() {
mutations = appendOnce(mutations, entry.Method)
}
}
cli.Info("runnables: %s %s %s %s %s", entry.Name, entry.Method, origin, strings.TrimPrefix(operation.effect, "EFFECT_"), entry.Digest)
}
var notices []string
for _, notice := range d.notices {
notices = appendOnce(notices, notice)
}
for _, notice := range notices {
cli.Info("runnables: not an operation: %s", notice)
}
if len(defaults) == 0 {
return
}
cli.Info("runnables: %d method(s) derived by default from their authority policy, %d of them mutations: %s",
len(defaults), len(mutations), strings.Join(mutations, ", "))
cli.Info("runnables: every operation is a tool, offered only to callers whose grants cover its binding; " +
"a method no agent may ever call declares `option (codefly.runnable.v0.exclude) = true;`")
}

// deriveRunnables walks every protobuf contract the module published and
// derives the operations its methods declare.
func deriveRunnables(ctx context.Context, workspace *resources.Workspace, module *resources.Module) (*derivation, error) {
Expand Down Expand Up @@ -186,7 +241,8 @@ func deriveRunnablesIn(ctx context.Context, workspaceName, workspaceDir string,
Module: module.Name,
Operations: []runnablespkg.IndexEntry{},
},
files: map[string][]byte{},
files: map[string][]byte{},
origins: map[string]derivedOperation{},
}

var endpoints []composition.APIContractEndpoint
Expand Down Expand Up @@ -311,6 +367,10 @@ func deriveMethod(module *resources.Module, endpoint *composition.APIContractEnd
if errors.Is(err, corerunnable.ErrNotAnOperation) {
return nil
}
if errors.Is(err, corerunnable.ErrNotDerivable) {
derived.notices = append(derived.notices, fmt.Sprintf("%s: %v", fullMethod, err))
return nil
}
derived.refusals = append(derived.refusals, fmt.Sprintf("%s: %v", fullMethod, err))
return nil
}
Expand All @@ -333,6 +393,7 @@ func deriveMethod(module *resources.Module, endpoint *composition.APIContractEnd
derived.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.OperationFileName))] = operationBytes

operation := pkg.GetServiceOperations()[0]
derived.origins[pkg.GetIdentity().GetName()] = derivedOperation{explicit: spec.Declared, effect: spec.Tool.GetEffect().String()}
derived.index.Operations = append(derived.index.Operations, runnablespkg.IndexEntry{
Name: pkg.GetIdentity().GetName(),
Version: pkg.GetIdentity().GetVersion(),
Expand Down Expand Up @@ -427,10 +488,26 @@ func derivedVersion(release string, endpoint *composition.APIContractEndpoint) s
// because one proto service may be published on both a grpc and a connect
// endpoint: those are two operations reached two ways, and a name that dropped
// the endpoint would give them one identity and two digests.
//
// A release name is at most maxDerivedName characters. A longer one is
// shortened deterministically — a stable prefix and a short digest of the
// whole name — so a long method name never fails generation and two long
// names never collapse into one.
func derivedName(service, endpoint, method string) string {
return strings.Join([]string{service, endpoint, kebab(method)}, "-")
return corerunnable.BoundedName(strings.Join([]string{service, endpoint, kebab(method)}, "-"), maxDerivedName, "-")
}

// appendOnce appends value unless the list already holds it.
func appendOnce(list []string, value string) []string {
if slices.Contains(list, value) {
return list
}
return append(list, value)
}

// maxDerivedName is the longest release name a RunnableIdentity admits.
const maxDerivedName = 50

// kebab spells a method name the way a resource name is spelled. An acronym
// stays one word: ApplyHTTPText is apply-http-text, not apply-h-t-t-p-text.
func kebab(name string) string {
Expand Down
Loading
Loading