Skip to content

Commit 0d1b42d

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 e47ae67 commit 0d1b42d

File tree

12 files changed

+892
-15
lines changed

12 files changed

+892
-15
lines changed

internal/localapi/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ type LocalAPIMap struct {
55
ClientPath string `json:"ClientPath,omitempty"`
66
RemotePath string `json:"RemotePath,omitempty"`
77
}
8+
9+
type TranslationLocalAPIMap map[string]string

internal/localapi/utils.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,71 @@ func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap,
154154

155155
return isPathAvailableOnMachine(mounts, vmType, path)
156156
}
157+
158+
// CheckMultiplePathsOnRunningMachine checks if multiple paths are available on a running machine.
159+
// Returns a translation map of local-to-remote paths if all paths are accessible, false otherwise.
160+
func CheckMultiplePathsOnRunningMachine(ctx context.Context, paths []string) (TranslationLocalAPIMap, bool) {
161+
result := TranslationLocalAPIMap{}
162+
163+
if machineMode := bindings.GetMachineMode(ctx); !machineMode {
164+
logrus.Debug("Machine mode is not enabled, skipping machine check")
165+
return nil, 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, 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, false
178+
}
179+
180+
for _, path := range paths {
181+
if err := fileutils.Exists(path); errors.Is(err, fs.ErrNotExist) {
182+
logrus.Debugf("Path %s does not exist locally, skipping machine check", path)
183+
return nil, false
184+
}
185+
mapping, found := isPathAvailableOnMachine(mounts, vmType, path)
186+
if !found {
187+
logrus.Debugf("Path %s is not available on the running machine", path)
188+
return nil, false
189+
}
190+
result[path] = mapping.RemotePath
191+
}
192+
193+
logrus.Debugf("Successfully created local-to-remote path translation map: %#v\n", result)
194+
return result, true
195+
}
196+
197+
func IsHyperVProvider(ctx context.Context) (bool, error) {
198+
conn, err := bindings.GetClient(ctx)
199+
if err != nil {
200+
logrus.Debugf("Failed to get client connection: %v", err)
201+
return false, err
202+
}
203+
204+
_, vmType, err := getMachineMountsAndVMType(conn.URI.String(), conn.URI)
205+
if err != nil {
206+
logrus.Debugf("Failed to get machine mounts: %v", err)
207+
return false, err
208+
}
209+
210+
return vmType == define.HyperVVirt, nil
211+
}
212+
213+
// ValidatePathForLocalAPI checks if the provided path satisfies requirements for local API usage.
214+
// It returns an error if the path is not absolute or does not exist on the filesystem.
215+
func ValidatePathForLocalAPI(path string) error {
216+
if !filepath.IsAbs(path) {
217+
return fmt.Errorf("path %q is not absolute", path)
218+
}
219+
220+
if err := fileutils.Exists(path); err != nil {
221+
return err
222+
}
223+
return nil
224+
}

internal/localapi/utils_unsupported.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,18 @@ func CheckPathOnRunningMachine(ctx context.Context, path string) (*LocalAPIMap,
1212
logrus.Debug("CheckPathOnRunningMachine is not supported")
1313
return nil, false
1414
}
15+
16+
func CheckMultiplePathsOnRunningMachine(ctx context.Context, paths []string) (TranslationLocalAPIMap, bool) {
17+
logrus.Debug("CheckMultiplePathsOnRunningMachine is not supported")
18+
return nil, false
19+
}
20+
21+
func IsHyperVProvider(ctx context.Context) (bool, error) {
22+
logrus.Debug("IsHyperVProvider is not supported")
23+
return false, nil
24+
}
25+
26+
func ValidatePathForLocalAPI(path string) error {
27+
logrus.Debug("ValidatePathForLocalAPI is not supported")
28+
return nil
29+
}

pkg/api/handlers/compat/images_build.go

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/containers/buildah"
2222
buildahDefine "github.com/containers/buildah/define"
2323
"github.com/containers/buildah/pkg/parse"
24+
"github.com/containers/podman/v5/internal/localapi"
2425
"github.com/containers/podman/v5/libpod"
2526
"github.com/containers/podman/v5/pkg/api/handlers/utils"
2627
api "github.com/containers/podman/v5/pkg/api/types"
@@ -135,6 +136,29 @@ type BuildContext struct {
135136
IgnoreFile string
136137
}
137138

139+
func (b *BuildContext) validateLocalAPIPaths() error {
140+
if err := localapi.ValidatePathForLocalAPI(b.ContextDirectory); err != nil {
141+
return err
142+
}
143+
144+
for _, containerfile := range b.ContainerFiles {
145+
if err := localapi.ValidatePathForLocalAPI(containerfile); err != nil {
146+
return err
147+
}
148+
}
149+
150+
for _, ctx := range b.AdditionalBuildContexts {
151+
if ctx.IsURL || ctx.IsImage {
152+
continue
153+
}
154+
if err := localapi.ValidatePathForLocalAPI(ctx.Value); err != nil {
155+
return err
156+
}
157+
}
158+
159+
return nil
160+
}
161+
138162
// genSpaceErr wraps filesystem errors to provide more context for disk space issues.
139163
func genSpaceErr(err error) error {
140164
if errors.Is(err, syscall.ENOSPC) {
@@ -188,7 +212,7 @@ func validateContentType(r *http.Request) (bool, error) {
188212
logrus.Infof("Received %s", hdr[0])
189213
multipart = true
190214
default:
191-
if utils.IsLibpodRequest(r) {
215+
if utils.IsLibpodRequest(r) && !utils.IsLibpodLocalRequest(r) {
192216
return false, utils.GetBadRequestError("Content-Type", hdr[0],
193217
fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0]))
194218
}
@@ -256,10 +280,14 @@ func processBuildContext(query url.Values, r *http.Request, buildContext *BuildC
256280
}
257281

258282
for _, containerfile := range m {
259-
// Add path to containerfile iff it is not URL
283+
// Add path to containerfile if it is not URL
260284
if !strings.HasPrefix(containerfile, "http://") && !strings.HasPrefix(containerfile, "https://") {
261-
containerfile = filepath.Join(buildContext.ContextDirectory,
262-
filepath.Clean(filepath.FromSlash(containerfile)))
285+
if filepath.IsAbs(containerfile) {
286+
containerfile = filepath.Clean(filepath.FromSlash(containerfile))
287+
} else {
288+
containerfile = filepath.Join(buildContext.ContextDirectory,
289+
filepath.Clean(filepath.FromSlash(containerfile)))
290+
}
263291
}
264292
buildContext.ContainerFiles = append(buildContext.ContainerFiles, containerfile)
265293
}
@@ -579,7 +607,7 @@ func createBuildOptions(query *BuildQuery, buildCtx *BuildContext, queryValues u
579607
// Credential value(s) not returned as their value is not human readable
580608
return nil, nil, utils.GetGenericBadRequestError(err)
581609
}
582-
// this smells
610+
583611
cleanup := func() {
584612
auth.RemoveAuthfile(authfile)
585613
}
@@ -819,7 +847,128 @@ func executeBuild(runtime *libpod.Runtime, w http.ResponseWriter, r *http.Reques
819847
}
820848
}
821849

850+
// handleLocalBuildContexts processes build contexts for local API builds and validates local paths.
851+
//
852+
// This function handles the main build context and any additional build contexts specified in the request:
853+
// - Validates that the main context directory (localcontextdir) exists and is accessible for local API usage
854+
// - Processes additional build contexts which can be:
855+
// - URLs (url:) - downloads content to temporary directories under anchorDir
856+
// - Container images (image:) - records image references for later resolution during build
857+
// - Local paths (localpath:) - validates and cleans local filesystem paths
858+
//
859+
// Returns a BuildContext struct with the main context directory and a map of additional build contexts,
860+
// or an error if validation fails or required parameters are missing.
861+
func handleLocalBuildContexts(query url.Values, anchorDir string) (*BuildContext, error) {
862+
localContextDir := query.Get("localcontextdir")
863+
if localContextDir == "" {
864+
return nil, utils.GetBadRequestError("localcontextdir", localContextDir, fmt.Errorf("localcontextdir cannot be empty"))
865+
}
866+
localContextDir = filepath.Clean(localContextDir)
867+
if err := localapi.ValidatePathForLocalAPI(localContextDir); err != nil {
868+
if errors.Is(err, os.ErrNotExist) {
869+
return nil, utils.GetFileNotFoundError(err)
870+
}
871+
return nil, utils.GetGenericBadRequestError(err)
872+
}
873+
874+
out := &BuildContext{
875+
ContextDirectory: localContextDir,
876+
AdditionalBuildContexts: make(map[string]*buildahDefine.AdditionalBuildContext),
877+
}
878+
879+
for _, url := range query["additionalbuildcontexts"] {
880+
name, value, found := strings.Cut(url, "=")
881+
if !found {
882+
return nil, utils.GetInternalServerError(fmt.Errorf("additionalbuildcontexts must be in name=value format: %q", url))
883+
}
884+
885+
logrus.Debugf("Processing additional build context: name=%q, value=%q", name, value)
886+
887+
switch {
888+
case strings.HasPrefix(value, "url:"):
889+
value = strings.TrimPrefix(value, "url:")
890+
tempDir, subdir, err := buildahDefine.TempDirForURL(anchorDir, "buildah", value)
891+
if err != nil {
892+
return nil, utils.GetInternalServerError(genSpaceErr(err))
893+
}
894+
895+
contextPath := filepath.Join(tempDir, subdir)
896+
out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{
897+
IsURL: true,
898+
IsImage: false,
899+
Value: contextPath,
900+
DownloadedCache: contextPath,
901+
}
902+
case strings.HasPrefix(value, "image:"):
903+
value = strings.TrimPrefix(value, "image:")
904+
out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{
905+
IsURL: false,
906+
IsImage: true,
907+
Value: value,
908+
}
909+
case strings.HasPrefix(value, "localpath:"):
910+
value = strings.TrimPrefix(value, "localpath:")
911+
out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{
912+
IsURL: false,
913+
IsImage: false,
914+
Value: filepath.Clean(value),
915+
}
916+
}
917+
}
918+
return out, nil
919+
}
920+
921+
// getLocalBuildContext processes build contexts from Local API HTTP request to a BuildContext struct.
922+
func getLocalBuildContext(r *http.Request, query url.Values, anchorDir string, _ bool) (*BuildContext, error) {
923+
// Handle build contexts
924+
buildContext, err := handleLocalBuildContexts(query, anchorDir)
925+
if err != nil {
926+
return nil, err
927+
}
928+
929+
// Process build context and container files
930+
buildContext, err = processBuildContext(query, r, buildContext, anchorDir)
931+
if err != nil {
932+
return nil, err
933+
}
934+
935+
if err := buildContext.validateLocalAPIPaths(); err != nil {
936+
if errors.Is(err, os.ErrNotExist) {
937+
return nil, utils.GetFileNotFoundError(err)
938+
}
939+
return nil, utils.GetGenericBadRequestError(err)
940+
}
941+
942+
// Process dockerignore
943+
_, ignoreFile, err := util.ParseDockerignore(buildContext.ContainerFiles, buildContext.ContextDirectory)
944+
if err != nil {
945+
return nil, utils.GetInternalServerError(fmt.Errorf("processing ignore file: %w", err))
946+
}
947+
buildContext.IgnoreFile = ignoreFile
948+
949+
return buildContext, nil
950+
}
951+
952+
// LocalBuildImage handles HTTP requests for building container images using the Local API.
953+
//
954+
// Uses localcontextdir and additionalbuildcontexts query parameters to specify build contexts
955+
// from the server's local filesystem. All paths must be absolute and exist on the server.
956+
// Processes build parameters, executes the build using buildah, and streams output to the client.
957+
func LocalBuildImage(w http.ResponseWriter, r *http.Request) {
958+
buildImage(w, r, getLocalBuildContext)
959+
}
960+
961+
// BuildImage handles HTTP requests for building container images using the Docker-compatible API.
962+
//
963+
// Extracts build contexts from the request body (tar/multipart), processes build parameters,
964+
// executes the build using buildah, and streams output back to the client.
822965
func BuildImage(w http.ResponseWriter, r *http.Request) {
966+
buildImage(w, r, getBuildContext)
967+
}
968+
969+
type getBuildContextFunc func(r *http.Request, query url.Values, anchorDir string, multipart bool) (*BuildContext, error)
970+
971+
func buildImage(w http.ResponseWriter, r *http.Request, getBuildContextFunc getBuildContextFunc) {
823972
// Create temporary directory for build context
824973
anchorDir, err := os.MkdirTemp(parse.GetTempDir(), "libpod_builder")
825974
if err != nil {
@@ -850,7 +999,7 @@ func BuildImage(w http.ResponseWriter, r *http.Request) {
850999
}
8511000
queryValues := r.URL.Query()
8521001

853-
buildContext, err := getBuildContext(r, queryValues, anchorDir, multipart)
1002+
buildContext, err := getBuildContextFunc(r, queryValues, anchorDir, multipart)
8541003
if err != nil {
8551004
utils.ProcessBuildError(w, err)
8561005
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
}

pkg/api/handlers/utils/handler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ func IsLibpodRequest(r *http.Request) bool {
3030
return apiutil.IsLibpodRequest(r)
3131
}
3232

33+
// IsLibpodLocalRequest returns true if the request related to a libpod local endpoint
34+
// (e.g., /v2/libpod/local...).
35+
func IsLibpodLocalRequest(r *http.Request) bool {
36+
return apiutil.IsLibpodLocalRequest(r)
37+
}
38+
3339
// SupportedVersion validates that the version provided by client is included in the given condition
3440
// https://github.com/blang/semver#ranges provides the details for writing conditions
3541
// If a version is not given in URL path, ErrVersionNotGiven is returned

0 commit comments

Comments
 (0)