| name | add-admin-v2-endpoint |
| description | Use when adding or modifying a Redpanda admin v2 ConnectRPC endpoint — authoring the protobuf under proto/redpanda/core/admin/v2/, wiring it into the Bazel build, implementing the C++ service handler under src/v/redpanda/admin/services/, registering it on the admin server, and regenerating Python bindings for ducktape integration tests. Covers both new RPCs on an existing service and brand-new services. |
Add an Admin v2 ConnectRPC Endpoint
This skill walks through every layer touched by a new admin v2 RPC: the .proto, the Bazel build, the C++ service implementation, registration on the admin server, and the Python bindings consumed by ducktape tests.
For generic protobuf authoring conventions (field options, message design, well-known types), the canonical reference is proto/redpanda/README.md. This skill assumes you've read that and focuses on what's specific to admin v2.
Reference example
Commit 04d3160c3a (cluster: add manual upgrade finalization with admin v2 RPC) is the cleanest minimal example — a new service with a single RPC. Skim these when unsure:
proto/redpanda/core/admin/v2/features.proto — the proto
proto/redpanda/core/admin/v2/BUILD — the BUILD entries (look at the features_proto / features_redpanda_proto pair)
src/v/redpanda/admin/services/features.h and features.cc — the C++ impl
src/v/redpanda/admin/services/BUILD — the features cc_library target
src/v/redpanda/application_admin.cc — the add_service registration
git show 04d3160c3a will print the full diff.
Procedure
1. Author the protobuf
Create or extend a .proto under proto/redpanda/core/admin/v2/. One file per service domain; the filename matches the dominant service (features.proto → FeaturesService).
Required header for a new file:
// (BSL copyright header — copy from features.proto:1-13)
syntax = "proto3";
package redpanda.core.admin.v2;
import "proto/redpanda/core/pbgen/options.proto";
import "proto/redpanda/core/pbgen/rpc.proto";
option (pbgen.cpp_namespace) = "proto::admin";
Notes:
package is always redpanda.core.admin.v2 for admin v2.
option (pbgen.cpp_namespace) = "proto::admin"; is mandatory and always exactly this value for admin v2 — the short form (pbgen.cpp_namespace, not the FQN redpanda.core.pbgen.cpp_namespace) is what's used in the tree.
- Import
rpc.proto only when the file defines a service.
Service / RPC block:
service MyService {
// One short paragraph explaining what the RPC does and any
// async/poll behavior the caller needs to know.
rpc MyMethod(MyMethodRequest) returns (MyMethodResponse) {
option (pbgen.rpc) = {
authz: SUPERUSER
};
}
}
message MyMethodRequest {}
message MyMethodResponse {}
- Every RPC must carry
option (pbgen.rpc) = { authz: ... }. Default to SUPERUSER for admin v2 — anything else (USER, PUBLIC) needs core-team approval (see proto/redpanda/core/pbgen/rpc.proto).
- Optional
http_route: "/v2/..." adds an alias HTTP path. The auto-generated /<package>.<Service>/<Method> route always works without it.
- Naming: messages are
<RpcName>Request / <RpcName>Response, one pair per RPC. Empty messages are fine.
Field-level options worth knowing (full reference in proto/redpanda/core/pbgen/options.proto):
[(pbgen.iobuf) = true] on string fields that may exceed 128 KiB — generates iobuf instead of ss::sstring. bytes is already iobuf-backed.
[(pbgen.ptr) = true] on message fields for indirection (recursive types or when you need to distinguish unset).
[debug_redact = true] on sensitive fields — suppresses them from fmt::formatter output (passwords, tokens, etc.).
Well-known types (google.protobuf.Duration, Timestamp, FieldMask) are supported; importing them requires the corresponding BUILD deps in step 2.
2. Wire up the Bazel build
Each .proto needs two stacked targets in proto/redpanda/core/admin/v2/BUILD. Append (alphabetical order in the file):
proto_library(
name = "myservice_proto",
srcs = ["myservice.proto"],
visibility = ["//visibility:public"],
deps = [
"//proto/redpanda/core/pbgen:options_proto",
"//proto/redpanda/core/pbgen:rpc_proto",
],
)
redpanda_proto_library(
name = "myservice_redpanda_proto",
protos = [":myservice_proto"],
visibility = ["//visibility:public"],
deps = [
],
)
Rules:
proto_library.deps mirrors import statements in the .proto file.
redpanda_proto_library.deps carries C++ deps the generated code needs (e.g. abseil for timestamps), plus any sibling _redpanda_proto targets for cross-proto imports.
- The pbgen plugin lives at
bazel/pbgen/; it generates <name>.proto.h and <name>.proto.cc with the message types, formatters, and service stubs.
3. Implement the C++ service
If extending an existing service (just adding a new RPC), open the matching src/v/redpanda/admin/services/<name>.{h,cc} and add the new handler. Skip to step 4 after.
For a brand-new service, create src/v/redpanda/admin/services/<name>.h:
#pragma once
#include "cluster/fwd.h"
#include "proto/redpanda/core/admin/v2/<name>.proto.h"
#include "redpanda/admin/proxy/client.h"
namespace admin {
class <name>_service_impl : public proto::admin::<name>_service {
public:
<name>_service_impl(
admin::proxy::client,
);
ss::future<proto::admin::<rpc>_response> <rpc>(
serde::pb::rpc::context, proto::admin::<rpc>_request) override;
private:
admin::proxy::client _proxy_client;
};
}
Naming conventions (CamelCase → snake_case translation):
- Service base class:
proto::admin::<service>_service (e.g. FeaturesService → features_service)
- Generated client (for inter-node proxying):
proto::admin::<service>_service_client
- Request / response types:
proto::admin::<rpc_name>_request / _response
- Handler method name: snake_case of the RPC name (e.g.
FinalizeUpgrade → finalize_upgrade)
- Implementation class:
admin::<service>_service_impl, in namespace admin (NOT proto::admin)
Handler signature is fixed: ss::future<...response> return type, serde::pb::rpc::context first arg, request second, override.
Then src/v/redpanda/admin/services/<name>.cc:
#include "redpanda/admin/services/<name>.h"
#include "redpanda/admin/services/utils.h"
#include "serde/protobuf/rpc.h"
#include <seastar/core/coroutine.hh>
namespace admin {
namespace {
ss::logger <name>log{"admin_api_server/<name>_service"};
}
<name>_service_impl::<name>_service_impl() : _proxy_client(...) {}
ss::future<proto::admin::<rpc>_response>
<name>_service_impl::<rpc>(
serde::pb::rpc::context ctx, proto::admin::<rpc>_request req) {
vlog(<name>log.trace, "<rpc>: {}", req);
co_return proto::admin::<rpc>_response{};
}
}
Patterns to know — read the existing service files for live examples:
- Validation failures throw typed exceptions declared in
src/v/serde/protobuf/rpc.h:241-287: invalid_argument_exception, failed_precondition_exception, not_found_exception, already_exists_exception, permission_denied_exception, unavailable_exception, unauthenticated_exception, etc. Each maps to a ConnectRPC/gRPC status code. Example: features.cc:46.
- Leader redirect:
admin::utils::redirect_to_leader(...) from services/utils.h returns an optional node_id; if set, proxy the call to that node using the generated _service_client. Pattern in features.cc:51-64.
- Proxying to peers:
_proxy_client.make_client_for_node<proto::admin::<service>_service_client>(node) for one target, or .make_clients_for_other_nodes<...>() for scatter-gather. Defined in redpanda/admin/proxy/client.h.
- License gating:
admin::utils::check_license(_feature_table.local()) if the RPC is enterprise-gated. See cluster.cc:51.
- Avoid proxy loops in scatter-gather handlers: gate with
proxy::is_proxied(ctx) (example in cluster.cc:56).
Add the BUILD entry to src/v/redpanda/admin/services/BUILD:
redpanda_cc_library(
name = "<name>",
srcs = ["<name>.cc"],
hdrs = ["<name>.h"],
implementation_deps = [
":utils",
],
deps = [
"//proto/redpanda/core/admin/v2:<name>_redpanda_proto",
"//src/v/cluster:fwd",
"//src/v/redpanda/admin/proxy:client",
"//src/v/serde/protobuf:rpc",
"@seastar",
],
)
Rule: types that appear in the .h belong in deps; collaborators only used in the .cc belong in implementation_deps.
4. Register the service
Two edits in src/v/redpanda/:
4a. src/v/redpanda/BUILD — add the new services library to the top-level redpanda library's deps list (kept sorted):
"//src/v/redpanda/admin/services:<name>",
4b. src/v/redpanda/application_admin.cc — two changes:
- Include the header (alphabetical with the other service includes):
#include "redpanda/admin/services/<name>.h"
- Inside
_admin.invoke_on_all([this, node_id](admin_server& s) { ... }), append an add_service call. create_client() is the local lambda defined at the top of the block; pass other collaborators via controller.get() or std::ref(...):
s.add_service(
std::make_unique<admin::<name>_service_impl>(
create_client(), ));
That's it for registration. add_service walks the service's routes and registers an HTTP POST handler per route at route.path with the auth wrapper keyed on route.authz_level.
5. Build and format
bazel build //proto/redpanda/core/admin/v2:<name>_redpanda_proto
bazel build //src/v/redpanda/admin/services:<name>
bazel build //src/v/redpanda:application
bazel run //tools:clang_format
Fix compilation errors before moving on. The pbgen plugin's error messages are usually self-explanatory; common causes are missing BUILD deps from step 2 or imports in the .proto without matching proto_library.deps.
6. Regenerate ducktape Python bindings
Ducktape tests consume admin v2 via committed-to-repo Python bindings under tests/rptest/clients/admin/proto/. They are regenerated by a script — CI (.github/workflows/check-ducktape-protos.yml) fails the PR if they're stale, so this step is mandatory whenever a .proto under proto/redpanda/core/admin/** changes.
Prerequisite: uv installed (https://docs.astral.sh/uv/getting-started/installation/). The script aborts otherwise.
bazel fetch //proto/...
tools/regenerate_ducktape_protos.sh
The script wipes tests/rptest/clients/admin/proto/**/*.py{,i} and regenerates from the proto glob using grpc_tools.protoc + mypy-protobuf + connect-python, then runs protoletariat to rewrite imports to relative form. The glob picks up new .proto files automatically — no manifest to update.
Commit the generated files. .gitignore does not exclude them.
7. Add a ducktape client accessor (only for new services)
If you created a brand-new service, expose it from the Admin wrapper at tests/rptest/clients/admin/v2.py. Two edits:
-
Add imports near the top (line ~9), alongside the existing service imports:
from rptest.clients.admin.proto.redpanda.core.admin.v2 import (
<name>_pb2,
<name>_pb2_connect,
)
And add a convenience re-export (<name>_pb = <name>_pb2) in the re-export block (around line ~52).
-
Add an accessor method on Admin matching the existing pattern (line ~133):
def <name>(self, **kwargs: Any) -> <name>_pb2_connect.<Name>ServiceClient:
return self._make_service(<name>_pb2_connect.<Name>ServiceClient, **kwargs)
Existing services (broker, cluster, features, security, shadow_link, datalake, debug, breakglass, metastore, l0, internal_shadow_link) show the shape. If you only added a new RPC to an existing service, no edit to v2.py is needed — _make_service returns the generated client which exposes every RPC as a method.
A ducktape test uses the new RPC like this:
from rptest.clients.admin.v2 import Admin as AdminV2, <name>_pb
admin_v2 = AdminV2(self.redpanda,
auth=(self.superuser.username, self.superuser.password))
req = <name>_pb.<Rpc>Request(...)
resp = admin_v2.<name>().<rpc>(req)
Verification
After all edits:
bazel build //...
bazel run //tools:clang_format
tools/regenerate_ducktape_protos.sh
git status
Manual smoke (optional, but the canonical way to confirm wiring):
bazel build //src/v/redpanda:application or a debug-binary target.
- Start a local cluster; invoke the RPC via the ducktape
AdminV2 client, or curl against /redpanda.core.admin.v2.<Service>/<Method> with a protobuf-encoded body.
Out of scope
Go bindings for rpk consumption. Admin v2 Go bindings are not generated in this repo — they're pulled from the Buf Schema Registry as a Go module (buf.build/gen/go/redpandadata/core/...). CI publishes to BSR automatically on merge to dev or a v* branch (see .github/workflows/buf.yml). Consuming a new RPC from rpk is a follow-up PR after the proto change has merged and the BSR push has run — out of scope for this skill.
If the user mentions needing rpk to consume the new RPC, surface this as a separate follow-up task rather than attempting it in the same change.
Rules and pitfalls
- Auth level defaults to
SUPERUSER. Don't use USER or PUBLIC without explicit approval — the proto README is clear on this.
- The proto's
option (pbgen.cpp_namespace) is always "proto::admin" for admin v2. Don't invent a per-service namespace.
add_service registration order matters only for readability. The existing block in application_admin.cc is not strictly sorted; match its grouping (regular services, then if (cloud_topics_app) services, then internal/security at the end).
bazel fetch //proto/... is a prerequisite for regenerate_ducktape_protos.sh — the script needs the googleapis external dir to exist. The script's error message is clear, but the failure mode (script aborts with "googleapis directory not found") is easy to misdiagnose if you skip it.
- CI enforces generated-file freshness.
check-ducktape-protos will fail if you forget step 6. The fix is always: re-run the script and commit the diff.
- Don't add comments that restate the code. RPC docstrings in the
.proto are valuable (they become the public contract); duplicating them in the C++ handler isn't.
- The handler signature
ss::future<R> name(serde::pb::rpc::context, Request) is fixed — context first, request second, both by value, return future. Deviating produces inscrutable pbgen-template errors.
- Validation errors must throw the typed exceptions from
src/v/serde/protobuf/rpc.h. Returning a default response, returning an errc, or throwing std::runtime_error will not surface a proper ConnectRPC status to the caller.