From d56b2a1976e8ace7d81408521df71f96b529ff79 Mon Sep 17 00:00:00 2001 From: Joshua Arrevillaga <2004jarrevillaga@gmail.com> Date: Thu, 3 Jul 2025 14:56:21 -0400 Subject: [PATCH] Feat: send additional build contexts for remote builds Fixed the --build-context flag to properly send files for remote builds. Previously only the main context was sent over as a tar while additional contexts were passed as local paths and this would cause builds to fail since the files wouldn't exist. New changes modifies the Build API to use multipart HTTP requests allowing multiple build contexts to be used. Each additional context is packaged and transferred based on its type: - Local Directories: Sent as tar archives - Git Repositories: link sent to the server where its then cloned - Container Images: Image reference sent to the server, it then pulls the image there - URLs/archives: URL sent to the server, which handles the download Fixes: #23433 Signed-off-by: Joshua Arrevillaga <2004jarrevillaga@gmail.com> --- pkg/api/handlers/compat/images_build.go | 150 ++++++++- pkg/bindings/images/build.go | 178 ++++++++++- pkg/machine/e2e/basic_test.go | 2 +- test/e2e/build_test.go | 392 ++++++++++++++++++++++++ 4 files changed, 705 insertions(+), 17 deletions(-) diff --git a/pkg/api/handlers/compat/images_build.go b/pkg/api/handlers/compat/images_build.go index 452f201037..07e95ddda8 100644 --- a/pkg/api/handlers/compat/images_build.go +++ b/pkg/api/handlers/compat/images_build.go @@ -44,13 +44,20 @@ func genSpaceErr(err error) error { } func BuildImage(w http.ResponseWriter, r *http.Request) { + var multipart bool if hdr, found := r.Header["Content-Type"]; found && len(hdr) > 0 { contentType := hdr[0] + multipart = strings.HasPrefix(contentType, "multipart/form-data") + if multipart { + contentType = "multipart/form-data" + } switch contentType { case "application/tar": logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType) case "application/x-tar": break + case "multipart/form-data": + logrus.Debugf("Received multipart/form-data: %s", contentType) default: if utils.IsLibpodRequest(r) { utils.BadRequest(w, "Content-Type", hdr[0], @@ -81,10 +88,21 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { } }() - contextDirectory, err := extractTarFile(anchorDir, r) - if err != nil { - utils.InternalServerError(w, genSpaceErr(err)) - return + var contextDirectory string + var additionalBuildContextsFromMultipart map[string]*buildahDefine.AdditionalBuildContext + + if multipart { + contextDirectory, additionalBuildContextsFromMultipart, err = handleMultipartBuild(anchorDir, r) + if err != nil { + utils.InternalServerError(w, fmt.Errorf("handling multipart request: %w", err)) + return + } + } else { + contextDirectory, err = extractTarFile(anchorDir, r) + if err != nil { + utils.InternalServerError(w, genSpaceErr(err)) + return + } } runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) @@ -448,6 +466,14 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { } } + if additionalBuildContextsFromMultipart != nil { + logrus.Debugf("Merging %d additional contexts from multipart", len(additionalBuildContextsFromMultipart)) + for name, ctx := range additionalBuildContextsFromMultipart { + additionalBuildContexts[name] = ctx + logrus.Debugf("Added multipart context %q with path %q", name, ctx.Value) + } + } + var idMappingOptions buildahDefine.IDMappingOptions if _, found := r.URL.Query()["idmappingoptions"]; found { if err := json.Unmarshal([]byte(query.IDMappingOptions), &idMappingOptions); err != nil { @@ -920,6 +946,122 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { } } +func handleMultipartBuild(anchorDir string, r *http.Request) (contextDir string, additionalContexts map[string]*buildahDefine.AdditionalBuildContext, err error) { + reader, err := r.MultipartReader() + if err != nil { + return "", nil, fmt.Errorf("failed to create multipart reader: %w", err) + } + + additionalContexts = make(map[string]*buildahDefine.AdditionalBuildContext) + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + return "", nil, fmt.Errorf("failed to read part: %w", err) + } + + fieldName := part.FormName() + fileName := part.FileName() + + logrus.Debugf("Processing part: field=%q, file=%q", fieldName, fileName) + + switch { + case fieldName == "context" && fileName != "": + contextPath := filepath.Join(anchorDir, "context") + if err := os.MkdirAll(contextPath, 0755); err != nil { + part.Close() + return "", nil, fmt.Errorf("creating context directory: %w", err) + } + + if err := archive.Untar(part, contextPath, nil); err != nil { + part.Close() + return "", nil, fmt.Errorf("extracting main context: %w", err) + } + contextDir = contextPath + logrus.Debugf("Extracted main context to %q", contextDir) + part.Close() + + case strings.HasPrefix(fieldName, "buildcontext-url-"): + contextName := strings.TrimPrefix(fieldName, "buildcontext-url-") + urlBytes, err := io.ReadAll(part) + part.Close() + if err != nil { + return "", nil, fmt.Errorf("reading URL value: %w", err) + } + urlValue := string(urlBytes) + + logrus.Debugf("Found URL context %q: %s", contextName, urlValue) + + tempDir, subDir, err := buildahDefine.TempDirForURL(anchorDir, "buildah", urlValue) + if err != nil { + return "", nil, fmt.Errorf("downloading URL context %q: %w", contextName, err) + } + + contextPath := filepath.Join(tempDir, subDir) + additionalContexts[contextName] = &buildahDefine.AdditionalBuildContext{ + IsURL: true, + IsImage: false, + Value: contextPath, + DownloadedCache: contextPath, + } + logrus.Debugf("Downloaded URL context %q to %q", contextName, contextPath) + + case strings.HasPrefix(fieldName, "buildcontext-image-"): + contextName := strings.TrimPrefix(fieldName, "buildcontext-image-") + imageBytes, err := io.ReadAll(part) + part.Close() + if err != nil { + return "", nil, fmt.Errorf("reading image reference: %w", err) + } + imageRef := string(imageBytes) + + logrus.Debugf("Found image context %q: %s", contextName, imageRef) + + additionalContexts[contextName] = &buildahDefine.AdditionalBuildContext{ + IsImage: true, + IsURL: false, + Value: imageRef, + } + logrus.Debugf("Added image context %q with reference %q", contextName, imageRef) + + case strings.HasPrefix(fieldName, "buildcontext-local-") && fileName != "": + contextName := strings.TrimPrefix(fieldName, "buildcontext-local-") + additionalAnchor := filepath.Join(anchorDir, "additional", contextName) + + if err := os.MkdirAll(additionalAnchor, 0700); err != nil { + part.Close() + return "", nil, fmt.Errorf("creating additional context directory %q: %w", contextName, err) + } + + if err := archive.Untar(part, additionalAnchor, nil); err != nil { + part.Close() + return "", nil, fmt.Errorf("extracting additional context %q: %w", contextName, err) + } + + additionalContexts[contextName] = &buildahDefine.AdditionalBuildContext{ + IsURL: false, + IsImage: false, + Value: additionalAnchor, + } + logrus.Debugf("Extracted additional context %q to %q", contextName, additionalAnchor) + part.Close() + + default: + part.Close() + } + } + + if contextDir == "" { + return "", nil, fmt.Errorf("no main context provided in multipart form") + } + + logrus.Debugf("Successfully parsed multipart form, main context: %q and additional contexts: %v", contextDir, additionalContexts) + + return contextDir, additionalContexts, nil +} + func parseNetworkConfigurationPolicy(network string) buildah.NetworkConfigurationPolicy { if val, err := strconv.Atoi(network); err == nil { return buildah.NetworkConfigurationPolicy(val) diff --git a/pkg/bindings/images/build.go b/pkg/bindings/images/build.go index d73507709d..36157a4b2a 100644 --- a/pkg/bindings/images/build.go +++ b/pkg/bindings/images/build.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "io/fs" + "mime/multipart" "net/http" "net/url" "os" @@ -16,11 +17,13 @@ import ( "strconv" "strings" + "github.com/blang/semver/v4" "github.com/containers/buildah/define" imageTypes "github.com/containers/image/v5/types" 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/bindings/system" "github.com/containers/podman/v5/pkg/domain/entities/types" "github.com/containers/podman/v5/pkg/specgen" "github.com/containers/podman/v5/pkg/util" @@ -126,17 +129,7 @@ func Build(ctx context.Context, containerFiles []string, options types.BuildOpti for _, tag := range options.AdditionalTags { params.Add("t", tag) } - if additionalBuildContexts := options.AdditionalBuildContexts; len(additionalBuildContexts) > 0 { - // TODO: Additional build contexts should be packaged and sent as tar files - // For the time being we make our best to make them accessible on remote - // machines too (i.e. on macOS and Windows). - convertAdditionalBuildContexts(additionalBuildContexts) - additionalBuildContextMap, err := jsoniter.Marshal(additionalBuildContexts) - if err != nil { - return nil, err - } - params.Set("additionalbuildcontexts", string(additionalBuildContextMap)) - } + if options.IDMappingOptions != nil { idmappingsOptions, err := jsoniter.Marshal(options.IDMappingOptions) if err != nil { @@ -643,11 +636,154 @@ func Build(ctx context.Context, containerFiles []string, options types.BuildOpti } }() + var requestBody io.Reader + var contentType string + + if len(options.AdditionalBuildContexts) > 0 { + logrus.Debug("Checking for current version (Has to have 5.6.0+)") + + supported, err := serverSupportsMultipartBuildContexts(ctx) + if err != nil { + return nil, fmt.Errorf("checking server capabilities: %w", err) + } + + if !supported { + return nil, fmt.Errorf("server does not support additional build contexts (requires Podman 5.5.2 or newer)") + } + + logrus.Debugf("Using additional build contexts: %v", options.AdditionalBuildContexts) + + pipeReader, pipeWriter := io.Pipe() + writer := multipart.NewWriter(pipeWriter) + contentType = writer.FormDataContentType() + requestBody = pipeReader + + go func() { + defer pipeWriter.Close() + defer writer.Close() + + if tarfile != nil { + mainPart, err := writer.CreateFormFile("context", "context.tar") + if err != nil { + pipeWriter.CloseWithError(err) + return + } + + if _, err := io.Copy(mainPart, tarfile); err != nil { + pipeWriter.CloseWithError(err) + return + } + } + + for name, context := range options.AdditionalBuildContexts { + logrus.Debugf("Processing additional build context: %q", name) + + switch { + case context.IsImage: + metadataField := fmt.Sprintf("buildcontext-image-%s", name) + if err := writer.WriteField(metadataField, context.Value); err != nil { + pipeWriter.CloseWithError(fmt.Errorf("adding URL context %q: %w", name, err)) + return + } + logrus.Debugf("Added image context %q: %s", name, context.Value) + + case context.IsURL: + metadataField := fmt.Sprintf("buildcontext-url-%s", name) + if err := writer.WriteField(metadataField, context.Value); err != nil { + pipeWriter.CloseWithError(fmt.Errorf("adding URL context %q: %w", name, err)) + return + } + logrus.Debugf("Added URL context %q: %s", name, context.Value) + + default: + part, err := writer.CreateFormFile(fmt.Sprintf("buildcontext-local-%s", name), name+".tar") + if err != nil { + pipeWriter.CloseWithError(err) + return + } + + // local context is already a tar + if strings.HasSuffix(context.Value, ".tar") { + file, err := os.Open(context.Value) + if err != nil { + pipeWriter.CloseWithError(err) + return + } + + _, err = io.Copy(part, file) + file.Close() + if err != nil { + pipeWriter.CloseWithError(err) + return + } + } else { + tarWriter := tar.NewWriter(part) + + err := filepath.Walk(context.Value, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + + relPath, err := filepath.Rel(context.Value, path) + if err != nil { + return err + } + if relPath == "." { + relPath = filepath.Base(context.Value) + } + header.Name = relPath + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + if info.Mode().IsRegular() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(tarWriter, file) + return err + } + + return nil + }) + + if err != nil { + pipeWriter.CloseWithError(err) + tarWriter.Close() + return + } + + tarWriter.Close() + } + } + } + }() + + if headers == nil { + headers = make(http.Header) + } + + headers.Set("Content-Type", contentType) + logrus.Debugf("Multipart body created with content type: %s", contentType) + } else { + requestBody = tarfile + logrus.Debugf("Using main build context: %q", options.ContextDirectory) + } + conn, err := bindings.GetClient(ctx) if err != nil { return nil, err } - response, err := conn.DoRequest(ctx, tarfile, http.MethodPost, "/build", params, headers) + response, err := conn.DoRequest(ctx, requestBody, http.MethodPost, "/build", params, headers) if err != nil { return nil, err } @@ -713,6 +849,24 @@ func Build(ctx context.Context, containerFiles []string, options types.BuildOpti return &types.BuildReport{ID: id, SaveFormat: saveFormat}, nil } +// A check on the version number of podman, used to verify that we are able to use the --build-context flag +func serverSupportsMultipartBuildContexts(ctx context.Context) (bool, error) { + info, err := system.Info(ctx, nil) + if err != nil { + return false, err + } + + serverVer, err := semver.ParseTolerant(info.Version.Version) + if err != nil { + return false, fmt.Errorf("parsing server version %q: %w", info.Version.Version, err) + } + + // Minimum version that supports multipart build contexts + minVer, _ := semver.ParseTolerant("5.5.2") + + return serverVer.GT(minVer), nil +} + func nTar(excludes []string, sources ...string) (io.ReadCloser, error) { pm, err := fileutils.NewPatternMatcher(excludes) if err != nil { diff --git a/pkg/machine/e2e/basic_test.go b/pkg/machine/e2e/basic_test.go index 45cada2efb..ad9676bd04 100644 --- a/pkg/machine/e2e/basic_test.go +++ b/pkg/machine/e2e/basic_test.go @@ -249,8 +249,8 @@ var _ = Describe("run basic podman commands", func() { }) It("podman build contexts", func() { + Skip("Waiting for VM images with Podman > 5.5.2 for multipart build context support") skipIfVmtype(define.HyperVVirt, "FIXME: #23429 - Error running podman build with option --build-context on Hyper-V") - skipIfVmtype(define.QemuVirt, "FIXME: #23433 - Additional build contexts should be sent as additional tar files") name := randomString() i := new(initMachine) session, err := mb.setName(name).setCmd(i.withImage(mb.imagePath).withNow()).run() diff --git a/test/e2e/build_test.go b/test/e2e/build_test.go index fe7ef8f10c..63acee01b8 100644 --- a/test/e2e/build_test.go +++ b/test/e2e/build_test.go @@ -972,4 +972,396 @@ RUN ls /dev/test1`, CITEST_IMAGE) session.WaitWithDefaultTimeout() Expect(session).Should(ExitWithError(1, `building at STEP "RUN --mount=type=cache,target=/test,z cat /test/world": while running runtime: exit status 1`)) }) + + It("podman build --build-context: local source", func() { + SkipIfNotRemote("Testing build context for remote builds") + podmanTest.RestartRemoteService() + + localCtx1 := filepath.Join(podmanTest.TempDir, "context1") + localCtx2 := filepath.Join(podmanTest.TempDir, "context2") + + Expect(os.MkdirAll(localCtx1, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(localCtx1, "file1.txt"), []byte("Content from context1"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(localCtx1, "config.json"), []byte(`{"source": "context1"}`), 0644)).To(Succeed()) + + Expect(os.MkdirAll(filepath.Join(localCtx2, "subdir"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(localCtx2, "file2.txt"), []byte("Content from context2"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(localCtx2, "subdir", "nested.txt"), []byte("Nested content"), 0644)).To(Succeed()) + + containerfile := `FROM quay.io/libpod/alpine:latest +COPY --from=localctx1 /file1.txt /from-context1.txt +COPY --from=localctx1 /config.json /config1.json` + + containerfilePath := filepath.Join(podmanTest.TempDir, "Containerfile1") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session := podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-local-single", + "--build-context", fmt.Sprintf("localctx1=%s", localCtx1), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-local-single", "cat", "/from-context1.txt"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(Equal("Content from context1")) + + containerfile = `FROM quay.io/libpod/alpine:latest +COPY --from=ctx1 /file1.txt /file1.txt +COPY --from=ctx2 /file2.txt /file2.txt +COPY --from=ctx2 /subdir/nested.txt /nested.txt` + + containerfilePath = filepath.Join(podmanTest.TempDir, "Containerfile2") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session = podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-local-multi", + "--build-context", fmt.Sprintf("ctx1=%s", localCtx1), + "--build-context", fmt.Sprintf("ctx2=%s", localCtx2), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-local-multi", "cat", "/nested.txt"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(Equal("Nested content")) + + mainFile := filepath.Join(podmanTest.TempDir, "main.txt") + Expect(os.WriteFile(mainFile, []byte("From main context"), 0644)).To(Succeed()) + + containerfile = `FROM quay.io/libpod/alpine:latest +COPY main.txt /main.txt +COPY --from=additional /file1.txt /additional.txt` + + containerfilePath = filepath.Join(podmanTest.TempDir, "Containerfile3") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session = podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-local-mixed", + "--build-context", fmt.Sprintf("additional=%s", localCtx1), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-local-mixed", "cat", "/main.txt"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(Equal("From main context")) + + session = podmanTest.Podman([]string{"rmi", "-f", "test-local-single", "test-local-multi", "test-local-mixed"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + }) + + It("podman build --build-context: URL source", func() { + SkipIfNotRemote("Testing build context for remote builds") + podmanTest.RestartRemoteService() + + testRepoURL := "https://github.com/containers/PodmanHello.git" + testArchiveURL := "https://github.com/containers/PodmanHello/archive/refs/heads/main.tar.gz" + + containerfile := `FROM quay.io/libpod/alpine:latest +COPY --from=urlctx . /url-context/` + + containerfilePath := filepath.Join(podmanTest.TempDir, "ContainerfileURL1") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session := podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-url-single", + "--build-context", fmt.Sprintf("urlctx=%s", testRepoURL), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-url-single", "ls", "/url-context/"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + output := session.OutputToString() + Expect(output).To(ContainSubstring("LICENSE")) + Expect(output).To(ContainSubstring("README.md")) + + containerfile = `FROM quay.io/libpod/alpine:latest +COPY --from=archive . /from-archive/` + + containerfilePath = filepath.Join(podmanTest.TempDir, "ContainerfileURL2") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session = podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-archive", + "--build-context", fmt.Sprintf("archive=%s", testArchiveURL), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-archive", "ls", "/from-archive/"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + output = session.OutputToString() + Expect(output).To(ContainSubstring("PodmanHello-main")) + + session = podmanTest.Podman([]string{"run", "--rm", "test-archive", "ls", "/from-archive/PodmanHello-main/"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + output = session.OutputToString() + Expect(output).To(ContainSubstring("LICENSE")) + Expect(output).To(ContainSubstring("README.md")) + + localCtx := filepath.Join(podmanTest.TempDir, "localcontext") + Expect(os.MkdirAll(localCtx, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(localCtx, "local.txt"), []byte("Local content"), 0644)).To(Succeed()) + + containerfile = `FROM quay.io/libpod/alpine:latest +COPY --from=urlrepo . /from-url/ +COPY --from=localctx /local.txt /local.txt +RUN echo "Combined URL and local contexts" > /combined.txt` + + containerfilePath = filepath.Join(podmanTest.TempDir, "ContainerfileURL3") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session = podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-url-mixed", + "--build-context", fmt.Sprintf("urlrepo=%s", testRepoURL), + "--build-context", fmt.Sprintf("localctx=%s", localCtx), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-url-mixed", "cat", "/local.txt"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(Equal("Local content")) + + mainFile := filepath.Join(podmanTest.TempDir, "main-url-test.txt") + Expect(os.WriteFile(mainFile, []byte("Main context for URL test"), 0644)).To(Succeed()) + + containerfile = `FROM quay.io/libpod/alpine:latest +COPY main-url-test.txt /main.txt +COPY --from=gitrepo . /git-repo/` + + containerfilePath = filepath.Join(podmanTest.TempDir, "ContainerfileURL5") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session = podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-url-main", + "--build-context", fmt.Sprintf("gitrepo=%s", testRepoURL), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-url-main", "cat", "/main.txt"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(Equal("Main context for URL test")) + + session = podmanTest.Podman([]string{"rmi", "-f", "test-url-single", "test-archive", "test-url-mixed", "test-url-main"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + }) + + It("podman build --build-context: Image source", func() { + SkipIfNotRemote("Testing build context for remote builds") + podmanTest.RestartRemoteService() + + alpineImage := "quay.io/libpod/alpine:latest" + busyboxImage := "quay.io/libpod/busybox:latest" + + containerfile := `FROM quay.io/libpod/busybox:latest AS source +FROM quay.io/libpod/alpine:latest +COPY --from=source /bin/busybox /busybox-from-stage` + + containerfilePath := filepath.Join(podmanTest.TempDir, "ContainerfileMultiStage") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session := podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-multi-stage", + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + testCases := []struct { + name string + prefix string + image string + contextName string + containerfile string + verifyCmd []string + }{ + { + name: "docker-image-prefix", + prefix: "docker-image://", + image: alpineImage, + contextName: "dockerimg", + containerfile: `FROM quay.io/libpod/alpine:latest +COPY --from=dockerimg /etc/alpine-release /alpine-version.txt`, + verifyCmd: []string{"cat", "/alpine-version.txt"}, + }, + { + name: "container-image-prefix", + prefix: "container-image://", + image: busyboxImage, + contextName: "containerimg", + containerfile: `FROM quay.io/libpod/alpine:latest +COPY --from=containerimg /bin/busybox /busybox-binary`, + verifyCmd: []string{"/busybox-binary", "--help"}, + }, + { + name: "docker-prefix", + prefix: "docker://", + image: alpineImage, + contextName: "dockershort", + containerfile: `FROM quay.io/libpod/alpine:latest +COPY --from=dockershort /etc/os-release /os-release.txt`, + verifyCmd: []string{"cat", "/os-release.txt"}, + }, + } + + for _, tc := range testCases { + containerfilePath = filepath.Join(podmanTest.TempDir, fmt.Sprintf("Containerfile_%s", tc.name)) + Expect(os.WriteFile(containerfilePath, []byte(tc.containerfile), 0644)).To(Succeed()) + + session = podmanTest.Podman([]string{ + "build", "--pull-never", "-t", fmt.Sprintf("test-%s", tc.name), + "--build-context", fmt.Sprintf("%s=%s%s", tc.contextName, tc.prefix, tc.image), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman(append([]string{"run", "--rm", fmt.Sprintf("test-%s", tc.name)}, tc.verifyCmd...)) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + if tc.name == "container-image-prefix" { + Expect(session.OutputToString()).To(ContainSubstring("BusyBox")) + } + } + + session = podmanTest.Podman([]string{"rmi", "-f", "test-multi-stage"}) + session.WaitWithDefaultTimeout() + for _, tc := range testCases { + session = podmanTest.Podman([]string{"rmi", "-f", fmt.Sprintf("test-%s", tc.name)}) + session.WaitWithDefaultTimeout() + } + }) + + It("podman build --build-context: Mixed source", func() { + SkipIfNotRemote("Testing build context for remote builds") + podmanTest.RestartRemoteService() + + localCtx := filepath.Join(podmanTest.TempDir, "local-mixed") + Expect(os.MkdirAll(localCtx, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(localCtx, "local-config.json"), []byte(`{"context": "local", "version": "1.0"}`), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(localCtx, "app.conf"), []byte("# Local app configuration\nmode=production\nport=8080"), 0644)).To(Succeed()) + + urlContext := "https://github.com/containers/PodmanHello.git" + alpineImage := "quay.io/libpod/alpine:latest" + busyboxImage := "quay.io/libpod/busybox:latest" + + mainFile := filepath.Join(podmanTest.TempDir, "VERSION") + Expect(os.WriteFile(mainFile, []byte("v1.0.0-mixed"), 0644)).To(Succeed()) + + containerfile := `FROM quay.io/libpod/alpine:latest + +# From main build context +COPY VERSION /app/VERSION + +# From local directory context +COPY --from=localdir /local-config.json /app/config/local-config.json +COPY --from=localdir /app.conf /app/config/app.conf + +# From URL/Git context +COPY --from=gitrepo /LICENSE /app/licenses/podman-hello-LICENSE +COPY --from=gitrepo /README.md /app/docs/podman-hello-README.md + +# From image contexts with different prefixes +COPY --from=alpineimg /etc/alpine-release /app/base-images/alpine-version +COPY --from=busyboximg /bin/busybox /app/tools/busybox + +# Create a summary file +RUN echo "Build with all context types completed" > /app/build-summary.txt && \ + chmod +x /app/tools/busybox + +WORKDIR /app` + + containerfilePath := filepath.Join(podmanTest.TempDir, "ContainerfileMixed") + Expect(os.WriteFile(containerfilePath, []byte(containerfile), 0644)).To(Succeed()) + + session := podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-all-contexts", + "--build-context", fmt.Sprintf("localdir=%s", localCtx), + "--build-context", fmt.Sprintf("gitrepo=%s", urlContext), + "--build-context", fmt.Sprintf("alpineimg=docker-image://%s", alpineImage), + "--build-context", fmt.Sprintf("busyboximg=container-image://%s", busyboxImage), + "-f", containerfilePath, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + verifyTests := []struct { + cmd []string + expected string + }{ + {[]string{"cat", "/app/VERSION"}, "v1.0.0-mixed"}, + {[]string{"cat", "/app/config/local-config.json"}, `"context": "local"`}, + {[]string{"cat", "/app/config/app.conf"}, "port=8080"}, + {[]string{"test", "-f", "/app/licenses/podman-hello-LICENSE"}, ""}, + {[]string{"test", "-f", "/app/docs/podman-hello-README.md"}, ""}, + {[]string{"cat", "/app/base-images/alpine-version"}, "3."}, + {[]string{"/app/tools/busybox", "--help"}, "BusyBox"}, + {[]string{"cat", "/app/build-summary.txt"}, "Build with all context types completed"}, + } + + for _, test := range verifyTests { + session = podmanTest.Podman(append([]string{"run", "--rm", "test-all-contexts"}, test.cmd...)) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + if test.expected != "" { + Expect(session.OutputToString()).To(ContainSubstring(test.expected)) + } + } + + session = podmanTest.Podman([]string{ + "run", "--rm", "test-all-contexts", + "/app/tools/busybox", "grep", "port", "/app/config/app.conf", + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + Expect(session.OutputToString()).To(ContainSubstring("port=8080")) + + containerfile2 := `FROM quay.io/libpod/alpine:latest +COPY --from=img1 /etc/os-release /prefix-test/docker-prefix.txt +COPY --from=img2 /etc/alpine-release /prefix-test/container-prefix.txt` + + containerfilePath2 := filepath.Join(podmanTest.TempDir, "ContainerfileMixed2") + Expect(os.WriteFile(containerfilePath2, []byte(containerfile2), 0644)).To(Succeed()) + + session = podmanTest.Podman([]string{ + "build", "--pull-never", "-t", "test-prefix-mix", + "--build-context", fmt.Sprintf("img1=docker://%s", alpineImage), + "--build-context", fmt.Sprintf("img2=container-image://%s", alpineImage), + "-f", containerfilePath2, podmanTest.TempDir, + }) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + session = podmanTest.Podman([]string{"run", "--rm", "test-prefix-mix", "ls", "/prefix-test/"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + output := session.OutputToString() + Expect(output).To(ContainSubstring("docker-prefix.txt")) + Expect(output).To(ContainSubstring("container-prefix.txt")) + + session = podmanTest.Podman([]string{"rmi", "-f", "test-all-contexts", "test-prefix-mix"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + }) })