From 0a07d6d914d5d3132bffbf5b0684809caeb24f04 Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Tue, 7 Apr 2026 15:01:53 +0200 Subject: [PATCH 1/2] feat(kubexport): export live Kubernetes resources with client-go Replace the hardcoded tutorial stub with a real Kubernetes exporter so the example can read Namespaces, ClusterRoles, and Pods from a cluster. Expand the tutorial for the new workflow and update Go/Nix dependencies to build the example. --- examples/kubexport/TUTORIAL.md | 1577 +++++++++++++++++++++++++++++++- examples/kubexport/main.go | 295 +++++- flake.lock | 718 +++++++++++++-- flake.nix | 2 +- go.mod | 8 +- go.sum | 24 +- 6 files changed, 2498 insertions(+), 126 deletions(-) diff --git a/examples/kubexport/TUTORIAL.md b/examples/kubexport/TUTORIAL.md index dd9441c..6967735 100644 --- a/examples/kubexport/TUTORIAL.md +++ b/examples/kubexport/TUTORIAL.md @@ -5,6 +5,8 @@ The goal is purely educational: by the end, you will have a working CLI skeleton This tutorial walks you through the project setup and the first steps step by step, building up the code incrementally. Later chapters of this tutorial will extend `kubexport` with real Kubernetes API calls. +> **Note:** Each chapter shows the complete `main.go` at that step. The file committed in the repository reflects the final state of the tutorial. If you are following along, replace the entire file contents at each step. + --- ## Table of Contents @@ -15,6 +17,15 @@ This tutorial walks you through the project setup and the first steps step by st 4. [Implementing the export command](#4-implementing-the-export-command) 5. [Exporting a resource](#5-exporting-a-resource) 6. [Saving the output to a file](#6-saving-the-output-to-a-file) +7. [Connecting to Kubernetes using kubeconfig](#7-connecting-to-kubernetes-using-kubeconfig) +8. [Exporting real Namespace resources](#8-exporting-real-namespace-resources) +9. [Running the Namespace export](#9-running-the-namespace-export) +10. [Choosing what to export with the built-in kind parameter](#10-choosing-what-to-export-with-the-built-in-kind-parameter) +11. [Exporting ClusterRole resources](#11-exporting-clusterrole-resources) +12. [Exporting Pod resources](#12-exporting-pod-resources) +13. [Adding a namespace configuration parameter](#13-adding-a-namespace-configuration-parameter) +14. [Selecting a namespace interactively](#14-selecting-a-namespace-interactively) +15. [Collecting related resources with mkcontainer](#15-collecting-related-resources-with-mkcontainer) --- @@ -30,6 +41,8 @@ Before you begin, make sure you have the following installed: If you need to install or upgrade Go, follow the official instructions at . +- **Access to a Kubernetes cluster from chapter 7 onward** — the later chapters talk to a live Kubernetes API through your kubeconfig. A local `kind` cluster works well for following along. + --- ## 2. Creating a new Go project @@ -281,13 +294,1567 @@ user: test-user --- +## 7. Connecting to Kubernetes using kubeconfig + +The hardcoded object was useful to understand how `xp-clifford` works, but a real exporter needs to talk to an API, in this case Kubernetes. +For this tutorial, use the standard `client-go` kubeconfig loading rules. That means: + +- If `$KUBECONFIG` is set, `client-go` uses it. +- Otherwise, it falls back to the default kubeconfig file, usually `~/.kube/config`. +- You do not need to add custom CLI flags for API connection settings. + +In this chapter, you will replace the hardcoded `Unstructured` object with real Kubernetes client setup. The export logic itself will remain a placeholder — the first real API request comes in the next chapter. + +Update `main.go`: + +```go +package main + +import ( + "context" + "log/slog" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + slog.Info("export started") + + clientset, err := newClientset() + if err != nil { + return err + } + + slog.Info("prepared Kubernetes client", "host", clientset.RESTClient().Get().URL().Host) + + return nil +} + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +Run `go mod tidy` once more so Go records the newly used Kubernetes client packages: + +```sh +go mod tidy +``` + +### What changed in chapter 7 + +- `clientcmd.NewDefaultClientConfigLoadingRules()` loads Kubernetes API settings using the standard kubeconfig mechanism. +- `clientcmd.NewNonInteractiveDeferredLoadingClientConfig(...)` builds a client configuration without prompting the user. +- `kubernetes.NewForConfig(...)` creates a typed Kubernetes clientset. +- `clientset.RESTClient().Get().URL().Host` shows which API host the client is configured to target. It does not contact the cluster yet. +- `erratt.Errorf(...)` wraps errors with structured attributes — this is the recommended error handling pattern in `xp-clifford`. +- `defer events.Stop()` replaces the explicit `events.Stop()` call from previous chapters. It makes sure the framework is always notified when the export finishes, even if the function returns early because of an error. + +The exporter does not produce any resources yet, but kubeconfig loading and client initialization are now wired up. + +--- + +## 8. Exporting real Namespace resources + +Now that the exporter can connect to the cluster, you will replace the placeholder log line with a real Kubernetes API call that lists and exports all `Namespace` objects. + +Update `main.go`: + +```go +package main + +import ( + "context" + "log/slog" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + slog.Info("export started") + + clientset, err := newClientset() + if err != nil { + return err + } + + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + events.Resource(namespace) + } + + return nil +} + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +### What changed in chapter 8 + +- `clientset.CoreV1().Namespaces().List(...)` queries the Kubernetes API for all namespace objects. +- `namespace.DeepCopy()` creates a copy of each list item so the original response is not mutated. +- `namespace.TypeMeta = metav1.TypeMeta{...}` ensures the exported YAML contains `apiVersion` and `kind`, because Kubernetes list responses do not always populate those fields on each item. + +--- + +## 9. Running the Namespace export + +The exporter now talks to a real cluster and emits each namespace returned by the Kubernetes API. + +Run the export command: + +```sh +go run main.go export +``` + +If your kubeconfig points to a working cluster, you should see log output followed by YAML documents for the namespaces. The exact namespaces, counts, and metadata depend on your cluster: + +```text +INFO export started +INFO exporting namespaces count=4 + +--- +apiVersion: v1 +kind: Namespace +metadata: + creationTimestamp: "2026-04-02T09:12:34Z" + name: default + resourceVersion: "123" + uid: 11111111-2222-3333-4444-555555555555 +spec: + finalizers: + - kubernetes +status: + phase: Active +... +``` + +The important part is that every object now comes from the live Kubernetes API. + +The output flag still works exactly as before: + +```sh +go run main.go export -o namespaces.yaml +``` + +This writes all exported Namespace objects to `namespaces.yaml` and keeps the log messages on the terminal. + +--- + +## 10. Choosing what to export with the built-in kind parameter + +So far, `kubexport` always exports namespaces. +That was fine while `Namespace` was the only supported resource kind, but the next feature will add more kinds. + +The good news is that `xp-clifford` already provides a built-in `kind` configuration parameter on the `export` subcommand. +In this chapter, you will start using that parameter, even though the tool still supports only `Namespace`. + +Update `main.go`: + +```go +package main + +import ( + "context" + "log/slog" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func exportNamespaces(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + events.Resource(namespace) + } + + return nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + kinds, err := export.ResourceKindParam.ValueOrAsk(ctx) + if err != nil { + return erratt.Errorf("cannot determine resource kinds: %w", err) + } + if len(kinds) == 0 { + return erratt.New("no resource kinds selected") + } + + slog.Info("export started", "kind", kinds) + + clientset, err := newClientset() + if err != nil { + return err + } + + for _, kind := range kinds { + switch kind { + case "Namespace": + if err := exportNamespaces(ctx, clientset, events); err != nil { + return err + } + default: + return erratt.New("unsupported resource kind", "kind", kind) + } + } + + return nil +} + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.AddResourceKinds("Namespace") + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +### What changed in chapter 10 + +- `export.AddResourceKinds("Namespace")` registers the supported value for the built-in `--kind` parameter. +- `export.ResourceKindParam.ValueOrAsk(ctx)` reads the configured kinds, or prompts interactively if the user did not provide `--kind`. +- `exportLogic` no longer hardcodes one export path. Instead, it loops over the selected kinds and dispatches to the appropriate helper. +- `exportNamespaces(...)` keeps the namespace-specific Kubernetes API logic separate from the selection logic. +- The empty selection case is handled explicitly with `no resource kinds selected`. + +Run the exporter and choose the resource kind explicitly: + +```sh +go run main.go export --kind Namespace +``` + +You should see output similar to this: + +```text +INFO export started kind=[Namespace] +INFO exporting namespaces count=4 + +--- +apiVersion: v1 +kind: Namespace +... +``` + +If you omit `--kind`, Clifford prompts interactively. +At this point the selector contains only one possible value: `Namespace`. + +Even though there is only one supported kind so far, this is an important change: the exporter is now driven by user-selected kinds instead of one hardcoded export path. +The `kind` parameter is multi-valued, which is why the CLI uses repeated `--kind` flags and the interactive prompt is a multi-select list. + +--- + +## 11. Exporting ClusterRole resources + +The structure from the previous chapter is already flexible enough to support more resource kinds. +Now you will add a second exporter that reads `ClusterRole` objects from the Kubernetes RBAC API. + +Update `main.go` again: + +```go +package main + +import ( + "context" + "log/slog" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func exportNamespaces(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + events.Resource(namespace) + } + + return nil +} + +func exportClusterRoles(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + clusterRoles, err := clientset.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list cluster roles: %w", err) + } + + slog.Info("exporting cluster roles", "count", len(clusterRoles.Items)) + for i := range clusterRoles.Items { + clusterRole := clusterRoles.Items[i].DeepCopy() + clusterRole.TypeMeta = metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRole", + } + events.Resource(clusterRole) + } + + return nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + kinds, err := export.ResourceKindParam.ValueOrAsk(ctx) + if err != nil { + return erratt.Errorf("cannot determine resource kinds: %w", err) + } + if len(kinds) == 0 { + return erratt.New("no resource kinds selected") + } + + slog.Info("export started", "kind", kinds) + + clientset, err := newClientset() + if err != nil { + return err + } + + for _, kind := range kinds { + switch kind { + case "Namespace": + if err := exportNamespaces(ctx, clientset, events); err != nil { + return err + } + case "ClusterRole": + if err := exportClusterRoles(ctx, clientset, events); err != nil { + return err + } + default: + return erratt.New("unsupported resource kind", "kind", kind) + } + } + + return nil +} + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.AddResourceKinds("Namespace", "ClusterRole") + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +### What changed in chapter 11 + +- `export.AddResourceKinds(...)` now registers both `Namespace` and `ClusterRole`. +- `exportClusterRoles(...)` adds a second exporter using `clientset.RbacV1().ClusterRoles().List(...)`. +- `ClusterRole` objects get `TypeMeta` before they are emitted, just like `Namespace` objects do. +- The `switch` in `exportLogic` now has two supported branches. + +Export only cluster roles: + +```sh +go run main.go export --kind ClusterRole +``` + +You should see output similar to this: + +```text +INFO export started kind=[ClusterRole] +INFO exporting cluster roles count=72 + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +... +``` + +Export both supported kinds in one run: + +```sh +go run main.go export --kind Namespace --kind ClusterRole -o resources.yaml +``` + +Because `kind` accepts multiple values, you pass both selections by repeating `--kind`. +The YAML output file now contains both `Namespace` and `ClusterRole` documents in the order you selected them. + +Because both kinds are cluster-scoped, you still do not need a namespace parameter. +That becomes relevant in the next feature, when you add Pod exporting. + +--- + +## 12. Exporting Pod resources + +Until now, every supported resource kind has been cluster-scoped. +`Pod` is different: it is a namespaced resource, so the exporter must know which namespace to read from. + +To keep this step focused on the new Kubernetes API call, start with a temporary hardcoded namespace. +Use `kube-system`, because it exists on all Kubernetes clusters and usually contains Pods on development clusters such as `kind`. + +Update `main.go`: + +```go +package main + +import ( + "context" + "log/slog" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func exportNamespaces(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + events.Resource(namespace) + } + + return nil +} + +func exportClusterRoles(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + clusterRoles, err := clientset.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list cluster roles: %w", err) + } + + slog.Info("exporting cluster roles", "count", len(clusterRoles.Items)) + for i := range clusterRoles.Items { + clusterRole := clusterRoles.Items[i].DeepCopy() + clusterRole.TypeMeta = metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRole", + } + events.Resource(clusterRole) + } + + return nil +} + +func exportPods(ctx context.Context, clientset *kubernetes.Clientset, namespace string, events export.EventHandler) error { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list pods: %w", err).With("namespace", namespace) + } + + slog.Info("exporting pods", "namespace", namespace, "count", len(pods.Items)) + for i := range pods.Items { + pod := pods.Items[i].DeepCopy() + pod.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + } + events.Resource(pod) + } + + return nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + kinds, err := export.ResourceKindParam.ValueOrAsk(ctx) + if err != nil { + return erratt.Errorf("cannot determine resource kinds: %w", err) + } + if len(kinds) == 0 { + return erratt.New("no resource kinds selected") + } + + slog.Info("export started", "kind", kinds) + + clientset, err := newClientset() + if err != nil { + return err + } + + for _, kind := range kinds { + switch kind { + case "Namespace": + if err := exportNamespaces(ctx, clientset, events); err != nil { + return err + } + case "ClusterRole": + if err := exportClusterRoles(ctx, clientset, events); err != nil { + return err + } + case "Pod": + if err := exportPods(ctx, clientset, "kube-system", events); err != nil { + return err + } + default: + return erratt.New("unsupported resource kind", "kind", kind) + } + } + + return nil +} + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.AddResourceKinds("Namespace", "ClusterRole", "Pod") + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +### What changed in chapter 12 + +- `export.AddResourceKinds(...)` now registers `Pod` in addition to the cluster-scoped kinds. +- `exportPods(...)` adds a third exporter using `clientset.CoreV1().Pods(namespace).List(...)`. +- The exporter sets `TypeMeta` on each Pod so the YAML includes `apiVersion: v1` and `kind: Pod`. +- For this first Pod-enabled version, the namespace is hardcoded to `kube-system`. + +Run the exporter: + +```sh +go run main.go export --kind Pod +``` + +You should see output similar to this. The exact pod count and metadata depend on your cluster: + +```text +INFO export started kind=[Pod] +INFO exporting pods namespace=kube-system count=4 + +--- +apiVersion: v1 +kind: Pod +... +``` + +This proves the exporter can now handle a namespaced resource kind. +The obvious limitation is that the namespace is still fixed in the source code. + +--- + +## 13. Adding a namespace configuration parameter + +Hardcoding `kube-system` was useful for the first Pod example, but a real exporter needs the namespace to be configurable. + +In this chapter, add a real `namespace` configuration parameter. +The user can set it with a flag, environment variable, or config file, just like any other Clifford configuration parameter. + +Update `main.go`: + +```go +package main + +import ( + "context" + "log/slog" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/configparam" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func exportNamespaces(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + events.Resource(namespace) + } + + return nil +} + +func exportClusterRoles(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + clusterRoles, err := clientset.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list cluster roles: %w", err) + } + + slog.Info("exporting cluster roles", "count", len(clusterRoles.Items)) + for i := range clusterRoles.Items { + clusterRole := clusterRoles.Items[i].DeepCopy() + clusterRole.TypeMeta = metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRole", + } + events.Resource(clusterRole) + } + + return nil +} + +func exportPods(ctx context.Context, clientset *kubernetes.Clientset, namespace string, events export.EventHandler) error { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list pods: %w", err).With("namespace", namespace) + } + + slog.Info("exporting pods", "namespace", namespace, "count", len(pods.Items)) + for i := range pods.Items { + pod := pods.Items[i].DeepCopy() + pod.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + } + events.Resource(pod) + } + + return nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + kinds, err := export.ResourceKindParam.ValueOrAsk(ctx) + if err != nil { + return erratt.Errorf("cannot determine resource kinds: %w", err) + } + if len(kinds) == 0 { + return erratt.New("no resource kinds selected") + } + + podNamespace := "" + for _, kind := range kinds { + if kind == "Pod" { + podNamespace = namespaceParam.Value() + if podNamespace == "" { + return erratt.New("namespace must be configured for Pod export") + } + break + } + } + + if podNamespace != "" { + slog.Info("export started", "kind", kinds, "namespace", podNamespace) + } else { + slog.Info("export started", "kind", kinds) + } + + clientset, err := newClientset() + if err != nil { + return err + } + + for _, kind := range kinds { + switch kind { + case "Namespace": + if err := exportNamespaces(ctx, clientset, events); err != nil { + return err + } + case "ClusterRole": + if err := exportClusterRoles(ctx, clientset, events); err != nil { + return err + } + case "Pod": + if err := exportPods(ctx, clientset, podNamespace, events); err != nil { + return err + } + default: + return erratt.New("unsupported resource kind", "kind", kind) + } + } + + return nil +} + +var namespaceParam = configparam.String("namespace", "Namespace for namespaced resources such as Pod"). + WithShortName("n"). + WithEnvVarName("NAMESPACE") + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.AddConfigParams(namespaceParam) + export.AddResourceKinds("Namespace", "ClusterRole", "Pod") + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +### What changed in chapter 13 + +- `namespaceParam` defines a real string configuration parameter for Pod export. +- `export.AddConfigParams(namespaceParam)` registers the parameter on the built-in `export` subcommand. +- The Pod branch now reads the namespace from configuration instead of using a hardcoded value. +- The exporter fails with `namespace must be configured for Pod export` if `Pod` is selected but no namespace is configured yet. + +Export Pods by passing the namespace explicitly: + +```sh +go run main.go export --kind Pod --namespace kube-system +``` + +You can also supply the same value using `NAMESPACE=kube-system` or a config file, because `namespaceParam` is a standard Clifford configuration parameter. + +This is already a useful version of the tool, but it still has one limitation: when the namespace is not configured, the user gets an error instead of a guided selection. + +--- + +## 14. Selecting a namespace interactively + +The final step is to remove that limitation. +When `Pod` export is requested and no namespace is configured, the exporter should query the cluster for namespaces and let the user choose one interactively. + +Because `configparam.String` only provides a text prompt, use the lower-level `widget.MultiInput(...)` helper directly here. +It is still a multi-select widget, so the code must enforce that exactly one namespace is chosen. + +Update `main.go` one last time: + +```go +package main + +import ( + "context" + "log/slog" + "sort" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/configparam" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/cli/widget" + "github.com/SAP/xp-clifford/erratt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func listNamespaceNames(ctx context.Context, clientset *kubernetes.Clientset) ([]string, error) { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, erratt.Errorf("cannot list namespaces for selection: %w", err) + } + + namespaceNames := make([]string, len(namespaces.Items)) + for i := range namespaces.Items { + namespaceNames[i] = namespaces.Items[i].GetName() + } + sort.Strings(namespaceNames) + return namespaceNames, nil +} + +func resolveNamespace(ctx context.Context, clientset *kubernetes.Clientset) (string, error) { + if namespace := namespaceParam.Value(); namespace != "" { + return namespace, nil + } + + namespaceNames, err := listNamespaceNames(ctx, clientset) + if err != nil { + return "", err + } + if len(namespaceNames) == 0 { + return "", erratt.New("cannot select namespace: no namespaces available") + } + + selected, err := widget.MultiInput(ctx, "Select namespace for Pod export", namespaceNames) + if err != nil { + return "", erratt.Errorf("cannot select namespace: %w", err) + } + if len(selected) != 1 { + return "", erratt.New("select exactly one namespace for Pod export", "selected", selected) + } + + return selected[0], nil +} + +func exportNamespaces(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + events.Resource(namespace) + } + + return nil +} + +func exportClusterRoles(ctx context.Context, clientset *kubernetes.Clientset, events export.EventHandler) error { + clusterRoles, err := clientset.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list cluster roles: %w", err) + } + + slog.Info("exporting cluster roles", "count", len(clusterRoles.Items)) + for i := range clusterRoles.Items { + clusterRole := clusterRoles.Items[i].DeepCopy() + clusterRole.TypeMeta = metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRole", + } + events.Resource(clusterRole) + } + + return nil +} + +func exportPods(ctx context.Context, clientset *kubernetes.Clientset, namespace string, events export.EventHandler) error { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list pods: %w", err).With("namespace", namespace) + } + + slog.Info("exporting pods", "namespace", namespace, "count", len(pods.Items)) + for i := range pods.Items { + pod := pods.Items[i].DeepCopy() + pod.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + } + events.Resource(pod) + } + + return nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + kinds, err := export.ResourceKindParam.ValueOrAsk(ctx) + if err != nil { + return erratt.Errorf("cannot determine resource kinds: %w", err) + } + kinds = uniqueStrings(kinds) + if len(kinds) == 0 { + return erratt.New("no resource kinds selected") + } + + clientset, err := newClientset() + if err != nil { + return err + } + + podNamespace := "" + for _, kind := range kinds { + if kind == "Pod" { + podNamespace, err = resolveNamespace(ctx, clientset) + if err != nil { + return err + } + break + } + } + + if podNamespace != "" { + slog.Info("export started", "kind", kinds, "namespace", podNamespace) + } else { + slog.Info("export started", "kind", kinds) + } + + for _, kind := range kinds { + switch kind { + case "Namespace": + if err := exportNamespaces(ctx, clientset, events); err != nil { + return err + } + case "ClusterRole": + if err := exportClusterRoles(ctx, clientset, events); err != nil { + return err + } + case "Pod": + if err := exportPods(ctx, clientset, podNamespace, events); err != nil { + return err + } + default: + return erratt.New("unsupported resource kind", "kind", kind) + } + } + + return nil +} + +var namespaceParam = configparam.String("namespace", "Namespace for namespaced resources such as Pod"). + WithShortName("n"). + WithEnvVarName("NAMESPACE") + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.AddConfigParams(namespaceParam) + export.AddResourceKinds("Namespace", "ClusterRole", "Pod") + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +### What changed in chapter 14 + +- `listNamespaceNames(...)` reads the available namespaces from the Kubernetes API and sorts them for a stable prompt order. +- `resolveNamespace(...)` uses the configured namespace if present, otherwise it prompts interactively from the live namespace list. +- `widget.MultiInput(...)` is used directly because `configparam.String` only provides text input, while this tutorial wants a selection from discovered values. +- `uniqueStrings(...)` removes duplicate kind selections while preserving their order. +- `exportLogic` resolves the namespace only when `Pod` export is requested. + +The explicit configuration path still works: + +```sh +go run main.go export --kind Pod --namespace kube-system +``` + +If you omit `--namespace`, the exporter queries the cluster and opens an interactive selector: + +```sh +go run main.go export --kind Pod +``` + +Choose exactly one namespace from the list. +After the selection, the exporter continues with output similar to this. The exact pod count depends on your cluster: + +```text +INFO export started kind=[Pod] namespace=kube-system +INFO exporting pods namespace=kube-system count=4 + +--- +apiVersion: v1 +kind: Pod +... +``` + +You can also combine `Pod` with the previously implemented kinds: + +```sh +go run main.go export --kind Namespace --kind Pod --namespace kube-system -o resources.yaml +``` + +Because the exporter processes kinds in the order you select them, the YAML file contains all `Namespace` documents first, followed by the `Pod` documents. + +--- + +## 15. Collecting related resources with mkcontainer + +The previous chapter still had one artificial limitation: `Pod` export accepted exactly one namespace. +That kept the example small, but it was not a meaningful restriction. + +In this final chapter, you will remove that limitation and add a practical use for `mkcontainer`. +The exporter will now: + +- accept one or more `namespace` values for `Pod` export +- allow selecting multiple namespaces interactively +- optionally include the related `Namespace` resources with `--include-namespaces` +- deduplicate collected resources before emitting them + +This is a good fit for `mkcontainer`. +Instead of emitting resources immediately, the exporter first collects them into an inventory. +That inventory uses: + +- an ordered slice to preserve output order +- `mkcontainer.TypedContainer[...]` to index collected resources by Kubernetes UID and a logical key + +The `namespace` parameter also becomes a `StringSlice` configuration parameter in this chapter. +That means all configuration paths stay consistent: + +- repeat `--namespace` on the command line +- set `NAMESPACES` in the environment +- use a YAML list in the config file +- select multiple namespaces interactively when no value is configured + +Update `main.go` one last time: + +```go +package main + +import ( + "context" + "log/slog" + "sort" + + "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/configparam" + "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + "github.com/SAP/xp-clifford/mkcontainer" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +type collectedResource struct { + key string + object resource.Object +} + +func (r *collectedResource) GetGUID() string { + return string(r.object.GetUID()) +} + +func (r *collectedResource) GetName() string { + return r.key +} + +type resourceInventory struct { + ordered []*collectedResource + index mkcontainer.TypedContainer[*collectedResource] +} + +func newResourceInventory() *resourceInventory { + return &resourceInventory{ + ordered: make([]*collectedResource, 0), + index: mkcontainer.NewTyped[*collectedResource](), + } +} + +func resourceKey(obj resource.Object) string { + if namespace := obj.GetNamespace(); namespace != "" { + return obj.GetObjectKind().GroupVersionKind().Kind + "/" + namespace + "/" + obj.GetName() + } + return obj.GetObjectKind().GroupVersionKind().Kind + "/" + obj.GetName() +} + +func namespaceKey(namespace string) string { + return "Namespace/" + namespace +} + +func (i *resourceInventory) HasKey(key string) bool { + return len(i.index.GetByName(key)) > 0 +} + +func (i *resourceInventory) Add(obj resource.Object) bool { + item := &collectedResource{ + key: resourceKey(obj), + object: obj, + } + if guid := item.GetGUID(); guid != "" && i.index.GetByGUID(guid) != nil { + return false + } + if i.HasKey(item.GetName()) { + return false + } + + i.index.Store(item) + i.ordered = append(i.ordered, item) + return true +} + +func (i *resourceInventory) Emit(events export.EventHandler) { + for _, item := range i.ordered { + events.Resource(item.object) + } +} + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func listNamespaceNames(ctx context.Context, clientset *kubernetes.Clientset) ([]string, error) { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, erratt.Errorf("cannot list namespaces for selection: %w", err) + } + + namespaceNames := make([]string, len(namespaces.Items)) + for i := range namespaces.Items { + namespaceNames[i] = namespaces.Items[i].GetName() + } + sort.Strings(namespaceNames) + return namespaceNames, nil +} + +func resolveNamespaces(ctx context.Context, clientset *kubernetes.Clientset) ([]string, error) { + if namespaces := uniqueStrings(namespaceParam.Value()); len(namespaces) > 0 { + return namespaces, nil + } + + namespaceNames, err := listNamespaceNames(ctx, clientset) + if err != nil { + return nil, err + } + if len(namespaceNames) == 0 { + return nil, erratt.New("cannot select namespaces: no namespaces available") + } + + namespaceParam.WithPossibleValues(namespaceNames) + namespaces, err := namespaceParam.ValueOrAsk(ctx) + if err != nil { + return nil, erratt.Errorf("cannot determine namespaces: %w", err) + } + namespaces = uniqueStrings(namespaces) + if len(namespaces) == 0 { + return nil, erratt.New("no namespaces selected for Pod export") + } + + return namespaces, nil +} + +func collectSelectedNamespace(ctx context.Context, clientset *kubernetes.Clientset, namespace string, inventory *resourceInventory) error { + if inventory.HasKey(namespaceKey(namespace)) { + return nil + } + + namespaceResource, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + return erratt.Errorf("cannot get namespace: %w", err).With("namespace", namespace) + } + + namespaceResource.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + slog.Info("exporting selected namespace", "namespace", namespace) + inventory.Add(namespaceResource) + return nil +} + +func collectNamespaces(ctx context.Context, clientset *kubernetes.Clientset, inventory *resourceInventory) error { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + inventory.Add(namespace) + } + + return nil +} + +func collectClusterRoles(ctx context.Context, clientset *kubernetes.Clientset, inventory *resourceInventory) error { + clusterRoles, err := clientset.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list cluster roles: %w", err) + } + + slog.Info("exporting cluster roles", "count", len(clusterRoles.Items)) + for i := range clusterRoles.Items { + clusterRole := clusterRoles.Items[i].DeepCopy() + clusterRole.TypeMeta = metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRole", + } + inventory.Add(clusterRole) + } + + return nil +} + +func collectPods(ctx context.Context, clientset *kubernetes.Clientset, namespace string, inventory *resourceInventory) error { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list pods: %w", err).With("namespace", namespace) + } + + slog.Info("exporting pods", "namespace", namespace, "count", len(pods.Items)) + for i := range pods.Items { + pod := pods.Items[i].DeepCopy() + pod.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + } + inventory.Add(pod) + } + + return nil +} + +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + kinds, err := export.ResourceKindParam.ValueOrAsk(ctx) + if err != nil { + return erratt.Errorf("cannot determine resource kinds: %w", err) + } + kinds = uniqueStrings(kinds) + if len(kinds) == 0 { + return erratt.New("no resource kinds selected") + } + + clientset, err := newClientset() + if err != nil { + return err + } + + podNamespaces := []string{} + for _, kind := range kinds { + if kind == "Pod" { + podNamespaces, err = resolveNamespaces(ctx, clientset) + if err != nil { + return err + } + break + } + } + + if len(podNamespaces) > 0 { + slog.Info("export started", "kind", kinds, "namespaces", podNamespaces) + } else { + slog.Info("export started", "kind", kinds) + } + + inventory := newResourceInventory() + + for _, kind := range kinds { + switch kind { + case "Namespace": + if err := collectNamespaces(ctx, clientset, inventory); err != nil { + return err + } + case "ClusterRole": + if err := collectClusterRoles(ctx, clientset, inventory); err != nil { + return err + } + case "Pod": + for _, namespace := range podNamespaces { + if includeNamespacesParam.Value() { + if err := collectSelectedNamespace(ctx, clientset, namespace, inventory); err != nil { + return err + } + } + if err := collectPods(ctx, clientset, namespace, inventory); err != nil { + return err + } + } + default: + return erratt.New("unsupported resource kind", "kind", kind) + } + } + inventory.Emit(events) + + return nil +} + +var namespaceParam = configparam.StringSlice("namespace", "Namespaces for namespaced resources such as Pod"). + WithShortName("n"). + WithEnvVarName("NAMESPACES") + +var includeNamespacesParam = configparam.Bool("include-namespaces", "Also export selected Namespace resources when exporting Pod") + +func main() { + cli.Configuration.ShortName = "kubexport" + cli.Configuration.ObservedSystem = "Kubernetes" + export.AddConfigParams(namespaceParam, includeNamespacesParam) + export.AddResourceKinds("Namespace", "ClusterRole", "Pod") + export.SetCommand(exportLogic) + cli.Execute() +} +``` + +### What changed in chapter 15 + +- `namespaceParam` is now a `StringSlice` configuration parameter, so `Pod` export can work with one or more namespaces. +- `resolveNamespaces(...)` uses `StringSliceParam.ValueOrAsk(...)` together with the live namespace list, so flags, environment variables, config files, and interactive selection all support multiple namespaces consistently. +- `collectedResource` wraps a Kubernetes object so it can be stored in `mkcontainer` using both UID (`GetGUID`) and a logical key (`GetName`). +- `resourceInventory` combines an ordered slice with `mkcontainer.TypedContainer[...]`: the slice preserves output order, while `mkcontainer` prevents duplicate collection. +- `collectSelectedNamespace(...)` adds related Namespace objects only when `--include-namespaces` is enabled and skips them if they are already in the inventory. +- The resource-specific helpers now gather objects into the inventory instead of emitting them immediately, and `inventory.Emit(...)` performs the final output pass. + +Export Pods from more than one namespace by repeating `--namespace`: + +```sh +go run main.go export --kind Pod --namespace kube-system --namespace default +``` + +The exporter processes the selected namespaces in the order you provided. +On many clusters, some namespaces may contain no Pods, which is fine. + +You can configure the same selection through the environment as well: + +```sh +NAMESPACES="kube-system default" go run main.go export --kind Pod +``` + +The interactive prompt now also allows selecting multiple namespaces, because `namespaceParam` itself is multi-valued. + +Run the related-resource mode with a single namespace: + +```sh +go run main.go export --kind Pod --namespace kube-system --include-namespaces +``` + +You should see output similar to this. The exact pod count depends on your cluster: + +```text +INFO export started kind=[Pod] namespaces=[kube-system] +INFO exporting selected namespace namespace=kube-system +INFO exporting pods namespace=kube-system count=4 + +--- +apiVersion: v1 +kind: Namespace +... +--- +apiVersion: v1 +kind: Pod +... +``` + +You can also combine the new flag with the explicit `Namespace` exporter: + +```sh +go run main.go export --kind Namespace --kind Pod --namespace kube-system --namespace default --include-namespaces -o resources.yaml +``` + +In that case, the exporter still emits each selected namespace only once, even when a namespace is reached from two paths: + +- the explicit `Namespace` export +- the Pod-related namespace inclusion enabled by `--include-namespaces` + +Because the inventory preserves first-seen order, resources are emitted in the order they were first collected. +This makes the output deterministic while still allowing multiple collectors to overlap safely. + +--- + ## Next steps -You have a working CLI tool that exports a resource. From here, you can: +You now have a working CLI tool that can export cluster-scoped and namespaced Kubernetes resources, resolve one or more namespaces interactively, and collect related resources without duplicating them. From here, you can: -- Replace the hardcoded `unstructured.Unstructured` with real API calls to the system you want to export from. -- Add configuration parameters (flags, environment variables, config file) using the `configparam` package — see the README for details. -- Add interactive prompts for credentials or selection using the `widget` package. +- Add more namespaced resource kinds by reusing the `namespace` resolution pattern from `Pod` export. +- Auto-include other related resources such as `ServiceAccount`, `ConfigMap`, or `Secret` by extending the inventory pattern from `mkcontainer`. +- Add filters such as labels or field selectors to reduce the exported result set. +- Add separate selection rules for different resources if some kinds should allow only one namespace while others should allow many. - Register additional subcommands (for example, a `login` command) using `cli.RegisterSubCommand`. -All of these are covered with full examples in the [README](../README.md). +See other examples in the [README](../../README.md). diff --git a/examples/kubexport/main.go b/examples/kubexport/main.go index 1edab53..34581ec 100644 --- a/examples/kubexport/main.go +++ b/examples/kubexport/main.go @@ -3,31 +3,306 @@ package main import ( "context" "log/slog" + "sort" "github.com/SAP/xp-clifford/cli" + "github.com/SAP/xp-clifford/cli/configparam" "github.com/SAP/xp-clifford/cli/export" + "github.com/SAP/xp-clifford/erratt" + "github.com/SAP/xp-clifford/mkcontainer" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/crossplane/crossplane-runtime/pkg/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" ) -func exportLogic(_ context.Context, events export.EventHandler) error { - slog.Info("export started") +type collectedResource struct { + key string + object resource.Object +} + +func (r *collectedResource) GetGUID() string { + return string(r.object.GetUID()) +} + +func (r *collectedResource) GetName() string { + return r.key +} + +type resourceInventory struct { + ordered []*collectedResource + index mkcontainer.TypedContainer[*collectedResource] +} + +func newResourceInventory() *resourceInventory { + return &resourceInventory{ + ordered: make([]*collectedResource, 0), + index: mkcontainer.NewTyped[*collectedResource](), + } +} + +func resourceKey(obj resource.Object) string { + if namespace := obj.GetNamespace(); namespace != "" { + return obj.GetObjectKind().GroupVersionKind().Kind + "/" + namespace + "/" + obj.GetName() + } + return obj.GetObjectKind().GroupVersionKind().Kind + "/" + obj.GetName() +} + +func namespaceKey(namespace string) string { + return "Namespace/" + namespace +} + +func (i *resourceInventory) HasKey(key string) bool { + return len(i.index.GetByName(key)) > 0 +} + +func (i *resourceInventory) Add(obj resource.Object) bool { + item := &collectedResource{ + key: resourceKey(obj), + object: obj, + } + if guid := item.GetGUID(); guid != "" && i.index.GetByGUID(guid) != nil { + return false + } + if i.HasKey(item.GetName()) { + return false + } - res := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "user": "test-user", - "password": "secret", - }, + i.index.Store(item) + i.ordered = append(i.ordered, item) + return true +} + +func (i *resourceInventory) Emit(events export.EventHandler) { + for _, item := range i.ordered { + events.Resource(item.object) + } +} + +func newClientset() (*kubernetes.Clientset, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, erratt.Errorf("cannot load kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, erratt.Errorf("cannot create Kubernetes client: %w", err) + } + + return clientset, nil +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func listNamespaceNames(ctx context.Context, clientset *kubernetes.Clientset) ([]string, error) { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, erratt.Errorf("cannot list namespaces for selection: %w", err) + } + + namespaceNames := make([]string, len(namespaces.Items)) + for i := range namespaces.Items { + namespaceNames[i] = namespaces.Items[i].GetName() + } + sort.Strings(namespaceNames) + return namespaceNames, nil +} + +func resolveNamespaces(ctx context.Context, clientset *kubernetes.Clientset) ([]string, error) { + if namespaces := uniqueStrings(namespaceParam.Value()); len(namespaces) > 0 { + return namespaces, nil + } + + namespaceNames, err := listNamespaceNames(ctx, clientset) + if err != nil { + return nil, err + } + if len(namespaceNames) == 0 { + return nil, erratt.New("cannot select namespaces: no namespaces available") + } + + namespaceParam.WithPossibleValues(namespaceNames) + namespaces, err := namespaceParam.ValueOrAsk(ctx) + if err != nil { + return nil, erratt.Errorf("cannot determine namespaces: %w", err) + } + namespaces = uniqueStrings(namespaces) + if len(namespaces) == 0 { + return nil, erratt.New("no namespaces selected for Pod export") + } + + return namespaces, nil +} + +func collectSelectedNamespace(ctx context.Context, clientset *kubernetes.Clientset, namespace string, inventory *resourceInventory) error { + if inventory.HasKey(namespaceKey(namespace)) { + return nil + } + + namespaceResource, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + return erratt.Errorf("cannot get namespace: %w", err).With("namespace", namespace) + } + + namespaceResource.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + slog.Info("exporting selected namespace", "namespace", namespace) + inventory.Add(namespaceResource) + return nil +} + +func collectNamespaces(ctx context.Context, clientset *kubernetes.Clientset, inventory *resourceInventory) error { + namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list namespaces: %w", err) + } + + slog.Info("exporting namespaces", "count", len(namespaces.Items)) + for i := range namespaces.Items { + namespace := namespaces.Items[i].DeepCopy() + namespace.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + } + inventory.Add(namespace) + } + + return nil +} + +func collectClusterRoles(ctx context.Context, clientset *kubernetes.Clientset, inventory *resourceInventory) error { + clusterRoles, err := clientset.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list cluster roles: %w", err) + } + + slog.Info("exporting cluster roles", "count", len(clusterRoles.Items)) + for i := range clusterRoles.Items { + clusterRole := clusterRoles.Items[i].DeepCopy() + clusterRole.TypeMeta = metav1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "ClusterRole", + } + inventory.Add(clusterRole) + } + + return nil +} + +func collectPods(ctx context.Context, clientset *kubernetes.Clientset, namespace string, inventory *resourceInventory) error { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return erratt.Errorf("cannot list pods: %w", err).With("namespace", namespace) + } + + slog.Info("exporting pods", "namespace", namespace, "count", len(pods.Items)) + for i := range pods.Items { + pod := pods.Items[i].DeepCopy() + pod.TypeMeta = metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + } + inventory.Add(pod) } - events.Resource(res) - events.Stop() return nil } +func exportLogic(ctx context.Context, events export.EventHandler) error { + defer events.Stop() + + kinds, err := export.ResourceKindParam.ValueOrAsk(ctx) + if err != nil { + return erratt.Errorf("cannot determine resource kinds: %w", err) + } + kinds = uniqueStrings(kinds) + if len(kinds) == 0 { + return erratt.New("no resource kinds selected") + } + + clientset, err := newClientset() + if err != nil { + return err + } + + podNamespaces := []string{} + for _, kind := range kinds { + if kind == "Pod" { + podNamespaces, err = resolveNamespaces(ctx, clientset) + if err != nil { + return err + } + break + } + } + + if len(podNamespaces) > 0 { + slog.Info("export started", "kind", kinds, "namespaces", podNamespaces) + } else { + slog.Info("export started", "kind", kinds) + } + + inventory := newResourceInventory() + + for _, kind := range kinds { + switch kind { + case "Namespace": + if err := collectNamespaces(ctx, clientset, inventory); err != nil { + return err + } + case "ClusterRole": + if err := collectClusterRoles(ctx, clientset, inventory); err != nil { + return err + } + case "Pod": + for _, namespace := range podNamespaces { + if includeNamespacesParam.Value() { + if err := collectSelectedNamespace(ctx, clientset, namespace, inventory); err != nil { + return err + } + } + if err := collectPods(ctx, clientset, namespace, inventory); err != nil { + return err + } + } + default: + return erratt.New("unsupported resource kind", "kind", kind) + } + } + inventory.Emit(events) + + return nil +} + +var namespaceParam = configparam.StringSlice("namespace", "Namespaces for namespaced resources such as Pod"). + WithShortName("n"). + WithEnvVarName("NAMESPACES") + +var includeNamespacesParam = configparam.Bool("include-namespaces", "Also export selected Namespace resources when exporting Pod") + func main() { cli.Configuration.ShortName = "kubexport" cli.Configuration.ObservedSystem = "Kubernetes" + export.AddConfigParams(namespaceParam, includeNamespacesParam) + export.AddResourceKinds("Namespace", "ClusterRole", "Pod") export.SetCommand(exportLogic) cli.Execute() } diff --git a/flake.lock b/flake.lock index 5c9b0ba..cf5c86e 100644 --- a/flake.lock +++ b/flake.lock @@ -33,22 +33,141 @@ "type": "github" } }, - "devenv": { + "cachix_2": { "inputs": { - "cachix": "cachix", + "devenv": [ + "devenv", + "crate2nix" + ], + "flake-compat": [ + "devenv", + "crate2nix" + ], + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1767714506, + "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=", + "owner": "cachix", + "repo": "cachix", + "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "cachix_3": { + "inputs": { + "devenv": [ + "devenv", + "crate2nix", + "crate2nix_stable" + ], + "flake-compat": [ + "devenv", + "crate2nix", + "crate2nix_stable" + ], + "git-hooks": "git-hooks_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1767714506, + "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=", + "owner": "cachix", + "repo": "cachix", + "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "crate2nix": { + "inputs": { + "cachix": "cachix_2", + "crate2nix_stable": "crate2nix_stable", + "devshell": "devshell_2", + "flake-compat": "flake-compat_2", + "flake-parts": "flake-parts_2", + "nix-test-runner": "nix-test-runner_2", + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "pre-commit-hooks": "pre-commit-hooks_2" + }, + "locked": { + "lastModified": 1772186516, + "narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=", + "owner": "rossng", + "repo": "crate2nix", + "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e", + "type": "github" + }, + "original": { + "owner": "rossng", + "repo": "crate2nix", + "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e", + "type": "github" + } + }, + "crate2nix_stable": { + "inputs": { + "cachix": "cachix_3", + "crate2nix_stable": [ + "devenv", + "crate2nix", + "crate2nix_stable" + ], + "devshell": "devshell", "flake-compat": "flake-compat", "flake-parts": "flake-parts", - "git-hooks": "git-hooks", + "nix-test-runner": "nix-test-runner", + "nixpkgs": "nixpkgs_3", + "pre-commit-hooks": "pre-commit-hooks" + }, + "locked": { + "lastModified": 1769627083, + "narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=", + "owner": "nix-community", + "repo": "crate2nix", + "rev": "7c33e664668faecf7655fa53861d7a80c9e464a2", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "0.15.0", + "repo": "crate2nix", + "type": "github" + } + }, + "devenv": { + "inputs": { + "cachix": "cachix", + "crate2nix": "crate2nix", + "flake-compat": "flake-compat_3", + "flake-parts": "flake-parts_3", + "git-hooks": "git-hooks_3", "nix": "nix", "nixd": "nixd", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs_4", + "rust-overlay": "rust-overlay" }, "locked": { - "lastModified": 1773034036, - "narHash": "sha256-J03ioow4WAtkKmZQ04cCXhar4UwW2RrpXzk7msFJUUA=", + "lastModified": 1775566203, + "narHash": "sha256-bBhX1xIB0rJ43xKZWzFaY1dQxOX6BcsdU4/+jpuaHcY=", "owner": "cachix", "repo": "devenv", - "rev": "32c9ecdacbd7093ae096e5ca312c4497e3758159", + "rev": "e82d5cbf38954a872c0547a0c141e8cc116d2b2a", "type": "github" }, "original": { @@ -59,7 +178,52 @@ }, "devshell": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "devshell_2": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "devshell_3": { + "inputs": { + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1768818222, @@ -76,6 +240,34 @@ } }, "flake-compat": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "revCount": 69, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, + "flake-compat_2": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "revCount": 69, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, + "flake-compat_3": { "flake": false, "locked": { "lastModified": 1767039857, @@ -91,7 +283,7 @@ "type": "github" } }, - "flake-compat_2": { + "flake-compat_4": { "flake": false, "locked": { "lastModified": 1767039857, @@ -107,7 +299,7 @@ "type": "github" } }, - "flake-compat_3": { + "flake-compat_5": { "flake": false, "locked": { "lastModified": 1761588595, @@ -127,15 +319,17 @@ "inputs": { "nixpkgs-lib": [ "devenv", + "crate2nix", + "crate2nix_stable", "nixpkgs" ] }, "locked": { - "lastModified": 1772408722, - "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", + "lastModified": 1768135262, + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", "type": "github" }, "original": { @@ -146,7 +340,32 @@ }, "flake-parts_2": { "inputs": { - "nixpkgs-lib": "nixpkgs-lib" + "nixpkgs-lib": [ + "devenv", + "crate2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768135262, + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_3": { + "inputs": { + "nixpkgs-lib": [ + "devenv", + "nixpkgs" + ] }, "locked": { "lastModified": 1772408722, @@ -162,7 +381,25 @@ "type": "github" } }, - "flake-parts_3": { + "flake-parts_4": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1775087534, + "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_5": { "inputs": { "nixpkgs-lib": "nixpkgs-lib_2" }, @@ -179,21 +416,6 @@ "url": "https://flakehub.com/f/hercules-ci/flake-parts/0.1" } }, - "flake-root": { - "locked": { - "lastModified": 1723604017, - "narHash": "sha256-rBtQ8gg+Dn4Sx/s+pvjdq3CB2wQNzx9XGFq/JVGCB6k=", - "owner": "srid", - "repo": "flake-root", - "rev": "b759a56851e10cb13f6b8e5698af7b59c44be26e", - "type": "github" - }, - "original": { - "owner": "srid", - "repo": "flake-root", - "type": "github" - } - }, "flake-utils": { "inputs": { "systems": "systems" @@ -216,20 +438,24 @@ "inputs": { "flake-compat": [ "devenv", + "crate2nix", + "cachix", "flake-compat" ], "gitignore": "gitignore", "nixpkgs": [ "devenv", + "crate2nix", + "cachix", "nixpkgs" ] }, "locked": { - "lastModified": 1772665116, - "narHash": "sha256-XmjUDG/J8Z8lY5DVNVUf5aoZGc400FxcjsNCqHKiKtc=", + "lastModified": 1765404074, + "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "39f53203a8458c330f61cc0759fe243f0ac0d198", + "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529", "type": "github" }, "original": { @@ -240,9 +466,67 @@ }, "git-hooks-nix": { "inputs": { - "flake-compat": "flake-compat_2", + "flake-compat": "flake-compat_4", + "gitignore": "gitignore_6", + "nixpkgs": "nixpkgs_6" + }, + "locked": { + "lastModified": 1775036584, + "narHash": "sha256-zW0lyy7ZNNT/x8JhzFHBsP2IPx7ATZIPai4FJj12BgU=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "4e0eb042b67d863b1b34b3f64d52ceb9cd926735", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "git-hooks_2": { + "inputs": { + "flake-compat": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "cachix", + "flake-compat" + ], "gitignore": "gitignore_2", - "nixpkgs": "nixpkgs_3" + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "cachix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1765404074, + "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "git-hooks_3": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "gitignore": "gitignore_5", + "nixpkgs": [ + "devenv", + "nixpkgs" + ] }, "locked": { "lastModified": 1772893680, @@ -258,10 +542,10 @@ "type": "github" } }, - "git-hooks_2": { + "git-hooks_4": { "inputs": { - "flake-compat": "flake-compat_3", - "gitignore": "gitignore_3", + "flake-compat": "flake-compat_5", + "gitignore": "gitignore_7", "nixpkgs": [ "go-overlay", "nixpkgs" @@ -283,15 +567,15 @@ }, "github-actions-nix": { "inputs": { - "flake-parts": "flake-parts_3", - "nixpkgs": "nixpkgs_4" + "flake-parts": "flake-parts_5", + "nixpkgs": "nixpkgs_7" }, "locked": { - "lastModified": 1772219490, - "narHash": "sha256-QxSEr6ueAyKZkYDyuZqWVv1GqZbuSb0jdc05KiXKzQ4=", + "lastModified": 1773808042, + "narHash": "sha256-97K9g40SdtDVWslJG8ytpiC3+qgeQzftsZheGQ8qoGY=", "owner": "synapdeck", "repo": "github-actions-nix", - "rev": "f5e6e94e3b1f694d2afa357570d65e95518c642d", + "rev": "805a4f69856e0f3d0bcd8ae916f1625f22b0578c", "type": "github" }, "original": { @@ -304,6 +588,8 @@ "inputs": { "nixpkgs": [ "devenv", + "crate2nix", + "cachix", "git-hooks", "nixpkgs" ] @@ -325,7 +611,11 @@ "gitignore_2": { "inputs": { "nixpkgs": [ - "git-hooks-nix", + "devenv", + "crate2nix", + "crate2nix_stable", + "cachix", + "git-hooks", "nixpkgs" ] }, @@ -344,6 +634,96 @@ } }, "gitignore_3": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_4": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_5": { + "inputs": { + "nixpkgs": [ + "devenv", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_6": { + "inputs": { + "nixpkgs": [ + "git-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_7": { "inputs": { "nixpkgs": [ "go-overlay", @@ -368,17 +748,17 @@ "go-overlay": { "inputs": { "flake-utils": "flake-utils", - "git-hooks": "git-hooks_2", + "git-hooks": "git-hooks_4", "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1773036810, - "narHash": "sha256-mnNHsr7ypNRlX63tTDeM5jLm1na6kHHO/vouIzqZbQI=", + "lastModified": 1774983777, + "narHash": "sha256-SeFJGMRXSRxQPD26aZyTnLNDkMh8A9Z4bNgIt91UDEI=", "owner": "purpleclay", "repo": "go-overlay", - "rev": "1e56c3a37ea6354397892a02c0f9da3093f3c411", + "rev": "594e438047b9493d335a03d04a2030ac82132117", "type": "github" }, "original": { @@ -428,11 +808,11 @@ ] }, "locked": { - "lastModified": 1772748357, - "narHash": "sha256-vtf03lfgQKNkPH9FdXdboBDS5DtFkXB8xRw5EBpuDas=", + "lastModified": 1774103430, + "narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=", "owner": "cachix", "repo": "nix", - "rev": "41eee9d3b1f611b1b90d51caa858b6d83834c44a", + "rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e", "type": "github" }, "original": { @@ -462,6 +842,38 @@ "type": "github" } }, + "nix-test-runner": { + "flake": false, + "locked": { + "lastModified": 1588761593, + "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=", + "owner": "stoeffel", + "repo": "nix-test-runner", + "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2", + "type": "github" + }, + "original": { + "owner": "stoeffel", + "repo": "nix-test-runner", + "type": "github" + } + }, + "nix-test-runner_2": { + "flake": false, + "locked": { + "lastModified": 1588761593, + "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=", + "owner": "stoeffel", + "repo": "nix-test-runner", + "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2", + "type": "github" + }, + "original": { + "owner": "stoeffel", + "repo": "nix-test-runner", + "type": "github" + } + }, "nix2container": { "inputs": { "nixpkgs": [ @@ -469,11 +881,11 @@ ] }, "locked": { - "lastModified": 1767430085, - "narHash": "sha256-SiXJ6xv4pS2MDUqfj0/mmG746cGeJrMQGmoFgHLS25Y=", + "lastModified": 1775487831, + "narHash": "sha256-2lguQpLPQaxpQCJjXhmEEAfabwsAhkP29Z7fgLzHARA=", "owner": "nlewo", "repo": "nix2container", - "rev": "66f4b8a47e92aa744ec43acbb5e9185078983909", + "rev": "76be9608a7f4d6c985d28b0e7be903ae2547df3e", "type": "github" }, "original": { @@ -488,7 +900,6 @@ "devenv", "flake-parts" ], - "flake-root": "flake-root", "nixpkgs": [ "devenv", "nixpkgs" @@ -496,11 +907,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1772441848, - "narHash": "sha256-H3W5PSJQTh8Yp51PGU3GUoGCcrD+y7nCsxYHQr+Orvw=", + "lastModified": 1773634079, + "narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=", "owner": "nix-community", "repo": "nixd", - "rev": "c896f916addae5b133ee0f4f01f9cd93906f62ea", + "rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd", "type": "github" }, "original": { @@ -510,31 +921,28 @@ } }, "nixpkgs": { - "inputs": { - "nixpkgs-src": "nixpkgs-src" - }, "locked": { - "lastModified": 1772749504, - "narHash": "sha256-eqtQIz0alxkQPym+Zh/33gdDjkkch9o6eHnMPnXFXN0=", - "owner": "cachix", - "repo": "devenv-nixpkgs", - "rev": "08543693199362c1fddb8f52126030d0d374ba2e", + "lastModified": 1765186076, + "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", "type": "github" }, "original": { - "owner": "cachix", - "ref": "rolling", - "repo": "devenv-nixpkgs", + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", "type": "github" } }, "nixpkgs-lib": { "locked": { - "lastModified": 1772328832, - "narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=", + "lastModified": 1774748309, + "narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742", + "rev": "333c4e0545a6da976206c74db8773a1645b5870a", "type": "github" }, "original": { @@ -561,11 +969,11 @@ "nixpkgs-src": { "flake": false, "locked": { - "lastModified": 1772173633, - "narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=", + "lastModified": 1773597492, + "narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6", + "rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef", "type": "github" }, "original": { @@ -576,6 +984,57 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1765186076, + "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1769433173, + "narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1773704619, + "narHash": "sha256-LKtmit8Sr81z8+N2vpIaN/fyiQJ8f7XJ6tMSKyDVQ9s=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "906534d75b0e2fe74a719559dfb1ad3563485f43", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { "locked": { "lastModified": 1762156382, "narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=", @@ -591,7 +1050,7 @@ "type": "github" } }, - "nixpkgs_3": { + "nixpkgs_6": { "locked": { "lastModified": 1770073757, "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=", @@ -607,7 +1066,7 @@ "type": "github" } }, - "nixpkgs_4": { + "nixpkgs_7": { "locked": { "lastModified": 1770197578, "narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=", @@ -621,13 +1080,13 @@ "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1" } }, - "nixpkgs_5": { + "nixpkgs_8": { "locked": { - "lastModified": 1772773019, - "narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=", + "lastModified": 1775423009, + "narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "aca4d95fce4914b3892661bcb80b8087293536c6", + "rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9", "type": "github" }, "original": { @@ -637,21 +1096,100 @@ "type": "github" } }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "flake-compat" + ], + "gitignore": "gitignore_3", + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769069492, + "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "pre-commit-hooks_2": { + "inputs": { + "flake-compat": [ + "devenv", + "crate2nix", + "flake-compat" + ], + "gitignore": "gitignore_4", + "nixpkgs": [ + "devenv", + "crate2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769069492, + "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, "root": { "inputs": { "devenv": "devenv", - "devshell": "devshell", - "flake-parts": "flake-parts_2", + "devshell": "devshell_3", + "flake-parts": "flake-parts_4", "git-hooks-nix": "git-hooks-nix", "github-actions-nix": "github-actions-nix", "go-overlay": "go-overlay", "mk-shell-bin": "mk-shell-bin", "nix-github-actions": "nix-github-actions", "nix2container": "nix2container", - "nixpkgs": "nixpkgs_5", + "nixpkgs": "nixpkgs_8", "treefmt-nix": "treefmt-nix_2" } }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773630837, + "narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -676,11 +1214,11 @@ ] }, "locked": { - "lastModified": 1734704479, - "narHash": "sha256-MMi74+WckoyEWBRcg/oaGRvXC9BVVxDZNRMpL+72wBI=", + "lastModified": 1772660329, + "narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "65712f5af67234dad91a5a4baee986a8b62dbf8f", + "rev": "3710e0e1218041bbad640352a0440114b1e10428", "type": "github" }, "original": { @@ -696,11 +1234,11 @@ ] }, "locked": { - "lastModified": 1772660329, - "narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=", + "lastModified": 1775125835, + "narHash": "sha256-2qYcPgzFhnQWchHo0SlqLHrXpux5i6ay6UHA+v2iH4U=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "3710e0e1218041bbad640352a0440114b1e10428", + "rev": "75925962939880974e3ab417879daffcba36c4a3", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index da48e82..5eab129 100644 --- a/flake.nix +++ b/flake.nix @@ -51,7 +51,7 @@ version = "0.0.1"; src = ./.; subPackages = ["examples/kubexport"]; - vendorHash = "sha256-4VAmGMPfMPt+BcDiFHZ68AlMmrOEg6pruxxw7pMo9GQ="; + vendorHash = "sha256-H/qvlPrzmaqQBN696JpQC6XkwZFASWpj8l7tXcH5dEU="; }; devenv.shells = let env = { diff --git a/go.mod b/go.mod index af160e6..7ad3d68 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 k8s.io/apimachinery v0.34.3 + k8s.io/client-go v0.34.3 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 sigs.k8s.io/yaml v1.6.0 ) @@ -36,7 +37,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/fatih/color v1.17.0 // indirect @@ -51,7 +52,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobuffalo/flect v1.0.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect @@ -102,12 +102,12 @@ require ( golang.org/x/tools v0.41.0 // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.31.0 // indirect + k8s.io/api v0.34.3 // indirect k8s.io/apiextensions-apiserver v0.31.0 // indirect - k8s.io/client-go v0.31.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect sigs.k8s.io/controller-runtime v0.19.0 // indirect diff --git a/go.sum b/go.sum index f6ca2fd..45cc579 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= @@ -116,17 +116,11 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -135,8 +129,6 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -240,6 +232,8 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -345,14 +339,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= -k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= -k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= @@ -367,8 +361,6 @@ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7np sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 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/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= From 0aa3199ac07c60d0500bae2a864ad643fa7b44c5 Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Tue, 7 Apr 2026 15:27:34 +0200 Subject: [PATCH 2/2] fix(ci): set markdownlint hook package explicitly --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 5eab129..1cf81cd 100644 --- a/flake.nix +++ b/flake.nix @@ -75,6 +75,7 @@ govet.enable = true; markdownlint = { enable = true; + package = pkgs.markdownlint-cli; settings.configuration = { MD010 = { code_blocks = false;