Skip to content

add the rpc namespace: connect generation with buf, no docker - #17

Merged
hugowetterberg merged 11 commits into
mainfrom
feature/rpc-namespace
Sep 6, 2026
Merged

add the rpc namespace: connect generation with buf, no docker#17
hugowetterberg merged 11 commits into
mainfrom
feature/rpc-namespace

Conversation

@hugowetterberg

Copy link
Copy Markdown
Contributor

Summary

The platform is moving from Twirp to Connect and will serve both for a long dual-stack period. This adds the rpc mage namespace that generates the Connect code alongside the messages, and replaces the elephant-twirptools Docker image as the way the fleet runs its protobuf generators.

What rpc:generate does. buf is the compiler and every plugin runs out of a module pinned in the rpc package, so generating needs no Docker, installs nothing and takes nothing off PATH. Per service it writes service.pb.go, <pkg>connect/service.connect.go (connect-go), <pkg>connect/service.elephant.go (the adapters from protoc-gen-elephant-rpc in elephantine that keep the plain service interface as the contract), service.rpc.go (that interface, when Twirp is off) and service.twirp.go when rpc.Twirp = true. Services are discovered in both layouts, <root>/*/service.proto and <root>/*/v*/service.proto, and rpc:stub scaffolds the versioned one. rpc:vendorProto copies a cross-module .proto (the fleet's one case is newsdoc/newsdoc.proto from elephant-api) into rpc/vendor and maintains the buf.yaml buf needs for it, since a buf workspace cannot reach into the module cache the way protoc was handed a --proto_path.

Reproducibility. protoc-gen-twirp is a +incompatible module with no go.mod, and its gzipped descriptor differs between Go 1.26 and 1.27, so it runs from an embedded module with a complete go.sum under a pinned toolchain (rpc.GeneratorToolchain), and a test generates under both toolchains and compares. GOFLAGS and GOTOOLCHAIN are set for the generator invocations rather than inherited. Two configurations that would produce uncompilable code are refused: rpc.Twirp together with the plugin's interface=true, and a go_package naming an import path other than the one the code lands under. An unpinned protoc-gen-elephant-rpc is an error, not a silent skip.

Removed. OpenAPI generation does not carry over: nobody consumed the specifications, they described the Twirp surface only, and the generator cannot run under buf. There is no rpc:release; a release is a plain tag. The twirp namespace stays, deprecated, until the image is retired.

Go 1.27.1 is the module's floor, recorded as a breaking lead-in since every magefile importing these targets needs that toolchain.

Release coupling. rpc.ElephantRPCVersion currently pins protoc-gen-elephant-rpc to a pseudo-version of elephantine's feature/connect-rpc branch. The order is: tag this module with that pin, tag elephantine, re-pin here to the tag and tag again, then the declaration modules. The CHANGELOG and README say so.

Adopted on feature branches by elephant-api, elephant-tt-api, elephant-public-api, bifrost-api and elephantine, all of which regenerate to a zero-byte diff against their committed output.

The twirp namespace runs protoc inside the elephant-twirptools image, which
exists for one reason: protoc is a C++ binary, so pinning it meant shipping a
container. Everything else in the image is Go. buf is a pure-Go compiler that
drives the same plugins, so the whole toolchain becomes "go run
<module>@<version>" with the versions as constants here, and generating needs
no Docker, installs nothing and takes nothing off PATH. That is the same pin
the image was, moved into version control, and it is what lets the fleet
generate Connect code alongside Twirp during the migration off it.

The generation template is passed to buf inline with --template, so no
repository commits a buf.gen.yaml and no repository can drift from the pinned
plugin set. A buf.yaml is written only where the workspace needs one, which is
a repository that vendors a proto file: a workspace cannot reach outside its
root, so the trick of handing protoc a dependency's module directory as a
--proto_path has no equivalent, and rpc:vendorProto copies the file in and
makes its directory a module root of its own.

protoc-gen-elephant-rpc is in the plugin set but has no release to pin yet, so
an empty version means the plugin is skipped, and ELEPHANT_RPC_PLUGIN runs it
from a checkout while it is being written.

Two things could not stay as they were. protoc-gen-openapi3 is built against a
protobuf-go that reports FEATURE_SUPPORTS_EDITIONS without a minimum edition,
which protoc accepts and buf rejects outright, so that plugin is driven
directly from the target with the descriptors buf compiled - the same exchange
buf would have made, and it produces byte-identical specifications. And a
go_package written as a relative path, which the stub template produced, is not
an import path, which never mattered while everything was generated into one
directory and does the moment Connect generates into a subpackage; rather than
making every repository edit its .proto files, the import path is derived from
the module path and passed to the plugins.

Checked against copies of elephant-collab and elephant-api: the OpenAPI
specifications come out byte-identical, the twirp files differ only in their
gzipped descriptor blob, and the .pb.go differences are the protoc-gen-go
version bump the image was holding back.
protoc-gen-openapi3 documents the Twirp surface: the paths it emits are
"/twirp/<pkg>.<Service>/<Method>" and the errors it declares are Twirp's. With
Twirp off by default and OpenAPI on, the first rpc:generate in a new
Connect-only repository committed a specification of an API that repository
does not serve — and those specifications are linked from every service README
and read by external customers, so the wrong document is worse than none.

rpc.OpenAPI becomes an OpenAPISetting whose zero value follows Twirp, with
OpenAPIOn and OpenAPIOff for the repositories that want to decide for
themselves. RPC_OPENAPI still overrides it for a single run. Nothing changes
for a repository that serves /twirp/: it sets rpc.Twirp and keeps its
specifications.
The plugin's adapters take and return the plain service interface, so something
has to declare it. protoc-gen-twirp does while a repository still generates
Twirp, and nothing does when it stops: a Connect-only repository generating
with the plugin got adapters that could not compile. The interface option now
follows rpc.Twirp the way the OpenAPI specifications do, and
rpc.ElephantRPCOptions overrides it in either direction, for a repository that
wants the generated interface while it still serves the /twirp/ paths, or that
declares the interface itself.

The end-to-end test now generates the fixture with the real plugin in both
configurations, rather than only with the fixture stub. The stub says nothing
about the Go import path mappings reaching the plugin or about what it emits
compiling against the Connect code generated beside it, which is what the
wiring has to get right. The plugin has no released version to pin, so the test
is skipped unless ELEPHANT_RPC_PLUGIN names a checkout or a module version -
the same variable that generates a repository with it.
rpc:generate asked git for the last ancestor tag before it had loaded the
configuration, so it failed outright in a repository with no tags. That is
exactly where a new Connect-only service starts: with Twirp off the OpenAPI
step is skipped, the resolved version is never used, and the only thing
standing between the repository and its first generated code was a tag it has
no reason to have yet.

The version is now a resolver that generateAll calls behind the OpenAPI
guard, so it is asked for only when a specification is actually written.
Nobody consumed the OpenAPI 3 specifications, they described the Twirp
surface only, and protoc-gen-openapi3 cannot run under buf at all, so
the rpc namespace generates Go and nothing else. With nothing left to
stamp a version into, rpc:release goes too: a release is a plain git
tag, and no target consults git describe any more.

The module no longer needs google.golang.org/protobuf, which only the
hand-rolled driver for the OpenAPI plugin used.
…live under rpc

A repository that keeps its services in the repository root and vendors
an import got an rpc/vendor directory from rpc:vendorProto, and the mere
existence of rpc/ made the targets believe the sources had moved there.
Service discovery then found only the excluded vendor directory and
generation failed with "no rpc/*/service.proto files to generate from".

The proto root is rpc only when that directory holds something other
than the vendored protos. An empty rpc directory still counts, so a
repository can choose the layout before it has a service to put in it.
The regression test moves the vendoring fixture to the root layout.
The plugin was skipped for want of a version to pin. elephantine's
feature/connect-rpc branch is pushed, so its pseudo-version is the pin
until the work is tagged, and the end-to-end plugin test runs by default
rather than only under ELEPHANT_RPC_PLUGIN.
Go 1.27 is the fleet's floor from this round on, and mage is the module
every repository's magefile builds against, so it goes first. The
generation fixtures move with it; rpc/testdata/greeter is left on the old
floor deliberately, as the fixture for a repository that has not moved.
The generated Twirp code depended on the machine: protoc-gen-twirp has no
go.mod, so "go run <module>@<version>" resolved its dependencies afresh on
every run and compiled it with whatever toolchain was around, and the
gzipped descriptor it embeds comes out of compress/flate, which changed
between Go 1.26 and Go 1.27. The same fixture gave 180 and 176 bytes of
descriptor on two toolchains. It now runs out of a module carried here
with a complete go.sum, and GOTOOLCHAIN is set to a pin for every
generator, which is what makes the output the same on both. A toolchain
directive would not have done it, since that is a floor the go command
overrides with a newer local toolchain.

The same invocations drop a -mod flag out of GOFLAGS, which a repository
that vendors its dependencies would otherwise use to send the generators
looking for themselves in its vendor directory.

Two configurations are refused rather than generated, both of which
produced code that does not compile: Twirp together with the plugin's
interface=true, since they write the same declaration, and a go_package
naming an import path other than the one the generated code lands under.
An unpinned protoc-gen-elephant-rpc is an error too, where it used to skip
the plugin and report success with the adapters left as they were, and
whichever of service.rpc.go and service.twirp.go is not generated is
removed if an earlier configuration left it behind.

Service discovery takes the versioned layout as well, and rpc:stub
scaffolds it, so a repository can move to buf's convention a service at a
time. A hand-written buf.yaml that does not declare the vendored proto
root is reported rather than left as a workspace buf cannot resolve.
The plugin's own fixes for the review landed on feature/connect-rpc after
the previous pin was taken, and the corrected release order tags mage
first with this pseudo-version, then elephantine, then repoints this
constant at the tag.
The module moved to go 1.27.1 and the v2.12 binary the lint job used is
built with Go 1.26, which refuses a target newer than itself. v2.13 is
built with Go 1.27; the configuration verifies and the code lints clean
under it.
@hugowetterberg
hugowetterberg merged commit d7b6a0a into main Sep 6, 2026
2 checks passed
@hugowetterberg
hugowetterberg deleted the feature/rpc-namespace branch September 6, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant