
kubectl create Knows Ingress. It Has Never Heard of HTTPRoute.
The Gateway API replaced Ingress in the API but not in the toolchain. So I wrote the create commands kubectl does not ship.
Key Takeaways
- kubectl create has seventeen subcommands on kubectl v1.35 and not one of them generates a Gateway API object, because its generators are compiled in against built-in types and a CRD group can never earn one.
- kubectl-gwapi renders nine Gateway API kinds from one-line commands, including canary weights, header matches, timeouts, rewrites, redirects and request mirroring on HTTPRoute.
- --dry-run=client -o yaml turns it into a manifest generator rather than an imperative tool, which is the mode that actually fits a cluster a reconciler owns.
- The plugin depends on neither sigs.k8s.io/gateway-api nor client-go: it renders YAML and hands it to kubectl, so the cluster's own CRDs validate the object and kubeconfig handling is never reimplemented.
- Contradictory input is rejected before the API server sees it, and CI server-side dry runs every supported kind against real Gateway API CRDs on both the standard and experimental channels.
You have Envoy Gateway running on a scratch cluster and you want to send ten percent of traffic to a new version of an API. You already know the shape of it: a Gateway with an HTTPS listener, an HTTPRoute with two backendRefs and a pair of weights. Thirty seconds of thinking.
Then you open a second tab, because you are not going to remember from memory whether weights live on the backend or on the rule, what sectionName has to match, or which of the six spellings of a reference this particular field wants. You copy an HTTPRoute out of the docs, rename it, paste it into a file, apply it, and the API server tells you the parent does not resolve. Back to the tab.
Meanwhile, the thing the Gateway API is supposed to replace has had a generator for years:
kubectl create ingress api --rule="api.example.com/v1=api:8080"One line. No second tab.
The Gateway API won the API argument and lost the toolchain one. Everybody agrees the object model is better: role-oriented, properly typed, extensible in a way Ingress annotations never were. Nobody has made it feel better to use from a terminal. On my kubectl (v1.35.2) kubectl create offers seventeen subcommands, from deployment down to poddisruptionbudget and token, and not one of them knows a Gateway API kind exists. So the friction of moving off Ingress is not conceptual. It is ergonomic, and it shows up on day one.
I got tired of the second tab and wrote the missing half: kubectl-gwapi, a plugin that adds imperative create commands for the Gateway API.
The generator gap is structural, not an oversight
It would be easy to read the missing commands as neglect. It is not. kubectl's generators are compiled in against built-in API types, which means a generator is a promise: this binary knows this schema and will keep knowing it across releases. That promise is cheap for Deployment, whose shape moves roughly never. It is expensive for a CRD group that ships on its own cadence, in two channels, with kinds that graduate from v1alpha2 to v1beta1 to v1 at different times and occasionally move group entirely.
Gateway API is exactly that kind of moving target, and pinning kubectl to it would be the wrong trade for everyone. So the missing commands are not going to arrive in kubectl. The surface where they belong is the plugin surface: any executable named kubectl-<name> on your PATH becomes a kubectl subcommand, with no registration and no build of kubectl itself.
Something already lives on the read side of that surface. The Gateway API project ships gwctl, which does get and describe with policy visibility that plain kubectl cannot give you. Nothing lived on the create side.
Two commands instead of forty-five lines of YAML
Here is the canary from the opening, in full:
kubectl gwapi create gateway eg --class=eg \
--listener name=http,port=80,protocol=HTTP \
--listener name=https,port=443,protocol=HTTPS,cert=api-tls,hostname=api.example.com
kubectl gwapi create httproute api --parent eg:https --hostname api.example.com \
--rule 'path=/v1,header=x-env:prod,backend=api-v1:8080@90,backend=api-v2:8080@10,timeout=5s'And here is the HTTPRoute those weights produce, which is the part I no longer type:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api
spec:
parentRefs:
- name: eg
sectionName: https
hostnames:
- api.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /v1
headers:
- type: Exact
name: x-env
value: prod
backendRefs:
- name: api-v1
port: 8080
weight: 90
- name: api-v2
port: 8080
weight: 10
timeouts:
request: 5sWith the Gateway, that is forty-five lines of YAML I did not have to get right by hand, and the defaults that are easy to forget (PathPrefix rather than Exact, Exact header matching, Terminate inferred on a listener that was given a certificate) are filled in the way the API expects.
Putting parentRefs on a command line
The hard part of an imperative generator for this API is not building the objects. It is the references.
Ingress got away with one host/path=service:port string because it only ever pointed one way, at a Service, in its own namespace. Gateway API references are richer on purpose: a route attaches to a named listener on a Gateway that may live in another namespace, on a port you may want pinned, and forwards to backends that may not be Services at all. All of that has to survive being typed between quotes.
It came out as three compact syntaxes:
--parent [namespace/]name[:sectionName][@port]
--backend [[group/]Kind/]name[:port][@weight]
--target [group/]Kind/name[:sectionName]
So eg is the Gateway in this namespace, eg:https pins the listener, and infra/eg:https@443 crosses a namespace with the listener and port both nailed down. On the backend side, api:8080@90 is a weighted Service and multicluster.x-k8s.io/ServiceImport/api:80 is not a Service at all. A literal comma inside any value escapes as \,.
One decision in there is worth naming, because it looks like a guess and is not. A leading segment that starts with a capital is read as a Kind, otherwise as a namespace. That is safe because Kubernetes namespace names are RFC 1123 labels and cannot contain a capital, so store/api and Service/api can never be confused with each other.
I will be honest that this is a small DSL, and a reader who dislikes DSLs is not wrong. The alternative I tried first was one flag per field (--backend-name, --backend-port, --backend-weight) and it collapsed the moment a rule needed a second backend, which is the exact case the tool exists for. Density is the price of a command that fits on one line, and fitting on one line is the whole point of an imperative command.
The mode that earns its keep
The reasonable objection to all of this is that imperative commands have no business anywhere near a production cluster. I agree. If Argo or Flux owns your cluster, a human running create against it is a drift incident waiting to be diagnosed.
But that objection is to the apply, not to the generator. Drop the apply and you get the mode I actually use:
kubectl gwapi create httproute api --parent eg --hostname api.example.com \
--rule 'path=/v1,backend=api-v1:8080' \
--dry-run=client -o yaml > routes/api.yaml--dry-run=client never contacts an API server. It renders the object and stops, so this works with no cluster, no kubeconfig and no network, and what lands in routes/api.yaml goes through review like anything else.
That inverts the workflow I had before. Copying YAML out of the docs means starting from something correct and hoping I renamed every field; generating it means starting from my own intent and reading what came out. The second one fails loudly and the first one fails in production.
What I chose not to depend on
The entire go.mod is three lines:
module github.com/chamodshehanka/kubectl-gwapi
go 1.22No sigs.k8s.io/gateway-api, no client-go, no dependencies at all. Objects are built from small local structs, rendered to YAML, and piped to kubectl create -f - with the connection flags forwarded.
architecture-beta
group plugin(logos:go)[kubectl gwapi]
group cluster(logos:kubernetes)[Cluster]
service flags(internet)[Command line flags]
service parse(server)[Reference and rule parsers] in plugin
service build(server)[Local object structs] in plugin
service render(disk)[YAML renderer] in plugin
service kctl(server)[kubectl create]
service api(server)[API server] in cluster
service crds(database)[Gateway API CRDs] in cluster
flags:R --> L:parse
parse:R --> L:build
build:B --> T:render
render:R --> L:kctl
kctl:R --> L:api
api:B --> T:crds
Three things fall out of that handoff, and they are the reason it is shaped this way.
The binary is 2.3 MB and static, with no module graph to keep current and no dependency bumps to review. The plugin pins no Gateway API version, so whichever CRDs your cluster has installed are what validate the object, which matters more than it sounds: a plugin that vendored the v1.3.0 types would be confidently wrong against a v1.2 cluster and would have no way to know. And authentication, exec credential plugins and proxy settings stay kubectl's problem rather than a second config loader quietly drifting from the one you already trust.
The cost is real and worth stating plainly: one exec per create, and a hard dependency on kubectl being on PATH. For something that only ever runs as a kubectl plugin, that is a safe assumption, and KUBECTL_GWAPI_KUBECTL overrides the binary if your setup disagrees.
If that trade ever stops paying, the escape hatch is contained rather than a rewrite. internal/gwapi mirrors the upstream types field for field, so swapping in sigs.k8s.io/gateway-api/apis/v1 and pointing Globals.Emit at a typed clientset leaves every parser and builder untouched.
Two smaller decisions held up better than I expected. Everything routes through one renderer, so --dry-run=client prints the exact bytes that would otherwise be piped to kubectl: the scaffolding path and the apply path cannot drift, and a test of one is a test of both. And the YAML encoder is hand-written, about 300 lines, supporting only the value space these structs actually use. Field order follows struct declaration order, which is what produces stable, readable, Kubernetes-shaped output without needing an ordered-map type anywhere.
Validation belongs where the schema lives
Handing schema validation to the cluster's CRDs is only half an answer, because a CRD will happily accept an object that is structurally legal and obviously not what you meant. Those get rejected up front instead:
$ kubectl gwapi create gateway eg --class=eg \
--listener name=https,port=443,protocol=HTTPS,tls=Terminate
error: --listener "name=https,port=443,protocol=HTTPS,tls=Terminate": tls=Terminate needs at least one cert=<secret>
$ kubectl gwapi create gateway eg --class=eg \
--listener name=http,port=80,protocol=HTTP,cert=api-tls
error: --listener "name=http,port=80,protocol=HTTP,cert=api-tls": tls is only valid on HTTPS or TLS listeners, not HTTP
$ kubectl gwapi create httproute r --parent eg \
--rule 'path=/,redirect-scheme=https,backend=api:8080'
error: --rule "path=/,redirect-scheme=https,backend=api:8080": a redirect rule cannot also have backends
Duplicate listener names and two kinds of rewrite in one rule fail the same way. None of those requests reach the API server, and none of them become a route that exists, passes admission and silently does the wrong thing.
The other half is CI. The workflow stands up a kind cluster, installs the Gateway API CRDs for both the standard and the experimental channel as a matrix, and then renders one object of every supported kind and pushes it through a server-side dry run. If upstream changes a schema, that job goes red and I find out before a user does.
It has already earned its place. Gateway API removed BackendLBPolicy in v1.3.0 in favour of XBackendTrafficPolicy in a different API group. The plugin does not generate it, and that is a kind that moved rather than a kind I forgot.
Shadowing kubectl create itself
kubectl has one more trick here. A binary named kubectl-create-<kind> can add a subcommand to kubectl create directly, so with the shims installed you get this:
kubectl create httproute api --parent eg --backend api:8080make install-shims symlinks one shim per supported kind, and the binary switches on its own argv[0], so a single build serves both entry points with no second code path.
The feature arrived as alpha behind KUBECTL_ENABLE_CMD_SHADOW=true, so whether you need the variable depends on your kubectl version. Built-in subcommands always win over plugins, which means no plugin can ever shadow kubectl create deployment. No Gateway API kind collides with a built-in, so that limit does not bite here, but it is why I treat the shims as a convenience: kubectl gwapi create works everywhere and needs no feature gate.
What it does not do
Nine kinds are covered today: gatewayclass, gateway, httproute, grpcroute and referencegrant from the standard channel, plus backendtlspolicy, tlsroute, tcproute and udproute from experimental.
Deliberately not covered: XListenerSet, which is left out rather than guessed at and is worth adding against the CRD you actually have installed; retry and CORS filters on HTTPRoute, and sessionPersistence; and XBackendTrafficPolicy, per the group move above. There is also no gwapi get or gwapi describe, because gwctl already does that job well and this plugin stays on the create side.
Try it
go install github.com/chamodshehanka/kubectl-gwapi@latestOr install v0.1.0 with krew, which every release ships a ready-made manifest for:
kubectl krew install --manifest-url="https://github.com/chamodshehanka/kubectl-gwapi/releases/download/v0.1.0/gwapi.yaml"Then check it without touching a cluster, because the client dry run never contacts an API server:
kubectl gwapi create gateway eg --class=eg \
--listener name=http,port=80,protocol=HTTP --dry-run=client -o yamlIngress got its generator because enough people typed the same four lines enough times. The Gateway API is past GA, is what everyone is migrating to, and still starts every object in a second browser tab. A plugin is not how that should be fixed forever, but it is how it gets fixed this week.

Chamod Shehanka
Software Engineer II at Circles building cloud-native systems with Go and Kubernetes. CNCF & CD Foundation Ambassador, Jenkins GSoC mentor, and lead of Kubernetes Sri Lanka & GDG Sri Lanka.