diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c168a1bc4..58d3c1f8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,6 +371,11 @@ jobs: # case the suite adds or a rule the authority core changes reaches accounts on # its next pull request, never silently on the next re-pin. The same suite at # the pinned revision runs with accounts' tests in the quality phases. + # Before the suite, the declaration gate (`module-package + # verify-host-interfaces`): every interface version module.codefly.yaml + # declares is the version that interface publishes at the pinned authority + # revision, or a patch below it within the same major.minor, so a declaration + # names no suite the pin did not run. host-interface-conformance: name: Host interface conformance (authority main) runs-on: ubuntu-latest @@ -380,10 +385,12 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.27.3" - cache-dependency-path: module/services/accounts/code/go.sum + cache-dependency-path: | + module/services/accounts/code/go.sum + module/tools/go.sum - name: Install Codefly run: bash scripts/ci/install-codefly.sh - - name: Run the shared suite at authority main against accounts + - name: Hold the declared interface versions to the pin, then run the shared suite at authority main against accounts run: bash scripts/ci/host-interface-conformance-main.sh marketing: diff --git a/AGENTS.md b/AGENTS.md index eda9d7a03..65c599bba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,8 @@ Skills in `.claude/skills/`, loaded when the task calls for them: module verification job, authorization coverage, the release-gate contract, interface docs and story tests, the kit's version, marketing isolation, the SDK boundary, the immutable module package, the scoped pools' database - authority, and the shared host-interface conformance suite at authority main. [RELEASE_GATES.md § Repository-specific + authority, and the shared host-interface conformance suite at authority main + with the declared interface versions held to the pinned suite. [RELEASE_GATES.md § Repository-specific gates](./RELEASE_GATES.md#repository-specific-gates) says what each runs, and `release-gates.test.mjs` holds this prose to the enforced set. - More checks run as *steps* inside the `base-integrity` job than as gates of their diff --git a/RELEASE_GATES.md b/RELEASE_GATES.md index 4abbe76de..20650158f 100644 --- a/RELEASE_GATES.md +++ b/RELEASE_GATES.md @@ -111,7 +111,7 @@ no per-track exemption. | `authz-coverage` | required | required | RBAC, audit, and no-broadening | | `release-contract` | required | required | this gating graph itself | | `docs-sync` | required | required | interface docs and story tests | -| `host-interface-conformance` | required | required | the shared host-interface suite at authority main | +| `host-interface-conformance` | required | required | the shared host-interface suite at authority main, and the declared interface versions held to the pin | | `kit-version` | required | required | published frontend kit version vs. content | | `marketing` | required | required | marketing isolation build | | `sdk-boundary` | required | required | Codefly SDK boundary and contracts | @@ -445,7 +445,7 @@ reverse, fails the `release-contract` job. | `authz-coverage` | the generated authorization catalog: RBAC coverage, audit coverage, and permission no-broadening against `main` | `node module/tools/authz-coverage-gate.mjs`, plus `go test` for the gateway header-lockstep and adapter enforcement tests | | `release-contract` | this gating graph itself, and the Dependabot configuration that feeds it | `node --test scripts/ci/release-gates.test.mjs`, `node --test scripts/ci/dependabot-coverage.test.mjs`, `node scripts/ci/release-gates.mjs check`, `node scripts/ci/dependabot-coverage.mjs check` | | `docs-sync` | the generated interface docs and the story-trace tests (#516) | `node module/tools/interface-docs-gate.mjs check`, `node module/tools/story-trace-gate.mjs tests` | -| `host-interface-conformance` | accounts serves the host interfaces (authority, composition, installation) exactly as the shared conformance suite of `codefly-dev/authority` states them, at authority's main whenever main contains the pinned revision, so a case authority adds reaches accounts on its next pull request | `bash scripts/ci/host-interface-conformance-main.sh`, which re-pins the authority libraries to main when main contains the pin and runs `go test -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters` | +| `host-interface-conformance` | accounts serves the host interfaces (authority, composition, installation, session, artifact) exactly as the shared conformance suite of `codefly-dev/authority` states them, at authority's main whenever main contains the pinned revision, so a case authority adds reaches accounts on its next pull request; and every interface version `module.codefly.yaml` declares is the version that interface publishes at the pinned authority revision, or a patch below it within the same major.minor, so a declaration names no suite the pin did not run | `bash scripts/ci/host-interface-conformance-main.sh`, which resolves the pinned authority commit in its clone of main (fetching it by full id when main does not contain it), runs `go run ./cmd/module-package verify-host-interfaces` (in `module/tools`) over its `interfaces/` tree, then re-pins the authority libraries to main when main contains the pin and runs `go test -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters` | | `kit-version` | the published frontend kit's version moves whenever its content does, since a registry version is immutable once served | `node --test scripts/ci/kit-version.test.mjs`, `node scripts/ci/kit-version.mjs check` | | `marketing` | the marketing runtime builds in isolation from the app, and the public config is current | `node module/tools/generate-public-config.mjs --check`, `node module/tools/marketing-extraction.mjs` | | `sdk-boundary` | the root module's own tests, the Codefly SDK boundary, single-invocation protocol generation, the exported API contract, and the published module-authority client against the real host | `go test ./...` **in the root module only**, `go test codefly_sdk_boundary_test.go`, `codefly generate contracts saas-starter --check`, `codefly generate runnables saas-starter --check`, `go test ./...` in `qualification/module-authority` | diff --git a/module/module.codefly.yaml b/module/module.codefly.yaml index 7801c36e7..089ba8b42 100644 --- a/module/module.codefly.yaml +++ b/module/module.codefly.yaml @@ -28,7 +28,7 @@ interface: - codefly.dev/authority@0.3.5 - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.4.0 - - codefly.dev/session@0.2.0 + - codefly.dev/session@0.2.1 - service: auth-gateway endpoint: grpc visibility: internal diff --git a/module/services/accounts/HOST_INTERFACE.md b/module/services/accounts/HOST_INTERFACE.md index 12af0f8f4..ceb43ef8e 100644 --- a/module/services/accounts/HOST_INTERFACE.md +++ b/module/services/accounts/HOST_INTERFACE.md @@ -1,10 +1,14 @@ # The host interfaces, served by accounts accounts implements the host interfaces published by `codefly-dev/authority`: -`codefly.dev/authority@0.3.5`, `codefly.dev/composition@0.2.0`, -`codefly.dev/installation@0.3.0` and `codefly.dev/session@0.2.0`. The local -authority implements the same four on a laptop. The shared conformance suite -(`libraries/conformance/go`) proves that the two agree. +`codefly.dev/artifact@0.1.0`, `codefly.dev/authority@0.3.5`, +`codefly.dev/composition@0.2.0`, `codefly.dev/installation@0.4.0` and +`codefly.dev/session@0.2.1`, as `module/module.codefly.yaml` declares them. The +local authority implements the same five on a laptop. The shared conformance +suite (`libraries/conformance/go`) proves that the two agree, and the +declaration gate (`module-package verify-host-interfaces`, under Proof below) +holds each version named here and in the manifest to the version the interface +publishes at the authority revision `go.mod` pins. ## One authority core, two thin hosts @@ -156,7 +160,39 @@ behavior-preserving rollback. - `.github/workflows/ci.yml` (`host-interface-conformance`, the required context "Host interface conformance (authority main)" and a release gate) runs the suite at authority main, whenever main contains the pinned revision, - on every pull request and merge-queue entry. + on every pull request and merge-queue entry; the same suite at the pinned + revision runs with accounts' tests in the Codefly quality phase. +- Before that suite, the same job runs the declaration gate, `module-package + verify-host-interfaces` (in `module/tools`, with its own tests in + `modulepackage/host_interfaces_test.go`). It reads + `interfaces//interface.codefly.yaml` at the pinned authority commit, + resolved in the clone the job already makes (a pin on an unmerged authority + branch is fetched by its full id instead), and refuses: a version + `module/module.codefly.yaml` declares above the one published there (a + claim no suite proved); a version in another major.minor than the published + one (passing 0.5.0's suite runs no 0.4.0 suite); an interface the pin does + not publish; authority libraries pinned at two revisions, replaced or + excluded (`go.mod` pins the suite, the core it decides with, the client, + delivery and resources at one commit, and the require line must be what + runs); and an opening sentence of this document naming another version than + the manifest. A declared patch below the published one, within the same + major.minor, is a notice, never a failure: the pin may move to run a newer + patch's suite before the declaration follows it, and the declaration never + moves first. The half that needs no network, one revision in `go.mod` and + this document agreeing with the manifest, also runs with the `module/tools` + tests in the module-package job. +- Last recorded run: authority `dea37afac9ad` (its main on 2026-10-30, the + revision `go.mod` pins for all five authority libraries), from + `module/services/accounts/code`, on 2026-11-03: + + GOWORK=off go test -count=1 -timeout 30m -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' -v ./pkg/adapters + + passed 94 cases (the shared suite, the solution-principal target and the + cases beyond the suite, as `go test -v` lists them one level under the + test), 294 subtests at every depth; failed 0; skipped 0; 74 s. The + admission-key case ("only the hop registers an admission key, published + apart from the Work Context keys") is among them, with the fixture's + `HopCredential`. The section below carries no red line from this run. - The core's store contract (`authority-core/go/storetest`) runs against this Postgres store in `pkg/infra/postgres_authority_core_test.go`. - The core's own decisions are unit tested in `codefly-dev/authority`. diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 74a0a12b6..ba96b70f4 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -10,8 +10,8 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad - github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261028224835-f82a98e093ef - github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261028224835-f82a98e093ef + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc github.com/codefly-dev/core v0.19.17-0.20261029043538-2aa2e9aaf950 github.com/codefly-dev/interface-cache/go/cache v0.4.1 diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 07a49bfd2..9788b4693 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -62,12 +62,12 @@ github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-202610302220 github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:Cdk+10ebRh0tZA9ze2dDIrfQC34E+Y7LMi6UjSQ2dQU= github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad h1:DZt68NLXwaGYWXo1RUM0+JrvuXt/za/EyeHMGYFSix8= github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:or3dd9bqacXWMjke/n81l/QxRaLwq2lq/hL4V2qtAZo= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261028224835-f82a98e093ef h1:Mso6TDL7zLY5HwR+gq0pRSNN/zO8fv89F9kzcPf+qI4= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261028224835-f82a98e093ef/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad h1:gq8WatMLdoqsfwafxj2ihYSl0jIEw/d7ZMTGRWu9qpo= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f h1:llYwKOBeCB6kS4faxICrR5YYlwnhiV+0qXjuT3IwBd4= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f/go.mod h1:/SZab4/ll1nAKTucP3h30rpcXo56BHmJWLkvs2ayaMs= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261028224835-f82a98e093ef h1:oJN5L6PkQB+VTVn1zsgXzSwiCO7eFUrd67PCkncrFI4= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261028224835-f82a98e093ef/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad h1:ZuYv+9r6U08xLQ5c+AOSBzhkF7h3lqUj6kMa0S2OKKE= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= github.com/codefly-dev/core v0.19.17-0.20261029043538-2aa2e9aaf950 h1:U5HeCDUfftwVFbMYN1HGPUL+9HO+ske3nkExu1C2kYY= diff --git a/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go b/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go index 63398c2d3..866727663 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_admission_test.go @@ -184,6 +184,11 @@ func TestTheHopsRegisteredKeyIsPublishedApartFromTheWorkContextKeys(t *testing.T require.Equal(t, id, published.ResourceID) require.Equal(t, id, published.Payload["key_id"]) require.Equal(t, "authority/hop", published.Payload["workload"]) + require.NotEmpty(t, published.Payload["instance"], "the hop instance the key is pinned to") + // The durable emitter writes the row and drops the whole payload when a + // key is not declared for the event, with a warning and nothing failing; + // the production check runs here so the declaration cannot drift. + require.NoError(t, business.ValidatePayload(published.EventType, published.Payload)) // A restart registers the same key again: one entry, renewed, and a // renewal is not an event. diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 624b7ee4d..8ac18d5c4 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -743,6 +743,9 @@ var auditEventCatalog = []AuditEventDefinition{ mutation(EventHopAdmissionKeyPublished, CategorySecurity, "Keys began publishing a hop admission key, registered by the hop's attested workload.", PayloadField{Name: "key_id", Kind: FieldString, Required: true}, PayloadField{Name: "workload", Kind: FieldString, Required: true}, + // The hop instance (its pod) the key is pinned to; a credential naming + // no pod is refused before anything is published. + PayloadField{Name: "instance", Kind: FieldString, Required: true}, PayloadField{Name: "expires_at", Kind: FieldString, Required: true}), mutation(EventModuleEnvelopeDelivered, CategorySecurity, "A release-signed revision of the module envelope was delivered.", PayloadField{Name: "revision", Kind: FieldInt, Required: true}, diff --git a/module/tools/cmd/module-package/main.go b/module/tools/cmd/module-package/main.go index 774dd3194..8ddaed53c 100644 --- a/module/tools/cmd/module-package/main.go +++ b/module/tools/cmd/module-package/main.go @@ -19,9 +19,22 @@ func main() { func run(arguments []string) error { if len(arguments) == 0 { - return fmt.Errorf("usage: module-package ") + return fmt.Errorf("usage: module-package ") } switch arguments[0] { + case "verify-host-interfaces": + flags := flag.NewFlagSet("verify-host-interfaces", flag.ContinueOnError) + moduleRoot := flags.String("module", ".", "module package root") + interfaces := flags.String("interfaces", "", "codefly-dev/authority's interfaces/ tree at the revision the services' go.mod pin") + revision := flags.String("revision", "", "the commit that tree was read at, held to the go.mod pin (optional)") + if err := flags.Parse(arguments[1:]); err != nil { + return err + } + return modulepackage.VerifyHostInterfaces(modulepackage.HostInterfaceOptions{ + ModuleRoot: *moduleRoot, + InterfacesDir: *interfaces, + Revision: *revision, + }, os.Stdout) case "validate": flags := flag.NewFlagSet("validate", flag.ContinueOnError) moduleRoot := flags.String("module", ".", "module package root") diff --git a/module/tools/go.mod b/module/tools/go.mod index 7aa33f5ae..5f6d50ce0 100644 --- a/module/tools/go.mod +++ b/module/tools/go.mod @@ -6,6 +6,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/codefly-dev/core v0.19.7 github.com/yoheimuta/go-protoparser/v4 v4.14.2 + golang.org/x/mod v0.41.0 golang.org/x/tools v0.50.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 @@ -46,7 +47,6 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.60.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.49.0 // indirect diff --git a/module/tools/modulepackage/host_interfaces.go b/module/tools/modulepackage/host_interfaces.go new file mode 100644 index 000000000..34f171686 --- /dev/null +++ b/module/tools/modulepackage/host_interfaces.go @@ -0,0 +1,407 @@ +package modulepackage + +// The host-interface declaration gate. module.codefly.yaml declares what a +// service implements (`implements: codefly.dev/@`), and the +// service's go.mod pins the codefly-dev/authority revision whose conformance +// suite its tests run. The two are one fact only if each declared version is +// the version the interface publishes at that revision, or a patch below it +// within the same major.minor: a declaration above it names a version no suite +// proved, and a declaration in another major.minor names a suite that did not +// run (passing 0.5.0's suite says nothing about 0.4.0's). A patch below is +// reported and never failed, because moving the pin to run a newer patch's +// suite must not force the declaration before that suite is green. + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/codefly-dev/core/resources" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + "gopkg.in/yaml.v3" +) + +// AuthorityLibraryPrefix is the module path every published authority library +// shares: the conformance suite, the core that decides, the client, delivery +// and resources. +const AuthorityLibraryPrefix = "github.com/codefly-dev/authority/libraries/" + +// ConformanceLibrary is the shared suite itself; a service that pins no suite +// has nothing proving what it declares. +const ConformanceLibrary = AuthorityLibraryPrefix + "conformance/go" + +// authorityPublisher is the publisher of every interface codefly-dev/authority +// carries under interfaces//interface.codefly.yaml. +const authorityPublisher = "codefly.dev" + +// HostInterfaceDeclaration is one `implements` entry of a service's interface +// endpoint: /@. +type HostInterfaceDeclaration struct { + Service string + Publisher string + Name string + Version string +} + +// ID is the declaration as a consumer names it, publisher/name. +func (d HostInterfaceDeclaration) ID() string { return d.Publisher + "/" + d.Name } + +// AuthorityPin is the one codefly-dev/authority revision a go.mod pins its +// authority libraries at, read from their pseudo-versions. +type AuthorityPin struct { + // Revision is the commit the pseudo-versions abbreviate. + Revision string + // Libraries are the pinned authority modules, by path, sorted. + Libraries []string +} + +// PublishedInterface is interfaces//interface.codefly.yaml as authority +// publishes it at a revision. +type PublishedInterface struct { + Publisher string `yaml:"publisher"` + Name string `yaml:"name"` + Version string `yaml:"version"` +} + +// HostInterfaceFinding is the gate's answer for one declaration. +type HostInterfaceFinding struct { + Interface string + Declared string + Published string + // Failure says the declaration is not proven; otherwise Reason, when set, + // is a notice. + Failure bool + Reason string +} + +// HostInterfaceOptions name what the gate reads. +type HostInterfaceOptions struct { + // ModuleRoot holds module.codefly.yaml and services//. + ModuleRoot string + // InterfacesDir is authority's interfaces/ tree at the pinned revision. + InterfacesDir string + // Revision, when given, is the commit InterfacesDir was read at; it must be + // the commit the go.mod pseudo-versions abbreviate. + Revision string +} + +// DeclaredHostInterfaces reads every `implements` entry of the module's +// interface endpoints, through core's own module loader. +func DeclaredHostInterfaces(moduleRoot string) ([]HostInterfaceDeclaration, error) { + mod, err := resources.LoadModuleFromDir(context.Background(), moduleRoot) + if err != nil { + return nil, fmt.Errorf("read %s: %w", filepath.Join(moduleRoot, resources.ModuleConfigurationName), err) + } + var declared []HostInterfaceDeclaration + seen := map[string]bool{} + if mod.Interface == nil { + return nil, nil + } + for _, endpoint := range mod.Interface.Endpoints { + for _, entry := range endpoint.Implements { + d, err := parseDeclaration(endpoint.Service, entry) + if err != nil { + return nil, err + } + key := d.Service + " " + d.ID() + "@" + d.Version + if seen[key] { + continue + } + seen[key] = true + declared = append(declared, d) + } + } + sort.Slice(declared, func(i, j int) bool { + if declared[i].Service != declared[j].Service { + return declared[i].Service < declared[j].Service + } + return declared[i].ID() < declared[j].ID() + }) + return declared, nil +} + +func parseDeclaration(service, entry string) (HostInterfaceDeclaration, error) { + at := strings.LastIndex(entry, "@") + slash := strings.Index(entry, "/") + if at < 0 || slash < 0 || slash > at || slash == 0 || at == len(entry)-1 { + return HostInterfaceDeclaration{}, fmt.Errorf("service %s implements %q, which is not /@", service, entry) + } + return HostInterfaceDeclaration{Service: service, Publisher: entry[:slash], Name: entry[slash+1 : at], Version: entry[at+1:]}, nil +} + +// ReadAuthorityPin reads the authority revision a go.mod pins: every direct +// requirement under AuthorityLibraryPrefix must be a pseudo-version, and all of +// them must abbreviate one commit, because the suite, the core it decides +// with and the client are run together. A replace or an exclude of an +// authority library is refused: the require line would then name nothing the +// suite runs (authority's own services replace these libraries with local +// directories, which is right there and never here). +func ReadAuthorityPin(goModPath string) (AuthorityPin, error) { + data, err := os.ReadFile(goModPath) + if err != nil { + return AuthorityPin{}, err + } + file, err := modfile.Parse(goModPath, data, nil) + if err != nil { + return AuthorityPin{}, err + } + for _, replace := range file.Replace { + if strings.HasPrefix(replace.Old.Path, AuthorityLibraryPrefix) { + return AuthorityPin{}, fmt.Errorf("%s replaces %s with %s, so its require line names nothing the suite runs; a replaced authority library is refused", goModPath, replace.Old.Path, strings.TrimSpace(replace.New.Path+" "+replace.New.Version)) + } + } + for _, exclude := range file.Exclude { + if strings.HasPrefix(exclude.Mod.Path, AuthorityLibraryPrefix) { + return AuthorityPin{}, fmt.Errorf("%s excludes %s %s, which moves the authority libraries away from the revision the require lines name; an excluded authority library is refused", goModPath, exclude.Mod.Path, exclude.Mod.Version) + } + } + var pin AuthorityPin + revisions := map[string][]string{} + for _, req := range file.Require { + if req.Indirect || !strings.HasPrefix(req.Mod.Path, AuthorityLibraryPrefix) { + continue + } + if !module.IsPseudoVersion(req.Mod.Version) { + return AuthorityPin{}, fmt.Errorf("%s pins %s at %s, which names no commit: this gate reads the revision from a pseudo-version, so a tagged authority library needs the gate taught to resolve its tag", goModPath, req.Mod.Path, req.Mod.Version) + } + rev, err := module.PseudoVersionRev(req.Mod.Version) + if err != nil { + return AuthorityPin{}, fmt.Errorf("%s pins %s at %s: %w", goModPath, req.Mod.Path, req.Mod.Version, err) + } + revisions[rev] = append(revisions[rev], shortLibraries([]string{req.Mod.Path})...) + pin.Libraries = append(pin.Libraries, req.Mod.Path) + } + if len(pin.Libraries) == 0 { + return AuthorityPin{}, fmt.Errorf("%s pins no %s* library, so no conformance suite proves what the service declares", goModPath, AuthorityLibraryPrefix) + } + if len(revisions) > 1 { + var split []string + for rev, libraries := range revisions { + sort.Strings(libraries) + split = append(split, rev+" ("+strings.Join(libraries, ", ")+")") + } + sort.Strings(split) + return AuthorityPin{}, fmt.Errorf("%s pins the authority libraries at two revisions, %s: the suite, the core it decides with and the client are one revision or the pass proves nothing", goModPath, strings.Join(split, " and ")) + } + sort.Strings(pin.Libraries) + if !slices.Contains(pin.Libraries, ConformanceLibrary) { + return AuthorityPin{}, fmt.Errorf("%s pins no %s, so no shared suite runs against what the service declares", goModPath, ConformanceLibrary) + } + for rev := range revisions { + pin.Revision = rev + } + return pin, nil +} + +// ReadPublishedInterfaces reads //interface.codefly.yaml for every +// interface authority publishes at the revision dir was read from. +func ReadPublishedInterfaces(dir string) (map[string]PublishedInterface, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + published := map[string]PublishedInterface{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name(), "interface.codefly.yaml") + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + var iface PublishedInterface + if err := yaml.Unmarshal(data, &iface); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if iface.Name != entry.Name() { + return nil, fmt.Errorf("%s names interface %q, not %q", path, iface.Name, entry.Name()) + } + published[iface.Publisher+"/"+iface.Name] = iface + } + if len(published) == 0 { + return nil, fmt.Errorf("%s publishes no interface (no /interface.codefly.yaml)", dir) + } + return published, nil +} + +var documentedInterface = regexp.MustCompile(`codefly\.dev/([a-z][a-z0-9-]*)@([0-9]+\.[0-9]+\.[0-9]+)`) + +// DocumentedHostInterfaces reads the versions a service's HOST_INTERFACE.md +// names in its opening section, before its first heading: the sentence that +// tells a reader what the service implements, held here to the manifest so it +// cannot drift from it. A service with no such document documents nothing. +func DocumentedHostInterfaces(path string) (map[string][]string, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + opening, _, _ := strings.Cut(string(data), "\n## ") + documented := map[string][]string{} + for _, match := range documentedInterface.FindAllStringSubmatch(opening, -1) { + id := authorityPublisher + "/" + match[1] + if !slices.Contains(documented[id], match[2]) { + documented[id] = append(documented[id], match[2]) + } + } + return documented, nil +} + +// CompareHostInterfaces judges each declaration against what the pinned +// revision publishes and what the service's document says. A failure is a +// declared version above the published one, a declared version in another +// major.minor than the published one (no suite of that minor ran), an +// interface the revision does not publish, a version that is not semver, or a +// document naming another version; a declared patch below the published one, +// within the same major.minor, is a notice. +func CompareHostInterfaces(declared []HostInterfaceDeclaration, published map[string]PublishedInterface, documented map[string][]string) []HostInterfaceFinding { + var findings []HostInterfaceFinding + for _, d := range declared { + finding := HostInterfaceFinding{Interface: d.ID(), Declared: d.Version} + if d.Publisher != authorityPublisher { + finding.Reason = "not published by codefly-dev/authority; no shared suite here proves it" + findings = append(findings, finding) + continue + } + p, ok := published[d.ID()] + if !ok { + finding.Failure = true + finding.Reason = "the pinned authority revision publishes no such interface, so no suite proves the declaration" + findings = append(findings, finding) + continue + } + finding.Published = p.Version + declaredVersion, err := semver.StrictNewVersion(d.Version) + if err != nil { + finding.Failure = true + finding.Reason = fmt.Sprintf("the declared version is not semver: %v", err) + findings = append(findings, finding) + continue + } + publishedVersion, err := semver.StrictNewVersion(p.Version) + if err != nil { + finding.Failure = true + finding.Reason = fmt.Sprintf("the published version is not semver: %v", err) + findings = append(findings, finding) + continue + } + switch declaredVersion.Compare(publishedVersion) { + case 1: + finding.Failure = true + finding.Reason = "the declaration claims a version the pinned suite does not prove; move the pin to a revision publishing it and pass that suite first" + case -1: + if declaredVersion.Major() != publishedVersion.Major() || declaredVersion.Minor() != publishedVersion.Minor() { + finding.Failure = true + finding.Reason = fmt.Sprintf("the pinned suite is %s's and no %d.%d suite ran, so the declaration is unproven; pin a revision publishing %d.%d, or declare %s once its suite is green", + p.Version, declaredVersion.Major(), declaredVersion.Minor(), declaredVersion.Major(), declaredVersion.Minor(), p.Version) + } else { + finding.Reason = fmt.Sprintf("the pinned suite proves %s, a patch above the declaration; declaring it is a manifest change backed by this pin", p.Version) + } + } + if versions, ok := documented[d.ID()]; ok { + if len(versions) != 1 || versions[0] != d.Version { + finding.Failure = true + finding.Reason = fmt.Sprintf("HOST_INTERFACE.md names %s where the manifest declares %s", strings.Join(versions, " and "), d.Version) + } + } else if documented != nil { + finding.Failure = true + finding.Reason = "HOST_INTERFACE.md does not name this interface in its opening section" + } + findings = append(findings, finding) + } + return findings +} + +// VerifyHostInterfaces runs the gate over the module: every service declaring +// a host interface, its go.mod pin, the interfaces published at that pin and +// its HOST_INTERFACE.md. It prints one line per declaration to out and returns +// an error naming every failure. +func VerifyHostInterfaces(options HostInterfaceOptions, out io.Writer) error { + if options.InterfacesDir == "" { + return errors.New("verify-host-interfaces needs --interfaces, authority's interfaces/ tree at the pinned revision") + } + declared, err := DeclaredHostInterfaces(options.ModuleRoot) + if err != nil { + return err + } + if len(declared) == 0 { + return fmt.Errorf("%s declares no host interface; the gate measures nothing", filepath.Join(options.ModuleRoot, resources.ModuleConfigurationName)) + } + published, err := ReadPublishedInterfaces(options.InterfacesDir) + if err != nil { + return err + } + byService := map[string][]HostInterfaceDeclaration{} + var services []string + for _, d := range declared { + if _, ok := byService[d.Service]; !ok { + services = append(services, d.Service) + } + byService[d.Service] = append(byService[d.Service], d) + } + sort.Strings(services) + var failures []string + for _, service := range services { + goMod := filepath.Join(options.ModuleRoot, "services", service, "code", "go.mod") + pin, err := ReadAuthorityPin(goMod) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("service %s declares host interfaces but has no Go module at %s, so no pinned suite proves them", service, goMod) + } + return err + } + if options.Revision != "" && !strings.HasPrefix(options.Revision, pin.Revision) { + return fmt.Errorf("the interfaces were read at %s, but %s pins %s", options.Revision, goMod, pin.Revision) + } + documented, err := DocumentedHostInterfaces(filepath.Join(options.ModuleRoot, "services", service, "HOST_INTERFACE.md")) + if err != nil { + return err + } + fmt.Fprintf(out, "%s: host interfaces declared by %s, held to authority %s (%s)\n", service, goMod, pin.Revision, strings.Join(shortLibraries(pin.Libraries), ", ")) + for _, finding := range CompareHostInterfaces(byService[service], published, documented) { + verdict := "ok" + switch { + case finding.Failure: + verdict = "FAIL: " + finding.Reason + failures = append(failures, fmt.Sprintf("%s %s declared %s, published %s: %s", service, finding.Interface, finding.Declared, orNone(finding.Published), finding.Reason)) + case finding.Reason != "": + verdict = "notice: " + finding.Reason + } + fmt.Fprintf(out, " %-28s declared %-8s published %-8s %s\n", finding.Interface, finding.Declared, orNone(finding.Published), verdict) + } + } + if len(failures) > 0 { + return fmt.Errorf("%d host interface declaration(s) are not proven by the pinned suite:\n %s", len(failures), strings.Join(failures, "\n ")) + } + return nil +} + +func shortLibraries(paths []string) []string { + out := make([]string, 0, len(paths)) + for _, path := range paths { + out = append(out, strings.TrimSuffix(strings.TrimPrefix(path, AuthorityLibraryPrefix), "/go")) + } + return out +} + +func orNone(version string) string { + if version == "" { + return "none" + } + return version +} diff --git a/module/tools/modulepackage/host_interfaces_test.go b/module/tools/modulepackage/host_interfaces_test.go new file mode 100644 index 000000000..aaa54c9b2 --- /dev/null +++ b/module/tools/modulepackage/host_interfaces_test.go @@ -0,0 +1,317 @@ +package modulepackage + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func publishedFixture(t *testing.T, versions map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, version := range versions { + writeFile(t, filepath.Join(dir, name, "interface.codefly.yaml"), + "kind: interface\npublisher: codefly.dev\nname: "+name+"\nversion: "+version+"\ntype: connect\n") + } + return dir +} + +// The real tree: accounts declares the authority interface, pins the shared +// suite at one revision, and its document names what the manifest declares. +// This half of the gate needs no network, so it runs with the package tests; +// the published half runs in CI against authority's tree at the pin. +func TestAccountsDeclaresHostInterfacesItPinsOneSuiteFor(t *testing.T) { + moduleRoot := findModuleRoot(t) + declared, err := DeclaredHostInterfaces(moduleRoot) + if err != nil { + t.Fatal(err) + } + var authority *HostInterfaceDeclaration + for i := range declared { + if declared[i].Service == "accounts" && declared[i].ID() == "codefly.dev/authority" { + authority = &declared[i] + } + } + if authority == nil { + t.Fatalf("accounts declares no codefly.dev/authority interface; declared %+v", declared) + } + pin, err := ReadAuthorityPin(filepath.Join(moduleRoot, "services", "accounts", "code", "go.mod")) + if err != nil { + t.Fatal(err) + } + if len(pin.Revision) < 12 { + t.Fatalf("pin revision %q is not a commit abbreviation", pin.Revision) + } + documented, err := DocumentedHostInterfaces(filepath.Join(moduleRoot, "services", "accounts", "HOST_INTERFACE.md")) + if err != nil { + t.Fatal(err) + } + if documented == nil { + t.Fatal("accounts has no HOST_INTERFACE.md to hold to the manifest") + } + for _, d := range declared { + if d.Service != "accounts" { + continue + } + if versions := documented[d.ID()]; len(versions) != 1 || versions[0] != d.Version { + t.Errorf("HOST_INTERFACE.md names %s %v where the manifest declares %s", d.ID(), versions, d.Version) + } + } +} + +func TestReadAuthorityPinRequiresOnePseudoVersionedRevision(t *testing.T) { + cases := map[string]struct { + goMod string + want string // substring of the error, or "" for success + }{ + "one revision": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f // indirect + github.com/example/other v1.2.0 +) + +replace github.com/example/other => ../other +`}, + "a replaced library": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad +) + +replace github.com/codefly-dev/authority/libraries/conformance/go => ../../../../../libraries/conformance/go +`, want: "replaces github.com/codefly-dev/authority/libraries/conformance/go with ../../../../../libraries/conformance/go"}, + "an excluded library": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad +) + +exclude github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261026111022-6870db87da8b +`, want: "excludes github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261026111022-6870db87da8b"}, + "two revisions": {goMod: `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261026111022-6870db87da8b +) +`, want: "two revisions, 6870db87da8b (conformance) and dea37afac9ad (authority-core)"}, + "a tag": {goMod: `module accounts + +require github.com/codefly-dev/authority/libraries/conformance/go v0.1.0 +`, want: "names no commit"}, + "no suite": {goMod: `module accounts + +require github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad +`, want: "pins no github.com/codefly-dev/authority/libraries/conformance/go"}, + "no authority": {goMod: "module accounts\n\nrequire github.com/google/uuid v1.6.0\n", want: "pins no github.com/codefly-dev/authority/libraries/* library"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "go.mod") + writeFile(t, path, tc.goMod) + pin, err := ReadAuthorityPin(path) + if tc.want == "" { + if err != nil { + t.Fatal(err) + } + if pin.Revision != "dea37afac9ad" || len(pin.Libraries) != 3 { + t.Fatalf("pin %+v", pin) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error %v, want it to contain %q", err, tc.want) + } + }) + } +} + +func TestReadPublishedInterfacesReadsEachInterfaceByItsDirectory(t *testing.T) { + dir := publishedFixture(t, map[string]string{"authority": "0.3.5", "session": "0.2.1"}) + writeFile(t, filepath.Join(dir, "notes.md"), "not an interface\n") + published, err := ReadPublishedInterfaces(dir) + if err != nil { + t.Fatal(err) + } + if published["codefly.dev/authority"].Version != "0.3.5" || published["codefly.dev/session"].Version != "0.2.1" || len(published) != 2 { + t.Fatalf("published %+v", published) + } + writeFile(t, filepath.Join(dir, "artifact", "interface.codefly.yaml"), "publisher: codefly.dev\nname: authority\nversion: 0.1.0\n") + if _, err := ReadPublishedInterfaces(dir); err == nil || !strings.Contains(err.Error(), `names interface "authority", not "artifact"`) { + t.Fatalf("a directory publishing another interface's manifest was read: %v", err) + } +} + +func TestDocumentedHostInterfacesReadsTheOpeningSectionAlone(t *testing.T) { + path := filepath.Join(t.TempDir(), "HOST_INTERFACE.md") + writeFile(t, path, "# Title\n\naccounts implements `codefly.dev/authority@0.3.5` and `codefly.dev/session@0.2.1`.\n\n## Later\n\nThe keys arrived in `codefly.dev/authority@0.3.3`.\n") + documented, err := DocumentedHostInterfaces(path) + if err != nil { + t.Fatal(err) + } + if got := documented["codefly.dev/authority"]; len(got) != 1 || got[0] != "0.3.5" { + t.Fatalf("authority %v: a later section's historical version was read as the claim", got) + } + if got := documented["codefly.dev/session"]; len(got) != 1 || got[0] != "0.2.1" { + t.Fatalf("session %v", got) + } + none, err := DocumentedHostInterfaces(filepath.Join(t.TempDir(), "absent.md")) + if err != nil || none != nil { + t.Fatalf("an absent document: %v %v", none, err) + } +} + +func TestCompareHostInterfacesJudgesEachDeclaration(t *testing.T) { + published := map[string]PublishedInterface{ + "codefly.dev/authority": {Publisher: "codefly.dev", Name: "authority", Version: "0.3.5"}, + "codefly.dev/session": {Publisher: "codefly.dev", Name: "session", Version: "0.2.1"}, + "codefly.dev/installation": {Publisher: "codefly.dev", Name: "installation", Version: "0.4.0"}, + "codefly.dev/composition": {Publisher: "codefly.dev", Name: "composition", Version: "0.2.0"}, + } + declare := func(name, version string) HostInterfaceDeclaration { + return HostInterfaceDeclaration{Service: "accounts", Publisher: "codefly.dev", Name: name, Version: version} + } + findings := CompareHostInterfaces([]HostInterfaceDeclaration{ + declare("authority", "0.3.5"), // equal: ok + declare("session", "0.2.0"), // a patch below, same major.minor: a notice + declare("installation", "0.5.0"), // above: not proven + declare("installation", "0.3.0"), // another major.minor below: no 0.3 suite ran + declare("authority", "0.2.9"), // another major.minor below, higher patch: no 0.2 suite ran + declare("artifact", "0.1.0"), // not published at the pin + declare("composition", "0.2"), // not semver + {Service: "accounts", Publisher: "example.com", Name: "other", Version: "1.0.0"}, + }, published, nil) + if len(findings) != 8 { + t.Fatalf("%d findings: %+v", len(findings), findings) + } + expect := []struct { + failure bool + reason string + }{ + {false, ""}, + {false, "the pinned suite proves 0.2.1"}, + {true, "claims a version the pinned suite does not prove"}, + {true, "no 0.3 suite ran"}, + {true, "no 0.2 suite ran"}, + {true, "publishes no such interface"}, + {true, "not semver"}, + {false, "not published by codefly-dev/authority"}, + } + for i, want := range expect { + got := findings[i] + if got.Failure != want.failure || !strings.Contains(got.Reason, want.reason) { + t.Errorf("%s: failure=%v reason=%q, want failure=%v reason containing %q", got.Interface, got.Failure, got.Reason, want.failure, want.reason) + } + } +} + +func TestCompareHostInterfacesHoldsTheDocumentToTheManifest(t *testing.T) { + published := map[string]PublishedInterface{ + "codefly.dev/authority": {Publisher: "codefly.dev", Name: "authority", Version: "0.3.5"}, + "codefly.dev/session": {Publisher: "codefly.dev", Name: "session", Version: "0.2.1"}, + } + declared := []HostInterfaceDeclaration{ + {Service: "accounts", Publisher: "codefly.dev", Name: "authority", Version: "0.3.5"}, + {Service: "accounts", Publisher: "codefly.dev", Name: "session", Version: "0.2.1"}, + } + agreeing := map[string][]string{"codefly.dev/authority": {"0.3.5"}, "codefly.dev/session": {"0.2.1"}} + for _, f := range CompareHostInterfaces(declared, published, agreeing) { + if f.Failure { + t.Errorf("%s failed with an agreeing document: %s", f.Interface, f.Reason) + } + } + drifted := map[string][]string{"codefly.dev/authority": {"0.3.5"}, "codefly.dev/session": {"0.2.0"}} + findings := CompareHostInterfaces(declared, published, drifted) + if !findings[1].Failure || !strings.Contains(findings[1].Reason, "names 0.2.0 where the manifest declares 0.2.1") { + t.Fatalf("a document naming another version passed: %+v", findings[1]) + } + silent := map[string][]string{"codefly.dev/authority": {"0.3.5"}} + if findings := CompareHostInterfaces(declared, published, silent); !findings[1].Failure || !strings.Contains(findings[1].Reason, "does not name this interface") { + t.Fatalf("a document naming no version passed: %+v", findings[1]) + } +} + +// End to end over a module fixture: the manifest, the service's go.mod and its +// document, against a published tree. A declaration above the pin fails the +// run and is named with both versions. +func TestVerifyHostInterfacesFailsADeclarationAboveThePin(t *testing.T) { + moduleRoot := t.TempDir() + writeFile(t, filepath.Join(moduleRoot, "module.codefly.yaml"), `kind: module +name: fixture +interface: + endpoints: + - service: accounts + endpoint: connect + visibility: internal + implements: + - codefly.dev/authority@0.3.5 + - codefly.dev/session@0.2.1 +services: + - name: accounts +`) + // Core's module loader resolves every service an interface entry names and + // requires the endpoint it serves on, so the fixture carries the manifest. + writeFile(t, filepath.Join(moduleRoot, "services", "accounts", "service.codefly.yaml"), `name: accounts +version: 0.0.0 +agent: + kind: codefly:service + name: go-grpc + version: 0.1.72 + publisher: codefly.dev +endpoints: + - name: connect + visibility: internal +`) + writeFile(t, filepath.Join(moduleRoot, "services", "accounts", "code", "go.mod"), `module accounts + +require ( + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad +) +`) + writeFile(t, filepath.Join(moduleRoot, "services", "accounts", "HOST_INTERFACE.md"), + "# Host interfaces\n\naccounts implements `codefly.dev/authority@0.3.5` and `codefly.dev/session@0.2.1`.\n") + + var out bytes.Buffer + err := VerifyHostInterfaces(HostInterfaceOptions{ModuleRoot: moduleRoot, + InterfacesDir: publishedFixture(t, map[string]string{"authority": "0.3.5", "session": "0.2.1"}), + Revision: "dea37afac9adda288c7b9fc7df0dc42a2746e47f"}, &out) + if err != nil { + t.Fatalf("an agreeing tree failed: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "codefly.dev/authority") || !strings.Contains(out.String(), "dea37afac9ad") { + t.Fatalf("the report names neither the interface nor the pin:\n%s", out.String()) + } + + out.Reset() + err = VerifyHostInterfaces(HostInterfaceOptions{ModuleRoot: moduleRoot, + InterfacesDir: publishedFixture(t, map[string]string{"authority": "0.3.4", "session": "0.2.1"})}, &out) + if err == nil || !strings.Contains(err.Error(), "codefly.dev/authority declared 0.3.5, published 0.3.4") { + t.Fatalf("a declaration above the pin passed: %v\n%s", err, out.String()) + } + + err = VerifyHostInterfaces(HostInterfaceOptions{ModuleRoot: moduleRoot, + InterfacesDir: publishedFixture(t, map[string]string{"authority": "0.3.5", "session": "0.2.1"}), + Revision: "0000000000000000000000000000000000000000"}, &out) + if err == nil || !strings.Contains(err.Error(), "pins dea37afac9ad") { + t.Fatalf("a tree read at another revision than the pin passed: %v", err) + } +} diff --git a/scripts/ci/host-interface-conformance-main.sh b/scripts/ci/host-interface-conformance-main.sh index 08f5bb837..8727f5ba5 100755 --- a/scripts/ci/host-interface-conformance-main.sh +++ b/scripts/ci/host-interface-conformance-main.sh @@ -5,13 +5,22 @@ # adds, or a rule the authority core changes, reaches accounts on its next pull # request instead of on the next re-pin. A main that does not contain the pin # (the pin is a branch not merged yet) runs at the pin and says so. +# +# Before the suite, at the pin and before any re-pin, the declaration gate: the +# interface versions module.codefly.yaml declares accounts implements +# (`implements: codefly.dev/@`) are held to the version each +# interface publishes at the pinned revision, the revision whose suite accounts' +# own tests run. "Declares 0.3.5" and "passes 0.3.5's suite" are one fact, or +# this job is red: a declaration above the published version, or in another +# major.minor than it, names a suite that did not run. A patch below it, within +# the same major.minor, is a notice. set -euo pipefail repo_root="$(cd "$(dirname "$0")/../.." && pwd)" cd "$repo_root/module/services/accounts/code" export GOWORK=off pinned="$(go list -m -f '{{.Version}}' github.com/codefly-dev/authority/libraries/authority-core/go)" -pinned_sha="${pinned##*-}" +pinned_short="${pinned##*-}" # Ancestry is read from a blobless clone of authority's main, never from the # GitHub API: an unauthenticated API call shares a small per-address budget on # hosted runners, and a check that fails when that budget runs out is a flake. @@ -30,20 +39,45 @@ for attempt in 1 2 3; do fi sleep $((attempt * 10)) done +# The pinned commit in full. A pin main contains resolves in the clone. A pin +# on an unmerged authority branch does not: its full id is then read from the +# module cache's origin record (written when the module was downloaded, so +# this reads nothing new) and the commit is fetched by that id, which GitHub +# serves for any reachable commit. A commit authority no longer carries leaves +# the declaration unprovable at its source, which is a red, not a skip. +if ! pinned_commit="$(git -C "$authority/main.git" rev-parse --verify --quiet "${pinned_short}^{commit}")"; then + pinned_commit="$(go mod download -json "github.com/codefly-dev/authority/libraries/authority-core/go@${pinned}" \ + | sed -n 's/^[[:space:]]*"Hash": *"\([0-9a-f]\{40\}\)".*$/\1/p')" + if [ -z "$pinned_commit" ]; then + echo "::error::cannot resolve the commit behind the authority pin ${pinned}: main does not contain ${pinned_short} and the module cache records no origin for it" + exit 1 + fi + if ! git -C "$authority/main.git" fetch -q --filter=blob:none origin "$pinned_commit"; then + echo "::error::codefly-dev/authority no longer carries the pinned commit ${pinned_commit}; the declared interface versions cannot be read at the pin" + exit 1 + fi +fi main_sha="$(git -C "$authority/main.git" rev-parse --short=12 HEAD)" -if git -C "$authority/main.git" merge-base --is-ancestor "$pinned_sha" "$main_sha" 2>/dev/null; then +if git -C "$authority/main.git" merge-base --is-ancestor "$pinned_commit" "$main_sha"; then status="ahead" - [ "$(git -C "$authority/main.git" rev-parse --short=12 "$pinned_sha")" = "$main_sha" ] && status="identical" + [ "$(git -C "$authority/main.git" rev-parse --short=12 "$pinned_commit")" = "$main_sha" ] && status="identical" else status="diverged" fi -echo "authority pinned at ${pinned_sha}; main at ${main_sha}; main is ${status} of the pin" +echo "authority pinned at ${pinned_commit:0:12}; main at ${main_sha}; main is ${status} of the pin" + +# The declaration gate reads interfaces//interface.codefly.yaml at the +# pin; the blobless clone fetches those few blobs on demand. +mkdir -p "$authority/pinned" +git -C "$authority/main.git" archive --format=tar "$pinned_commit" interfaces | tar -x -C "$authority/pinned" +(cd "$repo_root/module/tools" && go run ./cmd/module-package verify-host-interfaces --module .. --interfaces "$authority/pinned/interfaces" --revision "$pinned_commit") + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then for library in authority-core conformance authority-client delivery resources; do go get "github.com/codefly-dev/authority/libraries/${library}/go@${main_sha}" done go mod tidy else - echo "::notice::authority main does not contain the pinned revision ${pinned_sha}; the suite runs at the pin" + echo "::notice::authority main does not contain the pinned revision ${pinned_commit:0:12}; the suite runs at the pin" fi go test -count=1 -timeout 30m -run '^TestAccountsPassesTheHostInterfaceConformanceSuite$' ./pkg/adapters