From eb4f60843be79f1af00f9e656accbc8c5c1b1d1e Mon Sep 17 00:00:00 2001 From: Cory Dolphin Date: Mon, 20 Apr 2026 21:01:35 +0000 Subject: [PATCH] feat: expose client kube context to accessCommand templates Add a `clientKubeContext` field to PodAccessRequestSpec and ExecAccessRequestSpec, populated automatically by `ozctl` from the effective kubeconfig context (the standard `--context` flag if set, otherwise the kubeconfig's `current-context`). The renderer surfaces this value as `{{ .ClientKubeContext }}` so an `accessCommand` can include `--context ` and produce a kubectl invocation that targets the same cluster the request was created in. The field is optional. The default `accessCommand` does not reference it, so behavior is unchanged for existing templates. Templates that opt into using it should gate the reference with a conditional so they remain safe when the field is empty (e.g. when the request is applied as raw YAML or by an older `ozctl`). --- ...crds.wizardofoz.co_execaccessrequests.yaml | 8 ++ ...rds.wizardofoz.co_execaccesstemplates.yaml | 12 ++- .../crds.wizardofoz.co_podaccessrequests.yaml | 8 ++ ...crds.wizardofoz.co_podaccesstemplates.yaml | 83 ++++++++++++++-- internal/api/v1alpha1/access_config.go | 12 ++- .../api/v1alpha1/exec_access_request_types.go | 9 ++ .../api/v1alpha1/pod_access_request_types.go | 9 ++ .../create_access_resources.go | 6 +- .../create_access_resources.go | 6 +- internal/builders/utils/access_command.go | 25 +++-- .../builders/utils/access_command_test.go | 33 ++++++- .../ozctl/cmd/create_exec_access_request.go | 7 +- .../ozctl/cmd/create_pod_access_request.go | 5 +- internal/cmd/ozctl/cmd/util.go | 22 +++++ internal/cmd/ozctl/cmd/util_test.go | 96 +++++++++++++++++++ 15 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 internal/cmd/ozctl/cmd/util_test.go diff --git a/config/crd/bases/crds.wizardofoz.co_execaccessrequests.yaml b/config/crd/bases/crds.wizardofoz.co_execaccessrequests.yaml index 28c47a96..7a42b2d9 100644 --- a/config/crd/bases/crds.wizardofoz.co_execaccessrequests.yaml +++ b/config/crd/bases/crds.wizardofoz.co_execaccessrequests.yaml @@ -52,6 +52,14 @@ spec: spec: description: ExecAccessRequestSpec defines the desired state of ExecAccessRequest properties: + clientKubeContext: + description: |- + ClientKubeContext is the name of the kubeconfig context that the client (typically `ozctl`) + was using when it created this request. The controller does not act on this value; it is + surfaced to the `accessCommand` template as `{{ .ClientKubeContext }}` so the rendered + command can include `--context ` and target the same cluster the request was created + in. Populated automatically by `ozctl`; safe to omit when applying YAML directly. + type: string duration: description: |- Duration sets the length of time from the `spec.creationTimestamp` that this object will live. After the diff --git a/config/crd/bases/crds.wizardofoz.co_execaccesstemplates.yaml b/config/crd/bases/crds.wizardofoz.co_execaccesstemplates.yaml index 30f505a6..1e05506e 100644 --- a/config/crd/bases/crds.wizardofoz.co_execaccesstemplates.yaml +++ b/config/crd/bases/crds.wizardofoz.co_execaccesstemplates.yaml @@ -55,7 +55,17 @@ spec: }} -- /bin/sh description: |- AccessCommand is used to describe to the user how they can make use of their temporary access. - The AccessCommand can reference data from a Pod ObjectMeta. + + The string is rendered as a Go text/template with the following variables available: + + - `{{ .Metadata }}`: the target Pod's ObjectMeta (e.g. `{{ .Metadata.Name }}`, + `{{ .Metadata.Namespace }}`). + - `{{ .ClientKubeContext }}`: the kubeconfig context the request was created in, + populated automatically by `ozctl`. May be the empty string if the request was + applied as raw YAML or by an older `ozctl` that did not set the field. To be + safe in either case, gate references with a conditional, e.g.: + + kubectl {{ if .ClientKubeContext }}--context {{ .ClientKubeContext }} {{ end }}exec -ti -n {{ .Metadata.Namespace }} {{ .Metadata.Name }} -- /bin/sh type: string allowedGroups: description: |- diff --git a/config/crd/bases/crds.wizardofoz.co_podaccessrequests.yaml b/config/crd/bases/crds.wizardofoz.co_podaccessrequests.yaml index a4f5f48a..a41855f2 100644 --- a/config/crd/bases/crds.wizardofoz.co_podaccessrequests.yaml +++ b/config/crd/bases/crds.wizardofoz.co_podaccessrequests.yaml @@ -52,6 +52,14 @@ spec: spec: description: PodAccessRequestSpec defines the desired state of AccessRequest properties: + clientKubeContext: + description: |- + ClientKubeContext is the name of the kubeconfig context that the client (typically `ozctl`) + was using when it created this request. The controller does not act on this value; it is + surfaced to the `accessCommand` template as `{{ .ClientKubeContext }}` so the rendered + command can include `--context ` and target the same cluster the request was created + in. Populated automatically by `ozctl`; safe to omit when applying YAML directly. + type: string duration: description: |- Duration sets the length of time from the `spec.creationTimestamp` that this object will live. After the diff --git a/config/crd/bases/crds.wizardofoz.co_podaccesstemplates.yaml b/config/crd/bases/crds.wizardofoz.co_podaccesstemplates.yaml index 201d507b..d5fda0dc 100644 --- a/config/crd/bases/crds.wizardofoz.co_podaccesstemplates.yaml +++ b/config/crd/bases/crds.wizardofoz.co_podaccesstemplates.yaml @@ -54,7 +54,17 @@ spec: }} -- /bin/sh description: |- AccessCommand is used to describe to the user how they can make use of their temporary access. - The AccessCommand can reference data from a Pod ObjectMeta. + + The string is rendered as a Go text/template with the following variables available: + + - `{{ .Metadata }}`: the target Pod's ObjectMeta (e.g. `{{ .Metadata.Name }}`, + `{{ .Metadata.Namespace }}`). + - `{{ .ClientKubeContext }}`: the kubeconfig context the request was created in, + populated automatically by `ozctl`. May be the empty string if the request was + applied as raw YAML or by an older `ozctl` that did not set the field. To be + safe in either case, gate references with a conditional, e.g.: + + kubectl {{ if .ClientKubeContext }}--context {{ .ClientKubeContext }} {{ end }}exec -ti -n {{ .Metadata.Namespace }} {{ .Metadata.Name }} -- /bin/sh type: string allowedGroups: description: |- @@ -2309,7 +2319,9 @@ spec: type: integer type: object resizePolicy: - description: Resources resize policy for the container. + description: |- + Resources resize policy for the container. + This field cannot be set on ephemeral containers. items: description: ContainerResizePolicy represents resource resize policy for the container. @@ -5518,7 +5530,9 @@ spec: type: integer type: object resizePolicy: - description: Resources resize policy for the container. + description: |- + Resources resize policy for the container. + This field cannot be set on ephemeral containers. items: description: ContainerResizePolicy represents resource resize policy for the container. @@ -6304,8 +6318,8 @@ spec: will be made available to those containers which consume them by name. - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. + This is a stable field but requires that the + DynamicResourceAllocation feature gate is enabled. This field is immutable. items: @@ -6763,9 +6777,10 @@ spec: operator: description: |- Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. + Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. Exists is equivalent to wildcard for value, so that a pod can tolerate all taints of a particular category. + Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). type: string tolerationSeconds: description: |- @@ -7555,7 +7570,7 @@ spec: resources: description: |- resources represents the minimum resources the volume should have. - If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + Users are allowed to specify resource requirements that are lower than previous value but must still be higher than capacity recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources @@ -8436,6 +8451,24 @@ spec: description: Kubelet's generated CSRs will be addressed to this signer. type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object required: - keyType - signerName @@ -8860,6 +8893,42 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + workloadRef: + description: |- + WorkloadRef provides a reference to the Workload object that this Pod belongs to. + This field is used by the scheduler to identify the PodGroup and apply the + correct group scheduling policies. The Workload object referenced + by this field may not exist at the time the Pod is created. + This field is immutable, but a Workload object with the same name + may be recreated with different policies. Doing this during pod scheduling + may result in the placement not conforming to the expected policies. + properties: + name: + description: |- + Name defines the name of the Workload object this Pod belongs to. + Workload must be in the same namespace as the Pod. + If it doesn't match any existing Workload, the Pod will remain unschedulable + until a Workload object is created and observed by the kube-scheduler. + It must be a DNS subdomain. + type: string + podGroup: + description: |- + PodGroup is the name of the PodGroup within the Workload that this Pod + belongs to. If it doesn't match any existing PodGroup within the Workload, + the Pod will remain unschedulable until the Workload object is recreated + and observed by the kube-scheduler. It must be a DNS label. + type: string + podGroupReplicaKey: + description: |- + PodGroupReplicaKey specifies the replica key of the PodGroup to which this + Pod belongs. It is used to distinguish pods belonging to different replicas + of the same pod group. The pod group policy is applied separately to each replica. + When set, it must be a DNS label. + type: string + required: + - name + - podGroup + type: object required: - containers type: object diff --git a/internal/api/v1alpha1/access_config.go b/internal/api/v1alpha1/access_config.go index 6e3775ec..923f70c1 100644 --- a/internal/api/v1alpha1/access_config.go +++ b/internal/api/v1alpha1/access_config.go @@ -31,7 +31,17 @@ type AccessConfig struct { MaxDuration string `json:"maxDuration"` // AccessCommand is used to describe to the user how they can make use of their temporary access. - // The AccessCommand can reference data from a Pod ObjectMeta. + // + // The string is rendered as a Go text/template with the following variables available: + // + // - `{{ .Metadata }}`: the target Pod's ObjectMeta (e.g. `{{ .Metadata.Name }}`, + // `{{ .Metadata.Namespace }}`). + // - `{{ .ClientKubeContext }}`: the kubeconfig context the request was created in, + // populated automatically by `ozctl`. May be the empty string if the request was + // applied as raw YAML or by an older `ozctl` that did not set the field. To be + // safe in either case, gate references with a conditional, e.g.: + // + // kubectl {{ if .ClientKubeContext }}--context {{ .ClientKubeContext }} {{ end }}exec -ti -n {{ .Metadata.Namespace }} {{ .Metadata.Name }} -- /bin/sh // // +kubebuilder:validation:Optional // +kubebuilder:default:="kubectl exec -ti -n {{ .Metadata.Namespace }} {{ .Metadata.Name }} -- /bin/sh" diff --git a/internal/api/v1alpha1/exec_access_request_types.go b/internal/api/v1alpha1/exec_access_request_types.go index 658245a7..9811c862 100644 --- a/internal/api/v1alpha1/exec_access_request_types.go +++ b/internal/api/v1alpha1/exec_access_request_types.go @@ -45,6 +45,15 @@ type ExecAccessRequestSpec struct { // // Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". Duration string `json:"duration,omitempty"` + + // ClientKubeContext is the name of the kubeconfig context that the client (typically `ozctl`) + // was using when it created this request. The controller does not act on this value; it is + // surfaced to the `accessCommand` template as `{{ .ClientKubeContext }}` so the rendered + // command can include `--context ` and target the same cluster the request was created + // in. Populated automatically by `ozctl`; safe to omit when applying YAML directly. + // + // +kubebuilder:validation:Optional + ClientKubeContext string `json:"clientKubeContext,omitempty"` } // ExecAccessRequestStatus defines the observed state of ExecAccessRequest diff --git a/internal/api/v1alpha1/pod_access_request_types.go b/internal/api/v1alpha1/pod_access_request_types.go index b8a654f6..14c02450 100644 --- a/internal/api/v1alpha1/pod_access_request_types.go +++ b/internal/api/v1alpha1/pod_access_request_types.go @@ -47,6 +47,15 @@ type PodAccessRequestSpec struct { // +kubebuilder:validation:Optional // +kubebuilder:validation:Pattern="^[0-9]+(s|m|h)$" Duration string `json:"duration,omitempty"` + + // ClientKubeContext is the name of the kubeconfig context that the client (typically `ozctl`) + // was using when it created this request. The controller does not act on this value; it is + // surfaced to the `accessCommand` template as `{{ .ClientKubeContext }}` so the rendered + // command can include `--context ` and target the same cluster the request was created + // in. Populated automatically by `ozctl`; safe to omit when applying YAML directly. + // + // +kubebuilder:validation:Optional + ClientKubeContext string `json:"clientKubeContext,omitempty"` } // PodAccessRequestStatus defines the observed state of AccessRequest diff --git a/internal/builders/execaccessbuilder/create_access_resources.go b/internal/builders/execaccessbuilder/create_access_resources.go index 4ad4e4d4..191518cf 100644 --- a/internal/builders/execaccessbuilder/create_access_resources.go +++ b/internal/builders/execaccessbuilder/create_access_resources.go @@ -61,7 +61,11 @@ func (b *ExecAccessBuilder) CreateAccessResources( return statusString, err } - accessString, err := bldutil.CreateAccessCommand(execTmpl.Spec.AccessConfig.AccessCommand, targetPod.ObjectMeta) + accessString, err := bldutil.CreateAccessCommand( + execTmpl.Spec.AccessConfig.AccessCommand, + targetPod.ObjectMeta, + execReq.Spec.ClientKubeContext, + ) if err != nil { return "", err } diff --git a/internal/builders/podaccessbuilder/create_access_resources.go b/internal/builders/podaccessbuilder/create_access_resources.go index 54987ec7..fce72cc4 100644 --- a/internal/builders/podaccessbuilder/create_access_resources.go +++ b/internal/builders/podaccessbuilder/create_access_resources.go @@ -81,7 +81,11 @@ func (b *PodAccessBuilder) CreateAccessResources( return statusString, err } - accessString, err := bldutil.CreateAccessCommand(podTmpl.Spec.AccessConfig.AccessCommand, pod.ObjectMeta) + accessString, err := bldutil.CreateAccessCommand( + podTmpl.Spec.AccessConfig.AccessCommand, + pod.ObjectMeta, + podReq.Spec.ClientKubeContext, + ) if err != nil { return "", err } diff --git a/internal/builders/utils/access_command.go b/internal/builders/utils/access_command.go index 127ab47e..8c5be641 100644 --- a/internal/builders/utils/access_command.go +++ b/internal/builders/utils/access_command.go @@ -7,13 +7,24 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// CreateAccessCommand templates an access command string, -// evaluates data from a pod.ObjectMeta -func CreateAccessCommand(cmdString string, resource metav1.ObjectMeta) (string, error) { - type md struct { - Metadata metav1.ObjectMeta +// CreateAccessCommand templates an access command string. The template can +// reference `{{ .Metadata }}` (the target pod's ObjectMeta) and +// `{{ .ClientKubeContext }}` (the kubeconfig context the request was created +// in, populated by `ozctl`; empty string when the request was applied as raw +// YAML). +func CreateAccessCommand( + cmdString string, + resource metav1.ObjectMeta, + clientKubeContext string, +) (string, error) { + type data struct { + Metadata metav1.ObjectMeta + ClientKubeContext string + } + d := data{ + Metadata: resource, + ClientKubeContext: clientKubeContext, } - m := md{resource} tmpl, err := template.New("accessCommand").Parse(cmdString) if err != nil { @@ -21,7 +32,7 @@ func CreateAccessCommand(cmdString string, resource metav1.ObjectMeta) (string, } var buf bytes.Buffer - if err := tmpl.Execute(&buf, m); err != nil { + if err := tmpl.Execute(&buf, d); err != nil { return "", err } return buf.String(), nil diff --git a/internal/builders/utils/access_command_test.go b/internal/builders/utils/access_command_test.go index f219f1c4..fa65af67 100644 --- a/internal/builders/utils/access_command_test.go +++ b/internal/builders/utils/access_command_test.go @@ -9,8 +9,9 @@ import ( func TestCreateAccessCommand(t *testing.T) { type args struct { - cmdString string - resource *v1alpha1.ExecAccessTemplate + cmdString string + resource *v1alpha1.ExecAccessTemplate + clientKubeContext string } tests := []struct { name string @@ -32,10 +33,36 @@ func TestCreateAccessCommand(t *testing.T) { want: "kubectl exec -ti -n namespace podName -- /bin/sh", wantErr: false, }, + { + name: "client kube context is interpolated", + args: args{ + cmdString: "kubectl --context {{ .ClientKubeContext }} exec -ti -n {{ .Metadata.Namespace }} {{ .Metadata.Name }} -- /bin/sh", + resource: &v1alpha1.ExecAccessTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "podName", + Namespace: "namespace", + }, + }, + clientKubeContext: "us1", + }, + want: "kubectl --context us1 exec -ti -n namespace podName -- /bin/sh", + wantErr: false, + }, + { + name: "empty client kube context renders as empty string", + args: args{ + cmdString: "ctx=[{{ .ClientKubeContext }}]", + resource: &v1alpha1.ExecAccessTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: "n"}, + }, + }, + want: "ctx=[]", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := CreateAccessCommand(tt.args.cmdString, tt.args.resource.ObjectMeta) + got, err := CreateAccessCommand(tt.args.cmdString, tt.args.resource.ObjectMeta, tt.args.clientKubeContext) if (err != nil) != tt.wantErr { t.Errorf("CreateAccessCommand() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/internal/cmd/ozctl/cmd/create_exec_access_request.go b/internal/cmd/ozctl/cmd/create_exec_access_request.go index 80647b42..7c2a98b8 100644 --- a/internal/cmd/ozctl/cmd/create_exec_access_request.go +++ b/internal/cmd/ozctl/cmd/create_exec_access_request.go @@ -83,9 +83,10 @@ var createExecAccessRequestCmd = &cobra.Command{ Namespace: namespace, }, Spec: api.ExecAccessRequestSpec{ - TemplateName: template, - Duration: duration, - TargetPod: targetPod, + TemplateName: template, + Duration: duration, + TargetPod: targetPod, + ClientKubeContext: getCurrentKubeContext(), }, } diff --git a/internal/cmd/ozctl/cmd/create_pod_access_request.go b/internal/cmd/ozctl/cmd/create_pod_access_request.go index af681ff6..4c437b0f 100644 --- a/internal/cmd/ozctl/cmd/create_pod_access_request.go +++ b/internal/cmd/ozctl/cmd/create_pod_access_request.go @@ -69,8 +69,9 @@ var createPodAccessRequestCmd = &cobra.Command{ Namespace: namespace, }, Spec: api.PodAccessRequestSpec{ - TemplateName: templateName, - Duration: duration, + TemplateName: templateName, + Duration: duration, + ClientKubeContext: getCurrentKubeContext(), }, } diff --git a/internal/cmd/ozctl/cmd/util.go b/internal/cmd/ozctl/cmd/util.go index 3610ed08..55f53ef3 100644 --- a/internal/cmd/ozctl/cmd/util.go +++ b/internal/cmd/ozctl/cmd/util.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/fatih/color" + "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -38,3 +39,24 @@ func getKubeClient() (cl client.Client, ns string) { cl = client.NewNamespacedClient(rawCl, ns) return cl, ns } + +// getCurrentKubeContext returns the kubeconfig context that the CLI is talking +// to from the package-level kubeConfigFlags. +func getCurrentKubeContext() string { + return kubeContextFromFlags(kubeConfigFlags) +} + +// kubeContextFromFlags returns the kubeconfig context that the supplied flags +// resolve to: the value of the standard `--context` flag if set, otherwise the +// kubeconfig's `current-context`. Returns "" if no context is set anywhere — +// callers should treat that as "unknown" and not error. +func kubeContextFromFlags(flags *genericclioptions.ConfigFlags) string { + if flags.Context != nil && *flags.Context != "" { + return *flags.Context + } + rawConfig, err := flags.ToRawKubeConfigLoader().RawConfig() + if err != nil { + return "" + } + return rawConfig.CurrentContext +} diff --git a/internal/cmd/ozctl/cmd/util_test.go b/internal/cmd/ozctl/cmd/util_test.go new file mode 100644 index 00000000..c8b161b0 --- /dev/null +++ b/internal/cmd/ozctl/cmd/util_test.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +const fakeKubeconfig = `apiVersion: v1 +kind: Config +current-context: kube-current +clusters: +- name: c1 + cluster: + server: https://example.invalid +contexts: +- name: kube-current + context: + cluster: c1 + user: u1 +- name: kube-other + context: + cluster: c1 + user: u1 +users: +- name: u1 + user: {} +` + +func writeTempKubeconfig(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(fakeKubeconfig), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + return path +} + +func TestKubeContextFromFlags(t *testing.T) { + kubeconfigPath := writeTempKubeconfig(t) + empty := "" + override := "kube-other" + + tests := []struct { + name string + setup func() *genericclioptions.ConfigFlags + want string + }{ + { + name: "context flag wins over kubeconfig current-context", + setup: func() *genericclioptions.ConfigFlags { + f := genericclioptions.NewConfigFlags(false) + f.KubeConfig = &kubeconfigPath + f.Context = &override + return f + }, + want: "kube-other", + }, + { + name: "falls back to kubeconfig current-context when flag is empty", + setup: func() *genericclioptions.ConfigFlags { + f := genericclioptions.NewConfigFlags(false) + f.KubeConfig = &kubeconfigPath + f.Context = &empty + return f + }, + want: "kube-current", + }, + { + name: "returns empty string when no kubeconfig is loadable", + setup: func() *genericclioptions.ConfigFlags { + // Point at a non-existent kubeconfig. We also clear KUBECONFIG + // from the env so the loader can't fall back to it. + t.Setenv("KUBECONFIG", "/nonexistent/path/that/does/not/exist") + bogus := "/nonexistent/path/that/does/not/exist" + f := genericclioptions.NewConfigFlags(false) + f.KubeConfig = &bogus + f.Context = &empty + return f + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := kubeContextFromFlags(tt.setup()) + if got != tt.want { + t.Errorf("kubeContextFromFlags() = %q, want %q", got, tt.want) + } + }) + } +}