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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: ci

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
# core's own Go service-agent CI, not a copy of it. The pin is the core
# version go.mod requires, so the checks this agent is held to move with the
# core it compiles against rather than drifting in a workflow nobody reads.
ci:
uses: codefly-dev/core/.github/workflows/go-service-ci.yml@v0.19.10
58 changes: 58 additions & 0 deletions .github/workflows/releaser.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: GoReleaser

# The tag workflow is the SOLE publisher of this agent's artifacts
# (agent.codefly.yaml declares `release.owner: workflow`), so nothing but a
# `v*` tag produces a release and `codefly publish` never uploads from a
# laptop.
on:
push:
tags:
- 'v*'

jobs:
# The suite runs before anything is published, on the tag itself: a tag that
# does not pass is a tag that publishes nothing. The end-to-end test here
# scaffolds a solution and compiles it, so this job is also what proves the
# templates in the tagged tree render into Go that builds.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
cache: false
- run: go test -v ./...

release:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
# Each published archive carries its own SBOM, which GoReleaser needs
# Syft on PATH to produce. It is installed here and mounted into the
# cross-build container below, because that container has no Syft of its
# own.
- name: Install Syft for archive SBOMs
id: syft
uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
with:
syft-version: v1.44.0
# CGO-capable, and that is not optional: core's authoritative source
# inspection links real tree-sitter grammars, so an agent built with
# CGO_ENABLED=0 cannot parse anything. goreleaser-cross is what supplies
# the darwin and linux cross compilers .goreleaser.yaml names.
- name: Run CGO-capable GoReleaser
run: >-
docker run --rm
-e GITHUB_TOKEN
-v "$GITHUB_WORKSPACE:/workspace"
-v "$SYFT_PATH:/usr/local/bin/syft:ro"
-w /workspace
ghcr.io/goreleaser/goreleaser-cross:v1.27.1-1
release --clean
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
SYFT_PATH: ${{ steps.syft.outputs.cmd }}
72 changes: 72 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
version: 2

sboms:
- id: archives
artifacts: archive
documents:
- "${artifact}.sbom.json"
disable: false

project_name: service-solution-go

# A prerelease tag (a `codefly publish dev` build, v<version>-dev.<sha>) is
# published as a GitHub prerelease, never as Latest, so `version: latest` can
# never resolve to a dev build. Release tags are unaffected.
release:
prerelease: auto

builds:
# ARCHITECTURE: core's authoritative source inspection uses real tree-sitter
# grammars. Agent releases therefore require CGO and the platform compilers
# supplied by goreleaser/goreleaser-cross; disabling CGO would silently make
# the production parser impossible to ship.
- id: darwin-amd64
binary: service-solution-go
env:
- CGO_ENABLED=1
- CC=o64-clang
- CXX=o64-clang++
goos:
- darwin
goarch:
- amd64
- id: darwin-arm64
binary: service-solution-go
env:
- CGO_ENABLED=1
- CC=oa64-clang
- CXX=oa64-clang++
goos:
- darwin
goarch:
- arm64
- id: linux-amd64
binary: service-solution-go
env:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
- CXX=x86_64-linux-gnu-g++
goos:
- linux
goarch:
- amd64
- id: linux-arm64
binary: service-solution-go
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
goos:
- linux
goarch:
- arm64

archives:
- id: default
formats:
- tar.gz
# Must match core's downloader: service-{name}_{version}_{os}_{arch}.tar.gz
# (resources.AgentKindRegistration.GitHubAsset); the binary inside is
# service-{name}. For this agent {name} is solution-go, so both are
# service-solution-go. release_test.go pins it.
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
131 changes: 131 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Working in this repository

`solution-go` is a `codefly:service` agent. It scaffolds a **solution
backend** — the entry service of a solution, built on
`github.com/codefly-dev/solution-runtime-go` — where the generic `go` agent
scaffolds a plain Go service.

## base/ is hand-written. templates/factory/ is GENERATED. Never edit a template.

This is the authoring model of every service agent, and the single most
important thing to know before changing anything here.

```
base/ a REAL, COMPILING, TESTED solution backend <- edit this
templates/factory/ generated from base/ <- never edit
```

`templates/factory/` is produced by core's
`generator.GenerateServiceTemplate` — `codefly agent generate --service .` —
which copies every file in `base/`, adds a `.tmpl` suffix, and applies the
literal replacements in `base/service.generation.codefly.yaml`.

**Why, and not just convention.** A template cannot be compiled, vetted or
tested, so hand-written templates let you ship a scaffold that does not build
and nothing notices until someone scaffolds it. `base/` is a real service:
`go build`, `go vet` and `go test` run against it in CI
(`TestBaseCompilesAndPassesItsOwnSuite`), and **its own test suite is the suite
every scaffolded solution ships with**. The thing this agent scaffolds is
known-good before anybody scaffolds it.

### To change what a solution is scaffolded with

1. Edit `base/`. Keep it compiling: `cd base && GOWORK=off go build ./...`.
2. Regenerate and refresh the derived files:
```
GOWORK=off go test -update ./...
```
That one flag rewrites `base/consumed_gen.go`, `base/openapi/api.swagger.json`
and all of `templates/factory/`, so `base/` and the templates can never be
refreshed one without the other.
3. `GOWORK=off go test ./...`

`TestFactoryTemplatesAreGeneratedFromBase` fails on any difference between
`templates/factory/` and what `base/` generates, in both directions — a stale
template whose source left `base/` is caught too, because regeneration
overwrites a file but never deletes one. An edit made to a template instead of
to `base/` is reverted by the next regeneration and fails the suite until it is.

### The replacements are literal and whole-file

`base/` is a real solution named `codefly-base`, spelled three ways, each
derived from the name exactly as `scaffold.go` derives it for a real solution:

| in `base/` | becomes | derived by |
|---|---|---|
| `codefly-base` | `{{ .Module.Name.LowerCase }}` | core's own `Information` |
| `Codefly Base` | `{{ .Title }}` | `scaffold.go` `titleFrom` |
| `CodeflyBase` | `{{ .OperationPrefix }}` | `scaffold.go` `aliasField` |

Order matters and no `from` may be a substring of an earlier `to`.
`TestGeneratedTemplatesCarryNoBaseSolutionName` fails if any spelling of
`codefly-base` survives into a template, which is what catches a new
capitalisation introduced in `base/` that no replacement covers.

**The solution id is spelled `.Module.Name.LowerCase`, never `.SolutionID`.**
They are the same value — `scaffold.go` derives `SolutionID` from exactly that
field — but not renderable in the same places. Two files in a generated tree
are rendered by CORE rather than by this agent, each with a bare
`*services.Information` that carries only `Service`, `Module` and `Agent`:

| rendered by core | when | where |
|---|---|---|
| `templates/factory/GETTING_STARTED.md` | builder Load, creation mode | `core/agents/services/base_builder.go` |
| `templates/agent/README.md` | `GetAgentInformation`, before any service exists | this repo's `service.go` |

`{{ .SolutionID }}` there is not an empty render but a hard failure — *can't
evaluate field SolutionID in type \*services.Information* — and
`codefly add solution` dies with the module already written. The agent README
takes no values at all for the same reason: at advertisement time `Service` and
`Module` are themselves nil. `advertisement_test.go` renders both the way core
renders them.

`backend` is **not** replaced. A solution's entry service is named `backend` by
the shape's own definition — the CLI's `solutionEntryService` and this agent's
`backendService` are both that constant — so it is not a parameter, and it
appears in prose on nearly every line (`a solution backend`, `the compiled
backend`) which a whole-file substitution would corrupt.

## The other template trees are not generated

| tree | generated? | why |
|---|---|---|
| `templates/factory/` | yes, from `base/` | the service directory |
| `templates/page/` | **no — hand-written** | the `./Page` npm remote is not a Go service, so `GenerateServiceTemplate` does not produce it, and it is written to a SIBLING directory (`services/fe-remote`) rather than into the service. See `main.go`'s `pageFS`. |
| `templates/builder/` | no | Dockerfile and dockerignore, rendered with the builder's own context |
| `templates/deployment/` | no | kustomize base and overlays, rendered with the deployment context |
| `templates/agent/` | no | this agent's own README, not part of a scaffolded tree |

## What this agent must never scaffold

`codefly add solution` (codefly-dev/cli#1216) writes the solution's
declarations **after** this agent's `Create` returns. Anything written here in
those places is overwritten silently, by landing first:

- `module-identity`, the endpoints, and the only two `service-dependencies` a
solution backend has (the authority interface plus `authority/hop` as
`kind: external`);
- the five documents a solution is installed from.

**In particular, never a `service-dependency` on a consumed module.** A
solution reaches every module through the authority's hop, which *checks* the
call; Codefly renders a `service-dependency` into a mesh allow rule straight to
that module's service, past the check, and `check_solution_mesh.py` refuses it
(SP-SOL-04). `shape_test.go` holds the agent to all of this, and the files in
`base/` that carry the CLI's declarations are listed under `ignores` in
`base/service.generation.codefly.yaml` so they are never templatized.

## Build and test

| what | command |
|---|---|
| the agent | `GOWORK=off go build ./... && GOWORK=off go vet ./...` |
| everything | `GOWORK=off go test ./...` |
| refresh generated files | `GOWORK=off go test -update ./...` |
| `base/` alone | `cd base && GOWORK=off go build ./...` |

`base/` is a nested module, so the root `./...` does not include it;
`TestBaseCompilesAndPassesItsOwnSuite` builds, vets and tests it by staging it
into the layout a real solution has (`<root>/services/backend` beside
`<root>/solution.codefly.yaml`), which is where its `consumes_test.go` really
finds the solution manifest.
104 changes: 104 additions & 0 deletions advertisement_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"context"
"strings"
"testing"

"github.com/codefly-dev/core/agents/services"
agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0"
"github.com/codefly-dev/core/resources"
"github.com/codefly-dev/core/shared"
"github.com/codefly-dev/core/templates"
"github.com/stretchr/testify/require"
)

// THE ADVERTISEMENT IS ASKED FOR BEFORE A SERVICE EXISTS, and that is the
// whole reason this file is here.
//
// `codefly add service` calls GetAgentInformation to print what the agent
// scaffolds — at that moment nothing has been created, so core has only run
// the agent's Init and s.Information carries the AGENT and nothing else
// (core/agents/services/base.go: `s.Information = &Information{Agent:
// s.Agent}`). Service and Module are nil until Load runs against a service
// directory that exists.
//
// A README template that reaches for {{ .Service.Name.Title }} therefore does
// not render a blank — it fails the whole call with "nil pointer evaluating
// *resources.ServiceWithCase.Name", and `codefly add solution` dies after
// having already written the module, leaving a half-built tree behind. This
// agent shipped exactly that, and every test here passed: they all supply an
// Information built from a real identity, which is the state at Create time
// and NOT the state at advertisement time.
func TestTheAdvertisementRendersBeforeAnyServiceExists(t *testing.T) {
// NewService and nothing else: the state core leaves the agent in when it
// asks what the agent is, before Load.
svc := NewService()
require.Nil(t, svc.Base.Information,
"this test is only honest while the agent carries no service here; if core now supplies one, make this test build the state it supplies")

information, err := svc.GetAgentInformation(context.Background(), &agentv0.AgentInformationRequest{})
require.NoError(t, err, "GetAgentInformation must render with no service loaded: this is what `codefly add service` calls first")
require.NotEmpty(t, information.ReadMe)
require.Contains(t, information.ReadMe, agent.Name,
"the README a caller is shown must name the agent it is reading about")
}

// TestTheAgentReadmeNeedsNoServiceContext is the same rule one step earlier,
// stated against the template rather than the call: the agent README describes
// THE AGENT, so a service-shaped or module-shaped value in it has no source to
// come from. Reading the template says why the test above holds, rather than
// leaving it a fact about one rendering.
func TestTheAgentReadmeNeedsNoServiceContext(t *testing.T) {
raw, err := readmeFS.ReadFile("templates/agent/README.md.tmpl")
require.NoError(t, err)
content := string(raw)
for _, reach := range []string{".Service", ".Module", ".Information"} {
require.NotContains(t, content, reach,
"templates/agent/README.md.tmpl is rendered with the agent's own Information, where %s is nil until a service is loaded", reach)
}
require.NotContains(t, content, "{{",
"the agent README takes no values at all: keep it prose, so there is nothing left to be nil")
require.True(t, strings.Contains(content, agent.Name),
"the agent README must name the agent")
}

// THE SECOND THING CORE RENDERS WITH A BARE Information, and the one that
// cost a half-built module.
//
// templates/factory/GETTING_STARTED.md is rendered TWICE, by two callers with
// two different contexts:
//
// - by this agent's Create, with the scaffold context, into the service
// directory (scaffold.go);
// - by CORE, at builder Load in creation mode, with s.Information and
// nothing else, to print to the author — core/agents/services/
// base_builder.go does it by that exact path, on every agent.
//
// So this one template is constrained to the fields Information carries. A
// `{{ .SolutionID }}` in it is not an empty render: text/template fails the
// call with "can't evaluate field SolutionID in type *services.Information",
// core turns that into "builder load failed", and `codefly add solution` dies
// AFTER `add module` has already written the module — the half-built tree the
// command's own design goes out of its way to avoid.
//
// base/service.generation.codefly.yaml therefore spells the solution id
// `.Module.Name.LowerCase`, which both contexts carry. This test is what holds
// it there.
func TestTheGettingStartedRendersTheWayCoreRendersIt(t *testing.T) {
// Exactly core's call: same FS, same path, same context type.
information := &services.Information{
Service: resources.ToServiceWithCase(&resources.ServiceIdentity{Name: backendService, Module: "notes", Workspace: "workspace"}),
Module: resources.ToModuleWithCase(&resources.ServiceIdentity{Name: backendService, Module: "notes", Workspace: "workspace"}),
Agent: agent,
}
rendered, err := templates.ApplyTemplateFrom(context.Background(), shared.Embed(factoryFS),
"templates/factory/GETTING_STARTED.md", information)
require.NoError(t, err,
"core renders this template with a bare *services.Information at builder Load: a field Information does not carry fails `codefly add solution` after the module is already written")

// A render that succeeds but says nothing is the other way to be wrong:
// the solution's name is what the document is about.
require.Contains(t, rendered, "notes", "the rendered document must name the solution")
require.NotContains(t, rendered, "<no value>", "every action must resolve under Information, not render empty")
}
Loading
Loading