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)