Skip to content

Commit a1e7e9a

Browse files
committed
Add local build API for direct filesystem builds on MacOS and Windows (only WSL)
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 <[email protected]>
1 parent 685a448 commit a1e7e9a

File tree

11 files changed

+812
-17
lines changed

11 files changed

+812
-17
lines changed

internal/localapi/utils.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414

1515
"github.com/containers/podman/v5/pkg/bindings"
16+
"github.com/containers/podman/v5/pkg/domain/entities"
1617
"github.com/containers/podman/v5/pkg/machine/define"
1718
"github.com/containers/podman/v5/pkg/machine/env"
1819
"github.com/containers/podman/v5/pkg/machine/provider"
@@ -154,3 +155,113 @@ func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap,
154155

155156
return isPathAvailableOnMachine(mounts, vmType, path)
156157
}
158+
159+
// CheckIfImageBuildPathsOnRunningMachine checks if the build context directory and all specified
160+
// Containerfiles are available on the running machine. If they are, it translates their paths
161+
// to the corresponding remote paths and returns them along with a flag indicating success.
162+
func CheckIfImageBuildPathsOnRunningMachine(ctx context.Context, containerFiles []string, options entities.BuildOptions) ([]string, entities.BuildOptions, bool) {
163+
if machineMode := bindings.GetMachineMode(ctx); !machineMode {
164+
logrus.Debug("Machine mode is not enabled, skipping machine check")
165+
return nil, options, false
166+
}
167+
168+
conn, err := bindings.GetClient(ctx)
169+
if err != nil {
170+
logrus.Debugf("Failed to get client connection: %v", err)
171+
return nil, options, false
172+
}
173+
174+
mounts, vmType, err := getMachineMountsAndVMType(conn.URI.String(), conn.URI)
175+
if err != nil {
176+
logrus.Debugf("Failed to get machine mounts: %v", err)
177+
return nil, options, false
178+
}
179+
180+
// Context directory
181+
if err := fileutils.Lexists(options.ContextDirectory); errors.Is(err, fs.ErrNotExist) {
182+
logrus.Debugf("Path %s does not exist locally, skipping machine check", options.ContextDirectory)
183+
return nil, options, false
184+
}
185+
mapping, found := isPathAvailableOnMachine(mounts, vmType, options.ContextDirectory)
186+
if !found {
187+
logrus.Debugf("Path %s is not available on the running machine", options.ContextDirectory)
188+
return nil, options, false
189+
}
190+
options.ContextDirectory = mapping.RemotePath
191+
192+
// Containerfiles
193+
translatedContainerFiles := []string{}
194+
for _, containerFile := range containerFiles {
195+
if strings.HasPrefix(containerFile, "http://") || strings.HasPrefix(containerFile, "https://") {
196+
translatedContainerFiles = append(translatedContainerFiles, containerFile)
197+
continue
198+
}
199+
200+
// If Containerfile does not exist, assume it is in context directory
201+
if err := fileutils.Lexists(containerFile); err != nil {
202+
if !errors.Is(err, fs.ErrNotExist) {
203+
logrus.Fatalf("Failed to check if containerfile %s exists: %v", containerFile, err)
204+
return nil, options, false
205+
}
206+
continue
207+
}
208+
209+
mapping, found := isPathAvailableOnMachine(mounts, vmType, containerFile)
210+
if !found {
211+
logrus.Debugf("Path %s is not available on the running machine", containerFile)
212+
return nil, options, false
213+
}
214+
translatedContainerFiles = append(translatedContainerFiles, mapping.RemotePath)
215+
}
216+
217+
// Additional build contexts
218+
for _, context := range options.AdditionalBuildContexts {
219+
switch {
220+
case context.IsImage, context.IsURL:
221+
continue
222+
default:
223+
if err := fileutils.Lexists(context.Value); errors.Is(err, fs.ErrNotExist) {
224+
logrus.Debugf("Path %s does not exist locally, skipping machine check", context.Value)
225+
return nil, options, false
226+
}
227+
mapping, found := isPathAvailableOnMachine(mounts, vmType, context.Value)
228+
if !found {
229+
logrus.Debugf("Path %s is not available on the running machine", context.Value)
230+
return nil, options, false
231+
}
232+
context.Value = mapping.RemotePath
233+
}
234+
}
235+
return translatedContainerFiles, options, true
236+
}
237+
238+
// IsHyperVProvider checks if the current machine provider is Hyper-V.
239+
// It returns true if the provider is Hyper-V, false otherwise, or an error if the check fails.
240+
func IsHyperVProvider(ctx context.Context) (bool, error) {
241+
conn, err := bindings.GetClient(ctx)
242+
if err != nil {
243+
logrus.Debugf("Failed to get client connection: %v", err)
244+
return false, err
245+
}
246+
247+
_, vmType, err := getMachineMountsAndVMType(conn.URI.String(), conn.URI)
248+
if err != nil {
249+
logrus.Debugf("Failed to get machine mounts: %v", err)
250+
return false, err
251+
}
252+
253+
return vmType == define.HyperVVirt, nil
254+
}
255+
256+
// ValidatePathForLocalAPI checks if the provided path satisfies requirements for local API usage.
257+
// It returns an error if the path is not absolute or does not exist on the filesystem.
258+
func ValidatePathForLocalAPI(path string) error {
259+
if !filepath.IsAbs(path) {
260+
return fmt.Errorf("path %q is not absolute", path)
261+
}
262+
263+
if err := fileutils.Exists(path); err != nil {
264+
return err
265+
}
266+
return nil
267+
}

internal/localapi/utils_unsupported.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,26 @@ package localapi
55
import (
66
"context"
77

8+
"github.com/containers/podman/v5/pkg/domain/entities"
89
"github.com/sirupsen/logrus"
910
)
1011

1112
func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap, bool) {
1213
logrus.Debug("CheckPathOnRunningMachine is not supported")
1314
return nil, false
1415
}
16+
17+
func CheckIfImageBuildPathsOnRunningMachine(ctx context.Context, containerFiles []string, options entities.BuildOptions) ([]string, entities.BuildOptions, bool) {
18+
logrus.Debug("CheckIfImageBuildPathsOnRunningMachine is not supported")
19+
return nil, options, false
20+
}
21+
22+
func IsHyperVProvider(ctx context.Context) (bool, error) {
23+
logrus.Debug("IsHyperVProvider is not supported")
24+
return false, nil
25+
}
26+
27+
func ValidatePathForLocalAPI(path string) error {
28+
logrus.Debug("ValidatePathForLocalAPI is not supported")
29+
return nil
30+
}

pkg/api/handlers/compat/images_build.go

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/containers/buildah"
2323
buildahDefine "github.com/containers/buildah/define"
2424
"github.com/containers/buildah/pkg/parse"
25+
"github.com/containers/podman/v5/internal/localapi"
2526
"github.com/containers/podman/v5/libpod"
2627
"github.com/containers/podman/v5/pkg/api/handlers/utils"
2728
api "github.com/containers/podman/v5/pkg/api/types"
@@ -143,6 +144,29 @@ type BuildContext struct {
143144
IgnoreFile string
144145
}
145146

147+
func (b *BuildContext) validateLocalAPIPaths() error {
148+
if err := localapi.ValidatePathForLocalAPI(b.ContextDirectory); err != nil {
149+
return err
150+
}
151+
152+
for _, containerfile := range b.ContainerFiles {
153+
if err := localapi.ValidatePathForLocalAPI(containerfile); err != nil {
154+
return err
155+
}
156+
}
157+
158+
for _, ctx := range b.AdditionalBuildContexts {
159+
if ctx.IsURL || ctx.IsImage {
160+
continue
161+
}
162+
if err := localapi.ValidatePathForLocalAPI(ctx.Value); err != nil {
163+
return err
164+
}
165+
}
166+
167+
return nil
168+
}
169+
146170
// genSpaceErr wraps filesystem errors to provide more context for disk space issues.
147171
func genSpaceErr(err error) error {
148172
if errors.Is(err, syscall.ENOSPC) {
@@ -196,7 +220,7 @@ func validateContentType(r *http.Request) (bool, error) {
196220
logrus.Infof("Received %s", hdr[0])
197221
multipart = true
198222
default:
199-
if utils.IsLibpodRequest(r) {
223+
if utils.IsLibpodRequest(r) && !utils.IsLibpodLocalRequest(r) {
200224
return false, utils.GetBadRequestError("Content-Type", hdr[0],
201225
fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0]))
202226
}
@@ -264,10 +288,14 @@ func processBuildContext(query url.Values, r *http.Request, buildContext *BuildC
264288
}
265289

266290
for _, containerfile := range m {
267-
// Add path to containerfile iff it is not URL
291+
// Add path to containerfile if it is not URL
268292
if !strings.HasPrefix(containerfile, "http://") && !strings.HasPrefix(containerfile, "https://") {
269-
containerfile = filepath.Join(buildContext.ContextDirectory,
270-
filepath.Clean(filepath.FromSlash(containerfile)))
293+
if filepath.IsAbs(containerfile) {
294+
containerfile = filepath.Clean(filepath.FromSlash(containerfile))
295+
} else {
296+
containerfile = filepath.Join(buildContext.ContextDirectory,
297+
filepath.Clean(filepath.FromSlash(containerfile)))
298+
}
271299
}
272300
buildContext.ContainerFiles = append(buildContext.ContainerFiles, containerfile)
273301
}
@@ -587,7 +615,7 @@ func createBuildOptions(query *BuildQuery, buildCtx *BuildContext, queryValues u
587615
// Credential value(s) not returned as their value is not human readable
588616
return nil, nil, utils.GetGenericBadRequestError(err)
589617
}
590-
// this smells
618+
591619
cleanup := func() {
592620
auth.RemoveAuthfile(authfile)
593621
}
@@ -866,7 +894,128 @@ func executeBuild(runtime *libpod.Runtime, w http.ResponseWriter, r *http.Reques
866894
}
867895
}
868896

897+
// handleLocalBuildContexts processes build contexts for local API builds and validates local paths.
898+
//
899+
// This function handles the main build context and any additional build contexts specified in the request:
900+
// - Validates that the main context directory (localcontextdir) exists and is accessible for local API usage
901+
// - Processes additional build contexts which can be:
902+
// - URLs (url:) - downloads content to temporary directories under anchorDir
903+
// - Container images (image:) - records image references for later resolution during build
904+
// - Local paths (localpath:) - validates and cleans local filesystem paths
905+
//
906+
// Returns a BuildContext struct with the main context directory and a map of additional build contexts,
907+
// or an error if validation fails or required parameters are missing.
908+
func handleLocalBuildContexts(query url.Values, anchorDir string) (*BuildContext, error) {
909+
localContextDir := query.Get("localcontextdir")
910+
if localContextDir == "" {
911+
return nil, utils.GetBadRequestError("localcontextdir", localContextDir, fmt.Errorf("localcontextdir cannot be empty"))
912+
}
913+
localContextDir = filepath.Clean(localContextDir)
914+
if err := localapi.ValidatePathForLocalAPI(localContextDir); err != nil {
915+
if errors.Is(err, os.ErrNotExist) {
916+
return nil, utils.GetFileNotFoundError(err)
917+
}
918+
return nil, utils.GetGenericBadRequestError(err)
919+
}
920+
921+
out := &BuildContext{
922+
ContextDirectory: localContextDir,
923+
AdditionalBuildContexts: make(map[string]*buildahDefine.AdditionalBuildContext),
924+
}
925+
926+
for _, url := range query["additionalbuildcontexts"] {
927+
name, value, found := strings.Cut(url, "=")
928+
if !found {
929+
return nil, utils.GetInternalServerError(fmt.Errorf("additionalbuildcontexts must be in name=value format: %q", url))
930+
}
931+
932+
logrus.Debugf("Processing additional build context: name=%q, value=%q", name, value)
933+
934+
switch {
935+
case strings.HasPrefix(value, "url:"):
936+
value = strings.TrimPrefix(value, "url:")
937+
tempDir, subdir, err := buildahDefine.TempDirForURL(anchorDir, "buildah", value)
938+
if err != nil {
939+
return nil, utils.GetInternalServerError(genSpaceErr(err))
940+
}
941+
942+
contextPath := filepath.Join(tempDir, subdir)
943+
out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{
944+
IsURL: true,
945+
IsImage: false,
946+
Value: contextPath,
947+
DownloadedCache: contextPath,
948+
}
949+
case strings.HasPrefix(value, "image:"):
950+
value = strings.TrimPrefix(value, "image:")
951+
out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{
952+
IsURL: false,
953+
IsImage: true,
954+
Value: value,
955+
}
956+
case strings.HasPrefix(value, "localpath:"):
957+
value = strings.TrimPrefix(value, "localpath:")
958+
out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{
959+
IsURL: false,
960+
IsImage: false,
961+
Value: filepath.Clean(value),
962+
}
963+
}
964+
}
965+
return out, nil
966+
}
967+
968+
// getLocalBuildContext processes build contexts from Local API HTTP request to a BuildContext struct.
969+
func getLocalBuildContext(r *http.Request, query url.Values, anchorDir string, _ bool) (*BuildContext, error) {
970+
// Handle build contexts
971+
buildContext, err := handleLocalBuildContexts(query, anchorDir)
972+
if err != nil {
973+
return nil, err
974+
}
975+
976+
// Process build context and container files
977+
buildContext, err = processBuildContext(query, r, buildContext, anchorDir)
978+
if err != nil {
979+
return nil, err
980+
}
981+
982+
if err := buildContext.validateLocalAPIPaths(); err != nil {
983+
if errors.Is(err, os.ErrNotExist) {
984+
return nil, utils.GetFileNotFoundError(err)
985+
}
986+
return nil, utils.GetGenericBadRequestError(err)
987+
}
988+
989+
// Process dockerignore
990+
_, ignoreFile, err := util.ParseDockerignore(buildContext.ContainerFiles, buildContext.ContextDirectory)
991+
if err != nil {
992+
return nil, utils.GetInternalServerError(fmt.Errorf("processing ignore file: %w", err))
993+
}
994+
buildContext.IgnoreFile = ignoreFile
995+
996+
return buildContext, nil
997+
}
998+
999+
// LocalBuildImage handles HTTP requests for building container images using the Local API.
1000+
//
1001+
// Uses localcontextdir and additionalbuildcontexts query parameters to specify build contexts
1002+
// from the server's local filesystem. All paths must be absolute and exist on the server.
1003+
// Processes build parameters, executes the build using buildah, and streams output to the client.
1004+
func LocalBuildImage(w http.ResponseWriter, r *http.Request) {
1005+
buildImage(w, r, getLocalBuildContext)
1006+
}
1007+
1008+
// BuildImage handles HTTP requests for building container images using the Docker-compatible API.
1009+
//
1010+
// Extracts build contexts from the request body (tar/multipart), processes build parameters,
1011+
// executes the build using buildah, and streams output back to the client.
8691012
func BuildImage(w http.ResponseWriter, r *http.Request) {
1013+
buildImage(w, r, getBuildContext)
1014+
}
1015+
1016+
type getBuildContextFunc func(r *http.Request, query url.Values, anchorDir string, multipart bool) (*BuildContext, error)
1017+
1018+
func buildImage(w http.ResponseWriter, r *http.Request, getBuildContextFunc getBuildContextFunc) {
8701019
// Create temporary directory for build context
8711020
anchorDir, err := os.MkdirTemp(parse.GetTempDir(), "libpod_builder")
8721021
if err != nil {
@@ -897,7 +1046,7 @@ func BuildImage(w http.ResponseWriter, r *http.Request) {
8971046
}
8981047
queryValues := r.URL.Query()
8991048

900-
buildContext, err := getBuildContext(r, queryValues, anchorDir, multipart)
1049+
buildContext, err := getBuildContextFunc(r, queryValues, anchorDir, multipart)
9011050
if err != nil {
9021051
utils.ProcessBuildError(w, err)
9031052
return

pkg/api/handlers/swagger/errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ type imageNotFound struct {
1616
Body errorhandling.ErrorModel
1717
}
1818

19+
// No such file
20+
// swagger:response
21+
type fileNotFound struct {
22+
// in:body
23+
Body errorhandling.ErrorModel
24+
}
25+
1926
// No such container
2027
// swagger:response
2128
type containerNotFound struct {

pkg/api/handlers/utils/apiutil/apiutil.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ func IsLibpodRequest(r *http.Request) bool {
2828
return len(split) >= 3 && split[2] == "libpod"
2929
}
3030

31+
// IsLibpodLocalRequest returns true if the request related to a libpod local endpoint
32+
// (e.g., /v2/libpod/local...).
33+
func IsLibpodLocalRequest(r *http.Request) bool {
34+
split := strings.Split(r.URL.String(), "/")
35+
return len(split) >= 4 && split[2] == "libpod" && split[3] == "local"
36+
}
37+
3138
// SupportedVersion validates that the version provided by client is included in the given condition
3239
// https://github.com/blang/semver#ranges provides the details for writing conditions
3340
// If a version is not given in URL path, ErrVersionNotGiven is returned

pkg/api/handlers/utils/errors.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ func (e *BuildError) Error() string {
118118
return e.err.Error()
119119
}
120120

121+
func GetFileNotFoundError(err error) *BuildError {
122+
return &BuildError{code: http.StatusNotFound, err: err}
123+
}
124+
121125
func GetBadRequestError(key, value string, err error) *BuildError {
122126
return &BuildError{code: http.StatusBadRequest, err: fmt.Errorf("failed to parse query parameter '%s': %q: %w", key, value, err)}
123127
}

0 commit comments

Comments
 (0)