diff --git a/docs/agent-compatibility.md b/docs/agent-compatibility.md index d9f088d7..fd4e6b3e 100644 --- a/docs/agent-compatibility.md +++ b/docs/agent-compatibility.md @@ -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 diff --git a/pkg/composition/workspace_reference_test.go b/pkg/composition/workspace_reference_test.go new file mode 100644 index 00000000..bbc180da --- /dev/null +++ b/pkg/composition/workspace_reference_test.go @@ -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") +} diff --git a/pkg/orchestration/builder.go b/pkg/orchestration/builder.go index 1e15e578..63d052c7 100644 --- a/pkg/orchestration/builder.go +++ b/pkg/orchestration/builder.go @@ -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))) diff --git a/pkg/orchestration/interface_binding_refusal.go b/pkg/orchestration/interface_binding_refusal.go new file mode 100644 index 00000000..8339413a --- /dev/null +++ b/pkg/orchestration/interface_binding_refusal.go @@ -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) +} diff --git a/pkg/orchestration/interface_binding_refusal_test.go b/pkg/orchestration/interface_binding_refusal_test.go new file mode 100644 index 00000000..1754c55b --- /dev/null +++ b/pkg/orchestration/interface_binding_refusal_test.go @@ -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)) +} diff --git a/pkg/orchestration/local_runtime_image.go b/pkg/orchestration/local_runtime_image.go index cd5fcefd..5f4829db 100644 --- a/pkg/orchestration/local_runtime_image.go +++ b/pkg/orchestration/local_runtime_image.go @@ -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 { diff --git a/pkg/orchestration/runner.go b/pkg/orchestration/runner.go index 8fd5d2ad..1867a2de 100644 --- a/pkg/orchestration/runner.go +++ b/pkg/orchestration/runner.go @@ -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())