diff --git a/.gitattributes b/.gitattributes
index 06b6f9bb..b6152028 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -6,6 +6,9 @@
# and editors capable of LF line endings.
*.go text eol=lf diff=golang
+# Images should be stored in Git LFS, and not have their line endings modified.
+*.gif filter=lfs diff=lfs merge=lfs -text
+
# Generated files — collapsed in PR diffs, excluded from language stats.
api/**/zz_generated.deepcopy.go linguist-generated
charts/network-operator/templates/** linguist-generated
diff --git a/README.md b/README.md
index ef21b7da..a5057719 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,17 @@ make undeploy-crds
make undeploy
```
+## kubectl Plugin
+
+The `kubectl-net` plugin extends kubectl with shorthand flags and resource lifecycle operations tailored to network-operator.
+
+```bash
+kubectl net get interfaces --device leaf1
+kubectl net pause devices leaf1 --recursive
+```
+
+See [kubectl-net/README.md](kubectl-net/README.md) for installation and full usage documentation.
+
## Project Distribution
Following are the steps to build the installer and distribute this project to users.
diff --git a/kubectl-net/.gitignore b/kubectl-net/.gitignore
new file mode 100644
index 00000000..fb2d8d84
--- /dev/null
+++ b/kubectl-net/.gitignore
@@ -0,0 +1,24 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+bin/*
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Go workspace file
+go.work
+go.work.sum
+
+# editor and IDE paraphernalia
+.idea
+.vscode
+*.swp
+*.swo
+*~
diff --git a/kubectl-net/README.md b/kubectl-net/README.md
new file mode 100644
index 00000000..f6f2ff3c
--- /dev/null
+++ b/kubectl-net/README.md
@@ -0,0 +1,111 @@
+
+
+# kubectl-net
+
+A [kubectl plugin](https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/) for managing [network-operator](https://github.com/ironcore-dev/network-operator) custom resources.
+
+## Installation
+
+Install via `go install`:
+
+```bash
+go install github.com/ironcore-dev/network-operator/kubectl-net/cmd@latest
+```
+
+> Ensure your `$GOBIN` (or default `$GOPATH/bin`) is on your `$PATH`.
+
+Verify kubectl discovers the plugin:
+
+```bash
+kubectl plugin list | grep kubectl-net
+```
+
+You can now run the plugin as `kubectl net`.
+
+
+Build from source
+
+```bash
+go build -o kubectl-net ./cmd/kubectl-net.go
+sudo install -m 0755 kubectl-net /usr/local/bin/kubectl-net
+kubectl plugin list | grep kubectl-net
+```
+
+
+
+## Usage
+
+
+
+### Get resources
+
+The `get` subcommand works like `kubectl get` and supports the same output flags (`-o yaml`, `-o json`, `-o wide`, `-o name`, `-o jsonpath=...`, etc.).
+
+```bash
+# List all devices (server-side table output, same as kubectl get)
+kubectl net get devices
+
+# Get a single device
+kubectl net get devices leaf1
+
+# Filter interfaces by device
+kubectl net get interfaces --device leaf1
+
+# Filter interfaces by VRF
+kubectl net get interfaces --vrf default
+
+# Filter interfaces by aggregate
+kubectl net get interfaces --aggregate ae0
+
+# Filter VLANs by EVPN instance
+kubectl net get vlans --evi evi-100
+
+# Filter VLANs by routed VLAN interface
+kubectl net get vlans --routed-vlan irb100
+
+# Output as YAML
+kubectl net get devices leaf1 -o yaml
+```
+
+### Pause and unpause resources
+
+The `pause` and `unpause` subcommands set or remove the `networking.metal.ironcore.dev/paused` annotation. For devices, `--recursive` also sets `spec.paused` to propagate to child resources.
+
+```bash
+# Pause a single interface
+kubectl net pause interfaces lo0
+
+# Unpause interface
+kubectl net unpause interfaces lo0
+
+# Pause a device and all child resources
+kubectl net pause devices leaf1 --recursive
+
+# Unpause a device and all child resources
+kubectl net unpause devices leaf1 --recursive
+
+# Pause all interfaces on a device
+kubectl net pause interfaces --device leaf1
+```
+
+### Shell completion
+
+```bash
+# Generate and source completion for your shell
+source <(kubectl net completion bash)
+source <(kubectl net completion zsh)
+kubectl net completion fish | source
+```
+
+## Label shorthand flags
+
+| Flag | Label | Available for |
+| ---------------- | ------------------------------------------- | ------------- |
+| `--device`, `-d` | `networking.metal.ironcore.dev/device-name` | All resources |
+| `--aggregate` | `networking.metal.ironcore.dev/aggregate` | Interfaces |
+| `--vrf` | `networking.metal.ironcore.dev/vrf` | Interfaces |
+| `--routed-vlan` | `networking.metal.ironcore.dev/routed-vlan` | VLANs |
+| `--evi` | `networking.metal.ironcore.dev/l2vni` | VLANs |
diff --git a/kubectl-net/cmd/kubectl-net.go b/kubectl-net/cmd/kubectl-net.go
new file mode 100644
index 00000000..e51c00b9
--- /dev/null
+++ b/kubectl-net/cmd/kubectl-net.go
@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "os"
+
+ "github.com/spf13/pflag"
+ "k8s.io/cli-runtime/pkg/genericiooptions"
+
+ "github.com/ironcore-dev/kubectl-net/pkg/cmd"
+)
+
+func main() {
+ flags := pflag.NewFlagSet("kubectl-net", pflag.ExitOnError)
+ pflag.CommandLine = flags
+
+ root := cmd.NewCmdNet(genericiooptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr})
+ if err := root.Execute(); err != nil {
+ os.Exit(1)
+ }
+}
diff --git a/kubectl-net/examples/demo.gif b/kubectl-net/examples/demo.gif
new file mode 100644
index 00000000..17c9d7a1
--- /dev/null
+++ b/kubectl-net/examples/demo.gif
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7fc52f631059012d83a23fc9971c11d8ecc9724821c50ed248b40e54b282b8d0
+size 337984
diff --git a/kubectl-net/examples/demo.tape b/kubectl-net/examples/demo.tape
new file mode 100644
index 00000000..e9988b1b
--- /dev/null
+++ b/kubectl-net/examples/demo.tape
@@ -0,0 +1,35 @@
+# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+# SPDX-License-Identifier: Apache-2.0
+
+Require kubectl
+Require jq
+
+Output examples/demo.gif
+
+Set Shell "bash"
+Set Padding 24
+
+Type "# List all devices (drop-in replacement for kubectl get)" Enter Sleep 1s
+Type "kubectl net get devices" Enter Sleep 3s
+
+Type "# Filter interfaces by device name" Enter Sleep 1s
+Type "kubectl net get interfaces --device leaf1" Enter Sleep 3s
+
+Type "clear" Sleep 1s Enter Sleep 500ms
+
+Type "# Pause a single interface" Enter Sleep 1s
+Type "kubectl net pause interfaces lo0" Enter Sleep 3s
+
+Type "# Unpause the interface" Enter Sleep 1s
+Type "kubectl net unpause interfaces lo0" Enter Sleep 3s
+
+Type "clear" Sleep 1s Enter Sleep 500ms
+
+Type "# Recursively pause a device and children" Enter Sleep 1s
+Type "kubectl net pause devices leaf1 --recursive" Enter Sleep 3s
+
+Type "# Verify the device is paused" Enter Sleep 1s
+Type "kubectl net get devices leaf1 -o yaml | yq .spec.paused" Enter Sleep 3s
+
+Type "# Unpause the device and children" Enter Sleep 1s
+Type "kubectl net unpause devices leaf1 --recursive" Enter Sleep 3s
diff --git a/kubectl-net/go.mod b/kubectl-net/go.mod
new file mode 100644
index 00000000..66ef3679
--- /dev/null
+++ b/kubectl-net/go.mod
@@ -0,0 +1,75 @@
+module github.com/ironcore-dev/kubectl-net
+
+go 1.26.0
+
+require (
+ github.com/ironcore-dev/network-operator v0.0.0
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/pflag v1.0.10
+ k8s.io/apimachinery v0.36.0
+ k8s.io/cli-runtime v0.35.2
+ k8s.io/client-go v0.36.0
+)
+
+require (
+ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
+ github.com/blang/semver/v4 v4.0.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+ github.com/go-errors/errors v1.4.2 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-openapi/jsonpointer v0.22.4 // indirect
+ github.com/go-openapi/jsonreference v0.21.4 // indirect
+ github.com/go-openapi/swag v0.25.4 // indirect
+ github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
+ github.com/go-openapi/swag/conv v0.25.4 // indirect
+ github.com/go-openapi/swag/fileutils v0.25.4 // indirect
+ github.com/go-openapi/swag/jsonname v0.25.4 // indirect
+ github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
+ github.com/go-openapi/swag/loading v0.25.4 // indirect
+ github.com/go-openapi/swag/mangling v0.25.4 // indirect
+ github.com/go-openapi/swag/netutils v0.25.4 // indirect
+ github.com/go-openapi/swag/stringutils v0.25.4 // indirect
+ github.com/go-openapi/swag/typeutils v0.25.4 // indirect
+ github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
+ github.com/google/btree v1.1.3 // indirect
+ github.com/google/gnostic-models v0.7.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
+ github.com/moby/term v0.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ github.com/xlab/treeprint v1.2.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.3 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.21.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/term v0.44.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
+ golang.org/x/time v0.14.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ k8s.io/api v0.36.0 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
+ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/kustomize/api v0.20.1 // indirect
+ sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
+
+replace github.com/ironcore-dev/network-operator => ..
diff --git a/kubectl-net/go.sum b/kubectl-net/go.sum
new file mode 100644
index 00000000..c9b4bf74
--- /dev/null
+++ b/kubectl-net/go.sum
@@ -0,0 +1,170 @@
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
+github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
+github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
+github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
+github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
+github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
+github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
+github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
+github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
+github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
+github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
+github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
+github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
+github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
+github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
+github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
+github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
+github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
+github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
+github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
+github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
+github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
+github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
+github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
+github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
+github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
+github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
+github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
+github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
+github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
+github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
+github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
+github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0=
+github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
+github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
+github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
+go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
+golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
+k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
+k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
+k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
+k8s.io/cli-runtime v0.35.2 h1:3DNctzpPNXavqyrm/FFiT60TLk4UjUxuUMYbKOE970E=
+k8s.io/cli-runtime v0.35.2/go.mod h1:G2Ieu0JidLm5m1z9b0OkFhnykvJ1w+vjbz1tR5OFKL0=
+k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
+k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I=
+sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM=
+sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78=
+sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/kubectl-net/pkg/cmd/completion.go b/kubectl-net/pkg/cmd/completion.go
new file mode 100644
index 00000000..e9c73874
--- /dev/null
+++ b/kubectl-net/pkg/cmd/completion.go
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+// NewCmdCompletion returns a command that generates shell completion scripts
+// for bash, zsh, fish, and powershell.
+func NewCmdCompletion(root *cobra.Command) *cobra.Command {
+ return &cobra.Command{
+ Use: "completion [bash|zsh|fish|powershell]",
+ Short: "Generate completion scripts",
+ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
+ ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
+ DisableFlagsInUseLine: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ switch args[0] {
+ case "bash":
+ return root.GenBashCompletion(cmd.OutOrStdout())
+ case "zsh":
+ return root.GenZshCompletion(cmd.OutOrStdout())
+ case "fish":
+ return root.GenFishCompletion(cmd.OutOrStdout(), true)
+ case "powershell":
+ return root.GenPowerShellCompletion(cmd.OutOrStdout())
+ default:
+ return fmt.Errorf("unsupported shell type %q", args[0])
+ }
+ },
+ }
+}
diff --git a/kubectl-net/pkg/cmd/doc.go b/kubectl-net/pkg/cmd/doc.go
new file mode 100644
index 00000000..1368884f
--- /dev/null
+++ b/kubectl-net/pkg/cmd/doc.go
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+// Package cmd implements the kubectl-net plugin commands for managing
+// network-operator resources. It provides subcommands built on top of
+// cobra and the Kubernetes CLI runtime.
+package cmd
diff --git a/kubectl-net/pkg/cmd/gen.go b/kubectl-net/pkg/cmd/gen.go
new file mode 100644
index 00000000..ae20d00c
--- /dev/null
+++ b/kubectl-net/pkg/cmd/gen.go
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+// generate-resources scans the network-operator API types for kubebuilder
+// resource markers (+kubebuilder:resource:path=, singular=, shortName=) and
+// produces resources.go with the ResourceDef slice used by all subcommands.
+// Run "go generate ./pkg/cmd" to regenerate after API type changes.
+//go:generate go run ../../tools/generate-resources --api-root ../../../api --out ./resources.go
diff --git a/kubectl-net/pkg/cmd/get.go b/kubectl-net/pkg/cmd/get.go
new file mode 100644
index 00000000..df643273
--- /dev/null
+++ b/kubectl-net/pkg/cmd/get.go
@@ -0,0 +1,205 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/cli-runtime/pkg/genericclioptions"
+ "k8s.io/cli-runtime/pkg/printers"
+ "k8s.io/cli-runtime/pkg/resource"
+ "k8s.io/client-go/rest"
+)
+
+// GetOptions holds the configuration for the "get" subcommand.
+type GetOptions struct {
+ Root *RootOptions
+ Resource ResourceDef
+ Labels LabelFlags
+ PrintFlags *genericclioptions.PrintFlags
+
+ Namespace string
+}
+
+// NewCmdGet returns the "get" command with a subcommand for each known resource type.
+func NewCmdGet(root *RootOptions) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "get",
+ Short: "Get network-operator resources",
+ SilenceUsage: true,
+ }
+
+ for _, res := range resourceDefs {
+ cmd.AddCommand(newGetResourceCmd(root, res))
+ }
+
+ return cmd
+}
+
+// newGetResourceCmd returns a command that fetches and prints a specific resource type.
+func newGetResourceCmd(root *RootOptions, res ResourceDef) *cobra.Command {
+ o := &GetOptions{
+ Root: root,
+ Resource: res,
+ PrintFlags: genericclioptions.NewPrintFlags(""),
+ }
+
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("%s [NAME...]", res.Name),
+ Short: fmt.Sprintf("Get %s", res.Name),
+ Args: cobra.ArbitraryArgs,
+ RunE: func(c *cobra.Command, args []string) error {
+ if err := o.Complete(); err != nil {
+ return err
+ }
+ return o.Run(args)
+ },
+ }
+ cmd.Aliases = append(cmd.Aliases, res.Aliases...)
+
+ o.Labels.AddCommonFlags(cmd)
+ switch res.Kind {
+ case "Interface":
+ o.Labels.AddInterfaceFlags(cmd)
+ case "VLAN":
+ o.Labels.AddVLANFlags(cmd)
+ }
+ o.PrintFlags.AddFlags(cmd)
+
+ return cmd
+}
+
+// Complete resolves the target namespace from the kubeconfig.
+func (o *GetOptions) Complete() error {
+ namespace, _, err := o.Root.ConfigFlags.ToRawKubeConfigLoader().Namespace()
+ if err != nil {
+ return err
+ }
+ o.Namespace = namespace
+ return nil
+}
+
+// Run fetches the requested resources and prints them to the configured output.
+func (o *GetOptions) Run(args []string) error {
+ f := ""
+ if o.PrintFlags.OutputFormat != nil {
+ f = strings.ToLower(*o.PrintFlags.OutputFormat)
+ }
+
+ var transforms []resource.RequestTransform
+ if f == "" || f == "wide" {
+ transforms = append(transforms, func(req *rest.Request) {
+ req.SetHeader("Accept", fmt.Sprintf("application/json;as=Table;v=%s;g=%s,application/json", metav1.SchemeGroupVersion.Version, metav1.GroupName))
+ })
+ }
+
+ result, err := buildResourceResult(o.Root.ConfigFlags, o.Namespace, o.Root.AllNamespaces, o.Resource.QualifiedName(), args, o.Labels.BuildSelector(), transforms...)
+ if err != nil {
+ return err
+ }
+
+ infos, err := result.Infos()
+ if err != nil {
+ return err
+ }
+
+ // Table output (default or -o wide).
+ if f == "" || f == "wide" {
+ if len(infos) == 0 {
+ fmt.Fprintf(o.Root.IOStreams.ErrOut, "No resources found in %s namespace.\n", o.Namespace)
+ return nil
+ }
+ printer := printers.NewTablePrinter(printers.PrintOptions{
+ Wide: f == "wide",
+ WithNamespace: o.Root.AllNamespaces,
+ })
+ for _, info := range infos {
+ if info.Object == nil {
+ continue
+ }
+ u, ok := info.Object.(*unstructured.Unstructured)
+ if !ok {
+ return fmt.Errorf("unexpected object type %T", info.Object)
+ }
+ table := &metav1.Table{}
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, table); err != nil {
+ return err
+ }
+ if err := printer.PrintObj(table, o.Root.IOStreams.Out); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ for _, info := range infos {
+ if info.Object != nil && info.Object.GetObjectKind().GroupVersionKind().Empty() {
+ info.Object.GetObjectKind().SetGroupVersionKind(info.Mapping.GroupVersionKind)
+ }
+ }
+
+ printer, err := o.PrintFlags.ToPrinter()
+ if err != nil {
+ return err
+ }
+
+ // -o name prints each resource individually.
+ if f == "name" {
+ for _, info := range infos {
+ if info.Object != nil {
+ if err := printer.PrintObj(info.Object, o.Root.IOStreams.Out); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ }
+
+ // Structured output (-o json, -o yaml, etc.).
+ // A single resource is printed directly; multiple resources are wrapped in a List.
+ if len(infos) == 1 && infos[0].Object != nil {
+ return printer.PrintObj(infos[0].Object, o.Root.IOStreams.Out)
+ }
+
+ list := &unstructured.UnstructuredList{}
+ list.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "List"})
+ list.SetResourceVersion("")
+ for _, info := range infos {
+ if info.Object != nil {
+ list.Items = append(list.Items, *info.Object.(*unstructured.Unstructured))
+ }
+ }
+ return printer.PrintObj(list, o.Root.IOStreams.Out)
+}
+
+// buildResourceResult constructs a resource.Result for the given resource type,
+// optional names, and label selector using the Kubernetes resource builder.
+func buildResourceResult(configFlags *genericclioptions.ConfigFlags, namespace string, allNamespaces bool, resourceName string, args []string, labelSelector string, transforms ...resource.RequestTransform) (*resource.Result, error) {
+ resourceArgs := append([]string{resourceName}, args...)
+
+ builder := resource.NewBuilder(configFlags).
+ Unstructured().
+ NamespaceParam(namespace).
+ DefaultNamespace().
+ AllNamespaces(allNamespaces).
+ ResourceTypeOrNameArgs(true, resourceArgs...).
+ LabelSelectorParam(labelSelector).
+ ContinueOnError().
+ Latest().
+ Flatten().
+ TransformRequests(transforms...)
+
+ result := builder.Do()
+ if err := result.Err(); err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
diff --git a/kubectl-net/pkg/cmd/labels.go b/kubectl-net/pkg/cmd/labels.go
new file mode 100644
index 00000000..b09c2767
--- /dev/null
+++ b/kubectl-net/pkg/cmd/labels.go
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+// LabelFlags holds label-based filter flags used by get and pause commands.
+type LabelFlags struct {
+ Device string
+
+ Aggregate string
+ VRF string
+
+ RoutedVLAN string
+ EVI string
+}
+
+// AddCommonFlags registers the --device flag available for all resource types.
+func (l *LabelFlags) AddCommonFlags(cmd *cobra.Command) {
+ cmd.Flags().StringVarP(&l.Device, "device", "d", "", fmt.Sprintf("Filter by %s label", v1alpha1.DeviceLabel))
+}
+
+// AddInterfaceFlags registers the --aggregate and --vrf flags for Interface resources.
+func (l *LabelFlags) AddInterfaceFlags(cmd *cobra.Command) {
+ cmd.Flags().StringVar(&l.Aggregate, "aggregate", "", fmt.Sprintf("Filter by %s label", v1alpha1.AggregateLabel))
+ cmd.Flags().StringVar(&l.VRF, "vrf", "", fmt.Sprintf("Filter by %s label", v1alpha1.VRFLabel))
+}
+
+// AddVLANFlags registers the --routed-vlan and --evi flags for VLAN resources.
+func (l *LabelFlags) AddVLANFlags(cmd *cobra.Command) {
+ cmd.Flags().StringVar(&l.RoutedVLAN, "routed-vlan", "", fmt.Sprintf("Filter by %s label", v1alpha1.RoutedVLANLabel))
+ cmd.Flags().StringVar(&l.EVI, "evi", "", fmt.Sprintf("Filter by %s label", v1alpha1.L2VNILabel))
+}
+
+// BuildSelector returns a comma-separated Kubernetes label selector string
+// from the populated flag values.
+func (l *LabelFlags) BuildSelector() string {
+ parts := []string{}
+
+ if l.Device != "" {
+ parts = append(parts, fmt.Sprintf("%s=%s", v1alpha1.DeviceLabel, l.Device))
+ }
+ if l.Aggregate != "" {
+ parts = append(parts, fmt.Sprintf("%s=%s", v1alpha1.AggregateLabel, l.Aggregate))
+ }
+ if l.VRF != "" {
+ parts = append(parts, fmt.Sprintf("%s=%s", v1alpha1.VRFLabel, l.VRF))
+ }
+ if l.RoutedVLAN != "" {
+ parts = append(parts, fmt.Sprintf("%s=%s", v1alpha1.RoutedVLANLabel, l.RoutedVLAN))
+ }
+ if l.EVI != "" {
+ parts = append(parts, fmt.Sprintf("%s=%s", v1alpha1.L2VNILabel, l.EVI))
+ }
+
+ return strings.Join(parts, ",")
+}
diff --git a/kubectl-net/pkg/cmd/pause.go b/kubectl-net/pkg/cmd/pause.go
new file mode 100644
index 00000000..2499b300
--- /dev/null
+++ b/kubectl-net/pkg/cmd/pause.go
@@ -0,0 +1,200 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/cli-runtime/pkg/genericclioptions"
+ "k8s.io/client-go/dynamic"
+
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+// PauseOptions holds the configuration for the "pause" and "unpause" subcommands.
+type PauseOptions struct {
+ Root *RootOptions
+ Resource ResourceDef
+ Labels LabelFlags
+ PrintFlags *genericclioptions.PrintFlags
+
+ Pause bool
+ Recursive bool
+ Namespace string
+}
+
+// NewCmdPause returns a "pause" or "unpause" command depending on the pause flag,
+// with a subcommand for each known resource type.
+func NewCmdPause(root *RootOptions, pause bool) *cobra.Command {
+ use := "pause"
+ action := "paused"
+ short := "Pause network-operator resources"
+ if !pause {
+ use = "unpause"
+ action = "unpaused"
+ short = "Unpause network-operator resources"
+ }
+
+ cmd := &cobra.Command{
+ Use: use,
+ Short: short,
+ SilenceUsage: true,
+ }
+
+ for _, res := range resourceDefs {
+ cmd.AddCommand(newPauseResourceCmd(root, res, pause, action))
+ }
+
+ return cmd
+}
+
+// newPauseResourceCmd returns a command that pauses or unpauses a specific resource type.
+func newPauseResourceCmd(root *RootOptions, res ResourceDef, pause bool, action string) *cobra.Command {
+ o := &PauseOptions{
+ Root: root,
+ Resource: res,
+ Pause: pause,
+ PrintFlags: genericclioptions.NewPrintFlags(action),
+ }
+
+ title := action
+ if len(title) > 0 {
+ title = strings.ToUpper(title[:1]) + title[1:]
+ }
+
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("%s [NAME...]", res.Name),
+ Short: fmt.Sprintf("%s %s", title, res.Name),
+ Args: cobra.ArbitraryArgs,
+ RunE: func(c *cobra.Command, args []string) error {
+ if err := o.Complete(); err != nil {
+ return err
+ }
+ return o.Run(args)
+ },
+ }
+ cmd.Aliases = append(cmd.Aliases, res.Aliases...)
+
+ o.Labels.AddCommonFlags(cmd)
+ switch res.Kind {
+ case "Interface":
+ o.Labels.AddInterfaceFlags(cmd)
+ case "VLAN":
+ o.Labels.AddVLANFlags(cmd)
+ }
+ if res.Kind == "Device" {
+ cmd.Flags().BoolVar(&o.Recursive, "recursive", false, "Also set spec.paused on the Device to pause or unpause child resources")
+ }
+ o.PrintFlags.AddFlags(cmd)
+
+ return cmd
+}
+
+// Complete resolves the target namespace from the kubeconfig.
+func (o *PauseOptions) Complete() error {
+ namespace, _, err := o.Root.ConfigFlags.ToRawKubeConfigLoader().Namespace()
+ if err != nil {
+ return err
+ }
+ o.Namespace = namespace
+ return nil
+}
+
+// Run patches the matched resources to set or remove the paused annotation.
+func (o *PauseOptions) Run(args []string) error {
+ result, err := buildResourceResult(o.Root.ConfigFlags, o.Namespace, o.Root.AllNamespaces, o.Resource.QualifiedName(), args, o.Labels.BuildSelector())
+ if err != nil {
+ return err
+ }
+
+ infos, err := result.Infos()
+ if err != nil {
+ return err
+ }
+ if len(infos) == 0 {
+ return nil
+ }
+
+ restConfig, err := o.Root.ConfigFlags.ToRESTConfig()
+ if err != nil {
+ return err
+ }
+ dyn, err := dynamic.NewForConfig(restConfig)
+ if err != nil {
+ return err
+ }
+
+ patchBytes, err := o.buildPatch()
+ if err != nil {
+ return err
+ }
+
+ patched := make([]*unstructured.Unstructured, 0, len(infos))
+ for _, info := range infos {
+ resourceClient := dyn.Resource(info.Mapping.Resource)
+ var client dynamic.ResourceInterface = resourceClient
+ if info.Mapping.Scope.Name() == meta.RESTScopeNameNamespace && info.Namespace != "" {
+ client = resourceClient.Namespace(info.Namespace)
+ }
+ obj, err := client.Patch(context.Background(), info.Name, types.MergePatchType, patchBytes, metav1.PatchOptions{})
+ if err != nil {
+ return err
+ }
+ if obj.GetObjectKind().GroupVersionKind().Empty() {
+ obj.GetObjectKind().SetGroupVersionKind(info.Mapping.GroupVersionKind)
+ }
+ patched = append(patched, obj)
+ }
+
+ printer, err := o.PrintFlags.ToPrinter()
+ if err != nil {
+ return err
+ }
+
+ if len(patched) == 1 {
+ return printer.PrintObj(patched[0], o.Root.IOStreams.Out)
+ }
+
+ list := &unstructured.UnstructuredList{}
+ list.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "List"})
+ for _, obj := range patched {
+ list.Items = append(list.Items, *obj)
+ }
+
+ return printer.PrintObj(list, o.Root.IOStreams.Out)
+}
+
+// buildPatch returns the JSON merge-patch payload for setting or removing the
+// paused annotation, optionally including spec.paused for recursive Device operations.
+func (o *PauseOptions) buildPatch() ([]byte, error) {
+ annotations := map[string]any{}
+ if o.Pause {
+ annotations[v1alpha1.PausedAnnotation] = "true"
+ } else {
+ annotations[v1alpha1.PausedAnnotation] = nil
+ }
+
+ patch := map[string]any{
+ "metadata": map[string]any{
+ "annotations": annotations,
+ },
+ }
+
+ if o.Recursive && o.Resource.Kind == "Device" {
+ patch["spec"] = map[string]any{
+ "paused": o.Pause,
+ }
+ }
+
+ return json.Marshal(patch)
+}
diff --git a/kubectl-net/pkg/cmd/resources.go b/kubectl-net/pkg/cmd/resources.go
new file mode 100644
index 00000000..bc6e245f
--- /dev/null
+++ b/kubectl-net/pkg/cmd/resources.go
@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+// Code generated by generate-resources; DO NOT EDIT.
+
+package cmd
+
+type ResourceDef struct {
+ Name string
+ Aliases []string
+ Kind string
+ Group string
+ Version string
+}
+
+var resourceDefs = []ResourceDef{
+ {Name: "accesscontrollists", Aliases: []string{"accesscontrollist", "acl"}, Kind: "AccessControlList", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "banners", Aliases: []string{"banner"}, Kind: "Banner", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "bgp", Aliases: []string{}, Kind: "BGP", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "bgpconfigs", Aliases: []string{"bgpconfig", "nxbgp"}, Kind: "BGPConfig", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "bgppeers", Aliases: []string{"bgppeer", "peer", "bgpneighbor"}, Kind: "BGPPeer", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "bordergateways", Aliases: []string{"bordergateway", "bgw"}, Kind: "BorderGateway", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "certificates", Aliases: []string{"certificate", "cert", "netcert"}, Kind: "Certificate", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "devices", Aliases: []string{"device", "dev"}, Kind: "Device", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "dhcprelays", Aliases: []string{"dhcprelay"}, Kind: "DHCPRelay", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "dns", Aliases: []string{}, Kind: "DNS", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "evpninstances", Aliases: []string{"evpninstance", "evi", "vni"}, Kind: "EVPNInstance", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "interfaceconfigs", Aliases: []string{"interfaceconfig", "nxint"}, Kind: "InterfaceConfig", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "interfaces", Aliases: []string{"interface", "int"}, Kind: "Interface", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "isis", Aliases: []string{}, Kind: "ISIS", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "lldpconfigs", Aliases: []string{"lldpconfig"}, Kind: "LLDPConfig", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "lldps", Aliases: []string{"lldp"}, Kind: "LLDP", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "managementaccessconfigs", Aliases: []string{"managementaccessconfig", "nxmgmt", "nxmgmtaccess"}, Kind: "ManagementAccessConfig", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "managementaccesses", Aliases: []string{"managementaccess", "mgmt", "mgmtaccess"}, Kind: "ManagementAccess", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "networkvirtualizationedgeconfigs", Aliases: []string{"networkvirtualizationedgeconfig", "nveconfig"}, Kind: "NetworkVirtualizationEdgeConfig", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "networkvirtualizationedges", Aliases: []string{"networkvirtualizationedge", "nve", "vtep"}, Kind: "NetworkVirtualizationEdge", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "ntp", Aliases: []string{}, Kind: "NTP", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "ospf", Aliases: []string{}, Kind: "OSPF", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "pim", Aliases: []string{}, Kind: "PIM", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "prefixsets", Aliases: []string{"prefixset"}, Kind: "PrefixSet", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "routingpolicies", Aliases: []string{"routingpolicy", "routemap"}, Kind: "RoutingPolicy", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "snmp", Aliases: []string{}, Kind: "SNMP", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "syslogs", Aliases: []string{"syslog"}, Kind: "Syslog", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "systems", Aliases: []string{"system", "nxsystem"}, Kind: "System", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "users", Aliases: []string{"user"}, Kind: "User", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "vlans", Aliases: []string{"vlan"}, Kind: "VLAN", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "vpcdomains", Aliases: []string{"vpcdomain", "vpcdomain"}, Kind: "VPCDomain", Group: "nx.cisco.networking.metal.ironcore.dev", Version: "v1alpha1"},
+ {Name: "vrfs", Aliases: []string{"vrf"}, Kind: "VRF", Group: "networking.metal.ironcore.dev", Version: "v1alpha1"},
+}
+
+// QualifiedName returns the resource name in "name.group" form understood by
+// the Kubernetes resource builder, pinning the lookup to this resource's API
+// group and avoiding clashes with same-named types from other controllers
+// (e.g. Calico BGPPeer, cert-manager Certificate).
+func (r ResourceDef) QualifiedName() string {
+ if r.Group == "" {
+ return r.Name
+ }
+ return r.Name + "." + r.Group
+}
+
+func allResourceNames() []string {
+ names := make([]string, 0, len(resourceDefs))
+ for _, def := range resourceDefs {
+ names = append(names, def.Name)
+ }
+ return names
+}
diff --git a/kubectl-net/pkg/cmd/root.go b/kubectl-net/pkg/cmd/root.go
new file mode 100644
index 00000000..d4f47b94
--- /dev/null
+++ b/kubectl-net/pkg/cmd/root.go
@@ -0,0 +1,50 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "k8s.io/cli-runtime/pkg/genericclioptions"
+ "k8s.io/cli-runtime/pkg/genericiooptions"
+)
+
+// RootOptions holds the global configuration shared across all subcommands.
+type RootOptions struct {
+ ConfigFlags *genericclioptions.ConfigFlags
+ IOStreams genericiooptions.IOStreams
+ AllNamespaces bool
+}
+
+// NewCmdNet returns the root "kubectl net" command with all subcommands registered.
+func NewCmdNet(streams genericiooptions.IOStreams) *cobra.Command {
+ o := &RootOptions{
+ ConfigFlags: genericclioptions.NewConfigFlags(true),
+ IOStreams: streams,
+ }
+
+ cmd := &cobra.Command{
+ Use: "net",
+ Short: "Manage network-operator resources",
+ SilenceUsage: true,
+ Annotations: map[string]string{
+ cobra.CommandDisplayNameAnnotation: "kubectl net",
+ },
+ }
+
+ cmd.SetOut(streams.Out)
+ cmd.SetErr(streams.ErrOut)
+
+ o.ConfigFlags.AddFlags(cmd.PersistentFlags())
+ cmd.PersistentFlags().BoolVarP(&o.AllNamespaces, "all-namespaces", "A", false, "If present, list requested objects across all namespaces")
+
+ cmd.CompletionOptions.DisableDefaultCmd = true
+ cmd.AddCommand(
+ NewCmdGet(o),
+ NewCmdPause(o, true),
+ NewCmdPause(o, false),
+ NewCmdCompletion(cmd),
+ )
+
+ return cmd
+}
diff --git a/kubectl-net/tools/generate-resources/main.go b/kubectl-net/tools/generate-resources/main.go
new file mode 100644
index 00000000..0e53923b
--- /dev/null
+++ b/kubectl-net/tools/generate-resources/main.go
@@ -0,0 +1,232 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+// generate-resources walks the network-operator API directory for *_types.go
+// files and extracts resource definitions from kubebuilder markers:
+//
+// - +kubebuilder:resource:path=
+// - +kubebuilder:resource:singular=
+// - +kubebuilder:resource:shortName=[;...]
+//
+// It outputs a Go source file containing a ResourceDef slice with the plural
+// name, aliases (singular + short names), and kind for each resource. This
+// keeps the kubectl plugin's resource list in sync with the API types
+// automatically.
+//
+// Usage:
+//
+// go run ./tools/generate-resources --api-root --out
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+)
+
+type resourceDef struct {
+ Name string
+ Aliases []string
+ Kind string
+ Group string
+ Version string
+}
+
+func main() {
+ var apiRoot string
+ var outPath string
+ flag.StringVar(&apiRoot, "api-root", "", "Path to api directory")
+ flag.StringVar(&outPath, "out", "", "Output file path")
+ flag.Parse()
+
+ if apiRoot == "" || outPath == "" {
+ fmt.Fprintln(os.Stderr, "usage: generate-resources --api-root --out ")
+ os.Exit(2)
+ }
+
+ defs, err := loadResources(apiRoot)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ if err := writeOutput(outPath, defs); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+// packageMeta looks for a +groupName marker in groupversion.go and doc.go
+// (in that order) and returns (group, version). version is derived from the
+// last path segment of dir (e.g. "v1alpha1"). Returns empty group if no
+// marker is found in either file.
+func packageMeta(dir string) (group, version string) {
+ version = filepath.Base(dir)
+ for _, name := range []string{"groupversion.go", "doc.go"} {
+ data, err := os.ReadFile(filepath.Join(dir, name))
+ if err != nil {
+ continue
+ }
+ for line := range strings.SplitSeq(string(data), "\n") {
+ if matches := groupNameRe.FindStringSubmatch(line); matches != nil {
+ return matches[1], version
+ }
+ }
+ }
+ return "", version
+}
+
+func loadResources(apiRoot string) ([]resourceDef, error) {
+ var defs []resourceDef
+ cache := map[string][2]string{}
+
+ err := filepath.WalkDir(apiRoot, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), "_types.go") {
+ return nil
+ }
+ dir := filepath.Dir(path)
+ meta, ok := cache[dir]
+ if !ok {
+ g, v := packageMeta(dir)
+ meta = [2]string{g, v}
+ cache[dir] = meta
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ defs = append(defs, extractResources(string(content), meta[0], meta[1])...)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ slices.SortFunc(defs, func(i, j resourceDef) int {
+ return strings.Compare(i.Name, j.Name)
+ })
+ return defs, nil
+}
+
+var (
+ groupNameRe = regexp.MustCompile(`^\s*//\s*\+groupName=(\S+)`)
+ resourcePathRe = regexp.MustCompile(`^//\s*\+kubebuilder:resource:path=([^\s]+)`)
+ resourceSingRe = regexp.MustCompile(`^//\s*\+kubebuilder:resource:singular=([^\s]+)`)
+ resourceShortRe = regexp.MustCompile(`^//\s*\+kubebuilder:resource:shortName=([^\s]+)`)
+ typeDefRe = regexp.MustCompile(`^type\s+(\w+)\s+struct\b`)
+)
+
+func extractResources(content, group, version string) []resourceDef {
+ var defs []resourceDef
+ lines := strings.Split(content, "\n")
+ for i := range lines {
+ line := strings.TrimSpace(lines[i])
+ m := resourcePathRe.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+
+ var (
+ path = strings.TrimSpace(m[1])
+ kind string
+ singular string
+ shortNames []string
+ )
+
+ for j := i + 1; j < len(lines); j++ {
+ l := strings.TrimSpace(lines[j])
+ if match := resourceSingRe.FindStringSubmatch(l); match != nil {
+ singular = strings.TrimSpace(match[1])
+ continue
+ }
+ if match := resourceShortRe.FindStringSubmatch(l); match != nil {
+ for part := range strings.SplitSeq(match[1], ";") {
+ if name := strings.TrimSpace(part); name != "" {
+ shortNames = append(shortNames, name)
+ }
+ }
+ continue
+ }
+ if match := typeDefRe.FindStringSubmatch(l); match != nil {
+ kind = strings.TrimSpace(match[1])
+ break
+ }
+ }
+
+ if kind == "" {
+ continue
+ }
+
+ aliases := make([]string, 0, 1+len(shortNames))
+ if singular != "" && singular != path {
+ aliases = append(aliases, singular)
+ }
+ aliases = append(aliases, shortNames...)
+
+ defs = append(defs, resourceDef{
+ Name: path,
+ Aliases: aliases,
+ Kind: kind,
+ Group: group,
+ Version: version,
+ })
+ }
+ return defs
+}
+
+func writeOutput(outPath string, defs []resourceDef) error {
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "// SPDX-"+"FileCopyrightText: %d SAP SE or an SAP affiliate company and IronCore contributors\n", time.Now().UTC().Year())
+ fmt.Fprintln(&buf, "// SPDX-"+"License-Identifier: Apache-2.0")
+ fmt.Fprintln(&buf, "")
+ fmt.Fprintln(&buf, "// Code generated by generate-resources; DO NOT EDIT.")
+ fmt.Fprintln(&buf, "")
+ fmt.Fprintln(&buf, "package cmd")
+ fmt.Fprintln(&buf, "")
+ fmt.Fprintln(&buf, "type ResourceDef struct {")
+ fmt.Fprintln(&buf, "\tName string")
+ fmt.Fprintln(&buf, "\tAliases []string")
+ fmt.Fprintln(&buf, "\tKind string")
+ fmt.Fprintln(&buf, "\tGroup string")
+ fmt.Fprintln(&buf, "\tVersion string")
+ fmt.Fprintln(&buf, "}")
+ fmt.Fprintln(&buf, "")
+ fmt.Fprintln(&buf, "var resourceDefs = []ResourceDef{")
+ for _, def := range defs {
+ fmt.Fprintf(&buf, "\t{Name: %q, Aliases: %#v, Kind: %q, Group: %q, Version: %q},\n", def.Name, def.Aliases, def.Kind, def.Group, def.Version)
+ }
+ fmt.Fprintln(&buf, "}")
+ fmt.Fprintln(&buf, "")
+ fmt.Fprintln(&buf, "// QualifiedName returns the resource name in \"name.group\" form understood by")
+ fmt.Fprintln(&buf, "// the Kubernetes resource builder, pinning the lookup to this resource's API")
+ fmt.Fprintln(&buf, "// group and avoiding clashes with same-named types from other controllers")
+ fmt.Fprintln(&buf, "// (e.g. Calico BGPPeer, cert-manager Certificate).")
+ fmt.Fprintln(&buf, "func (r ResourceDef) QualifiedName() string {")
+ fmt.Fprintln(&buf, "\tif r.Group == \"\" {")
+ fmt.Fprintln(&buf, "\t\treturn r.Name")
+ fmt.Fprintln(&buf, "\t}")
+ fmt.Fprintln(&buf, "\treturn r.Name + \".\" + r.Group")
+ fmt.Fprintln(&buf, "}")
+ fmt.Fprintln(&buf, "")
+ fmt.Fprintln(&buf, "func allResourceNames() []string {")
+ fmt.Fprintln(&buf, "\tnames := make([]string, 0, len(resourceDefs))")
+ fmt.Fprintln(&buf, "\tfor _, def := range resourceDefs {")
+ fmt.Fprintln(&buf, "\t\tnames = append(names, def.Name)")
+ fmt.Fprintln(&buf, "\t}")
+ fmt.Fprintln(&buf, "\treturn names")
+ fmt.Fprintln(&buf, "}")
+
+ if err := os.MkdirAll(filepath.Dir(outPath), fs.ModePerm); err != nil {
+ return err
+ }
+ return os.WriteFile(outPath, buf.Bytes(), 0o644)
+}