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
18 changes: 18 additions & 0 deletions docs/agent-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,24 @@ response naming any other profile is refused, and the local-apply profile is
the one documented exemption from restricted-rendering evidence, because it
produces no promotable artifact and nothing persists it.

**An agent reads the interface binding the host supplies.** An agent loads
the service it serves by directory, and before core v0.19.7 it bound the
service's interface dependencies itself, against the workspace at the
identity's `workspace_path`. It cannot compose that workspace when it imports
another by `source:` and `version:`: only the host registers a workspace
resolver (every command context does), and the agent is another process, so it
fails with core's `requires host resolution` — a sentence about the host, from
a process that is not the host. Since core v0.19.7 the host hands the agent
the binding its composition made (`ServiceIdentity.interface_bindings`, filled
by core's `services.Load` from `Service.InterfaceBindingsProto` for every
service loaded through its workspace, in `run` and in a render alike), and the
agent applies it without loading the workspace. An agent built on an older
core ignores the field; when its refusal reaches the CLI for a service whose
binding the host did supply, the CLI names the agent and the release floor
(`pkg/orchestration/interface_binding_refusal.go`) rather than relaying the
host's sentence. An uncommitted `path:` override on the import makes such an
agent load, and fixes nothing: the agent's release is what is behind.

**A native run asks an agent nothing about what it started.** A `codefly
run` of a composition whose environment declares a `host` delivers each
module's signed manifest, which names the workloads that run as the module and
Expand Down
70 changes: 70 additions & 0 deletions pkg/composition/workspace_reference_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package composition

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/codefly-dev/cli/pkg/modulecache"
"github.com/codefly-dev/core/resources"
"github.com/stretchr/testify/require"
)

const importedPlatformCommit = "5a8d5ffa0d1e2c3b4a5968778695a4b3c2d1e0f9"

// importingWorkspace writes a product workspace importing platform-core by
// source and commit pin, with platform-core already in the module cache at
// that pin — what a previous pull, or a module pin on the same source, leaves
// there. apps/web requires an interface platform/api implements.
func importingWorkspace(t *testing.T) string {
t.Helper()
cacheRoot := t.TempDir()
t.Setenv(modulecache.Env, cacheRoot)
core := filepath.Join(cacheRoot, "obin-ai", "platform-core", importedPlatformCommit)
product := t.TempDir()
agent := "agent:\n kind: codefly:service\n name: go\n version: 0.0.67\n publisher: codefly.dev\n"
files := map[string]string{
filepath.Join(core, "workspace.codefly.yaml"): "name: platform-core\nlayout: modules\nmodules:\n - name: platform\n",
filepath.Join(core, "modules/platform/module.codefly.yaml"): "kind: module\nname: platform\nservices:\n - name: api\ninterface:\n endpoints:\n - service: api\n endpoint: grpc\n visibility: public\n implements:\n - example.dev/widgets@1.2.0\n",
filepath.Join(core, "modules/platform/services/api/service.codefly.yaml"): "kind: service\nname: api\nversion: 0.0.0\n" + agent + "endpoints:\n - name: grpc\n api: grpc\n",
filepath.Join(product, "workspace.codefly.yaml"): "name: product\nlayout: modules\nworkspaces:\n - name: platform-core\n source: obin-ai/platform-core\n version: " + importedPlatformCommit + "\nmodules:\n - name: apps\n",
filepath.Join(product, "modules/apps/module.codefly.yaml"): "kind: module\nname: apps\nservices:\n - name: web\n",
filepath.Join(product, "modules/apps/services/web/service.codefly.yaml"): "kind: service\nname: web\nversion: 0.0.0\n" + agent + "endpoints:\n - name: http\n api: http\nservice-dependencies:\n - interface: example.dev/widgets@^1.1\n",
}
for path, content := range files {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
}
return product
}

// A `workspaces:` import naming a source and a commit pin resolves through the
// module cache a module pin uses, with no `path:` override, and the service
// loaded through that composition carries the binding the host hands its
// agent (ServiceIdentity.interface_bindings): the agent never has to compose
// the import itself.
func TestResolvedWorkspaceImportBindsTheServiceForItsAgent(t *testing.T) {
product := importingWorkspace(t)
ctx := WithWorkspaceResolver(context.Background())
workspace, err := resources.LoadWorkspaceFromDir(ctx, product)
require.NoError(t, err)
web, err := workspace.LoadService(ctx, &resources.ServiceWithModule{Module: "apps", Name: "web"})
require.NoError(t, err)
supplied := web.InterfaceBindingsProto()
require.NotNil(t, supplied, "the host's composition bound the import's provider")
require.Len(t, supplied.Bindings, 1)
require.Equal(t, "example.dev/widgets@^1.1", supplied.Bindings[0].Interface)
require.Equal(t, "platform", supplied.Bindings[0].Module)
require.Equal(t, "api", supplied.Bindings[0].Service)
require.Equal(t, "grpc", supplied.Bindings[0].Endpoint)
}

// Without the resolver the import is genuinely unresolvable, and core's refusal
// stands: that is the sentence an agent process, which registers no resolver,
// reports when it loads the workspace itself.
func TestUnresolvedWorkspaceImportRequiresHostResolution(t *testing.T) {
product := importingWorkspace(t)
_, err := resources.LoadWorkspaceFromDir(context.Background(), product)
require.ErrorContains(t, err, `workspace "platform-core" at obin-ai/platform-core@`+importedPlatformCommit+" requires host resolution")
}
2 changes: 1 addition & 1 deletion pkg/orchestration/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func (b *Builder) Load(ctx context.Context) (*OutputProperty, error) {
}
resp, err := b.instance.Builder.Load(ctx, options...)
if err != nil {
return nil, w.Wrapf(err, "cannot load builder instance")
return nil, w.Wrapf(explainHostResolutionRefusal(err, b.instance), "cannot load builder instance")
}

w.Debug("loaded", wool.Field("endpoints", resources.MakeManyEndpointSummary(resp.Endpoints)))
Expand Down
51 changes: 51 additions & 0 deletions pkg/orchestration/interface_binding_refusal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package orchestration

import (
"fmt"
"strings"

"github.com/codefly-dev/core/services"
)

// An agent loads the service it serves by directory. Before core v0.19.7 it
// bound the service's interface dependencies itself, against the workspace at
// the identity's WorkspacePath — which it cannot compose when that workspace
// imports another by source and version: only the host registers a workspace
// resolver (cmd/common/context.go), and the agent is another process. Since
// core v0.19.7 the host hands the agent the binding its composition made
// (ServiceIdentity.interface_bindings, filled by core's services.Load from
// Service.InterfaceBindingsProto) and the agent never loads the workspace.
//
// An agent built on an older core ignores the field, loads the workspace, and
// fails with core's "requires host resolution" — a sentence about the host,
// from a process that is not the host, which has sent every operator to an
// uncommitted `path:` override on the import. The override does not fix the
// agent; it hides which one is behind. This names it.
const hostResolutionRefusal = "requires host resolution"

// explainHostResolutionRefusal returns err as the agent reported it unless it
// is the refusal above for a service whose binding this host supplied, in
// which case the returned error names the agent, what it did instead of
// reading the binding, and the release that reads it. The agent's own error
// stays wrapped underneath.
func explainHostResolutionRefusal(err error, instance *services.Instance) error {
if err == nil || instance == nil || instance.Service == nil {
return err
}
if !strings.Contains(err.Error(), hostResolutionRefusal) {
return err
}
if instance.Service.InterfaceBindingsProto() == nil {
// Nothing bound this service — it was loaded outside its composition —
// so the agent was right to bind it against the workspace on disk.
return err
}
agent := "its agent"
switch {
case instance.Agent != nil && instance.Agent.Agent != nil:
agent = "agent " + instance.Agent.Agent.Identifier()
case instance.Service.Agent != nil:
agent = "agent " + instance.Service.Agent.Identifier()
}
return fmt.Errorf("%s binds %s's interface dependencies against the workspace on disk instead of reading the binding this host supplied (ServiceIdentity.interface_bindings), so it cannot compose a workspace imported by source and version; the agent was built on a core older than v0.19.7, which reads the binding. Upgrade the agent to a release built on core v0.19.7 or newer; a path: override on the import hides this without fixing it: %w", agent, instance.Unique(), err)
}
88 changes: 88 additions & 0 deletions pkg/orchestration/interface_binding_refusal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package orchestration

import (
"context"
"errors"
"os"
"path/filepath"
"testing"

"github.com/codefly-dev/core/agents/services"
"github.com/codefly-dev/core/resources"
coreservices "github.com/codefly-dev/core/services"
"github.com/stretchr/testify/require"
)

// bindingWorkspace writes a workspace where apps/web requires an interface
// platform/api implements, so a load through the workspace binds it.
func bindingWorkspace(t *testing.T) string {
t.Helper()
root := t.TempDir()
agent := "agent:\n kind: codefly:service\n name: go\n version: 0.0.67\n publisher: codefly.dev\n"
files := map[string]string{
"workspace.codefly.yaml": "name: product\nlayout: modules\nmodules:\n - name: platform\n - name: apps\n",
"modules/platform/module.codefly.yaml": "kind: module\nname: platform\nservices:\n - name: api\ninterface:\n endpoints:\n - service: api\n endpoint: grpc\n visibility: public\n implements:\n - example.dev/widgets@1.2.0\n",
"modules/platform/services/api/service.codefly.yaml": "kind: service\nname: api\nversion: 0.0.0\n" + agent + "endpoints:\n - name: grpc\n api: grpc\n",
"modules/apps/module.codefly.yaml": "kind: module\nname: apps\nservices:\n - name: web\n",
"modules/apps/services/web/service.codefly.yaml": "kind: service\nname: web\nversion: 0.0.0\n" + agent + "endpoints:\n - name: http\n api: http\nservice-dependencies:\n - interface: example.dev/widgets@^1.1\n",
}
for name, content := range files {
path := filepath.Join(root, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
}
return root
}

// The agent's refusal is core's sentence about the host. When this host did
// supply the binding, the error names the agent that ignored it and the
// release floor that reads it, and keeps the agent's own error underneath.
func TestExplainHostResolutionRefusalNamesTheAgentBehindTheBinding(t *testing.T) {
ctx := context.Background()
workspace, err := resources.LoadWorkspaceFromDir(ctx, bindingWorkspace(t))
require.NoError(t, err)
bound, err := workspace.LoadService(ctx, &resources.ServiceWithModule{Module: "apps", Name: "web"})
require.NoError(t, err)
require.NotNil(t, bound.InterfaceBindingsProto(), "the host's composition bound web's interface dependency")
identity, err := bound.Identity()
require.NoError(t, err)
instance := &coreservices.Instance{Service: bound, Identity: identity}

agentErr := errors.New(`cannot bind interface dependencies: resources.ApplyInterfaceBindings: cannot load the workspace that binds the service's interfaces: LoadFromDir: workspace "platform-core" at obin-ai/platform-core@5a8d5ffa requires host resolution`)
explained := explainHostResolutionRefusal(agentErr, instance)
require.ErrorIs(t, explained, agentErr, "the agent's own error stays underneath")
require.ErrorContains(t, explained, "agent codefly.dev/go:0.0.67 binds apps/web's interface dependencies against the workspace on disk")
require.ErrorContains(t, explained, "core v0.19.7 or newer")
require.ErrorContains(t, explained, "path: override on the import hides this")

// The running agent, when the selection resolved one, is the one named.
instance.Agent = &services.ServiceAgent{Agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "go", Version: "0.0.74"}}
require.ErrorContains(t, explainHostResolutionRefusal(agentErr, instance), "agent codefly.dev/go:0.0.74 binds")
}

// A service loaded outside its composition has no binding from this host, so
// the agent binding it against the workspace on disk is the contract, and its
// refusal is reported as it stands. So is any other error.
func TestExplainHostResolutionRefusalLeavesOtherErrorsAlone(t *testing.T) {
ctx := context.Background()
root := bindingWorkspace(t)
unbound, err := resources.LoadServiceFromDir(ctx, filepath.Join(root, "modules/apps/services/web"))
require.NoError(t, err)
unbound.WithModule("apps")
require.Nil(t, unbound.InterfaceBindingsProto(), "loaded by directory: nothing bound it")
identity, err := unbound.Identity()
require.NoError(t, err)
instance := &coreservices.Instance{Service: unbound, Identity: identity}

refusal := errors.New(`workspace "platform-core" at obin-ai/platform-core@5a8d5ffa requires host resolution`)
require.Same(t, refusal, explainHostResolutionRefusal(refusal, instance))

workspace, err := resources.LoadWorkspaceFromDir(ctx, root)
require.NoError(t, err)
bound, err := workspace.LoadService(ctx, &resources.ServiceWithModule{Module: "apps", Name: "web"})
require.NoError(t, err)
other := errors.New("cannot load service configuration")
require.Same(t, other, explainHostResolutionRefusal(other, &coreservices.Instance{Service: bound, Identity: identity}))
require.NoError(t, explainHostResolutionRefusal(nil, instance))
require.Same(t, refusal, explainHostResolutionRefusal(refusal, nil))
}
2 changes: 1 addition & 1 deletion pkg/orchestration/local_runtime_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func (runner *Runner) localRuntimeImage(ctx context.Context) (string, error) {
// not overwrite those runtime declarations in shared state.
runner.instance.Builder.Workspace = runner.world.Workspace
if _, err := runner.instance.Builder.Load(ctx); err != nil {
return "", err
return "", explainHostResolutionRefusal(err, runner.instance)
}
b, err := NewBuilder(ctx, runner.instance, runner.world)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions pkg/orchestration/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ func (runner *Runner) Load(ctx context.Context) (*OutputProperty, error) {
if ContextCancelled(err) {
return nil, nil
}
err = explainHostResolutionRefusal(err, runner.instance)
w.Warn(fmt.Sprintf("cannot load runtime instance for <%s> %v", runner.instance.Unique(), err))
if runner.outputPropertyForLoad.processed == nil {
return nil, w.Wrapf(err, "cannot load runtime instance for %s", runner.instance.Unique())
Expand Down
Loading