package images import ( "archive/tar" "context" "encoding/json" "errors" "fmt" "io" "io/fs" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "runtime" "strconv" "strings" "github.com/blang/semver/v4" "github.com/containers/buildah/define" "github.com/containers/podman/v5/internal/remote_build_helpers" ldefine "github.com/containers/podman/v5/libpod/define" "github.com/containers/podman/v5/pkg/auth" "github.com/containers/podman/v5/pkg/bindings" "github.com/containers/podman/v5/pkg/domain/entities/types" "github.com/containers/podman/v5/pkg/specgen" "github.com/containers/podman/v5/pkg/util" "github.com/docker/docker/pkg/jsonmessage" "github.com/docker/go-units" "github.com/hashicorp/go-multierror" jsoniter "github.com/json-iterator/go" gzip "github.com/klauspost/pgzip" "github.com/sirupsen/logrus" imageTypes "go.podman.io/image/v5/types" "go.podman.io/storage/pkg/archive" "go.podman.io/storage/pkg/fileutils" "go.podman.io/storage/pkg/ioutils" "go.podman.io/storage/pkg/regexp" ) type devino struct { Dev uint64 Ino uint64 } var iidRegex = regexp.Delayed(`^[0-9a-f]{12}`) type BuildResponse struct { Stream string `json:"stream,omitempty"` Error *jsonmessage.JSONError `json:"errorDetail,omitempty"` // NOTE: `error` is being deprecated check https://github.com/moby/moby/blob/master/pkg/jsonmessage/jsonmessage.go#L148 ErrorMessage string `json:"error,omitempty"` // deprecate this slowly Aux json.RawMessage `json:"aux,omitempty"` } // BuildFilePaths contains the file paths and exclusion patterns for the build context. type BuildFilePaths struct { tarContent []string newContainerFiles []string // dockerfile paths, relative to context dir, ToSlash()ed dontexcludes []string excludes []string } // RequestParts contains the components of an HTTP request for the build API. type RequestParts struct { Headers http.Header Params url.Values Body io.ReadCloser } // Modify the build contexts that uses a local windows path. The windows path is // converted into the corresping guest path in the default Windows machine // (e.g. C:\test ==> /mnt/c/test). func convertAdditionalBuildContexts(additionalBuildContexts map[string]*define.AdditionalBuildContext) { for _, context := range additionalBuildContexts { if !context.IsImage && !context.IsURL { path, err := specgen.ConvertWinMountPath(context.Value) // It's not worth failing if the path can't be converted if err == nil { context.Value = path } } } } // convertVolumeSrcPath converts windows paths in the HOST-DIR part of a volume // into the corresponding path in the default Windows machine. // (e.g. C:\test:/src/docs ==> /mnt/c/test:/src/docs). // If any error occurs while parsing the volume string, the original volume // string is returned. func convertVolumeSrcPath(volume string) string { splitVol := specgen.SplitVolumeString(volume) if len(splitVol) < 2 || len(splitVol) > 3 { return volume } convertedSrcPath, err := specgen.ConvertWinMountPath(splitVol[0]) if err != nil { return volume } if len(splitVol) == 2 { return convertedSrcPath + ":" + splitVol[1] } else { return convertedSrcPath + ":" + splitVol[1] + ":" + splitVol[2] } } // isSupportedVersion checks if the server version is greater than or equal to the specified minimum version. // It extracts version numbers from the server version string, removing any suffixes like -dev or -rc, // and compares them using semantic versioning. func isSupportedVersion(ctx context.Context, minVersion string) (bool, error) { serverVersion := bindings.ServiceVersion(ctx) // Extract just the version numbers (remove -dev, -rc, etc) versionStr := serverVersion.String() if idx := strings.Index(versionStr, "-"); idx > 0 { versionStr = versionStr[:idx] } serverVer, err := semver.ParseTolerant(versionStr) if err != nil { return false, fmt.Errorf("parsing server version %q: %w", serverVersion, err) } minMultipartVersion, _ := semver.ParseTolerant(minVersion) return serverVer.GTE(minMultipartVersion), nil } // prepareParams converts BuildOptions into URL parameters for the build API request. // It handles various build options including capabilities, annotations, CPU settings, // devices, labels, platforms, volumes, and other build configuration parameters. func prepareParams(options types.BuildOptions) (url.Values, error) { params := url.Values{} if caps := options.AddCapabilities; len(caps) > 0 { c, err := jsoniter.MarshalToString(caps) if err != nil { return nil, err } params.Add("addcaps", c) } if annotations := options.Annotations; len(annotations) > 0 { l, err := jsoniter.MarshalToString(annotations) if err != nil { return nil, err } params.Set("annotations", l) } if cppflags := options.CPPFlags; len(cppflags) > 0 { l, err := jsoniter.MarshalToString(cppflags) if err != nil { return nil, err } params.Set("cppflags", l) } if options.AllPlatforms { params.Add("allplatforms", "1") } params.Add("t", options.Output) for _, tag := range options.AdditionalTags { params.Add("t", tag) } if options.IDMappingOptions != nil { idmappingsOptions, err := jsoniter.Marshal(options.IDMappingOptions) if err != nil { return nil, err } params.Set("idmappingoptions", string(idmappingsOptions)) } if buildArgs := options.Args; len(buildArgs) > 0 { bArgs, err := jsoniter.MarshalToString(buildArgs) if err != nil { return nil, err } params.Set("buildargs", bArgs) } if excludes := options.Excludes; len(excludes) > 0 { bArgs, err := jsoniter.MarshalToString(excludes) if err != nil { return nil, err } params.Set("excludes", bArgs) } if cpuPeriod := options.CommonBuildOpts.CPUPeriod; cpuPeriod > 0 { params.Set("cpuperiod", strconv.Itoa(int(cpuPeriod))) } if cpuQuota := options.CommonBuildOpts.CPUQuota; cpuQuota > 0 { params.Set("cpuquota", strconv.Itoa(int(cpuQuota))) } if cpuSetCpus := options.CommonBuildOpts.CPUSetCPUs; len(cpuSetCpus) > 0 { params.Set("cpusetcpus", cpuSetCpus) } if cpuSetMems := options.CommonBuildOpts.CPUSetMems; len(cpuSetMems) > 0 { params.Set("cpusetmems", cpuSetMems) } if cpuShares := options.CommonBuildOpts.CPUShares; cpuShares > 0 { params.Set("cpushares", strconv.Itoa(int(cpuShares))) } if len(options.CommonBuildOpts.CgroupParent) > 0 { params.Set("cgroupparent", options.CommonBuildOpts.CgroupParent) } params.Set("networkmode", strconv.Itoa(int(options.ConfigureNetwork))) params.Set("outputformat", options.OutputFormat) if devices := options.Devices; len(devices) > 0 { d, err := jsoniter.MarshalToString(devices) if err != nil { return nil, err } params.Add("devices", d) } if dnsservers := options.CommonBuildOpts.DNSServers; len(dnsservers) > 0 { c, err := jsoniter.MarshalToString(dnsservers) if err != nil { return nil, err } params.Add("dnsservers", c) } if dnsoptions := options.CommonBuildOpts.DNSOptions; len(dnsoptions) > 0 { c, err := jsoniter.MarshalToString(dnsoptions) if err != nil { return nil, err } params.Add("dnsoptions", c) } if dnssearch := options.CommonBuildOpts.DNSSearch; len(dnssearch) > 0 { c, err := jsoniter.MarshalToString(dnssearch) if err != nil { return nil, err } params.Add("dnssearch", c) } if caps := options.DropCapabilities; len(caps) > 0 { c, err := jsoniter.MarshalToString(caps) if err != nil { return nil, err } params.Add("dropcaps", c) } if options.ForceRmIntermediateCtrs { params.Set("forcerm", "1") } if options.RemoveIntermediateCtrs { params.Set("rm", "1") } else { params.Set("rm", "0") } if options.CommonBuildOpts.OmitHistory { params.Set("omithistory", "1") } else { params.Set("omithistory", "0") } if len(options.From) > 0 { params.Set("from", options.From) } if options.IgnoreUnrecognizedInstructions { params.Set("ignore", "1") } switch options.CreatedAnnotation { case imageTypes.OptionalBoolFalse: params.Set("createdannotation", "0") case imageTypes.OptionalBoolTrue: params.Set("createdannotation", "1") } switch options.InheritLabels { case imageTypes.OptionalBoolFalse: params.Set("inheritlabels", "0") case imageTypes.OptionalBoolTrue: params.Set("inheritlabels", "1") } if options.InheritAnnotations == imageTypes.OptionalBoolFalse { params.Set("inheritannotations", "0") } else { params.Set("inheritannotations", "1") } params.Set("isolation", strconv.Itoa(int(options.Isolation))) if options.CommonBuildOpts.HTTPProxy { params.Set("httpproxy", "1") } if options.Jobs != nil { params.Set("jobs", strconv.FormatUint(uint64(*options.Jobs), 10)) } if labels := options.Labels; len(labels) > 0 { l, err := jsoniter.MarshalToString(labels) if err != nil { return nil, err } params.Set("labels", l) } if opt := options.CommonBuildOpts.LabelOpts; len(opt) > 0 { o, err := jsoniter.MarshalToString(opt) if err != nil { return nil, err } params.Set("labelopts", o) } if len(options.CommonBuildOpts.SeccompProfilePath) > 0 { params.Set("seccomp", options.CommonBuildOpts.SeccompProfilePath) } if len(options.CommonBuildOpts.ApparmorProfile) > 0 { params.Set("apparmor", options.CommonBuildOpts.ApparmorProfile) } for _, layerLabel := range options.LayerLabels { params.Add("layerLabel", layerLabel) } if options.Layers { params.Set("layers", "1") } if options.LogRusage { params.Set("rusage", "1") } if len(options.RusageLogFile) > 0 { params.Set("rusagelogfile", options.RusageLogFile) } params.Set("retry", strconv.Itoa(options.MaxPullPushRetries)) params.Set("retry-delay", options.PullPushRetryDelay.String()) if len(options.Manifest) > 0 { params.Set("manifest", options.Manifest) } if options.CacheFrom != nil { cacheFrom := []string{} for _, cacheSrc := range options.CacheFrom { cacheFrom = append(cacheFrom, cacheSrc.String()) } cacheFromJSON, err := jsoniter.MarshalToString(cacheFrom) if err != nil { return nil, err } params.Set("cachefrom", cacheFromJSON) } switch options.SkipUnusedStages { case imageTypes.OptionalBoolTrue: params.Set("skipunusedstages", "1") case imageTypes.OptionalBoolFalse: params.Set("skipunusedstages", "0") } if options.CacheTo != nil { cacheTo := []string{} for _, cacheSrc := range options.CacheTo { cacheTo = append(cacheTo, cacheSrc.String()) } cacheToJSON, err := jsoniter.MarshalToString(cacheTo) if err != nil { return nil, err } params.Set("cacheto", cacheToJSON) } if int64(options.CacheTTL) != 0 { params.Set("cachettl", options.CacheTTL.String()) } if memSwap := options.CommonBuildOpts.MemorySwap; memSwap > 0 { params.Set("memswap", strconv.Itoa(int(memSwap))) } if mem := options.CommonBuildOpts.Memory; mem > 0 { params.Set("memory", strconv.Itoa(int(mem))) } switch options.CompatVolumes { case imageTypes.OptionalBoolTrue: params.Set("compatvolumes", "1") case imageTypes.OptionalBoolFalse: params.Set("compatvolumes", "0") } if options.NoCache { params.Set("nocache", "1") } if options.CommonBuildOpts.NoHosts { params.Set("nohosts", "1") } if t := options.Output; len(t) > 0 { params.Set("output", t) } if t := options.OSVersion; len(t) > 0 { params.Set("osversion", t) } for _, t := range options.OSFeatures { params.Set("osfeature", t) } var platform string if len(options.OS) > 0 { platform = options.OS } if len(options.Architecture) > 0 { if len(platform) == 0 { platform = "linux" } platform += "/" + options.Architecture } else if len(platform) > 0 { platform += "/" + runtime.GOARCH } if len(platform) > 0 { params.Set("platform", platform) } if len(options.Platforms) > 0 { params.Del("platform") for _, platformSpec := range options.Platforms { // podman-cli will send empty struct, in such // case don't add platform to param and let the // build backend decide the default platform. if platformSpec.OS == "" && platformSpec.Arch == "" && platformSpec.Variant == "" { continue } platform = platformSpec.OS + "/" + platformSpec.Arch if platformSpec.Variant != "" { platform += "/" + platformSpec.Variant } params.Add("platform", platform) } } for _, volume := range options.CommonBuildOpts.Volumes { params.Add("volume", convertVolumeSrcPath(volume)) } for _, group := range options.GroupAdd { params.Add("groupadd", group) } params.Set("pullpolicy", options.PullPolicy.String()) switch options.CommonBuildOpts.IdentityLabel { case imageTypes.OptionalBoolTrue: params.Set("identitylabel", "1") case imageTypes.OptionalBoolFalse: params.Set("identitylabel", "0") } if options.Quiet { params.Set("q", "1") } if options.RemoveIntermediateCtrs { params.Set("rm", "1") } if len(options.Target) > 0 { params.Set("target", options.Target) } if hosts := options.CommonBuildOpts.AddHost; len(hosts) > 0 { h, err := jsoniter.MarshalToString(hosts) if err != nil { return nil, err } params.Set("extrahosts", h) } if nsoptions := options.NamespaceOptions; len(nsoptions) > 0 { ns, err := jsoniter.MarshalToString(nsoptions) if err != nil { return nil, err } params.Set("nsoptions", ns) } if shmSize := options.CommonBuildOpts.ShmSize; len(shmSize) > 0 { shmBytes, err := units.RAMInBytes(shmSize) if err != nil { return nil, err } params.Set("shmsize", strconv.Itoa(int(shmBytes))) } if options.Squash { params.Set("squash", "1") } if options.SourceDateEpoch != nil { t := options.SourceDateEpoch params.Set("sourcedateepoch", strconv.FormatInt(t.Unix(), 10)) } if options.RewriteTimestamp { params.Set("rewritetimestamp", "1") } else { params.Set("rewritetimestamp", "0") } if options.Timestamp != nil { t := options.Timestamp params.Set("timestamp", strconv.FormatInt(t.Unix(), 10)) } if len(options.CommonBuildOpts.Ulimit) > 0 { ulimitsJSON, err := json.Marshal(options.CommonBuildOpts.Ulimit) if err != nil { return nil, err } params.Set("ulimits", string(ulimitsJSON)) } for _, env := range options.Envs { params.Add("setenv", env) } for _, uenv := range options.UnsetEnvs { params.Add("unsetenv", uenv) } for _, ulabel := range options.UnsetLabels { params.Add("unsetlabel", ulabel) } for _, uannotation := range options.UnsetAnnotations { params.Add("unsetannotation", uannotation) } return params, nil } // prepareAuthHeaders sets up authentication headers for the build request. // It handles Docker authentication configuration and TLS verification settings // from the system context. func prepareAuthHeaders(options types.BuildOptions, requestParts *RequestParts) (*RequestParts, error) { var err error if options.SystemContext == nil { return requestParts, err } if options.SystemContext.DockerAuthConfig != nil { requestParts.Headers, err = auth.MakeXRegistryAuthHeader(options.SystemContext, options.SystemContext.DockerAuthConfig.Username, options.SystemContext.DockerAuthConfig.Password) } else { requestParts.Headers, err = auth.MakeXRegistryConfigHeader(options.SystemContext, "", "") } if options.SystemContext.DockerInsecureSkipTLSVerify == imageTypes.OptionalBoolTrue { requestParts.Params.Set("tlsVerify", "false") } return requestParts, err } // prepareContainerFiles processes container files (Dockerfiles/Containerfiles) for the build. // It handles URLs, stdin input, symlinks, and determines which files need to be included // in the tar archive versus which are already in the context directory. // 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) { out := BuildFilePaths{ tarContent: []string{options.ContextDirectory}, 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 if strings.HasPrefix(c, "http://") || strings.HasPrefix(c, "https://") { out.newContainerFiles = append(out.newContainerFiles, c) continue } if c == "/dev/stdin" { stdinFile, err := tempManager.CreateTempFileFromReader("", "podman-build-stdin-*", os.Stdin) if err != nil { return nil, fmt.Errorf("processing 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 } // Check if Containerfile is in the context directory, if so truncate the context directory off path // Do NOT add to tarfile if after, ok := strings.CutPrefix(containerfile, contextDir+string(filepath.Separator)); ok { containerfile = after out.dontexcludes = append(out.dontexcludes, "!"+containerfile) out.dontexcludes = append(out.dontexcludes, "!"+containerfile+".dockerignore") out.dontexcludes = append(out.dontexcludes, "!"+containerfile+".containerignore") } else { // If Containerfile does not exist, assume it is in context directory and do Not add to tarfile if err := fileutils.Lexists(containerfile); err != nil { if !os.IsNotExist(err) { return nil, err } containerfile = c out.dontexcludes = append(out.dontexcludes, "!"+containerfile) out.dontexcludes = append(out.dontexcludes, "!"+containerfile+".dockerignore") out.dontexcludes = append(out.dontexcludes, "!"+containerfile+".containerignore") } else { // If Containerfile does exist and not in the context directory, add it to the tarfile out.tarContent = append(out.tarContent, containerfile) } } out.newContainerFiles = append(out.newContainerFiles, filepath.ToSlash(containerfile)) } 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. // WARNING: Caller must ensure tempManager.Cleanup() is called to remove any temporary files created. func prepareSecrets(secrets []string, contextDir string, tempManager *remote_build_helpers.TempFileManager) ([]string, []string, error) { if len(secrets) == 0 { return nil, nil, nil } secretsForRemote := []string{} tarContent := []string{} for _, secret := range secrets { secretOpt := strings.Split(secret, ",") modifiedOpt := []string{} for _, token := range secretOpt { opt, val, hasVal := strings.Cut(token, "=") if hasVal { if opt == "src" { // read specified secret into a tmp file // move tmp file to tar and change secret source to relative tmp file tmpSecretFilePath, err := tempManager.CreateTempSecret(val, contextDir) if err != nil { return nil, nil, err } // add tmp file to context dir tarContent = append(tarContent, tmpSecretFilePath) modifiedSrc := fmt.Sprintf("src=%s", filepath.Base(tmpSecretFilePath)) modifiedOpt = append(modifiedOpt, modifiedSrc) } else { modifiedOpt = append(modifiedOpt, token) } } } secretsForRemote = append(secretsForRemote, strings.Join(modifiedOpt, ",")) } return secretsForRemote, tarContent, nil } // prepareRequestBody 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) { 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) return nil, err } var contentType string // If there are additional build contexts, we need to handle them based on the server version // podman version >= 5.6.0 supports multipart/form-data for additional build contexts that // are local directories or archives. URLs and images are still sent as query parameters. isSupported, err := isSupportedVersion(ctx, "5.6.0") if err != nil { return nil, err } if len(options.SBOMScanOptions) > 0 { for _, sbomScanOpts := range options.SBOMScanOptions { if sbomScanOpts.SBOMOutput != "" { requestParts.Params.Set("sbom-output", sbomScanOpts.SBOMOutput) } if sbomScanOpts.PURLOutput != "" { requestParts.Params.Set("sbom-purl-output", sbomScanOpts.PURLOutput) } if sbomScanOpts.ImageSBOMOutput != "" { requestParts.Params.Set("sbom-image-output", sbomScanOpts.ImageSBOMOutput) } if sbomScanOpts.ImagePURLOutput != "" { requestParts.Params.Set("sbom-image-purl-output", sbomScanOpts.ImagePURLOutput) } if sbomScanOpts.Image != "" { requestParts.Params.Set("sbom-scanner-image", sbomScanOpts.Image) } if commands := sbomScanOpts.Commands; len(commands) > 0 { c, err := jsoniter.MarshalToString(commands) if err != nil { return nil, err } requestParts.Params.Add("sbom-scanner-command", c) } if sbomScanOpts.MergeStrategy != "" { requestParts.Params.Set("sbom-merge-strategy", string(sbomScanOpts.MergeStrategy)) } } } if len(options.AdditionalBuildContexts) == 0 { requestParts.Body = tarfile logrus.Debugf("Using main build context: %q", options.ContextDirectory) return requestParts, nil } if !isSupported { convertAdditionalBuildContexts(options.AdditionalBuildContexts) additionalBuildContextMap, err := jsoniter.Marshal(options.AdditionalBuildContexts) if err != nil { return nil, err } requestParts.Params.Set("additionalbuildcontexts", string(additionalBuildContextMap)) requestParts.Body = tarfile logrus.Debugf("Using main build context: %q", options.ContextDirectory) return requestParts, nil } imageContexts := make(map[string]string) urlContexts := make(map[string]string) localContexts := make(map[string]*define.AdditionalBuildContext) for name, context := range options.AdditionalBuildContexts { switch { case context.IsImage: imageContexts[name] = context.Value case context.IsURL: urlContexts[name] = context.Value default: localContexts[name] = context } } logrus.Debugf("URL Contexts: %v", urlContexts) for name, url := range urlContexts { requestParts.Params.Add("additionalbuildcontexts", fmt.Sprintf("%s=url:%s", name, url)) } logrus.Debugf("Image Contexts: %v", imageContexts) for name, imageRef := range imageContexts { requestParts.Params.Add("additionalbuildcontexts", fmt.Sprintf("%s=image:%s", name, imageRef)) } if len(localContexts) == 0 { requestParts.Body = tarfile logrus.Debugf("Using main build context: %q", options.ContextDirectory) return requestParts, nil } // Multipart request structure: // - "MainContext": The main build context as a tar file // - "build-context-": Each additional local context as a tar file logrus.Debugf("Using additional local build contexts: %v", localContexts) pr, pw := io.Pipe() writer := multipart.NewWriter(pw) contentType = writer.FormDataContentType() requestParts.Body = pr if requestParts.Headers == nil { requestParts.Headers = make(http.Header) } requestParts.Headers.Set("Content-Type", contentType) go func() { defer pw.Close() defer writer.Close() mainContext, err := writer.CreateFormFile("MainContext", "MainContext.tar") if err != nil { pw.CloseWithError(fmt.Errorf("creating form file for main context: %w", err)) return } if _, err := io.Copy(mainContext, tarfile); err != nil { pw.CloseWithError(fmt.Errorf("copying main context: %w", err)) return } defer func() { if err := tarfile.Close(); err != nil { logrus.Errorf("failed to close context tarfile: %v\n", err) } }() for name, context := range localContexts { logrus.Debugf("Processing additional local context: %s", name) part, err := writer.CreateFormFile(fmt.Sprintf("build-context-%s", name), name) if err != nil { pw.CloseWithError(fmt.Errorf("creating form file for context %q: %w", name, err)) return } // Context is already a tar if archive.IsArchivePath(context.Value) { file, err := os.Open(context.Value) if err != nil { pw.CloseWithError(fmt.Errorf("opening archive %q: %w", name, err)) return } if _, err := io.Copy(part, file); err != nil { file.Close() pw.CloseWithError(fmt.Errorf("copying context %q: %w", name, err)) return } file.Close() } else { tarContent, err := nTar(nil, context.Value) if err != nil { pw.CloseWithError(fmt.Errorf("creating tar content %q: %w", name, err)) return } if _, err = io.Copy(part, tarContent); err != nil { pw.CloseWithError(fmt.Errorf("copying tar content %q: %w", name, err)) return } if err := tarContent.Close(); err != nil { logrus.Errorf("Error closing tar content for context %q: %v\n", name, err) } } } }() logrus.Debugf("Multipart body is created with content type: %s", contentType) return requestParts, nil } // executeBuildRequest sends the build request to the API endpoint and returns the response. // It handles the HTTP request creation and error checking for the build operation. // WARNING: Caller must close the response body. func executeBuildRequest(ctx context.Context, endpoint string, requestParts *RequestParts) (*bindings.APIResponse, error) { conn, err := bindings.GetClient(ctx) if err != nil { return nil, err } response, err := conn.DoRequest(ctx, requestParts.Body, http.MethodPost, endpoint, requestParts.Params, requestParts.Headers) if err != nil { return nil, err } if !response.IsSuccess() { return nil, response.Process(err) } return response, nil } // processBuildResponse processes the streaming build response from the API. // It reads the JSON stream, extracts build output and errors, writes to stdout, // and returns a build report with the final image ID. func processBuildResponse(response *bindings.APIResponse, stdout io.Writer, saveFormat string) (*types.BuildReport, error) { body := response.Body.(io.Reader) if logrus.IsLevelEnabled(logrus.DebugLevel) { if v, found := os.LookupEnv("PODMAN_RETAIN_BUILD_ARTIFACT"); found { if keep, _ := strconv.ParseBool(v); keep { t, _ := os.CreateTemp("", "build_*_client") defer t.Close() body = io.TeeReader(response.Body, t) } } } dec := json.NewDecoder(body) var id string for { var s BuildResponse select { // FIXME(vrothberg): it seems we always hit the EOF case below, // even when the server quit but it seems desirable to // distinguish a proper build from a transient EOF. case <-response.Request.Context().Done(): return &types.BuildReport{ID: id, SaveFormat: saveFormat}, nil default: // non-blocking select } if err := dec.Decode(&s); err != nil { if errors.Is(err, io.ErrUnexpectedEOF) { return nil, fmt.Errorf("server probably quit: %w", err) } // EOF means the stream is over in which case we need // to have read the id. if errors.Is(err, io.EOF) && id != "" { break } return &types.BuildReport{ID: id, SaveFormat: saveFormat}, fmt.Errorf("decoding stream: %w", err) } switch { case s.Stream != "": raw := []byte(s.Stream) stdout.Write(raw) if iidRegex.Match(raw) { id = strings.TrimSuffix(s.Stream, "\n") } case s.Error != nil: // If there's an error, return directly. The stream // will be closed on return. return &types.BuildReport{ID: id, SaveFormat: saveFormat}, errors.New(s.Error.Message) default: return &types.BuildReport{ID: id, SaveFormat: saveFormat}, errors.New("failed to parse build results stream, unexpected input") } } return &types.BuildReport{ID: id, SaveFormat: saveFormat}, nil } func Build(ctx context.Context, containerFiles []string, options types.BuildOptions) (*types.BuildReport, error) { if options.CommonBuildOpts == nil { options.CommonBuildOpts = new(define.CommonBuildOptions) } tempManager := remote_build_helpers.NewTempFileManager() defer tempManager.Cleanup() params_, err := prepareParams(options) if err != nil { return nil, err } var headers http.Header var requestBody io.ReadCloser requestParts := &RequestParts{ Params: params_, Headers: headers, Body: requestBody, } var contextDir string if contextDir, err = filepath.EvalSymlinks(options.ContextDirectory); err == nil { options.ContextDirectory = contextDir } requestParts, err = prepareAuthHeaders(options, requestParts) if err != nil { return nil, err } contextDirAbs, err := filepath.Abs(options.ContextDirectory) if err != nil { logrus.Errorf("Cannot find absolute path of %v: %v", options.ContextDirectory, err) return nil, err } buildFilePaths, err := prepareContainerFiles(containerFiles, contextDirAbs, &options, tempManager) if err != nil { return nil, err } if len(buildFilePaths.newContainerFiles) > 0 { cFileJSON, err := json.Marshal(buildFilePaths.newContainerFiles) if err != nil { return nil, err } requestParts.Params.Set("dockerfile", string(cFileJSON)) } buildFilePaths.excludes = options.Excludes if len(buildFilePaths.excludes) == 0 { buildFilePaths.excludes, _, err = util.ParseDockerignore(buildFilePaths.newContainerFiles, options.ContextDirectory) if err != nil { return nil, err } } // build secrets are usually absolute host path or relative to context dir on host // in any case move secret to current context and ship the tar. secretsForRemote, secretsTarContent, err := prepareSecrets(options.CommonBuildOpts.Secrets, options.ContextDirectory, tempManager) if err != nil { return nil, err } if len(secretsForRemote) > 0 { c, err := jsoniter.MarshalToString(secretsForRemote) if err != nil { return nil, err } requestParts.Params.Add("secrets", c) buildFilePaths.tarContent = append(buildFilePaths.tarContent, secretsTarContent...) } requestParts, err = prepareRequestBody(ctx, requestParts, buildFilePaths, options) if err != nil { 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) } }() response, err := executeBuildRequest(ctx, "/build", requestParts) if err != nil { return nil, err } defer response.Body.Close() saveFormat := ldefine.OCIArchive if options.OutputFormat == define.Dockerv2ImageManifest { saveFormat = ldefine.V2s2Archive } stdout := io.Writer(os.Stdout) if options.Out != nil { stdout = options.Out } return processBuildResponse(response, stdout, saveFormat) } func nTar(excludes []string, sources ...string) (io.ReadCloser, error) { pm, err := fileutils.NewPatternMatcher(excludes) if err != nil { return nil, fmt.Errorf("processing excludes list %v: %w", excludes, err) } if len(sources) == 0 { return nil, errors.New("no source(s) provided for build") } pr, pw := io.Pipe() gw := gzip.NewWriter(pw) tw := tar.NewWriter(gw) var merr *multierror.Error go func() { defer pw.Close() defer gw.Close() defer tw.Close() seen := make(map[devino]string) for i, src := range sources { source, err := filepath.Abs(src) if err != nil { logrus.Errorf("Cannot stat one of source context: %v", err) merr = multierror.Append(merr, err) return } err = filepath.WalkDir(source, func(path string, dentry fs.DirEntry, err error) error { if err != nil { return err } separator := string(filepath.Separator) // check if what we are given is an empty dir, if so then continue w/ it. Else return. // if we are given a file or a symlink, we do not want to exclude it. if source == path { separator = "" if dentry.IsDir() { var p *os.File p, err = os.Open(path) if err != nil { return err } defer p.Close() _, err = p.Readdir(1) if err == nil { return nil // non empty root dir, need to return } if err != io.EOF { logrus.Errorf("While reading directory %v: %v", path, err) } } } var name string if i == 0 { name = filepath.ToSlash(strings.TrimPrefix(path, source+separator)) } else { if !dentry.Type().IsRegular() { return fmt.Errorf("path %s must be a regular file", path) } name = filepath.ToSlash(path) } // If name is absolute path, then it has to be containerfile outside of build context. // If not, we should check it for being excluded via pattern matcher. if !filepath.IsAbs(name) { excluded, err := pm.Matches(name) //nolint:staticcheck if err != nil { return fmt.Errorf("checking if %q is excluded: %w", name, err) } if excluded { // Note: filepath.SkipDir is not possible to use given .dockerignore semantics. // An exception to exclusions may include an excluded directory, therefore we // are required to visit all files. :( return nil } } switch { case dentry.Type().IsRegular(): // add file item info, err := dentry.Info() if err != nil { return err } di, isHardLink := checkHardLink(info) hdr, err := tar.FileInfoHeader(info, "") if err != nil { return err } hdr.Uid, hdr.Gid = 0, 0 orig, ok := seen[di] if ok { hdr.Typeflag = tar.TypeLink hdr.Linkname = orig hdr.Size = 0 hdr.Name = name return tw.WriteHeader(hdr) } f, err := os.Open(path) if err != nil { return err } hdr.Name = name if err := tw.WriteHeader(hdr); err != nil { f.Close() return err } _, err = io.Copy(tw, f) f.Close() if err == nil && isHardLink { seen[di] = name } return err case dentry.IsDir(): // add folders info, err := dentry.Info() if err != nil { return err } hdr, lerr := tar.FileInfoHeader(info, name) if lerr != nil { return lerr } hdr.Name = name hdr.Uid, hdr.Gid = 0, 0 if lerr := tw.WriteHeader(hdr); lerr != nil { return lerr } case dentry.Type()&os.ModeSymlink != 0: // add symlinks as it, not content link, err := os.Readlink(path) if err != nil { return err } info, err := dentry.Info() if err != nil { return err } hdr, lerr := tar.FileInfoHeader(info, link) if lerr != nil { return lerr } hdr.Name = name hdr.Uid, hdr.Gid = 0, 0 if lerr := tw.WriteHeader(hdr); lerr != nil { return lerr } } // skip other than file,folder and symlinks return nil }) merr = multierror.Append(merr, err) } }() rc := ioutils.NewReadCloserWrapper(pr, func() error { if merr != nil { merr = multierror.Append(merr, pr.Close()) return merr.ErrorOrNil() } return pr.Close() }) return rc, nil }