Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(libpod): support kube play tar content-type #24015

Merged
merged 2 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion pkg/api/handlers/libpod/kube.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -110,14 +195,18 @@ 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)
}
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
Expand Down
70 changes: 70 additions & 0 deletions pkg/api/handlers/libpod/kube_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
46 changes: 45 additions & 1 deletion pkg/api/server/register_kube.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions test/apiv2/80-kube.at
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,72 @@ t DELETE libpod/play/kube $YAML 200 \

rm -rf $TMPD

# 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: ${IMAGE}
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 ${IMAGE}
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

# vim: filetype=sh