Skip to content

Commit cfa7a00

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 7fecff5 commit cfa7a00

File tree

12 files changed

+893
-16
lines changed

12 files changed

+893
-16
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
@@ -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
}

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)