From a1e7e9a46d0f9ebb8f00fc9395d925a5fa45db61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Rod=C3=A1k?= Date: Fri, 12 Sep 2025 14:30:01 +0200 Subject: [PATCH 1/2] Add local build API for direct filesystem builds on MacOS and Windows (only WSL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /libpod/local/build endpoint, client bindings, and path translation utilities to enable container builds from mounted directories to podman machine without tar uploads. This optimization significantly speeds up build operations when working with remote Podman machines by eliminating redundant file transfers for already-accessible files. Fixes: https://issues.redhat.com/browse/RUN-3249 Signed-off-by: Jan Rodák --- internal/localapi/utils.go | 111 +++++++ internal/localapi/utils_unsupported.go | 16 + pkg/api/handlers/compat/images_build.go | 161 +++++++++- pkg/api/handlers/swagger/errors.go | 7 + pkg/api/handlers/utils/apiutil/apiutil.go | 7 + pkg/api/handlers/utils/errors.go | 4 + pkg/api/handlers/utils/handler.go | 6 + pkg/api/server/register_images.go | 350 ++++++++++++++++++++++ pkg/bindings/images/build.go | 75 ++++- pkg/domain/infra/tunnel/images.go | 30 ++ test/apiv2/90-build.at | 62 ++++ 11 files changed, 812 insertions(+), 17 deletions(-) diff --git a/internal/localapi/utils.go b/internal/localapi/utils.go index 6e07e87edf..856da677ca 100644 --- a/internal/localapi/utils.go +++ b/internal/localapi/utils.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/containers/podman/v5/pkg/bindings" + "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/machine/define" "github.com/containers/podman/v5/pkg/machine/env" "github.com/containers/podman/v5/pkg/machine/provider" @@ -154,3 +155,113 @@ func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap, return isPathAvailableOnMachine(mounts, vmType, path) } + +// CheckIfImageBuildPathsOnRunningMachine checks if the build context directory and all specified +// Containerfiles are available on the running machine. If they are, it translates their paths +// to the corresponding remote paths and returns them along with a flag indicating success. +func CheckIfImageBuildPathsOnRunningMachine(ctx context.Context, containerFiles []string, options entities.BuildOptions) ([]string, entities.BuildOptions, bool) { + if machineMode := bindings.GetMachineMode(ctx); !machineMode { + logrus.Debug("Machine mode is not enabled, skipping machine check") + return nil, options, false + } + + conn, err := bindings.GetClient(ctx) + if err != nil { + logrus.Debugf("Failed to get client connection: %v", err) + return nil, options, false + } + + mounts, vmType, err := getMachineMountsAndVMType(conn.URI.String(), conn.URI) + if err != nil { + logrus.Debugf("Failed to get machine mounts: %v", err) + return nil, options, false + } + + // Context directory + if err := fileutils.Lexists(options.ContextDirectory); errors.Is(err, fs.ErrNotExist) { + logrus.Debugf("Path %s does not exist locally, skipping machine check", options.ContextDirectory) + return nil, options, false + } + mapping, found := isPathAvailableOnMachine(mounts, vmType, options.ContextDirectory) + if !found { + logrus.Debugf("Path %s is not available on the running machine", options.ContextDirectory) + return nil, options, false + } + options.ContextDirectory = mapping.RemotePath + + // Containerfiles + translatedContainerFiles := []string{} + for _, containerFile := range containerFiles { + if strings.HasPrefix(containerFile, "http://") || strings.HasPrefix(containerFile, "https://") { + translatedContainerFiles = append(translatedContainerFiles, containerFile) + continue + } + + // If Containerfile does not exist, assume it is in context directory + if err := fileutils.Lexists(containerFile); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + logrus.Fatalf("Failed to check if containerfile %s exists: %v", containerFile, err) + return nil, options, false + } + continue + } + + mapping, found := isPathAvailableOnMachine(mounts, vmType, containerFile) + if !found { + logrus.Debugf("Path %s is not available on the running machine", containerFile) + return nil, options, false + } + translatedContainerFiles = append(translatedContainerFiles, mapping.RemotePath) + } + + // Additional build contexts + for _, context := range options.AdditionalBuildContexts { + switch { + case context.IsImage, context.IsURL: + continue + default: + if err := fileutils.Lexists(context.Value); errors.Is(err, fs.ErrNotExist) { + logrus.Debugf("Path %s does not exist locally, skipping machine check", context.Value) + return nil, options, false + } + mapping, found := isPathAvailableOnMachine(mounts, vmType, context.Value) + if !found { + logrus.Debugf("Path %s is not available on the running machine", context.Value) + return nil, options, false + } + context.Value = mapping.RemotePath + } + } + return translatedContainerFiles, options, true +} + +// IsHyperVProvider checks if the current machine provider is Hyper-V. +// It returns true if the provider is Hyper-V, false otherwise, or an error if the check fails. +func IsHyperVProvider(ctx context.Context) (bool, error) { + conn, err := bindings.GetClient(ctx) + if err != nil { + logrus.Debugf("Failed to get client connection: %v", err) + return false, err + } + + _, vmType, err := getMachineMountsAndVMType(conn.URI.String(), conn.URI) + if err != nil { + logrus.Debugf("Failed to get machine mounts: %v", err) + return false, err + } + + return vmType == define.HyperVVirt, nil +} + +// ValidatePathForLocalAPI checks if the provided path satisfies requirements for local API usage. +// It returns an error if the path is not absolute or does not exist on the filesystem. +func ValidatePathForLocalAPI(path string) error { + if !filepath.IsAbs(path) { + return fmt.Errorf("path %q is not absolute", path) + } + + if err := fileutils.Exists(path); err != nil { + return err + } + return nil +} diff --git a/internal/localapi/utils_unsupported.go b/internal/localapi/utils_unsupported.go index 87ee9eafbe..2b56cde4bd 100644 --- a/internal/localapi/utils_unsupported.go +++ b/internal/localapi/utils_unsupported.go @@ -5,6 +5,7 @@ package localapi import ( "context" + "github.com/containers/podman/v5/pkg/domain/entities" "github.com/sirupsen/logrus" ) @@ -12,3 +13,18 @@ func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap, logrus.Debug("CheckPathOnRunningMachine is not supported") return nil, false } + +func CheckIfImageBuildPathsOnRunningMachine(ctx context.Context, containerFiles []string, options entities.BuildOptions) ([]string, entities.BuildOptions, bool) { + logrus.Debug("CheckIfImageBuildPathsOnRunningMachine is not supported") + return nil, options, false +} + +func IsHyperVProvider(ctx context.Context) (bool, error) { + logrus.Debug("IsHyperVProvider is not supported") + return false, nil +} + +func ValidatePathForLocalAPI(path string) error { + logrus.Debug("ValidatePathForLocalAPI is not supported") + return nil +} diff --git a/pkg/api/handlers/compat/images_build.go b/pkg/api/handlers/compat/images_build.go index acd4e65cb0..73cd14ee57 100644 --- a/pkg/api/handlers/compat/images_build.go +++ b/pkg/api/handlers/compat/images_build.go @@ -22,6 +22,7 @@ import ( "github.com/containers/buildah" buildahDefine "github.com/containers/buildah/define" "github.com/containers/buildah/pkg/parse" + "github.com/containers/podman/v5/internal/localapi" "github.com/containers/podman/v5/libpod" "github.com/containers/podman/v5/pkg/api/handlers/utils" api "github.com/containers/podman/v5/pkg/api/types" @@ -143,6 +144,29 @@ type BuildContext struct { IgnoreFile string } +func (b *BuildContext) validateLocalAPIPaths() error { + if err := localapi.ValidatePathForLocalAPI(b.ContextDirectory); err != nil { + return err + } + + for _, containerfile := range b.ContainerFiles { + if err := localapi.ValidatePathForLocalAPI(containerfile); err != nil { + return err + } + } + + for _, ctx := range b.AdditionalBuildContexts { + if ctx.IsURL || ctx.IsImage { + continue + } + if err := localapi.ValidatePathForLocalAPI(ctx.Value); err != nil { + return err + } + } + + return nil +} + // genSpaceErr wraps filesystem errors to provide more context for disk space issues. func genSpaceErr(err error) error { if errors.Is(err, syscall.ENOSPC) { @@ -196,7 +220,7 @@ func validateContentType(r *http.Request) (bool, error) { logrus.Infof("Received %s", hdr[0]) multipart = true default: - if utils.IsLibpodRequest(r) { + 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])) } @@ -264,10 +288,14 @@ func processBuildContext(query url.Values, r *http.Request, buildContext *BuildC } for _, containerfile := range m { - // Add path to containerfile iff it is not URL + // Add path to containerfile if it is not URL if !strings.HasPrefix(containerfile, "http://") && !strings.HasPrefix(containerfile, "https://") { - containerfile = filepath.Join(buildContext.ContextDirectory, - filepath.Clean(filepath.FromSlash(containerfile))) + if filepath.IsAbs(containerfile) { + containerfile = filepath.Clean(filepath.FromSlash(containerfile)) + } else { + containerfile = filepath.Join(buildContext.ContextDirectory, + filepath.Clean(filepath.FromSlash(containerfile))) + } } buildContext.ContainerFiles = append(buildContext.ContainerFiles, containerfile) } @@ -587,7 +615,7 @@ func createBuildOptions(query *BuildQuery, buildCtx *BuildContext, queryValues u // Credential value(s) not returned as their value is not human readable return nil, nil, utils.GetGenericBadRequestError(err) } - // this smells + cleanup := func() { auth.RemoveAuthfile(authfile) } @@ -866,7 +894,128 @@ func executeBuild(runtime *libpod.Runtime, w http.ResponseWriter, r *http.Reques } } +// handleLocalBuildContexts processes build contexts for local API builds and validates local paths. +// +// This function handles the main build context and any additional build contexts specified in the request: +// - Validates that the main context directory (localcontextdir) exists and is accessible for local API usage +// - Processes additional build contexts which can be: +// - URLs (url:) - downloads content to temporary directories under anchorDir +// - Container images (image:) - records image references for later resolution during build +// - Local paths (localpath:) - validates and cleans local filesystem paths +// +// Returns a BuildContext struct with the main context directory and a map of additional build contexts, +// or an error if validation fails or required parameters are missing. +func handleLocalBuildContexts(query url.Values, anchorDir string) (*BuildContext, error) { + localContextDir := query.Get("localcontextdir") + if localContextDir == "" { + return nil, utils.GetBadRequestError("localcontextdir", localContextDir, fmt.Errorf("localcontextdir cannot be empty")) + } + localContextDir = filepath.Clean(localContextDir) + if err := localapi.ValidatePathForLocalAPI(localContextDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, utils.GetFileNotFoundError(err) + } + return nil, utils.GetGenericBadRequestError(err) + } + + out := &BuildContext{ + ContextDirectory: localContextDir, + AdditionalBuildContexts: make(map[string]*buildahDefine.AdditionalBuildContext), + } + + for _, url := range query["additionalbuildcontexts"] { + name, value, found := strings.Cut(url, "=") + if !found { + return nil, utils.GetInternalServerError(fmt.Errorf("additionalbuildcontexts must be in name=value format: %q", url)) + } + + logrus.Debugf("Processing additional build context: name=%q, value=%q", name, value) + + switch { + case strings.HasPrefix(value, "url:"): + value = strings.TrimPrefix(value, "url:") + tempDir, subdir, err := buildahDefine.TempDirForURL(anchorDir, "buildah", value) + if err != nil { + return nil, utils.GetInternalServerError(genSpaceErr(err)) + } + + contextPath := filepath.Join(tempDir, subdir) + out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{ + IsURL: true, + IsImage: false, + Value: contextPath, + DownloadedCache: contextPath, + } + case strings.HasPrefix(value, "image:"): + value = strings.TrimPrefix(value, "image:") + out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{ + IsURL: false, + IsImage: true, + Value: value, + } + case strings.HasPrefix(value, "localpath:"): + value = strings.TrimPrefix(value, "localpath:") + out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{ + IsURL: false, + IsImage: false, + Value: filepath.Clean(value), + } + } + } + return out, nil +} + +// getLocalBuildContext processes build contexts from Local API HTTP request to a BuildContext struct. +func getLocalBuildContext(r *http.Request, query url.Values, anchorDir string, _ bool) (*BuildContext, error) { + // Handle build contexts + buildContext, err := handleLocalBuildContexts(query, anchorDir) + if err != nil { + return nil, err + } + + // Process build context and container files + buildContext, err = processBuildContext(query, r, buildContext, anchorDir) + if err != nil { + return nil, err + } + + if err := buildContext.validateLocalAPIPaths(); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, utils.GetFileNotFoundError(err) + } + return nil, utils.GetGenericBadRequestError(err) + } + + // Process dockerignore + _, ignoreFile, err := util.ParseDockerignore(buildContext.ContainerFiles, buildContext.ContextDirectory) + if err != nil { + return nil, utils.GetInternalServerError(fmt.Errorf("processing ignore file: %w", err)) + } + buildContext.IgnoreFile = ignoreFile + + return buildContext, nil +} + +// LocalBuildImage handles HTTP requests for building container images using the Local API. +// +// Uses localcontextdir and additionalbuildcontexts query parameters to specify build contexts +// from the server's local filesystem. All paths must be absolute and exist on the server. +// Processes build parameters, executes the build using buildah, and streams output to the client. +func LocalBuildImage(w http.ResponseWriter, r *http.Request) { + buildImage(w, r, getLocalBuildContext) +} + +// BuildImage handles HTTP requests for building container images using the Docker-compatible API. +// +// Extracts build contexts from the request body (tar/multipart), processes build parameters, +// executes the build using buildah, and streams output back to the client. func BuildImage(w http.ResponseWriter, r *http.Request) { + buildImage(w, r, getBuildContext) +} + +type getBuildContextFunc func(r *http.Request, query url.Values, anchorDir string, multipart bool) (*BuildContext, error) + +func buildImage(w http.ResponseWriter, r *http.Request, getBuildContextFunc getBuildContextFunc) { // Create temporary directory for build context anchorDir, err := os.MkdirTemp(parse.GetTempDir(), "libpod_builder") if err != nil { @@ -897,7 +1046,7 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { } queryValues := r.URL.Query() - buildContext, err := getBuildContext(r, queryValues, anchorDir, multipart) + buildContext, err := getBuildContextFunc(r, queryValues, anchorDir, multipart) if err != nil { utils.ProcessBuildError(w, err) return diff --git a/pkg/api/handlers/swagger/errors.go b/pkg/api/handlers/swagger/errors.go index deaec2f1c4..69522900e2 100644 --- a/pkg/api/handlers/swagger/errors.go +++ b/pkg/api/handlers/swagger/errors.go @@ -16,6 +16,13 @@ type imageNotFound struct { Body errorhandling.ErrorModel } +// No such file +// swagger:response +type fileNotFound struct { + // in:body + Body errorhandling.ErrorModel +} + // No such container // swagger:response type containerNotFound struct { diff --git a/pkg/api/handlers/utils/apiutil/apiutil.go b/pkg/api/handlers/utils/apiutil/apiutil.go index f5fde780b4..41037c04a3 100644 --- a/pkg/api/handlers/utils/apiutil/apiutil.go +++ b/pkg/api/handlers/utils/apiutil/apiutil.go @@ -28,6 +28,13 @@ func IsLibpodRequest(r *http.Request) bool { return len(split) >= 3 && split[2] == "libpod" } +// IsLibpodLocalRequest returns true if the request related to a libpod local endpoint +// (e.g., /v2/libpod/local...). +func IsLibpodLocalRequest(r *http.Request) bool { + split := strings.Split(r.URL.String(), "/") + return len(split) >= 4 && split[2] == "libpod" && split[3] == "local" +} + // 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/handlers/utils/errors.go b/pkg/api/handlers/utils/errors.go index 6f1bd23fc2..e25fdbd67f 100644 --- a/pkg/api/handlers/utils/errors.go +++ b/pkg/api/handlers/utils/errors.go @@ -118,6 +118,10 @@ func (e *BuildError) Error() string { return e.err.Error() } +func GetFileNotFoundError(err error) *BuildError { + return &BuildError{code: http.StatusNotFound, err: err} +} + func GetBadRequestError(key, value string, err error) *BuildError { return &BuildError{code: http.StatusBadRequest, err: fmt.Errorf("failed to parse query parameter '%s': %q: %w", key, value, err)} } diff --git a/pkg/api/handlers/utils/handler.go b/pkg/api/handlers/utils/handler.go index 9b23a9b0ad..dec453eddb 100644 --- a/pkg/api/handlers/utils/handler.go +++ b/pkg/api/handlers/utils/handler.go @@ -30,6 +30,12 @@ func IsLibpodRequest(r *http.Request) bool { return apiutil.IsLibpodRequest(r) } +// IsLibpodLocalRequest returns true if the request related to a libpod local endpoint +// (e.g., /v2/libpod/local...). +func IsLibpodLocalRequest(r *http.Request) bool { + return apiutil.IsLibpodLocalRequest(r) +} + // 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_images.go b/pkg/api/server/register_images.go index d4851baebe..c39371052a 100644 --- a/pkg/api/server/register_images.go +++ b/pkg/api/server/register_images.go @@ -1839,6 +1839,356 @@ func (s *APIServer) registerImagesHandlers(r *mux.Router) error { // $ref: "#/responses/internalError" r.Handle(VersionedPath("/libpod/build"), s.APIHandler(compat.BuildImage)).Methods(http.MethodPost) + // swagger:operation POST /libpod/local/build libpod LocalBuildLibpod + // --- + // tags: + // - images + // summary: Create image from local build context + // description: Build an image from a local build context directory without requiring tar archive upload. The build context must already exist on the server filesystem. + // parameters: + // - in: header + // name: X-Registry-Config + // type: string + // - in: query + // name: localcontextdir + // type: string + // required: true + // description: | + // Absolute path to the build context directory on the server filesystem. + // This directory must contain all files needed for the build. + // - in: query + // name: dockerfile + // type: string + // default: Dockerfile + // description: | + // Absolute path within the build context to the `Dockerfile`. + // This is ignored if remote is specified and points to an external `Dockerfile`. + // - in: query + // name: t + // type: string + // default: latest + // description: A name and optional tag to apply to the image in the `name:tag` format. If you omit the tag, the default latest value is assumed. You can provide several t parameters. + // - in: query + // name: allplatforms + // type: boolean + // default: false + // description: | + // Instead of building for a set of platforms specified using the platform option, inspect the build's base images, + // and build for all of the platforms that are available. Stages that use *scratch* as a starting point can not be inspected, + // so at least one non-*scratch* stage must be present for detection to work usefully. + // - in: query + // name: additionalbuildcontexts + // type: array + // items: + // type: string + // default: [] + // description: | + // Additional build contexts for builds that require more than one context. + // Each additional context must be specified as a key-value pair in the format "name=value". + // + // The value can be specified in three formats: + // - URL context: Use the prefix "url:" followed by a URL to a tar archive + // Example: "mycontext=url:https://example.com/context.tar" + // - Image context: Use the prefix "image:" followed by an image reference + // Example: "mycontext=image:alpine:latest" or "mycontext=image:docker.io/library/ubuntu:22.04" + // - Local path context: Use the prefix "localpath:" followed by an absolute path on the server filesystem + // Example: "mycontext=localpath:/path/to/context/dir" + // + // (As of version 5.6.0) + // - in: query + // name: extrahosts + // type: string + // default: + // description: | + // TBD Extra hosts to add to /etc/hosts + // (As of version 1.xx) + // - in: query + // name: nohosts + // type: boolean + // default: + // description: | + // Not to create /etc/hosts when building the image + // - in: query + // name: remote + // type: string + // default: + // description: | + // A Git repository URI or HTTP/HTTPS context URI. + // If the URI points to a single text file, the file's contents are placed + // into a file called Dockerfile and the image is built from that file. If + // the URI points to a tarball, the file is downloaded by the daemon and the + // contents therein used as the context for the build. If the URI points to a + // tarball and the dockerfile parameter is also specified, there must be a file + // with the corresponding path inside the tarball. + // (As of version 1.xx) + // - in: query + // name: q + // type: boolean + // default: false + // description: | + // Suppress verbose build output + // - in: query + // name: compatvolumes + // type: boolean + // default: false + // description: | + // Contents of volume locations to be modified on ADD or COPY only + // (As of Podman version v5.2) + // - in: query + // name: createdannotation + // type: boolean + // default: true + // description: | + // Add an "org.opencontainers.image.created" annotation to the + // image. + // (As of Podman version v5.6) + // - in: query + // name: sourcedateepoch + // type: number + // description: | + // Timestamp to use for newly-added history entries and the image's + // creation date. + // (As of Podman version v5.6) + // - in: query + // name: rewritetimestamp + // type: boolean + // default: false + // description: | + // If sourcedateepoch is set, force new content added in layers to + // have timestamps no later than the sourcedateepoch date. + // (As of Podman version v5.6) + // - in: query + // name: timestamp + // type: number + // description: | + // Timestamp to use for newly-added history entries, the image's + // creation date, and for new content added in layers. + // - in: query + // name: inheritlabels + // type: boolean + // default: true + // description: | + // Inherit the labels from the base image or base stages + // (As of Podman version v5.5) + // - in: query + // name: inheritannotations + // type: boolean + // default: true + // description: | + // Inherit the annotations from the base image or base stages + // (As of Podman version v5.6) + // - in: query + // name: nocache + // type: boolean + // default: false + // description: | + // Do not use the cache when building the image + // (As of version 1.xx) + // - in: query + // name: cachefrom + // type: string + // default: + // description: | + // JSON array of images used to build cache resolution + // (As of version 1.xx) + // - in: query + // name: pull + // type: boolean + // default: false + // description: | + // Attempt to pull the image even if an older image exists locally + // (As of version 1.xx) + // - in: query + // name: rm + // type: boolean + // default: true + // description: | + // Remove intermediate containers after a successful build + // (As of version 1.xx) + // - in: query + // name: forcerm + // type: boolean + // default: false + // description: | + // Always remove intermediate containers, even upon failure + // (As of version 1.xx) + // - in: query + // name: memory + // type: integer + // description: | + // Memory is the upper limit (in bytes) on how much memory running containers can use + // (As of version 1.xx) + // - in: query + // name: memswap + // type: integer + // description: | + // MemorySwap limits the amount of memory and swap together + // (As of version 1.xx) + // - in: query + // name: cpushares + // type: integer + // description: | + // CPUShares (relative weight + // (As of version 1.xx) + // - in: query + // name: cpusetcpus + // type: string + // description: | + // CPUSetCPUs in which to allow execution (0-3, 0,1) + // (As of version 1.xx) + // - in: query + // name: cpuperiod + // type: integer + // description: | + // CPUPeriod limits the CPU CFS (Completely Fair Scheduler) period + // (As of version 1.xx) + // - in: query + // name: cpuquota + // type: integer + // description: | + // CPUQuota limits the CPU CFS (Completely Fair Scheduler) quota + // (As of version 1.xx) + // - in: query + // name: buildargs + // type: string + // default: + // description: | + // JSON map of string pairs denoting build-time variables. + // For example, the build argument `Foo` with the value of `bar` would be encoded in JSON as `["Foo":"bar"]`. + // + // For example, buildargs={"Foo":"bar"}. + // + // Note(s): + // * This should not be used to pass secrets. + // * The value of buildargs should be URI component encoded before being passed to the API. + // + // (As of version 1.xx) + // - in: query + // name: shmsize + // type: integer + // default: 67108864 + // description: | + // ShmSize is the "size" value to use when mounting an shmfs on the container's /dev/shm directory. + // Default is 64MB + // (As of version 1.xx) + // - in: query + // name: squash + // type: boolean + // default: false + // description: | + // Silently ignored. + // Squash the resulting images layers into a single layer + // (As of version 1.xx) + // - in: query + // name: labels + // type: string + // default: + // description: | + // JSON map of key, value pairs to set as labels on the new image + // (As of version 1.xx) + // - in: query + // name: layerLabel + // description: Add an intermediate image *label* (e.g. label=*value*) to the intermediate image metadata. + // type: array + // items: + // type: string + // - in: query + // name: layers + // type: boolean + // default: true + // description: | + // Cache intermediate layers during build. + // (As of version 1.xx) + // - in: query + // name: networkmode + // type: string + // default: bridge + // description: | + // Sets the networking mode for the run commands during build. + // Supported standard values are: + // * `bridge` limited to containers within a single host, port mapping required for external access + // * `host` no isolation between host and containers on this network + // * `none` disable all networking for this container + // * container: share networking with given container + // ---All other values are assumed to be a custom network's name + // (As of version 1.xx) + // - in: query + // name: platform + // type: string + // default: + // description: | + // Platform format os[/arch[/variant]] + // (As of version 1.xx) + // - in: query + // name: target + // type: string + // default: + // description: | + // Target build stage + // (As of version 1.xx) + // - in: query + // name: outputs + // type: string + // default: + // description: | + // output configuration TBD + // (As of version 1.xx) + // - in: query + // name: httpproxy + // type: boolean + // default: + // description: | + // Inject http proxy environment variables into container + // (As of version 2.0.0) + // - in: query + // name: unsetenv + // description: Unset environment variables from the final image. + // type: array + // items: + // type: string + // - in: query + // name: unsetlabel + // description: Unset the image label, causing the label not to be inherited from the base image. + // type: array + // items: + // type: string + // - in: query + // name: unsetannotation + // description: | + // Unset the image annotation, causing the annotation not to be inherited from the base image. + // (As of Podman version v5.6) + // type: array + // items: + // type: string + // - in: query + // name: volume + // description: Extra volumes that should be mounted in the build container. + // type: array + // items: + // type: string + // produces: + // - application/json + // responses: + // 200: + // description: OK (As of version 1.xx) + // schema: + // type: object + // required: + // - stream + // properties: + // stream: + // type: string + // description: output from build process + // example: | + // (build details...) + // 400: + // $ref: "#/responses/badParamError" + // 404: + // $ref: "#/responses/fileNotFound" + // 500: + // $ref: "#/responses/internalError" + r.Handle(VersionedPath("/libpod/local/build"), s.APIHandler(compat.LocalBuildImage)).Methods(http.MethodPost) + // swagger:operation POST /libpod/images/scp/{name} libpod ImageScpLibpod // --- // tags: diff --git a/pkg/bindings/images/build.go b/pkg/bindings/images/build.go index 8a78332698..fa220d9b16 100644 --- a/pkg/bindings/images/build.go +++ b/pkg/bindings/images/build.go @@ -544,23 +544,24 @@ func prepareAuthHeaders(options types.BuildOptions, requestParts *RequestParts) // prepareContainerFiles processes container files (Dockerfiles/Containerfiles) for the build. // It handles URLs, stdin input, symlinks, and determines which files need to be included // in the tar archive versus which are already in the context directory. +// The stdinDestination parameter specifies where to save stdin content when processing /dev/stdin. // WARNING: Caller must ensure tempManager.Cleanup() is called to remove any temporary files created. -func prepareContainerFiles(containerFiles []string, contextDir string, options *BuildOptions, tempManager *remote_build_helpers.TempFileManager) (*BuildFilePaths, error) { +func prepareContainerFiles(containerFiles []string, contextDir string, stdinDestination string, tempManager *remote_build_helpers.TempFileManager) (*BuildFilePaths, error) { out := BuildFilePaths{ - tarContent: []string{options.ContextDirectory}, + tarContent: []string{contextDir}, newContainerFiles: []string{}, // dockerfile paths, relative to context dir, ToSlash()ed dontexcludes: []string{"!Dockerfile", "!Containerfile", "!.dockerignore", "!.containerignore"}, excludes: []string{}, } for _, c := range containerFiles { - // Don not add path to containerfile if it is a URL + // Do not add path to containerfile if it is a URL if strings.HasPrefix(c, "http://") || strings.HasPrefix(c, "https://") { out.newContainerFiles = append(out.newContainerFiles, c) continue } if c == "/dev/stdin" { - stdinFile, err := tempManager.CreateTempFileFromReader("", "podman-build-stdin-*", os.Stdin) + stdinFile, err := tempManager.CreateTempFileFromReader(stdinDestination, "podman-build-stdin-*", os.Stdin) if err != nil { return nil, fmt.Errorf("processing stdin: %w", err) } @@ -649,11 +650,11 @@ func prepareSecrets(secrets []string, contextDir string, tempManager *remote_bui return secretsForRemote, tarContent, nil } -// prepareRequestBody creates the request body for the build API call. +// prepareRemoteRequestBody creates the request body for the build API call. // It handles both simple tar archives and multipart form data for builds with // additional build contexts, supporting URLs, images, and local directories. // WARNING: Caller must close request body. -func prepareRequestBody(ctx context.Context, requestParts *RequestParts, buildFilePaths *BuildFilePaths, options types.BuildOptions) (*RequestParts, error) { +func prepareRemoteRequestBody(ctx context.Context, requestParts *RequestParts, buildFilePaths *BuildFilePaths, options types.BuildOptions) (*RequestParts, error) { tarfile, err := nTar(append(buildFilePaths.excludes, buildFilePaths.dontexcludes...), buildFilePaths.tarContent...) if err != nil { logrus.Errorf("Cannot tar container entries %v error: %v", buildFilePaths.tarContent, err) @@ -913,7 +914,54 @@ func processBuildResponse(response *bindings.APIResponse, stdout io.Writer, save return &types.BuildReport{ID: id, SaveFormat: saveFormat}, nil } +// prepareLocalRequestBody prepares HTTP request parameters for local build API calls. +// It sets up local context directory and additional build contexts using already translated paths. +func prepareLocalRequestBody(_ context.Context, requestParts *RequestParts, _ *BuildFilePaths, options types.BuildOptions) (*RequestParts, error) { + requestParts.Params.Set("localcontextdir", options.ContextDirectory) + + for name, context := range options.AdditionalBuildContexts { + switch { + case context.IsImage: + requestParts.Params.Add("additionalbuildcontexts", fmt.Sprintf("%s=image:%s", name, context.Value)) + case context.IsURL: + requestParts.Params.Add("additionalbuildcontexts", fmt.Sprintf("%s=url:%s", name, context.Value)) + default: + requestParts.Params.Add("additionalbuildcontexts", fmt.Sprintf("%s=localpath:%s", name, context.Value)) + } + } + return requestParts, nil +} + +// BuildFromServerContext performs a container image build using the local build API to build image with files present on server. +// +// Unlike the standard Build function, this uses existing files on the remote server's filesystem +// rather than uploading build contexts. The containerFiles and options parameters should contain +// already translated paths pointing to files on the remote server, making it suitable for scenarios +// where build contexts already exist on the server (e.g., shared filesystems, mounted volumes). +// +// The context directory and containerFiles paths must be accessible on the remote server. +// Missing paths will result in build errors. +// +// Returns a BuildReport containing the final image ID and save format. +func BuildFromServerContext(ctx context.Context, containerFiles []string, options types.BuildOptions) (*types.BuildReport, error) { + return build(ctx, containerFiles, options, "/local/build", prepareLocalRequestBody) +} + +// Build performs a container image build on the remote API using the standard build API. +// +// Prepares build contexts and container files by creating tar archives from local directories, +// processes build secrets and authentication, and streams the build to the remote server. +// Supports additional build contexts (URLs, images, local directories) via multipart uploads +// for servers >= v5.6.0, otherwise uses query parameters for compatibility. +// +// Returns a BuildReport containing the final image ID and save format. func Build(ctx context.Context, containerFiles []string, options types.BuildOptions) (*types.BuildReport, error) { + return build(ctx, containerFiles, options, "/build", prepareRemoteRequestBody) +} + +type prepareRequestBodyFunc func(ctx context.Context, requestParts *RequestParts, buildFilePaths *BuildFilePaths, options types.BuildOptions) (*RequestParts, error) + +func build(ctx context.Context, containerFiles []string, options types.BuildOptions, endpoint string, prepareRequestBody prepareRequestBodyFunc) (*types.BuildReport, error) { if options.CommonBuildOpts == nil { options.CommonBuildOpts = new(define.CommonBuildOptions) } @@ -949,8 +997,11 @@ func Build(ctx context.Context, containerFiles []string, options types.BuildOpti logrus.Errorf("Cannot find absolute path of %v: %v", options.ContextDirectory, err) return nil, err } - - buildFilePaths, err := prepareContainerFiles(containerFiles, contextDirAbs, &options, tempManager) + stdinDestination := "" + if endpoint == "/local/build" { + stdinDestination = contextDirAbs + } + buildFilePaths, err := prepareContainerFiles(containerFiles, contextDirAbs, stdinDestination, tempManager) if err != nil { return nil, err } @@ -992,12 +1043,14 @@ func Build(ctx context.Context, containerFiles []string, options types.BuildOpti return nil, fmt.Errorf("building tar file: %w", err) } defer func() { - if err := requestParts.Body.Close(); err != nil { - logrus.Errorf("failed to close build request body: %v\n", err) + if requestParts.Body != nil { + if err := requestParts.Body.Close(); err != nil { + logrus.Errorf("failed to close build request body: %v\n", err) + } } }() - response, err := executeBuildRequest(ctx, "/build", requestParts) + response, err := executeBuildRequest(ctx, endpoint, requestParts) if err != nil { return nil, err } diff --git a/pkg/domain/infra/tunnel/images.go b/pkg/domain/infra/tunnel/images.go index 1c319ed957..40db9b2850 100644 --- a/pkg/domain/infra/tunnel/images.go +++ b/pkg/domain/infra/tunnel/images.go @@ -18,6 +18,7 @@ import ( "github.com/containers/podman/v5/pkg/domain/entities/reports" "github.com/containers/podman/v5/pkg/domain/utils" "github.com/containers/podman/v5/pkg/errorhandling" + "github.com/sirupsen/logrus" "go.podman.io/common/libimage/filter" "go.podman.io/common/pkg/config" "go.podman.io/image/v5/docker/reference" @@ -409,6 +410,35 @@ func (ir *ImageEngine) Config(_ context.Context) (*config.Config, error) { } func (ir *ImageEngine) Build(_ context.Context, containerFiles []string, opts entities.BuildOptions) (*entities.BuildReport, error) { + isHyperV, err := localapi.IsHyperVProvider(ir.ClientCtx) + if err != nil { + logrus.Debugf("IsHyperVProvider check failed: %v", err) + } + // Local api is not supported on Windows Hyper-V, because 9p mounts don't translate all file attributes correctly. + // So we skip trying to use localapi in that case. + if !isHyperV { + if translatedContainerFiles, translatedOptions, ok := localapi.CheckIfImageBuildPathsOnRunningMachine(ir.ClientCtx, containerFiles, opts); ok { + report, err := images.BuildFromServerContext(ir.ClientCtx, translatedContainerFiles, translatedOptions) + if err == nil { + return report, nil + } + if err != nil { + logrus.Debugf("BuildLocal failed: %v", err) + } + + var errModel *errorhandling.ErrorModel + if errors.As(err, &errModel) { + switch errModel.ResponseCode { + case http.StatusNotFound, http.StatusMethodNotAllowed: + default: + return nil, err + } + } else { + return nil, err + } + } + } + report, err := images.Build(ir.ClientCtx, containerFiles, opts) if err != nil { return nil, err diff --git a/test/apiv2/90-build.at b/test/apiv2/90-build.at index 8c667f8db2..5d8e624215 100644 --- a/test/apiv2/90-build.at +++ b/test/apiv2/90-build.at @@ -23,4 +23,66 @@ t GET images/labeltest/json 200 \ .Config.Labels.created_by="test/system/build-testimage" cleanBuildTest +# --- /libpod/local/build tests + +TMPD=$(mktemp -d podman-apiv2-test.localbuild.XXXXXXXX) +TMPD=$(realpath "$TMPD") + + +cat > $TMPD/Containerfile << EOF +FROM $IMAGE +RUN echo "local build test" +LABEL test=local-build +EOF + +t POST "libpod/local/build?localcontextdir=${TMPD}&t=localbuildtest" - 200 + +t GET images/localbuildtest/json 200 \ + .Config.Labels.test="local-build" + + +cat > $TMPD/MyDockerfile << EOF +FROM $IMAGE +RUN echo "custom dockerfile" +LABEL dockerfile=custom +EOF + +t POST "libpod/local/build?localcontextdir=${TMPD}&dockerfile=${TMPD}/MyDockerfile&t=customdockerfile" - 200 + +t GET images/customdockerfile/json 200 \ + .Config.Labels.dockerfile="custom" + + +# Test local build with additional build contexts +mkdir -p ${TMPD}/additional_context +echo "additional file" > ${TMPD}/additional_context/extra.txt + +cat > $TMPD/AdditionalContext << EOF +FROM $IMAGE +COPY --from=additional /extra.txt /copied.txt +RUN cat /copied.txt +EOF + +t POST "libpod/local/build?localcontextdir=${TMPD}&dockerfile=${TMPD}/AdditionalContext&additionalbuildcontexts=additional=localpath:${TMPD}/additional_context&t=additionalcontext" - 200 + +echo "not a directory" > ${TMPD}/notadir +t POST "libpod/local/build?localcontextdir=${TMPD}/notadir&t=filenotdir" - 400 + +t POST "libpod/local/build?localcontextdir=${TMPD}&dockerfile=/nonexistent/dockerfile&t=invalidfile" - 404 + +t POST "libpod/local/build?localcontextdir=${TMPD}&additionalbuildcontexts=malformed-context-spec=localpath:/nonexistent/directory&t=badcontext" - 404 + +t POST "libpod/local/build?localcontextdir=${TMPD}&additionalbuildcontexts=malformed-context-spec=localpath:../../nonexistent/directory&t=badcontext" - 400 + +cleanBuildTest + +t POST "libpod/local/build?localcontextdir=/nonexistent/directory&t=errortest" - 404 + +t POST "libpod/local/build?localcontextdir=../../../etc/passwd&t=errortest" - 400 + +t POST "libpod/local/build?t=missingcontext" - 400 + +t POST "libpod/local/build?localcontextdir=&t=emptycontext" - 400 + + # vim: filetype=sh From 1656c90c6efee663fc459f76a13d4d901eb2b640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Rod=C3=A1k?= Date: Mon, 22 Sep 2025 17:15:40 +0200 Subject: [PATCH 2/2] Iterate through all machine providers in FindMachineByPort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace single provider.Get() with provider.GetAll() loop to search across all available machine providers when finding machine by port. Signed-off-by: Jan Rodák --- internal/localapi/utils.go | 68 ++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/internal/localapi/utils.go b/internal/localapi/utils.go index 856da677ca..5b88758717 100644 --- a/internal/localapi/utils.go +++ b/internal/localapi/utils.go @@ -26,44 +26,46 @@ import ( // FindMachineByPort finds a running machine that matches the given connection port. // It returns the machine configuration and provider, or an error if not found. func FindMachineByPort(connectionURI string, parsedConnection *url.URL) (*vmconfigs.MachineConfig, vmconfigs.VMProvider, error) { - machineProvider, err := provider.Get() - if err != nil { - return nil, nil, fmt.Errorf("getting machine provider: %w", err) - } - - dirs, err := env.GetMachineDirs(machineProvider.VMType()) - if err != nil { - return nil, nil, err - } - - machineList, err := vmconfigs.LoadMachinesInDir(dirs) - if err != nil { - return nil, nil, fmt.Errorf("listing machines: %w", err) - } - - // Now we know that the connection points to a machine and we - // can find the machine by looking for the one with the - // matching port. - connectionPort, err := strconv.Atoi(parsedConnection.Port()) - if err != nil { - return nil, nil, fmt.Errorf("parsing connection port: %w", err) - } - - for _, mc := range machineList { - if connectionPort != mc.SSH.Port { + for _, machineProvider := range provider.GetAll() { + logrus.Debugf("Checking provider: %s", machineProvider.VMType()) + dirs, err := env.GetMachineDirs(machineProvider.VMType()) + if err != nil { + logrus.Debugf("Failed to get machine dirs for provider %s: %v", machineProvider.VMType(), err) continue } - state, err := machineProvider.State(mc, false) + machineList, err := vmconfigs.LoadMachinesInDir(dirs) if err != nil { - return nil, nil, err + logrus.Debugf("Failed to list machines: %v", err) + continue } - if state != define.Running { - return nil, nil, fmt.Errorf("machine %s is not running but in state %s", mc.Name, state) + // Now we know that the connection points to a machine and we + // can find the machine by looking for the one with the + // matching port. + connectionPort, err := strconv.Atoi(parsedConnection.Port()) + if err != nil { + logrus.Debugf("Failed to parse connection port: %v", err) + continue } - return mc, machineProvider, nil + for _, mc := range machineList { + if connectionPort != mc.SSH.Port { + continue + } + + state, err := machineProvider.State(mc, false) + if err != nil { + logrus.Debugf("Failed to get machine state for %s: %v", mc.Name, err) + continue + } + + if state != define.Running { + return nil, nil, fmt.Errorf("machine %s is not running but in state %s", mc.Name, state) + } + + return mc, machineProvider, nil + } } return nil, nil, fmt.Errorf("could not find a matching machine for connection %q", connectionURI) @@ -244,13 +246,13 @@ func IsHyperVProvider(ctx context.Context) (bool, error) { return false, err } - _, vmType, err := getMachineMountsAndVMType(conn.URI.String(), conn.URI) + _, vmProvider, err := FindMachineByPort(conn.URI.String(), conn.URI) if err != nil { - logrus.Debugf("Failed to get machine mounts: %v", err) + logrus.Debugf("Failed to get machine hypervisor type: %v", err) return false, err } - return vmType == define.HyperVVirt, nil + return vmProvider.VMType() == define.HyperVVirt, nil } // ValidatePathForLocalAPI checks if the provided path satisfies requirements for local API usage.