Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions cmd/add/solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -476,7 +477,7 @@ func reportNextSteps(name string) {

func init() {
SolutionCmd.Flags().StringVar(&solutionPackageID, "package-id", "", "The <owner>/<name> 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 [<id>=]<protocol>:<module>/<service>/<endpoint>. 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)")
Expand Down
92 changes: 53 additions & 39 deletions cmd/add/solution_playground.go → cmd/add/solution_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name> to the
Expand All @@ -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
Expand All @@ -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 <commit>.", authorityModule, err)
cli.Warning("the local composition was not written: cannot resolve %s's default branch (%v). Re-run with --authority-version <commit>.", 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,
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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}
Expand All @@ -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.
Expand All @@ -188,32 +199,35 @@ 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
# ../fe-remote/dist relative to the backend — so with no build 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 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"; \
Expand All @@ -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:

Expand All @@ -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

Expand Down Expand Up @@ -306,7 +320,7 @@ installations:
# shipped fixture; with no teams every other person signs in and is
# refused by name —
# "<person> 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]}
Expand Down
56 changes: 39 additions & 17 deletions cmd/add/solution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name> 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
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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",
Expand All @@ -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")
}
}
Loading
Loading