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
114 changes: 94 additions & 20 deletions cmd/add/solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -89,30 +95,39 @@ 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)
if err != nil {
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")
Expand All @@ -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
// <name> 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},
Expand All @@ -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")
Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -403,7 +476,8 @@ 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(&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 [<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)")
SolutionCmd.Flags().StringVar(&solutionDescription, "description", "", "Short description written to module.codefly.yaml")
Expand Down
150 changes: 135 additions & 15 deletions cmd/add/solution_documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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: <name> 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 `<name>.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 — "<person> holds no role on <name>" —
// 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{},
}
}

Expand Down Expand Up @@ -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}}},
Expand Down
Loading
Loading