diff --git a/internal/localapi/types.go b/internal/localapi/types.go index 1020d78b88..badaed9383 100644 --- a/internal/localapi/types.go +++ b/internal/localapi/types.go @@ -5,3 +5,5 @@ type LocalAPIMap struct { ClientPath string `json:"ClientPath,omitempty"` RemotePath string `json:"RemotePath,omitempty"` } + +type TranslationLocalAPIMap map[string]string diff --git a/internal/localapi/utils.go b/internal/localapi/utils.go index 6e07e87edf..97c7089223 100644 --- a/internal/localapi/utils.go +++ b/internal/localapi/utils.go @@ -25,44 +25,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) @@ -154,3 +156,71 @@ func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap, return isPathAvailableOnMachine(mounts, vmType, path) } + +// CheckMultiplePathsOnRunningMachine checks if multiple paths are available on a running machine. +// Returns a translation map of local-to-remote paths if all paths are accessible, false otherwise. +func CheckMultiplePathsOnRunningMachine(ctx context.Context, paths []string) (TranslationLocalAPIMap, bool) { + result := TranslationLocalAPIMap{} + + if machineMode := bindings.GetMachineMode(ctx); !machineMode { + logrus.Debug("Machine mode is not enabled, skipping machine check") + return nil, false + } + + conn, err := bindings.GetClient(ctx) + if err != nil { + logrus.Debugf("Failed to get client connection: %v", err) + return nil, false + } + + mounts, vmType, err := getMachineMountsAndVMType(conn.URI.String(), conn.URI) + if err != nil { + logrus.Debugf("Failed to get machine mounts: %v", err) + return nil, false + } + + for _, path := range paths { + if err := fileutils.Exists(path); errors.Is(err, fs.ErrNotExist) { + logrus.Debugf("Path %s does not exist locally, skipping machine check", path) + return nil, false + } + mapping, found := isPathAvailableOnMachine(mounts, vmType, path) + if !found { + logrus.Debugf("Path %s is not available on the running machine", path) + return nil, false + } + result[path] = mapping.RemotePath + } + + logrus.Debugf("Successfully created local-to-remote path translation map: %#v\n", result) + return result, true +} + +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 + } + + _, vmProvider, err := FindMachineByPort(conn.URI.String(), conn.URI) + if err != nil { + logrus.Debugf("Failed to get machine hypervisor type: %v", err) + return false, err + } + + return vmProvider.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..5375729e8e 100644 --- a/internal/localapi/utils_unsupported.go +++ b/internal/localapi/utils_unsupported.go @@ -12,3 +12,18 @@ func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap, logrus.Debug("CheckPathOnRunningMachine is not supported") return nil, false } + +func CheckMultiplePathsOnRunningMachine(ctx context.Context, paths []string) (TranslationLocalAPIMap, bool) { + logrus.Debug("CheckMultiplePathsOnRunningMachine is not supported") + return nil, 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..fd50e3be07 100644 --- a/pkg/bindings/images/build.go +++ b/pkg/bindings/images/build.go @@ -541,20 +541,20 @@ func prepareAuthHeaders(options types.BuildOptions, requestParts *RequestParts) return requestParts, err } -// prepareContainerFiles processes container files (Dockerfiles/Containerfiles) for the build. +// prepareRemoteContainerFiles 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. // 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 prepareRemoteContainerFiles(containerFiles []string, contextDir 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 @@ -607,6 +607,75 @@ func prepareContainerFiles(containerFiles []string, contextDir string, options * return &out, nil } +// prepareLocalContainerFiles prepares container files for local build by translating paths for remote system and creating temporary files. +// WARNING: Caller must ensure tempManager.Cleanup() is called to remove any temporary files created. +func prepareLocalContainerFiles(containerFiles []string, contextDir string, tempManager *remote_build_helpers.TempFileManager, translationLocalAPIMap map[string]string) (*BuildFilePaths, error) { + out := BuildFilePaths{ + tarContent: []string{contextDir}, + newContainerFiles: []string{}, // dockerfile paths, relative to context dir, ToSlash()ed + } + + for _, c := range containerFiles { + // Don 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 + } + isStdIn := c == "/dev/stdin" + if isStdIn { + stdinFile, err := tempManager.CreateTempFileFromReader(contextDir, "podman-build-stdin-*", os.Stdin) + if err != nil { + return nil, fmt.Errorf("processing containerfile from stdin: %w", err) + } + c = stdinFile + } + c = filepath.Clean(c) + cfDir := filepath.Dir(c) + if absDir, err := filepath.EvalSymlinks(cfDir); err == nil { + name := filepath.ToSlash(strings.TrimPrefix(c, cfDir+string(filepath.Separator))) + c = filepath.Join(absDir, name) + } + + containerfile, err := filepath.Abs(c) + if err != nil { + logrus.Errorf("Cannot find absolute path of %v: %v", c, err) + return nil, err + } + + // If Containerfile does not exist, assume it is in context directory and do Not add to tarfile + isInContextDir := false + if err := fileutils.Lexists(containerfile); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + containerfile = c + isInContextDir = true + } + + remoteContainerfile := containerfile + if isStdIn { + remoteContainerfile, err = specgen.ConvertWinMountPath(remoteContainerfile) + if err != nil { + return nil, err + } + } else { + var ok bool + remoteContainerfile, ok = translationLocalAPIMap[containerfile] + if !ok { + if !isInContextDir { + return nil, fmt.Errorf("the Containerfile %q not found in a translation map", containerfile) + } else { + remoteContainerfile = containerfile + } + } + logrus.Debugf("Translated local containerfile %q -> %q", containerfile, remoteContainerfile) + } + out.newContainerFiles = append(out.newContainerFiles, filepath.ToSlash(remoteContainerfile)) + } + + return &out, nil +} + // prepareSecrets processes build secrets by creating temporary files for them. // It moves secrets to the context directory and modifies the secret configuration // to use relative paths suitable for remote builds. @@ -649,11 +718,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 +982,72 @@ 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 path translation. +func prepareLocalRequestBody(_ context.Context, requestParts *RequestParts, _ *BuildFilePaths, options types.BuildOptions, translationLocalAPIMap map[string]string) (*RequestParts, error) { + contextRemotePath, found := translationLocalAPIMap[options.ContextDirectory] + if !found { + return nil, fmt.Errorf("cannot access context directory %q for local build", options.ContextDirectory) + } + requestParts.Params.Set("localcontextdir", contextRemotePath) + + 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: + path, found := translationLocalAPIMap[context.Value] + if !found { + return nil, fmt.Errorf("missing build context path %q on machine", context.Value) + } + requestParts.Params.Add("additionalbuildcontexts", fmt.Sprintf("%s=localpath:%s", name, path)) + } + } + return requestParts, nil +} + +// BuildLocal performs a container image build using the local build API with path translation. +// +// Unlike the standard Build function, this uses existing files on the remote server's filesystem +// rather than uploading build contexts. It translates local client paths to remote server paths +// using the provided translation map, making it suitable for scenarios where build contexts +// already exist on the server (e.g., shared filesystems, mounted volumes). +// +// The translation map must contain mappings for the context directory and any additional +// build contexts. Missing mappings will result in build errors. +// +// Returns a BuildReport containing the final image ID and save format. +func BuildLocal(ctx context.Context, containerFiles []string, options types.BuildOptions, translationLocalAPIMap map[string]string) (*types.BuildReport, error) { + prepareLocalRequestBodyFunc := func(ctx context.Context, requestParts *RequestParts, buildFilePaths *BuildFilePaths, options types.BuildOptions) (*RequestParts, error) { + return prepareLocalRequestBody(ctx, requestParts, buildFilePaths, options, translationLocalAPIMap) + } + + prepareLocalContainerFilesFunc := func(containerFiles []string, contextDir string, tempManager *remote_build_helpers.TempFileManager) (*BuildFilePaths, error) { + return prepareLocalContainerFiles(containerFiles, contextDir, tempManager, translationLocalAPIMap) + } + + return build(ctx, containerFiles, options, "/local/build", prepareLocalRequestBodyFunc, prepareLocalContainerFilesFunc) +} + +// 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, prepareRemoteContainerFiles) +} + +type prepareRequestBodyFunc func(ctx context.Context, requestParts *RequestParts, buildFilePaths *BuildFilePaths, options types.BuildOptions) (*RequestParts, error) + +type prepareContainerFilesFunc func(containerFiles []string, contextDir string, tempManager *remote_build_helpers.TempFileManager) (*BuildFilePaths, error) + +func build(ctx context.Context, containerFiles []string, options types.BuildOptions, endpoint string, prepareRequestBody prepareRequestBodyFunc, prepareContainerFiles prepareContainerFilesFunc) (*types.BuildReport, error) { if options.CommonBuildOpts == nil { options.CommonBuildOpts = new(define.CommonBuildOptions) } @@ -950,7 +1084,7 @@ func Build(ctx context.Context, containerFiles []string, options types.BuildOpti return nil, err } - buildFilePaths, err := prepareContainerFiles(containerFiles, contextDirAbs, &options, tempManager) + buildFilePaths, err := prepareContainerFiles(containerFiles, contextDirAbs, tempManager) if err != nil { return nil, err } @@ -992,12 +1126,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..aed1eaa60e 100644 --- a/pkg/domain/infra/tunnel/images.go +++ b/pkg/domain/infra/tunnel/images.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "io/fs" "net/http" "os" + "path/filepath" "strconv" "strings" "time" @@ -18,11 +20,13 @@ 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" "go.podman.io/image/v5/types" "go.podman.io/storage/pkg/archive" + "go.podman.io/storage/pkg/fileutils" ) func (ir *ImageEngine) Exists(_ context.Context, nameOrID string) (*entities.BoolReport, error) { @@ -408,7 +412,74 @@ func (ir *ImageEngine) Config(_ context.Context) (*config.Config, error) { return config.Default() } +// getLocalFilesForBuild extracts all local file paths needed for a build operation. +// It collects the context directory, container files, and additional build contexts. +func getLocalFilesForBuild(containerFiles []string, options entities.BuildOptions) []string { + localFiles := []string{options.ContextDirectory} + for _, v := range containerFiles { + if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") { + continue + } + // If Containerfile does not exist, assume it is in context directory + if err := fileutils.Lexists(v); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + logrus.Fatalf("Failed to check if containerfile %s exists: %v", v, err) + return nil + } + continue + } + + v, err := filepath.Abs(v) + if err != nil { + logrus.Fatalf("Failed to get absolute path for %s: %v", v, err) + return nil + } + + localFiles = append(localFiles, v) + } + + for _, context := range options.AdditionalBuildContexts { + switch { + case context.IsImage, context.IsURL: + continue + default: + localFiles = append(localFiles, context.Value) + } + } + return localFiles +} + 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 { + localFiles := getLocalFilesForBuild(containerFiles, opts) + if translationLocalAPIMap, ok := localapi.CheckMultiplePathsOnRunningMachine(ir.ClientCtx, localFiles); ok { + report, err := images.BuildLocal(ir.ClientCtx, containerFiles, opts, translationLocalAPIMap) + 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