diff --git a/cmd/add/solution.go b/cmd/add/solution.go index 5b386d3b..a6076658 100644 --- a/cmd/add/solution.go +++ b/cmd/add/solution.go @@ -260,9 +260,10 @@ func addSolution(ctx context.Context, name string) (result error) { } 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) + // 8. The local composition: what runs this solution on the development + // authority, at the solution's own root, where all nine solutions in the + // fleet keep theirs. Not a playground — that is a module's. + applySolutionLocalRun(ctx, mod, solutionAuthorityVersion) cli.Header(1, "Solution <%s> scaffolded: %s (entry) and %s (the page's shell).", name, solutionEntryService, solutionPageService) reportNextSteps(name) @@ -476,7 +477,7 @@ 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(&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(&solutionAuthorityVersion, "authority-version", "", "Commit of codefly-dev/authority the local composition 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)") diff --git a/cmd/add/solution_playground.go b/cmd/add/solution_local.go similarity index 78% rename from cmd/add/solution_playground.go rename to cmd/add/solution_local.go index 80139939..ec94c9b9 100644 --- a/cmd/add/solution_playground.go +++ b/cmd/add/solution_local.go @@ -12,8 +12,15 @@ import ( "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. +// The local composition: what runs this solution on the development +// authority, written at the SOLUTION'S OWN ROOT, which is where all nine +// solutions in the fleet keep theirs. +// +// IT IS NOT A PLAYGROUND. A playground is a MODULE's — somewhere to exercise +// a module on its own — and two solutions happen to carry one as an extra. +// Scaffolding a solution into a playground/ subdirectory made the solution +// look like a thing its own local run composes, which is the confusion this +// avoids: here the solution's directory IS the workspace, with `path: .`. // // 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 @@ -32,9 +39,8 @@ import ( // 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. +// authorityModule is the development authority the local composition names. // // 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 @@ -43,31 +49,31 @@ const playgroundDir = "playground" // stale by the next release; --authority-version overrides it. const authorityModule = "codefly-dev/authority" -// applySolutionPlayground writes the playground beside the solution. +// applySolutionLocalRun writes the local composition at the solution's root. // // 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 +// written and validated, so a composition that cannot be pinned is reported // and skipped rather than discarding a correct solution. -func applySolutionPlayground(ctx context.Context, module *resources.Module, authorityVersion string) { +func applySolutionLocalRun(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) + cli.Warning("the local composition 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) + root := module.Dir() 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), + "workspace.codefly.yaml": localWorkspace(module.Name, pin), + "configurations/local/authority.env": localAuthorityGroup(), + "configurations/local/edge.env": localEdgeGroup(module.Name), + "LOCAL.md": localReadme(module.Name), } // The solution's own fragment of the authority's fixture-composition - // document. It belongs beside the SOLUTION, not in the playground: the + // document. It belongs beside the SOLUTION: the // composing workspace merges every composed module's fragment, and this // is the solution's. if err := shared.WriteFileAtomic(ctx, @@ -78,15 +84,15 @@ func applySolutionPlayground(ctx context.Context, module *resources.Module, auth } 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) + cli.Warning("the local composition'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) + if err := shared.WriteFileAtomic(ctx, filepath.Join(module.Dir(), "Makefile"), []byte(localMakefile(module.Name)), 0o644); err != nil { + cli.Warning("the Makefile was not written: %v", err) return } - cli.Info("wrote playground/ — `make playground` runs this solution on the development authority") + cli.Info("wrote the local composition — `make run` runs this solution on the development authority") } // defaultBranchCommit resolves a GitHub repository's default-branch head. @@ -110,7 +116,7 @@ func defaultBranchCommit(ctx context.Context, repository string) (string, error) return commit, nil } -func playgroundWorkspace(name, authorityPin string) string { +func localWorkspace(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 @@ -123,15 +129,20 @@ func playgroundWorkspace(name, authorityPin string) string { # 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 +name: %s-local layout: modules module-resolution: authority: git modules: + # THIS DIRECTORY IS THE MODULE. The workspace document sits beside + # module.codefly.yaml, as it does in all nine solutions of the fleet, so + # the path is "." and not the ".." a separate playground directory would + # need. Get it wrong and the run resolves the module one level up and + # fails on a module.codefly.yaml that is not there. - name: %s - path: .. + 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. @@ -163,7 +174,7 @@ environments: `, name, name, authorityModule, authorityPin, titleFromName(name)) } -func playgroundAuthorityGroup() string { +func localAuthorityGroup() 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} @@ -174,7 +185,7 @@ rest-endpoint=${endpoint:authority/local/rest} ` } -func playgroundEdgeGroup(name string) string { +func localEdgeGroup(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. @@ -188,21 +199,25 @@ 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 +// localMakefile 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 remote +func localMakefile(name string) string { + return fmt.Sprintf(`.PHONY: run remote # %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. # +# This directory IS the workspace (workspace.codefly.yaml, path: .), so the +# run happens here. There is no playground subdirectory: a playground is a +# MODULE's, somewhere to exercise a module on its own. +# # 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 +# the backend and nothing elects it as ingress, so neither `+"`codefly run solution`"+` +# nor `+"`codefly run workspace`"+` starts it: name it, or the page is served on +# no origin that can sign anyone in. +SERVICES = authority/edge %s/web %s/backend # THE REMOTE IS BUILT FIRST, and that is not a convenience. The solution # runtime serves /assets/ from ASSETS_DIR, which defaults to @@ -210,10 +225,9 @@ PLAYGROUND_SERVICES = authority/edge %s/web %s/backend # backend answers 404 for /assets/mf-manifest.json, the manifest it # advertises in /.well-known/solution.json, and a host can federate nothing. # The shell also installs the remote as a copy, so it needs it built. -playground: remote - cd %s && codefly run service $(PLAYGROUND_SERVICES) --env local +run: remote + codefly run service $(SERVICES) --env local -.PHONY: remote remote: @test -n "$$NODE_AUTH_TOKEN" || { \ echo "NODE_AUTH_TOKEN is not set. The page's kits are published to GitHub"; \ @@ -225,14 +239,14 @@ remote: exit 1; } cd services/fe-remote && npm install --no-audit --no-fund && npm run build test -f services/fe-remote/dist/mf-manifest.json -`, titleFromName(name), name, name, playgroundDir) +`, titleFromName(name), name, name) } -func playgroundReadme(name string) string { - return fmt.Sprintf(`# %s — the playground +func localReadme(name string) string { + return fmt.Sprintf(`# Running %s locally export NODE_AUTH_TOKEN=$(gh auth token) # read:packages, for the page's kits - make playground + make run Then open the address the EDGE prints: @@ -253,7 +267,7 @@ Then open the address the EDGE prints: The page's kits (@codefly-dev/*) are published to GitHub Packages, and services/fe-remote/.npmrc reads NODE_AUTH_TOKEN for them. Without it npm answers "401 Unauthorized - unauthenticated" and names no cause, so -make playground refuses to start until it is set. +make run refuses to start until it is set. ## The two addresses that are not it @@ -306,7 +320,7 @@ installations: # 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, + # useless local run. 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]} diff --git a/cmd/add/solution_test.go b/cmd/add/solution_test.go index a3c6cac4..5f6f5038 100644 --- a/cmd/add/solution_test.go +++ b/cmd/add/solution_test.go @@ -480,7 +480,7 @@ func TestTheShellLinksAndMountsTheRemote(t *testing.T) { } } -// TestThePlaygroundNamesTheEdge. The edge is the browser's one origin: it +// TestTheLocalRunNamesTheEdge. 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 @@ -490,15 +490,15 @@ func TestTheShellLinksAndMountsTheRemote(t *testing.T) { // 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") { +func TestTheLocalRunNamesTheEdge(t *testing.T) { + makefile := localMakefile("notes") + if !strings.Contains(makefile, "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") + group := localEdgeGroup("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) @@ -507,8 +507,8 @@ func TestThePlaygroundNamesTheEdge(t *testing.T) { // 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"} { + workspace := localWorkspace("notes", "88599779642808bce196d612b99b0898be6b78c3") + for _, required := range []string{`version: "88599779642808bce196d612b99b0898be6b78c3"`, "source: codefly-dev/authority", "fixture: local", "name: notes-local", "path: ."} { if !strings.Contains(workspace, required) { t.Fatalf("the playground workspace must contain %q", required) } @@ -556,16 +556,16 @@ func TestTheContractDeclaresARoleBesidesInstaller(t *testing.T) { } } -// TestThePlaygroundBuildsTheRemoteFirst. The solution runtime serves /assets/ +// TestTheLocalRunBuildsTheRemoteFirst. The solution runtime serves /assets/ // from ASSETS_DIR, which defaults to ../fe-remote/dist relative to the // backend. With nothing built there the backend answers 404 for // /assets/mf-manifest.json — the manifest it advertises in // /.well-known/solution.json — and a host can federate nothing. The fleet's // own playground builds the remote before the run for exactly this reason. -func TestThePlaygroundBuildsTheRemoteFirst(t *testing.T) { - makefile := playgroundMakefile("notes") - if !strings.Contains(makefile, "playground: remote") { - t.Fatalf("the playground must build the remote first:\n%s", makefile) +func TestTheLocalRunBuildsTheRemoteFirst(t *testing.T) { + makefile := localMakefile("notes") + if !strings.Contains(makefile, "run: remote") { + t.Fatalf("the local run must build the remote first:\n%s", makefile) } for _, required := range []string{ "cd services/fe-remote && npm install", @@ -578,20 +578,42 @@ func TestThePlaygroundBuildsTheRemoteFirst(t *testing.T) { } } -// TestThePlaygroundRefusesWithoutTheRegistryToken. The page's kits are +// TestTheLocalRunRefusesWithoutTheRegistryToken. The page's kits are // published to GitHub Packages and the remote's .npmrc reads // NODE_AUTH_TOKEN, so without it npm answers "401 Unauthorized - // unauthenticated" and names no cause. The playground says so itself rather // than letting a newcomer meet that. -func TestThePlaygroundRefusesWithoutTheRegistryToken(t *testing.T) { - makefile := playgroundMakefile("notes") +func TestTheLocalRunRefusesWithoutTheRegistryToken(t *testing.T) { + makefile := localMakefile("notes") if !strings.Contains(makefile, "NODE_AUTH_TOKEN") { t.Fatalf("the remote target must check for the registry token:\n%s", makefile) } if !strings.Contains(makefile, "gh auth token") { t.Fatal("the refusal must name the command that sets it") } - if !strings.Contains(playgroundReadme("notes"), "NODE_AUTH_TOKEN") { - t.Fatal("the playground README must state the token as a prerequisite") + if !strings.Contains(localReadme("notes"), "NODE_AUTH_TOKEN") { + t.Fatal("the local run doc must state the token as a prerequisite") + } +} + +// TestTheSolutionDirectoryIsTheWorkspace. The local composition sits at the +// solution's own root, as it does in all nine solutions of the fleet — +// core-solutions has no root workspace of its own, so each solution IS one. +// +// It is deliberately NOT a playground/ subdirectory: a playground is a +// MODULE's, somewhere to exercise a module on its own, and two solutions +// carry one as an extra. Scaffolding into one also made the module path ".." +// instead of ".", which resolved the module a level up and failed the run on +// a module.codefly.yaml that was not there. +func TestTheSolutionDirectoryIsTheWorkspace(t *testing.T) { + workspace := localWorkspace("notes", "88599779642808bce196d612b99b0898be6b78c3") + if !strings.Contains(workspace, "path: .\n") { + t.Fatalf("the module path must be \".\", the workspace sitting beside module.codefly.yaml:\n%s", workspace) + } + if strings.Contains(workspace, "path: ..") { + t.Fatal("\"..\" is the path a separate playground directory needed; the workspace is at the solution root") + } + if strings.Contains(localMakefile("notes"), "cd playground") { + t.Fatal("the run happens at the solution root, not in a playground subdirectory") } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b5a0c1c9..397adbe9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -362,7 +362,7 @@ Flags: ``` --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 + --authority-version string Commit of codefly-dev/authority the local composition 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 cd698152..9326fec7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2429,7 +2429,7 @@ What it creates: | `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 | +| `workspace.codefly.yaml` + `configurations/local/` + `Makefile` + `LOCAL.md` | the composition that runs the solution LOCALLY on the development authority — `make run`, at the solution's own root, where all nine solutions keep theirs. 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 @@ -2439,7 +2439,7 @@ agent, not by this command: it is an npm package, not a Codefly service. ```sh export NODE_AUTH_TOKEN=$(gh auth token) # read:packages, for the page's kits -make playground +make run ``` The token is a prerequisite, not a nicety: the page's `@codefly-dev/*` kits are @@ -2447,7 +2447,7 @@ published to GitHub Packages and the remote's `.npmrc` reads it, so without one npm answers `401 Unauthorized - unauthenticated` and names no cause. The playground refuses to start and says which command sets it. -`make playground` builds the page's remote, starts the development authority +`make run` builds the page's remote, starts the development authority and this solution, and prints the one address a browser opens: ``` @@ -2460,7 +2460,8 @@ 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. +is local only. It lives at the solution's own root — **not** in a +`playground/`, which is a *module's* thing. The remote is built **before** the run, and that is load-bearing: the solution runtime serves `/assets/` from `ASSETS_DIR`, which defaults to