From 0d09e609e40327b452d767e252d24d04bdc2f376 Mon Sep 17 00:00:00 2001 From: ROKUMATE Date: Wed, 8 Jul 2026 12:19:49 +0530 Subject: [PATCH] add validate flag to podman kube play ignore warn and strict modes for unknown yaml fields and kinds ignore is the default and skips them warn reports them strict fails warnings are returned in the play report so they show up in the cli and over the api for tools like podman desktop Closes #18332 Signed-off-by: ROKUMATE --- cmd/podman/common/completion.go | 5 + cmd/podman/kube/play.go | 15 +++ docs/source/markdown/podman-kube-play.1.md.in | 13 ++ pkg/api/handlers/libpod/kube.go | 9 ++ pkg/api/server/register_kube.go | 11 ++ pkg/bindings/kube/types.go | 3 + pkg/bindings/kube/types_play_options.go | 15 +++ pkg/domain/entities/play.go | 37 ++++++ pkg/domain/entities/types/play.go | 3 + pkg/domain/infra/abi/play.go | 115 ++++++++++++++---- pkg/domain/infra/abi/play_test.go | 86 ++++++++++++- pkg/domain/infra/tunnel/kube.go | 2 +- test/apiv2/80-kube.at | 44 +++++++ test/e2e/play_kube_test.go | 114 +++++++++++++++++ 14 files changed, 446 insertions(+), 26 deletions(-) diff --git a/cmd/podman/common/completion.go b/cmd/podman/common/completion.go index 54317288a87..7bae7db16d3 100644 --- a/cmd/podman/common/completion.go +++ b/cmd/podman/common/completion.go @@ -2006,6 +2006,11 @@ func AutocompleteHealthOnFailure(_ *cobra.Command, _ []string, _ string) ([]stri return define.SupportedHealthCheckOnFailureActions, cobra.ShellCompDirectiveNoFileComp } +// AutocompleteKubePlayValidate - autocomplete the values for the kube play --validate flag. +func AutocompleteKubePlayValidate(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return entities.KubeValidateModeNames(), cobra.ShellCompDirectiveNoFileComp +} + // AutocompleteSysctl - autocomplete list all sysctl names func AutocompleteSysctl(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { var completions []string diff --git a/cmd/podman/kube/play.go b/cmd/podman/kube/play.go index c7ec3d35cd4..dccc92f5990 100644 --- a/cmd/podman/kube/play.go +++ b/cmd/podman/kube/play.go @@ -21,6 +21,7 @@ import ( "go.podman.io/podman/v6/cmd/podman/parse" "go.podman.io/podman/v6/cmd/podman/registry" "go.podman.io/podman/v6/cmd/podman/utils" + "go.podman.io/podman/v6/cmd/podman/validate" "go.podman.io/podman/v6/libpod/define" "go.podman.io/podman/v6/libpod/shutdown" "go.podman.io/podman/v6/pkg/annotations" @@ -38,6 +39,7 @@ type playKubeOptionsWrapper struct { CredentialsCLI string StartCLI bool BuildCLI bool + ValidateCLI string annotations []string macs []string } @@ -142,6 +144,12 @@ func playFlags(cmd *cobra.Command) { ) _ = cmd.RegisterFlagCompletionFunc(usernsFlagName, common.AutocompleteUserNamespace) + playOptions.ValidateCLI = string(entities.KubeValidateIgnore) + validateChoice := validate.Value(&playOptions.ValidateCLI, entities.KubeValidateModeNames()...) + validateFlagName := "validate" + flags.Var(validateChoice, validateFlagName, "How to handle unrecognized YAML fields and objects: "+validateChoice.Choices()) + _ = cmd.RegisterFlagCompletionFunc(validateFlagName, common.AutocompleteKubePlayValidate) + flags.BoolVar(&playOptions.NoHostname, "no-hostname", false, "Do not create /etc/hostname within the container, instead use the version from the image") flags.BoolVar(&playOptions.NoHosts, "no-hosts", podmanConfig.ContainersConfDefaultsRO.Containers.NoHosts, "Do not create /etc/hosts within the pod's containers, instead use the version from the image") flags.BoolVarP(&playOptions.Quiet, "quiet", "q", false, "Suppress output information when pulling images") @@ -217,6 +225,8 @@ func play(cmd *cobra.Command, args []string) error { if playOptions.ServiceContainer && !playOptions.StartCLI { // Sanity check to be future proof return fmt.Errorf("--service-container does not work with --start=stop") } + // The --validate value is enforced at flag-parse time by validate.Value. + playOptions.Validate = entities.KubeValidateMode(playOptions.ValidateCLI) // TLS verification in c/image is controlled via a `types.OptionalBool` // which allows for distinguishing among set-true, set-false, unspecified // which is important to implement a sane way of dealing with defaults of @@ -517,6 +527,11 @@ func kubeplay(body io.Reader) error { // printPlayReport goes through the report returned by KubePlay and prints it out in a human // friendly format. func printPlayReport(report *entities.PlayKubeReport) error { + // Print any validation warnings (for example --validate=warn) to stderr. + for _, warning := range report.ValidationWarnings { + fmt.Fprintln(os.Stderr, "Warning:", warning) + } + // Print volumes report for i, volume := range report.Volumes { if i == 0 { diff --git a/docs/source/markdown/podman-kube-play.1.md.in b/docs/source/markdown/podman-kube-play.1.md.in index 8efd391df9f..687bd1e5990 100644 --- a/docs/source/markdown/podman-kube-play.1.md.in +++ b/docs/source/markdown/podman-kube-play.1.md.in @@ -296,6 +296,19 @@ Start the pod after creating it, set to false to only create it. @@option userns.container +#### **--validate**=*mode* + +Control how unrecognized YAML fields and unsupported objects are handled. Supported modes are: + +- **ignore**: silently skip them (default). +- **warn**: report a warning and continue. Warnings are printed to stderr and, when using the API, returned in the play report. +- **strict**: fail with an error. + +When **strict** is used, any object that fails validation halts the processing of +further objects, but prior objects will still exist and will not be rolled back. +For example, in a YAML file containing a Pod followed by an unsupported object, the +Pod is created and then the command fails. + #### **--wait**, **-w** Run pods and containers in the foreground. Default is false. diff --git a/pkg/api/handlers/libpod/kube.go b/pkg/api/handlers/libpod/kube.go index e2e99854e70..ec9b4f58400 100644 --- a/pkg/api/handlers/libpod/kube.go +++ b/pkg/api/handlers/libpod/kube.go @@ -120,6 +120,7 @@ func KubePlay(w http.ResponseWriter, r *http.Request) { StaticMACs []string `schema:"staticMACs"` TLSVerify bool `schema:"tlsVerify"` Userns string `schema:"userns"` + Validate string `schema:"validate"` Wait bool `schema:"wait"` Build bool `schema:"build"` NoPodPrefix bool `schema:"noPodPrefix"` @@ -133,6 +134,13 @@ func KubePlay(w http.ResponseWriter, r *http.Request) { return } + // An empty value is allowed for backward compatibility with older clients + // that do not send the parameter; it is treated as the default (ignore). + if query.Validate != "" && !entities.KubeValidateMode(query.Validate).IsValid() { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("invalid validate value %q", query.Validate)) + return + } + staticIPs := make([]net.IP, 0, len(query.StaticIPs)) for _, ipString := range query.StaticIPs { ip := net.ParseIP(ipString) @@ -196,6 +204,7 @@ func KubePlay(w http.ResponseWriter, r *http.Request) { UseLongAnnotations: query.NoTrunc, Username: username, Userns: query.Userns, + Validate: entities.KubeValidateMode(query.Validate), Wait: query.Wait, ContextDir: contextDirectory, NoPodPrefix: query.NoPodPrefix, diff --git a/pkg/api/server/register_kube.go b/pkg/api/server/register_kube.go index 37738f2dd2e..0fe20ec420b 100644 --- a/pkg/api/server/register_kube.go +++ b/pkg/api/server/register_kube.go @@ -135,6 +135,15 @@ func (s *APIServer) registerKubeHandlers(r *mux.Router) error { // type: string // description: Set the user namespace mode for the pods. // - in: query + // name: validate + // type: string + // default: ignore + // enum: [ignore, warn, strict] + // description: | + // How to handle unrecognized YAML fields and unsupported objects. "ignore" skips them, + // "warn" returns them in the ValidationWarnings field of the response, and "strict" + // fails the request. An empty value is treated as "ignore". + // - in: query // name: wait // type: boolean // default: false @@ -153,6 +162,8 @@ func (s *APIServer) registerKubeHandlers(r *mux.Router) error { // responses: // 200: // $ref: "#/responses/playKubeResponseLibpod" + // 400: + // $ref: "#/responses/badParamError" // 500: // $ref: "#/responses/internalError" r.HandleFunc(VersionedPath("/libpod/play/kube"), s.APIHandler(libpod.PlayKube)).Methods(http.MethodPost) diff --git a/pkg/bindings/kube/types.go b/pkg/bindings/kube/types.go index 38c22b1c607..45256b4cfe2 100644 --- a/pkg/bindings/kube/types.go +++ b/pkg/bindings/kube/types.go @@ -53,6 +53,9 @@ type PlayOptions struct { NoTrunc *bool // Userns - define the user namespace to use. Userns *string + // Validate - how to handle unrecognized YAML fields and kinds: + // "ignore", "warn", or "strict". + Validate *string // Force - remove volumes on --down Force *bool // PublishPorts - configure how to expose ports configured inside the K8S YAML file diff --git a/pkg/bindings/kube/types_play_options.go b/pkg/bindings/kube/types_play_options.go index 7a04650968e..3362a3816dd 100644 --- a/pkg/bindings/kube/types_play_options.go +++ b/pkg/bindings/kube/types_play_options.go @@ -333,6 +333,21 @@ func (o *PlayOptions) GetUserns() string { return *o.Userns } +// WithValidate set field Validate to given value +func (o *PlayOptions) WithValidate(value string) *PlayOptions { + o.Validate = &value + return o +} + +// GetValidate returns value of field Validate +func (o *PlayOptions) GetValidate() string { + if o.Validate == nil { + var z string + return z + } + return *o.Validate +} + // WithForce set field Force to given value func (o *PlayOptions) WithForce(value bool) *PlayOptions { o.Force = &value diff --git a/pkg/domain/entities/play.go b/pkg/domain/entities/play.go index 687712569e2..a8cdde94165 100644 --- a/pkg/domain/entities/play.go +++ b/pkg/domain/entities/play.go @@ -7,6 +7,41 @@ import ( entitiesTypes "go.podman.io/podman/v6/pkg/domain/entities/types" ) +// KubeValidateMode controls how `podman kube play` handles unrecognized YAML +// fields and unsupported kinds. +type KubeValidateMode string + +const ( + // KubeValidateIgnore silently skips unrecognized fields and kinds. + KubeValidateIgnore KubeValidateMode = "ignore" + // KubeValidateWarn logs a warning for unrecognized fields and kinds. + KubeValidateWarn KubeValidateMode = "warn" + // KubeValidateStrict fails on unrecognized fields and kinds. + KubeValidateStrict KubeValidateMode = "strict" +) + +// supportedKubeValidateModes is the set of accepted --validate values. +var supportedKubeValidateModes = map[KubeValidateMode]bool{ + KubeValidateIgnore: true, + KubeValidateWarn: true, + KubeValidateStrict: true, +} + +// IsValid reports whether m is a supported validate mode. +func (m KubeValidateMode) IsValid() bool { + return supportedKubeValidateModes[m] +} + +// KubeValidateModeNames returns the supported --validate values as strings, in +// order of increasing strictness, for use in shell completion and error messages. +func KubeValidateModeNames() []string { + return []string{ + string(KubeValidateIgnore), + string(KubeValidateWarn), + string(KubeValidateStrict), + } +} + // PlayKubeOptions controls playing kube YAML files. type PlayKubeOptions struct { // Annotations - Annotations to add to Pods @@ -83,6 +118,8 @@ type PlayKubeOptions struct { SystemContext *types.SystemContext // Do not prefix container name with pod name NoPodPrefix bool + // Validate controls how unrecognized YAML fields and kinds are handled. + Validate KubeValidateMode } // PlayKubePod represents a single pod and associated containers created by play kube diff --git a/pkg/domain/entities/types/play.go b/pkg/domain/entities/types/play.go index 7f744106c34..40afafb2179 100644 --- a/pkg/domain/entities/types/play.go +++ b/pkg/domain/entities/types/play.go @@ -29,6 +29,9 @@ type PlayKubeReport struct { Secrets []PlaySecret // ServiceContainerID - ID of the service container if one is created ServiceContainerID string + // ValidationWarnings - non-fatal messages produced by --validate=warn, for + // example unrecognized YAML fields or unsupported kinds. + ValidationWarnings []string // If set, exit with the specified exit code. ExitCode *int32 } diff --git a/pkg/domain/infra/abi/play.go b/pkg/domain/infra/abi/play.go index ec74751fd55..01402053b17 100644 --- a/pkg/domain/infra/abi/play.go +++ b/pkg/domain/infra/abi/play.go @@ -355,9 +355,11 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options var podYAML v1.Pod var podTemplateSpec v1.PodTemplateSpec - if err := yaml.Unmarshal(document, &podYAML); err != nil { - return nil, fmt.Errorf("unable to read YAML as Kube Pod: %w", err) + warnings, err := unmarshalKubeObject("Pod", options.Validate, document, &podYAML) + if err != nil { + return nil, err } + report.ValidationWarnings = append(report.ValidationWarnings, warnings...) podTemplateSpec.ObjectMeta = podYAML.ObjectMeta podTemplateSpec.Spec = podYAML.Spec @@ -380,14 +382,17 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options notifyProxies = append(notifyProxies, proxies...) report.Pods = append(report.Pods, r.Pods...) + report.ValidationWarnings = append(report.ValidationWarnings, r.ValidationWarnings...) validKinds++ setRanContainers(r) case "DaemonSet": var daemonSetYAML v1apps.DaemonSet - if err := yaml.Unmarshal(document, &daemonSetYAML); err != nil { - return nil, fmt.Errorf("unable to read YAML as Kube DaemonSet: %w", err) + warnings, err := unmarshalKubeObject("DaemonSet", options.Validate, document, &daemonSetYAML) + if err != nil { + return nil, err } + report.ValidationWarnings = append(report.ValidationWarnings, warnings...) r, proxies, err := ic.playKubeDaemonSet(ctx, &daemonSetYAML, options, &ipIndex, configMaps, serviceContainer) if err != nil { @@ -396,14 +401,17 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options notifyProxies = append(notifyProxies, proxies...) report.Pods = append(report.Pods, r.Pods...) + report.ValidationWarnings = append(report.ValidationWarnings, r.ValidationWarnings...) validKinds++ setRanContainers(r) case "Deployment": var deploymentYAML v1apps.Deployment - if err := yaml.Unmarshal(document, &deploymentYAML); err != nil { - return nil, fmt.Errorf("unable to read YAML as Kube Deployment: %w", err) + warnings, err := unmarshalKubeObject("Deployment", options.Validate, document, &deploymentYAML) + if err != nil { + return nil, err } + report.ValidationWarnings = append(report.ValidationWarnings, warnings...) r, proxies, err := ic.playKubeDeployment(ctx, &deploymentYAML, options, &ipIndex, configMaps, serviceContainer) if err != nil { @@ -412,14 +420,17 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options notifyProxies = append(notifyProxies, proxies...) report.Pods = append(report.Pods, r.Pods...) + report.ValidationWarnings = append(report.ValidationWarnings, r.ValidationWarnings...) validKinds++ setRanContainers(r) case "Job": var jobYAML v1.Job - if err := yaml.Unmarshal(document, &jobYAML); err != nil { - return nil, fmt.Errorf("unable to read YAML as Kube Job: %w", err) + warnings, err := unmarshalKubeObject("Job", options.Validate, document, &jobYAML) + if err != nil { + return nil, err } + report.ValidationWarnings = append(report.ValidationWarnings, warnings...) r, proxies, err := ic.playKubeJob(ctx, &jobYAML, options, &ipIndex, configMaps, serviceContainer) if err != nil { @@ -428,14 +439,17 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options notifyProxies = append(notifyProxies, proxies...) report.Pods = append(report.Pods, r.Pods...) + report.ValidationWarnings = append(report.ValidationWarnings, r.ValidationWarnings...) validKinds++ setRanContainers(r) case "PersistentVolumeClaim": var pvcYAML v1.PersistentVolumeClaim - if err := yaml.Unmarshal(document, &pvcYAML); err != nil { - return nil, fmt.Errorf("unable to read YAML as Kube PersistentVolumeClaim: %w", err) + warnings, err := unmarshalKubeObject("PersistentVolumeClaim", options.Validate, document, &pvcYAML) + if err != nil { + return nil, err } + report.ValidationWarnings = append(report.ValidationWarnings, warnings...) for name, val := range options.Annotations { if pvcYAML.Annotations == nil { @@ -460,16 +474,20 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options case "ConfigMap": var configMap v1.ConfigMap - if err := yaml.Unmarshal(document, &configMap); err != nil { - return nil, fmt.Errorf("unable to read YAML as Kube ConfigMap: %w", err) + warnings, err := unmarshalKubeObject("ConfigMap", options.Validate, document, &configMap) + if err != nil { + return nil, err } + report.ValidationWarnings = append(report.ValidationWarnings, warnings...) configMaps = append(configMaps, configMap) case "Secret": var secret v1.Secret - if err := yaml.Unmarshal(document, &secret); err != nil { - return nil, fmt.Errorf("unable to read YAML as kube secret: %w", err) + warnings, err := unmarshalKubeObject("Secret", options.Validate, document, &secret) + if err != nil { + return nil, err } + report.ValidationWarnings = append(report.ValidationWarnings, warnings...) r, err := ic.playKubeSecret(&secret) if err != nil { @@ -478,7 +496,15 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options report.Secrets = append(report.Secrets, entities.PlaySecret{CreateReport: r}) validKinds++ default: - logrus.Infof("Kube kind %s not supported", kind) + msg := fmt.Sprintf("kube kind %q is not supported", kind) + switch options.Validate { + case entities.KubeValidateStrict: + return nil, errors.New(msg) + case entities.KubeValidateWarn: + report.ValidationWarnings = append(report.ValidationWarnings, msg) + default: + logrus.Info(msg) + } continue } } @@ -541,6 +567,40 @@ func (ic *ContainerEngine) PlayKube(ctx context.Context, body io.Reader, options return report, nil } +// unmarshalKubeObject decodes a kube YAML document into obj, honoring the +// validation mode for unrecognized fields, and returns any non-fatal warnings. +// In "ignore" mode the document is decoded leniently. In "strict" mode an +// unknown field is a fatal error. In "warn" mode an unknown field is decoded +// leniently and returned as a warning so the caller can surface it (in the +// report and/or logs). +func unmarshalKubeObject(kind string, mode entities.KubeValidateMode, document []byte, obj any) ([]string, error) { + switch mode { + case entities.KubeValidateStrict: + if err := yaml.UnmarshalStrict(document, obj); err != nil { + return nil, fmt.Errorf("validating kube %s: %w", kind, err) + } + case entities.KubeValidateWarn: + if err := yaml.UnmarshalStrict(document, obj); err != nil { + warning := fmt.Sprintf("kube %s: %v", kind, err) + if err := decodeKubeObject(kind, document, obj); err != nil { + return nil, err + } + return []string{warning}, nil + } + default: // ignore, plus an unset value sent by an older remote client + return nil, decodeKubeObject(kind, document, obj) + } + return nil, nil +} + +// decodeKubeObject leniently decodes a kube YAML document into obj. +func decodeKubeObject(kind string, document []byte, obj any) error { + if err := yaml.Unmarshal(document, obj); err != nil { + return fmt.Errorf("unable to read YAML as Kube %s: %w", kind, err) + } + return nil +} + func (ic *ContainerEngine) playKubeDaemonSet(ctx context.Context, daemonSetYAML *v1apps.DaemonSet, options entities.PlayKubeOptions, ipIndex *int, configMaps []v1.ConfigMap, serviceContainer *libpod.Container) (*entities.PlayKubeReport, []*notifyproxy.NotifyProxy, error) { var ( daemonSetName string @@ -560,6 +620,7 @@ func (ic *ContainerEngine) playKubeDaemonSet(ctx context.Context, daemonSetYAML return nil, nil, fmt.Errorf("encountered while bringing up pod %s: %w", podName, err) } report.Pods = podReport.Pods + report.ValidationWarnings = podReport.ValidationWarnings return &report, proxies, nil } @@ -591,6 +652,7 @@ func (ic *ContainerEngine) playKubeDeployment(ctx context.Context, deploymentYAM return nil, nil, fmt.Errorf("encountered while bringing up pod %s: %w", podName, err) } report.Pods = podReport.Pods + report.ValidationWarnings = podReport.ValidationWarnings return &report, proxies, nil } @@ -614,6 +676,7 @@ func (ic *ContainerEngine) playKubeJob(ctx context.Context, jobYAML *v1.Job, opt return nil, nil, fmt.Errorf("encountered while bringing up pod %s: %w", podName, err) } report.Pods = podReport.Pods + report.ValidationWarnings = podReport.ValidationWarnings return &report, proxies, nil } @@ -761,10 +824,11 @@ func (ic *ContainerEngine) playKubePod(ctx context.Context, podName string, podY } defer f.Close() - cms, err := readConfigMapFromFile(f) + cms, cmWarnings, err := readConfigMapFromFile(f, options.Validate) if err != nil { return nil, nil, fmt.Errorf("%q: %w", p, err) } + report.ValidationWarnings = append(report.ValidationWarnings, cmWarnings...) for _, cm := range cms { if _, present := configMapIndex[cm.Name]; present { @@ -1519,38 +1583,41 @@ func (ic *ContainerEngine) importVolume(ctx context.Context, vol *libpod.Volume, } // readConfigMapFromFile returns a kubernetes configMap obtained from --configmap flag -func readConfigMapFromFile(r io.Reader) ([]v1.ConfigMap, error) { +func readConfigMapFromFile(r io.Reader, mode entities.KubeValidateMode) ([]v1.ConfigMap, []string, error) { configMaps := make([]v1.ConfigMap, 0) + var warnings []string content, err := io.ReadAll(r) if err != nil { - return nil, fmt.Errorf("unable to read ConfigMap YAML content: %w", err) + return nil, nil, fmt.Errorf("unable to read ConfigMap YAML content: %w", err) } // split yaml document documentList, err := splitMultiDocYAML(content) if err != nil { - return nil, fmt.Errorf("unable to read as kube YAML: %w", err) + return nil, nil, fmt.Errorf("unable to read as kube YAML: %w", err) } for _, document := range documentList { kind, err := getKubeKind(document) if err != nil { - return nil, fmt.Errorf("unable to read as kube YAML: %w", err) + return nil, nil, fmt.Errorf("unable to read as kube YAML: %w", err) } if kind != "ConfigMap" { - return nil, fmt.Errorf("invalid YAML kind: %q. [ConfigMap] is the only supported by --configmap", kind) + return nil, nil, fmt.Errorf("invalid YAML kind: %q. [ConfigMap] is the only supported by --configmap", kind) } var configMap v1.ConfigMap - if err := yaml.Unmarshal(document, &configMap); err != nil { - return nil, fmt.Errorf("unable to read YAML as Kube ConfigMap: %w", err) + docWarnings, err := unmarshalKubeObject("ConfigMap", mode, document, &configMap) + if err != nil { + return nil, nil, err } + warnings = append(warnings, docWarnings...) configMaps = append(configMaps, configMap) } - return configMaps, nil + return configMaps, warnings, nil } // splitMultiDocYAML reads multiple documents in a YAML file and diff --git a/pkg/domain/infra/abi/play_test.go b/pkg/domain/infra/abi/play_test.go index 8a06f69c9be..beb66797583 100644 --- a/pkg/domain/infra/abi/play_test.go +++ b/pkg/domain/infra/abi/play_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "go.podman.io/podman/v6/pkg/domain/entities" v1 "go.podman.io/podman/v6/pkg/k8s.io/api/core/v1" v12 "go.podman.io/podman/v6/pkg/k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -152,7 +153,7 @@ data: for _, test := range tests { t.Run(test.name, func(t *testing.T) { buf := bytes.NewReader([]byte(test.configMapContent)) - cm, err := readConfigMapFromFile(buf) + cm, _, err := readConfigMapFromFile(buf, entities.KubeValidateIgnore) if test.expectError { assert.Error(t, err) @@ -167,6 +168,42 @@ data: } } +func TestReadConfigMapFromFileValidate(t *testing.T) { + const configMapWithUnknownField = ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: foo +bogusfield: nope +data: + myvar: foo +` + + t.Run("ignore accepts an unknown field", func(t *testing.T) { + buf := bytes.NewReader([]byte(configMapWithUnknownField)) + cms, warnings, err := readConfigMapFromFile(buf, entities.KubeValidateIgnore) + assert.NoError(t, err) + assert.Empty(t, warnings) + assert.Len(t, cms, 1) + }) + + t.Run("warn reports an unknown field but still reads it", func(t *testing.T) { + buf := bytes.NewReader([]byte(configMapWithUnknownField)) + cms, warnings, err := readConfigMapFromFile(buf, entities.KubeValidateWarn) + assert.NoError(t, err) + assert.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "ConfigMap") + assert.Len(t, cms, 1) + }) + + t.Run("strict fails on an unknown field", func(t *testing.T) { + buf := bytes.NewReader([]byte(configMapWithUnknownField)) + _, _, err := readConfigMapFromFile(buf, entities.KubeValidateStrict) + assert.Error(t, err) + assert.Contains(t, err.Error(), "validating kube ConfigMap") + }) +} + func TestGetKubeKind(t *testing.T) { tests := []struct { name string @@ -278,3 +315,50 @@ items: }) } } + +func TestUnmarshalKubeObject(t *testing.T) { + type sample struct { + Name string `json:"name"` + } + const ( + validDoc = "name: valid\n" + unknownFieldDoc = "name: valid\nbogus: nope\n" + ) + + tests := []struct { + name string + mode entities.KubeValidateMode + document string + expectError bool + errContains string + expectWarning bool + }{ + {"ignore skips an unknown field", entities.KubeValidateIgnore, unknownFieldDoc, false, "", false}, + {"warn reports an unknown field but keeps decoding", entities.KubeValidateWarn, unknownFieldDoc, false, "", true}, + {"warn is silent on a valid document", entities.KubeValidateWarn, validDoc, false, "", false}, + {"strict fails on an unknown field", entities.KubeValidateStrict, unknownFieldDoc, true, "validating kube Sample", false}, + {"strict accepts a valid document", entities.KubeValidateStrict, validDoc, false, "", false}, + {"an unset mode decodes leniently", entities.KubeValidateMode(""), unknownFieldDoc, false, "", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var obj sample + warnings, err := unmarshalKubeObject("Sample", test.mode, []byte(test.document), &obj) + if test.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), test.errContains) + return + } + assert.NoError(t, err) + // Known fields are always decoded, whatever the mode. + assert.Equal(t, "valid", obj.Name) + if test.expectWarning { + assert.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "kube Sample") + } else { + assert.Empty(t, warnings) + } + }) + } +} diff --git a/pkg/domain/infra/tunnel/kube.go b/pkg/domain/infra/tunnel/kube.go index 5f0adf4f5bf..9372c91007e 100644 --- a/pkg/domain/infra/tunnel/kube.go +++ b/pkg/domain/infra/tunnel/kube.go @@ -65,7 +65,7 @@ func (ic *ContainerEngine) PlayKube(_ context.Context, body io.Reader, opts enti if opts.Annotations != nil { options.WithAnnotations(opts.Annotations) } - options.WithNoHostname(opts.NoHostname).WithNoHosts(opts.NoHosts).WithUserns(opts.Userns) + options.WithNoHostname(opts.NoHostname).WithNoHosts(opts.NoHosts).WithUserns(opts.Userns).WithValidate(string(opts.Validate)) if s := opts.SkipTLSVerify; s != types.OptionalBoolUndefined { options.WithSkipTLSVerify(s == types.OptionalBoolTrue) } diff --git a/test/apiv2/80-kube.at b/test/apiv2/80-kube.at index 47c3e1c2e27..1ca4aca964f 100644 --- a/test/apiv2/80-kube.at +++ b/test/apiv2/80-kube.at @@ -146,4 +146,48 @@ t DELETE "libpod/kube/play" $TMPD/play.yaml 200 \ rm -rf $TMPD +# check the validate parameter + +TMPD=$(mktemp -d podman-apiv2-test-kube-validate.XXXXXX) +YAML="${TMPD}/validate.yaml" +cat > $YAML << EOF +apiVersion: v1 +kind: Pod +metadata: + name: demo-validate +spec: + containers: + - name: container + image: ${IMAGE} + bogusfield: nope +EOF + +# No validate parameter at all: older clients keep working and unknown fields +# are ignored +t POST "libpod/play/kube" $YAML 200 \ + .Pods[0].ID~[0-9a-f]\\{64\\} \ + .ValidationWarnings=null +t DELETE libpod/kube/play $YAML 200 + +# validate=ignore behaves the same as not passing the parameter +t POST "libpod/play/kube?validate=ignore" $YAML 200 \ + .Pods[0].ID~[0-9a-f]\\{64\\} \ + .ValidationWarnings=null +t DELETE libpod/kube/play $YAML 200 + +# validate=warn still plays the pod and returns the warning in the response +t POST "libpod/play/kube?validate=warn" $YAML 200 \ + .Pods[0].ID~[0-9a-f]\\{64\\} \ + .ValidationWarnings[0]~".*bogusfield.*" +t DELETE libpod/kube/play $YAML 200 + +# validate=strict fails the request and creates nothing +t POST "libpod/play/kube?validate=strict" $YAML 500 \ + .message~".*bogusfield.*" + +# an unsupported value is rejected +t POST "libpod/play/kube?validate=bogus" $YAML 400 + +rm -rf $TMPD + # vim: filetype=sh diff --git a/test/e2e/play_kube_test.go b/test/e2e/play_kube_test.go index 3f16aa35f94..3b9c7be2e6a 100644 --- a/test/e2e/play_kube_test.go +++ b/test/e2e/play_kube_test.go @@ -2660,6 +2660,120 @@ var _ = Describe("Podman kube play", func() { Expect(kube).Should(ExitWithError(125, `container "podDoesntHaveAnImage" is missing the required 'image' field`)) }) + yamlWithUnknownField := fmt.Sprintf(` +apiVersion: v1 +kind: Pod +metadata: + name: unknown-field-pod +spec: + containers: + - name: testctr + image: %s + command: + - "true" + bogusfield: "value" +`, CITEST_IMAGE) + + // A BogusKind document followed by a valid Pod: strict fails on the + // unsupported kind, while ignore/warn skip it and still run the Pod. + yamlWithUnknownKind := fmt.Sprintf(` +apiVersion: v1 +kind: BogusKind +metadata: + name: unknown-kind +--- +apiVersion: v1 +kind: Pod +metadata: + name: unknown-kind-pod +spec: + containers: + - name: testctr + image: %s + command: + - "true" +`, CITEST_IMAGE) + + It("--validate=ignore skips unrecognized YAML fields (default)", func() { + err := writeYaml(yamlWithUnknownField, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + // ExitCleanly also asserts nothing is written to stderr, i.e. no warning. + podmanTest.PodmanExitCleanly("kube", "play", kubeYaml) + }) + + It("--validate=strict fails on unrecognized YAML fields", func() { + err := writeYaml(yamlWithUnknownField, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + kube := podmanTest.Podman([]string{"kube", "play", "--validate=strict", kubeYaml}) + kube.WaitWithDefaultTimeout() + Expect(kube).Should(ExitWithError(125, "bogusfield")) + }) + + It("--validate=warn reports unrecognized YAML fields but keeps running", func() { + err := writeYaml(yamlWithUnknownField, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + kube := podmanTest.Podman([]string{"kube", "play", "--validate=warn", kubeYaml}) + kube.WaitWithDefaultTimeout() + Expect(kube).Should(Exit(0)) + Expect(kube.ErrorToString()).To(ContainSubstring("bogusfield")) + }) + + It("--validate=ignore skips unsupported kube kinds (default)", func() { + err := writeYaml(yamlWithUnknownKind, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + // ExitCleanly also asserts nothing is written to stderr, i.e. no warning. + podmanTest.PodmanExitCleanly("kube", "play", kubeYaml) + }) + + It("--validate=warn reports unsupported kube kinds but keeps running", func() { + err := writeYaml(yamlWithUnknownKind, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + kube := podmanTest.Podman([]string{"kube", "play", "--validate=warn", kubeYaml}) + kube.WaitWithDefaultTimeout() + Expect(kube).Should(Exit(0)) + Expect(kube.ErrorToString()).To(ContainSubstring("BogusKind")) + }) + + It("--validate=strict fails on unsupported kube kinds", func() { + err := writeYaml(yamlWithUnknownKind, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + kube := podmanTest.Podman([]string{"kube", "play", "--validate=strict", kubeYaml}) + kube.WaitWithDefaultTimeout() + Expect(kube).Should(ExitWithError(125, `kube kind "BogusKind" is not supported`)) + }) + + It("--validate=strict accepts a valid manifest", func() { + validYaml := fmt.Sprintf(` +apiVersion: v1 +kind: Pod +metadata: + name: valid-pod +spec: + containers: + - name: testctr + image: %s + command: + - "true" +`, CITEST_IMAGE) + err := writeYaml(validYaml, kubeYaml) + Expect(err).ToNot(HaveOccurred()) + + podmanTest.PodmanExitCleanly("kube", "play", "--validate=strict", kubeYaml) + }) + + It("rejects an invalid --validate value", func() { + // The value is rejected at flag-parse time, before any file is read. + kube := podmanTest.Podman([]string{"kube", "play", "--validate=bogus", kubeYaml}) + kube.WaitWithDefaultTimeout() + Expect(kube).Should(ExitWithError(125, "not a valid value")) + }) + It("container start error identifies the container by name", func() { pod := getPod(withCtr(getCtr(withImage(ALPINE), withCmd([]string{"/no/such/command"}), withArg(nil)))) err := generateKubeYaml("pod", pod, kubeYaml)