diff --git a/pkg/api/handlers/compat/images_build.go b/pkg/api/handlers/compat/images_build.go index dce5ff7e07..6d3c85f98e 100644 --- a/pkg/api/handlers/compat/images_build.go +++ b/pkg/api/handlers/compat/images_build.go @@ -10,6 +10,7 @@ import ( "io" "mime" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -24,13 +25,12 @@ import ( "github.com/containers/podman/v5/pkg/api/handlers/utils" api "github.com/containers/podman/v5/pkg/api/types" "github.com/containers/podman/v5/pkg/auth" - "github.com/containers/podman/v5/pkg/bindings/images" "github.com/containers/podman/v5/pkg/channel" "github.com/containers/podman/v5/pkg/rootless" "github.com/containers/podman/v5/pkg/util" - "github.com/docker/docker/pkg/jsonmessage" "github.com/opencontainers/runtime-spec/specs-go" "github.com/sirupsen/logrus" + "go.podman.io/common/pkg/config" "go.podman.io/image/v5/docker/reference" "go.podman.io/image/v5/types" "go.podman.io/storage/pkg/archive" @@ -38,6 +38,104 @@ import ( "go.podman.io/storage/pkg/fileutils" ) +type cleanUpFunc func() + +// BuildQuery represents query parameters for the container image build API endpoint. +// Uses struct tags to map HTTP query parameters to Go fields for automatic parsing. +type BuildQuery struct { + AddHosts string `schema:"extrahosts"` + AdditionalCapabilities string `schema:"addcaps"` + AdditionalBuildContexts string `schema:"additionalbuildcontexts"` + AllPlatforms bool `schema:"allplatforms"` + Annotations string `schema:"annotations"` + AppArmor string `schema:"apparmor"` + BuildArgs string `schema:"buildargs"` + CacheFrom string `schema:"cachefrom"` + CacheTo string `schema:"cacheto"` + CacheTTL string `schema:"cachettl"` + CgroupParent string `schema:"cgroupparent"` + CompatVolumes bool `schema:"compatvolumes"` + Compression uint64 `schema:"compression"` + ConfigureNetwork string `schema:"networkmode"` + CPPFlags string `schema:"cppflags"` + CpuPeriod uint64 `schema:"cpuperiod"` + CpuQuota int64 `schema:"cpuquota"` + CpuSetCpus string `schema:"cpusetcpus"` + CpuSetMems string `schema:"cpusetmems"` + CpuShares uint64 `schema:"cpushares"` + CreatedAnnotation types.OptionalBool `schema:"createdannotation"` + DNSOptions string `schema:"dnsoptions"` + DNSSearch string `schema:"dnssearch"` + DNSServers string `schema:"dnsservers"` + Devices string `schema:"devices"` + Dockerfile string `schema:"dockerfile"` + DropCapabilities string `schema:"dropcaps"` + Envs []string `schema:"setenv"` + Excludes string `schema:"excludes"` + ForceRm bool `schema:"forcerm"` + From string `schema:"from"` + GroupAdd []string `schema:"groupadd"` + HTTPProxy bool `schema:"httpproxy"` + IDMappingOptions string `schema:"idmappingoptions"` + IdentityLabel bool `schema:"identitylabel"` + Ignore bool `schema:"ignore"` + InheritLabels types.OptionalBool `schema:"inheritlabels"` + InheritAnnotations types.OptionalBool `schema:"inheritannotations"` + Isolation string `schema:"isolation"` + Jobs int `schema:"jobs"` + LabelOpts string `schema:"labelopts"` + Labels string `schema:"labels"` + LayerLabels []string `schema:"layerLabel"` + Layers bool `schema:"layers"` + LogRusage bool `schema:"rusage"` + Manifest string `schema:"manifest"` + MemSwap int64 `schema:"memswap"` + Memory int64 `schema:"memory"` + NamespaceOptions string `schema:"nsoptions"` + NoCache bool `schema:"nocache"` + NoHosts bool `schema:"nohosts"` + OmitHistory bool `schema:"omithistory"` + OSFeatures []string `schema:"osfeature"` + OSVersion string `schema:"osversion"` + OutputFormat string `schema:"outputformat"` + Platform []string `schema:"platform"` + Pull bool `schema:"pull"` + PullPolicy string `schema:"pullpolicy"` + Quiet bool `schema:"q"` + Registry string `schema:"registry"` + Rm bool `schema:"rm"` + RusageLogFile string `schema:"rusagelogfile"` + Remote string `schema:"remote"` + RewriteTimestamp bool `schema:"rewritetimestamp"` + Retry int `schema:"retry"` + RetryDelay string `schema:"retry-delay"` + Seccomp string `schema:"seccomp"` + Secrets string `schema:"secrets"` + SecurityOpt string `schema:"securityopt"` + ShmSize int `schema:"shmsize"` + SkipUnusedStages bool `schema:"skipunusedstages"` + SourceDateEpoch int64 `schema:"sourcedateepoch"` + Squash bool `schema:"squash"` + TLSVerify bool `schema:"tlsVerify"` + Tags []string `schema:"t"` + Target string `schema:"target"` + Timestamp int64 `schema:"timestamp"` + Ulimits string `schema:"ulimits"` + UnsetEnvs []string `schema:"unsetenv"` + UnsetLabels []string `schema:"unsetlabel"` + UnsetAnnotations []string `schema:"unsetannotation"` + Volumes []string `schema:"volume"` +} + +// BuildContext represents processed build context and metadata for container image builds. +type BuildContext struct { + ContextDirectory string + AdditionalBuildContexts map[string]*buildahDefine.AdditionalBuildContext + ContainerFiles []string + IgnoreFile string +} + +// genSpaceErr wraps filesystem errors to provide more context for disk space issues. func genSpaceErr(err error) error { if errors.Is(err, syscall.ENOSPC) { return fmt.Errorf("context directory may be too large: %w", err) @@ -45,13 +143,40 @@ func genSpaceErr(err error) error { return err } -func BuildImage(w http.ResponseWriter, r *http.Request) { +// processCacheReferences processes JSON-encoded lists of repository references for cache operations. +func processCacheReferences(jsonValue, fieldName string, queryValues url.Values) ([]reference.Named, error) { + var result []reference.Named + if _, found := queryValues[fieldName]; found { + var stringList []string + if err := json.Unmarshal([]byte(jsonValue), &stringList); err != nil { + return nil, err + } + var err error + result, err = parse.RepoNamesToNamedReferences(stringList) + if err != nil { + return nil, err + } + } + return result, nil +} + +// processCacheFrom processes the cachefrom query parameter for build cache lookup. +func processCacheFrom(query *BuildQuery, queryValues url.Values) ([]reference.Named, error) { + return processCacheReferences(query.CacheFrom, "cachefrom", queryValues) +} + +// processCacheTo processes the cacheto query parameter for build cache export. +func processCacheTo(query *BuildQuery, queryValues url.Values) ([]reference.Named, error) { + return processCacheReferences(query.CacheTo, "cacheto", queryValues) +} + +// validateContentType validates the Content-Type header and determines if multipart processing is needed. +func validateContentType(r *http.Request) (bool, error) { multipart := false if hdr, found := r.Header["Content-Type"]; found && len(hdr) > 0 { contentType, _, err := mime.ParseMediaType(hdr[0]) if err != nil { - utils.BadRequest(w, "Content-Type", hdr[0], fmt.Errorf("failed to parse content type: %w", err)) - return + return false, utils.GetBadRequestError("Content-Type", hdr[0], err) } switch contentType { @@ -64,131 +189,18 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { multipart = true default: if utils.IsLibpodRequest(r) { - utils.BadRequest(w, "Content-Type", hdr[0], + return false, utils.GetBadRequestError("Content-Type", hdr[0], fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0])) - return } logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType) } } + return multipart, nil +} - anchorDir, err := os.MkdirTemp("", "libpod_builder") - if err != nil { - utils.InternalServerError(w, err) - return - } - - defer func() { - if logrus.IsLevelEnabled(logrus.DebugLevel) { - if v, found := os.LookupEnv("PODMAN_RETAIN_BUILD_ARTIFACT"); found { - if keep, _ := strconv.ParseBool(v); keep { - return - } - } - } - err := os.RemoveAll(anchorDir) - if err != nil { - logrus.Warn(fmt.Errorf("failed to remove build scratch directory %q: %w", anchorDir, err)) - } - }() - - contextDirectory, additionalBuildContexts, err := handleBuildContexts(anchorDir, r, multipart) - if err != nil { - utils.InternalServerError(w, genSpaceErr(err)) - return - } - - runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) - conf, err := runtime.GetConfigNoCopy() - if err != nil { - utils.InternalServerError(w, err) - return - } - - query := struct { - AddHosts string `schema:"extrahosts"` - AdditionalCapabilities string `schema:"addcaps"` - AdditionalBuildContexts string `schema:"additionalbuildcontexts"` - AllPlatforms bool `schema:"allplatforms"` - Annotations string `schema:"annotations"` - AppArmor string `schema:"apparmor"` - BuildArgs string `schema:"buildargs"` - CacheFrom string `schema:"cachefrom"` - CacheTo string `schema:"cacheto"` - CacheTTL string `schema:"cachettl"` - CgroupParent string `schema:"cgroupparent"` - CompatVolumes bool `schema:"compatvolumes"` - Compression uint64 `schema:"compression"` - ConfigureNetwork string `schema:"networkmode"` - CPPFlags string `schema:"cppflags"` - CpuPeriod uint64 `schema:"cpuperiod"` - CpuQuota int64 `schema:"cpuquota"` - CpuSetCpus string `schema:"cpusetcpus"` - CpuSetMems string `schema:"cpusetmems"` - CpuShares uint64 `schema:"cpushares"` - CreatedAnnotation types.OptionalBool `schema:"createdannotation"` - DNSOptions string `schema:"dnsoptions"` - DNSSearch string `schema:"dnssearch"` - DNSServers string `schema:"dnsservers"` - Devices string `schema:"devices"` - Dockerfile string `schema:"dockerfile"` - DropCapabilities string `schema:"dropcaps"` - Envs []string `schema:"setenv"` - Excludes string `schema:"excludes"` - ForceRm bool `schema:"forcerm"` - From string `schema:"from"` - GroupAdd []string `schema:"groupadd"` - HTTPProxy bool `schema:"httpproxy"` - IDMappingOptions string `schema:"idmappingoptions"` - IdentityLabel bool `schema:"identitylabel"` - Ignore bool `schema:"ignore"` - InheritLabels types.OptionalBool `schema:"inheritlabels"` - InheritAnnotations types.OptionalBool `schema:"inheritannotations"` - Isolation string `schema:"isolation"` - Jobs int `schema:"jobs"` - LabelOpts string `schema:"labelopts"` - Labels string `schema:"labels"` - LayerLabels []string `schema:"layerLabel"` - Layers bool `schema:"layers"` - LogRusage bool `schema:"rusage"` - Manifest string `schema:"manifest"` - MemSwap int64 `schema:"memswap"` - Memory int64 `schema:"memory"` - NamespaceOptions string `schema:"nsoptions"` - NoCache bool `schema:"nocache"` - NoHosts bool `schema:"nohosts"` - OmitHistory bool `schema:"omithistory"` - OSFeatures []string `schema:"osfeature"` - OSVersion string `schema:"osversion"` - OutputFormat string `schema:"outputformat"` - Platform []string `schema:"platform"` - Pull bool `schema:"pull"` - PullPolicy string `schema:"pullpolicy"` - Quiet bool `schema:"q"` - Registry string `schema:"registry"` - Rm bool `schema:"rm"` - RusageLogFile string `schema:"rusagelogfile"` - Remote string `schema:"remote"` - RewriteTimestamp bool `schema:"rewritetimestamp"` - Retry int `schema:"retry"` - RetryDelay string `schema:"retry-delay"` - Seccomp string `schema:"seccomp"` - Secrets string `schema:"secrets"` - SecurityOpt string `schema:"securityopt"` - ShmSize int `schema:"shmsize"` - SkipUnusedStages bool `schema:"skipunusedstages"` - SourceDateEpoch int64 `schema:"sourcedateepoch"` - Squash bool `schema:"squash"` - TLSVerify bool `schema:"tlsVerify"` - Tags []string `schema:"t"` - Target string `schema:"target"` - Timestamp int64 `schema:"timestamp"` - Ulimits string `schema:"ulimits"` - UnsetEnvs []string `schema:"unsetenv"` - UnsetLabels []string `schema:"unsetlabel"` - UnsetAnnotations []string `schema:"unsetannotation"` - Volumes []string `schema:"volume"` - }{ +// parseBuildQuery parses HTTP query parameters into a BuildQuery struct with defaults. +func parseBuildQuery(r *http.Request, conf *config.Config, queryValues url.Values) (*BuildQuery, error) { + query := &BuildQuery{ Dockerfile: "Dockerfile", Registry: "docker.io", Rm: true, @@ -199,228 +211,192 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { } decoder := utils.GetDecoder(r) - if err := decoder.Decode(&query, r.URL.Query()); err != nil { - utils.Error(w, http.StatusBadRequest, err) - return - } - - var identityLabel types.OptionalBool - if _, found := r.URL.Query()["identitylabel"]; found { - identityLabel = types.NewOptionalBool(query.IdentityLabel) + if err := decoder.Decode(query, queryValues); err != nil { + return nil, utils.GetGenericBadRequestError(err) } // if layers field not set assume its not from a valid podman-client // could be a docker client, set `layers=true` since that is the default // expected behaviour if !utils.IsLibpodRequest(r) { - if _, found := r.URL.Query()["layers"]; !found { + if _, found := queryValues["layers"]; !found { query.Layers = true } } - // convert tag formats - tags := query.Tags + return query, nil +} - // convert addcaps formats - var addCaps = []string{} - if _, found := r.URL.Query()["addcaps"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.AdditionalCapabilities), &m); err != nil { - utils.BadRequest(w, "addcaps", query.AdditionalCapabilities, err) - return - } - addCaps = m - } - - // convert addcaps formats - containerFiles := []string{} - // Tells if query parameter `dockerfile` is set or not. +// processBuildContext processes build context directory and container files based on request parameters. +func processBuildContext(query url.Values, r *http.Request, buildContext *BuildContext, anchorDir string) (*BuildContext, error) { dockerFileSet := false - if utils.IsLibpodRequest(r) && query.Remote != "" { - // The context directory could be a URL. Try to handle that. - anchorDir, err := os.MkdirTemp(parse.GetTempDir(), "libpod_builder") + remote := query.Get("remote") + + if utils.IsLibpodRequest(r) && remote != "" { + tempDir, subDir, err := buildahDefine.TempDirForURL(anchorDir, "buildah", remote) if err != nil { - utils.InternalServerError(w, err) - return - } - tempDir, subDir, err := buildahDefine.TempDirForURL(anchorDir, "buildah", query.Remote) - if err != nil { - utils.InternalServerError(w, genSpaceErr(err)) - return + return nil, utils.GetInternalServerError(genSpaceErr(err)) } if tempDir != "" { - // We had to download it to a temporary directory. - // Delete it later. - defer func() { - if err = os.RemoveAll(tempDir); err != nil { - // We are deleting this on server so log on server end - // client does not have to worry about server cleanup. - logrus.Errorf("Cannot delete downloaded temp dir %q: %s", tempDir, err) - } - }() - contextDirectory = filepath.Join(tempDir, subDir) + buildContext.ContextDirectory = filepath.Join(tempDir, subDir) } else { // Nope, it was local. Use it as is. - absDir, err := filepath.Abs(query.Remote) + absDir, err := filepath.Abs(remote) if err != nil { - utils.BadRequest(w, "remote", query.Remote, err) - return + return nil, utils.GetBadRequestError("remote", remote, err) } - contextDirectory = absDir + buildContext.ContextDirectory = absDir } } else { - if _, found := r.URL.Query()["dockerfile"]; found { + if dockerFile := query.Get("dockerfile"); dockerFile != "" { var m = []string{} - if err := json.Unmarshal([]byte(query.Dockerfile), &m); err != nil { + if err := json.Unmarshal([]byte(dockerFile), &m); err != nil { // it's not json, assume just a string - m = []string{query.Dockerfile} + m = []string{dockerFile} } for _, containerfile := range m { // Add path to containerfile iff it is not URL if !strings.HasPrefix(containerfile, "http://") && !strings.HasPrefix(containerfile, "https://") { - containerfile = filepath.Join(contextDirectory, + containerfile = filepath.Join(buildContext.ContextDirectory, filepath.Clean(filepath.FromSlash(containerfile))) } - containerFiles = append(containerFiles, containerfile) + buildContext.ContainerFiles = append(buildContext.ContainerFiles, containerfile) } dockerFileSet = true } } if !dockerFileSet { - containerFiles = []string{filepath.Join(contextDirectory, "Dockerfile")} + buildContext.ContainerFiles = []string{filepath.Join(buildContext.ContextDirectory, "Dockerfile")} if utils.IsLibpodRequest(r) { - containerFiles = []string{filepath.Join(contextDirectory, "Containerfile")} - if err = fileutils.Exists(containerFiles[0]); err != nil { - containerFiles = []string{filepath.Join(contextDirectory, "Dockerfile")} - if err1 := fileutils.Exists(containerFiles[0]); err1 != nil { - utils.BadRequest(w, "dockerfile", query.Dockerfile, err) - return + buildContext.ContainerFiles = []string{filepath.Join(buildContext.ContextDirectory, "Containerfile")} + if err := fileutils.Exists(buildContext.ContainerFiles[0]); err != nil { + buildContext.ContainerFiles = []string{filepath.Join(buildContext.ContextDirectory, "Dockerfile")} + if err1 := fileutils.Exists(buildContext.ContainerFiles[0]); err1 != nil { + return nil, utils.GetBadRequestError("dockerfile", query.Get("dockerfile"), err1) } } } } - addhosts := []string{} - if _, found := r.URL.Query()["extrahosts"]; found { - if err := json.Unmarshal([]byte(query.AddHosts), &addhosts); err != nil { - utils.BadRequest(w, "extrahosts", query.AddHosts, err) - return + return buildContext, nil +} + +// processSecrets processes build secrets for podman-remote operations. +// Moves secrets outside build context to prevent accidental inclusion in images. +func processSecrets(query *BuildQuery, contextDirectory string, queryValues url.Values) ([]string, error) { + var secrets = []string{} + var m = []string{} + if err := utils.ParseOptionalJSONField(query.Secrets, "secrets", queryValues, &m); err != nil { + return nil, err + } + + // for podman-remote all secrets must be picked from context director + // hence modify src so contextdir is added as prefix + for _, secret := range m { + secretOpt := strings.Split(secret, ",") + if len(secretOpt) > 0 { + modifiedOpt := []string{} + for _, token := range secretOpt { + key, val, hasVal := strings.Cut(token, "=") + if hasVal { + if key == "src" { + /* move secret away from contextDir */ + /* to make sure we dont accidentally commit temporary secrets to image*/ + builderDirectory, _ := filepath.Split(contextDirectory) + // following path is outside build context + newSecretPath := filepath.Join(builderDirectory, val) + oldSecretPath := filepath.Join(contextDirectory, val) + err := os.Rename(oldSecretPath, newSecretPath) + if err != nil { + return nil, err + } + + modifiedSrc := fmt.Sprintf("src=%s", newSecretPath) + modifiedOpt = append(modifiedOpt, modifiedSrc) + } else { + modifiedOpt = append(modifiedOpt, token) + } + } + } + secrets = append(secrets, strings.Join(modifiedOpt, ",")) } } + return secrets, nil +} + +// createBuildOptions creates a buildah BuildOptions struct from query parameters and build context. +// WARNING: caller must call the cleanup function if not nil. +func createBuildOptions(query *BuildQuery, buildCtx *BuildContext, queryValues url.Values, r *http.Request) (*buildahDefine.BuildOptions, cleanUpFunc, error) { + identityLabel, _ := utils.ParseOptionalBool(query.IdentityLabel, "identitylabel", queryValues) + + // Process various query parameters + addCaps, err := utils.ParseJSONOptionalSlice(query.AdditionalCapabilities, queryValues, "addcaps") + if err != nil { + return nil, nil, utils.GetBadRequestError("addcaps", query.AdditionalCapabilities, err) + } + + dropCaps, err := utils.ParseJSONOptionalSlice(query.DropCapabilities, queryValues, "dropcaps") + if err != nil { + return nil, nil, utils.GetBadRequestError("dropcaps", query.DropCapabilities, err) + } + + devices, err := utils.ParseJSONOptionalSlice(query.Devices, queryValues, "devices") + if err != nil { + return nil, nil, utils.GetBadRequestError("devices", query.Devices, err) + } + + dnsservers, err := utils.ParseJSONOptionalSlice(query.DNSServers, queryValues, "dnsservers") + if err != nil { + return nil, nil, utils.GetBadRequestError("dnsservers", query.DNSServers, err) + } + + dnsoptions, err := utils.ParseJSONOptionalSlice(query.DNSOptions, queryValues, "dnsoptions") + if err != nil { + return nil, nil, utils.GetBadRequestError("dnsoptions", query.DNSOptions, err) + } + + dnssearch, err := utils.ParseJSONOptionalSlice(query.DNSSearch, queryValues, "dnssearch") + if err != nil { + return nil, nil, utils.GetBadRequestError("dnssearch", query.DNSSearch, err) + } + + secrets, err := processSecrets(query, buildCtx.ContextDirectory, queryValues) + if err != nil { + return nil, nil, utils.GetBadRequestError("secrets", query.Secrets, err) + } + + addhosts, err := utils.ParseJSONOptionalSlice(query.AddHosts, queryValues, "extrahosts") + if err != nil { + return nil, nil, utils.GetBadRequestError("extrahosts", query.AddHosts, err) + } + + compatVolumes, _ := utils.ParseOptionalBool(query.CompatVolumes, "compatvolumes", queryValues) compression := archive.Compression(query.Compression) - var compatVolumes types.OptionalBool - if _, found := r.URL.Query()["compatvolumes"]; found { - compatVolumes = types.NewOptionalBool(query.CompatVolumes) - } - - // convert dropcaps formats - var dropCaps = []string{} - if _, found := r.URL.Query()["dropcaps"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.DropCapabilities), &m); err != nil { - utils.BadRequest(w, "dropcaps", query.DropCapabilities, err) - return - } - dropCaps = m - } - - // convert devices formats - var devices = []string{} - if _, found := r.URL.Query()["devices"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.Devices), &m); err != nil { - utils.BadRequest(w, "devices", query.Devices, err) - return - } - devices = m - } - - var dnsservers = []string{} - if _, found := r.URL.Query()["dnsservers"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.DNSServers), &m); err != nil { - utils.BadRequest(w, "dnsservers", query.DNSServers, err) - return - } - dnsservers = m - } - - var dnsoptions = []string{} - if _, found := r.URL.Query()["dnsoptions"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.DNSOptions), &m); err != nil { - utils.BadRequest(w, "dnsoptions", query.DNSOptions, err) - return - } - dnsoptions = m - } - - var dnssearch = []string{} - if _, found := r.URL.Query()["dnssearch"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.DNSSearch), &m); err != nil { - utils.BadRequest(w, "dnssearches", query.DNSSearch, err) - return - } - dnssearch = m - } - - var secrets = []string{} - if _, found := r.URL.Query()["secrets"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.Secrets), &m); err != nil { - utils.BadRequest(w, "secrets", query.Secrets, err) - return - } - - // for podman-remote all secrets must be picked from context director - // hence modify src so contextdir is added as prefix - - for _, secret := range m { - secretOpt := strings.Split(secret, ",") - if len(secretOpt) > 0 { - modifiedOpt := []string{} - for _, token := range secretOpt { - key, val, hasVal := strings.Cut(token, "=") - if hasVal { - if key == "src" { - /* move secret away from contextDir */ - /* to make sure we dont accidentally commit temporary secrets to image*/ - builderDirectory, _ := filepath.Split(contextDirectory) - // following path is outside build context - newSecretPath := filepath.Join(builderDirectory, val) - oldSecretPath := filepath.Join(contextDirectory, val) - err := os.Rename(oldSecretPath, newSecretPath) - if err != nil { - utils.BadRequest(w, "secrets", query.Secrets, err) - return - } - - modifiedSrc := fmt.Sprintf("src=%s", newSecretPath) - modifiedOpt = append(modifiedOpt, modifiedSrc) - } else { - modifiedOpt = append(modifiedOpt, token) - } - } - } - secrets = append(secrets, strings.Join(modifiedOpt, ",")) - } - } - } - + // Process tags + tags := query.Tags var output string + var additionalTags []string if len(tags) > 0 { possiblyNormalizedName, err := utils.NormalizeToDockerHub(r, tags[0]) if err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("normalizing image: %w", err)) - return + return nil, nil, utils.GetInternalServerError(fmt.Errorf("normalizing image: %w", err)) } output = possiblyNormalizedName + + for i := 1; i < len(tags); i++ { + possiblyNormalizedTag, err := utils.NormalizeToDockerHub(r, tags[i]) + if err != nil { + return nil, nil, utils.GetInternalServerError(fmt.Errorf("normalizing image: %w", err)) + } + additionalTags = append(additionalTags, possiblyNormalizedTag) + } } + + // Process build format and isolation format := buildah.Dockerv2ImageManifest registry := query.Registry isolation := buildah.IsolationDefault @@ -428,8 +404,7 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { var err error isolation, err = parseLibPodIsolation(query.Isolation) if err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("failed to parse isolation: %w", err)) - return + return nil, nil, utils.GetInternalServerError(fmt.Errorf("failed to parse isolation: %w", err)) } // Make sure to force rootless as rootless otherwise buildah runs code which is intended to be run only as root. @@ -448,105 +423,67 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { registry = "" format = query.OutputFormat } else { - if _, found := r.URL.Query()["isolation"]; found { + if _, found := queryValues["isolation"]; found { if query.Isolation != "" && query.Isolation != "default" { logrus.Debugf("invalid `isolation` parameter: %q", query.Isolation) } } } - var additionalTags []string - for i := 1; i < len(tags); i++ { - possiblyNormalizedTag, err := utils.NormalizeToDockerHub(r, tags[i]) - if err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("normalizing image: %w", err)) - return - } - additionalTags = append(additionalTags, possiblyNormalizedTag) - } + // Process IDMapping var idMappingOptions buildahDefine.IDMappingOptions - if _, found := r.URL.Query()["idmappingoptions"]; found { - if err := json.Unmarshal([]byte(query.IDMappingOptions), &idMappingOptions); err != nil { - utils.BadRequest(w, "idmappingoptions", query.IDMappingOptions, err) - return - } + if err := utils.ParseOptionalJSONField(query.IDMappingOptions, "idmappingoptions", queryValues, &idMappingOptions); err != nil { + return nil, nil, utils.GetBadRequestError("idmappingoptions", query.IDMappingOptions, err) } - cacheFrom := []reference.Named{} - if _, found := r.URL.Query()["cachefrom"]; found { - var cacheFromSrcList []string - if err := json.Unmarshal([]byte(query.CacheFrom), &cacheFromSrcList); err != nil { - utils.BadRequest(w, "cacheFrom", query.CacheFrom, err) - return - } - cacheFrom, err = parse.RepoNamesToNamedReferences(cacheFromSrcList) - if err != nil { - utils.BadRequest(w, "cacheFrom", query.CacheFrom, err) - return - } + // Process cache options + cacheFrom, err := processCacheFrom(query, queryValues) + if err != nil { + return nil, nil, utils.GetBadRequestError("cachefrom", query.CacheFrom, err) } - cacheTo := []reference.Named{} - if _, found := r.URL.Query()["cacheto"]; found { - var cacheToDestList []string - if err := json.Unmarshal([]byte(query.CacheTo), &cacheToDestList); err != nil { - utils.BadRequest(w, "cacheTo", query.CacheTo, err) - return - } - cacheTo, err = parse.RepoNamesToNamedReferences(cacheToDestList) - if err != nil { - utils.BadRequest(w, "cacheto", query.CacheTo, err) - return - } + + cacheTo, err := processCacheTo(query, queryValues) + if err != nil { + return nil, nil, utils.GetBadRequestError("cacheTo", query.CacheTo, err) } + var cacheTTL time.Duration - if _, found := r.URL.Query()["cachettl"]; found { + if _, found := queryValues["cachettl"]; found { cacheTTL, err = time.ParseDuration(query.CacheTTL) if err != nil { - utils.BadRequest(w, "cachettl", query.CacheTTL, err) - return + return nil, nil, utils.GetBadRequestError("cachettl", query.CacheTTL, err) } } + // Process build args var buildArgs = map[string]string{} - if _, found := r.URL.Query()["buildargs"]; found { - if err := json.Unmarshal([]byte(query.BuildArgs), &buildArgs); err != nil { - utils.BadRequest(w, "buildargs", query.BuildArgs, err) - return - } + if err := utils.ParseOptionalJSONField(query.BuildArgs, "buildargs", queryValues, &buildArgs); err != nil { + return nil, nil, utils.GetBadRequestError("buildargs", query.BuildArgs, err) } + // Process excludes var excludes = []string{} - if _, found := r.URL.Query()["excludes"]; found { - if err := json.Unmarshal([]byte(query.Excludes), &excludes); err != nil { - utils.BadRequest(w, "excludes", query.Excludes, err) - return - } + if err := utils.ParseOptionalJSONField(query.Excludes, "excludes", queryValues, &excludes); err != nil { + return nil, nil, utils.GetBadRequestError("excludes", query.Excludes, err) } - // convert annotations formats + // Process annotations var annotations = []string{} - if _, found := r.URL.Query()["annotations"]; found { - if err := json.Unmarshal([]byte(query.Annotations), &annotations); err != nil { - utils.BadRequest(w, "annotations", query.Annotations, err) - return - } + if err := utils.ParseOptionalJSONField(query.Annotations, "annotations", queryValues, &annotations); err != nil { + return nil, nil, utils.GetBadRequestError("annotations", query.Annotations, err) } - // convert cppflags formats + // Process CPP flags var cppflags = []string{} - if _, found := r.URL.Query()["cppflags"]; found { - if err := json.Unmarshal([]byte(query.CPPFlags), &cppflags); err != nil { - utils.BadRequest(w, "cppflags", query.CPPFlags, err) - return - } + if err := utils.ParseOptionalJSONField(query.CPPFlags, "cppflags", queryValues, &cppflags); err != nil { + return nil, nil, utils.GetBadRequestError("cppflags", query.CPPFlags, err) } - // convert nsoptions formats + // Process namespace options nsoptions := buildah.NamespaceOptions{} - if _, found := r.URL.Query()["nsoptions"]; found { - if err := json.Unmarshal([]byte(query.NamespaceOptions), &nsoptions); err != nil { - utils.BadRequest(w, "nsoptions", query.NamespaceOptions, err) - return + if _, found := queryValues["nsoptions"]; found { + if err := utils.ParseOptionalJSONField(query.NamespaceOptions, "nsoptions", queryValues, &nsoptions); err != nil { + return nil, nil, utils.GetBadRequestError("nsoptions", query.NamespaceOptions, err) } } else { nsoptions = append(nsoptions, buildah.NamespaceOption{ @@ -554,9 +491,10 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { Host: true, }) } - // convert label formats + + // Process labels var labels = []string{} - if _, found := r.URL.Query()["labels"]; found { + if _, found := queryValues["labels"]; found { makeLabels := make(map[string]string) err := json.Unmarshal([]byte(query.Labels), &makeLabels) if err == nil { @@ -565,17 +503,17 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { } } else { if err := json.Unmarshal([]byte(query.Labels), &labels); err != nil { - utils.BadRequest(w, "labels", query.Labels, err) - return + return nil, nil, utils.GetBadRequestError("labels", query.Labels, err) } } } jobs := 1 - if _, found := r.URL.Query()["jobs"]; found { + if _, found := queryValues["jobs"]; found { jobs = query.Jobs } + // Process security options var ( labelOpts = []string{} seccomp string @@ -586,141 +524,106 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { seccomp = query.Seccomp apparmor = query.AppArmor // convert labelopts formats - if _, found := r.URL.Query()["labelopts"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.LabelOpts), &m); err != nil { - utils.BadRequest(w, "labelopts", query.LabelOpts, err) - return - } - labelOpts = m + if err := utils.ParseOptionalJSONField(query.LabelOpts, "labelopts", queryValues, &labelOpts); err != nil { + return nil, nil, utils.GetBadRequestError("labelopts", query.LabelOpts, err) } } else { // handle security-opt - if _, found := r.URL.Query()["securityopt"]; found { - var securityOpts = []string{} - if err := json.Unmarshal([]byte(query.SecurityOpt), &securityOpts); err != nil { - utils.BadRequest(w, "securityopt", query.SecurityOpt, err) - return + var securityOpts = []string{} + if err := utils.ParseOptionalJSONField(query.SecurityOpt, "securityopt", queryValues, &securityOpts); err != nil { + return nil, nil, utils.GetBadRequestError("securityopt", query.SecurityOpt, err) + } + for _, opt := range securityOpts { + if opt == "no-new-privileges" { + return nil, nil, utils.GetBadRequestError("securityopt", opt, fmt.Errorf("no-new-privileges is not supported")) + } + name, value, hasValue := strings.Cut(opt, "=") + if !hasValue { + return nil, nil, utils.GetBadRequestError("securityopt", opt, fmt.Errorf("invalid --security-opt name=value pair: %q", opt)) } - for _, opt := range securityOpts { - if opt == "no-new-privileges" { - utils.BadRequest(w, "securityopt", query.SecurityOpt, errors.New("no-new-privileges is not supported")) - return - } - name, value, hasValue := strings.Cut(opt, "=") - if !hasValue { - utils.BadRequest(w, "securityopt", query.SecurityOpt, fmt.Errorf("invalid --security-opt name=value pair: %q", opt)) - return - } - switch name { - case "label": - labelOpts = append(labelOpts, value) - case "apparmor": - apparmor = value - case "seccomp": - seccomp = value - default: - utils.BadRequest(w, "securityopt", query.SecurityOpt, fmt.Errorf("invalid --security-opt 2: %q", opt)) - return - } + switch name { + case "label": + labelOpts = append(labelOpts, value) + case "apparmor": + apparmor = value + case "seccomp": + seccomp = value + default: + return nil, nil, utils.GetBadRequestError("securityopt", opt, fmt.Errorf("invalid --security-opt 2: %q", opt)) } } } - // convert ulimits formats + // Process ulimits var ulimits = []string{} - if _, found := r.URL.Query()["ulimits"]; found { - var m = []string{} - if err := json.Unmarshal([]byte(query.Ulimits), &m); err != nil { - utils.BadRequest(w, "ulimits", query.Ulimits, err) - return - } - ulimits = m + if err := utils.ParseOptionalJSONField(query.Ulimits, "ulimits", queryValues, &ulimits); err != nil { + return nil, nil, utils.GetBadRequestError("ulimits", query.Ulimits, err) } + // Process pull policy pullPolicy := buildahDefine.PullIfMissing if utils.IsLibpodRequest(r) { pullPolicy = buildahDefine.PolicyMap[query.PullPolicy] } else { - if _, found := r.URL.Query()["pull"]; found { + if _, found := queryValues["pull"]; found { if query.Pull { pullPolicy = buildahDefine.PullAlways } } } + // Get authentication creds, authfile, err := auth.GetCredentials(r) if err != nil { // Credential value(s) not returned as their value is not human readable - utils.Error(w, http.StatusBadRequest, err) - return + return nil, nil, utils.GetGenericBadRequestError(err) + } + // this smells + cleanup := func() { + auth.RemoveAuthfile(authfile) } - defer auth.RemoveAuthfile(authfile) + // Process from image fromImage := query.From if fromImage != "" { possiblyNormalizedName, err := utils.NormalizeToDockerHub(r, fromImage) if err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("normalizing image: %w", err)) - return + return nil, cleanup, utils.GetInternalServerError(fmt.Errorf("normalizing image: %w", err)) } fromImage = possiblyNormalizedName } + // Create system context systemContext := &types.SystemContext{ AuthFilePath: authfile, DockerAuthConfig: creds, } if err := utils.PossiblyEnforceDockerHub(r, systemContext); err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("checking to enforce DockerHub: %w", err)) - return + return nil, cleanup, utils.GetInternalServerError(fmt.Errorf("checking to enforce DockerHub: %w", err)) } - var skipUnusedStages types.OptionalBool - if _, found := r.URL.Query()["skipunusedstages"]; found { - skipUnusedStages = types.NewOptionalBool(query.SkipUnusedStages) - } + skipUnusedStages, _ := utils.ParseOptionalBool(query.SkipUnusedStages, "skipunusedstages", queryValues) - if _, found := r.URL.Query()["tlsVerify"]; found { + if _, found := queryValues["tlsVerify"]; found { systemContext.DockerInsecureSkipTLSVerify = types.NewOptionalBool(!query.TLSVerify) systemContext.OCIInsecureSkipTLSVerify = !query.TLSVerify systemContext.DockerDaemonInsecureSkipTLSVerify = !query.TLSVerify } - // Channels all mux'ed in select{} below to follow API build protocol - stdout := channel.NewWriter(make(chan []byte)) - defer stdout.Close() - - auxout := channel.NewWriter(make(chan []byte)) - defer auxout.Close() - - stderr := channel.NewWriter(make(chan []byte)) - defer stderr.Close() - - reporter := channel.NewWriter(make(chan []byte)) - defer reporter.Close() - - _, ignoreFile, err := util.ParseDockerignore(containerFiles, contextDirectory) - if err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("processing ignore file: %w", err)) - return - } + // Process retry delay retryDelay := 2 * time.Second if query.RetryDelay != "" { retryDelay, err = time.ParseDuration(query.RetryDelay) if err != nil { - utils.BadRequest(w, "retry-delay", query.RetryDelay, err) - return + return nil, cleanup, utils.GetBadRequestError("retry-delay", query.RetryDelay, err) } } - // Note: avoid using types.NewOptionaBool() to initialize optional bool fields of this - // struct without checking if the client supplied a value. Skipping that step prevents - // the builder from choosing/using its defaults. - buildOptions := buildahDefine.BuildOptions{ + // Create build options + buildOptions := &buildahDefine.BuildOptions{ AddCapabilities: addCaps, - AdditionalBuildContexts: additionalBuildContexts, + AdditionalBuildContexts: buildCtx.AdditionalBuildContexts, AdditionalTags: additionalTags, Annotations: annotations, CPPFlags: cppflags, @@ -758,18 +661,17 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { CreatedAnnotation: query.CreatedAnnotation, Compression: compression, ConfigureNetwork: parseNetworkConfigurationPolicy(query.ConfigureNetwork), - ContextDirectory: contextDirectory, + ContextDirectory: buildCtx.ContextDirectory, Devices: devices, DropCapabilities: dropCaps, Envs: query.Envs, - Err: auxout, Excludes: excludes, ForceRmIntermediateCtrs: query.ForceRm, GroupAdd: query.GroupAdd, From: fromImage, IDMappingOptions: &idMappingOptions, IgnoreUnrecognizedInstructions: query.Ignore, - IgnoreFile: ignoreFile, + IgnoreFile: buildCtx.IgnoreFile, InheritLabels: query.InheritLabels, InheritAnnotations: query.InheritAnnotations, Isolation: isolation, @@ -784,7 +686,6 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { NoCache: query.NoCache, OSFeatures: query.OSFeatures, OSVersion: query.OSVersion, - Out: stdout, Output: output, OutputFormat: format, PullPolicy: pullPolicy, @@ -792,7 +693,6 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { Quiet: query.Quiet, Registry: registry, RemoveIntermediateCtrs: query.Rm, - ReportWriter: reporter, RewriteTimestamp: query.RewriteTimestamp, RusageLogFile: query.RusageLogFile, SkipUnusedStages: skipUnusedStages, @@ -804,6 +704,7 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { UnsetAnnotations: query.UnsetAnnotations, } + // Process platforms platforms := query.Platform if len(platforms) == 1 { // Docker API uses comma separated platform arg so match this here @@ -812,8 +713,7 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { for _, platformSpec := range platforms { os, arch, variant, err := parse.Platform(platformSpec) if err != nil { - utils.BadRequest(w, "platform", platformSpec, err) - return + return nil, cleanup, utils.GetBadRequestError("platform", platformSpec, err) } buildOptions.Platforms = append(buildOptions.Platforms, struct{ OS, Arch, Variant string }{ OS: os, @@ -821,15 +721,40 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { Variant: variant, }) } - if _, found := r.URL.Query()["sourcedateepoch"]; found { + + // Process timestamps + if _, found := queryValues["sourcedateepoch"]; found { ts := time.Unix(query.SourceDateEpoch, 0) buildOptions.SourceDateEpoch = &ts } - if _, found := r.URL.Query()["timestamp"]; found { + if _, found := queryValues["timestamp"]; found { ts := time.Unix(query.Timestamp, 0) buildOptions.Timestamp = &ts } + return buildOptions, cleanup, nil +} + +// executeBuild performs the container build operation and streams results to the client. +func executeBuild(runtime *libpod.Runtime, w http.ResponseWriter, r *http.Request, buildOptions *buildahDefine.BuildOptions, containerFiles []string, query *BuildQuery) { + // Channels all mux'ed in select{} below to follow API build protocol + stdout := channel.NewWriter(make(chan []byte)) + defer stdout.Close() + + auxout := channel.NewWriter(make(chan []byte)) + defer auxout.Close() + + stderr := channel.NewWriter(make(chan []byte)) + defer stderr.Close() + + reporter := channel.NewWriter(make(chan []byte)) + defer reporter.Close() + + // Set output channels + buildOptions.Err = auxout + buildOptions.Out = stdout + buildOptions.ReportWriter = reporter + var ( imageID string success bool @@ -838,7 +763,8 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { runCtx, cancel := context.WithCancel(r.Context()) go func() { defer cancel() - imageID, _, err = runtime.Build(r.Context(), buildOptions, containerFiles...) + var err error + imageID, _, err = runtime.Build(r.Context(), *buildOptions, containerFiles...) if err == nil { success = true } else { @@ -846,55 +772,22 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { } }() - flush := func() { - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } - } - // Send headers and prime client for stream to come w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - flush() - body := w.(io.Writer) - 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_*_server") - defer t.Close() - body = io.MultiWriter(t, w) - } - } - } - - enc := json.NewEncoder(body) - enc.SetEscapeHTML(true) + sender := utils.NewBuildResponseSender(w) var stepErrors []string for { - m := images.BuildResponse{} - select { case e := <-stdout.Chan(): - m.Stream = string(e) - if err := enc.Encode(m); err != nil { - stderr.Write([]byte(err.Error())) - } - flush() + sender.SendBuildStream(string(e)) case e := <-reporter.Chan(): - m.Stream = string(e) - if err := enc.Encode(m); err != nil { - stderr.Write([]byte(err.Error())) - } - flush() + sender.SendBuildStream(string(e)) case e := <-auxout.Chan(): if !query.Quiet { - m.Stream = string(e) - if err := enc.Encode(m); err != nil { - stderr.Write([]byte(err.Error())) - } - flush() + sender.SendBuildStream(string(e)) } else { stepErrors = append(stepErrors, string(e)) } @@ -903,63 +796,129 @@ func BuildImage(w http.ResponseWriter, r *http.Request) { // output all step errors irrespective of quiet // flag. for _, stepError := range stepErrors { - t := images.BuildResponse{} - t.Stream = stepError - if err := enc.Encode(t); err != nil { - stderr.Write([]byte(err.Error())) - } - flush() + sender.SendBuildStream(stepError) } - m.ErrorMessage = string(e) - m.Error = &jsonmessage.JSONError{ - Message: string(e), - } - if err := enc.Encode(m); err != nil { - logrus.Warnf("Failed to json encode error %v", err) - } - flush() + sender.SendBuildError(string(e)) return case <-runCtx.Done(): if success { if !utils.IsLibpodRequest(r) && !query.Quiet { - m.Aux = []byte(fmt.Sprintf(`{"ID":"sha256:%s"}`, imageID)) - if err := enc.Encode(m); err != nil { - logrus.Warnf("failed to json encode error %v", err) - } - flush() - m.Aux = nil - m.Stream = fmt.Sprintf("Successfully built %12.12s\n", imageID) - if err := enc.Encode(m); err != nil { - logrus.Warnf("Failed to json encode error %v", err) - } - flush() - for _, tag := range tags { - m.Stream = fmt.Sprintf("Successfully tagged %s\n", tag) - if err := enc.Encode(m); err != nil { - logrus.Warnf("Failed to json encode error %v", err) - } - flush() + sender.SendBuildAux([]byte(fmt.Sprintf(`{"ID":"sha256:%s"}`, imageID))) + sender.SendBuildStream(fmt.Sprintf("Successfully built %12.12s\n", imageID)) + for _, tag := range query.Tags { + sender.SendBuildStream(fmt.Sprintf("Successfully tagged %s\n", tag)) } } } - flush() return case <-r.Context().Done(): cancel() - logrus.Infof("Client disconnect reported for build %q / %q.", registry, query.Dockerfile) + logrus.Infof("Client disconnect reported for build %q / %q.", buildOptions.Registry, query.Dockerfile) return } } } -func handleBuildContexts(anchorDir string, r *http.Request, multipart bool) (contextDir string, additionalContexts map[string]*buildahDefine.AdditionalBuildContext, err error) { - additionalContexts = make(map[string]*buildahDefine.AdditionalBuildContext) - query := r.URL.Query() +func BuildImage(w http.ResponseWriter, r *http.Request) { + // Create temporary directory for build context + anchorDir, err := os.MkdirTemp(parse.GetTempDir(), "libpod_builder") + if err != nil { + utils.InternalServerError(w, err) + return + } + + defer func() { + if logrus.IsLevelEnabled(logrus.DebugLevel) { + if v, found := os.LookupEnv("PODMAN_RETAIN_BUILD_ARTIFACT"); found { + if keep, _ := strconv.ParseBool(v); keep { + return + } + } + } + err := os.RemoveAll(anchorDir) + if err != nil { + logrus.Warn(fmt.Errorf("failed to remove build scratch directory %q: %w", anchorDir, err)) + } + }() + + // If we have a multipart we use the operations, if not default extraction for main context + // Validate content type + multipart, err := validateContentType(r) + if err != nil { + utils.ProcessBuildError(w, err) + return + } + queryValues := r.URL.Query() + + buildContext, err := getBuildContext(r, queryValues, anchorDir, multipart) + if err != nil { + utils.ProcessBuildError(w, err) + return + } + + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + conf, err := runtime.GetConfigNoCopy() + if err != nil { + utils.InternalServerError(w, err) + return + } + + query, err := parseBuildQuery(r, conf, queryValues) + if err != nil { + utils.ProcessBuildError(w, err) + return + } + + // Create build options + buildOptions, cleanup, err := createBuildOptions(query, buildContext, queryValues, r) + if cleanup != nil { + defer cleanup() + } + if err != nil { + utils.ProcessBuildError(w, err) + return + } + + // Execute build + executeBuild(runtime, w, r, buildOptions, buildContext.ContainerFiles, query) +} + +// getBuildContext processes build contexts from HTTP request to a BuildContext struct. +func getBuildContext(r *http.Request, query url.Values, anchorDir string, multipart bool) (*BuildContext, error) { + // Handle build contexts (extract from tar/multipart) + buildContext, err := handleBuildContexts(r, query, anchorDir, multipart) + if err != nil { + return nil, utils.GetInternalServerError(genSpaceErr(err)) + } + + // Process build context and container files + buildContext, err = processBuildContext(query, r, buildContext, anchorDir) + if err != nil { + return nil, err + } + + // Process dockerignore + _, ignoreFile, err := util.ParseDockerignore(buildContext.ContainerFiles, buildContext.ContextDirectory) + if err != nil { + return nil, utils.GetInternalServerError(fmt.Errorf("processing ignore file: %w", err)) + } + buildContext.IgnoreFile = ignoreFile + + return buildContext, nil +} + +// handleBuildContexts extracts and processes build contexts from the HTTP request body. +// Supports both single-context builds and multi-context builds with named references. +func handleBuildContexts(r *http.Request, query url.Values, anchorDir string, multipart bool) (*BuildContext, error) { + var err error + out := &BuildContext{ + AdditionalBuildContexts: make(map[string]*buildahDefine.AdditionalBuildContext), + } for _, url := range query["additionalbuildcontexts"] { name, value, found := strings.Cut(url, "=") if !found { - return "", nil, fmt.Errorf("invalid additional build context format: %q", url) + return nil, fmt.Errorf("invalid additional build context format: %q", url) } logrus.Debugf("name: %q, context: %q", name, value) @@ -969,11 +928,11 @@ func handleBuildContexts(anchorDir string, r *http.Request, multipart bool) (con value = strings.TrimPrefix(value, "url:") tempDir, subdir, err := buildahDefine.TempDirForURL(anchorDir, "buildah", value) if err != nil { - return "", nil, fmt.Errorf("downloading URL %q: %w", name, err) + return nil, fmt.Errorf("downloading URL %q: %w", name, err) } contextPath := filepath.Join(tempDir, subdir) - additionalContexts[name] = &buildahDefine.AdditionalBuildContext{ + out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{ IsURL: true, IsImage: false, Value: contextPath, @@ -983,7 +942,7 @@ func handleBuildContexts(anchorDir string, r *http.Request, multipart bool) (con logrus.Debugf("Downloaded URL context %q to %q", name, contextPath) case strings.HasPrefix(value, "image:"): value = strings.TrimPrefix(value, "image:") - additionalContexts[name] = &buildahDefine.AdditionalBuildContext{ + out.AdditionalBuildContexts[name] = &buildahDefine.AdditionalBuildContext{ IsURL: false, IsImage: true, Value: value, @@ -993,106 +952,99 @@ func handleBuildContexts(anchorDir string, r *http.Request, multipart bool) (con } } - // If we have a multipart we use the operations, if not default extraction for main context - if multipart { - logrus.Debug("Multipart is needed") - reader, err := r.MultipartReader() - if err != nil { - return "", nil, fmt.Errorf("failed to create multipart reader: %w", err) - } - - for { - part, err := reader.NextPart() - if err == io.EOF { - break - } - if err != nil { - return "", nil, fmt.Errorf("failed to read multipart: %w", err) - } - - fieldName := part.FormName() - - switch { - case fieldName == "MainContext": - mainDir, err := extractTarFile(anchorDir, part) - if err != nil { - part.Close() - return "", nil, fmt.Errorf("extracting main context in multipart: %w", err) - } - if mainDir == "" { - part.Close() - return "", nil, fmt.Errorf("main context directory is empty") - } - contextDir = mainDir - part.Close() - - case strings.HasPrefix(fieldName, "build-context-"): - contextName := strings.TrimPrefix(fieldName, "build-context-") - - // Create temp directory directly under anchorDir - additionalAnchor, err := os.MkdirTemp(anchorDir, contextName+"-*") - if err != nil { - part.Close() - return "", nil, fmt.Errorf("creating temp directory for additional context %q: %w", contextName, err) - } - - if err := chrootarchive.Untar(part, additionalAnchor, nil); err != nil { - part.Close() - return "", nil, fmt.Errorf("extracting additional context %q: %w", contextName, err) - } - - var latestModTime time.Time - fileCount := 0 - walkErr := filepath.Walk(additionalAnchor, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - // Skip the root directory itself since it's always going to have the latest timestamp - if path == additionalAnchor { - return nil - } - if !info.IsDir() { - fileCount++ - } - // Use any extracted content timestamp (files or subdirectories) - if info.ModTime().After(latestModTime) { - latestModTime = info.ModTime() - } - return nil - }) - if walkErr != nil { - part.Close() - return "", nil, fmt.Errorf("error walking additional context: %w", walkErr) - } - - // If we found any files, set the timestamp on the additional context directory - // to the latest modified time found in the files. - if !latestModTime.IsZero() { - if err := os.Chtimes(additionalAnchor, latestModTime, latestModTime); err != nil { - logrus.Warnf("Failed to set timestamp on additional context directory: %v", err) - } - } - - additionalContexts[contextName] = &buildahDefine.AdditionalBuildContext{ - IsURL: false, - IsImage: false, - Value: additionalAnchor, - } - part.Close() - default: - logrus.Debugf("Ignoring unknown multipart field: %s", fieldName) - part.Close() - } - } - } else { + if !multipart { logrus.Debug("No multipart needed") - contextDir, err = extractTarFile(anchorDir, r.Body) + out.ContextDirectory, err = extractTarFile(anchorDir, r.Body) if err != nil { - return "", nil, err + return nil, err + } + return out, nil + } + + logrus.Debug("Multipart is needed") + reader, err := r.MultipartReader() + if err != nil { + return nil, fmt.Errorf("failed to create multipart reader: %w", err) + } + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read multipart: %w", err) + } + defer part.Close() + + fieldName := part.FormName() + + switch { + case fieldName == "MainContext": + mainDir, err := extractTarFile(anchorDir, part) + if err != nil { + return nil, fmt.Errorf("extracting main context in multipart: %w", err) + } + if mainDir == "" { + return nil, fmt.Errorf("main context directory is empty") + } + out.ContextDirectory = mainDir + + case strings.HasPrefix(fieldName, "build-context-"): + contextName := strings.TrimPrefix(fieldName, "build-context-") + + // Create temp directory directly under anchorDir + additionalAnchor, err := os.MkdirTemp(anchorDir, contextName+"-*") + if err != nil { + return nil, fmt.Errorf("creating temp directory for additional context %q: %w", contextName, err) + } + + if err := chrootarchive.Untar(part, additionalAnchor, nil); err != nil { + return nil, fmt.Errorf("extracting additional context %q: %w", contextName, err) + } + + var latestModTime time.Time + fileCount := 0 + walkErr := filepath.Walk(additionalAnchor, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Skip the root directory itself since it's always going to have the latest timestamp + if path == additionalAnchor { + return nil + } + if !info.IsDir() { + fileCount++ + } + // Use any extracted content timestamp (files or subdirectories) + if info.ModTime().After(latestModTime) { + latestModTime = info.ModTime() + } + return nil + }) + if walkErr != nil { + return nil, fmt.Errorf("error walking additional context: %w", walkErr) + } + + // If we found any files, set the timestamp on the additional context directory + // to the latest modified time found in the files. + if !latestModTime.IsZero() { + if err := os.Chtimes(additionalAnchor, latestModTime, latestModTime); err != nil { + logrus.Warnf("Failed to set timestamp on additional context directory: %v", err) + } + } + + out.AdditionalBuildContexts[contextName] = &buildahDefine.AdditionalBuildContext{ + IsURL: false, + IsImage: false, + Value: additionalAnchor, + } + default: + logrus.Debugf("Ignoring unknown multipart field: %s", fieldName) } } - return contextDir, additionalContexts, nil + return out, nil } func parseNetworkConfigurationPolicy(network string) buildah.NetworkConfigurationPolicy { diff --git a/pkg/api/handlers/utils/errors.go b/pkg/api/handlers/utils/errors.go index b75edb8624..35b18599e6 100644 --- a/pkg/api/handlers/utils/errors.go +++ b/pkg/api/handlers/utils/errors.go @@ -108,3 +108,32 @@ func BadRequest(w http.ResponseWriter, key string, value string, err error) { func UnSupportedParameter(param string) { log.Infof("API parameter %q: not supported", param) } + +type BuildError struct { + err error + code int +} + +func (e *BuildError) Error() string { + return e.err.Error() +} + +func GetBadRequestError(key, value string, err error) *BuildError { + return &BuildError{code: http.StatusBadRequest, err: fmt.Errorf("failed to parse query parameter '%s': %q: %w", key, value, err)} +} + +func GetGenericBadRequestError(err error) *BuildError { + return &BuildError{code: http.StatusBadRequest, err: err} +} + +func GetInternalServerError(err error) *BuildError { + return &BuildError{code: http.StatusInternalServerError, err: err} +} + +func ProcessBuildError(w http.ResponseWriter, err error) { + if buildErr, ok := err.(*BuildError); ok { + Error(w, buildErr.code, buildErr.err) + return + } + InternalServerError(w, err) +} diff --git a/pkg/api/handlers/utils/handler.go b/pkg/api/handlers/utils/handler.go index 069010eada..b3f3742356 100644 --- a/pkg/api/handlers/utils/handler.go +++ b/pkg/api/handlers/utils/handler.go @@ -8,16 +8,20 @@ import ( "net/http" "net/url" "os" + "strconv" "unsafe" "github.com/blang/semver/v4" + "github.com/docker/docker/pkg/jsonmessage" "github.com/gorilla/mux" "github.com/gorilla/schema" jsoniter "github.com/json-iterator/go" "github.com/sirupsen/logrus" + "go.podman.io/image/v5/types" "github.com/containers/podman/v5/pkg/api/handlers/utils/apiutil" api "github.com/containers/podman/v5/pkg/api/types" + "github.com/containers/podman/v5/pkg/bindings/images" ) // IsLibpodRequest returns true if the request related to a libpod endpoint @@ -147,3 +151,97 @@ func GetDecoder(r *http.Request) *schema.Decoder { } return r.Context().Value(api.CompatDecoderKey).(*schema.Decoder) } + +// ParseOptionalJSONField unmarshals a JSON string only if the field exists in query values. +func ParseOptionalJSONField[T any](jsonStr, fieldName string, queryValues url.Values, target *T) error { + if _, found := queryValues[fieldName]; found { + return json.Unmarshal([]byte(jsonStr), target) + } + return nil +} + +// ParseOptionalBool creates a types.OptionalBool if the field exists in query values. +// Returns the OptionalBool and whether the field was found. +func ParseOptionalBool(value bool, fieldName string, queryValues url.Values) (types.OptionalBool, bool) { + if _, found := queryValues[fieldName]; found { + return types.NewOptionalBool(value), true + } + return types.OptionalBoolUndefined, false +} + +// ParseJSONOptionalSlice parses a JSON array string into a slice if the parameter exists. +// Returns nil if the parameter is not found. +func ParseJSONOptionalSlice(value string, query url.Values, paramName string) ([]string, error) { + if _, found := query[paramName]; found { + var result []string + if err := json.Unmarshal([]byte(value), &result); err != nil { + return nil, err + } + return result, nil + } + return nil, nil +} + +// ResponseSender provides streaming JSON responses with automatic flushing. +type ResponseSender struct { + encoder *jsoniter.Encoder + flusher func() +} + +// NewBuildResponseSender creates a ResponseSender for streaming build responses. +// Optionally writes to a debug file if PODMAN_RETAIN_BUILD_ARTIFACT is set. +func NewBuildResponseSender(w http.ResponseWriter) *ResponseSender { + body := w.(io.Writer) + if logrus.IsLevelEnabled(logrus.DebugLevel) { + if v, found := os.LookupEnv("PODMAN_RETAIN_BUILD_ARTIFACT"); found { + if keep, _ := strconv.ParseBool(v); keep { + if t, err := os.CreateTemp("", "build_*_server"); err != nil { + logrus.Warnf("Failed to create temp file: %v", err) + } else { + defer t.Close() + body = io.MultiWriter(t, w) + } + } + } + } + + enc := jsoniter.NewEncoder(body) + enc.SetEscapeHTML(true) + + flusher := func() { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + return &ResponseSender{encoder: enc, flusher: flusher} +} + +// Send encodes and sends a response object as JSON with automatic flushing. +func (b *ResponseSender) Send(response any) { + if err := b.encoder.Encode(response); err != nil { + logrus.Warnf("Failed to json encode build response: %v", err) + } + b.flusher() +} + +// SendBuildStream sends a build stream message to the client. +func (b *ResponseSender) SendBuildStream(message string) { + b.Send(images.BuildResponse{Stream: message}) +} + +// SendBuildError sends an error message as a build response. +func (b *ResponseSender) SendBuildError(message string) { + response := images.BuildResponse{ + ErrorMessage: message, + Error: &jsonmessage.JSONError{ + Message: message, + }, + } + b.Send(response) +} + +// SendBuildAux sends auxiliary data as part of a build response. +func (b *ResponseSender) SendBuildAux(aux []byte) { + b.Send(images.BuildResponse{Aux: aux}) +} diff --git a/pkg/api/handlers/utils/handler_test.go b/pkg/api/handlers/utils/handler_test.go index 1b2128656e..84441d984c 100644 --- a/pkg/api/handlers/utils/handler_test.go +++ b/pkg/api/handlers/utils/handler_test.go @@ -4,9 +4,14 @@ package utils import ( "net/http/httptest" + "net/url" "reflect" "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.podman.io/image/v5/types" ) func TestErrorEncoderFuncOmit(t *testing.T) { @@ -107,3 +112,306 @@ func TestWriteJSONNoHTMLEscape(t *testing.T) { t.Errorf("Parsed message doesn't match original: got %v, want %v", parsed, testData) } } + +func TestParseOptionalJSONField(t *testing.T) { + t.Run("field exists with valid JSON", func(t *testing.T) { + jsonStr := `["item1", "item2"]` + queryValues := url.Values{"testfield": []string{jsonStr}} + var target []string + + err := ParseOptionalJSONField(jsonStr, "testfield", queryValues, &target) + + assert.NoError(t, err) + assert.Equal(t, []string{"item1", "item2"}, target) + }) + + t.Run("field does not exist", func(t *testing.T) { + jsonStr := `["item1", "item2"]` + queryValues := url.Values{"otherfield": []string{jsonStr}} + var target []string + originalLen := len(target) + + err := ParseOptionalJSONField(jsonStr, "testfield", queryValues, &target) + + assert.NoError(t, err) + assert.Len(t, target, originalLen) // Should remain unchanged + }) + + t.Run("field exists with invalid JSON", func(t *testing.T) { + jsonStr := `{invalid json}` + queryValues := url.Values{"testfield": []string{jsonStr}} + var target map[string]string + + err := ParseOptionalJSONField(jsonStr, "testfield", queryValues, &target) + + assert.Error(t, err) + }) + + t.Run("complex object parsing", func(t *testing.T) { + jsonStr := `{"buildargs": {"ARG1": "value1", "ARG2": "value2"}}` + queryValues := url.Values{"config": []string{jsonStr}} + var target map[string]map[string]string + + err := ParseOptionalJSONField(jsonStr, "config", queryValues, &target) + + assert.NoError(t, err) + expected := map[string]map[string]string{ + "buildargs": {"ARG1": "value1", "ARG2": "value2"}, + } + assert.Equal(t, expected, target) + }) +} + +func TestParseOptionalBool(t *testing.T) { + t.Run("field exists with true value", func(t *testing.T) { + queryValues := url.Values{"testfield": []string{"true"}} + result, found := ParseOptionalBool(true, "testfield", queryValues) + + assert.True(t, found) + assert.Equal(t, types.NewOptionalBool(true), result) + }) + + t.Run("field exists with false value", func(t *testing.T) { + queryValues := url.Values{"testfield": []string{"false"}} + result, found := ParseOptionalBool(false, "testfield", queryValues) + + assert.True(t, found) + assert.Equal(t, types.NewOptionalBool(false), result) + }) + + t.Run("field does not exist", func(t *testing.T) { + queryValues := url.Values{"otherfield": []string{"value"}} + result, found := ParseOptionalBool(true, "testfield", queryValues) + + assert.False(t, found) + var empty types.OptionalBool + assert.Equal(t, empty, result) + }) + + t.Run("multiple values for same field", func(t *testing.T) { + queryValues := url.Values{"testfield": []string{"true", "false"}} + result, found := ParseOptionalBool(true, "testfield", queryValues) + + assert.True(t, found) + assert.Equal(t, types.NewOptionalBool(true), result) + }) +} + +func TestParseJSONOptionalSlice(t *testing.T) { + t.Run("parameter exists with valid JSON array", func(t *testing.T) { + value := `["item1", "item2", "item3"]` + queryValues := url.Values{"testparam": []string{value}} + + result, err := ParseJSONOptionalSlice(value, queryValues, "testparam") + + assert.NoError(t, err) + assert.Equal(t, []string{"item1", "item2", "item3"}, result) + }) + + t.Run("parameter does not exist", func(t *testing.T) { + value := `["item1", "item2"]` + queryValues := url.Values{"otherparam": []string{value}} + + result, err := ParseJSONOptionalSlice(value, queryValues, "testparam") + + assert.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("parameter exists with invalid JSON", func(t *testing.T) { + value := `[invalid json]` + queryValues := url.Values{"testparam": []string{value}} + + result, err := ParseJSONOptionalSlice(value, queryValues, "testparam") + + assert.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("parameter exists with empty array", func(t *testing.T) { + value := `[]` + queryValues := url.Values{"testparam": []string{value}} + + result, err := ParseJSONOptionalSlice(value, queryValues, "testparam") + + assert.NoError(t, err) + assert.Equal(t, []string{}, result) + }) + + t.Run("parameter exists with single item", func(t *testing.T) { + value := `["single"]` + queryValues := url.Values{"testparam": []string{value}} + + result, err := ParseJSONOptionalSlice(value, queryValues, "testparam") + + assert.NoError(t, err) + assert.Equal(t, []string{"single"}, result) + }) +} + +func TestNewBuildResponseSender(t *testing.T) { + t.Run("normal operation", func(t *testing.T) { + w := httptest.NewRecorder() + sender := NewBuildResponseSender(w) + + assert.NotNil(t, sender) + assert.NotNil(t, sender.encoder) + assert.NotNil(t, sender.flusher) + }) +} + +func TestResponseSender_Send(t *testing.T) { + w := httptest.NewRecorder() + sender := NewBuildResponseSender(w) + + testResponse := map[string]interface{}{ + "stream": "test message", + "id": "12345", + } + + sender.Send(testResponse) + + // Check that the response was written + assert.NotEmpty(t, w.Body.String()) + + // Verify the JSON was properly encoded + var decoded map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &decoded) + assert.NoError(t, err) + assert.Equal(t, "test message", decoded["stream"]) + assert.Equal(t, "12345", decoded["id"]) +} + +func TestResponseSender_SendBuildStream(t *testing.T) { + w := httptest.NewRecorder() + sender := NewBuildResponseSender(w) + + message := "Building step 1/5" + sender.SendBuildStream(message) + + // Verify the response structure + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, message, response["stream"]) +} + +func TestResponseSender_SendBuildError(t *testing.T) { + w := httptest.NewRecorder() + sender := NewBuildResponseSender(w) + + errorMessage := "Build failed: syntax error" + sender.SendBuildError(errorMessage) + + // Verify the response structure + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + // ErrorMessage field maps to "error" in JSON + assert.Equal(t, errorMessage, response["error"]) + assert.NotNil(t, response["errorDetail"]) + + // Check the nested error structure (errorDetail) + errorObj := response["errorDetail"].(map[string]interface{}) + assert.Equal(t, errorMessage, errorObj["message"]) +} + +func TestResponseSender_SendBuildAux(t *testing.T) { + w := httptest.NewRecorder() + sender := NewBuildResponseSender(w) + + auxData := []byte(`{"ID":"sha256:1234567890abcdef"}`) + sender.SendBuildAux(auxData) + + // Verify the response structure + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + assert.NotNil(t, response["aux"]) + + // The aux field should contain the raw JSON data + auxBytes, err := json.Marshal(response["aux"]) + assert.NoError(t, err) + assert.Equal(t, auxData, auxBytes) +} + +func TestResponseSender_SendInvalidJSON(t *testing.T) { + w := httptest.NewRecorder() + sender := NewBuildResponseSender(w) + + // Create a value that can't be JSON encoded (contains channels) + invalidValue := map[string]interface{}{ + "channel": make(chan string), + } + + // This should not panic, but should log a warning + sender.Send(invalidValue) + + // The body should be empty since encoding failed + assert.Empty(t, w.Body.String()) +} + +// Test integration scenarios +func TestParseOptionalJSONFieldIntegration(t *testing.T) { + // Simulate a real query parameter scenario + queryValues := url.Values{ + "buildargs": []string{`{"ARG1":"value1","ARG2":"value2"}`}, + "labels": []string{`{"app":"myapp","version":"1.0"}`}, + } + + t.Run("parse build args", func(t *testing.T) { + var buildArgs map[string]string + err := ParseOptionalJSONField(queryValues.Get("buildargs"), "buildargs", queryValues, &buildArgs) + + require.NoError(t, err) + expected := map[string]string{"ARG1": "value1", "ARG2": "value2"} + assert.Equal(t, expected, buildArgs) + }) + + t.Run("parse labels", func(t *testing.T) { + var labels map[string]string + err := ParseOptionalJSONField(queryValues.Get("labels"), "labels", queryValues, &labels) + + require.NoError(t, err) + expected := map[string]string{"app": "myapp", "version": "1.0"} + assert.Equal(t, expected, labels) + }) + + t.Run("parse non-existent field", func(t *testing.T) { + var nonExistent map[string]string + err := ParseOptionalJSONField("", "nonexistent", queryValues, &nonExistent) + + assert.NoError(t, err) + assert.Nil(t, nonExistent) + }) +} + +func TestResponseSenderFlushBehavior(t *testing.T) { + // Create a custom ResponseWriter that tracks flush calls + flushCalled := false + w := &testResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + onFlush: func() { + flushCalled = true + }, + } + + sender := NewBuildResponseSender(w) + sender.Send(map[string]string{"test": "message"}) + + assert.True(t, flushCalled, "Flush should have been called") +} + +// Helper type for testing flush behavior +type testResponseWriter struct { + *httptest.ResponseRecorder + onFlush func() +} + +func (t *testResponseWriter) Flush() { + if t.onFlush != nil { + t.onFlush() + } +}