diff --git a/hack/swagger-check b/hack/swagger-check index b4481f5bbc..ac88ccab4d 100755 --- a/hack/swagger-check +++ b/hack/swagger-check @@ -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"; diff --git a/pkg/api/handlers/compat/images_build.go b/pkg/api/handlers/compat/images_build.go index af37f0a472..b5b8e77eac 100644 --- a/pkg/api/handlers/compat/images_build.go +++ b/pkg/api/handlers/compat/images_build.go @@ -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 diff --git a/pkg/api/handlers/libpod/quadlets.go b/pkg/api/handlers/libpod/quadlets.go index cd28f7e0a0..613404d289 100644 --- a/pkg/api/handlers/libpod/quadlets.go +++ b/pkg/api/handlers/libpod/quadlets.go @@ -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) +} diff --git a/pkg/api/handlers/utils/handler.go b/pkg/api/handlers/utils/handler.go index 51b6009be7..aaefb52742 100644 --- a/pkg/api/handlers/utils/handler.go +++ b/pkg/api/handlers/utils/handler.go @@ -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 diff --git a/pkg/api/server/register_quadlets.go b/pkg/api/server/register_quadlets.go index 4c35d3ab1d..8d006bcae6 100644 --- a/pkg/api/server/register_quadlets.go +++ b/pkg/api/server/register_quadlets.go @@ -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 } diff --git a/test/apiv2/36-quadlets.at b/test/apiv2/36-quadlets.at index 2410bf566e..a575de8b8d 100644 --- a/test/apiv2/36-quadlets.at +++ b/test/apiv2/36-quadlets.at @@ -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