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,