diff --git a/cmd/add/solution.go b/cmd/add/solution.go index 800c753f..5b386d3b 100644 --- a/cmd/add/solution.go +++ b/cmd/add/solution.go @@ -2,12 +2,14 @@ package add import ( "context" + "errors" "fmt" "os" "path/filepath" "strings" "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/cmd/generate" "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/cli/yamledit" "github.com/codefly-dev/cli/pkg/solutionrun" @@ -56,7 +58,11 @@ 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). +--agent names the agent that scaffolds the backend, and it must be a SOLUTION +agent (default: solution-go). The service agent of the same language is a +different thing: the go agent scaffolds a service — no declared operation, no +published policy, no page remote — and delivery refuses the result. Naming +one is refused here, with the solution agent to use instead. What this writes, and what stays yours: the module, both services, the solution manifest, the module contract, the package identity and the page @@ -68,8 +74,8 @@ are the composition's to say and are never guessed here.`, 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`, + # A solution agent other than the default. + codefly add solution forecast --package-id obin/forecast --agent solution-python`, Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { cli.Init() @@ -89,23 +95,24 @@ are the composition's to say and are never guessed here.`, } var ( - solutionAgentInput string - solutionConsumes []string - solutionPackageID string - solutionExecutor string - solutionDescription string - solutionTitle string - solutionOrder int64 - solutionAuthorityIface string - solutionMinimumCodefly string - solutionWithDefault bool + solutionAgentInput string + solutionConsumes []string + solutionPackageID string + solutionExecutor string + solutionDescription string + solutionTitle string + solutionOrder int64 + solutionAuthorityIface string + solutionMinimumCodefly string + solutionWithDefault bool + solutionAuthorityVersion string ) // 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 { +func addSolution(ctx context.Context, name string) (result error) { w := wool.Get(ctx).In("cmd.add.solution") documents, err := solutionDocuments(ctx, name) @@ -113,6 +120,14 @@ func addSolution(ctx context.Context, name string) error { return err } + // Resolved with the other input, BEFORE the first write: naming the + // service agent instead of the solution agent is an ordinary mistake and + // must cost nothing, not half a module. + entryAgent, err := solutionAgentFor(solutionAgentInput) + if err != nil { + return err + } + workspace, err := common.LoadWorkspace(ctx) if err != nil { return w.Wrapf(err, "cannot load workspace") @@ -129,16 +144,40 @@ func addSolution(ctx context.Context, name string) error { if moduleErr := addModule(name); moduleErr != nil { return moduleErr } + // `add module` rolls back only what fails inside its own call. Everything + // below can fail with the module already written — an agent that cannot + // scaffold the service is the ordinary case, not a hypothetical: the + // generic `python` agent runs a Python service but defines no project + // template, so `--agent python` always fails here. Without this the + // author is left a module and a half-built service to delete by hand + // before they can retry. + defer func() { + if result == nil { + return + } + // Re-read, for the same reason step 3 does: the handle loaded above + // predates the module, so deleting through it reports that the module + // does not exist and leaves it on disk. + current, loadErr := common.LoadWorkspace(ctx) + if loadErr != nil { + result = errors.Join(result, fmt.Errorf("roll back: cannot re-load the workspace: %w", loadErr)) + return + } + moduleDir := current.ModulePath(ctx, &resources.ModuleReference{Name: name}) + if rollbackErr := current.DeleteModule(ctx, name); rollbackErr != nil { + result = errors.Join(result, fmt.Errorf("roll back module reference: %w", rollbackErr)) + return + } + if rollbackErr := os.RemoveAll(moduleDir); rollbackErr != nil { + result = errors.Join(result, fmt.Errorf("roll back module directory: %w", rollbackErr)) + } + }() // 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}, @@ -149,6 +188,14 @@ func addSolution(ctx context.Context, name string) error { } } + // Re-read the workspace: the handle above was loaded before `add module` + // wrote the module into it, so it still lists the modules that existed + // when this command started. `add service` re-reads for the same reason, + // which is why the two services above resolved and this did not. + workspace, err = common.LoadWorkspace(ctx) + if err != nil { + return w.Wrapf(err, "cannot re-load the workspace after scaffolding") + } mod, err := workspace.LoadModuleFromName(ctx, name) if err != nil { return w.Wrapf(err, "cannot load the scaffolded module") @@ -173,7 +220,16 @@ func addSolution(ctx context.Context, name string) error { return err } - // 5. The documents. The three at the module root, and the page + // 5. The page shell, over the generic app the nextjs agent scaffolded. + // It runs here because it is the only step after BOTH agents: the nextjs + // agent would overwrite anything the solution agent wrote into its + // service, and what it leaves on its own is a standalone web app with + // placeholder login routes and "Your app is running" — not a solution. + if err := applySolutionShell(ctx, mod, name+"-fe-remote"); err != nil { + return err + } + + // 6. 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)) @@ -191,6 +247,23 @@ func addSolution(ctx context.Context, name string) error { return fmt.Errorf("the generated %s is not readable by core: %w", solutionrun.FrontendFileName, err) } + // 7. The backend's published contract, and its entry in the package. + // + // The authority derives the method policy of every route from it, so a + // solution whose package carries none admits no call to any route it + // serves — and says so only as a warning in the authority's log, during + // a run. Generated here so what this command leaves behind is a solution + // that conforms, rather than one whose first `codefly doctor solution` + // fails on a step the author had no reason to know about. + if err := generate.PublishModuleContracts(ctx, workspace, mod); err != nil { + return fmt.Errorf("cannot publish the backend's contract: %w", err) + } + cli.Info("wrote contracts/api and the package's api-contracts entry") + + // 8. The playground: the composition that runs this solution locally on + // the development authority, the way every solution in the fleet is run. + applySolutionPlayground(ctx, mod, solutionAuthorityVersion) + cli.Header(1, "Solution <%s> scaffolded: %s (entry) and %s (the page's shell).", name, solutionEntryService, solutionPageService) reportNextSteps(name) return nil @@ -403,7 +476,8 @@ func reportNextSteps(name string) { 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().StringVar(&solutionAuthorityVersion, "authority-version", "", "Commit of codefly-dev/authority the playground pins (default: its current default-branch head, resolved here). Must be a commit the DEFAULT BRANCH holds: a PR head reads the same and dies on squash-merge") + SolutionCmd.Flags().StringVar(&solutionAgentInput, "agent", "", "Agent that scaffolds the backend (default: "+solutionEntryAgent+"). Must be a SOLUTION agent; naming the service agent of a language is refused, with the one to use instead") 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") diff --git a/cmd/add/solution_documents.go b/cmd/add/solution_documents.go index 63c9c406..ea2b5540 100644 --- a/cmd/add/solution_documents.go +++ b/cmd/add/solution_documents.go @@ -46,10 +46,59 @@ import ( const ( solutionEntryService = "backend" solutionPageService = "web" - solutionEntryAgent = "go" solutionPageAgent = "nextjs" + // solutionEntryAgent scaffolds the backend when --agent names no other. + // It is a SOLUTION agent, which is a different thing from the service + // agent of the same language — see solutionAgentFor. + solutionEntryAgent = "solution-go" ) +// serviceAgentSolutionAgent maps a SERVICE agent to the SOLUTION agent of the +// same language. +// +// The two are not interchangeable and the difference is not a detail. A +// service agent scaffolds a service: `go` writes a net/http mux serving +// /health. A solution agent scaffolds a solution: the solution runtime, the +// viewer's Work Context, a published policy on every route the authority +// checks against, the passthrough that relays a module call AS THE VIEWER, +// the interface document, and the ./Page remote a host federates. +// +// `codefly add solution` used to take the service agent and scaffold a +// service into the shape of a solution. What came out served nothing a viewer +// could reach, published no policy, and failed delivery outright ("declares a +// frontend and holds no npm project to build it from"). Nothing said so: the +// documents were all valid. +// +// So this is a REFUSAL, not a substitution. Passing `--agent go` is a +// statement about which agent to run, and quietly running a different one +// would be the CLI deciding what the author meant. It names the solution +// agent for that language instead and stops. +var serviceAgentSolutionAgent = map[string]string{ + "go": "solution-go", + "python": "solution-python", + "python-fastapi": "solution-python", +} + +// solutionAgentFor checks the agent the author named is one that scaffolds a +// solution, and says which one they meant when it is not. +// +// An agent this table does not know is accepted: a publisher may ship a +// solution agent this build has never heard of, and refusing by allow-list +// would make the CLI the gatekeeper of a registry it does not own. What is +// refused is the specific, silent mistake — naming the service agent of a +// language whose solution agent exists. +func solutionAgentFor(agent string) (string, error) { + if agent == "" { + return solutionEntryAgent, nil + } + if solutionAgent, isServiceAgent := serviceAgentSolutionAgent[agent]; isServiceAgent { + return "", fmt.Errorf( + "--agent %s is the SERVICE agent: it scaffolds a service, not a solution — no declared operation, no published policy, no page remote, and delivery refuses the result. Pass --agent %s", + agent, solutionAgent) + } + return agent, nil +} + // 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. @@ -300,11 +349,20 @@ const contractPreamble = `# What this solution asks for (codefly/module-contract # 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. +# It asks for nothing at another module 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. +# +# ROLES. A solution is a unit a person installs, and the authority refuses to +# admit one that offers a person no role to hold on it ("declares 0 role(s) +# and none is \"installer\""). The installer role below is scoped to this +# solution's own page, the one resource a scaffold knows it has. Name the +# solution's real resource kinds in scope_ceilings and widen the role to them, +# and add a role per other kind of person who uses it (wiki: editor, viewer; +# proposals: reviewer, viewer). A role may grant a scope only within a +# ceiling, or through a binding it names, so the two cannot drift apart. ` // solutionContract is the module contract the solution ships: the principal @@ -318,16 +376,63 @@ const contractPreamble = `# What this solution asks for (codefly/module-contract // 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. +// readAction and writeAction are the two actions a scaffolded solution's own +// vocabulary carries, spelled once: they appear in the ceiling and in each +// role that sits within it, and a typo between them is a role the authority +// silently never issues. +const ( + readAction = "read" + writeAction = "write" +) + func solutionContract(name string) *modulecontract.Contract { + // The solution's own permission vocabulary, and the one role the host + // requires. A solution is a unit a PERSON installs, so the authority + // refuses to admit one that offers a person no role to hold on it: + // + // state: forbidden: declares 0 role(s) and none is "installer": + // a solution declares an installer role + // + // That refusal happens at `runtime-run`, after the whole composition has + // booted, and every document reads fine until then — which is how seven + // of the nine solutions in one fleet came to declare no role at all. Only + // the two that are actually run, wiki and proposals, declare one. + // + // The kind is derived, not guessed at: every solution this command + // scaffolds exposes a page, so `.page` is the one resource it is + // known to have. A solution whose domain object is something else renames + // it here and in the role together — core's Contract.Validate refuses a + // role granting a scope no ceiling covers, so the pair cannot drift + // apart silently. + ownScope := []modulecontract.CeilingScope{{ + ResourceKind: name + ".page", + Actions: []string{readAction, writeAction}, + }} return &modulecontract.Contract{ - Schema: modulecontract.SchemaV1, - Principal: name, - OwnerAware: true, - Namespaces: []string{}, - Queues: []string{}, - ScopeCeilings: []modulecontract.ScopeCeiling{}, - Bindings: []modulecontract.Binding{}, - Destinations: []modulecontract.Destination{}, + Schema: modulecontract.SchemaV1, + Principal: name, + OwnerAware: true, + Namespaces: []string{}, + Queues: []string{}, + ScopeCeilings: []modulecontract.ScopeCeiling{{ + ResourceKind: name + ".page", + Actions: []string{readAction, writeAction}, + }}, + Roles: []modulecontract.Role{ + {Name: modulecontract.InstallerRole, Scopes: ownScope}, + // The role the ORGANIZATION is installed as, so the people of an + // installation can open the solution at all. Without a role + // besides installer only the owner holds anything, and everyone + // else is refused by name — " holds no role on " — + // which is a correct refusal and a useless scaffold. wiki and + // proposals both declare a viewer beside their installer. + {Name: "viewer", Scopes: []modulecontract.CeilingScope{{ + ResourceKind: name + ".page", + Actions: []string{readAction}, + }}}, + }, + Bindings: []modulecontract.Binding{}, + Destinations: []modulecontract.Destination{}, } } @@ -409,7 +514,22 @@ func solutionPackage(id, toolVersion, declaredFloor string) (*composition.Packag ID: id, Version: "0.1.0", MinimumCodeflyVersion: floor, - ArtifactRoots: []string{"services"}, + // "contracts" alongside "services": `codefly generate contracts` + // writes the backend's published http contract to + // contracts/api/backend/http/openapi.json, and a package that does + // not declare the root cannot carry it — "the package cannot carry + // the backend/http contract ... add \"contracts\" under + // artifact-roots", which is where the generate stops. + // + // It is not a packaging detail. The CLI derives the authority's + // per-route method policies from the contract this package carries + // (ServiceMethodPolicies reads api-contracts), so without it the + // authority is delivered the module's ceilings and no method policy + // at all — "no delivered method policies; declared ceilings alone do + // not admit a call". The backend serves its route, the route declares + // its policy, and every call is still refused, with the only sign a + // warning in the authority's log. + ArtifactRoots: []string{"contracts", "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}}}, diff --git a/cmd/add/solution_playground.go b/cmd/add/solution_playground.go new file mode 100644 index 00000000..cf3ee5fb --- /dev/null +++ b/cmd/add/solution_playground.go @@ -0,0 +1,287 @@ +package add + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/shared" +) + +// The playground: the composition that runs this solution LOCALLY, on the +// development authority, the way every solution in the fleet is run. +// +// WHY IT IS SCAFFOLDED. A solution's page is served on ONE origin — the +// authority's edge — which signs the person in and routes /v1/ to the +// backend with their Work Context. Nothing about that is discoverable from +// the solution's own files, and getting it wrong does not look like an error: +// open the shell's own port instead and the page says "This page is not +// behind its sign-in edge", open the authority's API port and every path +// 404s. Both were hit, repeatedly, reconstructing this by hand. +// +// The one thing that is genuinely not derivable is that THE EDGE IS NAMED +// EXPLICITLY. It is not a dependency of the backend and nothing elects it as +// ingress, so neither `run solution` nor `run workspace` starts it — the +// fleet's own Makefiles list it first in the services they run, and this +// writes the same line. +// +// LOCAL ONLY. In a cell the host's gateway is the origin and signs the person +// in; the development authority stands in for it here. Nothing in this +// directory describes a deployment. +const playgroundDir = "playground" + +// authorityModule is the development authority the playground composes. +// +// It is pinned by COMMIT, because the repository publishes no module release +// — its only tags are its Go libraries' — and every solution in the fleet +// pins it the same way. The commit is resolved from the default branch at +// scaffold time rather than written into this binary, where it would be +// stale by the next release; --authority-version overrides it. +const authorityModule = "codefly-dev/authority" + +// applySolutionPlayground writes the playground beside the solution. +// +// It never fails the scaffold: the solution is the deliverable and is already +// written and validated, so a playground that cannot be pinned is reported +// and skipped rather than discarding a correct solution. +func applySolutionPlayground(ctx context.Context, module *resources.Module, authorityVersion string) { + pin := authorityVersion + if pin == "" { + resolved, err := defaultBranchCommit(ctx, authorityModule) + if err != nil { + cli.Warning("the playground was not written: cannot resolve %s's default branch (%v). Re-run with --authority-version .", authorityModule, err) + return + } + pin = resolved + } + + root := filepath.Join(module.Dir(), playgroundDir) + files := map[string]string{ + "workspace.codefly.yaml": playgroundWorkspace(module.Name, pin), + "configurations/local/authority.env": playgroundAuthorityGroup(), + "configurations/local/edge.env": playgroundEdgeGroup(module.Name), + "README.md": playgroundReadme(module.Name), + } + // The solution's own fragment of the authority's fixture-composition + // document. It belongs beside the SOLUTION, not in the playground: the + // composing workspace merges every composed module's fragment, and this + // is the solution's. + if err := shared.WriteFileAtomic(ctx, + filepath.Join(module.Dir(), "configurations", "local", "fixture-composition.yaml"), + []byte(fixtureComposition(module.Name)), 0o644); err != nil { + cli.Warning("the fixture composition was not written: %v", err) + return + } + for name, content := range files { + if err := shared.WriteFileAtomic(ctx, filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil { + cli.Warning("the playground's %s was not written: %v", name, err) + return + } + } + if err := shared.WriteFileAtomic(ctx, filepath.Join(module.Dir(), "Makefile"), []byte(playgroundMakefile(module.Name)), 0o644); err != nil { + cli.Warning("the playground's Makefile was not written: %v", err) + return + } + cli.Info("wrote playground/ — `make playground` runs this solution on the development authority") +} + +// defaultBranchCommit resolves a GitHub repository's default-branch head. +// +// A module pin must be a commit the default branch holds. A PR head looks +// identical in the manifest and dies on squash-merge, when the commit it +// names stops existing on any branch — so the pin is read from HEAD, which +// is the default branch by definition. +func defaultBranchCommit(ctx context.Context, repository string) (string, error) { + //nolint:gosec // G204: a fixed git subcommand over a constant repository + // path; repository is this file's own authorityModule constant. + command := exec.CommandContext(ctx, "git", "ls-remote", "https://github.com/"+repository, "HEAD") + output, err := command.Output() + if err != nil { + return "", err + } + commit, _, found := strings.Cut(strings.TrimSpace(string(output)), "\t") + if !found || len(commit) != 40 { + return "", fmt.Errorf("git ls-remote answered %q", strings.TrimSpace(string(output))) + } + return commit, nil +} + +func playgroundWorkspace(name, authorityPin string) string { + return fmt.Sprintf(`# This solution, run on the DEVELOPMENT AUTHORITY: the composition a person +# opens locally, and the only one this directory describes. In a cell the +# host's gateway is the origin and issues the Work Contexts; here the +# authority's edge stands in for it. +# +# It composes the authority and nothing else, because this solution consumes +# no module yet. Add each module it calls here as you add it to +# solution.codefly.yaml's api.consumes, AND COPY THAT MODULE'S CONFIGURATION +# GROUP WHOLE: a group is the set its consumers declare, not the modules you +# call, and trimming one gets "module contract slot cannot be resolved", +# "slot resolves ambiguously", or "it is not a module this workspace +# composes". +name: %s-playground +layout: modules + +module-resolution: + authority: git + +modules: + - name: %s + path: .. + # Pinned by COMMIT: the authority publishes no module release, and the + # commit must be one the DEFAULT BRANCH holds. A PR head reads the same + # here and dies on squash-merge, when the commit it names is on no branch. + - name: authority + source: %s + module: modules/authority + version: %q + +environments: + - name: local + description: %s on the development authority + fixture: local + host: + coordinate: codefly/local/authority + component: authority + domain: local + audience: https://authority.local.test + trust_domain: authority.local + delivery: authority/local/grpc + service-secrets: + generate: + # The hop signs each admission it issues with its own seed, and + # registers the public half with the authority. Generated per + # run; never committed. + - scope: service + configuration: hop-signing + services: [authority/hop] + keys: [SEED] +`, name, name, authorityModule, authorityPin, titleFromName(name)) +} + +func playgroundAuthorityGroup() string { + return `# The authority interface: this solution's backend asks it who the viewer is +# and is issued every Work Context it sends. +endpoint=${endpoint:authority/local/grpc} +# The organisation this solution's own work runs in: one of the fixture's. +organization=acme +# The interface's HTTP face, for a module with no generated client of its own. +rest-endpoint=${endpoint:authority/local/rest} +` +} + +func playgroundEdgeGroup(name string) string { + return fmt.Sprintf(`# The edge: the browser's ONE origin. It signs the person in from the +# authority's fixture, routes /v1/%s to this solution's backend with that +# person's Work Context, and serves the solution's page on every other path. +# +# Opening the page at the shell's own port instead is the mistake this file +# exists to prevent: the shell asks the edge for /auth/session on its own +# origin, finds nothing, and says so. +MODULES=%s +%s_REST=${endpoint:%s/backend/http} +FRONTEND=${endpoint:%s/web/http} +`, name, name, strings.ToUpper(strings.ReplaceAll(name, "-", "_")), name, name) +} + +// playgroundMakefile writes the run line, whose one non-obvious part is that +// authority/edge is NAMED. Nothing starts it otherwise. +func playgroundMakefile(name string) string { + return fmt.Sprintf(`.PHONY: playground + +# %s on the development authority, as a solution runs in a cell with no saas +# host: the authority's edge is the one origin the browser opens and signs a +# person in from the authority's fixture, and this solution's backend is +# issued every Work Context it sends. +# +# THE EDGE IS LISTED FIRST AND THAT IS LOAD-BEARING. It is not a dependency of +# the backend and nothing elects it as ingress, so neither `+"`run solution`"+` +# nor `+"`run workspace`"+` starts it: name it, or the page is served on no +# origin that can sign anyone in. +PLAYGROUND_SERVICES = authority/edge %s/web %s/backend + +playground: + cd %s && codefly run service $(PLAYGROUND_SERVICES) --env local +`, titleFromName(name), name, name, playgroundDir) +} + +func playgroundReadme(name string) string { + return fmt.Sprintf(`# %s — the playground + + make playground + +Then open the address the EDGE prints: + + edge listening on :, routing 1 module(s) + +## What runs, and why each one + +| | | +|---|---| +| `+"`authority/edge`"+` | the browser's one origin: signs the person in, serves the page, routes `+"`/v1/%s`"+` to the backend with their Work Context | +| `+"`authority/local`"+` | the authority itself, which issues every Work Context and checks each call against the route's published policy | +| `+"`authority/store`"+` | the authority's own store, where approvals persist | +| `+"`%s/backend`"+` | this solution's entry service | +| `+"`%s/web`"+` | the shell that serves the page, mounting the same `+"`./Page`"+` a host federates | + +## The two addresses that are not it + +- **the shell's own port.** The page asks the edge for `+"`/auth/session`"+` on + the origin it is served from; on the shell's port there is no edge, and the + page says "This page is not behind its sign-in edge". +- **the authority's REST port.** That is the authority's API, not the edge. + Every browser path 404s there. + +## Local only + +In a cell the host's gateway is the origin and issues the Work Contexts. The +development authority stands in for it here, and nothing in this directory +describes a deployment. +`, titleFromName(name), name, name, name) +} + +// fixtureComposition is this solution's fragment of the local authority's +// fixture-composition document: who, in the development fixture, has this +// solution INSTALLED. +// +// Without it the solution runs and the person signs in, and the authority +// still refuses every capability — "audience %q is not installed in this +// organization, so there is no installation to seal the capability to" — so +// the page renders its not-installed branch and nothing else. The fixture +// itself names only who exists and the authority's own vocabulary; which +// solutions are installed is the composing workspace's to say, merged from +// each composed module's fragment. +// +// alex is the acme admin of the authority's shipped fixture, so the +// installation is owned by a person who exists. Change the organization, the +// owner and the role as the solution grows roles of its own. +func fixtureComposition(name string) string { + return fmt.Sprintf(`# %s's fragment of the local authority's fixture-composition document +# (codefly-dev/authority libraries/fixture-composition, schema v1): what is +# this solution's own in the fixture of a run that composes it. +# +# `+"`codefly run`"+` merges this with every composed module's fragment into the +# composing workspace's configurations/local/fixture-composition.yaml, the +# document the authority reads. A grant a module states in its own fragment is +# never restated here: the merge refuses it, naming both. +schema: codefly.dev/authority/fixture-composition/v1 +installations: + # WITHOUT THIS the authority refuses every capability at this solution — + # "audience %q is not installed in this organization" — and the page can + # only render its not-installed branch. + # + # TEAMS ARE WHAT ADMIT ANYONE BUT THE OWNER. The role below is the + # owner's, and the owner is alex, the acme admin of the authority's + # shipped fixture; with no teams every other person signs in and is + # refused by name — + # " holds no role on %s" — which is a correct refusal and a + # useless playground. finance and engineering are the fixture's acme teams, + # so maya, rémi, alex and dev can all open it; no-roles, who is in no team, + # still cannot, and that is the fixture showing a real refusal. + - {organization: acme, target: %s, role: viewer, owner: alex, teams: [finance, engineering]} +`, titleFromName(name), name, name, name) +} diff --git a/cmd/add/solution_shell.go b/cmd/add/solution_shell.go new file mode 100644 index 00000000..2477efdd --- /dev/null +++ b/cmd/add/solution_shell.go @@ -0,0 +1,347 @@ +package add + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/shared" +) + +// The page shell, written over the generic app the nextjs agent scaffolds. +// +// WHY THE CLI WRITES THIS AND NOT AN AGENT. The shell is a nextjs service, so +// the nextjs agent creates it — and that agent knows nothing about solutions: +// what it leaves is a standalone web app with placeholder login, signup and +// dashboard routes and a home page reading "Your app is running. Edit +// src/app/page.tsx to get started." The solution agent cannot write it either, +// because it runs for the BACKEND and the nextjs agent runs after it; anything +// it wrote here would be overwritten. The CLI is the only step that happens +// after both, and it is the only one that knows the shape has a page at all. +// +// WHAT THE SHELL IS FOR. A host federates the solution's ./Page remote and +// mounts it among its own surfaces. Where no host does, this shell is what +// serves that same page, so the two cannot drift: it imports the remote +// rather than reimplementing it. +// +// A SOLUTION HAS NO LOGIN OF ITS OWN, which is why the generic app's login +// and signup routes are deleted rather than filled in. The authority's edge +// is the browser's origin and signs the person in; the page holds no token +// and decides nothing. A solution that grew its own sign-in would be a second +// front door onto the same data. +func applySolutionShell(ctx context.Context, module *resources.Module, remotePackage string) error { + app := filepath.Join(module.Dir(), "services", solutionPageService, "code", "src", "app") + + // The generic app's placeholders. They are not a starting point for a + // solution: a solution never authenticates anyone. + for _, placeholder := range []string{"login", "signup", "dashboard"} { + if err := os.RemoveAll(filepath.Join(app, placeholder)); err != nil { + return fmt.Errorf("cannot remove the generic app's %s route: %w", placeholder, err) + } + } + + for name, content := range map[string]string{ + "session.ts": shellSession(module.Name), + "solution-app.tsx": shellApp(module.Name, remotePackage), + "page.tsx": shellPage(), + } { + if err := shared.WriteFileAtomic(ctx, filepath.Join(app, name), []byte(content), 0o644); err != nil { + return fmt.Errorf("cannot write the shell's %s: %w", name, err) + } + } + if err := shellNextConfig(ctx, module, remotePackage); err != nil { + return err + } + if err := shellDependsOnRemote(ctx, module, remotePackage); err != nil { + return err + } + cli.Info("the shell serves the solution's ./Page (login routes removed: the edge signs the person in)") + return nil +} + +// shellPage is the route itself: a server component that renders the client +// half. The page is one person's session, read in the browser, so it is +// rendered per request and never prerendered — and `force-dynamic` is +// route-segment configuration, which only a server component may declare. +func shellPage() string { + return `import SolutionApp from "./solution-app"; + +export const dynamic = "force-dynamic"; + +export default function Page() { + return ; +} +` +} + +// shellSession reads the viewer from the authority's edge. +// +// The edge in front of this page is the host's door. The browser holds only +// its session cookie; every call goes through the edge, which sends it on +// with the person's Work Context. This file holds no token and decides +// nothing a call may do — it only reports who the edge says is here, and +// whether this solution is installed for them. +func shellSession(solution string) string { + return fmt.Sprintf(`"use client"; + +import { useEffect, useState } from "react"; + +/** One target the person holds a Work Context at, and the installation it is sealed to. */ +export type Installation = { id: string; target: string; namespace?: string }; + +/** The edge's /auth/session: who is here, and what they hold where. */ +export type Session = { + moduleApiBase?: string; + personId: string; + organizationId: string; + personDisplayName?: string; + organizationDisplayName?: string; + roles: string[]; + installations: Installation[]; + /** Every target the authority refused an issue at, in its own words. */ + refused: Record; +}; + +export type SessionState = + | { status: "loading" } + | { status: "signed-in"; session: Session } + | { status: "error"; reason: string }; + +/** The sign-in page is the EDGE'S, never a route of this app. */ +export function signInLocation(pathname: string, search: string): string { + return "/auth/sign-in?return=" + encodeURIComponent(pathname + search); +} + +/** Whether the person may use this solution: the edge's session names an installation of it. */ +export function installed(session: Pick): boolean { + return session.installations.some((installation) => installation.target === %q); +} + +/** Why the edge's session names no installation, in the authority's own words. */ +export function refusal(session: Pick): string | undefined { + return session.refused[%q]; +} + +export function useSession(): SessionState { + const [state, setState] = useState({ status: "loading" }); + useEffect(() => { + let cancelled = false; + fetch("/auth/session", { credentials: "include" }) + .then((response) => { + // Not signed in: the edge's own sign-in page, with a return to here. + if (response.status === 401 || response.status === 403) { + window.location.assign(signInLocation(window.location.pathname, window.location.search)); + return null; + } + if (!response.ok) throw new Error("the edge answered " + response.status); + return response.json(); + }) + .then((session: Session | null) => { + if (!cancelled && session) setState({ status: "signed-in", session }); + }) + .catch((cause) => { + if (!cancelled) setState({ status: "error", reason: String(cause) }); + }); + return () => { + cancelled = true; + }; + }, []); + return state; +} + +export async function signOut(): Promise { + await fetch("/auth/sign-out", { method: "POST", credentials: "include" }); + window.location.assign("/"); +} +`, solution, solution) +} + +// shellApp mounts the remote's ./Page — the SAME module a host federates, +// imported rather than reimplemented, so the shell and the host show one page. +// +// It is loaded with ssr: false because that is how a host mounts it: in the +// browser. The server bundle never runs it, and compiling it there is what +// leaves the renderer resolving browser APIs it has none of. +func shellApp(solution, remotePackage string) string { + return fmt.Sprintf(`"use client"; + +import dynamic from "next/dynamic"; +import { installed, refusal, signOut, useSession } from "./session"; + +// The page renders in the browser only, as a host mounts it. +const SolutionPage = dynamic(() => import(%q), { ssr: false }); + +// Every call the page makes goes to this one same-origin base. The edge in +// front of this page routes /v1/%s to this solution's backend and sends each +// call on with the signed-in person's Work Context. +const apiBase = "/v1/%s"; + +// The page holds no token: the browser's cookie and the edge carry the viewer. +const noToken = () => null; + +// Wrapped, never passed as `+"`fetch`"+` itself: called as a member of the +// binding, the browser's fetch refuses a `+"`this`"+` that is not the window. +const sameOrigin = (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init); + +export default function SolutionApp() { + const signedIn = useSession(); + + if (signedIn.status === "loading") return null; + + if (signedIn.status === "error") { + return ( +
+

This page is not behind its sign-in edge

+

+ {signedIn.reason}. Open this page at the address the authority's edge + serves, never at this shell's own port — the edge is what signs the + person in and routes {apiBase} to the backend. +

+
+ ); + } + + const { session } = signedIn; + if (!installed(session)) { + return ( +
+

%s is not available to you here

+

+ {refusal(session) ?? + "The edge's session names neither an installation of this solution nor the authority's reason for refusing one: the edge does not route it."} +

+
+ ); + } + + return ( +
+
+

%s

+ + {session.personDisplayName?.trim() || session.personId} ·{" "} + {session.organizationDisplayName?.trim() || session.organizationId} + {session.roles.length ? " · " + session.roles.join(", ") : ""} + + +
+ +
+ ); +} +`, remotePackage+"/Page", solution, solution, titleFromName(solution), titleFromName(solution), solution) +} + +// shellNextConfig lets the shell compile the remote's source. +// +// It is NOT wiki's config. wiki aliases React and drops the page from the +// server bundle through a `webpack` block, and that block is a hard ERROR on +// the Next this agent scaffolds: Next 16 runs Turbopack by default and +// refuses a webpack config it was not told to use. Neither alias is needed +// here — npm hoists one React for the linked remote, and ssr: false is what +// keeps the page out of the server bundle. +func shellNextConfig(ctx context.Context, module *resources.Module, remotePackage string) error { + path := filepath.Join(module.Dir(), "services", solutionPageService, "code", "next.config.ts") + return shared.WriteFileAtomic(ctx, path, []byte(shellNextConfigSource(remotePackage)), 0o644) +} + +// shellNextConfigSource is the config itself, separate from writing it so the +// two rules that cost a run each — the Turbopack root and the absence of a +// webpack block — are assertable without scaffolding a module. +func shellNextConfigSource(remotePackage string) string { + return fmt.Sprintf(`import type { NextConfig } from "next"; +import { resolve } from "node:path"; + +const config: NextConfig = { + // The remote lives beside this app, not under it. + experimental: { externalDir: true }, + // It ships its SOURCE, as the host's remote build reads it, so the shell + // compiles it rather than consuming a build. + transpilePackages: [%q], + turbopack: { + // THE BUILD ROOT IS THE SOLUTION'S, not this app's. The remote is linked + // into node_modules as a symlink to ../../fe-remote, and Turbopack + // refuses to follow a symlink out of its root: "Symlink + // node_modules/%s/src/Page.tsx could not be resolved: the symlink target + // leaves the configured filesystem roots", served as a 500 while the + // link, the package's exports map and the file are all correct. + root: resolve("../../.."), + resolveExtensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".json"], + }, +}; + +export default config; +`, remotePackage, remotePackage) +} + +// shellDependsOnRemote links the remote into the shell's npm project, which is +// how `import "/Page"` resolves at all. It is a relative file: +// dependency, so the link holds wherever the repository is checked out. +func shellDependsOnRemote(ctx context.Context, module *resources.Module, remotePackage string) error { + path := filepath.Join(module.Dir(), "services", solutionPageService, "code", "package.json") + existing, err := os.ReadFile(path) //nolint:gosec // a path inside the scaffolded module + if err != nil { + return fmt.Errorf("cannot read the shell's package.json: %w", err) + } + updated, err := addJSONDependency(existing, remotePackage, "file:../../fe-remote") + if err != nil { + return fmt.Errorf("cannot link the remote into the shell: %w", err) + } + if err = shared.WriteFileAtomic(ctx, path, updated, 0o644); err != nil { + return err + } + // The lock has to follow the manifest. The nextjs agent ships one, and + // its runner installs with `npm ci`, which refuses a lock that does not + // match package.json — so adding the remote without relocking fails the + // shell at runtime-run ("npm ci failed: exit status 1"), long after the + // scaffold reported success. + // + // Resolution needs the network, so a failure here is reported and not + // fatal: the solution is complete and the author runs the one command. + command := exec.CommandContext(ctx, "npm", "install", "--package-lock-only", "--silent") + command.Dir = filepath.Dir(path) + if output, lockErr := command.CombinedOutput(); lockErr != nil { + cli.Warning("could not relock the shell after linking the remote (%v). Its runner installs with `npm ci`, which needs the lock to match package.json: run `npm install --package-lock-only` in services/%s/code.\n%s", + lockErr, solutionPageService, strings.TrimSpace(string(output))) + } + return nil +} + +// addJSONDependency adds one dependency to a package.json without reformatting +// the rest of it. encoding/json would reorder every key and drop the file's +// own formatting, which makes the agent's template unreadable in a diff; this +// edits the dependencies object in place. +func addJSONDependency(manifest []byte, name, version string) ([]byte, error) { + var document map[string]any + if err := json.Unmarshal(manifest, &document); err != nil { + return nil, err + } + dependencies, _ := document["dependencies"].(map[string]any) + if dependencies == nil { + dependencies = map[string]any{} + } + if existing, present := dependencies[name]; present && existing == version { + return manifest, nil + } + dependencies[name] = version + document["dependencies"] = dependencies + rendered, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, err + } + return append(rendered, '\n'), nil +} diff --git a/cmd/add/solution_test.go b/cmd/add/solution_test.go index 7fff35bf..48df770d 100644 --- a/cmd/add/solution_test.go +++ b/cmd/add/solution_test.go @@ -2,8 +2,10 @@ package add import ( "context" + "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" @@ -387,3 +389,169 @@ func TestSolutionCommandIsRegisteredAndHelpStandsAlone(t *testing.T) { t.Errorf("solution rejected a valid name: %v", err) } } + +// TestTheServiceAgentOfALanguageIsRefused. `codefly add solution --agent go` +// used to scaffold a Go SERVICE into the shape of a solution: a net/http mux +// serving /health, no declared operation, no published policy and no page +// remote. Every document was valid, nothing said a word, and delivery refused +// the result for a frontend it had no npm project to build. +// +// The refusal names the solution agent rather than substituting it: which +// agent to run is the author's statement, and quietly running another is the +// CLI deciding what they meant. +func TestTheServiceAgentOfALanguageIsRefused(t *testing.T) { + for serviceAgent, solutionAgent := range map[string]string{ + "go": "solution-go", + "python": "solution-python", + "python-fastapi": "solution-python", + } { + resolved, err := solutionAgentFor(serviceAgent) + if err == nil { + t.Fatalf("--agent %s was accepted and resolved to %q; the service agent scaffolds a service, not a solution", serviceAgent, resolved) + } + if !strings.Contains(err.Error(), solutionAgent) { + t.Fatalf("refusing --agent %s must name %s to use instead, got: %v", serviceAgent, solutionAgent, err) + } + } +} + +// TestTheDefaultAndAnUnknownAgentAreBothAccepted. An agent this build has +// never heard of is accepted: a publisher may ship a solution agent newer +// than this CLI, and refusing by allow-list would make the CLI the gatekeeper +// of a registry it does not own. +func TestTheDefaultAndAnUnknownAgentAreBothAccepted(t *testing.T) { + resolved, err := solutionAgentFor("") + if err != nil || resolved != solutionEntryAgent { + t.Fatalf("no --agent must resolve to %s, got %q (%v)", solutionEntryAgent, resolved, err) + } + if resolved, err = solutionAgentFor("solution-rust"); err != nil || resolved != "solution-rust" { + t.Fatalf("an unknown agent must be accepted as named, got %q (%v)", resolved, err) + } +} + +// TestTheShellLinksAndMountsTheRemote. The shell the nextjs agent leaves is a +// standalone web app: placeholder login, signup and dashboard routes and +// "Your app is running. Edit src/app/page.tsx to get started." Nothing in it +// is a solution, and nothing else writes it — the solution agent runs for the +// BACKEND and the nextjs agent runs after it, so anything the agent wrote +// there is overwritten. This command is the only step after both. +func TestTheShellLinksAndMountsTheRemote(t *testing.T) { + manifest, err := addJSONDependency([]byte(`{ + "name": "code", + "dependencies": { "next": "16.4.1" } +}`), "notes-fe-remote", "file:../../fe-remote") + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err = json.Unmarshal(manifest, &document); err != nil { + t.Fatal(err) + } + dependencies, _ := document["dependencies"].(map[string]any) + if dependencies["notes-fe-remote"] != "file:../../fe-remote" { + t.Fatalf("the remote must be linked into the shell, got %v", dependencies) + } + if dependencies["next"] != "16.4.1" { + t.Fatalf("linking the remote must not drop the shell's own dependencies, got %v", dependencies) + } + + // The shell mounts the remote's ./Page with ssr: false — the same module + // a host federates, imported rather than reimplemented. + app := shellApp("notes", "notes-fe-remote") + for _, required := range []string{`import("notes-fe-remote/Page")`, "ssr: false", `const apiBase = "/v1/notes"`} { + if !strings.Contains(app, required) { + t.Fatalf("the shell must contain %q", required) + } + } + + // Turbopack refuses to follow the remote's symlink out of the app's own + // root, so the build root is the solution's. Without it the shell answers + // 500 while the link, the exports map and the file are all correct. + config := shellNextConfigSource("notes-fe-remote") + for _, required := range []string{`root: resolve("../../..")`, `transpilePackages: ["notes-fe-remote"]`, "externalDir: true"} { + if !strings.Contains(config, required) { + t.Fatalf("the shell's next.config.ts must contain %q", required) + } + } + // wiki's `webpack` block is a hard error on the Next this agent + // scaffolds: Next 16 runs Turbopack and refuses a webpack config. + if strings.Contains(config, "webpack(") { + t.Fatal("a webpack config is refused by the Next this agent scaffolds") + } +} + +// TestThePlaygroundNamesTheEdge. The edge is the browser's one origin: it +// signs the person in and routes /v1/ to the backend with their Work +// Context. It is NOT a dependency of the backend and nothing elects it as +// ingress, so neither `run solution` nor `run workspace` starts it — the +// fleet's own Makefiles name it first, and so does this one. +// +// Getting it wrong does not look like an error. Open the shell's own port and +// the page says "This page is not behind its sign-in edge"; open the +// authority's REST port and every path 404s. Both were hit repeatedly +// reconstructing this by hand, which is why it is scaffolded. +func TestThePlaygroundNamesTheEdge(t *testing.T) { + makefile := playgroundMakefile("notes") + if !strings.Contains(makefile, "PLAYGROUND_SERVICES = authority/edge notes/web notes/backend") { + t.Fatalf("the edge must be named first in the services the playground runs:\n%s", makefile) + } + + // The edge group is what tells the edge which module to route and where + // the page is. Its key is the solution's name, upper-cased. + group := playgroundEdgeGroup("my-notes") + for _, required := range []string{"MODULES=my-notes", "MY_NOTES_REST=${endpoint:my-notes/backend/http}", "FRONTEND=${endpoint:my-notes/web/http}"} { + if !strings.Contains(group, required) { + t.Fatalf("the edge group must contain %q, got:\n%s", required, group) + } + } + + // The authority is pinned by commit, and the pin must be one the DEFAULT + // BRANCH holds: a PR head reads identically and dies on squash-merge. + workspace := playgroundWorkspace("notes", "88599779642808bce196d612b99b0898be6b78c3") + for _, required := range []string{`version: "88599779642808bce196d612b99b0898be6b78c3"`, "source: codefly-dev/authority", "fixture: local", "name: notes-playground"} { + if !strings.Contains(workspace, required) { + t.Fatalf("the playground workspace must contain %q", required) + } + } +} + +// TestTheInstallationAdmitsTheFixturesPeople. A solution the authority has no +// installation for refuses every capability — "audience %q is not installed +// in this organization, so there is no installation to seal the capability +// to" — so the page can only render its not-installed branch. The fixture +// itself names who exists; which solutions are installed is each composed +// module's fragment to say. +// +// TEAMS ARE WHAT ADMIT ANYONE BUT THE OWNER, and dropping them is the +// mistake this guards: with `role` and `owner` alone every other person of +// the fixture signs in and is refused by name, " holds no role on +// ". Verified against the running authority — maya, rémi, alex and +// dev all reach the solution's route; no-roles, in no team, gets 403. +func TestTheInstallationAdmitsTheFixturesPeople(t *testing.T) { + fragment := fixtureComposition("notes") + for _, required := range []string{ + "schema: codefly.dev/authority/fixture-composition/v1", + "organization: acme", + "target: notes", + "owner: alex", + "teams: [finance, engineering]", + } { + if !strings.Contains(fragment, required) { + t.Fatalf("the fixture composition must contain %q, got:\n%s", required, fragment) + } + } +} + +// TestTheContractDeclaresARoleBesidesInstaller. The organization is installed +// as viewer, so the contract has to declare that role: a role the contract +// does not name is one the authority never issues. +func TestTheContractDeclaresARoleBesidesInstaller(t *testing.T) { + contract := solutionContract("notes") + var names []string + for index := range contract.Roles { + names = append(names, contract.Roles[index].Name) + } + if !slices.Contains(names, "installer") || !slices.Contains(names, "viewer") { + t.Fatalf("the contract must declare installer and viewer, got %v", names) + } +} diff --git a/cmd/generate/contracts.go b/cmd/generate/contracts.go index 7dfb0aae..faf53d51 100644 --- a/cmd/generate/contracts.go +++ b/cmd/generate/contracts.go @@ -1073,3 +1073,23 @@ func contractRoutes(groups []*basev0.RestRouteGroup) []composition.APIContractRo } return routes } + +// PublishModuleContracts writes a module's published API contracts and adds +// them to its package manifest — the same work `codefly generate contracts` +// does with no flags, exported so a command that scaffolds a module can leave +// it in the state that command would. +// +// `codefly add solution` calls it as its last step. Without it a scaffolded +// solution declares a package carrying no contract, and the authority is +// delivered its scope ceilings and no method policy at all — "no delivered +// method policies; declared ceilings alone do not admit a call" — so the +// backend serves its route, the route declares its policy, and every call is +// refused with only a line in the authority's log to say why. +func PublishModuleContracts(ctx context.Context, workspace *resources.Workspace, module *resources.Module) error { + _, err := generateContracts(ctx, workspace, module, generateOptions{ + canonicalOutput: filepath.Join(module.Dir(), "contracts", "api"), + writeDir: filepath.Join(module.Dir(), "contracts", "api"), + updateManifest: true, + }) + return err +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 966ccb69..b5a0c1c9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -339,7 +339,11 @@ 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). +--agent names the agent that scaffolds the backend, and it must be a SOLUTION +agent (default: solution-go). The service agent of the same language is a +different thing: the go agent scaffolds a service — no declared operation, no +published policy, no page remote — and delivery refuses the result. Naming +one is refused here, with the solution agent to use instead. What this writes, and what stays yours: the module, both services, the solution manifest, the module contract, the package identity and the page @@ -356,8 +360,9 @@ 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 + --agent string Agent that scaffolds the backend (default: solution-go). Must be a SOLUTION agent; naming the service agent of a language is refused, with the one to use instead --authority-interface string The authority interface the backend requires, as /@ (default "codefly.dev/authority@>=0.2.0 <0.4.0") + --authority-version string Commit of codefly-dev/authority the playground pins (default: its current default-branch head, resolved here). Must be a commit the DEFAULT BRANCH holds: a PR head reads the same and dies on squash-merge --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) diff --git a/docs/commands.md b/docs/commands.md index cf5b65c1..dde8b037 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -121,40 +121,6 @@ never `--stand-alone` or `--exclude-root`; `--exclude-dependency` and out. The selected environment's `exclude-services` also removes services from the composition roots; a module withdrawn whole contributes no root, including its service-entry. CLI and run-profile exclusions still apply on top. - -**An operation is held to the environment it selected, never refused because a -DIFFERENT environment's `exclude-services` is inconsistent with the workspace -graph.** Run, build, deploy and `deploy gitops render` each check the one -environment they were given against the graph, and a refusal names that -environment in the message itself rather than only under `--debug`. - -This is scoped to the graph-consistency check, which is where the -cross-environment refusal came from. It is not a general rule that other -environments are ignored. The check itself decodes and validates EVERY -environment's declarations before it compares the selected one against the -graph, so a **malformed** `exclude-services` anywhere — an unqualified name, a -duplicate, a service withdrawn from itself — still refuses, whatever was -selected. (Loading the workspace does not: a workspace with such a declaration -still loads, and a clean environment still selects. It is the validation that -refuses, not the load.) Module namespaces, host composition, -service-scoped names (`service-config`, `service-identity`, `service-mesh`) -and `managed-services` keys are likewise still held across every environment, -so a bad declaration in one of those blocks in an unselected environment can -still refuse a correct operation elsewhere. -This matters because the exclusion graph is the workspace's **current** -composition: an environment imported before a module overlay added a service -carries no `withdrawn-from` for it, so holding every environment to that graph -refused a correct render of a different environment — and the operator could -not satisfy it, since re-importing the offending environment requires the -product intent to declare it. - -Every CLI path that checks exclusions against the graph selects an -environment: run, build, test and deploy through the flow, `deploy gitops -render` and the solution and dev renders through the workspace validation, and -`codefly doctor workspace` through its own scope check — doctor selects and -calls the selected check directly, rather than relying on the fallback. The -whole-workspace form, which holds every environment's graph at once, remains -the behaviour for a caller that has selected none. The deterministic port hash folds every endpoint into a few thousand buckets, so a `--naming-scope` can hash two endpoints to one port and abort the run; `--temporary-ports` sidesteps the hash, under the ordering its @@ -2427,6 +2393,22 @@ codefly add solution wiki --package-id obin/wiki \ --consumes connect:documents/documents/grpc --consumes connect:robin/chat/connect ``` +**The backend is scaffolded by a solution agent, not a service agent.** The two +are different things and the CLI refuses the wrong one rather than quietly +substituting it: + +``` +$ codefly add solution demo --package-id obin/demo --agent go +--agent go is the SERVICE agent: it scaffolds a service, not a solution — no +declared operation, no published policy, no page remote, and delivery refuses +the result. Pass --agent solution-go +``` + +`solution-go` builds on **solution-runtime-go**: the viewer's Work Context, the +authority's check against each route's published policy, the passthrough that +relays a module call *as the viewer*, the interface document, and the `./Page` +remote a host federates. The refusal happens before anything is written. + 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. @@ -2441,14 +2423,47 @@ 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 | +| `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. It **mounts the solution's own `./Page`**, the same module a host federates, and its generic login/signup routes are removed: a solution has no sign-in of its own, the authority's edge signs the person in | | `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.contract.codefly.yaml` | the principal its credentials are issued to, with `owner_aware: true`, the solution's own `scope_ceilings`, and the **installer role** — the authority refuses to admit a solution that offers a person no role to hold on it | | `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 | +| `configurations/local/fixture-composition.yaml` | who, in the development fixture, has this solution **installed**. Without it the authority refuses every capability ("not installed in this organization") and the page can only render its not-installed branch. `teams` is what admits anyone but the owner | +| `playground/` + `Makefile` | the composition that runs the solution LOCALLY on the development authority — `make playground`. It names `authority/edge` explicitly, because the edge is not a dependency of the backend and nothing elects it as ingress, so neither `run solution` nor `run workspace` starts it | +| `contracts/api` + the package's `api-contracts` | the backend's published http contract, generated as the last step. The authority derives the method policy of **every route** from it, so a solution carrying none admits no call to any route it serves — and says so only as a warning in the authority's log, during a run | + +`services/fe-remote` — the `./Page` remote itself — is written by the solution +agent, not by this command: it is an npm package, not a Codefly service. + +**Running it.** `make playground` starts the development authority and this +solution, and prints the one address a browser opens: -`services/fe-remote` — the `./Page` remote itself — is an npm package, not a -Codefly service, and is yours to author. +``` +edge listening on :14581, routing 1 module(s) +``` + +That edge is the browser's single origin: it signs the person in from the +authority's fixture, serves the page, and routes `/v1/` to the backend +with that person's Work Context. Two addresses are *not* it, and neither fails +in a way that says so — the shell's own port (the page reports "This page is +not behind its sign-in edge") and the authority's REST port (every browser +path 404s). In a cell the host's gateway plays the edge's part; the playground +is local only. + +Sign in as any of the fixture's acme people in a team — `maya`, `remi`, +`alex`, `dev` — and the solution's route answers: + +``` +GET /v1/notes/overview 200 {"solution":"notes","consumes":[]} +``` + +`no-roles`, who is in no team, signs in and is refused by name (403, *"holds +no role on notes"*). That is the fixture doing its job, not a fault. + +The authority is pinned by commit, resolved from its **default branch** when +the solution is scaffolded: it publishes no module release, and a PR head +reads identically in the manifest and dies on squash-merge. +`--authority-version` overrides 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 @@ -2464,7 +2479,7 @@ 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 | +| `--agent` | Agent that scaffolds the backend (default `solution-go`). Must be a **solution** agent; naming the service agent of a language (`go`, `python-fastapi`) is refused, naming the solution agent to use instead | | `--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 | @@ -3068,40 +3083,6 @@ release commit also: in full. A catalog that no longer matches its contract files is refused — run `codefly generate contracts` and commit it first. -**Agents the command starts, and what ends them.** Reading each service's -exported endpoints loads that service, which starts its agent as a child -process. The command retires them on return by closing the cached connections. -That close is Core's, and what it attempts is cooperative: Core asks the agent -to stop, gives it time to retire its own children (user binaries, containers), -and forces the process group down if that wait expires. The signal it sends is -Core's own choice and is not restated here. - -What that does **not** establish is that every child was retired. The close -reports no error, the agent ignores the error from stopping its children, and -the agent has a hard-deadline exit of its own, so an agent process that has -gone away is evidence the agent exited — not proof that nothing it started -survived it. Treat this as best-effort cleanup with a forced fallback. - -Cancelling the command once (Ctrl-C) takes the same path: the reap is deferred -rather than conditional on success, so it still runs. A **second** Ctrl-C does -not — it exits the process immediately and no deferred cleanup runs at all, -which leaves exactly the agents this retirement exists to prevent. - -It retires the agents **that run of the command started**. An agent left -running by an earlier command — a version of the CLI before this retirement -existed, for instance — is not adopted or reaped here: it is a separate process -that outlived its own command. Retiring one is `codefly recover process ---preview` to read its identity, then `--expect FILE` with an explicit mode to -act on exactly that birth, one process at a time, with no group or registry -effect. - -The only mode this version offers is `--force`, an irreversible SIGKILL. That -is **not** a substitute for a cooperative shutdown: it gives the agent no -chance to reap its own children, so a leftover agent's user binaries and -containers can survive it. A graceful mode, and what should happen when a -target refuses to exit or is signalled twice, are not covered at this version. - - The services and modules are found through `workspace.codefly.yaml` and each `module.codefly.yaml` — never by searching the tree, so a fixture workspace elsewhere in the repository is not touched, and never through the machine-local @@ -3573,13 +3554,7 @@ codefly agent ci --skip-conformance # Source/build/drift debugging ### `codefly generate` -Generate client code from service APIs. Codefly owns the proto generation path -and invokes the pinned proto companion for its compilers and plugins. Core -0.19.15 selects proto companion 0.0.23, which generates Go, Python, and -TypeScript without building or shipping Rust tooling. Rust client generation -is not supported. Companion publication cross-compiles Go tools natively for -each architecture instead of compiling under ARM emulation. Its runtime caches -remain writable when Linux runs generation as the host user. +Generate client code from service APIs. ```bash codefly generate client --from billing/api/grpc --language go --output libraries/billing-api-client # A codefly library from a live local service diff --git a/pkg/solutionrun/frontend_local.go b/pkg/solutionrun/frontend_local.go index 6a3676ad..212d11a0 100644 --- a/pkg/solutionrun/frontend_local.go +++ b/pkg/solutionrun/frontend_local.go @@ -567,9 +567,35 @@ func (s *frontendSources) sync(ctx context.Context, destination string) error { return nil } +// installCommand is how the staged project's dependencies are installed: +// `npm ci` against a lockfile, and `npm install` when there is none. +// +// `npm ci` is the right command wherever a lock exists — it installs exactly +// what the lock pins and fails on any drift from it — but it cannot run +// without one at all, by design: "loadVirtual requires existing shrinkwrap +// file", which is what a freshly scaffolded page remote hits. The solution +// boots, its backend answers, and delivery then reports the frontend +// unbuildable for a project whose only fault is that nobody has installed it +// yet. +// +// Falling back is safe precisely because this runs in STAGING, a copy of the +// sources: the lock npm resolves is written to the copy and the author's tree +// is not touched. Reproducibility is unchanged for every project that has a +// lock, which is every project that has ever been installed. +func installCommand(projectDir string) []string { + const npm = "npm" + for _, lock := range []string{"package-lock.json", "npm-shrinkwrap.json"} { + if _, err := os.Stat(filepath.Join(projectDir, lock)); err == nil { + return []string{npm, "ci"} + } + } + return []string{npm, "install"} +} + // buildNatively runs the frontend's own npm build in staging, a copy of -// exactly its sources — `npm ci` first when the copy's dependencies were not -// installed from this lockfile — and answers the bundle it wrote. The +// exactly its sources — installing first (see installCommand) when the copy's +// dependencies were not installed from this lockfile — and answers the bundle +// it wrote. The // component's own node_modules and files are never touched. A failure names the // command and carries the end of its output. func (s *frontendSources) buildNatively(ctx context.Context, module *resources.Module, staging string, install bool) (string, error) { @@ -582,7 +608,7 @@ func (s *frontendSources) buildNatively(ctx context.Context, module *resources.M } steps := [][]string{{"npm", "run", "build"}} if install { - steps = append([][]string{{"npm", "ci"}}, steps...) + steps = append([][]string{installCommand(projectDir)}, steps...) } for _, step := range steps { //nolint:gosec // G204: fixed npm executable and arguments, no shell. @@ -617,6 +643,16 @@ func nativeBuildEnvironment(environ []string) []string { switch { case name == "PATH", name == "HOME", name == "USER", name == "LOGNAME", name == "TMPDIR", name == "LANG", name == "LC_ALL", name == "NODE_EXTRA_CA_CERTS", name == "SSL_CERT_FILE", + // The registry credential. A solution's page depends on the + // @codefly-dev kits, which are published to GitHub Packages, so + // the .npmrc beside it reads ${NODE_AUTH_TOKEN} — and an + // allow-list that drops it makes that file impossible to satisfy: + // the install fails "401 Unauthorized ... unauthenticated" no + // matter what the operator exports, with nothing saying the CLI + // removed it. It is the one secret an npm install is supposed to + // carry, and npm_config_* above can already carry the same + // credential by another spelling. + name == "NODE_AUTH_TOKEN", strings.EqualFold(name, "HTTP_PROXY"), strings.EqualFold(name, "HTTPS_PROXY"), strings.EqualFold(name, "NO_PROXY"), strings.HasPrefix(strings.ToLower(name), "npm_config_"): kept = append(kept, entry) diff --git a/pkg/solutionrun/frontend_local_test.go b/pkg/solutionrun/frontend_local_test.go index 7c377b00..02e8635d 100644 --- a/pkg/solutionrun/frontend_local_test.go +++ b/pkg/solutionrun/frontend_local_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" @@ -384,3 +385,44 @@ func TestStagedFrontendCopiesAreBoundedAndForgotten(t *testing.T) { require.FileExists(t, filepath.Join(state, "built.json")) require.NoError(t, LocalFrontend{StateDir: filepath.Join(state, "absent")}.ForgetAll(t.Context())) } + +// TestInstallCommandFallsBackWhereThereIsNoLock. `npm ci` cannot run without a +// lockfile — "loadVirtual requires existing shrinkwrap file" — and a freshly +// scaffolded page remote has none. The solution booted and its backend +// answered, and delivery still reported the frontend unbuildable, for a +// project whose only fault was that nobody had installed it yet. +func TestInstallCommandFallsBackWhereThereIsNoLock(t *testing.T) { + project := t.TempDir() + if got := installCommand(project); !slices.Equal(got, []string{"npm", "install"}) { + t.Fatalf("a project with no lockfile must be installed with npm install, got %v", got) + } + for _, lock := range []string{"package-lock.json", "npm-shrinkwrap.json"} { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, lock), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if got := installCommand(dir); !slices.Equal(got, []string{"npm", "ci"}) { + t.Fatalf("a project with %s must be installed with npm ci, got %v", lock, got) + } + } +} + +// TestTheRegistryCredentialReachesTheBuild. A solution's page depends on the +// @codefly-dev kits from GitHub Packages, and the .npmrc the agent writes +// beside it reads ${NODE_AUTH_TOKEN}. The build environment is an allow-list, +// so dropping that name made the file impossible to satisfy: every install +// failed "401 Unauthorized ... unauthenticated" whatever the operator +// exported, and nothing said the CLI had removed it. +func TestTheRegistryCredentialReachesTheBuild(t *testing.T) { + kept := nativeBuildEnvironment([]string{ + "PATH=/usr/bin", + "NODE_AUTH_TOKEN=secret", + "AWS_SECRET_ACCESS_KEY=must-not-leak", + }) + if !slices.Contains(kept, "NODE_AUTH_TOKEN=secret") { + t.Fatalf("the registry credential must reach npm, got %v", kept) + } + if slices.Contains(kept, "AWS_SECRET_ACCESS_KEY=must-not-leak") { + t.Fatal("the allow-list must still drop everything it does not name") + } +} diff --git a/pkg/solutionshape/check.go b/pkg/solutionshape/check.go index 74e72206..105f5483 100644 --- a/pkg/solutionshape/check.go +++ b/pkg/solutionshape/check.go @@ -79,6 +79,8 @@ const ( CodeConsumedWithoutBinding = "consumed_module_without_binding" CodePageShellHasNoApp = "page_shell_has_no_app" CodePageExportedTooWidely = "page_shell_exported_too_widely" + CodeInstallerRoleMissing = "installer_role_missing" + CodeBackendContractMissing = "backend_contract_missing" ) // Check is one rule's outcome, with the fix when it failed. @@ -292,6 +294,8 @@ func (r *Report) checkContract(dir string, consumed []string) { r.ok(CodeContractMissing, fmt.Sprintf("the contract declares principal %q", contract.Principal)) r.checkConsumedModulesHaveAuthority(consumed, contract) + r.checkInstallerRole(contract) + // The one that fails silently, and the reason this checker exists. if contract.OwnerAware { r.ok(CodeOwnerAwareMissing, "the contract declares owner-aware checking") @@ -301,6 +305,38 @@ func (r *Report) checkContract(dir string, consumed []string) { } } +// checkInstallerRole holds the contract to the role the host requires of a +// solution. A solution is a unit a PERSON installs, so the authority refuses +// to admit one that offers a person no role to hold on it: +// +// state: forbidden: declares 0 role(s) and none is "installer": +// a solution declares an installer role +// +// It is reported here because of WHEN that refusal arrives: at `runtime-run`, +// after the whole composition — authority, store, every consumed module — has +// booted. Every document reads fine until then, `manifest.Validate` passes, +// and the three consumer-repository gates pass. Of the nine solutions in one +// fleet, seven declared no role at all and only the two that are actually +// run, wiki and proposals, declared one; the shape checker called all nine +// conformant. +// +// Only the role's EXISTENCE is checked. What it should grant is the +// solution's own business, and core's Contract.Validate already refuses a +// role granting a scope no ceiling covers or no binding spells out — a rule +// this package must not restate, having once shipped a permissions check that +// failed wiki and proposals, the only two solutions known to work. +func (r *Report) checkInstallerRole(contract *modulecontract.Contract) { + for index := range contract.Roles { + if contract.Roles[index].Name == modulecontract.InstallerRole { + r.ok(CodeInstallerRoleMissing, "the contract declares the installer role") + return + } + } + r.fail(CodeInstallerRoleMissing, + fmt.Sprintf("the contract declares %d role(s) and none is %q", len(contract.Roles), modulecontract.InstallerRole), + "the authority refuses to admit a solution that offers a person no role to hold on it, and it refuses at runtime-run — after the whole composition has booted and every document has read fine. Declare an installer role scoped within the contract's own scope_ceilings") +} + // checkPackage holds the published identity, and the one thing about it that // is easy to get wrong in the right-looking direction. func (r *Report) checkPackage(dir string) { @@ -315,6 +351,7 @@ func (r *Report) checkPackage(dir string) { return } r.ok(CodePackageMissing, fmt.Sprintf("the package publishes %s@%s", packaged.ID, packaged.Version)) + r.checkBackendContract(packaged) for index := range packaged.Services { if packaged.Services[index].Name == PageService { @@ -327,6 +364,43 @@ func (r *Report) checkPackage(dir string) { r.ok(CodePackagePublishesShell, "the package does not publish the shell") } +// checkBackendContract holds the package to carrying the backend's published +// http contract, which is what the authority checks a call against. +// +// The CLI derives the authority's per-route method policies from the contract +// the package declares (pkg/gitops ServiceMethodPolicies reads api-contracts). +// A package that declares none delivers the module's scope ceilings and NO +// method policy, and the authority says so and then refuses everything: +// +// permissions: WARN @1: no delivered method policies; +// declared ceilings alone do not admit a call +// +// Every document reads fine, the backend boots, the route is served and its +// policy is declared — and no call to it can ever be admitted. The only sign +// is that one warning, in the authority's log, during a run. +// +// `codefly generate contracts` writes the contract and adds the entry, but it +// refuses unless the package also declares the "contracts" artifact root, +// which is why the remediation names both. Of the nine solutions in one +// fleet, the two that are actually run — wiki and proposals — declare them +// and the other seven declare neither. +func (r *Report) checkBackendContract(packaged *composition.PackageManifest) { + for index := range packaged.Services { + service := &packaged.Services[index] + if service.Name != EntryService { + continue + } + if len(service.APIContracts) > 0 { + r.ok(CodeBackendContractMissing, "the package carries the backend's published contract") + return + } + r.fail(CodeBackendContractMissing, + fmt.Sprintf("the package publishes %q and carries no contract for it", EntryService), + "the authority derives the method policy of every route from this contract, so without it the solution is delivered its ceilings and no policy, and admits no call to any route it serves. Declare \"contracts\" under artifact-roots and run `codefly generate contracts`") + return + } +} + // checkFrontend reads the page declaration with core's own strict parser — // the reader that refuses it at delivery — and from beside the ENTRY service, // which is the only place anything looks. @@ -537,13 +611,15 @@ func (r *Report) checkConsumedModulesHaveAuthority(consumes []string, contract * // and the first sign is a run that does nothing. func (r *Report) checkPageShellHasAnApp(dir string) { shell := filepath.Join(dir, "services", PageService) - if _, err := os.Stat(filepath.Join(shell, "service.codefly.yaml")); err != nil { + service, err := resources.LoadFromDir[resources.Service](context.Background(), shell) + if err != nil { // No manifest at all is the module declaring a service it does not // have, which the entry-service rules already speak to. return } + app := filepath.Join(shell, pageSourceDir(service)) for _, marker := range []string{"package.json", "next.config.ts", "next.config.js"} { - if _, err := os.Stat(filepath.Join(shell, marker)); err == nil { + if _, statErr := os.Stat(filepath.Join(app, marker)); statErr == nil { r.ok(CodePageShellHasNoApp, "the page's shell has an app behind it") return } @@ -552,3 +628,20 @@ func (r *Report) checkPageShellHasAnApp(dir string) { fmt.Sprintf("the %q shell is declared with no app behind it", PageService), "`codefly run service "+PageService+"` has nothing to start. Write the shell, or drop the service from module.codefly.yaml — a declaration with no source is the one state neither reading the manifests nor running the gates reveals") } + +// pageSourceDir is where the shell's app lives, relative to the service: the +// manifest's `spec.source-dir`, or `code` when it says nothing. +// +// The empty default is the nextjs agent's own (`Settings.NodeSourceDir`), and +// the two layouts in use differ by exactly this key: a shell this CLI +// scaffolds takes the agent's default and gets an app under `code/`, while the +// fleet's hand-written pages sit at the service root and say `source-dir: .`. +// A check that assumed either one called the other empty — the scaffolded +// shell was reported as having no app while its package.json sat one directory +// down. +func pageSourceDir(service *resources.Service) string { + if dir, ok := service.Spec["source-dir"].(string); ok && dir != "" { + return dir + } + return "code" +} diff --git a/pkg/solutionshape/check_test.go b/pkg/solutionshape/check_test.go index a48156f1..646aa915 100644 --- a/pkg/solutionshape/check_test.go +++ b/pkg/solutionshape/check_test.go @@ -49,19 +49,37 @@ principal: wiki owner_aware: true namespaces: [] queues: [] -scope_ceilings: [] +scope_ceilings: + - resource_kind: wiki.page + actions: [read, write] +roles: + - name: installer + scopes: + - resource_kind: wiki.page + actions: [read, write] bindings: [] `) + // The published contract the package carries. The manifest loader checks + // every artifact root exists on disk, and the authority derives each + // route's method policy from this document. + write(t, filepath.Join(dir, "contracts", "api", "backend", "http", "openapi.json"), `{"openapi":"3.0.0"}`) write(t, filepath.Join(dir, "module.package.codefly.yaml"), `kind: module-package schema: codefly/module-package/v2 id: obin/wiki version: 0.1.0 minimum-codefly-version: ">=0.1.220 <1.0.0" artifact-roots: + - contracts - services services: - name: backend endpoints: [http] + api-contracts: + - endpoint: http + kind: openapi + package: wiki solution backend + path: contracts/api/backend/http/openapi.json + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 contracts: composition: ">=2.0.0 <3.0.0" `) @@ -254,10 +272,17 @@ id: obin/wiki version: 0.1.0 minimum-codefly-version: ">=0.1.220 <1.0.0" artifact-roots: + - contracts - services services: - name: backend endpoints: [http] + api-contracts: + - endpoint: http + kind: openapi + package: wiki solution backend + path: contracts/api/backend/http/openapi.json + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 - name: web endpoints: [http] contracts: @@ -390,9 +415,112 @@ func TestAPageShellWithNoAppWarns(t *testing.T) { t.Fatal("a warning was treated as a failure") } - // With an app beside it, the finding goes away. - write(t, filepath.Join(dir, "services", PageService, "package.json"), `{"name":"web","private":true}`) + // With an app where the manifest says it is, the finding goes away. + write(t, filepath.Join(dir, "services", PageService, "code", "package.json"), `{"name":"web","private":true}`) if checkFor(t, Inspect("wiki", dir), CodePageShellHasNoApp).Status != StatusOK { t.Fatal("a shell with a real app was still reported") } } + +// TestAPageShellFindsItsAppWhereTheManifestSaysItIs covers the two layouts in +// use. They differ by exactly `spec.source-dir`, and a check that knew only +// one reported the other as having no app: a shell this CLI scaffolds takes +// the nextjs agent's default and gets its app under `code/`, while the +// fleet's hand-written pages sit at the service root and say `source-dir: .`. +// The first `codefly add solution` ever run reported its own freshly +// scaffolded, working shell as empty. +func TestAPageShellFindsItsAppWhereTheManifestSaysItIs(t *testing.T) { + for _, layout := range []struct { + name string + // sourceDir is written into spec; empty means the key is absent, so + // the agent's own default applies. + sourceDir string + appDir string + }{ + {"the agent's default puts the app under code/", "", "code"}, + {"source-dir . puts the app at the service root", ".", ""}, + } { + t.Run(layout.name, func(t *testing.T) { + dir := conformingSolution(t) + shell := filepath.Join(dir, "services", PageService) + if layout.sourceDir != "" { + manifest := filepath.Join(shell, "service.codefly.yaml") + existing, err := os.ReadFile(manifest) + if err != nil { + t.Fatalf("cannot read the shell's manifest: %v", err) + } + write(t, manifest, string(existing)+"spec:\n source-dir: "+layout.sourceDir+"\n") + } + write(t, filepath.Join(shell, layout.appDir, "package.json"), `{"name":"web","private":true}`) + if checkFor(t, Inspect("wiki", dir), CodePageShellHasNoApp).Status != StatusOK { + t.Fatal("the shell's app was not found where the manifest says it is") + } + }) + } +} + +// TestAContractWithNoInstallerRoleFails covers the rule the authority states +// at runtime and nothing else catches. A solution is a unit a person installs, +// so a contract offering no installer role is refused — at `runtime-run`, +// after the whole composition has booted. Seven of nine solutions in one fleet +// declared no role at all and the shape checker called them all conformant. +func TestAContractWithNoInstallerRoleFails(t *testing.T) { + dir := conformingSolution(t) + if checkFor(t, Inspect("wiki", dir), CodeInstallerRoleMissing).Status != StatusOK { + t.Fatal("a contract declaring the installer role was reported") + } + + // Strip the roles: the contract still reads, and everything else passes. + write(t, filepath.Join(dir, "module.contract.codefly.yaml"), `schema: codefly/module-contract/v1 +principal: wiki +owner_aware: true +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +`) + report := Inspect("wiki", dir) + if checkFor(t, report, CodeInstallerRoleMissing).Status != StatusFail { + t.Fatal("a contract with no installer role was not reported") + } + if report.Conforms() { + t.Fatal("a solution the authority refuses to admit was called conformant") + } +} + +// TestAPackageCarryingNoBackendContractFails. The authority derives the +// method policy of every route from the contract the package carries, so a +// package declaring none is delivered the module's ceilings and no policy — +// "no delivered method policies; declared ceilings alone do not admit a +// call". The backend boots, serves its route, declares its policy, and admits +// nothing; the only sign is a warning in the authority's log during a run. +// +// Of the nine solutions in one fleet, the two that are actually run — wiki +// and proposals — carry it and the other seven do not. +func TestAPackageCarryingNoBackendContractFails(t *testing.T) { + dir := conformingSolution(t) + if checkFor(t, Inspect("wiki", dir), CodeBackendContractMissing).Status != StatusOK { + t.Fatal("a package carrying the backend's contract was reported") + } + + write(t, filepath.Join(dir, "module.package.codefly.yaml"), `kind: module-package +schema: codefly/module-package/v2 +id: obin/wiki +version: 0.1.0 +minimum-codefly-version: ">=0.1.220 <1.0.0" +artifact-roots: + - services +services: + - name: backend + endpoints: [http] +contracts: + composition: ">=2.0.0 <3.0.0" +`) + report := Inspect("wiki", dir) + if checkFor(t, report, CodeBackendContractMissing).Status != StatusFail { + t.Fatal("a package carrying no contract for the backend was not reported") + } + if report.Conforms() { + t.Fatal("a solution that can admit no call to any route it serves was called conformant") + } +}