Skip to content

Commit eee37c1

Browse files
fix: derive hop service routes through composition configuration
1 parent efb61fd commit eee37c1

18 files changed

Lines changed: 796 additions & 60 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
- **Module:** `github.com/codefly-dev/cli`
1010
- **Repository:** `https://github.com/codefly-dev/cli`
11-
- **Language:** Go 1.27.2 (`go.mod` is the source of truth — see [Bump the Go version](docs/runbooks/bump-go-version.md))
11+
- **Language:** Go 1.27.3 (`go.mod` is the source of truth — see [Bump the Go version](docs/runbooks/bump-go-version.md))
1212

1313
The CLI is the primary interface for codefly. It orchestrates agent lifecycles, manages the
1414
daemon, runs services with their dependency graphs, and exposes an MCP server for AI tool

‎cmd/run/extend.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ func watchForExtensions(ctx context.Context, flow *orchestration.Flow, baseline
7272
case <-ctx.Done():
7373
return
7474
case <-signals:
75+
workspace, loadErr := common.LoadWorkspace(ctx)
76+
if loadErr == nil {
77+
loadErr = flow.CheckRoutingSnapshot(ctx, workspace)
78+
}
79+
if loadErr != nil {
80+
cli.Warning("the running composition was not changed: %v", loadErr)
81+
continue
82+
}
7583
if err := withdrawDroppedRoots(ctx, flow, pinned); err != nil {
7684
cli.Warning("composition withdrawal is incomplete; the next SIGUSR1 retries it: %v", err)
7785
continue
@@ -122,6 +130,9 @@ func withdrawDroppedRoots(ctx context.Context, flow *orchestration.Flow, pinned
122130
if err != nil {
123131
return fmt.Errorf("reload the workspace: %w", err)
124132
}
133+
if err = flow.CheckRoutingSnapshot(ctx, workspace); err != nil {
134+
return err
135+
}
125136
roots, err := CompositionRoots(ctx, workspace)
126137
if err != nil {
127138
return err
@@ -155,6 +166,9 @@ func extendRunningWorkspace(ctx context.Context, flow *orchestration.Flow, basel
155166
if err != nil {
156167
return fmt.Errorf("reload the workspace: %w", err)
157168
}
169+
if err = flow.CheckRoutingSnapshot(ctx, workspace); err != nil {
170+
return err
171+
}
158172
roots, err := CompositionRoots(ctx, workspace)
159173
if err != nil {
160174
return err

‎docs/orchestration.md‎

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -399,17 +399,15 @@ Whether a producer is in *this* run is not a plan-time question.
399399

400400
A group a consumer **declares** is judged against the plan's graph and, beyond
401401
it, every other service of the workspace except one this run excludes. A
402-
module-closure run's graph holds the seeds' modules and the modules their
403-
service dependencies reach — never a module only a configuration value names —
404-
so `codefly run service authority/hop` alone does not contain the producers its
405-
own group references. Such a producer is not a plan-time fault: the resolution
406-
drops the value at WARN, naming the consumer, the value, which reference of it
407-
and the producer, exactly as for a root group. Only membership is relaxed: the
408-
endpoint must still exist and be visible to the consumer, a producer the
409-
workspace does not have is still "not a service of this workspace", and a
410-
producer the run **excludes** (`--exclude-dependency`, a run profile) still
411-
refuses the run naming the exclusion — the exclusion and the group that
412-
references it are coupled, and a value is never dropped because of one.
402+
module-closure run includes producers of groups its services declare, using
403+
Core's configuration-reference closure option with the same invocation snapshot
404+
used for graph edges and validation. Thus a hop's generated routes bring their
405+
producers into the run. Root-only groups do not grow the module closure; a
406+
reference to a producer outside that run is dropped at WARN with its consumer,
407+
value and producer named. Endpoint existence and visibility still apply. A
408+
producer the workspace lacks is refused, and an explicit run exclusion
409+
(`--exclude-dependency` or a run profile) coupled to a declared reference is
410+
refused rather than silently dropping that value.
413411

414412
##### What this refuses that used to be tolerated
415413

@@ -1199,3 +1197,37 @@ advertisement never grants a skip. A supported operation that returns Unimplemen
11991197
remains a contract failure. CI records unsupported operations as skipped with
12001198
`agent_no_lint_capability`, `agent_no_compile_capability`, or
12011199
`agent_no_test_capability`, rather than reporting a successful check.
1200+
## Published hop routes
1201+
1202+
A hop declaring the `hop-routes` workspace group receives a generated route
1203+
snapshot for the principals named by `hop/MODULES`. Do not author that group or
1204+
an RPC-to-address table. Codefly reads the selected modules' published API catalogs,
1205+
checks their package identity and contract digests, and derives exact endpoint
1206+
references. Run and render feed those references through the same Core loader,
1207+
dependency graph, visibility checks and address resolver. Only the declaring
1208+
consumer receives the generated group; it is not a composition-root group.
1209+
1210+
This requires published catalogs from `codefly generate contracts`. A source
1211+
file that has not been exported does not create a route. Unknown principals,
1212+
stale contract artifacts, conflicting destinations, and unreachable exact
1213+
endpoints refuse before a hop starts. The hop keeps authority enforcement:
1214+
routes identify a listener and policy method, never grants or readiness.
1215+
1216+
The selected Core validator checks package/catalog identity and artifact bytes;
1217+
it does not independently reconcile every catalog route or procedure with those
1218+
bytes. Inventory comes from `codefly generate contracts`. This path does not
1219+
prove that unexported source is current or that a running listener registers
1220+
every published method; generation checks and live module qualification remain
1221+
separate requirements.
1222+
1223+
The snapshot lasts for one run. Ordinary source reload keeps accepted endpoint
1224+
addresses. Changing the published route inventory or a used address requires
1225+
stopping and restarting the run. `SIGUSR1`, withdrawal, extension and loader
1226+
reload and source-watch admission reject a changed snapshot before stopping
1227+
services or adopting declarations; endpoint publication cannot
1228+
replace an address already handed to a snapshot. An address first learned from
1229+
an agent's Init response is rejected before publication; this does not undo that
1230+
agent's process effects. This is an explicit restart
1231+
boundary, not live route reconfiguration. Existing hop versions that do not
1232+
declare `hop-routes` keep their existing configuration mode; a new hop requires
1233+
both the CLI projection and the generated group.

‎go.mod‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ require (
1111
github.com/asottile/dockerfile v3.1.0+incompatible
1212
github.com/blang/semver v3.5.1+incompatible
1313
github.com/briandowns/spinner v1.23.2
14-
github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024211423-39f45e9d69af
14+
github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025205543-cac1f6b3dcb6
1515
github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025010902-dc41de4126ae
16-
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06
16+
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025205543-cac1f6b3dcb6
1717
github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0
1818
github.com/codefly-dev/golor v0.1.3
1919
github.com/codefly-dev/llm v0.1.7

‎go.sum‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,14 @@ github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGn
156156
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
157157
github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024211423-39f45e9d69af h1:2PWZxCPQLn/dQmcNST993g11nfWNgNc9YOvWIhr5pn0=
158158
github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024211423-39f45e9d69af/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is=
159+
github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025205543-cac1f6b3dcb6 h1:xme0meaIpxN5g3kQX/s4x8WxRdzwdN0r6xIJwMIY0RM=
160+
github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025205543-cac1f6b3dcb6/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU=
159161
github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025010902-dc41de4126ae h1:GURYozeJoFfzwsW0iyjdyQ7w7+qpa/6vwWw4SxTqOxg=
160162
github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025010902-dc41de4126ae/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q=
161163
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06 h1:Gh531vlcnmPL41VurXGoVyn5Oyf5uJWH9j+wuvoPKAg=
162164
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06/go.mod h1:wPunnn+I0MM36rLqhRiCbIB/bqIypVA9+YjfQRHmgqY=
165+
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025205543-cac1f6b3dcb6 h1:zUGZPAZZCEuhTrGgjJohq2CGCeM7t+OB8rufnPmbklA=
166+
github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025205543-cac1f6b3dcb6/go.mod h1:021MSgdhW9pSlRLcHEryPDXfI/rGY49rJAhe4RbU7BQ=
163167
github.com/codefly-dev/core v0.19.7-0.20261025175252-88f4434c68c7 h1:sFkQTBSvAjzEBYJYHVFfzXdudFbHXIj6ccDLb8AlZNs=
164168
github.com/codefly-dev/core v0.19.7-0.20261025175252-88f4434c68c7/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A=
165169
github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA=

‎pkg/gitops/cell.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/codefly-dev/cli/pkg/orchestration"
1818
"github.com/codefly-dev/cli/pkg/run/fixture"
1919
"github.com/codefly-dev/cli/pkg/solutionrun"
20-
"github.com/codefly-dev/core/configurations"
2120
"github.com/codefly-dev/core/resources"
2221
"github.com/codefly-dev/core/shared"
2322
"gopkg.in/yaml.v3"
@@ -666,7 +665,7 @@ type configuredReference struct{ source, reference string }
666665
// reading one dials that endpoint, so it is a consumer of it exactly as a
667666
// service dependency makes it one.
668667
func configuredEndpointReferences(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) (map[string][]configuredReference, error) {
669-
reader, err := configurations.NewConfigurationLocalReader(ctx, workspace)
668+
reader, err := orchestration.NewWorkspaceConfigurationReader(ctx, workspace)
670669
if err != nil {
671670
return nil, err
672671
}

‎pkg/gitops/hop_routes_test.go‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package gitops
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"github.com/codefly-dev/cli/pkg/orchestration"
8+
"github.com/codefly-dev/core/composition"
9+
"github.com/codefly-dev/core/resources"
10+
"github.com/stretchr/testify/require"
11+
"os"
12+
"path/filepath"
13+
"testing"
14+
)
15+
16+
func hopRouteWorkspace(t *testing.T, visibility string) *resources.Workspace {
17+
t.Helper()
18+
files := map[string]string{
19+
"workspace.codefly.yaml": "name: routes\nlayout: modules\nmodules:\n - name: platform\n - name: payments\n",
20+
"configurations/local/hop.env": "MODULES=platform\n",
21+
"modules/platform/module.codefly.yaml": "name: platform\nservices:\n - name: first\n - name: second\n",
22+
"modules/platform/module.contract.codefly.yaml": "schema: codefly/module-contract/v1\nprincipal: platform\nnamespaces: [platform]\nqueues: []\nscope_ceilings: []\nbindings: []\n",
23+
"modules/payments/module.codefly.yaml": "name: payments\nservices:\n - name: worker\n",
24+
"modules/payments/services/worker/service.codefly.yaml": "name: worker\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\nworkspace-configuration-dependencies: [hop-routes]\n",
25+
}
26+
catalog := composition.APIContractCatalog{Schema: composition.APIContractCatalogSchema, Package: "owner/platform", Version: "1.0.0"}
27+
manifest := "schema: codefly/module-package/v2\nkind: module-package\nid: owner/platform\nversion: 1.0.0\nminimum-codefly-version: '>=0.3.32 <1.0.0'\nartifact-roots: [contracts, services]\ncontracts: {composition: '>=2.0.0 <3.0.0'}\nservices:\n"
28+
for _, service := range []string{"first", "second"} {
29+
path := "contracts/api/" + service + ".json"
30+
doc := fmt.Sprintf(`{"openapi":"3.0.0","info":{"title":"%s","version":"1.0.0"},"paths":{"/%s":{"get":{"responses":{"200":{"description":"ok"}}}}}}`, service, service)
31+
digest := composition.APIContractDigest([]byte(doc))
32+
files["modules/platform/"+path] = doc
33+
files["modules/platform/services/"+service+"/service.codefly.yaml"] = "name: " + service + "\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\nendpoints:\n - name: web\n api: http\n visibility: " + visibility + "\n" + " exposure: none\n"
34+
manifest += fmt.Sprintf(" - name: %s\n endpoints: [web]\n api-contracts:\n - endpoint: web\n kind: openapi\n package: api\n path: %s\n digest: %s\n", service, path, digest)
35+
catalog.Endpoints = append(catalog.Endpoints, composition.APIContractEndpoint{Service: service, Endpoint: "web", API: "http", Kind: composition.APIContractKindOpenAPI, Package: "api", Path: path, Digest: digest, Routes: []composition.APIContractRoute{{Method: "GET", Path: "/" + service}}})
36+
}
37+
raw, err := json.Marshal(catalog)
38+
require.NoError(t, err)
39+
files["modules/platform/"+composition.APIContractCatalogFileName] = string(raw)
40+
files["modules/platform/module.package.codefly.yaml"] = manifest
41+
root := t.TempDir()
42+
for rel, raw := range files {
43+
path := filepath.Join(root, rel)
44+
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
45+
require.NoError(t, os.WriteFile(path, []byte(raw), 0600))
46+
}
47+
workspace, err := resources.LoadWorkspaceFromDir(context.Background(), root)
48+
require.NoError(t, err)
49+
return workspace
50+
}
51+
52+
func TestCellSeesGeneratedHopRoutes(t *testing.T) {
53+
ctx := context.Background()
54+
ws := hopRouteWorkspace(t, "public")
55+
env := selectedEnvironment(t, ws, "local")
56+
provided, _, err := orchestration.WorkspaceConfigurationsForChecking(ctx, ws, env)
57+
require.NoError(t, err)
58+
require.NotEmpty(t, provided.Infos)
59+
consumers, _, err := endpointConsumers(ctx, ws, env)
60+
require.NoError(t, err)
61+
require.Equal(t, []string{"payments/worker"}, consumers["platform/first/web"])
62+
require.Equal(t, []string{"payments/worker"}, consumers["platform/second/web"])
63+
}

‎pkg/orchestration/configuration_references_check.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ func WorkspaceConfigurationsForChecking(
343343
if workspace == nil || env == nil {
344344
return nil, nil, nil
345345
}
346-
reader, err := configurations.NewConfigurationLocalReader(ctx, workspace)
346+
reader, err := NewWorkspaceConfigurationReader(ctx, workspace)
347347
if err != nil {
348348
return nil, nil, fmt.Errorf("cannot build a configuration reader, so the workspace configurations this invocation resolves cannot be checked: %w", err)
349349
}

‎pkg/orchestration/declared_reference_outside_run_test.go‎

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,31 +56,22 @@ func declaredReferenceFlow(t *testing.T, workspace *resources.Workspace, options
5656
return flow
5757
}
5858

59-
// (a) A run of fewer services than the composition is not refused over a
60-
// declared group's reference to a service the workspace composes but this run
61-
// does not contain.
62-
//
63-
// A module-closure run's graph holds the seeds' modules and the modules their
64-
// service dependencies reach; a configuration reference reaches none, so a
65-
// producer only a value names is outside it. The plan gate judged declared
66-
// groups against that graph alone and reported the producer as "not a service
67-
// of this workspace" — a real service, with the wrong fault and no way out short
68-
// of running more than was asked. Rolling declaredProducerLookup back to the
69-
// graph alone fails this test.
70-
func TestAModuleClosureRunDoesNotRefuseADeclaredReferenceToAComposedServiceOutsideIt(t *testing.T) {
59+
// A declared configuration reference participates in Core's module closure,
60+
// just as a declared run dependency does. Root-only groups still do not grow it.
61+
func TestAModuleClosureRunIncludesDeclaredReferenceProducers(t *testing.T) {
7162
t.Setenv(resources.CodeflyHomeEnv, filepath.Join(t.TempDir(), "home"))
7263
flow := declaredReferenceFlow(t,
7364
declaredReferenceWorkspace(t, "platform/authority/admin", "public"), WithRunModuleClosure("payments"))
74-
75-
// The trap is set: without this the test would assert the absence of a
76-
// refusal nothing could have produced.
7765
_, err := flow.world.Dependencies.ServiceFromUnique("platform/authority")
78-
require.Error(t, err, "the closure must not contain the producer")
79-
80-
err = flow.InitManagers(context.Background())
81-
var unresolved *configurations.UnresolvedReferencesError
82-
require.False(t, errors.As(err, &unresolved),
83-
"a composed producer outside this run must not refuse it: %v", err)
66+
require.NoError(t, err, "declared reference producer must be in the closure")
67+
deps, err := flow.managerDependencies(context.Background())
68+
require.NoError(t, err)
69+
var uniques []string
70+
for _, dep := range deps {
71+
uniques = append(uniques, dep.Unique)
72+
}
73+
require.Contains(t, uniques, "platform/authority")
74+
require.NoError(t, flow.checkConfigurationReferences(context.Background(), nil))
8475
}
8576

8677
// (a), the other half: the resolution drops that value with a WARN naming the

‎pkg/orchestration/extend.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ func (flow *Flow) Extend(ctx context.Context, workspace *resources.Workspace, ro
5353
}
5454
flow.extendMu.Lock()
5555
defer flow.extendMu.Unlock()
56+
if err := flow.CheckRoutingSnapshot(ctx, workspace); err != nil {
57+
return nil, err
58+
}
5659
added, err := flow.admitRoots(ctx, workspace, roots)
5760
if err != nil || len(added) == 0 {
5861
return nil, err
@@ -97,6 +100,9 @@ func (flow *Flow) Withdraw(ctx context.Context, workspace *resources.Workspace,
97100
}
98101
flow.extendMu.Lock()
99102
defer flow.extendMu.Unlock()
103+
if err := flow.CheckRoutingSnapshot(ctx, workspace); err != nil {
104+
return nil, err
105+
}
100106
// Keep the admission visible until every requested stop succeeds. A stop
101107
// RPC can fail after other dependents were already stopped; retaining the
102108
// original composition lets the next SIGUSR1 rediscover the failed root
@@ -139,7 +145,7 @@ func (flow *Flow) Withdraw(ctx context.Context, workspace *resources.Workspace,
139145
}
140146
*flow.workspace = *workspace
141147
if flow.graphWorkspace != nil {
142-
closure, closureErr := runModuleClosure(ctx, flow.workspace, retainedSeeds(seeds, flow.retainedModules))
148+
closure, closureErr := runModuleClosure(ctx, flow.workspace, retainedSeeds(seeds, flow.retainedModules), flow.providedWorkspaceConfigurations)
143149
if closureErr != nil {
144150
return nil, closureErr
145151
}
@@ -232,7 +238,7 @@ func (flow *Flow) admitRoots(ctx context.Context, workspace *resources.Workspace
232238
// the same corrected workspace look already admitted on the next signal.
233239
candidate := *workspace
234240
if flow.graphWorkspace != nil {
235-
closure, err := runModuleClosure(ctx, &candidate, retainedSeeds(seeds, flow.retainedModules))
241+
closure, err := runModuleClosure(ctx, &candidate, retainedSeeds(seeds, flow.retainedModules), flow.providedWorkspaceConfigurations)
236242
if err != nil {
237243
return nil, err
238244
}

0 commit comments

Comments
 (0)