Merge pull request #27659 from inknos/post-quadlet-api

Add POST /libpod/quadlets
This commit is contained in:
Ashley Cui
2025-12-17 10:32:49 -05:00
committed by GitHub
6 changed files with 516 additions and 36 deletions

View File

@@ -323,6 +323,9 @@ sub operation_name {
elsif ($action eq "delete" && $endpoint eq "/libpod/play/kube") {
$action = "KubeDown"
}
elsif ($action eq "list" && $endpoint eq "/libpod/quadlets") {
$action = "Install"
}
# Grrrrrr, this one is annoying: some operations get an extra 'All'
elsif ($action =~ /^(delete|get|stats)$/ && $endpoint !~ /\{/) {
$action .= "All";

View File

@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
@@ -202,34 +201,6 @@ func processCacheTo(query *BuildQuery, queryValues url.Values) ([]reference.Name
return processCacheReferences(query.CacheTo, "cacheto", queryValues)
}
// validateContentType validates the Content-Type header and determines if multipart processing is needed.
func validateContentType(r *http.Request) (bool, error) {
multipart := false
if hdr, found := r.Header["Content-Type"]; found && len(hdr) > 0 {
contentType, _, err := mime.ParseMediaType(hdr[0])
if err != nil {
return false, utils.GetBadRequestError("Content-Type", hdr[0], err)
}
switch contentType {
case "application/tar":
logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType)
case "application/x-tar":
break
case "multipart/form-data":
logrus.Infof("Received %s", hdr[0])
multipart = true
default:
if utils.IsLibpodRequest(r) && !utils.IsLibpodLocalRequest(r) {
return false, utils.GetBadRequestError("Content-Type", hdr[0],
fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0]))
}
logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType)
}
}
return multipart, nil
}
// parseBuildQuery parses HTTP query parameters into a BuildQuery struct with defaults.
func parseBuildQuery(r *http.Request, conf *config.Config, queryValues url.Values) (*BuildQuery, error) {
query := &BuildQuery{
@@ -1039,7 +1010,7 @@ func buildImage(w http.ResponseWriter, r *http.Request, getBuildContextFunc getB
// If we have a multipart we use the operations, if not default extraction for main context
// Validate content type
multipart, err := validateContentType(r)
multipart, err := utils.ValidateContentType(r)
if err != nil {
utils.ProcessBuildError(w, err)
return

View File

@@ -3,15 +3,23 @@
package libpod
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"go.podman.io/storage/pkg/archive"
"github.com/containers/podman/v6/libpod"
"github.com/containers/podman/v6/pkg/api/handlers/utils"
api "github.com/containers/podman/v6/pkg/api/types"
"github.com/containers/podman/v6/pkg/domain/entities"
"github.com/containers/podman/v6/pkg/domain/infra/abi"
"github.com/containers/podman/v6/pkg/systemd/quadlet"
"github.com/containers/podman/v6/pkg/util"
"github.com/gorilla/schema"
"github.com/sirupsen/logrus"
)
@@ -56,3 +64,177 @@ func GetQuadletPrint(w http.ResponseWriter, r *http.Request) {
return
}
}
// extractQuadletFiles extracts quadlet files from tar archive to a temporary directory
func extractQuadletFiles(tempDir string, r io.ReadCloser) ([]string, error) {
quadletDir := filepath.Join(tempDir, "quadlets")
err := os.Mkdir(quadletDir, 0o700)
if err != nil {
return nil, err
}
err = archive.Untar(r, quadletDir, nil)
if err != nil {
return nil, err
}
// Collect all files from the extracted directory
var filePaths []string
err = filepath.Walk(quadletDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
filePaths = append(filePaths, path)
}
return nil
})
return filePaths, err
}
// processMultipartQuadlets processes multipart form data and saves files to temporary directory
func processMultipartQuadlets(tempDir string, r *http.Request) ([]string, error) {
quadletDir := filepath.Join(tempDir, "quadlets")
err := os.Mkdir(quadletDir, 0o700)
if err != nil {
return nil, err
}
reader, err := r.MultipartReader()
if err != nil {
return nil, fmt.Errorf("failed to create multipart reader: %w", err)
}
var filePaths []string
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read multipart: %w", err)
}
defer part.Close()
filename := part.FileName()
if filename == "" {
// Skip parts without filenames
continue
}
// Create file in temp directory
filePath := filepath.Join(quadletDir, filename)
file, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", filename, err)
}
defer file.Close()
_, err = io.Copy(file, part)
if err != nil {
return nil, fmt.Errorf("failed to write file %s: %w", filename, err)
}
filePaths = append(filePaths, filePath)
}
return filePaths, nil
}
func InstallQuadlets(w http.ResponseWriter, r *http.Request) {
// Create temporary directory for processing
contextDirectory, err := os.MkdirTemp("", "libpod_quadlet")
if err != nil {
utils.InternalServerError(w, err)
return
}
defer func() {
if err := os.RemoveAll(contextDirectory); err != nil {
logrus.Warn(fmt.Errorf("failed to remove libpod_quadlet tmp directory %q: %w", contextDirectory, err))
}
}()
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
// Parse query parameters
query := struct {
Replace bool `schema:"replace"`
ReloadSystemd bool `schema:"reload-systemd"`
}{
Replace: false,
ReloadSystemd: true, // Default to true like CLI
}
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err))
return
}
multipart, err := utils.ValidateContentType(r)
if err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
var filePaths []string
if multipart {
logrus.Debug("Processing multipart form data")
filePaths, err = processMultipartQuadlets(contextDirectory, r)
if err != nil {
utils.InternalServerError(w, err)
return
}
} else {
logrus.Debug("Processing tar archive")
filePaths, err = extractQuadletFiles(contextDirectory, r.Body)
if err != nil {
utils.InternalServerError(w, err)
return
}
}
if len(filePaths) == 0 {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("no files found in request"))
return
}
countQuadletFiles := 0
for _, filePath := range filePaths {
isQuadletFile := quadlet.IsExtSupported(filePath)
if isQuadletFile {
countQuadletFiles++
}
}
switch {
case countQuadletFiles > 1:
utils.Error(w, http.StatusBadRequest, fmt.Errorf("only a single quadlet file is allowed per request"))
return
case countQuadletFiles == 0:
utils.Error(w, http.StatusBadRequest, fmt.Errorf("no quadlet files found in request"))
return
}
containerEngine := abi.ContainerEngine{Libpod: runtime}
installOptions := entities.QuadletInstallOptions{
Replace: query.Replace,
ReloadSystemd: query.ReloadSystemd,
}
installReport, err := containerEngine.QuadletInstall(r.Context(), filePaths, installOptions)
if err != nil {
utils.InternalServerError(w, err)
return
}
if len(installReport.QuadletErrors) > 0 {
var errs []error
for path, err := range installReport.QuadletErrors {
errs = append(errs, fmt.Errorf("%s: %w", path, err))
}
utils.Error(w, http.StatusBadRequest, fmt.Errorf("errors occurred installing some Quadlets: %w", errors.Join(errs...)))
return
}
utils.WriteResponse(w, http.StatusOK, installReport)
}

View File

@@ -5,6 +5,7 @@ package utils
import (
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
@@ -36,6 +37,34 @@ func IsLibpodLocalRequest(r *http.Request) bool {
return apiutil.IsLibpodLocalRequest(r)
}
// ValidateContentType validates the Content-Type header and determines if multipart processing is needed.
func ValidateContentType(r *http.Request) (bool, error) {
multipart := false
if hdr, found := r.Header["Content-Type"]; found && len(hdr) > 0 {
contentType, _, err := mime.ParseMediaType(hdr[0])
if err != nil {
return false, GetBadRequestError("Content-Type", hdr[0], err)
}
switch contentType {
case "application/tar":
logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType)
case "application/x-tar":
break
case "multipart/form-data":
logrus.Infof("Received %s", hdr[0])
multipart = true
default:
if IsLibpodRequest(r) && !IsLibpodLocalRequest(r) {
return false, GetBadRequestError("Content-Type", hdr[0],
fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0]))
}
logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType)
}
}
return multipart, nil
}
// SupportedVersion validates that the version provided by client is included in the given condition
// https://github.com/blang/semver#ranges provides the details for writing conditions
// If a version is not given in URL path, ErrVersionNotGiven is returned

View File

@@ -54,5 +54,60 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error {
// 500:
// $ref: "#/responses/internalError"
r.HandleFunc(VersionedPath("/libpod/quadlets/{name}/file"), s.APIHandler(libpod.GetQuadletPrint)).Methods(http.MethodGet)
// swagger:operation POST /libpod/quadlets libpod QuadletInstallLibpod
// ---
// tags:
// - quadlets
// summary: Install quadlet files
// description: |
// Install one or more files for a quadlet application. Each request should contain a single quadlet file
// and optionally more files such as containerfile, kube yaml or configuration files. Supports both tar
// archives and multipart form data uploads.
// consumes:
// - application/x-tar
// - multipart/form-data
// produces:
// - application/json
// parameters:
// - in: query
// name: replace
// type: boolean
// default: false
// description: Replace the installation files even if the files already exists
// - in: query
// name: reload-systemd
// type: boolean
// default: true
// description: Reload systemd after installing quadlets
// - in: body
// name: request
// description: |
// Quadlet files to install. Can be provided as:
// - application/x-tar: A tar archive containing one quadlet file and optionally additional files
// - multipart/form-data: One quadlet file as form data and optionally additional files
// schema:
// type: string
// format: binary
// responses:
// 200:
// description: Quadlet installation report
// schema:
// type: object
// properties:
// InstalledQuadlets:
// type: object
// additionalProperties:
// type: string
// description: Map of source path to installed path for successfully installed quadlets
// QuadletErrors:
// type: object
// additionalProperties:
// type: string
// description: Map of source path to error message for failed installations
// 400:
// $ref: "#/responses/badParamError"
// 500:
// $ref: "#/responses/internalError"
r.HandleFunc(VersionedPath("/libpod/quadlets"), s.APIHandler(libpod.InstallQuadlets)).Methods(http.MethodPost)
return nil
}

View File

@@ -6,6 +6,24 @@
# NOTE: Once podman-remote quadlet support is added we can enable the podman quadlet tests in
# test/system/253-podman-quadlet.bats which should cover it in more detail then.
function is_rootless() {
[ "$(id -u)" -ne 0 ]
}
function get_quadlet_install_dir() {
if is_rootless; then
# For rootless: $XDG_CONFIG_HOME/containers/systemd or ~/.config/containers/systemd
local config_home=${XDG_CONFIG_HOME:-$HOME/.config}
echo "$config_home/containers/systemd"
else
# For root: /etc/containers/systemd
echo "/etc/containers/systemd"
fi
}
quadlet_install_dir=$(get_quadlet_install_dir)
## Test list endpoint
t GET libpod/quadlets/json 200
@@ -18,7 +36,7 @@ quadlet_name=quadlet-test-$(cat /proc/sys/kernel/random/uuid)
quadlet_container_name="$quadlet_name.container"
quadlet_build_name="$quadlet_name.build"
TMPDIR=$(mktemp -d podman-apiv2-test.quadlet.XXXXXXXX)
TMPD=$(mktemp -d podman-apiv2-test.quadlet.XXXXXXXX)
quadlet_container_file_content=$(cat << EOF
[Container]
@@ -32,12 +50,12 @@ ImageTag=localhost/$quadlet_name
EOF
)
echo "$quadlet_container_file_content" > $TMPDIR/$quadlet_container_name
echo "$quadlet_build_file_content" > $TMPDIR/$quadlet_build_name
echo "$quadlet_container_file_content" > $TMPD/$quadlet_container_name
echo "$quadlet_build_file_content" > $TMPD/$quadlet_build_name
# this should ensure the .config/containers/systemd directory is created
podman quadlet install $TMPDIR/$quadlet_container_name
podman quadlet install $TMPDIR/$quadlet_build_name
podman quadlet install $TMPD/$quadlet_container_name
podman quadlet install $TMPD/$quadlet_build_name
filter_param=$(printf '{"name":["%s"]}' "$quadlet_name")
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
@@ -60,6 +78,228 @@ is "$output" "$quadlet_build_file_content"
podman quadlet rm $quadlet_container_name
podman quadlet rm $quadlet_build_name
rm -rf $TMPDIR
rm -rf $TMPD
TMPD=$(mktemp -d podman-apiv2-test.quadlets.XXXXXXXX)
# Scenario: try to send nothing
t POST "libpod/quadlets" 400 \
.cause~.*'Content-Type: application/json is not supported. Should be "application/x-tar"'
# Scenario: send an empty tar archive will fail with no files found in request
tar -C "$TMPD" -cvf "$TMPD/empty.tar" -T /dev/null &> /dev/null
t POST "libpod/quadlets" "$TMPD/empty.tar" 400 \
.cause="no files found in request"
# Scenario: send a plaintext file will fail with no quadlet files found in request
echo "test" > "$TMPD/test.txt"
t POST "libpod/quadlets" --form="test.txt=@$TMPD/test.txt" 400 \
.cause="no quadlet files found in request"
# Scenario: send an invalid quadlet type in a tar archive will fail with no quadlet files found in request
echo "test" > "$TMPD/test.txt"
tar -C "$TMPD" -cvf "$TMPD/test.tar" "test.txt" &> /dev/null
t POST "libpod/quadlets" "$TMPD/test.tar" 400 \
.cause="no quadlet files found in request"
# Scenario 1: install a single quadlet
quadlet_1=quadlet-test-1-$(cat /proc/sys/kernel/random/uuid).container
quadlet_1_content=$(cat << EOF
[Container]
ContainerName=quadlet-1
Image=quay.io/podman/hello
EOF
)
echo "$quadlet_1_content" > "$TMPD/$quadlet_1"
tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_1.tar" "$quadlet_1" &> /dev/null
t POST "libpod/quadlets" "$TMPD/$quadlet_1.tar" 200 \
'.InstalledQuadlets|length=1' \
'.QuadletErrors|length=0'
t GET "libpod/quadlets/$quadlet_1/file" 200
is "$output" "$quadlet_1_content" "quadlet-1 should be installed"
# Scenario: install a quadlet that already exists, verify it won't be overwritten
# then use replace=true to overwrite it and verify
quadlet_2=$quadlet_1
quadlet_2_content=$(cat << EOF
[Container]
ContainerName=quadlet-2
Image=quay.io/podman/hello
EOF
)
echo "$quadlet_2_content" > "$TMPD/$quadlet_2"
tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_2.tar" "$quadlet_2" &> /dev/null
t POST "libpod/quadlets" "$TMPD/$quadlet_2.tar" 400 \
.cause~.*"a Quadlet with name $quadlet_2 already exists, refusing to overwrite"
t GET "libpod/quadlets/$quadlet_1/file" 200
is "$output" "$quadlet_1_content" "quadlet-1 should not be overwritten"
#replace
t POST "libpod/quadlets?replace=true" "$TMPD/$quadlet_2.tar" 200 \
'.InstalledQuadlets|length=1' \
'.QuadletErrors|length=0'
t GET "libpod/quadlets/$quadlet_2/file" 200
is "$output" "$quadlet_2_content" "quadlet-1 should be overwritten by quadlet-2"
# Scenario: install multiple quadlets at once in a single tar will fail
quadlet_3=quadlet-test-3-$(cat /proc/sys/kernel/random/uuid).container
quadlet_4=quadlet-test-4-$(cat /proc/sys/kernel/random/uuid).container
quadlet_3_content=$(cat << EOF
[Container]
ContainerName=quadlet-3
Image=quay.io/podman/hello
EOF
)
quadlet_4_content=$(cat << EOF
[Container]
ContainerName=quadlet-4
Image=quay.io/podman/hello
EOF
)
echo "$quadlet_3_content" > "$TMPD/$quadlet_3"
echo "$quadlet_4_content" > "$TMPD/$quadlet_4"
tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_3_4.tar" "$quadlet_3" "$quadlet_4" &> /dev/null
t POST "libpod/quadlets" "$TMPD/$quadlet_3_4.tar" 400 \
.cause="only a single quadlet file is allowed per request"
# Scenario: install tar that contains one quadlet file and a non-quadlet file will succeed
# then update the quadlet file, and the non-quadlet file, and verify the update is successful
quadlet_5=quadlet-test-5-$(cat /proc/sys/kernel/random/uuid).container
containerfile_1=quadlet-test-containerfile-1-$(cat /proc/sys/kernel/random/uuid).Containerfile
containerfile_1_content=$(cat << EOF
FROM quay.io/podman/hello
CMD ["echo", "hello"]
EOF
)
quadlet_5_content=$(cat << EOF
[Container]
ContainerName=quadlet-5
Image=quay.io/podman/hello
EOF
)
quadlet_5_updated_content=$(cat << EOF
[Container]
ContainerName=quadlet-5-updated
Image=quay.io/podman/hello
EOF
)
containerfile_1_updated_content=$(cat << EOF
FROM quay.io/podman/hello
CMD ["echo", "Updated"]
EOF
)
echo "$quadlet_5_content" > "$TMPD/$quadlet_5"
echo "$containerfile_1_content" > "$TMPD/$containerfile_1"
tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_5$containerfile_1.tar" "$quadlet_5" "$containerfile_1" &> /dev/null
t POST "libpod/quadlets" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \
'.InstalledQuadlets|length=2' \
'.QuadletErrors|length=0'
t GET "libpod/quadlets/$quadlet_5/file" 200
is "$output" "$quadlet_5_content" "quadlet-5 should be installed"
is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed"
echo "$quadlet_5_updated_content" > "$TMPD/$quadlet_5"
echo "$containerfile_1_updated_content" > "$TMPD/$containerfile_1"
tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_5$containerfile_1.tar" "$quadlet_5" "$containerfile_1" &> /dev/null
# update with no replace and check nothing changed
t POST "libpod/quadlets" "$TMPD/$quadlet_5$containerfile_1.tar" 400
t GET "libpod/quadlets/$quadlet_5/file" 200
is "$output" "$quadlet_5_content" "quadlet-5 should be installed"
is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed"
# replace
t POST "libpod/quadlets?replace=true" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \
'.InstalledQuadlets|length=2' \
'.QuadletErrors|length=0'
t GET "libpod/quadlets/$quadlet_5/file" 200
is "$output" "$quadlet_5_updated_content" "quadlet-5 should be updated"
is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_updated_content" "containerfile_1 should be installed"
# Scenario: test a multipart call, then update without replace, and then replace
quadlet_6=quadlet-test-6-$(cat /proc/sys/kernel/random/uuid).container
containerfile_2=quadlet-test-containerfile-2-$(cat /proc/sys/kernel/random/uuid).Containerfile
quadlet_6_content=$(cat << EOF
[Container]
ContainerName=quadlet-6
Image=quay.io/podman/hello
EOF
)
containerfile_2_content=$(cat << EOF
FROM quay.io/podman/hello
CMD ["echo", "hello"]
EOF
)
quadlet_6_updated_content=$(cat << EOF
[Container]
ContainerName=quadlet-6-updated
Image=quay.io/podman/hello
EOF
)
containerfile_2_updated_content=$(cat << EOF
FROM quay.io/podman/hello
CMD ["echo", "Updated"]
EOF
)
echo "$quadlet_6_content" > "$TMPD/$quadlet_6"
echo "$containerfile_2_content" > "$TMPD/$containerfile_2"
t POST "libpod/quadlets" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200
t GET "libpod/quadlets/$quadlet_6/file" 200
is "$output" "$quadlet_6_content" "quadlet-6 should be installed"
is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should be installed"
# update with no replace and check nothing changed
echo "$quadlet_6_updated_content" > "$TMPD/$quadlet_6"
echo "$containerfile_2_updated_content" > "$TMPD/$containerfile_2"
t POST "libpod/quadlets" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 400
t GET "libpod/quadlets/$quadlet_6/file" 200
is "$output" "$quadlet_6_content" "quadlet-6 should not be updated"
is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should not be updated"
# replace
t POST "libpod/quadlets?replace=true" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200
t GET "libpod/quadlets/$quadlet_6/file" 200
is "$output" "$quadlet_6_updated_content" "quadlet-6 should be updated"
is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_updated_content" "containerfile_2 should be updated"
# clean up
podman quadlet rm "$quadlet_1" \
"$quadlet_5" \
"$quadlet_6"
rm -f "$quadlet_install_dir/$containerfile_1"
rm -f "$quadlet_install_dir/$containerfile_2"
rm -rf $TMPD
# vim: filetype=sh