From c408c6f6a467ba4b06124c04a709f83c69ac5c94 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:10:17 -0400 Subject: [PATCH] feat(add): scaffold a solution in the one shape a solution has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codefly add solution ` creates a solution: the backend a host installs, the shell that serves its page, and the four documents core already specifies. core declares `Lifecycle.Create` and nothing implements it, so this fills in the implementation rather than designing a feature. There is ONE shape, taken from `obin-ai/core-solutions`' `wiki` — the solution that has been running longest — not a flag offering two. A second shape beside it is what produced nine solutions that do not look alike. The rule the backend lives under is documented rather than made a variant: a module call the authority holds to a person, admitted only "in person, with no intervening actor", is made by the page with that person's own Work Context and never relayed through the backend. That is why the entry service declares the authority interface and the authority's hop and no module edge at all. Composes existing verbs instead of inventing a protocol: `add module`, then `add service` per service. Every document is built from core's own type and read back through core's own reader — `manifest.LoadConsumption`, `modulecontract.Load`, `PackageManifest.Validate`, `solutionhost.ParseFrontend` — so the schema stays the single source of truth and a file that would be refused later is refused at generation. The solution manifest names no executor. core requires an agent of kind codefly:solution and publishes no executor protocol; the agent the shipped manifests name has no releases. Every other rule is still checked, against a stand-in, mirroring core's own consumptionValidationPrincipal. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 1 + cmd/add.go | 1 + cmd/add/solution.go | 415 +++++++++++++++++++++++++++++ cmd/add/solution_documents.go | 483 ++++++++++++++++++++++++++++++++++ cmd/add/solution_test.go | 389 +++++++++++++++++++++++++++ docs/cli-reference.md | 58 ++++ docs/commands.md | 56 ++++ 7 files changed, 1403 insertions(+) create mode 100644 cmd/add/solution.go create mode 100644 cmd/add/solution_documents.go create mode 100644 cmd/add/solution_test.go diff --git a/AGENTS.md b/AGENTS.md index b67281463..2b31c8c31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ new runbook (and its skill) whenever you do a multi-step operational task a seco ### Extending the CLI - **Add a new command** (Cobra wiring, help, MCP exposure) → [docs/runbooks/add-a-command.md](docs/runbooks/add-a-command.md) +- **Scaffold a solution** (`codefly add solution`: the one shape, its two services, and what the composition still owes it) → [docs/commands.md#add-solution](docs/commands.md) - **Rebuild the CLI and agents from local source** → [docs/runbooks/update-agents.md](docs/runbooks/update-agents.md) - **Point a workspace at a coordinate** (import explicit `codefly/coordinate/v1` environment declarations) → [docs/commands.md#codefly-environment](docs/commands.md) - **Export a module's API contracts** → [docs/commands.md#generate-contracts](docs/commands.md#generate-contracts) diff --git a/cmd/add.go b/cmd/add.go index 602f7189f..f6291efa9 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -14,6 +14,7 @@ var AddCmd = &cobra.Command{ func init() { AddCmd.AddCommand(add.ModuleCmd) AddCmd.AddCommand(add.ServiceCmd) + AddCmd.AddCommand(add.SolutionCmd) AddCmd.AddCommand(add.ServiceDependencyCmd) AddCmd.AddCommand(add.LibraryCmd) AddCmd.AddCommand(add.LibraryDependencyCmd) diff --git a/cmd/add/solution.go b/cmd/add/solution.go new file mode 100644 index 000000000..800c753ff --- /dev/null +++ b/cmd/add/solution.go @@ -0,0 +1,415 @@ +package add + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/cli/yamledit" + "github.com/codefly-dev/cli/pkg/solutionrun" + "github.com/codefly-dev/core/composition" + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/services" + "github.com/codefly-dev/core/shared" + "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/core/wool" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// SolutionCmd scaffolds a solution in the one shape a solution has. +// +// It composes verbs the CLI already has — `add module`, then `add service` +// twice — and writes the documents core already specifies, by marshalling +// core's own types and reading each file back through core's own reader. It +// introduces no executor protocol and no new agent: core's Lifecycle.Create is +// declared by nothing and implemented by nothing, so `create` here is the CLI +// doing what an executor would have been asked to do (codefly-dev/cli#1215). +var SolutionCmd = &cobra.Command{ + Use: "solution", + Short: "Scaffold a solution: its backend, its page's shell, and the documents a host installs it from", + Long: `Scaffold a solution in the one shape a solution has. + +A solution is two Codefly services and a page: + + backend The entry service. It presents the solution's identity, serves the + API the page calls, and is the workload a host installs. It is + what the solution is published as. + web The shell that serves the page where no host shell mounts it — the + same ./Page a saas host federates, in a shell of its own, which an + authority's edge serves as the browser's one origin. The package + does not publish it: a host that federates the remote needs no + shell. + fe-remote The ./Page remote itself. It is an npm package, not a Codefly + service, so this command does not scaffold it. + +THE RULE THE BACKEND LIVES UNDER. A module call the authority holds to a +PERSON — one its artifact interface admits only in person, with no intervening +actor — is made by the PAGE, with that person's own Work Context. It is never +relayed through the backend: a backend reaches a module on a context minted +for the solution's own audience, so relaying such a call puts the solution +between the person and the module, and admission refuses it. Reads and work +that are the solution's own go through the backend as usual. + +The backend's language is --agent (go by default, python for the same shape). + +What this writes, and what stays yours: the module, both services, the +solution manifest, the module contract, the package identity and the page +declaration are written and each is read back through core's own validator. +The COMPOSITION is not: which workspace composes this solution, which modules +it pins and at what commits, and the configuration groups those modules need, +are the composition's to say and are never guessed here.`, + Example: ` # A solution with a Go backend that calls two modules. + codefly add solution wiki --package-id obin/wiki \ + --consumes connect:documents/documents/grpc --consumes connect:robin/chat/connect + + # The same shape with a Python backend — the language is the agent. + codefly add solution forecast --package-id obin/forecast --agent python`, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + cli.Init() + defer services.ClearAgents() + + ctx, done := common.NewContext() + defer done() + + ctx, stop := common.SignalContext(ctx) + defer stop() + + if err := addSolution(ctx, args[0]); err != nil { + return fmt.Errorf("cannot add solution: %w", err) + } + return ctx.Err() + }, +} + +var ( + solutionAgentInput string + solutionConsumes []string + solutionPackageID string + solutionExecutor string + solutionDescription string + solutionTitle string + solutionOrder int64 + solutionAuthorityIface string + solutionMinimumCodefly string + solutionWithDefault bool +) + +// addSolution scaffolds the solution. Everything that can fail on the author's +// input — the consumed bindings, a document the schema refuses — is rendered +// and validated BEFORE the first write, so a mistyped flag leaves no +// half-built module behind. +func addSolution(ctx context.Context, name string) error { + w := wool.Get(ctx).In("cmd.add.solution") + + documents, err := solutionDocuments(ctx, name) + if err != nil { + return err + } + + workspace, err := common.LoadWorkspace(ctx) + if err != nil { + return w.Wrapf(err, "cannot load workspace") + } + if workspace.ExistsModule(name) { + return fmt.Errorf("module <%s> already exists", name) + } + + // 1. The module, through `add module`'s own path — including its rollback + // of a module whose creation then fails. + previousModuleDefault := moduleWithDefault + moduleWithDefault = solutionWithDefault + defer func() { moduleWithDefault = previousModuleDefault }() + if moduleErr := addModule(name); moduleErr != nil { + return moduleErr + } + + // 2. Both services, through the agents' own Create flows — the same call + // `add service` makes. + previousWithDefault := withDefault + withDefault = solutionWithDefault + defer func() { withDefault = previousWithDefault }() + entryAgent := solutionAgentInput + if entryAgent == "" { + entryAgent = solutionEntryAgent + } + for _, scaffolded := range []struct{ service, agent string }{ + {solutionEntryService, entryAgent}, + {solutionPageService, solutionPageAgent}, + } { + target := fmt.Sprintf("%s/%s", name, scaffolded.service) + if serviceErr := addService(ctx, target, scaffolded.agent); serviceErr != nil { + return fmt.Errorf("cannot add the solution's %s service: %w", scaffolded.service, serviceErr) + } + } + + mod, err := workspace.LoadModuleFromName(ctx, name) + if err != nil { + return w.Wrapf(err, "cannot load the scaffolded module") + } + + // 3. The module's own declarations, written after both services exist so + // service-entry and the interface never name a service that is not there. + mod.ServiceEntry = solutionEntryService + if solutionDescription != "" { + mod.Description = solutionDescription + } + mod.Interface = solutionModuleInterface() + if err := mod.Save(ctx); err != nil { + return w.Wrapf(err, "cannot save the module manifest") + } + + // 4. The shape each service carries, on the manifest its agent just wrote. + if err := applyEntryServiceShape(ctx, mod); err != nil { + return err + } + if err := applyPageServiceShape(ctx, mod); err != nil { + return err + } + + // 5. The documents. The three at the module root, and the page + // declaration beside the entry service. + for _, document := range documents { + path := filepath.Join(mod.Dir(), filepath.FromSlash(document.name)) + if err := shared.WriteFileAtomic(ctx, path, document.content, 0o644); err != nil { + return fmt.Errorf("cannot write %s: %w", document.name, err) + } + cli.Info("wrote %s", document.name) + } + // Read the contract back through core's own loader: the bytes on disk are + // what every reader of this solution will see. + if _, err := modulecontract.Load(mod.Dir()); err != nil { + return fmt.Errorf("the generated %s is not readable by core: %w", modulecontract.FileName, err) + } + if _, err := solutionrun.DeclaredFrontend(ctx, mod); err != nil { + return fmt.Errorf("the generated %s is not readable by core: %w", solutionrun.FrontendFileName, err) + } + + cli.Header(1, "Solution <%s> scaffolded: %s (entry) and %s (the page's shell).", name, solutionEntryService, solutionPageService) + reportNextSteps(name) + return nil +} + +// scaffoldedDocument is one file the scaffold writes, rendered and validated +// before anything is created. name is slash-separated, relative to the module. +type scaffoldedDocument struct { + name string + content []byte +} + +// solutionDocuments renders every document the solution ships, holding each to +// the validator core owns for it, before the first write. +func solutionDocuments(ctx context.Context, name string) ([]scaffoldedDocument, error) { + if solutionPackageID == "" { + return nil, fmt.Errorf("--package-id / is required: it is the identity the solution is PUBLISHED under, which nothing here can derive — without it the render declares no presence, the host never lists the solution, and nobody can install it") + } + + var consumes []manifest.APIDeclaration + for _, value := range solutionConsumes { + declaration, err := parseConsumedAPI(value) + if err != nil { + return nil, err + } + consumes = append(consumes, declaration) + } + + var executor *resources.Agent + if solutionExecutor != "" { + parsed, err := resources.ParseAgent(ctx, resources.SolutionAgent, solutionExecutor) + if err != nil { + return nil, fmt.Errorf("cannot read --executor %q: %w", solutionExecutor, err) + } + executor = parsed + } + + solution, err := marshalSolutionManifest(solutionManifest(consumes, executor)) + if err != nil { + return nil, err + } + + contract, err := yaml.Marshal(solutionContract(name)) + if err != nil { + return nil, err + } + + toolVersion, err := cli.GetCurrentVersion() + if err != nil { + return nil, fmt.Errorf("cannot read the running Codefly version: %w", err) + } + packaged, err := solutionPackage(solutionPackageID, toolVersion, solutionMinimumCodefly) + if err != nil { + return nil, err + } + if validateErr := packaged.Validate(); validateErr != nil { + return nil, fmt.Errorf("the generated %s is invalid: %w", composition.PackageManifestFileName, validateErr) + } + packageContent, err := yaml.Marshal(packaged) + if err != nil { + return nil, err + } + // A nil optional (required-qualifications) renders as an explicit null, + // which decodes the same as absent and reads as a decision nobody made. + if packageContent, err = dropTopLevelKeys(packageContent, func(_ string, value *yaml.Node) bool { + return isNullNode(value) + }); err != nil { + return nil, err + } + + title := solutionTitle + if title == "" { + title = titleFromName(name) + } + frontend, err := marshalSolutionFrontend(solutionFrontend(title, solutionOrder)) + if err != nil { + return nil, err + } + + return []scaffoldedDocument{ + {name: manifest.FileName, content: solution}, + {name: modulecontract.FileName, content: []byte(contractPreamble + string(contract))}, + {name: composition.PackageManifestFileName, content: []byte(packagePreamble + string(packageContent))}, + {name: "services/" + solutionEntryService + "/" + solutionrun.FrontendFileName, content: frontend}, + }, nil +} + +// titleFromName is the page title a solution gets when --title names none: the +// module name, hyphens read as spaces, each word capitalised. It is the +// product's name, so it is the author's to give — this is only a legible +// placeholder rather than the raw slug in the host's shell. +func titleFromName(name string) string { + words := strings.Split(name, "-") + for index, word := range words { + if word == "" { + continue + } + words[index] = strings.ToUpper(word[:1]) + word[1:] + } + return strings.Join(words, " ") +} + +// applyEntryServiceShape states the solution shape on the backend manifest the +// service agent just wrote, editing the nodes in place so the agent's own +// comments and every key it set that the shape does not speak to survive. +func applyEntryServiceShape(ctx context.Context, mod *resources.Module) error { + return editServiceManifest(ctx, mod, solutionEntryService, func(root *yaml.Node) error { + entry := solutionEntryEndpoint() + if setErr := setEndpoints(root, solutionEntryService, &entry); setErr != nil { + return setErr + } + // The backend presents the solution's identity: the authority takes + // its projected workload credential as the actor of every Work Context + // it issues the solution, so the authority document the CLI renders + // from the module contract approves this service's build — and the CLI + // refuses an authority request with no such service. + identity, err := yamledit.Encode(true) + if err != nil { + return err + } + yamledit.SetMapValue(root, "module-identity", identity) + + dependencies, err := yamledit.Encode(solutionEntryDependencies(solutionAuthorityIface)) + if err != nil { + return err + } + yamledit.SetMapValue(root, "service-dependencies", dependencies) + + // The authority interface's own address. A binding added to the module + // contract later adds its own groups beside this one. + groups, err := yamledit.Encode([]string{"authority"}) + if err != nil { + return err + } + yamledit.SetMapValue(root, "workspace-configuration-dependencies", groups) + return nil + }) +} + +// applyPageServiceShape states the shape on the shell's manifest. The shell +// declares no dependency and no identity: it serves a page, and every call +// that page makes is the browser's, not the shell's. +func applyPageServiceShape(ctx context.Context, mod *resources.Module) error { + return editServiceManifest(ctx, mod, solutionPageService, func(root *yaml.Node) error { + page := solutionPageEndpoint() + return setEndpoints(root, solutionPageService, &page) + }) +} + +// setEndpoints replaces the service's endpoints with the one the shape +// declares. It SETS rather than merges: a solution's service serves one +// endpoint, and a port an agent template happened to scaffold is not part of +// the solution and would be one more surface the cell has to account for. The +// replacement is reported rather than silent. +func setEndpoints(root *yaml.Node, service string, endpoint *resources.Endpoint) error { + if existing := yamledit.MapValue(root, "endpoints"); existing != nil && len(existing.Content) > 0 { + cli.Info("%s: replacing the %d endpoint(s) the agent scaffolded with the solution's one %s endpoint", service, len(existing.Content), endpoint.Name) + } + encoded, err := yamledit.Encode([]resources.Endpoint{*endpoint}) + if err != nil { + return err + } + yamledit.SetMapValue(root, "endpoints", encoded) + return nil +} + +// editServiceManifest applies edit to a scaffolded service's manifest, +// preserving the agent's own comments and unmodeled keys. +func editServiceManifest(ctx context.Context, mod *resources.Module, service string, edit func(root *yaml.Node) error) error { + path := filepath.Join(mod.ServicesDir(), service, resources.ServiceConfigurationName) + original, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("cannot read the %s service manifest: %w", service, err) + } + document, root, err := yamledit.Document(original) + if err != nil { + return fmt.Errorf("cannot parse the %s service manifest: %w", service, err) + } + if editErr := edit(root); editErr != nil { + return editErr + } + updated, err := yamledit.Marshal(document) + if err != nil { + return err + } + if err := shared.WriteFileAtomic(ctx, path, updated, 0o644); err != nil { + return fmt.Errorf("cannot write the %s service manifest: %w", service, err) + } + return nil +} + +// reportNextSteps names what the scaffold deliberately did not decide, so the +// author meets each one here rather than as a failed run. +func reportNextSteps(name string) { + cli.Info("Next, and none of it derivable from here:") + cli.Info(" 1. Author services/fe-remote — the ./Page remote the shell compiles and a host") + cli.Info(" federates. It is an npm package, not a Codefly service.") + cli.Info(" 2. Compose the modules this solution calls, and COPY EACH CONFIGURATION GROUP WHOLE.") + cli.Info(" A group is the set its consumers declare, not the modules you call: trimming one") + cli.Info(" gets `module contract slot cannot be resolved`, `slot resolves ambiguously`, or") + cli.Info(" `it is not a module this workspace composes`. Disable behaviour by the module's own flag.") + cli.Info(" 3. Pin every composed module at a DEFAULT-BRANCH commit. A PR head dies on squash-merge.") + cli.Info(" 4. Add a binding to %s per module call made ON A PERSON'S BEHALF. A call the", modulecontract.FileName) + cli.Info(" authority holds to a person IN PERSON takes no binding: the page makes it, as the person.") + cli.Info(" Running it locally: do not pass --temporary-ports — it breaks ${endpoint:...} for") + cli.Info(" anything resolved before init. Exclude any converter service whose local run needs an") + cli.Info(" image the CLI does not pass (codefly-dev/cli#932).") + cli.Info(" The solution is <%s> to the composition, and %s to whoever publishes it.", name, solutionPackageID) +} + +func init() { + SolutionCmd.Flags().StringVar(&solutionPackageID, "package-id", "", "The / identity the solution is published under. Required; nothing here can derive who publishes it") + SolutionCmd.Flags().StringVar(&solutionAgentInput, "agent", "", "Service agent for the backend (default: "+solutionEntryAgent+"). The backend's language is this flag, not a different shape") + SolutionCmd.Flags().StringArrayVar(&solutionConsumes, "consumes", nil, "A module this solution calls, as [=]://. Repeatable. Route names only — authority is declared once, in the module contract") + SolutionCmd.Flags().StringVar(&solutionExecutor, "executor", "", "A codefly:solution executor to name in the solution manifest. Omitted by default: core specifies no executor protocol and none is published (codefly-dev/cli#1215)") + SolutionCmd.Flags().StringVar(&solutionDescription, "description", "", "Short description written to module.codefly.yaml") + SolutionCmd.Flags().StringVar(&solutionTitle, "title", "", "The page's title in the host's shell (default: the module name, capitalised)") + SolutionCmd.Flags().Int64Var(&solutionOrder, "order", 50, "The page's order in the host's shell") + SolutionCmd.Flags().StringVar(&solutionAuthorityIface, "authority-interface", authorityInterfaceRange, "The authority interface the backend requires, as /@") + SolutionCmd.Flags().StringVar(&solutionMinimumCodefly, "minimum-codefly-version", "", "The Codefly range the package declares (default: the running release, open to the end of its major). Required when scaffolding with a build from source, which reports no release version") + SolutionCmd.Flags().BoolVar(&solutionWithDefault, "yes", false, "Skip confirmation prompts (non-interactive/MCP mode)") +} diff --git a/cmd/add/solution_documents.go b/cmd/add/solution_documents.go new file mode 100644 index 000000000..63c9c406e --- /dev/null +++ b/cmd/add/solution_documents.go @@ -0,0 +1,483 @@ +package add + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/codefly-dev/cli/pkg/cli/yamledit" + "github.com/codefly-dev/cli/pkg/solutionrun" + "github.com/codefly-dev/core/composition" + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/standards" + "gopkg.in/yaml.v3" +) + +// A solution has ONE shape. It is the one obin-ai/core-solutions' `wiki` has +// been running on, not the smaller one the demos carry, and every solution is +// conformed to it rather than a second being offered beside it. +// +// Two Codefly services and one page: +// +// - backend — the entry service. It presents the solution's identity +// (module-identity), serves the API the page calls, and is the workload a +// host installs. It is NOT optional: it is what the solution is published +// as, and what the principal in module.contract.codefly.yaml belongs to. +// - web — the shell that serves the page where no host shell mounts it. The +// same ./Page a saas host federates, in a shell of its own, which an +// authority's edge serves as the browser's one origin. The PACKAGE does +// not publish it: a host that federates the remote needs no shell. +// - services/fe-remote — the ./Page remote itself. It is an npm package, not +// a Codefly service, so this command does not scaffold it. +// +// THE ONE RULE THE BACKEND LIVES UNDER, which is not a second shape: a module +// call the authority holds to a PERSON — one its artifact interface admits +// only "in person, with no intervening actor" — is made by the page, with that +// person's own Work Context, never relayed through the backend. A backend +// reaches a module on a context minted for the solution's own audience, so +// relaying such a call puts the solution between the person and the module and +// admission refuses it. A backend that does not relay those calls is no +// intervening actor, and wiki is the proof: it keeps its backend and reaches +// robin, tracing and annotations from the page. +const ( + solutionEntryService = "backend" + solutionPageService = "web" + solutionEntryAgent = "go" + solutionPageAgent = "nextjs" +) + +// authorityInterfaceRange is the published interface a solution's backend +// requires: the authority gateway on a laptop, the host in a cell. A +// composition that names none is refused at boot. +// +// It is the one value here with no derivation behind it — core publishes no +// "current authority range" for a scaffold to read — so it is the range the +// running solutions require (wiki's services/backend/service.codefly.yaml), +// and --authority-interface overrides it for a composition on another. +const authorityInterfaceRange = "codefly.dev/authority@>=0.2.0 <0.4.0" + +// solutionEntryEndpoint is the endpoint the backend serves: the API behind the +// page. `exposure: none` makes it reachable inside the cell and addressed from +// nowhere outside it — what the outside reaches is the host's own door. +func solutionEntryEndpoint() resources.Endpoint { + return resources.Endpoint{ + Name: standards.HTTP, + Visibility: resources.VisibilityPublic, + Exposure: resources.ExposureNone, + API: standards.HTTP, + Health: &resources.Health{Readiness: &resources.Probe{ + Kind: standards.HTTP, Path: "/health", Statuses: []string{"200"}, + }}, + } +} + +// solutionPageEndpoint is the shell's endpoint. The edge serves it as the +// browser's origin and nothing outside the composition reaches it, so it +// carries no API: it answers a browser, not a contract. +func solutionPageEndpoint() resources.Endpoint { + return resources.Endpoint{ + Name: standards.HTTP, + Visibility: resources.VisibilityPublic, + Exposure: resources.ExposureNone, + } +} + +// solutionModuleInterface is what the composition may reach across the +// solution's boundary: the backend's entry endpoint, which an edge fronts, and +// the shell, reached only by the edge that serves it. An endpoint the +// interface does not export admits no other module, so an edge could not be +// routed to one that is missing here. +func solutionModuleInterface() *resources.ModuleInterface { + return &resources.ModuleInterface{Endpoints: []*resources.InterfaceEndpoint{ + {Service: solutionEntryService, Endpoint: standards.HTTP, Visibility: resources.VisibilityPublic}, + {Service: solutionPageService, Endpoint: standards.HTTP, Visibility: resources.VisibilityInternal}, + }} +} + +// solutionEntryDependencies is the ONLY service-dependency set a solution's +// backend declares, and it is the host's own door: the authority interface, +// which the workspace binds to the one authority in scope, and the authority's +// hop, which lets through only a call the authority checked. +// +// Every consumed module is reached THROUGH that hop and gets no entry here. A +// service-dependency on a module renders into a mesh allow rule straight to +// that module's service, past the check — which is what core-solutions' +// check-solution-mesh gate refuses, and what cmd/sync/solution_sdk.go's +// missingServiceDependencies already declines to add. +// +// The hop is `kind: external`: composed by the workspace that composes this +// solution and never started from here, so it consumes no endpoint and derives +// no mesh allow rule of its own. +func solutionEntryDependencies(authorityInterface string) []authoredDependency { + return []authoredDependency{ + {Interface: authorityInterface}, + { + Name: "hop", + Module: "authority", + Kind: resources.DependencyKindExternal, + Endpoints: []map[string]string{{"name": standards.GRPC}}, + }, + } +} + +// authoredDependency is the half of a service-dependency an author writes. +// core's resources.ServiceDependency carries fields a renderer fills, and its +// EndpointReference renders an empty `api:` that says nothing, so the authored +// half is spelled here — the same shape, and for the same reason, as +// cmd/sync/solution_sdk.go's applyServiceDependencies, which is the other +// place this repository appends one. +type authoredDependency struct { + Interface string `yaml:"interface,omitempty"` + Name string `yaml:"name,omitempty"` + Module string `yaml:"module,omitempty"` + Kind resources.DependencyKind `yaml:"kind,omitempty"` + Endpoints []map[string]string `yaml:"endpoints,omitempty"` +} + +// parseConsumedAPI reads one --consumes value into the api.consumes entry core +// specifies: +// +// [=]:// +// +// The id defaults to the producing module's name, and `as` — the route name +// the solution addresses it by — defaults to the id. core-solutions' +// check-consumed-apis gate requires all three of id, as and protocol on every +// entry, so none is left for core's defaults to supply. +// +// Entries carry NO scopes and no audience, deliberately. A solution declares +// its authority ONCE, in module.contract.codefly.yaml, and the CLI refuses a +// module that asks for authority from both a contract and scoped api.consumes +// (pkg/gitops/authority.go: "a module asks for authority from one +// declaration"). Unscoped entries beside a contract are accepted, so these are +// route names and nothing more. +func parseConsumedAPI(value string) (manifest.APIDeclaration, error) { + declaration := manifest.APIDeclaration{} + spec := strings.TrimSpace(value) + if id, rest, found := strings.Cut(spec, "="); found { + declaration.ID = strings.TrimSpace(id) + spec = strings.TrimSpace(rest) + } + protocol, binding, found := strings.Cut(spec, ":") + if !found { + return declaration, fmt.Errorf("--consumes %q must be [=]://", value) + } + declaration.Protocol = strings.TrimSpace(protocol) + parts := strings.Split(strings.TrimSpace(binding), "/") + if len(parts) != 3 { + return declaration, fmt.Errorf("--consumes %q names %d of module/service/endpoint; a consumed API is bound by all three or by none", value, len(parts)) + } + declaration.Module, declaration.Service, declaration.Endpoint = parts[0], parts[1], parts[2] + if declaration.ID == "" { + declaration.ID = declaration.Module + } + declaration.As = declaration.ID + return declaration, nil +} + +// solutionManifest builds the manifest the solution ships. The schema is +// core's (solution/manifest), so this chooses VALUES and never restates the +// document: a field core adds arrives here by recompiling. +func solutionManifest(consumes []manifest.APIDeclaration, executor *resources.Agent) *manifest.Manifest { + built := &manifest.Manifest{ + SchemaVersion: manifest.SchemaVersionV0, + ProtocolVersion: manifest.ProtocolVersionV0, + // The schema calls a Service "one service the solution scaffolds", so + // the manifest names what was scaffolded rather than leaving a reader + // to guess. The shell is one of them: the package does not publish it, + // but the solution does scaffold it. + Services: []manifest.Service{ + {ID: solutionEntryService, Name: solutionEntryService}, + {ID: solutionPageService, Name: solutionPageService}, + }, + API: manifest.API{ + Exposes: []manifest.APIDeclaration{{ID: standards.HTTP, Protocol: standards.HTTP}}, + Consumes: consumes, + }, + // `create` and `update` stay false: they are executor operations, and + // declaring one a packaged runtime cannot perform is what + // Manifest.AdmitInformation refuses. This command IS the create — the + // CLI composing verbs it already has — so nothing advertises it. + Lifecycle: manifest.Lifecycle{Package: true, Render: true}, + } + if executor != nil { + built.Agent = *executor + } + return built +} + +// validationExecutor stands in for the executor while a manifest with none is +// validated. It mirrors core's own consumptionValidationPrincipal: a field a +// later caller supplies is stood in for so every rule NOT about it is still +// checked, rather than the whole document going unvalidated. +// +// Standing one in is necessary because Manifest.Validate requires an agent of +// kind codefly:solution at a concrete semantic version and no such executor is +// published anywhere — core/solution/ holds a manifest schema and a policy and +// no executor protocol for an agent to implement (codefly-dev/cli#1215). +// Naming one that does not exist would be worse than naming none: the shipped +// manifests that name codefly.dev/solution-generic@0.0.1 name a repository +// with no releases, which resolves to "agent: binary not found" at the first +// command that tries to run it. +var validationExecutor = resources.Agent{ + Kind: resources.SolutionAgent, + Publisher: "codefly.dev", + Name: "solution", + Version: "0.0.0", +} + +// validateSolutionManifest holds the manifest to core's rules — every rule, +// for a manifest naming an executor, and every rule but the executor's own for +// one that does not. +func validateSolutionManifest(built *manifest.Manifest) error { + probe := *built + if probe.Agent.Kind == "" { + probe.Agent = validationExecutor + } + return probe.Validate() +} + +// noExecutorPreamble is written above a manifest that names none, so the file +// says on its own why — the next author reads the file, not this source. +const noExecutorPreamble = `# No executor is named. core's schema (solution/manifest, Validate) requires an +# agent of kind codefly:solution, but core specifies no executor PROTOCOL for one +# to implement and none is published: codefly.dev/solution-generic, which the +# shipped manifests name, is a repository with no releases, so naming it resolves +# to "agent: binary not found" at the first command that runs it (cli#1215). +# +# Nothing refuses this file for it. Every reader of a shipped manifest reads the +# consumption and leaves the rest unread — core's LoadConsumption, whose own doc +# says a manifest whose agent block does not pass Load "asks for no authority +# because of it". ` + "`codefly add solution --executor`" + ` writes one the day an +# executor exists; until then every other rule of the schema was checked at +# generation against a stand-in. +` + +// marshalSolutionManifest renders the manifest, dropping the agent key when no +// executor is named rather than emitting the zero Agent's four empty strings. +// The key is removed from the rendered document instead of the document being +// rebuilt from a local struct, so core's Manifest stays the only statement of +// the schema. +func marshalSolutionManifest(built *manifest.Manifest) ([]byte, error) { + if err := validateSolutionManifest(built); err != nil { + return nil, err + } + rendered, err := yaml.Marshal(built) + if err != nil { + return nil, err + } + preamble := "" + if built.Agent.Kind == "" { + // Drop the key rather than emit the zero Agent's four empty strings. + if rendered, err = dropTopLevelKeys(rendered, func(key string, _ *yaml.Node) bool { + return key == "agent" + }); err != nil { + return nil, err + } + preamble = noExecutorPreamble + } + // Read the BYTES back through core's own consumption reader — the one + // every render, sync and authority derivation uses. Validating the value + // and shipping the file is not the same check: this is the file as those + // readers will see it. + content := []byte(preamble + string(rendered)) + if _, err := manifest.LoadConsumption(content); err != nil { + return nil, fmt.Errorf("the generated %s is not readable by core's consumption reader: %w", manifest.FileName, err) + } + return content, nil +} + +// contractPreamble says what the contract is, in the file, because a reader +// meets the document before this source. +const contractPreamble = `# What this solution asks for (codefly/module-contract/v1). A request, never a +# grant. Its credentials are issued to the principal below, the id its +# module-identity services present. This is the ONE declaration of the +# solution's authority: the CLI renders the signed authority document from +# these bindings, and refuses a module that asks for authority from both a +# contract and SCOPED api.consumes — which is why solution.codefly.yaml's +# api.consumes carries route names only. +# +# It asks for nothing yet. Add a binding per module call the solution makes ON +# A PERSON'S BEHALF, with the audience as a slot into the composition's own +# group: the audience a module verifies is the composition's to say. A call the +# authority holds to a person IN PERSON takes no binding here — the page makes +# it, as the person. +` + +// solutionContract is the module contract the solution ships: the principal +// every credential it mints is issued to, owner-aware checking, and lists +// declared EMPTY rather than omitted — an absent list and "there are none" +// must not look the same. +// +// owner_aware is TRUE and is not a choice. The authority issues a person a +// capability at a solution only when the solution declares owner-aware +// checking (core contracts/module: "Solutions must declare it to receive +// person capabilities from the authority"), and refuses it by name otherwise. +// A solution scaffolded without it receives no owner-selected scope and can +// act for nobody. +func solutionContract(name string) *modulecontract.Contract { + return &modulecontract.Contract{ + Schema: modulecontract.SchemaV1, + Principal: name, + OwnerAware: true, + Namespaces: []string{}, + Queues: []string{}, + ScopeCeilings: []modulecontract.ScopeCeiling{}, + Bindings: []modulecontract.Binding{}, + Destinations: []modulecontract.Destination{}, + } +} + +// solutionFrontend is the page's declaration, read by core's own strict +// parser. `./Page` is the Module Federation remote a host federates, which is +// why the shell and the host mount the same module. +func solutionFrontend(title string, order int64) *solutionhost.Frontend { + return &solutionhost.Frontend{ + Title: title, + Order: order, + ExposedModule: "./Page", + HostContract: 1, + } +} + +// marshalSolutionFrontend renders the frontend declaration and reads it back +// through core's strict parser — the same reader that will refuse it at +// delivery, so a file that would fail there fails here instead. +func marshalSolutionFrontend(frontend *solutionhost.Frontend) ([]byte, error) { + rendered, err := yaml.Marshal(struct { + Title string `yaml:"title"` + Order int64 `yaml:"order"` + ExposedModule string `yaml:"exposed_module"` + HostContract uint64 `yaml:"host_contract"` + }{frontend.Title, frontend.Order, frontend.ExposedModule, frontend.HostContract}) + if err != nil { + return nil, err + } + if _, err := solutionhost.ParseFrontend(rendered); err != nil { + return nil, fmt.Errorf("the generated %s is invalid: %w", solutionrun.FrontendFileName, err) + } + return rendered, nil +} + +// packagePreamble states what the package identity is for, in the file. +const packagePreamble = `# The solution's package identity. A solution is delivered to a host the way a +# module is: the render declares its presence from this file, and a solution +# without one declares no presence, so the host never lists it and nobody can +# install it. Authority is effective from that presence generation too, so the +# scopes module.contract.codefly.yaml declares reach no host without it. +# +# The id is the identity the solution is published under; the version is the +# package's, not the repository tag's: it moves when what the package publishes +# moves. The shell service is deliberately absent below — a host that federates +# the page's remote needs no shell of its own. +` + +// solutionPackage is the package identity the solution ships. +// +// Every value but the id is derived. The contract ranges come from core's +// DefaultSupportedContracts, so the day core supports a new major the scaffold +// writes that one; the Codefly floor comes from the build doing the +// scaffolding. The id is the one thing no derivation reaches: it is the +// identity the solution is PUBLISHED under, owned by whoever publishes it, and +// a guess here would be committed and wrong. +func solutionPackage(id, toolVersion, declaredFloor string) (*composition.PackageManifest, error) { + contracts := map[string]string{} + for contract, supported := range composition.DefaultSupportedContracts { + if contract != composition.ContractComposition && contract != composition.ContractFrontendPlugin { + continue + } + major, err := majorRange(supported) + if err != nil { + return nil, fmt.Errorf("cannot derive the %s contract range: %w", contract, err) + } + contracts[contract] = major + } + floor := declaredFloor + if floor == "" { + derived, err := codeflyFloor(toolVersion) + if err != nil { + return nil, err + } + floor = derived + } + return &composition.PackageManifest{ + Kind: composition.PackageKind, + Schema: composition.PackageSchema, + ID: id, + Version: "0.1.0", + MinimumCodeflyVersion: floor, + ArtifactRoots: []string{"services"}, + // The backend only. The shell is scaffolded and composed; it is not + // published, because a host that federates the remote needs no shell. + Services: []composition.ProvidedService{{Name: solutionEntryService, Endpoints: []string{standards.HTTP}}}, + Contracts: contracts, + }, nil +} + +// majorRange turns the contract versions core supports into the range a +// package declares: the newest supported major, open to the end of that major. +func majorRange(supported []string) (string, error) { + best := -1 + for _, value := range supported { + major, _, _ := strings.Cut(value, ".") + parsed, err := strconv.Atoi(major) + if err != nil { + return "", fmt.Errorf("supported version %q has no major", value) + } + if parsed > best { + best = parsed + } + } + if best < 0 { + return "", fmt.Errorf("core supports no version of this contract") + } + return fmt.Sprintf(">=%d.0.0 <%d.0.0", best, best+1), nil +} + +// codeflyFloor is the Codefly requirement a freshly scaffolded package +// declares: the build that scaffolded it, open to the end of its major. A +// package cannot claim to work with a Codefly older than the one that wrote +// its documents, and the upper bound is the major it was written against. +// +// A build from source reports no release version ("development"), and there is +// no honest floor to derive from it — the package would claim compatibility +// with a release that may not exist yet. That is a case for the author to +// state, not for this to guess, so it is refused by name with the flag that +// settles it. +func codeflyFloor(toolVersion string) (string, error) { + parsed, err := semver.NewVersion(strings.TrimPrefix(toolVersion, "v")) + if err != nil { + return "", fmt.Errorf("this Codefly reports version %q, which is not a release, so the floor the package declares cannot be derived from it: pass --minimum-codefly-version (e.g. \">=0.1.220 <1.0.0\")", toolVersion) + } + return fmt.Sprintf(">=%d.%d.%d <%d.0.0", parsed.Major(), parsed.Minor(), parsed.Patch(), parsed.Major()+1), nil +} + +// dropTopLevelKeys re-renders a freshly marshalled document without the +// top-level keys drop selects. It exists so a document can keep core's own +// struct as the one statement of its schema — a local struct with different +// tags would be a second statement that drifts — while still not shipping a +// key whose value says nothing. +func dropTopLevelKeys(rendered []byte, drop func(key string, value *yaml.Node) bool) ([]byte, error) { + document, root, err := yamledit.Document(rendered) + if err != nil { + return nil, err + } + content := make([]*yaml.Node, 0, len(root.Content)) + for index := 0; index+1 < len(root.Content); index += 2 { + if drop(root.Content[index].Value, root.Content[index+1]) { + continue + } + content = append(content, root.Content[index], root.Content[index+1]) + } + root.Content = content + return yamledit.Marshal(document) +} + +// isNullNode reports a YAML null — the rendering of a nil pointer field that +// no omitempty tag suppresses. +func isNullNode(node *yaml.Node) bool { + return node != nil && node.Kind == yaml.ScalarNode && node.Tag == "!!null" +} diff --git a/cmd/add/solution_test.go b/cmd/add/solution_test.go new file mode 100644 index 000000000..7fff35bf0 --- /dev/null +++ b/cmd/add/solution_test.go @@ -0,0 +1,389 @@ +package add + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/solutionrun" + "github.com/codefly-dev/core/composition" + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/core/solutionhost" + "gopkg.in/yaml.v3" +) + +// withSolutionFlags sets the command's package-level flag state for one test +// and restores it, so tests do not leak into each other or into the binary's +// defaults. +func withSolutionFlags(t *testing.T, packageID string, consumes []string) { + t.Helper() + previous := struct { + packageID, executor, title, minimum, authority string + consumes []string + order int64 + }{solutionPackageID, solutionExecutor, solutionTitle, solutionMinimumCodefly, solutionAuthorityIface, solutionConsumes, solutionOrder} + t.Cleanup(func() { + solutionPackageID, solutionExecutor, solutionTitle = previous.packageID, previous.executor, previous.title + solutionMinimumCodefly, solutionAuthorityIface = previous.minimum, previous.authority + solutionConsumes, solutionOrder = previous.consumes, previous.order + }) + solutionPackageID, solutionConsumes = packageID, consumes + solutionExecutor, solutionTitle = "", "" + solutionOrder = 50 + solutionAuthorityIface = authorityInterfaceRange + // A test binary reports no release version, which is exactly the case the + // scaffold refuses to guess a floor for. + solutionMinimumCodefly = ">=0.1.220 <1.0.0" +} + +func documentsByName(t *testing.T, name string, consumes []string) map[string][]byte { + t.Helper() + withSolutionFlags(t, "obin/"+name, consumes) + rendered, err := solutionDocuments(context.Background(), name) + if err != nil { + t.Fatalf("solutionDocuments: %v", err) + } + out := make(map[string][]byte, len(rendered)) + for _, document := range rendered { + out[document.name] = document.content + } + return out +} + +// The scaffold writes the four documents a solution is installed from, and the +// page declaration sits beside the ENTRY service — core reads it from there. +func TestSolutionScaffoldWritesTheDocumentsAHostInstallsFrom(t *testing.T) { + documents := documentsByName(t, "wiki", nil) + for _, want := range []string{ + manifest.FileName, + modulecontract.FileName, + composition.PackageManifestFileName, + "services/" + solutionEntryService + "/" + solutionrun.FrontendFileName, + } { + if _, found := documents[want]; !found { + t.Errorf("scaffold writes no %s", want) + } + } +} + +// The package identity is required: a solution without one declares no +// presence, so the host never lists it and nobody can install it. Guessing an +// owner would commit a wrong identity, so the scaffold refuses instead. +func TestSolutionRefusesWithoutAPublishedIdentity(t *testing.T) { + withSolutionFlags(t, "", nil) + _, err := solutionDocuments(context.Background(), "wiki") + if err == nil || !strings.Contains(err.Error(), "--package-id") { + t.Fatalf("error = %v, want a refusal naming --package-id", err) + } +} + +// A build from source reports no release version. The package's Codefly floor +// cannot be derived from it, and a guess would claim compatibility with a +// release that may not exist, so it is refused by name. +func TestSolutionRefusesToGuessACodeflyFloorFromADevelopmentBuild(t *testing.T) { + if _, err := codeflyFloor("development"); err == nil || + !strings.Contains(err.Error(), "--minimum-codefly-version") { + t.Fatalf("error = %v, want a refusal naming --minimum-codefly-version", err) + } + floor, err := codeflyFloor("0.1.220") + if err != nil { + t.Fatal(err) + } + if floor != ">=0.1.220 <1.0.0" { + t.Fatalf("floor = %q, want >=0.1.220 <1.0.0", floor) + } +} + +// The generated manifest names no executor — none is published — and is still +// read by the reader every render, sync and authority derivation uses. This is +// the whole claim behind shipping it without an agent. +func TestGeneratedSolutionManifestNamesNoExecutorAndStillLoads(t *testing.T) { + documents := documentsByName(t, "wiki", []string{"connect:documents/documents/grpc"}) + content := documents[manifest.FileName] + + var decoded map[string]any + if err := yaml.Unmarshal(content, &decoded); err != nil { + t.Fatal(err) + } + if _, named := decoded["agent"]; named { + t.Fatalf("manifest names an executor; none is published:\n%s", content) + } + + loaded, err := manifest.LoadConsumption(content) + if err != nil { + t.Fatalf("core's consumption reader refuses the generated manifest: %v\n%s", err, content) + } + if len(loaded.API.Consumes) != 1 || loaded.API.Consumes[0].Module != "documents" { + t.Fatalf("consumption = %+v, want the one documents binding", loaded.API.Consumes) + } +} + +// Naming an executor makes the manifest pass core's STRICT loader outright, +// which is the state the file reaches the day an executor is published. +func TestGeneratedSolutionManifestWithAnExecutorPassesTheStrictLoader(t *testing.T) { + withSolutionFlags(t, "obin/wiki", nil) + executor := &resources.Agent{ + Kind: resources.SolutionAgent, Publisher: "codefly.dev", Name: "solution-generic", Version: "0.1.0", + } + content, err := marshalSolutionManifest(solutionManifest(nil, executor)) + if err != nil { + t.Fatal(err) + } + if _, err := manifest.Load(content); err != nil { + t.Fatalf("core's strict loader refuses a manifest naming an executor: %v\n%s", err, content) + } +} + +// Every rule of the schema but the executor's own is checked even when no +// executor is named — the stand-in exists so an unvalidated document is never +// what shipping without an agent costs. +func TestSolutionManifestRulesAreCheckedWithoutAnExecutor(t *testing.T) { + built := solutionManifest([]manifest.APIDeclaration{ + {ID: "documents", Protocol: "connect", Module: "documents", Service: "documents"}, + }, nil) + if _, err := marshalSolutionManifest(built); err == nil { + t.Fatal("a half-bound consumed API was accepted; the stand-in is not validating the rest of the schema") + } +} + +// owner_aware is not a style choice: the authority issues a person a +// capability at a solution only when the solution declares owner-aware +// checking, and refuses it by name otherwise. A scaffold that omitted it would +// produce a solution that can act for nobody, silently. +func TestGeneratedContractDeclaresOwnerAwareChecking(t *testing.T) { + documents := documentsByName(t, "wiki", nil) + var contract modulecontract.Contract + if err := yaml.Unmarshal(documents[modulecontract.FileName], &contract); err != nil { + t.Fatal(err) + } + if !contract.OwnerAware { + t.Fatal("the generated contract does not declare owner-aware checking, so the authority issues it no person capability") + } + if contract.Principal != "wiki" { + t.Fatalf("principal = %q, want wiki", contract.Principal) + } + // Declared empty, never omitted: an absent list and "there are none" must + // not look the same. + for key, declared := range map[string]bool{ + "namespaces": contract.Namespaces != nil, + "queues": contract.Queues != nil, + "scope_ceilings": contract.ScopeCeilings != nil, + "bindings": contract.Bindings != nil, + } { + if !declared { + t.Errorf("%s is omitted rather than declared empty", key) + } + } +} + +// The scaffolded module and the published package disagree on purpose: the +// shell is scaffolded and composed, and is NOT published, because a host that +// federates the page's remote needs no shell of its own. +func TestThePackagePublishesTheBackendAndNotTheShell(t *testing.T) { + documents := documentsByName(t, "wiki", nil) + var packaged composition.PackageManifest + if err := yaml.Unmarshal(documents[composition.PackageManifestFileName], &packaged); err != nil { + t.Fatal(err) + } + if err := packaged.Validate(); err != nil { + t.Fatalf("the generated package manifest is invalid: %v", err) + } + if len(packaged.Services) != 1 || packaged.Services[0].Name != solutionEntryService { + t.Fatalf("published services = %+v, want only %s", packaged.Services, solutionEntryService) + } + // The contract ranges are derived from what core supports, never typed. + if packaged.Contracts[composition.ContractComposition] != ">=2.0.0 <3.0.0" { + t.Fatalf("composition contract = %q, want the range derived from core's supported majors", + packaged.Contracts[composition.ContractComposition]) + } +} + +// The page declaration is read back through core's own strict parser, which is +// the reader that will refuse it at delivery. +func TestGeneratedFrontendDeclarationParsesWithCore(t *testing.T) { + documents := documentsByName(t, "last-login", nil) + frontend, err := solutionhost.ParseFrontend(documents["services/"+solutionEntryService+"/"+solutionrun.FrontendFileName]) + if err != nil { + t.Fatal(err) + } + if frontend.ExposedModule != "./Page" { + t.Fatalf("exposed_module = %q, want ./Page", frontend.ExposedModule) + } + // A legible placeholder rather than the raw slug in the host's shell. + if frontend.Title != "Last Login" { + t.Fatalf("title = %q, want Last Login", frontend.Title) + } +} + +func TestParseConsumedAPI(t *testing.T) { + declaration, err := parseConsumedAPI("connect:documents/documents/grpc") + if err != nil { + t.Fatal(err) + } + // The id and the route name default to the producing module's name, and + // both are required by the consumer repository's own gate. + if declaration.ID != "documents" || declaration.As != "documents" || declaration.Protocol != "connect" { + t.Fatalf("declaration = %+v", declaration) + } + named, err := parseConsumedAPI("store=connect:documents/documents/grpc") + if err != nil { + t.Fatal(err) + } + if named.ID != "store" || named.As != "store" || named.Module != "documents" { + t.Fatalf("declaration = %+v", named) + } + for _, malformed := range []string{"documents/documents/grpc", "connect:documents/documents", "connect:a/b/c/d"} { + if _, err := parseConsumedAPI(malformed); err == nil { + t.Errorf("parseConsumedAPI(%q) was accepted", malformed) + } + } +} + +// The entry service declares the host's door and NOTHING else. A +// service-dependency on a consumed module renders into a mesh allow rule +// straight to that module's service, past the authority's check — which is the +// rule cmd/sync/solution_sdk.go already applies and the consumer repository's +// check-solution-mesh gate enforces. +func TestEntryServiceDeclaresTheAuthorityDoorAndNoModuleEdge(t *testing.T) { + dependencies := solutionEntryDependencies(authorityInterfaceRange) + if len(dependencies) != 2 { + t.Fatalf("dependencies = %+v, want the interface and the hop", dependencies) + } + if dependencies[0].Interface != authorityInterfaceRange { + t.Fatalf("first dependency = %+v, want the authority interface", dependencies[0]) + } + hop := dependencies[1] + if hop.Module != "authority" || hop.Name != "hop" || hop.Kind != resources.DependencyKindExternal { + t.Fatalf("second dependency = %+v, want authority/hop as external", hop) + } + // What the scaffold writes must decode as core's own declaration. + rendered, err := yaml.Marshal(dependencies) + if err != nil { + t.Fatal(err) + } + var decoded []resources.ServiceDependency + if err := yaml.Unmarshal(rendered, &decoded); err != nil { + t.Fatalf("the authored dependencies do not decode as core's: %v\n%s", err, rendered) + } + if len(decoded) != 2 || decoded[1].Endpoints[0].Name != "grpc" { + t.Fatalf("decoded = %+v", decoded) + } +} + +// The entry service's manifest keeps whatever the agent wrote that the shape +// does not speak to, and gains exactly what the shape states. +func TestApplyEntryServiceShapeEditsInPlace(t *testing.T) { + root := t.TempDir() + module := &resources.Module{Name: "wiki"} + module.WithDir(root) + scaffolded := filepath.Join(root, "services", solutionEntryService, resources.ServiceConfigurationName) + writeAddTestFile(t, scaffolded, `kind: service +name: backend +version: 0.0.0 +# the agent's own note, which must survive +agent: + publisher: codefly.dev + kind: codefly:service + name: go + version: 0.0.70 +spec: + source-dir: . +endpoints: + - name: grpc + visibility: private +`) + withSolutionFlags(t, "obin/wiki", nil) + if err := applyEntryServiceShape(context.Background(), module); err != nil { + t.Fatal(err) + } + updated, err := os.ReadFile(scaffolded) + if err != nil { + t.Fatal(err) + } + text := string(updated) + if !strings.Contains(text, "the agent's own note") { + t.Errorf("the agent's comments were lost:\n%s", text) + } + if !strings.Contains(text, "module-identity: true") { + t.Errorf("the backend does not present the solution's identity:\n%s", text) + } + // An `api:` nobody wrote says nothing and is not shipped. + if strings.Contains(text, `api: ""`) { + t.Errorf("an empty api key was written:\n%s", text) + } + + service := struct { + Endpoints []resources.Endpoint `yaml:"endpoints"` + Dependencies []resources.ServiceDependency `yaml:"service-dependencies"` + Groups []string `yaml:"workspace-configuration-dependencies"` + }{} + if err := yaml.Unmarshal(updated, &service); err != nil { + t.Fatal(err) + } + // The agent's own port is replaced, not kept beside the solution's: one + // service, one endpoint, and no surface the cell did not account for. + if len(service.Endpoints) != 1 || service.Endpoints[0].Name != "http" || + service.Endpoints[0].Visibility != resources.VisibilityPublic || + service.Endpoints[0].Exposure != resources.ExposureNone { + t.Fatalf("endpoints = %+v, want one public http endpoint exposed nowhere", service.Endpoints) + } + for _, dependency := range service.Dependencies { + if dependency.Module != "" && dependency.Module != "authority" { + t.Errorf("the entry service declares a direct path to %s/%s, past the authority's check", + dependency.Module, dependency.Name) + } + } + if len(service.Groups) == 0 { + t.Error("the entry service reads no configuration group, so it cannot find the authority") + } +} + +// service-entry is load-bearing, not cosmetic: pkg/solutionrun resolves a +// module's solution manifest only for the service named by service-entry, so a +// solution whose entry does not name its backend boots with no api.consumes +// projection and every route it consumes stays unrouted. +func TestTheModuleInterfaceAndEntryNameTheScaffoldedServices(t *testing.T) { + moduleInterface := solutionModuleInterface() + if len(moduleInterface.Endpoints) != 2 { + t.Fatalf("interface = %+v, want the backend and the shell", moduleInterface.Endpoints) + } + byService := map[string]string{} + for _, endpoint := range moduleInterface.Endpoints { + byService[endpoint.Service] = endpoint.Visibility + } + if byService[solutionEntryService] != resources.VisibilityPublic { + t.Errorf("the backend is exported at %q, want public", byService[solutionEntryService]) + } + // The shell is reached only by the edge that serves it, never beyond. + if byService[solutionPageService] != resources.VisibilityInternal { + t.Errorf("the shell is exported at %q, want internal", byService[solutionPageService]) + } +} + +func TestSolutionCommandIsRegisteredAndHelpStandsAlone(t *testing.T) { + if SolutionCmd.RunE == nil || SolutionCmd.Run != nil { + t.Fatal("solution must use RunE so the root owns exit codes") + } + for name, text := range map[string]string{ + "Short": SolutionCmd.Short, "Long": SolutionCmd.Long, "Example": SolutionCmd.Example, + } { + if strings.TrimSpace(text) == "" { + t.Errorf("%s is empty; help must stand alone without network access", name) + } + } + for _, flag := range []string{"package-id", "agent", "consumes", "executor", "minimum-codefly-version"} { + if SolutionCmd.Flags().Lookup(flag) == nil { + t.Errorf("--%s is not registered", flag) + } + } + if err := SolutionCmd.Args(SolutionCmd, nil); err == nil { + t.Error("solution accepted no name") + } + if err := SolutionCmd.Args(SolutionCmd, []string{"wiki"}); err != nil { + t.Errorf("solution rejected a valid name: %v", err) + } +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4c0df52ed..3d306cf94 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -109,6 +109,7 @@ Subcommands: - [`codefly add module`](#codefly-add-module) - [`codefly add runnable`](#codefly-add-runnable) - [`codefly add service`](#codefly-add-service) +- [`codefly add solution`](#codefly-add-solution) ## `codefly add application` @@ -310,6 +311,63 @@ Flags: --override Override existing service ``` +## `codefly add solution` + +Scaffold a solution: its backend, its page's shell, and the documents a host installs it from + +``` +Scaffold a solution in the one shape a solution has. + +A solution is two Codefly services and a page: + + backend The entry service. It presents the solution's identity, serves the + API the page calls, and is the workload a host installs. It is + what the solution is published as. + web The shell that serves the page where no host shell mounts it — the + same ./Page a saas host federates, in a shell of its own, which an + authority's edge serves as the browser's one origin. The package + does not publish it: a host that federates the remote needs no + shell. + fe-remote The ./Page remote itself. It is an npm package, not a Codefly + service, so this command does not scaffold it. + +THE RULE THE BACKEND LIVES UNDER. A module call the authority holds to a +PERSON — one its artifact interface admits only in person, with no intervening +actor — is made by the PAGE, with that person's own Work Context. It is never +relayed through the backend: a backend reaches a module on a context minted +for the solution's own audience, so relaying such a call puts the solution +between the person and the module, and admission refuses it. Reads and work +that are the solution's own go through the backend as usual. + +The backend's language is --agent (go by default, python for the same shape). + +What this writes, and what stays yours: the module, both services, the +solution manifest, the module contract, the package identity and the page +declaration are written and each is read back through core's own validator. +The COMPOSITION is not: which workspace composes this solution, which modules +it pins and at what commits, and the configuration groups those modules need, +are the composition's to say and are never guessed here. +``` + +``` +codefly add solution [flags] +``` + +Flags: + +``` + --agent string Service agent for the backend (default: go). The backend's language is this flag, not a different shape + --authority-interface string The authority interface the backend requires, as /@ (default "codefly.dev/authority@>=0.2.0 <0.4.0") + --consumes stringArray A module this solution calls, as [=]://. Repeatable. Route names only — authority is declared once, in the module contract + --description string Short description written to module.codefly.yaml + --executor string A codefly:solution executor to name in the solution manifest. Omitted by default: core specifies no executor protocol and none is published (codefly-dev/cli#1215) + --minimum-codefly-version string The Codefly range the package declares (default: the running release, open to the end of its major). Required when scaffolding with a build from source, which reports no release version + --order int The page's order in the host's shell (default 50) + --package-id string The / identity the solution is published under. Required; nothing here can derive who publishes it + --title string The page's title in the host's shell (default: the module name, capitalised) + --yes Skip confirmation prompts (non-interactive/MCP mode) +``` + ## `codefly agent` Develop, inspect, install, and validate Codefly service agents diff --git a/docs/commands.md b/docs/commands.md index 15350f9e2..86cbd17d3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2386,6 +2386,62 @@ contract and handler before building. See [Runnables](runnable.md). |------|-------------| | `--agent` | Agent type (required). Examples: `go-grpc`, `python-grpc`, `nextjs`, `krakend` | +**`add solution`:** + +```sh +codefly add solution wiki --package-id obin/wiki \ + --consumes connect:documents/documents/grpc --consumes connect:robin/chat/connect +``` + +Scaffolds a solution — a module a host installs and runs as a product surface — +in the one shape a solution has. There is no shape flag: a second shape beside +it is what produced nine solutions that do not look alike. + +It composes verbs that already exist (`add module`, then `add service` for each +of the two services) and writes the documents Core already specifies, by +marshalling Core's own types and reading each file back through Core's own +reader. It adds no executor protocol and no new agent. + +What it creates: + +| | | +|------|-------------| +| `services/backend` | the entry service: presents the solution's identity, serves the API the page calls, and is the workload a host installs | +| `services/web` | the shell that serves the page where no host shell mounts it — not published, because a host that federates the remote needs no shell | +| `solution.codefly.yaml` | what the solution exposes, and the route name it addresses each consumed module by | +| `module.contract.codefly.yaml` | the principal its credentials are issued to, with `owner_aware: true` | +| `module.package.codefly.yaml` | the published identity; without it the render declares no presence and nobody can install the solution | +| `services/backend/solution.frontend.codefly.yaml` | the page's title, order and `./Page` remote | + +`services/fe-remote` — the `./Page` remote itself — is an npm package, not a +Codefly service, and is yours to author. + +**The rule the backend lives under.** A module call the authority holds to a +*person* — one its artifact interface admits only in person, with no +intervening actor — is made by the **page**, with that person's own Work +Context. It is never relayed through the backend: a backend reaches a module on +a context minted for the solution's own audience, so relaying such a call puts +the solution between the person and the module and admission refuses it. Reads +and work that are the solution's own go through the backend as usual. This is +why the entry service declares the authority interface and the authority's hop +and **no module edge at all** — every consumed module is reached through that +hop, which lets through only a call the authority checked. + +| Flag | Description | +|------|-------------| +| `--package-id` | The `/` identity the solution is published under. Required — nothing can derive who publishes it | +| `--agent` | Service agent for the backend (default `go`). The backend's language is this flag, not a different shape | +| `--consumes` | A module this solution calls, as `[=]://`. Repeatable. Route names only — authority is declared once, in the module contract | +| `--executor` | A `codefly:solution` executor to name in the solution manifest. Omitted by default: Core specifies no executor protocol and none is published | +| `--title` / `--order` | The page's title and position in the host's shell | +| `--authority-interface` | The authority interface the backend requires | +| `--minimum-codefly-version` | The Codefly range the package declares. Required when scaffolding with a build from source, which reports no release version | + +The **composition** is not scaffolded: which workspace composes the solution, +which modules it pins and at what commits, and the configuration groups those +modules need, are the composition's to say. The command prints each of them, +with the failure each one causes when it is skipped. + ### `codefly override service /` Run one service of a composed module from somewhere else on this machine,