diff --git a/pkg/bindings/quadlets/quadlets.go b/pkg/bindings/quadlets/quadlets.go new file mode 100644 index 00000000000..33a9bab7c13 --- /dev/null +++ b/pkg/bindings/quadlets/quadlets.go @@ -0,0 +1,157 @@ +package quadlets + +import ( + "context" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "go.podman.io/podman/v6/pkg/bindings" + "go.podman.io/podman/v6/pkg/domain/entities" +) + +// List returns a list of quadlets on the server. +func List(ctx context.Context, options *ListOptions) ([]*entities.ListQuadlet, error) { + if options == nil { + options = new(ListOptions) + } + conn, err := bindings.GetClient(ctx) + if err != nil { + return nil, err + } + params, err := options.ToParams() + if err != nil { + return nil, err + } + + var quadlets []*entities.ListQuadlet + response, err := conn.DoRequest(ctx, nil, http.MethodGet, "/quadlets/json", params, nil) + if err != nil { + return nil, err + } + defer response.Body.Close() + + return quadlets, response.Process(&quadlets) +} + +// Exists checks whether a quadlet with the given name exists on the server. +func Exists(ctx context.Context, name string, _ *ExistsOptions) (bool, error) { + conn, err := bindings.GetClient(ctx) + if err != nil { + return false, err + } + response, err := conn.DoRequest(ctx, nil, http.MethodGet, "/quadlets/%s/exists", nil, nil, name) + if err != nil { + return false, err + } + defer response.Body.Close() + + return response.IsSuccess(), nil +} + +// Print returns the contents of a quadlet file from the server. +func Print(ctx context.Context, name string, _ *PrintOptions) (string, error) { + conn, err := bindings.GetClient(ctx) + if err != nil { + return "", err + } + response, err := conn.DoRequest(ctx, nil, http.MethodGet, "/quadlets/%s/file", nil, nil, name) + if err != nil { + return "", err + } + defer response.Body.Close() + + if !response.IsSuccess() { + return "", response.Process(nil) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + return string(body), nil +} + +// Install sends local quadlet files to the server for installation via multipart upload. +func Install(ctx context.Context, filePaths []string, options *InstallOptions) (*entities.QuadletInstallReport, error) { + if options == nil { + options = new(InstallOptions) + } + conn, err := bindings.GetClient(ctx) + if err != nil { + return nil, err + } + params, err := options.ToParams() + if err != nil { + return nil, err + } + + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + + go func() { + defer pw.Close() + defer writer.Close() + + for _, filePath := range filePaths { + filename := filepath.Base(filePath) + part, err := writer.CreateFormFile(filename, filename) + if err != nil { + pw.CloseWithError(err) + return + } + file, err := os.Open(filePath) + if err != nil { + pw.CloseWithError(err) + return + } + _, err = io.Copy(part, file) + file.Close() + if err != nil { + pw.CloseWithError(err) + return + } + } + }() + + header := make(http.Header) + header.Set("Content-Type", writer.FormDataContentType()) + + var report entities.QuadletInstallReport + response, err := conn.DoRequest(ctx, pr, http.MethodPost, "/quadlets", params, header) + if err != nil { + return nil, err + } + defer response.Body.Close() + + return &report, response.Process(&report) +} + +// Remove removes one or more quadlets from the server (batch operation). +func Remove(ctx context.Context, quadletNames []string, options *RemoveOptions) (*entities.QuadletRemoveReport, error) { + if options == nil { + options = new(RemoveOptions) + } + conn, err := bindings.GetClient(ctx) + if err != nil { + return nil, err + } + params, err := options.ToParams() + if err != nil { + return nil, err + } + for _, name := range quadletNames { + params.Add("quadlets", name) + } + + var report entities.QuadletRemoveReport + response, err := conn.DoRequest(ctx, nil, http.MethodDelete, "/quadlets", params, nil) + if err != nil { + return nil, err + } + defer response.Body.Close() + + return &report, response.Process(&report) +} diff --git a/pkg/bindings/quadlets/types.go b/pkg/bindings/quadlets/types.go new file mode 100644 index 00000000000..f7572ff5017 --- /dev/null +++ b/pkg/bindings/quadlets/types.go @@ -0,0 +1,36 @@ +package quadlets + +// ListOptions are optional options for listing quadlets +// +//go:generate go run ../generator/generator.go ListOptions +type ListOptions struct { + Filters map[string][]string +} + +// ExistsOptions are optional options for checking if a quadlet exists +// +//go:generate go run ../generator/generator.go ExistsOptions +type ExistsOptions struct{} + +// PrintOptions are optional options for printing quadlet file contents +// +//go:generate go run ../generator/generator.go PrintOptions +type PrintOptions struct{} + +// InstallOptions are optional options for installing quadlets +// +//go:generate go run ../generator/generator.go InstallOptions +type InstallOptions struct { + Replace *bool + ReloadSystemd *bool +} + +// RemoveOptions are optional options for removing quadlets +// +//go:generate go run ../generator/generator.go RemoveOptions +type RemoveOptions struct { + Force *bool + All *bool + Ignore *bool + ReloadSystemd *bool +} diff --git a/pkg/bindings/quadlets/types_exists_options.go b/pkg/bindings/quadlets/types_exists_options.go new file mode 100644 index 00000000000..b4619194075 --- /dev/null +++ b/pkg/bindings/quadlets/types_exists_options.go @@ -0,0 +1,18 @@ +// Code generated by go generate; DO NOT EDIT. +package quadlets + +import ( + "net/url" + + "go.podman.io/podman/v6/pkg/bindings/internal/util" +) + +// Changed returns true if named field has been set +func (o *ExistsOptions) Changed(fieldName string) bool { + return util.Changed(o, fieldName) +} + +// ToParams formats struct fields to be passed to API service +func (o *ExistsOptions) ToParams() (url.Values, error) { + return util.ToParams(o) +} diff --git a/pkg/bindings/quadlets/types_install_options.go b/pkg/bindings/quadlets/types_install_options.go new file mode 100644 index 00000000000..23742054760 --- /dev/null +++ b/pkg/bindings/quadlets/types_install_options.go @@ -0,0 +1,48 @@ +// Code generated by go generate; DO NOT EDIT. +package quadlets + +import ( + "net/url" + + "go.podman.io/podman/v6/pkg/bindings/internal/util" +) + +// Changed returns true if named field has been set +func (o *InstallOptions) Changed(fieldName string) bool { + return util.Changed(o, fieldName) +} + +// ToParams formats struct fields to be passed to API service +func (o *InstallOptions) ToParams() (url.Values, error) { + return util.ToParams(o) +} + +// WithReplace set field Replace to given value +func (o *InstallOptions) WithReplace(value bool) *InstallOptions { + o.Replace = &value + return o +} + +// GetReplace returns value of field Replace +func (o *InstallOptions) GetReplace() bool { + if o.Replace == nil { + var z bool + return z + } + return *o.Replace +} + +// WithReloadSystemd set field ReloadSystemd to given value +func (o *InstallOptions) WithReloadSystemd(value bool) *InstallOptions { + o.ReloadSystemd = &value + return o +} + +// GetReloadSystemd returns value of field ReloadSystemd +func (o *InstallOptions) GetReloadSystemd() bool { + if o.ReloadSystemd == nil { + var z bool + return z + } + return *o.ReloadSystemd +} diff --git a/pkg/bindings/quadlets/types_list_options.go b/pkg/bindings/quadlets/types_list_options.go new file mode 100644 index 00000000000..b55ba1f6945 --- /dev/null +++ b/pkg/bindings/quadlets/types_list_options.go @@ -0,0 +1,33 @@ +// Code generated by go generate; DO NOT EDIT. +package quadlets + +import ( + "net/url" + + "go.podman.io/podman/v6/pkg/bindings/internal/util" +) + +// Changed returns true if named field has been set +func (o *ListOptions) Changed(fieldName string) bool { + return util.Changed(o, fieldName) +} + +// ToParams formats struct fields to be passed to API service +func (o *ListOptions) ToParams() (url.Values, error) { + return util.ToParams(o) +} + +// WithFilters set field Filters to given value +func (o *ListOptions) WithFilters(value map[string][]string) *ListOptions { + o.Filters = value + return o +} + +// GetFilters returns value of field Filters +func (o *ListOptions) GetFilters() map[string][]string { + if o.Filters == nil { + var z map[string][]string + return z + } + return o.Filters +} diff --git a/pkg/bindings/quadlets/types_print_options.go b/pkg/bindings/quadlets/types_print_options.go new file mode 100644 index 00000000000..8c1cce7f82c --- /dev/null +++ b/pkg/bindings/quadlets/types_print_options.go @@ -0,0 +1,18 @@ +// Code generated by go generate; DO NOT EDIT. +package quadlets + +import ( + "net/url" + + "go.podman.io/podman/v6/pkg/bindings/internal/util" +) + +// Changed returns true if named field has been set +func (o *PrintOptions) Changed(fieldName string) bool { + return util.Changed(o, fieldName) +} + +// ToParams formats struct fields to be passed to API service +func (o *PrintOptions) ToParams() (url.Values, error) { + return util.ToParams(o) +} diff --git a/pkg/bindings/quadlets/types_remove_options.go b/pkg/bindings/quadlets/types_remove_options.go new file mode 100644 index 00000000000..3a0192e4f92 --- /dev/null +++ b/pkg/bindings/quadlets/types_remove_options.go @@ -0,0 +1,78 @@ +// Code generated by go generate; DO NOT EDIT. +package quadlets + +import ( + "net/url" + + "go.podman.io/podman/v6/pkg/bindings/internal/util" +) + +// Changed returns true if named field has been set +func (o *RemoveOptions) Changed(fieldName string) bool { + return util.Changed(o, fieldName) +} + +// ToParams formats struct fields to be passed to API service +func (o *RemoveOptions) ToParams() (url.Values, error) { + return util.ToParams(o) +} + +// WithForce set field Force to given value +func (o *RemoveOptions) WithForce(value bool) *RemoveOptions { + o.Force = &value + return o +} + +// GetForce returns value of field Force +func (o *RemoveOptions) GetForce() bool { + if o.Force == nil { + var z bool + return z + } + return *o.Force +} + +// WithAll set field All to given value +func (o *RemoveOptions) WithAll(value bool) *RemoveOptions { + o.All = &value + return o +} + +// GetAll returns value of field All +func (o *RemoveOptions) GetAll() bool { + if o.All == nil { + var z bool + return z + } + return *o.All +} + +// WithIgnore set field Ignore to given value +func (o *RemoveOptions) WithIgnore(value bool) *RemoveOptions { + o.Ignore = &value + return o +} + +// GetIgnore returns value of field Ignore +func (o *RemoveOptions) GetIgnore() bool { + if o.Ignore == nil { + var z bool + return z + } + return *o.Ignore +} + +// WithReloadSystemd set field ReloadSystemd to given value +func (o *RemoveOptions) WithReloadSystemd(value bool) *RemoveOptions { + o.ReloadSystemd = &value + return o +} + +// GetReloadSystemd returns value of field ReloadSystemd +func (o *RemoveOptions) GetReloadSystemd() bool { + if o.ReloadSystemd == nil { + var z bool + return z + } + return *o.ReloadSystemd +} diff --git a/pkg/domain/infra/tunnel/quadlet.go b/pkg/domain/infra/tunnel/quadlet.go index 2a12162a8a5..696836ce97b 100644 --- a/pkg/domain/infra/tunnel/quadlet.go +++ b/pkg/domain/infra/tunnel/quadlet.go @@ -2,29 +2,229 @@ package tunnel import ( "context" - "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "github.com/sirupsen/logrus" + "go.podman.io/podman/v6/pkg/bindings/quadlets" "go.podman.io/podman/v6/pkg/domain/entities" + systemdquadlet "go.podman.io/podman/v6/pkg/systemd/quadlet" ) -var errNotImplemented = errors.New("not implemented for the remote Podman client") +func (ic *ContainerEngine) QuadletExists(_ context.Context, name string) (*entities.BoolReport, error) { + exists, err := quadlets.Exists(ic.ClientCtx, name, nil) + if err != nil { + return nil, err + } + return &entities.BoolReport{Value: exists}, nil +} + +func (ic *ContainerEngine) QuadletInstall(_ context.Context, pathsOrURLs []string, opts entities.QuadletInstallOptions) (*entities.QuadletInstallReport, error) { + options := new(quadlets.InstallOptions).WithReplace(opts.Replace).WithReloadSystemd(opts.ReloadSystemd) + + report := &entities.QuadletInstallReport{ + InstalledQuadlets: make(map[string]string), + QuadletErrors: make(map[string]error), + } + + allFiles, cleanup, err := resolveInstallPaths(pathsOrURLs) + if err != nil { + return nil, err + } + defer cleanup() + + // The API allows exactly one quadlet file per request, plus optional + // non-quadlet asset files. Group the flat file list so each quadlet file + // is bundled with any non-quadlet files that follow it. + groups := groupByQuadletFile(allFiles) + + for _, group := range groups { + installReport, err := quadlets.Install(ic.ClientCtx, group, options) + if err != nil { + report.QuadletErrors[group[0]] = err + continue + } + for k, v := range installReport.InstalledQuadlets { + report.InstalledQuadlets[k] = v + } + for k, v := range installReport.QuadletErrors { + report.QuadletErrors[k] = v + } + } -func (ic *ContainerEngine) QuadletExists(_ context.Context, _ string) (*entities.BoolReport, error) { - return nil, errNotImplemented + return report, nil } -func (ic *ContainerEngine) QuadletInstall(_ context.Context, _ []string, _ entities.QuadletInstallOptions) (*entities.QuadletInstallReport, error) { - return nil, errNotImplemented +// groupByQuadletFile splits a flat file list into groups where each group +// contains exactly one quadlet file plus any non-quadlet asset files that +// follow it. Non-quadlet files that appear before the first quadlet are +// prepended to the first quadlet group. +func groupByQuadletFile(files []string) [][]string { + var groups [][]string + var pending []string + + for _, f := range files { + if systemdquadlet.IsExtSupported(f) { + if len(pending) > 0 && len(groups) > 0 { + groups[len(groups)-1] = append(groups[len(groups)-1], pending...) + pending = nil + } + if len(pending) > 0 { + // Non-quadlet files before the first quadlet; will be + // prepended once the first quadlet group is created. + groups = append(groups, append(pending, f)) + pending = nil + } else { + groups = append(groups, []string{f}) + } + } else { + pending = append(pending, f) + } + } + + if len(pending) > 0 { + if len(groups) > 0 { + groups[len(groups)-1] = append(groups[len(groups)-1], pending...) + } else { + groups = append(groups, pending) + } + } + + return groups } -func (ic *ContainerEngine) QuadletList(_ context.Context, _ entities.QuadletListOptions) ([]*entities.ListQuadlet, error) { - return nil, errNotImplemented +func (ic *ContainerEngine) QuadletList(_ context.Context, opts entities.QuadletListOptions) ([]*entities.ListQuadlet, error) { + options := new(quadlets.ListOptions) + if len(opts.Filters) > 0 { + filterMap := make(map[string][]string) + for _, f := range opts.Filters { + fname, filter, hasFilter := strings.Cut(f, "=") + if hasFilter { + filterMap[fname] = append(filterMap[fname], filter) + } + } + options.Filters = filterMap + } + return quadlets.List(ic.ClientCtx, options) } -func (ic *ContainerEngine) QuadletPrint(_ context.Context, _ string) (string, error) { - return "", errNotImplemented +func (ic *ContainerEngine) QuadletPrint(_ context.Context, quadlet string) (string, error) { + return quadlets.Print(ic.ClientCtx, quadlet, nil) +} + +func (ic *ContainerEngine) QuadletRemove(_ context.Context, names []string, opts entities.QuadletRemoveOptions) (*entities.QuadletRemoveReport, error) { + options := new(quadlets.RemoveOptions). + WithForce(opts.Force). + WithAll(opts.All). + WithIgnore(opts.Ignore). + WithReloadSystemd(opts.ReloadSystemd) + + return quadlets.Remove(ic.ClientCtx, names, options) +} + +// resolveInstallPaths resolves pathsOrURLs into a flat list of local file paths +// ready for upload. URLs are downloaded to temp files (in temp directories to +// preserve the original filename). Directories are expanded to their contained +// files. Returns the flat file list, a cleanup function that removes any temp +// dirs, and an error. +func resolveInstallPaths(pathsOrURLs []string) ([]string, func(), error) { + var result []string + var tempDirs []string + cleanup := func() { + for _, d := range tempDirs { + if err := os.RemoveAll(d); err != nil { + logrus.Warnf("Failed to remove temp dir %s: %v", d, err) + } + } + } + + for _, arg := range pathsOrURLs { + switch { + case strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://"): + tmpFile, err := downloadToTemp(arg) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("downloading %s: %w", arg, err) + } + tempDirs = append(tempDirs, filepath.Dir(tmpFile)) + result = append(result, tmpFile) + + default: + info, err := os.Stat(arg) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("cannot stat %s: %w", arg, err) + } + if info.IsDir() { + entries, err := os.ReadDir(arg) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("reading directory %s: %w", arg, err) + } + for _, entry := range entries { + if !entry.IsDir() { + result = append(result, filepath.Join(arg, entry.Name())) + } + } + } else { + result = append(result, arg) + } + } + } + + return result, cleanup, nil +} + +func downloadToTemp(fileURL string) (string, error) { + resp, err := http.Get(fileURL) //nolint:gosec,noctx + if err != nil { + return "", err + } + defer resp.Body.Close() + + filename := getFileNameFromResponse(resp, fileURL) + + tmpDir, err := os.MkdirTemp("", "quadlet-download-*") + if err != nil { + return "", err + } + + tmpPath := filepath.Join(tmpDir, filename) + tmpFile, err := os.Create(tmpPath) + if err != nil { + os.RemoveAll(tmpDir) + return "", err + } + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + os.RemoveAll(tmpDir) + return "", err + } + return tmpPath, nil } -func (ic *ContainerEngine) QuadletRemove(_ context.Context, _ []string, _ entities.QuadletRemoveOptions) (*entities.QuadletRemoveReport, error) { - return nil, errNotImplemented +func getFileNameFromResponse(resp *http.Response, fileURL string) string { + cd := resp.Header.Get("Content-Disposition") + if cd != "" { + const prefix = "filename=" + if idx := strings.Index(cd, prefix); idx != -1 { + filename := cd[idx+len(prefix):] + filename = strings.Trim(filename, "\"'") + if filename != "" { + return filename + } + } + } + u, err := url.Parse(fileURL) + if err != nil { + return "quadlet-download" + } + return path.Base(u.Path) } diff --git a/test/apiv2/36-quadlets.at b/test/apiv2/36-quadlets.at index 90646deda5d..377da5c4fc1 100644 --- a/test/apiv2/36-quadlets.at +++ b/test/apiv2/36-quadlets.at @@ -3,9 +3,6 @@ # quadlet-related tests # -# 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 ] diff --git a/test/system/253-podman-quadlet.bats b/test/system/253-podman-quadlet.bats index 4d10ff442cf..2b36b5d8650 100644 --- a/test/system/253-podman-quadlet.bats +++ b/test/system/253-podman-quadlet.bats @@ -9,7 +9,6 @@ load helpers.registry load helpers.systemd function setup() { - skip_if_remote "podman quadlet is not implemented for remote setup yet" skip_if_journald_unavailable "Needed for RHEL. FIXME: we might be able to re-enable a subset of tests." test -x "$QUADLET" || die "Cannot run quadlet tests without executable \$QUADLET ($QUADLET)" @@ -156,8 +155,9 @@ EOF @test "quadlet verb - install multiple files from directory and remove by app name" { + skip_if_remote "app-name grouping requires local directory install semantics" # Create a directory for multiple quadlet files - local app_name="test-app-$(safe_name)" + local app_name="test-app-$(safename)" local quadlet_dir="$PODMAN_TMPDIR/$app_name" mkdir -p $quadlet_dir @@ -219,7 +219,7 @@ EOF @test "quadlet verb - install from URL" { # Create a directory for multiple quadlet files echo READY > $PODMAN_TMPDIR/ready - local quadlet_dir="$PODMAN_TMPDIR/quadlet_diri_$(safe_name)" + local quadlet_dir="$PODMAN_TMPDIR/quadlet_diri_$(safename)" mkdir -p $quadlet_dir cat > $quadlet_dir/basic.container <