diff --git a/pkg/api/handlers/libpod/kube.go b/pkg/api/handlers/libpod/kube.go index 7068074f3b..a5a3bf4d73 100644 --- a/pkg/api/handlers/libpod/kube.go +++ b/pkg/api/handlers/libpod/kube.go @@ -3,9 +3,16 @@ package libpod import ( + "bytes" + "errors" "fmt" + "io" "net" "net/http" + "os" + "path/filepath" + + "github.com/containers/storage/pkg/archive" "github.com/containers/image/v5/types" "github.com/containers/podman/v5/libpod" @@ -15,9 +22,86 @@ import ( "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/domain/infra/abi" "github.com/gorilla/schema" + "github.com/sirupsen/logrus" ) +// ExtractPlayReader provide an io.Reader given a http.Request object +// the function will extract the Content-Type header, if not provided, the body will be returned +// of the header define a text format (json, yaml or text) it will also return the body +// if the Content-Type is tar, we extract the content to the anchorDir and try to read the `play.yaml` file +func extractPlayReader(anchorDir string, r *http.Request) (io.Reader, error) { + hdr, found := r.Header["Content-Type"] + + // If Content-Type is not specific we use the body + if !found || len(hdr) == 0 { + return r.Body, nil + } + + var reader io.Reader + switch hdr[0] { + // backward compatibility + case "text/plain": + fallthrough + case "application/json": + fallthrough + case "application/yaml": + fallthrough + case "application/text": + fallthrough + case "application/x-yaml": + reader = r.Body + case "application/x-tar": + // un-tar the content + err := archive.Untar(r.Body, anchorDir, nil) + if err != nil { + return nil, err + } + + // check for play.yaml + path := filepath.Join(anchorDir, "play.yaml") + // open the play.yaml file + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("file not found: tar missing play.yaml file at root") + } else if err != nil { + return nil, err + } + defer f.Close() + reader = f + default: + return nil, fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0]) + } + + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + func KubePlay(w http.ResponseWriter, r *http.Request) { + // create a tmp directory + contextDirectory, err := os.MkdirTemp("", "libpod_kube") + if err != nil { + utils.InternalServerError(w, err) + return + } + + // cleanup the tmp directory + defer func() { + err := os.RemoveAll(contextDirectory) + if err != nil { + logrus.Warn(fmt.Errorf("failed to remove libpod_kube tmp directory %q: %w", contextDirectory, err)) + } + }() + + // extract the reader + reader, err := extractPlayReader(contextDirectory, r) + if err != nil { + utils.InternalServerError(w, err) + return + } + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) query := struct { @@ -37,6 +121,7 @@ func KubePlay(w http.ResponseWriter, r *http.Request) { TLSVerify bool `schema:"tlsVerify"` Userns string `schema:"userns"` Wait bool `schema:"wait"` + Build bool `schema:"build"` }{ TLSVerify: true, Start: true, @@ -110,6 +195,10 @@ func KubePlay(w http.ResponseWriter, r *http.Request) { Username: username, Userns: query.Userns, Wait: query.Wait, + ContextDir: contextDirectory, + } + if _, found := r.URL.Query()["build"]; found { + options.Build = types.NewOptionalBool(query.Build) } if _, found := r.URL.Query()["tlsVerify"]; found { options.SkipTLSVerify = types.NewOptionalBool(!query.TLSVerify) @@ -117,7 +206,7 @@ func KubePlay(w http.ResponseWriter, r *http.Request) { if _, found := r.URL.Query()["start"]; found { options.Start = types.NewOptionalBool(query.Start) } - report, err := containerEngine.PlayKube(r.Context(), r.Body, options) + report, err := containerEngine.PlayKube(r.Context(), reader, options) if err != nil { utils.Error(w, http.StatusInternalServerError, fmt.Errorf("playing YAML file: %w", err)) return diff --git a/pkg/api/handlers/libpod/kube_test.go b/pkg/api/handlers/libpod/kube_test.go new file mode 100644 index 0000000000..3d07e4bfd6 --- /dev/null +++ b/pkg/api/handlers/libpod/kube_test.go @@ -0,0 +1,70 @@ +//go:build !remote + +package libpod + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractPlayReader(t *testing.T) { + // Setup temporary directory for testing purposes + tempDir := t.TempDir() + + t.Run("Content-Type not provided - should return body", func(t *testing.T) { + req := &http.Request{ + Body: io.NopCloser(strings.NewReader("test body content")), + } + + reader, err := extractPlayReader(tempDir, req) + assert.NoError(t, err) + + // Read from the returned reader + data, err := io.ReadAll(reader) + assert.NoError(t, err) + assert.Equal(t, "test body content", string(data)) + }) + + t.Run("Supported content types (json/yaml/text) - should return body", func(t *testing.T) { + supportedTypes := []string{ + "application/json", + "application/yaml", + "application/text", + "application/x-yaml", + } + + for _, contentType := range supportedTypes { + req := &http.Request{ + Header: map[string][]string{ + "Content-Type": {contentType}, + }, + Body: io.NopCloser(strings.NewReader("test body content")), + } + + reader, err := extractPlayReader(tempDir, req) + assert.NoError(t, err) + + // Read from the returned reader + data, err := io.ReadAll(reader) + assert.NoError(t, err) + assert.Equal(t, "test body content", string(data)) + } + }) + + t.Run("Unsupported content type - should return error", func(t *testing.T) { + req := &http.Request{ + Header: map[string][]string{ + "Content-Type": {"application/unsupported"}, + }, + Body: io.NopCloser(strings.NewReader("test body content")), + } + + _, err := extractPlayReader(tempDir, req) + assert.Error(t, err) + assert.Equal(t, "Content-Type: application/unsupported is not supported. Should be \"application/x-tar\"", err.Error()) + }) +} diff --git a/pkg/api/server/register_kube.go b/pkg/api/server/register_kube.go index b50bafecab..751a16d0a0 100644 --- a/pkg/api/server/register_kube.go +++ b/pkg/api/server/register_kube.go @@ -16,8 +16,48 @@ func (s *APIServer) registerKubeHandlers(r *mux.Router) error { // - containers // - pods // summary: Play a Kubernetes YAML file. - // description: Create and run pods based on a Kubernetes YAML file (pod or service kind). + // description: | + // Create and run pods based on a Kubernetes YAML file. + // + // ### Content-Type + // + // Then endpoint support two Content-Type + // - `plain/text` for yaml format + // - `application/x-tar` for sending context(s) required for building images + // + // #### Tar format + // + // The tar format must contain a `play.yaml` file at the root that will be used. + // If the file format requires context to build an image, it uses the image name and + // check for corresponding folder. + // + // For example, the client sends a tar file with the following structure: + // + // ``` + // └── content.tar + // ├── play.yaml + // └── foobar/ + // └── Containerfile + // ``` + // + // The `play.yaml` is the following, the `foobar` image means we are looking for a context with this name. + // ``` + // apiVersion: v1 + // kind: Pod + // metadata: + // name: demo-build-remote + // spec: + // containers: + // - name: container + // image: foobar + // ``` + // // parameters: + // - in: header + // name: Content-Type + // type: string + // default: plain/text + // enum: ["plain/text", "application/x-tar"] // - in: query // name: annotations // type: string @@ -99,6 +139,10 @@ func (s *APIServer) registerKubeHandlers(r *mux.Router) error { // type: boolean // default: false // description: Clean up all objects created when a SIGTERM is received or pods exit. + // - in: query + // name: build + // type: boolean + // description: Build the images with corresponding context. // - in: body // name: request // description: Kubernetes YAML file. diff --git a/test/apiv2/80-kube.at b/test/apiv2/80-kube.at index dee7828cf3..640e3e4d2b 100644 --- a/test/apiv2/80-kube.at +++ b/test/apiv2/80-kube.at @@ -79,3 +79,71 @@ t DELETE libpod/play/kube $YAML 200 \ rm -rf $TMPD # vim: filetype=sh + +# check kube play works when uploading body as a tar +TMPD=$(mktemp -d podman-apiv2-test-kube.XXXXXX) +KUBE_PLAY_TAR="${TMPD}/kubeplay.tar" +cat > $TMPD/play.yaml << EOF +apiVersion: v1 +kind: Pod +metadata: + name: demo-tar-remote +spec: + containers: + - name: container + image: docker.io/library/nginx:latest +EOF +# Tar the content of the tmp folder +tar --format=posix -C $TMPD -cvf ${KUBE_PLAY_TAR} play.yaml &> /dev/null +t POST "libpod/play/kube" $KUBE_PLAY_TAR 200 \ + .Pods[0].ID~[0-9a-f]\\{64\\} \ + .Pods[0].ContainerErrors=null \ + .Pods[0].Containers[0]~[0-9a-f]\\{64\\} +# Cleanup +t DELETE libpod/kube/play $TMPD/play.yaml 200 \ + .StopReport[0].Id~[0-9a-f]\\{64\\} \ + .RmReport[0].Id~[0-9a-f]\\{64\\} +rm -rf $TMPD + +# check kube play is capable of building the image when uploading body as a tar + +TMPD=$(mktemp -d podman-apiv2-test-kube-build.XXXXXX) +KUBE_PLAY_TAR="${TMPD}/kubeplay.tar" +# Generate an unique label value +LABEL_VALUE="foo-$(date +%s)" +cat > $TMPD/play.yaml << EOF +apiVersion: v1 +kind: Pod +metadata: + name: demo-build-remote +spec: + containers: + - name: container + image: barfoo +EOF +mkdir $TMPD/barfoo +cat > $TMPD/barfoo/Containerfile << EOF +FROM docker.io/library/nginx:latest +LABEL bar="${LABEL_VALUE}" +EOF + +tar --format=posix -C $TMPD -cvf ${KUBE_PLAY_TAR} . &> /dev/null + +t POST "libpod/play/kube?build=true" $KUBE_PLAY_TAR 200 \ + .Pods[0].ID~[0-9a-f]\\{64\\} \ + .Pods[0].ContainerErrors=null \ + .Pods[0].Containers[0]~[0-9a-f]\\{64\\} + +# Get the container id created +cid=$(jq -r '.Pods[0].Containers[0]' <<<"$output") + +# Ensure the image build has the label defined in the Containerfile +t GET containers/$cid/json 200 \ + .Config.Labels.bar="${LABEL_VALUE}" + +# Cleanup +t DELETE "libpod/kube/play" $TMPD/play.yaml 200 \ + .StopReport[0].Id~[0-9a-f]\\{64\\} \ + .RmReport[0].Id~[0-9a-f]\\{64\\} + +rm -rf $TMPD