diff --git a/cmd/podman/manifest/add.go b/cmd/podman/manifest/add.go index 15f22e4350..eafc9de3dc 100644 --- a/cmd/podman/manifest/add.go +++ b/cmd/podman/manifest/add.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "strings" "github.com/containers/common/pkg/auth" "github.com/containers/common/pkg/completion" @@ -12,30 +14,35 @@ import ( "github.com/containers/podman/v5/cmd/podman/registry" "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/util" + imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" ) -// manifestAddOptsWrapper wraps entities.ManifestAddOptions and prevents leaking -// CLI-only fields into the API types. +// manifestAddOptsWrapper wraps entities.ManifestAddOptions and prevents +// leaking CLI-only fields into the API types. type manifestAddOptsWrapper struct { entities.ManifestAddOptions + artifactOptions entities.ManifestAddArtifactOptions - TLSVerifyCLI bool // CLI only - Insecure bool // CLI only - CredentialsCLI string + tlsVerifyCLI bool // CLI only + insecure bool // CLI only + credentialsCLI string // CLI only + artifact bool // CLI only + artifactConfigFile string // CLI only + artifactType string // CLI only } var ( manifestAddOpts = manifestAddOptsWrapper{} addCmd = &cobra.Command{ - Use: "add [options] LIST IMAGE [IMAGE...]", - Short: "Add images to a manifest list or image index", - Long: "Adds an image to a manifest list or image index.", + Use: "add [options] LIST IMAGEORARTIFACT [IMAGEORARTIFACT...]", + Short: "Add images or artifacts to a manifest list or image index", + Long: "Adds an image or artifact to a manifest list or image index.", RunE: add, Args: cobra.MinimumNArgs(2), ValidArgsFunction: common.AutocompleteImages, Example: `podman manifest add mylist:v1.11 image:v1.11-amd64 - podman manifest add mylist:v1.11 transport:imageName`, + podman manifest add mylist:v1.11 transport:imageName`, } ) @@ -55,6 +62,32 @@ func init() { flags.StringVar(&manifestAddOpts.Arch, archFlagName, "", "override the `architecture` of the specified image") _ = addCmd.RegisterFlagCompletionFunc(archFlagName, completion.AutocompleteArch) + artifactFlagName := "artifact" + flags.BoolVar(&manifestAddOpts.artifact, artifactFlagName, false, "add all arguments as artifact files rather than as images") + + artifactExcludeTitlesFlagName := "artifact-exclude-titles" + flags.BoolVar(&manifestAddOpts.artifactOptions.ExcludeTitles, artifactExcludeTitlesFlagName, false, fmt.Sprintf(`refrain from setting %q annotations on "layers"`, imgspecv1.AnnotationTitle)) + + artifactTypeFlagName := "artifact-type" + flags.StringVar(&manifestAddOpts.artifactType, artifactTypeFlagName, "", "override the artifactType value") + _ = addCmd.RegisterFlagCompletionFunc(artifactTypeFlagName, completion.AutocompleteNone) + + artifactConfigFlagName := "artifact-config" + flags.StringVar(&manifestAddOpts.artifactConfigFile, artifactConfigFlagName, "", "artifact configuration file") + _ = addCmd.RegisterFlagCompletionFunc(artifactConfigFlagName, completion.AutocompleteNone) + + artifactConfigTypeFlagName := "artifact-config-type" + flags.StringVar(&manifestAddOpts.artifactOptions.ConfigType, artifactConfigTypeFlagName, "", "artifact configuration media type") + _ = addCmd.RegisterFlagCompletionFunc(artifactConfigTypeFlagName, completion.AutocompleteNone) + + artifactLayerTypeFlagName := "artifact-layer-type" + flags.StringVar(&manifestAddOpts.artifactOptions.LayerType, artifactLayerTypeFlagName, "", "artifact layer media type") + _ = addCmd.RegisterFlagCompletionFunc(artifactLayerTypeFlagName, completion.AutocompleteNone) + + artifactSubjectFlagName := "artifact-subject" + flags.StringVar(&manifestAddOpts.IndexSubject, artifactSubjectFlagName, "", "artifact subject reference") + _ = addCmd.RegisterFlagCompletionFunc(artifactSubjectFlagName, completion.AutocompleteNone) + authfileFlagName := "authfile" flags.StringVar(&manifestAddOpts.Authfile, authfileFlagName, auth.GetDefaultAuthFile(), "path of the authentication file. Use REGISTRY_AUTH_FILE environment variable to override") _ = addCmd.RegisterFlagCompletionFunc(authfileFlagName, completion.AutocompleteDefault) @@ -64,7 +97,7 @@ func init() { _ = addCmd.RegisterFlagCompletionFunc(certDirFlagName, completion.AutocompleteDefault) credsFlagName := "creds" - flags.StringVar(&manifestAddOpts.CredentialsCLI, credsFlagName, "", "use `[username[:password]]` for accessing the registry") + flags.StringVar(&manifestAddOpts.credentialsCLI, credsFlagName, "", "use `[username[:password]]` for accessing the registry") _ = addCmd.RegisterFlagCompletionFunc(credsFlagName, completion.AutocompleteNone) featuresFlagName := "features" @@ -79,9 +112,9 @@ func init() { flags.StringVar(&manifestAddOpts.OSVersion, osVersionFlagName, "", "override the OS `version` of the specified image") _ = addCmd.RegisterFlagCompletionFunc(osVersionFlagName, completion.AutocompleteNone) - flags.BoolVar(&manifestAddOpts.Insecure, "insecure", false, "neither require HTTPS nor verify certificates when accessing the registry") + flags.BoolVar(&manifestAddOpts.insecure, "insecure", false, "neither require HTTPS nor verify certificates when accessing the registry") _ = flags.MarkHidden("insecure") - flags.BoolVar(&manifestAddOpts.TLSVerifyCLI, "tls-verify", true, "require HTTPS and verify certificates when accessing the registry") + flags.BoolVar(&manifestAddOpts.tlsVerifyCLI, "tls-verify", true, "require HTTPS and verify certificates when accessing the registry") variantFlagName := "variant" flags.StringVar(&manifestAddOpts.Variant, variantFlagName, "", "override the `Variant` of the specified image") @@ -99,8 +132,8 @@ func add(cmd *cobra.Command, args []string) error { } } - if manifestAddOpts.CredentialsCLI != "" { - creds, err := util.ParseRegistryCreds(manifestAddOpts.CredentialsCLI) + if manifestAddOpts.credentialsCLI != "" { + creds, err := util.ParseRegistryCreds(manifestAddOpts.credentialsCLI) if err != nil { return err } @@ -113,18 +146,53 @@ func add(cmd *cobra.Command, args []string) error { // which is important to implement a sane way of dealing with defaults of // boolean CLI flags. if cmd.Flags().Changed("tls-verify") { - manifestAddOpts.SkipTLSVerify = types.NewOptionalBool(!manifestAddOpts.TLSVerifyCLI) + manifestAddOpts.SkipTLSVerify = types.NewOptionalBool(!manifestAddOpts.tlsVerifyCLI) } if cmd.Flags().Changed("insecure") { if manifestAddOpts.SkipTLSVerify != types.OptionalBoolUndefined { return errors.New("--insecure may not be used with --tls-verify") } - manifestAddOpts.SkipTLSVerify = types.NewOptionalBool(manifestAddOpts.Insecure) + manifestAddOpts.SkipTLSVerify = types.NewOptionalBool(manifestAddOpts.insecure) } - listID, err := registry.ImageEngine().ManifestAdd(context.Background(), args[0], args[1:], manifestAddOpts.ManifestAddOptions) - if err != nil { - return err + if !manifestAddOpts.artifact { + var changedArtifactFlags []string + for _, artifactOption := range []string{"artifact-type", "artifact-config", "artifact-config-type", "artifact-layer-type", "artifact-subject", "artifact-exclude-titles"} { + if cmd.Flags().Changed(artifactOption) { + changedArtifactFlags = append(changedArtifactFlags, "--"+artifactOption) + } + } + switch { + case len(changedArtifactFlags) == 1: + return fmt.Errorf("%s requires --artifact", changedArtifactFlags[0]) + case len(changedArtifactFlags) > 1: + return fmt.Errorf("%s require --artifact", strings.Join(changedArtifactFlags, "/")) + } + } + + var listID string + var err error + if manifestAddOpts.artifact { + if cmd.Flags().Changed("artifact-type") { + manifestAddOpts.artifactOptions.Type = &manifestAddOpts.artifactType + } + if manifestAddOpts.artifactConfigFile != "" { + configBytes, err := os.ReadFile(manifestAddOpts.artifactConfigFile) + if err != nil { + return fmt.Errorf("%v", err) + } + manifestAddOpts.artifactOptions.Config = string(configBytes) + } + manifestAddOpts.artifactOptions.ManifestAnnotateOptions = manifestAddOpts.ManifestAnnotateOptions + listID, err = registry.ImageEngine().ManifestAddArtifact(context.Background(), args[0], args[1:], manifestAddOpts.artifactOptions) + if err != nil { + return err + } + } else { + listID, err = registry.ImageEngine().ManifestAdd(context.Background(), args[0], args[1:], manifestAddOpts.ManifestAddOptions) + if err != nil { + return err + } } fmt.Println(listID) return nil diff --git a/cmd/podman/manifest/annotate.go b/cmd/podman/manifest/annotate.go index e5dae5649b..60765a9b22 100644 --- a/cmd/podman/manifest/annotate.go +++ b/cmd/podman/manifest/annotate.go @@ -1,7 +1,9 @@ package manifest import ( + "errors" "fmt" + "strings" "github.com/containers/common/pkg/completion" "github.com/containers/podman/v5/cmd/podman/common" @@ -10,14 +12,22 @@ import ( "github.com/spf13/cobra" ) +// manifestAnnotateOptsWrapper wraps entities.ManifestAnnotateOptions and +// prevents us from having to add CLI-only fields to the API types. +type manifestAnnotateOptsWrapper struct { + entities.ManifestAnnotateOptions + annotations []string + index bool +} + var ( - manifestAnnotateOpts = entities.ManifestAnnotateOptions{} + manifestAnnotateOpts = manifestAnnotateOptsWrapper{} annotateCmd = &cobra.Command{ - Use: "annotate [options] LIST IMAGE", + Use: "annotate [options] LIST IMAGEORARTIFACT", Short: "Add or update information about an entry in a manifest list or image index", Long: "Adds or updates information about an entry in a manifest list or image index.", RunE: annotate, - Args: cobra.ExactArgs(2), + Args: cobra.RangeArgs(1, 2), Example: `podman manifest annotate --annotation left=right mylist:v1.11 sha256:15352d97781ffdf357bf3459c037be3efac4133dc9070c2dce7eca7c05c3e736`, ValidArgsFunction: common.AutocompleteImages, } @@ -31,36 +41,85 @@ func init() { flags := annotateCmd.Flags() annotationFlagName := "annotation" - flags.StringArrayVar(&manifestAnnotateOpts.Annotation, annotationFlagName, nil, "set an `annotation` for the specified image") + flags.StringArrayVar(&manifestAnnotateOpts.annotations, annotationFlagName, nil, "set an `annotation` for the specified image or artifact") _ = annotateCmd.RegisterFlagCompletionFunc(annotationFlagName, completion.AutocompleteNone) archFlagName := "arch" - flags.StringVar(&manifestAnnotateOpts.Arch, archFlagName, "", "override the `architecture` of the specified image") + flags.StringVar(&manifestAnnotateOpts.Arch, archFlagName, "", "override the `architecture` of the specified image or artifact") _ = annotateCmd.RegisterFlagCompletionFunc(archFlagName, completion.AutocompleteArch) featuresFlagName := "features" - flags.StringSliceVar(&manifestAnnotateOpts.Features, featuresFlagName, nil, "override the `features` of the specified image") + flags.StringSliceVar(&manifestAnnotateOpts.Features, featuresFlagName, nil, "override the `features` of the specified image or artifact") _ = annotateCmd.RegisterFlagCompletionFunc(featuresFlagName, completion.AutocompleteNone) + indexFlagName := "index" + flags.BoolVar(&manifestAnnotateOpts.index, indexFlagName, false, "apply --"+annotationFlagName+" values to the image index itself") + osFlagName := "os" - flags.StringVar(&manifestAnnotateOpts.OS, osFlagName, "", "override the `OS` of the specified image") + flags.StringVar(&manifestAnnotateOpts.OS, osFlagName, "", "override the `OS` of the specified image or artifact") _ = annotateCmd.RegisterFlagCompletionFunc(osFlagName, completion.AutocompleteOS) osFeaturesFlagName := "os-features" - flags.StringSliceVar(&manifestAnnotateOpts.OSFeatures, osFeaturesFlagName, nil, "override the OS `features` of the specified image") + flags.StringSliceVar(&manifestAnnotateOpts.OSFeatures, osFeaturesFlagName, nil, "override the OS `features` of the specified image or artifact") _ = annotateCmd.RegisterFlagCompletionFunc(osFeaturesFlagName, completion.AutocompleteNone) osVersionFlagName := "os-version" - flags.StringVar(&manifestAnnotateOpts.OSVersion, osVersionFlagName, "", "override the OS `version` of the specified image") + flags.StringVar(&manifestAnnotateOpts.OSVersion, osVersionFlagName, "", "override the OS `version` of the specified image or artifact") _ = annotateCmd.RegisterFlagCompletionFunc(osVersionFlagName, completion.AutocompleteNone) variantFlagName := "variant" - flags.StringVar(&manifestAnnotateOpts.Variant, variantFlagName, "", "override the `Variant` of the specified image") + flags.StringVar(&manifestAnnotateOpts.Variant, variantFlagName, "", "override the `Variant` of the specified image or artifact") _ = annotateCmd.RegisterFlagCompletionFunc(variantFlagName, completion.AutocompleteNone) + + subjectFlagName := "subject" + flags.StringVar(&manifestAnnotateOpts.IndexSubject, subjectFlagName, "", "set the `subject` to which the image index refers") + _ = annotateCmd.RegisterFlagCompletionFunc(subjectFlagName, completion.AutocompleteNone) } func annotate(cmd *cobra.Command, args []string) error { - id, err := registry.ImageEngine().ManifestAnnotate(registry.Context(), args[0], args[1], manifestAnnotateOpts) + var listImageSpec, instanceSpec string + switch len(args) { + case 1: + listImageSpec = args[0] + if listImageSpec == "" { + return fmt.Errorf(`invalid image name "%s"`, args[0]) + } + if !manifestAnnotateOpts.index { + return errors.New(`expected an instance digest, image name, or artifact name`) + } + case 2: + listImageSpec = args[0] + if listImageSpec == "" { + return fmt.Errorf(`invalid image name "%s"`, args[0]) + } + if manifestAnnotateOpts.index { + return fmt.Errorf(`did not expect image or artifact name "%s" when modifying the entire index`, args[1]) + } + instanceSpec = args[1] + if instanceSpec == "" { + return fmt.Errorf(`invalid instance digest, image name, or artifact name "%s"`, instanceSpec) + } + default: + return errors.New("expected either a list name and --index or a list name and an image digest or image name or artifact name") + } + opts := manifestAnnotateOpts.ManifestAnnotateOptions + var annotations map[string]string + for _, annotation := range manifestAnnotateOpts.annotations { + k, v, parsed := strings.Cut(annotation, "=") + if !parsed { + return fmt.Errorf("expected --annotation %q to be in key=value format", annotation) + } + if annotations == nil { + annotations = make(map[string]string) + } + annotations[k] = v + } + if manifestAnnotateOpts.index { + opts.IndexAnnotations = annotations + } else { + opts.Annotations = annotations + } + id, err := registry.ImageEngine().ManifestAnnotate(registry.Context(), args[0], args[1], opts) if err != nil { return err } diff --git a/cmd/podman/manifest/create.go b/cmd/podman/manifest/create.go index eb8d6ad517..4e953ab5a2 100644 --- a/cmd/podman/manifest/create.go +++ b/cmd/podman/manifest/create.go @@ -3,7 +3,9 @@ package manifest import ( "errors" "fmt" + "strings" + "github.com/containers/common/pkg/completion" "github.com/containers/image/v5/types" "github.com/containers/podman/v5/cmd/podman/common" "github.com/containers/podman/v5/cmd/podman/registry" @@ -15,8 +17,8 @@ import ( // CLI-only fields into the API types. type manifestCreateOptsWrapper struct { entities.ManifestCreateOptions - - TLSVerifyCLI, Insecure bool // CLI only + annotations []string // CLI only + tlsVerifyCLI, insecure bool // CLI only } var ( @@ -43,9 +45,11 @@ func init() { flags := createCmd.Flags() flags.BoolVar(&manifestCreateOpts.All, "all", false, "add all of the lists' images if the images to add are lists") flags.BoolVarP(&manifestCreateOpts.Amend, "amend", "a", false, "modify an existing list if one with the desired name already exists") - flags.BoolVar(&manifestCreateOpts.Insecure, "insecure", false, "neither require HTTPS nor verify certificates when accessing the registry") + flags.BoolVar(&manifestCreateOpts.insecure, "insecure", false, "neither require HTTPS nor verify certificates when accessing the registry") + flags.StringArrayVar(&manifestCreateOpts.annotations, "annotation", nil, "set annotations on the new list") + _ = createCmd.RegisterFlagCompletionFunc("annotation", completion.AutocompleteNone) _ = flags.MarkHidden("insecure") - flags.BoolVar(&manifestCreateOpts.TLSVerifyCLI, "tls-verify", true, "require HTTPS and verify certificates when accessing the registry") + flags.BoolVar(&manifestCreateOpts.tlsVerifyCLI, "tls-verify", true, "require HTTPS and verify certificates when accessing the registry") } func create(cmd *cobra.Command, args []string) error { @@ -54,13 +58,23 @@ func create(cmd *cobra.Command, args []string) error { // which is important to implement a sane way of dealing with defaults of // boolean CLI flags. if cmd.Flags().Changed("tls-verify") { - manifestCreateOpts.SkipTLSVerify = types.NewOptionalBool(!manifestCreateOpts.TLSVerifyCLI) + manifestCreateOpts.SkipTLSVerify = types.NewOptionalBool(!manifestCreateOpts.tlsVerifyCLI) } if cmd.Flags().Changed("insecure") { if manifestCreateOpts.SkipTLSVerify != types.OptionalBoolUndefined { return errors.New("--insecure may not be used with --tls-verify") } - manifestCreateOpts.SkipTLSVerify = types.NewOptionalBool(manifestCreateOpts.Insecure) + manifestCreateOpts.SkipTLSVerify = types.NewOptionalBool(manifestCreateOpts.insecure) + } + for _, annotation := range manifestCreateOpts.annotations { + k, v, parsed := strings.Cut(annotation, "=") + if !parsed { + return fmt.Errorf("expected --annotation %q to be in key=value format", annotation) + } + if manifestCreateOpts.Annotations == nil { + manifestCreateOpts.Annotations = make(map[string]string) + } + manifestCreateOpts.Annotations[k] = v } imageID, err := registry.ImageEngine().ManifestCreate(registry.Context(), args[0], args[1:], manifestCreateOpts.ManifestCreateOptions) diff --git a/pkg/api/handlers/libpod/manifests.go b/pkg/api/handlers/libpod/manifests.go index 4d28cdd9ec..ee97aa1f12 100644 --- a/pkg/api/handlers/libpod/manifests.go +++ b/pkg/api/handlers/libpod/manifests.go @@ -8,8 +8,12 @@ import ( "io" "net/http" "net/url" + "os" + "path" + "path/filepath" "strconv" "strings" + "sync" "github.com/containers/common/libimage/define" "github.com/containers/image/v5/docker/reference" @@ -23,22 +27,24 @@ import ( "github.com/containers/podman/v5/pkg/channel" "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/domain/infra/abi" - envLib "github.com/containers/podman/v5/pkg/env" "github.com/containers/podman/v5/pkg/errorhandling" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/opencontainers/go-digest" "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" ) func ManifestCreate(w http.ResponseWriter, r *http.Request) { runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) query := struct { - Name string `schema:"name"` - Images []string `schema:"images"` - All bool `schema:"all"` - Amend bool `schema:"amend"` + Name string `schema:"name"` + Images []string `schema:"images"` + All bool `schema:"all"` + Amend bool `schema:"amend"` + Annotation []string `schema:"annotation"` + Annotations map[string]string `schema:"annotations"` }{ // Add defaults here once needed. } @@ -73,7 +79,21 @@ func ManifestCreate(w http.ResponseWriter, r *http.Request) { imageEngine := abi.ImageEngine{Libpod: runtime} - createOptions := entities.ManifestCreateOptions{All: query.All, Amend: query.Amend} + annotations := maps.Clone(query.Annotations) + for _, annotation := range query.Annotation { + k, v, ok := strings.Cut(annotation, "=") + if !ok { + utils.Error(w, http.StatusBadRequest, + fmt.Errorf("invalid annotation %s", annotation)) + return + } + if annotations == nil { + annotations = make(map[string]string) + } + annotations[k] = v + } + + createOptions := entities.ManifestCreateOptions{All: query.All, Amend: query.Amend, Annotations: annotations} manID, err := imageEngine.ManifestCreate(r.Context(), query.Name, query.Images, createOptions) if err != nil { utils.InternalServerError(w, err) @@ -99,26 +119,29 @@ func ManifestCreate(w http.ResponseWriter, r *http.Request) { body := new(entities.ManifestModifyOptions) if err := json.Unmarshal(buffer, body); err != nil { - utils.InternalServerError(w, fmt.Errorf("Decode(): %w", err)) + utils.InternalServerError(w, fmt.Errorf("decoding modifications in request: %w", err)) return } - // gather all images for manifest list - var images []string - if len(query.Images) > 0 { - images = query.Images + if len(body.IndexAnnotation) != 0 || len(body.IndexAnnotations) != 0 || body.IndexSubject != "" { + manifestAnnotateOptions := entities.ManifestAnnotateOptions{ + IndexAnnotation: body.IndexAnnotation, + IndexAnnotations: body.IndexAnnotations, + IndexSubject: body.IndexSubject, + } + if _, err := imageEngine.ManifestAnnotate(r.Context(), manID, "", manifestAnnotateOptions); err != nil { + utils.InternalServerError(w, err) + return + } } if len(body.Images) > 0 { - images = body.Images + if _, err := imageEngine.ManifestAdd(r.Context(), manID, body.Images, body.ManifestAddOptions); err != nil { + utils.InternalServerError(w, err) + return + } } - id, err := imageEngine.ManifestAdd(r.Context(), query.Name, images, body.ManifestAddOptions) - if err != nil { - utils.InternalServerError(w, err) - return - } - - utils.WriteResponse(w, status, entities.IDResponse{ID: id}) + utils.WriteResponse(w, status, entities.IDResponse{ID: manID}) } // ManifestExists return true if manifest list exists. @@ -194,7 +217,7 @@ func ManifestAddV3(w http.ResponseWriter, r *http.Request) { TLSVerify bool `schema:"tlsVerify"` }{} if err := json.NewDecoder(r.Body).Decode(&query); err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err)) + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decoding AddV3 query: %w", err)) return } @@ -471,33 +494,137 @@ func ManifestModify(w http.ResponseWriter, r *http.Request) { imageEngine := abi.ImageEngine{Libpod: runtime} body := new(entities.ManifestModifyOptions) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err)) - return + + multireader, err := r.MultipartReader() + if err != nil { + multireader = nil + // not multipart - request is just encoded JSON, nothing else + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decoding modify request: %w", err)) + return + } + } else { + // multipart - request is encoded JSON in the first part, each artifact is its own part + bodyPart, err := multireader.NextPart() + if err != nil { + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("reading first part of multipart request: %w", err)) + return + } + err = json.NewDecoder(bodyPart).Decode(body) + bodyPart.Close() + if err != nil { + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decoding modify request in multipart request: %w", err)) + return + } } name := utils.GetName(r) - if _, err := runtime.LibimageRuntime().LookupManifestList(name); err != nil { + manifestList, err := runtime.LibimageRuntime().LookupManifestList(name) + if err != nil { utils.Error(w, http.StatusNotFound, err) return } + annotationsFromAnnotationSlice := func(annotation []string) map[string]string { + annotations := make(map[string]string) + for _, annotationSpec := range annotation { + key, val, hasVal := strings.Cut(annotationSpec, "=") + if !hasVal { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("no value given for annotation %q", key)) + return nil + } + annotations[key] = val + } + return annotations + } if len(body.ManifestAddOptions.Annotation) != 0 { if len(body.ManifestAddOptions.Annotations) != 0 { utils.Error(w, http.StatusBadRequest, fmt.Errorf("can not set both Annotation and Annotations")) return } - annotations := make(map[string]string) - for _, annotationSpec := range body.ManifestAddOptions.Annotation { - key, val, hasVal := strings.Cut(annotationSpec, "=") - if !hasVal { - utils.Error(w, http.StatusBadRequest, fmt.Errorf("no value given for annotation %q", key)) + body.ManifestAddOptions.Annotations = annotationsFromAnnotationSlice(body.ManifestAddOptions.Annotation) + body.ManifestAddOptions.Annotation = nil + } + if len(body.ManifestAddOptions.IndexAnnotation) != 0 { + if len(body.ManifestAddOptions.IndexAnnotations) != 0 { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("can not set both IndexAnnotation and IndexAnnotations")) + return + } + body.ManifestAddOptions.IndexAnnotations = annotationsFromAnnotationSlice(body.ManifestAddOptions.IndexAnnotation) + body.ManifestAddOptions.IndexAnnotation = nil + } + + var artifactExtractionError error + var artifactExtraction sync.WaitGroup + if multireader != nil { + // If the data was multipart, then save items from it into a + // directory that will be removed along with this list, + // whenever that happens. + artifactExtraction.Add(1) + go func() { + defer artifactExtraction.Done() + storageConfig := runtime.StorageConfig() + // FIXME: knowing that this is the location of the + // per-image-record-stuff directory is a little too + // "inside storage" + fileDir, err := os.MkdirTemp(filepath.Join(runtime.GraphRoot(), storageConfig.GraphDriverName+"-images", manifestList.ID()), "") + if err != nil { + artifactExtractionError = err return } - annotations[key] = val - } - body.ManifestAddOptions.Annotations = envLib.Join(body.ManifestAddOptions.Annotations, annotations) - body.ManifestAddOptions.Annotation = nil + // We'll be building a list of the names of files we + // received as part of the request and setting it in + // the request body before we're done. + var contentFiles []string + part, err := multireader.NextPart() + if err != nil { + artifactExtractionError = err + return + } + for part != nil { + partName := part.FormName() + if filename := part.FileName(); filename != "" { + partName = filename + } + if partName != "" { + partName = path.Base(partName) + } + // Write the file in a scope that lets us close it as quickly + // as possible. + if err = func() error { + defer part.Close() + var f *os.File + // Create the file. + if partName != "" { + // Try to use the supplied name. + f, err = os.OpenFile(filepath.Join(fileDir, partName), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + } else { + // No supplied name means they don't care. + f, err = os.CreateTemp(fileDir, "upload") + } + if err != nil { + return err + } + defer f.Close() + // Write the file's contents. + if _, err = io.Copy(f, part); err != nil { + return err + } + contentFiles = append(contentFiles, f.Name()) + return nil + }(); err != nil { + break + } + part, err = multireader.NextPart() + } + // If we stowed all of the uploaded files without issue, we're all good. + if err != nil && !errors.Is(err, io.EOF) { + artifactExtractionError = err + return + } + // Save the list of files that we created. + body.ArtifactFiles = contentFiles + }() } if tlsVerify, ok := r.URL.Query()["tlsVerify"]; ok { @@ -527,17 +654,50 @@ func ManifestModify(w http.ResponseWriter, r *http.Request) { body.ManifestAddOptions.CertDir = sys.DockerCertPath } - var report entities.ManifestModifyReport + report := entities.ManifestModifyReport{ID: manifestList.ID()} switch { case strings.EqualFold("update", body.Operation): - id, err := imageEngine.ManifestAdd(r.Context(), name, body.Images, body.ManifestAddOptions) - if err != nil { - report.Errors = append(report.Errors, err) - break + if len(body.Images) > 0 { + id, err := imageEngine.ManifestAdd(r.Context(), name, body.Images, body.ManifestAddOptions) + if err != nil { + report.Errors = append(report.Errors, err) + break + } + report.ID = id + report.Images = body.Images } - report = entities.ManifestModifyReport{ - ID: id, - Images: body.Images, + if multireader != nil { + // Wait for the extraction goroutine to finish + // processing the message in the request body, so that + // we know whether or not everything looked alright. + artifactExtraction.Wait() + if artifactExtractionError != nil { + report.Errors = append(report.Errors, artifactExtractionError) + artifactExtractionError = nil + break + } + // Reconstruct a ManifestAddArtifactOptions from the corresponding + // fields in the entities.ManifestModifyOptions that we decoded + // the request struct into and then supplemented with the files list. + // We waited until after the extraction goroutine finished to ensure + // that we'd pick up its changes to the ArtifactFiles list. + manifestAddArtifactOptions := entities.ManifestAddArtifactOptions{ + Type: body.ArtifactType, + LayerType: body.ArtifactLayerType, + ConfigType: body.ArtifactConfigType, + Config: body.ArtifactConfig, + ExcludeTitles: body.ArtifactExcludeTitles, + Annotations: body.ArtifactAnnotations, + Subject: body.ArtifactSubject, + Files: body.ArtifactFiles, + } + id, err := imageEngine.ManifestAddArtifact(r.Context(), name, body.ArtifactFiles, manifestAddArtifactOptions) + if err != nil { + report.Errors = append(report.Errors, err) + break + } + report.ID = id + report.Files = body.ArtifactFiles } case strings.EqualFold("remove", body.Operation): for _, image := range body.Images { @@ -550,15 +710,7 @@ func ManifestModify(w http.ResponseWriter, r *http.Request) { report.Images = append(report.Images, image) } case strings.EqualFold("annotate", body.Operation): - options := entities.ManifestAnnotateOptions{ - Annotations: body.Annotations, - Arch: body.Arch, - Features: body.Features, - OS: body.OS, - OSFeatures: body.OSFeatures, - OSVersion: body.OSVersion, - Variant: body.Variant, - } + options := body.ManifestAnnotateOptions for _, image := range body.Images { id, err := imageEngine.ManifestAnnotate(r.Context(), name, image, options) if err != nil { @@ -573,6 +725,13 @@ func ManifestModify(w http.ResponseWriter, r *http.Request) { return } + // In case something weird happened, don't just let the goroutine go; make the + // client at least wait for it. + artifactExtraction.Wait() + if artifactExtractionError != nil { + report.Errors = append(report.Errors, artifactExtractionError) + } + statusCode := http.StatusOK switch { case len(report.Errors) > 0 && len(report.Images) > 0: diff --git a/pkg/bindings/manifests/manifests.go b/pkg/bindings/manifests/manifests.go index bc183042d6..c77dceaf13 100644 --- a/pkg/bindings/manifests/manifests.go +++ b/pkg/bindings/manifests/manifests.go @@ -6,10 +6,14 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "os" + "path/filepath" "strconv" "strings" + "sync" "github.com/containers/common/libimage/define" "github.com/containers/image/v5/manifest" @@ -21,6 +25,7 @@ import ( "github.com/containers/podman/v5/pkg/errorhandling" dockerAPI "github.com/docker/docker/api/types" jsoniter "github.com/json-iterator/go" + "golang.org/x/exp/slices" ) // Create creates a manifest for the given name. Optional images to be associated with @@ -160,7 +165,7 @@ func Add(ctx context.Context, name string, options *AddOptions) (string, error) Features: options.Features, Images: options.Images, OS: options.OS, - OSFeatures: nil, + OSFeatures: options.OSFeatures, OSVersion: options.OSVersion, Variant: options.Variant, Username: options.Username, @@ -172,6 +177,37 @@ func Add(ctx context.Context, name string, options *AddOptions) (string, error) return Modify(ctx, name, options.Images, &optionsv4) } +// AddArtifact creates an artifact manifest and adds it to a given manifest +// list. Additional options for the manifest can also be specified. The ID of +// the new manifest list is returned as a string +func AddArtifact(ctx context.Context, name string, options *AddArtifactOptions) (string, error) { + if options == nil { + options = new(AddArtifactOptions) + } + optionsv4 := ModifyOptions{ + Annotations: options.Annotation, + Arch: options.Arch, + Features: options.Features, + OS: options.OS, + OSFeatures: options.OSFeatures, + OSVersion: options.OSVersion, + Variant: options.Variant, + + ArtifactType: options.Type, + ArtifactConfigType: options.ConfigType, + ArtifactLayerType: options.LayerType, + ArtifactConfig: options.Config, + ArtifactExcludeTitles: options.ExcludeTitles, + ArtifactSubject: options.Subject, + ArtifactAnnotations: options.Annotations, + } + if len(options.Files) > 0 { + optionsv4.WithArtifactFiles(options.Files) + } + optionsv4.WithOperation("update") + return Modify(ctx, name, nil, &optionsv4) +} + // Remove deletes a manifest entry from a manifest list. Both name and the digest to be // removed are mandatory inputs. The ID of the new manifest list is returned as a string. func Remove(ctx context.Context, name, digest string, _ *RemoveOptions) (string, error) { @@ -284,6 +320,16 @@ func Modify(ctx context.Context, name string, images []string, options *ModifyOp } options.WithImages(images) + var artifactFiles, artifactBaseNames []string + if options.ArtifactFiles != nil && len(*options.ArtifactFiles) > 0 { + artifactFiles = slices.Clone(*options.ArtifactFiles) + artifactBaseNames = make([]string, 0, len(artifactFiles)) + for _, filename := range artifactFiles { + artifactBaseNames = append(artifactBaseNames, filepath.Base(filename)) + } + options.ArtifactFiles = &artifactBaseNames + } + conn, err := bindings.GetClient(ctx) if err != nil { return "", err @@ -292,12 +338,81 @@ func Modify(ctx context.Context, name string, images []string, options *ModifyOp if err != nil { return "", err } - reader := strings.NewReader(opts) + reader := io.Reader(strings.NewReader(opts)) + if options.Body != nil { + reader = io.MultiReader(reader, *options.Body) + } + var artifactContentType string + var artifactWriterGroup sync.WaitGroup + var artifactWriterError error + if len(artifactFiles) > 0 { + // get ready to upload the passed-in files + bodyReader, bodyWriter := io.Pipe() + defer bodyReader.Close() + requestBodyReader := reader + reader = bodyReader + // upload the files in another goroutine + writer := multipart.NewWriter(bodyWriter) + artifactContentType = writer.FormDataContentType() + artifactWriterGroup.Add(1) + go func() { + defer bodyWriter.Close() + defer writer.Close() + // start with the body we would have uploaded if we weren't + // attaching artifacts + headers := textproto.MIMEHeader{ + "Content-Type": []string{"application/json"}, + } + requestPartWriter, err := writer.CreatePart(headers) + if err != nil { + artifactWriterError = fmt.Errorf("creating form part for request: %v", err) + return + } + if _, err := io.Copy(requestPartWriter, requestBodyReader); err != nil { + artifactWriterError = fmt.Errorf("uploading request as form part: %v", err) + return + } + // now walk the list of files we're attaching + for _, file := range artifactFiles { + if err := func() error { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + fileBase := filepath.Base(file) + formFile, err := writer.CreateFormFile(fileBase, fileBase) + if err != nil { + return err + } + st, err := f.Stat() + if err != nil { + return err + } + // upload the file contents + n, err := io.Copy(formFile, f) + if err != nil { + return fmt.Errorf("uploading contents of artifact file %s: %w", filepath.Base(file), err) + } + if n != st.Size() { + return fmt.Errorf("short write while uploading contents of artifact file %s: %d != %d", filepath.Base(file), n, st.Size()) + } + return nil + }(); err != nil { + artifactWriterError = err + break + } + } + }() + } header, err := auth.MakeXRegistryAuthHeader(&imageTypes.SystemContext{AuthFilePath: options.GetAuthfile()}, options.GetUsername(), options.GetPassword()) if err != nil { return "", err } + if artifactContentType != "" { + header["Content-Type"] = []string{artifactContentType} + } params, err := options.ToParams() if err != nil { @@ -315,6 +430,11 @@ func Modify(ctx context.Context, name string, images []string, options *ModifyOp } defer response.Body.Close() + artifactWriterGroup.Wait() + if artifactWriterError != nil { + return "", fmt.Errorf("uploading artifacts: %w", err) + } + data, err := io.ReadAll(response.Body) if err != nil { return "", fmt.Errorf("unable to process API response: %w", err) diff --git a/pkg/bindings/manifests/types.go b/pkg/bindings/manifests/types.go index c9e14b1233..aae36c9a64 100644 --- a/pkg/bindings/manifests/types.go +++ b/pkg/bindings/manifests/types.go @@ -1,5 +1,7 @@ package manifests +import "io" + // InspectOptions are optional options for inspecting manifests // //go:generate go run ../generator/generator.go InspectOptions @@ -15,8 +17,9 @@ type InspectOptions struct { // //go:generate go run ../generator/generator.go CreateOptions type CreateOptions struct { - All *bool - Amend *bool + All *bool + Amend *bool + Annotation map[string]string } // ExistsOptions are optional options for checking @@ -30,20 +33,45 @@ type ExistsOptions struct { // //go:generate go run ../generator/generator.go AddOptions type AddOptions struct { - All *bool - Annotation map[string]string - Arch *string - Features []string + All *bool + + Annotation map[string]string + Arch *string + Features []string + OS *string + OSVersion *string + OSFeatures []string + Variant *string + Images []string - OS *string - OSVersion *string - Variant *string Authfile *string Password *string Username *string SkipTLSVerify *bool `schema:"-"` } +// AddArtifactOptions are optional options for adding artifact manifests +// +//go:generate go run ../generator/generator.go AddArtifactOptions +type AddArtifactOptions struct { + Annotation map[string]string + Arch *string + Features []string + OS *string + OSVersion *string + OSFeatures []string + Variant *string + + Type **string `json:"artifact_type,omitempty"` + ConfigType *string `json:"artifact_config_type,omitempty"` + Config *string `json:"artifact_config,omitempty"` + LayerType *string `json:"artifact_layer_type,omitempty"` + ExcludeTitles *bool `json:"artifact_exclude_titles,omitempty"` + Subject *string `json:"artifact_subject,omitempty"` + Annotations map[string]string `json:"artifact_annotations,omitempty"` + Files []string `json:"artifact_files,omitempty"` +} + // RemoveOptions are optional options for removing manifest lists // //go:generate go run ../generator/generator.go RemoveOptions @@ -55,21 +83,31 @@ type RemoveOptions struct { //go:generate go run ../generator/generator.go ModifyOptions type ModifyOptions struct { // Operation values are "update", "remove" and "annotate". This allows the service to - // efficiently perform each update on a manifest list. - Operation *string - All *bool // All when true, operate on all images in a manifest list that may be included in Images - Annotations map[string]string // Annotations to add to manifest list + // efficiently perform each update on a manifest list. + Operation *string + All *bool // All when true, operate on all images in a manifest list that may be included in Images + + Annotations map[string]string // Annotations to add to the entries for Images in the manifest list Arch *string // Arch overrides the architecture for the image Features []string // Feature list for the image - Images []string // Images is an optional list of images to add/remove to/from manifest list depending on operation OS *string // OS overrides the operating system for the image - // OS features for the image - OSFeatures []string `json:"os_features" schema:"os_features"` - // OSVersion overrides the operating system for the image - OSVersion *string `json:"os_version" schema:"os_version"` - Variant *string // Variant overrides the operating system variant for the image + OSFeatures []string `json:"os_features" schema:"os_features"` // OSFeatures overrides the OS features for the image + OSVersion *string `json:"os_version" schema:"os_version"` // OSVersion overrides the operating system version for the image + Variant *string // Variant overrides the architecture variant for the image + + Images []string // Images is an optional list of images to add/remove to/from manifest list depending on operation Authfile *string Password *string Username *string SkipTLSVerify *bool `schema:"-"` + + ArtifactType **string `json:"artifact_type"` // the ArtifactType in an artifact manifest being created + ArtifactConfigType *string `json:"artifact_config_type"` // the config.MediaType in an artifact manifest being created + ArtifactConfig *string `json:"artifact_config"` // the config.Data in an artifact manifest being created + ArtifactLayerType *string `json:"artifact_layer_type"` // the MediaType for each layer in an artifact manifest being created + ArtifactExcludeTitles *bool `json:"artifact_exclude_titles"` // whether or not to include title annotations for each layer in an artifact manifest being created + ArtifactSubject *string `json:"artifact_subject"` // subject to set in an artifact manifest being created + ArtifactAnnotations map[string]string `json:"artifact_annotations"` // annotations to add to an artifact manifest being created + ArtifactFiles *[]string `json:"artifact_files"` // an optional list of files to add to a new artifact manifest in the manifest list + Body *io.Reader `json:"-" schema:"-"` } diff --git a/pkg/bindings/manifests/types_add_options.go b/pkg/bindings/manifests/types_add_options.go index cd1c2c7aa2..47892d7936 100644 --- a/pkg/bindings/manifests/types_add_options.go +++ b/pkg/bindings/manifests/types_add_options.go @@ -77,21 +77,6 @@ func (o *AddOptions) GetFeatures() []string { return o.Features } -// WithImages set field Images to given value -func (o *AddOptions) WithImages(value []string) *AddOptions { - o.Images = value - return o -} - -// GetImages returns value of field Images -func (o *AddOptions) GetImages() []string { - if o.Images == nil { - var z []string - return z - } - return o.Images -} - // WithOS set field OS to given value func (o *AddOptions) WithOS(value string) *AddOptions { o.OS = &value @@ -122,6 +107,21 @@ func (o *AddOptions) GetOSVersion() string { return *o.OSVersion } +// WithOSFeatures set field OSFeatures to given value +func (o *AddOptions) WithOSFeatures(value []string) *AddOptions { + o.OSFeatures = value + return o +} + +// GetOSFeatures returns value of field OSFeatures +func (o *AddOptions) GetOSFeatures() []string { + if o.OSFeatures == nil { + var z []string + return z + } + return o.OSFeatures +} + // WithVariant set field Variant to given value func (o *AddOptions) WithVariant(value string) *AddOptions { o.Variant = &value @@ -137,6 +137,21 @@ func (o *AddOptions) GetVariant() string { return *o.Variant } +// WithImages set field Images to given value +func (o *AddOptions) WithImages(value []string) *AddOptions { + o.Images = value + return o +} + +// GetImages returns value of field Images +func (o *AddOptions) GetImages() []string { + if o.Images == nil { + var z []string + return z + } + return o.Images +} + // WithAuthfile set field Authfile to given value func (o *AddOptions) WithAuthfile(value string) *AddOptions { o.Authfile = &value diff --git a/pkg/bindings/manifests/types_addartifact_options.go b/pkg/bindings/manifests/types_addartifact_options.go new file mode 100644 index 0000000000..3b1c2804ec --- /dev/null +++ b/pkg/bindings/manifests/types_addartifact_options.go @@ -0,0 +1,243 @@ +// Code generated by go generate; DO NOT EDIT. +package manifests + +import ( + "net/url" + + "github.com/containers/podman/v5/pkg/bindings/internal/util" +) + +// Changed returns true if named field has been set +func (o *AddArtifactOptions) Changed(fieldName string) bool { + return util.Changed(o, fieldName) +} + +// ToParams formats struct fields to be passed to API service +func (o *AddArtifactOptions) ToParams() (url.Values, error) { + return util.ToParams(o) +} + +// WithAnnotation set field Annotation to given value +func (o *AddArtifactOptions) WithAnnotation(value map[string]string) *AddArtifactOptions { + o.Annotation = value + return o +} + +// GetAnnotation returns value of field Annotation +func (o *AddArtifactOptions) GetAnnotation() map[string]string { + if o.Annotation == nil { + var z map[string]string + return z + } + return o.Annotation +} + +// WithArch set field Arch to given value +func (o *AddArtifactOptions) WithArch(value string) *AddArtifactOptions { + o.Arch = &value + return o +} + +// GetArch returns value of field Arch +func (o *AddArtifactOptions) GetArch() string { + if o.Arch == nil { + var z string + return z + } + return *o.Arch +} + +// WithFeatures set field Features to given value +func (o *AddArtifactOptions) WithFeatures(value []string) *AddArtifactOptions { + o.Features = value + return o +} + +// GetFeatures returns value of field Features +func (o *AddArtifactOptions) GetFeatures() []string { + if o.Features == nil { + var z []string + return z + } + return o.Features +} + +// WithOS set field OS to given value +func (o *AddArtifactOptions) WithOS(value string) *AddArtifactOptions { + o.OS = &value + return o +} + +// GetOS returns value of field OS +func (o *AddArtifactOptions) GetOS() string { + if o.OS == nil { + var z string + return z + } + return *o.OS +} + +// WithOSVersion set field OSVersion to given value +func (o *AddArtifactOptions) WithOSVersion(value string) *AddArtifactOptions { + o.OSVersion = &value + return o +} + +// GetOSVersion returns value of field OSVersion +func (o *AddArtifactOptions) GetOSVersion() string { + if o.OSVersion == nil { + var z string + return z + } + return *o.OSVersion +} + +// WithOSFeatures set field OSFeatures to given value +func (o *AddArtifactOptions) WithOSFeatures(value []string) *AddArtifactOptions { + o.OSFeatures = value + return o +} + +// GetOSFeatures returns value of field OSFeatures +func (o *AddArtifactOptions) GetOSFeatures() []string { + if o.OSFeatures == nil { + var z []string + return z + } + return o.OSFeatures +} + +// WithVariant set field Variant to given value +func (o *AddArtifactOptions) WithVariant(value string) *AddArtifactOptions { + o.Variant = &value + return o +} + +// GetVariant returns value of field Variant +func (o *AddArtifactOptions) GetVariant() string { + if o.Variant == nil { + var z string + return z + } + return *o.Variant +} + +// WithType set field Type to given value +func (o *AddArtifactOptions) WithType(value *string) *AddArtifactOptions { + o.Type = &value + return o +} + +// GetType returns value of field Type +func (o *AddArtifactOptions) GetType() *string { + if o.Type == nil { + var z *string + return z + } + return *o.Type +} + +// WithConfigType set field ConfigType to given value +func (o *AddArtifactOptions) WithConfigType(value string) *AddArtifactOptions { + o.ConfigType = &value + return o +} + +// GetConfigType returns value of field ConfigType +func (o *AddArtifactOptions) GetConfigType() string { + if o.ConfigType == nil { + var z string + return z + } + return *o.ConfigType +} + +// WithConfig set field Config to given value +func (o *AddArtifactOptions) WithConfig(value string) *AddArtifactOptions { + o.Config = &value + return o +} + +// GetConfig returns value of field Config +func (o *AddArtifactOptions) GetConfig() string { + if o.Config == nil { + var z string + return z + } + return *o.Config +} + +// WithLayerType set field LayerType to given value +func (o *AddArtifactOptions) WithLayerType(value string) *AddArtifactOptions { + o.LayerType = &value + return o +} + +// GetLayerType returns value of field LayerType +func (o *AddArtifactOptions) GetLayerType() string { + if o.LayerType == nil { + var z string + return z + } + return *o.LayerType +} + +// WithExcludeTitles set field ExcludeTitles to given value +func (o *AddArtifactOptions) WithExcludeTitles(value bool) *AddArtifactOptions { + o.ExcludeTitles = &value + return o +} + +// GetExcludeTitles returns value of field ExcludeTitles +func (o *AddArtifactOptions) GetExcludeTitles() bool { + if o.ExcludeTitles == nil { + var z bool + return z + } + return *o.ExcludeTitles +} + +// WithSubject set field Subject to given value +func (o *AddArtifactOptions) WithSubject(value string) *AddArtifactOptions { + o.Subject = &value + return o +} + +// GetSubject returns value of field Subject +func (o *AddArtifactOptions) GetSubject() string { + if o.Subject == nil { + var z string + return z + } + return *o.Subject +} + +// WithAnnotations set field Annotations to given value +func (o *AddArtifactOptions) WithAnnotations(value map[string]string) *AddArtifactOptions { + o.Annotations = value + return o +} + +// GetAnnotations returns value of field Annotations +func (o *AddArtifactOptions) GetAnnotations() map[string]string { + if o.Annotations == nil { + var z map[string]string + return z + } + return o.Annotations +} + +// WithFiles set field Files to given value +func (o *AddArtifactOptions) WithFiles(value []string) *AddArtifactOptions { + o.Files = value + return o +} + +// GetFiles returns value of field Files +func (o *AddArtifactOptions) GetFiles() []string { + if o.Files == nil { + var z []string + return z + } + return o.Files +} diff --git a/pkg/bindings/manifests/types_create_options.go b/pkg/bindings/manifests/types_create_options.go index 65407928cc..758010716c 100644 --- a/pkg/bindings/manifests/types_create_options.go +++ b/pkg/bindings/manifests/types_create_options.go @@ -46,3 +46,18 @@ func (o *CreateOptions) GetAmend() bool { } return *o.Amend } + +// WithAnnotation set field Annotation to given value +func (o *CreateOptions) WithAnnotation(value map[string]string) *CreateOptions { + o.Annotation = value + return o +} + +// GetAnnotation returns value of field Annotation +func (o *CreateOptions) GetAnnotation() map[string]string { + if o.Annotation == nil { + var z map[string]string + return z + } + return o.Annotation +} diff --git a/pkg/bindings/manifests/types_modify_options.go b/pkg/bindings/manifests/types_modify_options.go index 866b658b4d..1957b8027a 100644 --- a/pkg/bindings/manifests/types_modify_options.go +++ b/pkg/bindings/manifests/types_modify_options.go @@ -2,6 +2,7 @@ package manifests import ( + "io" "net/url" "github.com/containers/podman/v5/pkg/bindings/internal/util" @@ -47,13 +48,13 @@ func (o *ModifyOptions) GetAll() bool { return *o.All } -// WithAnnotations set annotations to add to manifest list +// WithAnnotations set annotations to add to the entries for Images in the manifest list func (o *ModifyOptions) WithAnnotations(value map[string]string) *ModifyOptions { o.Annotations = value return o } -// GetAnnotations returns value of annotations to add to manifest list +// GetAnnotations returns value of annotations to add to the entries for Images in the manifest list func (o *ModifyOptions) GetAnnotations() map[string]string { if o.Annotations == nil { var z map[string]string @@ -92,21 +93,6 @@ func (o *ModifyOptions) GetFeatures() []string { return o.Features } -// WithImages set images is an optional list of images to add/remove to/from manifest list depending on operation -func (o *ModifyOptions) WithImages(value []string) *ModifyOptions { - o.Images = value - return o -} - -// GetImages returns value of images is an optional list of images to add/remove to/from manifest list depending on operation -func (o *ModifyOptions) GetImages() []string { - if o.Images == nil { - var z []string - return z - } - return o.Images -} - // WithOS set oS overrides the operating system for the image func (o *ModifyOptions) WithOS(value string) *ModifyOptions { o.OS = &value @@ -122,13 +108,13 @@ func (o *ModifyOptions) GetOS() string { return *o.OS } -// WithOSFeatures set field OSFeatures to given value +// WithOSFeatures set oSFeatures overrides the OS features for the image func (o *ModifyOptions) WithOSFeatures(value []string) *ModifyOptions { o.OSFeatures = value return o } -// GetOSFeatures returns value of field OSFeatures +// GetOSFeatures returns value of oSFeatures overrides the OS features for the image func (o *ModifyOptions) GetOSFeatures() []string { if o.OSFeatures == nil { var z []string @@ -137,13 +123,13 @@ func (o *ModifyOptions) GetOSFeatures() []string { return o.OSFeatures } -// WithOSVersion set field OSVersion to given value +// WithOSVersion set oSVersion overrides the operating system version for the image func (o *ModifyOptions) WithOSVersion(value string) *ModifyOptions { o.OSVersion = &value return o } -// GetOSVersion returns value of field OSVersion +// GetOSVersion returns value of oSVersion overrides the operating system version for the image func (o *ModifyOptions) GetOSVersion() string { if o.OSVersion == nil { var z string @@ -152,13 +138,13 @@ func (o *ModifyOptions) GetOSVersion() string { return *o.OSVersion } -// WithVariant set variant overrides the operating system variant for the image +// WithVariant set variant overrides the architecture variant for the image func (o *ModifyOptions) WithVariant(value string) *ModifyOptions { o.Variant = &value return o } -// GetVariant returns value of variant overrides the operating system variant for the image +// GetVariant returns value of variant overrides the architecture variant for the image func (o *ModifyOptions) GetVariant() string { if o.Variant == nil { var z string @@ -167,6 +153,21 @@ func (o *ModifyOptions) GetVariant() string { return *o.Variant } +// WithImages set images is an optional list of images to add/remove to/from manifest list depending on operation +func (o *ModifyOptions) WithImages(value []string) *ModifyOptions { + o.Images = value + return o +} + +// GetImages returns value of images is an optional list of images to add/remove to/from manifest list depending on operation +func (o *ModifyOptions) GetImages() []string { + if o.Images == nil { + var z []string + return z + } + return o.Images +} + // WithAuthfile set field Authfile to given value func (o *ModifyOptions) WithAuthfile(value string) *ModifyOptions { o.Authfile = &value @@ -226,3 +227,138 @@ func (o *ModifyOptions) GetSkipTLSVerify() bool { } return *o.SkipTLSVerify } + +// WithArtifactType set the ArtifactType in an artifact manifest being created +func (o *ModifyOptions) WithArtifactType(value *string) *ModifyOptions { + o.ArtifactType = &value + return o +} + +// GetArtifactType returns value of the ArtifactType in an artifact manifest being created +func (o *ModifyOptions) GetArtifactType() *string { + if o.ArtifactType == nil { + var z *string + return z + } + return *o.ArtifactType +} + +// WithArtifactConfigType set the config.MediaType in an artifact manifest being created +func (o *ModifyOptions) WithArtifactConfigType(value string) *ModifyOptions { + o.ArtifactConfigType = &value + return o +} + +// GetArtifactConfigType returns value of the config.MediaType in an artifact manifest being created +func (o *ModifyOptions) GetArtifactConfigType() string { + if o.ArtifactConfigType == nil { + var z string + return z + } + return *o.ArtifactConfigType +} + +// WithArtifactConfig set the config.Data in an artifact manifest being created +func (o *ModifyOptions) WithArtifactConfig(value string) *ModifyOptions { + o.ArtifactConfig = &value + return o +} + +// GetArtifactConfig returns value of the config.Data in an artifact manifest being created +func (o *ModifyOptions) GetArtifactConfig() string { + if o.ArtifactConfig == nil { + var z string + return z + } + return *o.ArtifactConfig +} + +// WithArtifactLayerType set the MediaType for each layer in an artifact manifest being created +func (o *ModifyOptions) WithArtifactLayerType(value string) *ModifyOptions { + o.ArtifactLayerType = &value + return o +} + +// GetArtifactLayerType returns value of the MediaType for each layer in an artifact manifest being created +func (o *ModifyOptions) GetArtifactLayerType() string { + if o.ArtifactLayerType == nil { + var z string + return z + } + return *o.ArtifactLayerType +} + +// WithArtifactExcludeTitles set whether or not to include title annotations for each layer in an artifact manifest being created +func (o *ModifyOptions) WithArtifactExcludeTitles(value bool) *ModifyOptions { + o.ArtifactExcludeTitles = &value + return o +} + +// GetArtifactExcludeTitles returns value of whether or not to include title annotations for each layer in an artifact manifest being created +func (o *ModifyOptions) GetArtifactExcludeTitles() bool { + if o.ArtifactExcludeTitles == nil { + var z bool + return z + } + return *o.ArtifactExcludeTitles +} + +// WithArtifactSubject set subject to set in an artifact manifest being created +func (o *ModifyOptions) WithArtifactSubject(value string) *ModifyOptions { + o.ArtifactSubject = &value + return o +} + +// GetArtifactSubject returns value of subject to set in an artifact manifest being created +func (o *ModifyOptions) GetArtifactSubject() string { + if o.ArtifactSubject == nil { + var z string + return z + } + return *o.ArtifactSubject +} + +// WithArtifactAnnotations set annotations to add to an artifact manifest being created +func (o *ModifyOptions) WithArtifactAnnotations(value map[string]string) *ModifyOptions { + o.ArtifactAnnotations = value + return o +} + +// GetArtifactAnnotations returns value of annotations to add to an artifact manifest being created +func (o *ModifyOptions) GetArtifactAnnotations() map[string]string { + if o.ArtifactAnnotations == nil { + var z map[string]string + return z + } + return o.ArtifactAnnotations +} + +// WithArtifactFiles set an optional list of files to add to a new artifact manifest in the manifest list +func (o *ModifyOptions) WithArtifactFiles(value []string) *ModifyOptions { + o.ArtifactFiles = &value + return o +} + +// GetArtifactFiles returns value of an optional list of files to add to a new artifact manifest in the manifest list +func (o *ModifyOptions) GetArtifactFiles() []string { + if o.ArtifactFiles == nil { + var z []string + return z + } + return *o.ArtifactFiles +} + +// WithBody set field Body to given value +func (o *ModifyOptions) WithBody(value io.Reader) *ModifyOptions { + o.Body = &value + return o +} + +// GetBody returns value of field Body +func (o *ModifyOptions) GetBody() io.Reader { + if o.Body == nil { + var z io.Reader + return z + } + return *o.Body +} diff --git a/pkg/domain/entities/engine_image.go b/pkg/domain/entities/engine_image.go index 09941a39da..8179b9a67f 100644 --- a/pkg/domain/entities/engine_image.go +++ b/pkg/domain/entities/engine_image.go @@ -36,6 +36,7 @@ type ImageEngine interface { //nolint:interfacebloat ManifestExists(ctx context.Context, name string) (*BoolReport, error) ManifestInspect(ctx context.Context, name string, opts ManifestInspectOptions) ([]byte, error) ManifestAdd(ctx context.Context, listName string, imageNames []string, opts ManifestAddOptions) (string, error) + ManifestAddArtifact(ctx context.Context, name string, files []string, opts ManifestAddArtifactOptions) (string, error) ManifestAnnotate(ctx context.Context, names, image string, opts ManifestAnnotateOptions) (string, error) ManifestRemoveDigest(ctx context.Context, names, image string) (string, error) ManifestRm(ctx context.Context, names []string) (*ImageRemoveReport, []error) diff --git a/pkg/domain/entities/manifest.go b/pkg/domain/entities/manifest.go index 303995903b..7e2a5c46de 100644 --- a/pkg/domain/entities/manifest.go +++ b/pkg/domain/entities/manifest.go @@ -5,7 +5,7 @@ import ( entitiesTypes "github.com/containers/podman/v5/pkg/domain/entities/types" ) -// ManifestCreateOptions provides model for creating manifest +// ManifestCreateOptions provides model for creating manifest list or image index type ManifestCreateOptions struct { // True when adding lists to include all images All bool `schema:"all"` @@ -13,6 +13,8 @@ type ManifestCreateOptions struct { Amend bool `schema:"amend"` // Should TLS registry certificate be verified? SkipTLSVerify types.OptionalBool `json:"-" schema:"-"` + // Annotations to set on the list, which forces it to be OCI format + Annotations map[string]string `json:"annotations" schema:"annotations"` } // ManifestInspectOptions provides model for inspecting manifest @@ -40,28 +42,51 @@ type ManifestAddOptions struct { SkipTLSVerify types.OptionalBool `json:"-" schema:"-"` // Username to authenticate to registry when pushing manifest list Username string `json:"-" schema:"-"` - // Images is an optional list of images to add to manifest list + // Images is an optional list of image references to add to manifest list Images []string `json:"images" schema:"images"` } +// ManifestAddArtifactOptions provides the model for creating artifact manifests +// for files and adding those manifests to a manifest list +// +// swagger:model +type ManifestAddArtifactOptions struct { + ManifestAnnotateOptions + // Note to future maintainers: keep these fields synchronized with ManifestModifyOptions! + Type *string `json:"artifact_type" schema:"artifact_type"` + LayerType string `json:"artifact_layer_type" schema:"artifact_layer_type"` + ConfigType string `json:"artifact_config_type" schema:"artifact_config_type"` + Config string `json:"artifact_config" schema:"artifact_config"` + ExcludeTitles bool `json:"artifact_exclude_titles" schema:"artifact_exclude_titles"` + Annotations map[string]string `json:"artifact_annotations" schema:"artifact_annotations"` + Subject string `json:"artifact_subject" schema:"artifact_subject"` + Files []string `json:"artifact_files" schema:"-"` +} + // ManifestAnnotateOptions provides model for annotating manifest list type ManifestAnnotateOptions struct { - // Annotation to add to manifest list + // Annotation to add to the item in the manifest list Annotation []string `json:"annotation" schema:"annotation"` - // Annotations to add to manifest list by a map which is preferred over Annotation + // Annotations to add to the item in the manifest list by a map which is preferred over Annotation Annotations map[string]string `json:"annotations" schema:"annotations"` - // Arch overrides the architecture for the image + // Arch overrides the architecture for the item in the manifest list Arch string `json:"arch" schema:"arch"` - // Feature list for the image + // Feature list for the item in the manifest list Features []string `json:"features" schema:"features"` - // OS overrides the operating system for the image + // OS overrides the operating system for the item in the manifest list OS string `json:"os" schema:"os"` - // OS features for the image + // OS features for the item in the manifest list OSFeatures []string `json:"os_features" schema:"os_features"` - // OSVersion overrides the operating system for the image + // OSVersion overrides the operating system for the item in the manifest list OSVersion string `json:"os_version" schema:"os_version"` - // Variant for the image + // Variant for the item in the manifest list Variant string `json:"variant" schema:"variant"` + // IndexAnnotation is a slice of key=value annotations to add to the manifest list itself + IndexAnnotation []string `json:"index_annotation" schema:"annotation"` + // IndexAnnotations is a map of key:value annotations to add to the manifest list itself, by a map which is preferred over IndexAnnotation + IndexAnnotations map[string]string `json:"index_annotations" schema:"annotations"` + // IndexSubject is a subject value to set in the manifest list itself + IndexSubject string `json:"subject" schema:"subject"` } // ManifestModifyOptions provides the model for mutating a manifest @@ -77,6 +102,18 @@ type ManifestModifyOptions struct { Operation string `json:"operation" schema:"operation"` // Valid values: update, remove, annotate ManifestAddOptions ManifestRemoveOptions + // The following are all of the fields from ManifestAddArtifactOptions. + // We can't just embed the whole structure because it embeds a + // ManifestAnnotateOptions, which would conflict with the one that + // ManifestAddOptions embeds. + ArtifactType *string `json:"artifact_type" schema:"artifact_type"` + ArtifactLayerType string `json:"artifact_layer_type" schema:"artifact_layer_type"` + ArtifactConfigType string `json:"artifact_config_type" schema:"artifact_config_type"` + ArtifactConfig string `json:"artifact_config" schema:"artifact_config"` + ArtifactExcludeTitles bool `json:"artifact_exclude_titles" schema:"artifact_exclude_titles"` + ArtifactAnnotations map[string]string `json:"artifact_annotations" schema:"artifact_annotations"` + ArtifactSubject string `json:"artifact_subject" schema:"artifact_subject"` + ArtifactFiles []string `json:"artifact_files" schema:"-"` } // ManifestPushReport provides the model for the pushed manifest diff --git a/pkg/domain/entities/types/manifest.go b/pkg/domain/entities/types/manifest.go index 941c3265ef..493950bc72 100644 --- a/pkg/domain/entities/types/manifest.go +++ b/pkg/domain/entities/types/manifest.go @@ -14,8 +14,10 @@ type ManifestPushReport struct { type ManifestModifyReport struct { // Manifest List ID ID string `json:"Id"` - // Images to removed from manifest list, otherwise not provided. + // Images added to or removed from manifest list, otherwise not provided. Images []string `json:"images,omitempty" schema:"images"` + // Files added to manifest list, otherwise not provided. + Files []string `json:"files,omitempty" schema:"files"` // Errors associated with operation Errors []error `json:"errors,omitempty"` } diff --git a/pkg/domain/infra/abi/manifest.go b/pkg/domain/infra/abi/manifest.go index 65037ce5cc..db638e1ed9 100644 --- a/pkg/domain/infra/abi/manifest.go +++ b/pkg/domain/infra/abi/manifest.go @@ -6,12 +6,14 @@ import ( "encoding/json" "fmt" "os" + "path" "strings" "errors" "github.com/containers/common/libimage" cp "github.com/containers/image/v5/copy" + "github.com/containers/image/v5/docker" "github.com/containers/image/v5/manifest" "github.com/containers/image/v5/pkg/compression" "github.com/containers/image/v5/pkg/shortnames" @@ -24,6 +26,7 @@ import ( "github.com/opencontainers/go-digest" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" ) // ManifestCreate implements logic for creating manifest lists via ImageEngine @@ -45,6 +48,14 @@ func (ir *ImageEngine) ManifestCreate(ctx context.Context, name string, images [ } } + annotateOptions := &libimage.ManifestListAnnotateOptions{} + if len(opts.Annotations) != 0 { + annotateOptions.IndexAnnotations = opts.Annotations + if err := manifestList.AnnotateInstance("", annotateOptions); err != nil { + return "", err + } + } + addOptions := &libimage.ManifestListAddOptions{All: opts.All} for _, image := range images { if _, err := manifestList.Add(ctx, image, addOptions); err != nil { @@ -214,6 +225,13 @@ func (ir *ImageEngine) ManifestAdd(ctx context.Context, name string, images []st Password: opts.Password, } + images = slices.Clone(images) + for _, image := range opts.Images { + if !slices.Contains(images, image) { + images = append(images, image) + } + } + for _, image := range images { instanceDigest, err := manifestList.Add(ctx, image, addOptions) if err != nil { @@ -226,6 +244,7 @@ func (ir *ImageEngine) ManifestAdd(ctx context.Context, name string, images []st OS: opts.OS, OSVersion: opts.OSVersion, Variant: opts.Variant, + Subject: opts.IndexSubject, } if len(opts.Annotation) != 0 { annotations := make(map[string]string) @@ -247,13 +266,26 @@ func (ir *ImageEngine) ManifestAdd(ctx context.Context, name string, images []st return manifestList.ID(), nil } +func mergeAnnotations(preferred map[string]string, aux []string) (map[string]string, error) { + if len(aux) != 0 { + auxAnnotations := make(map[string]string) + for _, annotationSpec := range aux { + key, val, hasVal := strings.Cut(annotationSpec, "=") + if !hasVal { + return nil, fmt.Errorf("no value given for annotation %q", key) + } + auxAnnotations[key] = val + } + if preferred == nil { + preferred = make(map[string]string) + } + preferred = envLib.Join(auxAnnotations, preferred) + } + return preferred, nil +} + // ManifestAnnotate updates an entry of the manifest list func (ir *ImageEngine) ManifestAnnotate(ctx context.Context, name, image string, opts entities.ManifestAnnotateOptions) (string, error) { - instanceDigest, err := digest.Parse(image) - if err != nil { - return "", fmt.Errorf(`invalid image digest "%s": %v`, image, err) - } - manifestList, err := ir.Libpod.LibimageRuntime().LookupManifestList(name) if err != nil { return "", err @@ -265,19 +297,56 @@ func (ir *ImageEngine) ManifestAnnotate(ctx context.Context, name, image string, OS: opts.OS, OSVersion: opts.OSVersion, Variant: opts.Variant, + Subject: opts.IndexSubject, } - if len(opts.Annotation) != 0 { - annotations := make(map[string]string) - for _, annotationSpec := range opts.Annotation { - key, val, hasVal := strings.Cut(annotationSpec, "=") - if !hasVal { - return "", fmt.Errorf("no value given for annotation %q", key) - } - annotations[key] = val + if annotateOptions.Annotations, err = mergeAnnotations(opts.Annotations, opts.Annotation); err != nil { + return "", err + } + if annotateOptions.IndexAnnotations, err = mergeAnnotations(opts.IndexAnnotations, opts.IndexAnnotation); err != nil { + return "", err + } + + var instanceDigest digest.Digest + if image == "" { + if len(opts.Annotations) != 0 { + return "", errors.New("setting annotation on an item in a manifest list requires an instance digest") + } + if len(opts.Annotation) != 0 { + return "", errors.New("setting annotation on an item in a manifest list requires an instance digest") + } + if opts.Arch != "" { + return "", errors.New("setting architecture on an item in a manifest list requires an instance digest") + } + if len(opts.Features) != 0 { + return "", errors.New("setting features on an item in a manifest list requires an instance digest") + } + if opts.OS != "" { + return "", errors.New("setting OS on an item in a manifest list requires an instance digest") + } + if len(opts.OSFeatures) != 0 { + return "", errors.New("setting OS features on an item in a manifest list requires an instance digest") + } + if opts.OSVersion != "" { + return "", errors.New("setting OS version on an item in a manifest list requires an instance digest") + } + if opts.Variant != "" { + return "", errors.New("setting variant on an item in a manifest list requires an instance digest") + } + } else { + if len(opts.IndexAnnotations) != 0 { + return "", errors.New("setting index-wide annotation in a manifest list requires no instance digest") + } + if len(opts.IndexAnnotation) != 0 { + return "", errors.New("setting index-wide annotation in a manifest list requires no instance digest") + } + if len(opts.IndexSubject) != 0 { + return "", errors.New("setting subject for a manifest list requires no instance digest") + } + instanceDigest, err = ir.digestFromDigestOrManifestListMember(ctx, manifestList, image) + if err != nil { + return "", fmt.Errorf("finding instance for %q: %w", image, err) } - opts.Annotations = envLib.Join(opts.Annotations, annotations) } - annotateOptions.Annotations = opts.Annotations if err := manifestList.AnnotateInstance(instanceDigest, annotateOptions); err != nil { return "", err @@ -286,6 +355,108 @@ func (ir *ImageEngine) ManifestAnnotate(ctx context.Context, name, image string, return manifestList.ID(), nil } +// ManifestAddArtifact creates artifact manifest for files and adds them to the manifest list +func (ir *ImageEngine) ManifestAddArtifact(ctx context.Context, name string, files []string, opts entities.ManifestAddArtifactOptions) (string, error) { + if len(files) < 1 { + return "", errors.New("manifest add artifact requires at least one file") + } + + manifestList, err := ir.Libpod.LibimageRuntime().LookupManifestList(name) + if err != nil { + return "", err + } + + files = slices.Clone(files) + for _, file := range opts.Files { + if !slices.Contains(files, file) { + files = append(files, file) + } + } + + addArtifactOptions := &libimage.ManifestListAddArtifactOptions{ + Type: opts.Type, + ConfigType: opts.ConfigType, + Config: opts.Config, + LayerType: opts.LayerType, + ExcludeTitles: opts.ExcludeTitles, + Annotations: opts.Annotations, + Subject: opts.Subject, + } + + instanceDigest, err := manifestList.AddArtifact(ctx, addArtifactOptions, files...) + if err != nil { + return "", err + } + + annotateOptions := &libimage.ManifestListAnnotateOptions{ + Architecture: opts.Arch, + Features: opts.Features, + OS: opts.OS, + OSVersion: opts.OSVersion, + Variant: opts.Variant, + Subject: opts.IndexSubject, + } + if annotateOptions.Annotations, err = mergeAnnotations(opts.Annotations, opts.Annotation); err != nil { + return "", err + } + if annotateOptions.IndexAnnotations, err = mergeAnnotations(opts.IndexAnnotations, opts.IndexAnnotation); err != nil { + return "", err + } + + if err := manifestList.AnnotateInstance(instanceDigest, annotateOptions); err != nil { + return "", err + } + + return manifestList.ID(), nil +} + +func (ir *ImageEngine) digestFromDigestOrManifestListMember(ctx context.Context, list *libimage.ManifestList, name string) (digest.Digest, error) { + instanceDigest, err := digest.Parse(name) + if err == nil { + return instanceDigest, nil + } + listData, inspectErr := list.Inspect() + if inspectErr != nil { + return "", fmt.Errorf(`inspecting list "%s" for instance list: %v`, list.ID(), err) + } + // maybe the name is a file name we previously attached as part of an artifact manifest + for _, descriptor := range listData.Manifests { + if slices.Contains(descriptor.Files, path.Base(name)) || slices.Contains(descriptor.Files, name) { + return descriptor.Digest, nil + } + } + // maybe it's the name of an image we added to the list? + ref, err := alltransports.ParseImageName(name) + if err != nil { + withDocker := fmt.Sprintf("%s://%s", docker.Transport.Name(), name) + ref, err = alltransports.ParseImageName(withDocker) + if err != nil { + image, _, err := ir.Libpod.LibimageRuntime().LookupImage(name, &libimage.LookupImageOptions{ManifestList: true}) + if err != nil { + return "", fmt.Errorf("locating image named %q to check if it's in the manifest list: %w", name, err) + } + if ref, err = image.StorageReference(); err != nil { + return "", fmt.Errorf("reading image reference %q to check if it's in the manifest list: %w", name, err) + } + } + } + // read the manifest of this image + src, err := ref.NewImageSource(ctx, ir.Libpod.SystemContext()) + if err != nil { + return "", fmt.Errorf("reading local image %q to check if it's in the manifest list: %w", name, err) + } + defer src.Close() + manifestBytes, _, err := src.GetManifest(ctx, nil) + if err != nil { + return "", fmt.Errorf("locating image named %q to check if it's in the manifest list: %w", name, err) + } + refDigest, err := manifest.Digest(manifestBytes) + if err != nil { + return "", fmt.Errorf("digesting manifest of local image %q: %w", name, err) + } + return refDigest, nil +} + // ManifestRemoveDigest removes specified digest from the specified manifest list func (ir *ImageEngine) ManifestRemoveDigest(ctx context.Context, name, image string) (string, error) { instanceDigest, err := digest.Parse(image) diff --git a/pkg/domain/infra/tunnel/manifest.go b/pkg/domain/infra/tunnel/manifest.go index cdbfd63fc0..e042364290 100644 --- a/pkg/domain/infra/tunnel/manifest.go +++ b/pkg/domain/infra/tunnel/manifest.go @@ -11,11 +11,12 @@ import ( "github.com/containers/podman/v5/pkg/bindings/manifests" "github.com/containers/podman/v5/pkg/domain/entities" envLib "github.com/containers/podman/v5/pkg/env" + "golang.org/x/exp/slices" ) // ManifestCreate implements manifest create via ImageEngine func (ir *ImageEngine) ManifestCreate(ctx context.Context, name string, images []string, opts entities.ManifestCreateOptions) (string, error) { - options := new(manifests.CreateOptions).WithAll(opts.All).WithAmend(opts.Amend) + options := new(manifests.CreateOptions).WithAll(opts.All).WithAmend(opts.Amend).WithAnnotation(opts.Annotations) imageID, err := manifests.Create(ir.ClientCtx, name, images, options) if err != nil { return imageID, fmt.Errorf("creating manifest: %w", err) @@ -57,6 +58,13 @@ func (ir *ImageEngine) ManifestInspect(ctx context.Context, name string, opts en // ManifestAdd adds images to the manifest list func (ir *ImageEngine) ManifestAdd(_ context.Context, name string, imageNames []string, opts entities.ManifestAddOptions) (string, error) { + imageNames = slices.Clone(imageNames) + for _, image := range opts.Images { + if !slices.Contains(imageNames, image) { + imageNames = append(imageNames, image) + } + } + options := new(manifests.AddOptions).WithAll(opts.All).WithArch(opts.Arch).WithVariant(opts.Variant) options.WithFeatures(opts.Features).WithImages(imageNames).WithOS(opts.OS).WithOSVersion(opts.OSVersion) options.WithUsername(opts.Username).WithPassword(opts.Password).WithAuthfile(opts.Authfile) @@ -89,6 +97,39 @@ func (ir *ImageEngine) ManifestAdd(_ context.Context, name string, imageNames [] return id, nil } +// ManifestAddArtifact creates artifact manifests and adds them to the manifest list +func (ir *ImageEngine) ManifestAddArtifact(_ context.Context, name string, files []string, opts entities.ManifestAddArtifactOptions) (string, error) { + files = slices.Clone(files) + for _, file := range opts.Files { + if !slices.Contains(files, file) { + files = append(files, file) + } + } + options := new(manifests.AddArtifactOptions).WithArch(opts.Arch).WithVariant(opts.Variant) + options.WithFeatures(opts.Features).WithOS(opts.OS).WithOSVersion(opts.OSVersion).WithOSFeatures(opts.OSFeatures) + if len(opts.Annotation) != 0 { + annotations := make(map[string]string) + for _, annotationSpec := range opts.Annotation { + key, val, hasVal := strings.Cut(annotationSpec, "=") + if !hasVal { + return "", fmt.Errorf("no value given for annotation %q", key) + } + annotations[key] = val + } + options.WithAnnotation(annotations) + } + options.WithType(opts.Type).WithConfigType(opts.ConfigType).WithLayerType(opts.LayerType) + options.WithConfig(opts.Config) + options.WithExcludeTitles(opts.ExcludeTitles).WithSubject(opts.Subject) + options.WithAnnotations(opts.Annotations) + options.WithFiles(files) + id, err := manifests.AddArtifact(ir.ClientCtx, name, options) + if err != nil { + return id, fmt.Errorf("adding to manifest list %s: %w", name, err) + } + return id, nil +} + // ManifestAnnotate updates an entry of the manifest list func (ir *ImageEngine) ManifestAnnotate(ctx context.Context, name, images string, opts entities.ManifestAnnotateOptions) (string, error) { options := new(manifests.ModifyOptions).WithArch(opts.Arch).WithVariant(opts.Variant) diff --git a/test/apiv2/15-manifest.at b/test/apiv2/15-manifest.at index 8b43740e5a..44f3c7c83e 100644 --- a/test/apiv2/15-manifest.at +++ b/test/apiv2/15-manifest.at @@ -3,6 +3,7 @@ # Tests for manifest list endpoints start_registry +export REGISTRY_PORT # Creates the manifest list t POST /v3.4.0/libpod/manifests/create?name=abc 200 \ @@ -28,7 +29,7 @@ RUN >file2 EOF ) -# manifest add --anotation tests +# manifest add --annotation tests t POST /v3.4.0/libpod/manifests/$id_abc/add images="[\"containers-storage:$id_abc_image\"]" 200 t PUT /v4.0.0/libpod/manifests/$id_xyz operation='update' images="[\"containers-storage:$id_xyz_image\"]" annotations="{\"foo\":\"bar\"}" annotation="[\"hoge=fuga\"]" 400 \ .cause='can not set both Annotation and Annotations' @@ -66,5 +67,130 @@ t POST "/v4.0.0/libpod/manifests/xyz:latest/registry/localhost:$REGISTRY_PORT%2F t DELETE /v4.0.0/libpod/manifests/$id_abc 200 t DELETE /v4.0.0/libpod/manifests/$id_xyz 200 +# manifest add --artifact tests +truncate -s 20M $WORKDIR/zeroes +function test_artifacts_with_args() { + # these values, ideally, are local to our caller + local args="$artifact_annotations $artifact_config $artifact_config_type $artifact_exclude_titles $artifact_layer_type $artifact_type" + t POST /v5.0.0/libpod/manifests/artifacts 201 + local id_artifacts=$(jq -r '.Id' <<<"$output") + t PUT /v5.0.0/libpod/manifests/$id_artifacts operation='update' $args --form=listed.txt="oh yeah" --form=zeroes=@"$WORKDIR/zeroes" 200 + t POST "/v5.0.0/libpod/manifests/artifacts:latest/registry/localhost:$REGISTRY_PORT%2Fartifacts:latest?tlsVerify=false&all=true" 200 + + local index=$(skopeo inspect --raw --tls-verify=false docker://localhost:$REGISTRY_PORT/artifacts:latest) + # jq <<<"$index" + local digest=$(jq -r '.manifests[0].digest' <<<"$index") + local artifact=$(skopeo inspect --raw --tls-verify=false docker://localhost:$REGISTRY_PORT/artifacts@${digest}) + # jq <<<"$artifact" + + local expect_type + case ${artifact_type} in + artifact_type=*) + expect_type=${artifact_type#artifact_type=} + expect_type=${expect_type:-null};; + *) + expect_type=application/vnd.unknown.artifact.v1;; + esac + is $(jq -r '.artifactType'<<<"$artifact") "${expect_type}" "artifactType in artifact manifest with artifact_type arg \"${artifact_type}\"" + is $(jq -r '.manifests[0].artifactType'<<<"$index") "${expect_type}" "artifactType in image index with artifact_type arg \"${artifact_type}\"" + + local expect_annotations + case ${artifact_annotations} in + artifact_annotations=*) + expect_annotations=$(jq -r '.foo' <<<"${artifact_annotations#artifact_annotations=}");; + *) + expect_annotations=null;; + esac + is $(jq -r '.annotations["foo"]'<<<"$artifact") "$expect_annotations" "\"foo\" annotation in artifact manifest with artifact_annotations arg \"${artifact_annotations}\"" + + local expect_config_size + case ${artifact_config} in + artifact_config=*) + expect_config_size=$(wc -c <<<"${artifact_config#artifact_config=}") + expect_config_size=$((expect_config_size-1)) + if [[ $expect_config_size -eq 0 ]]; then + expect_config_size=2 + fi ;; + *) + expect_config_size=2;; + esac + is $(jq -r '.config.size'<<<"$artifact") "$expect_config_size" "size of config blob in artifact manifest with artifact_config arg \"${artifact_config}\"" + + local expect_config_type + case ${artifact_config_type} in + artifact_config_type=*) + expect_config_type=${artifact_config_type#artifact_config_type=} + if [[ -z "$expect_config_type" ]] ; then + if [[ -n "${artifact_config#artifact_config=}" ]] ; then + expect_config_type=application/vnd.oci.image.config.v1+json + else + expect_config_type=application/vnd.oci.empty.v1+json + fi + fi;; + *) + if [[ -n "${artifact_config#artifact_config=}" ]] ; then + expect_config_type=application/vnd.oci.image.config.v1+json + else + expect_config_type=application/vnd.oci.empty.v1+json + fi;; + esac + is $(jq -r '.config.mediaType'<<<"$artifact") "$expect_config_type" "mediaType of config blob in artifact manifest with artifact_config_type arg \"${artifact_config_type}\" and artifact_config arg \"${artifact_config}\"" + + local expect_first_layer_type expect_second_layer_type + case ${artifact_layer_type} in + artifact_layer_type=*) + expect_first_layer_type=${artifact_layer_type#artifact_layer_type=} + expect_first_layer_type=${expect_first_layer_type:-text/plain} + expect_second_layer_type=${artifact_layer_type#artifact_layer_type=} + expect_second_layer_type=${expect_second_layer_type:-application/octet-stream};; + *) + expect_first_layer_type=text/plain; + expect_second_layer_type=application/octet-stream;; + esac + is $(jq -r '.layers[0].mediaType'<<<"$artifact") "$expect_first_layer_type" "mediaType of listed.txt layer in artifact manifest with artifact_layer_type arg \"${artifact_layer_type}\"" + is $(jq -r '.layers[1].mediaType'<<<"$artifact") "$expect_second_layer_type" "mediaType of zeroes layer in artifact manifest with artifact_layer_type arg \"${artifact_layer_type}\"" + + local expect_first_title expect_second_title + case ${artifact_exclude_titles} in + artifact_exclude_titles=true) + expect_first_title=null; + expect_second_title=null;; + *) + expect_first_title=listed.txt; + expect_second_title=zeroes;; + esac + is $(jq -r '.layers[0].annotations["org.opencontainers.image.title"]'<<<"$artifact") "$expect_first_title" "org.opencontainers.image.title annotation on listed.txt layer in artifact manifest with artifact_exclude_titles arg \"${artifact_exclude_titles}\"" + is $(jq -r '.layers[1].annotations["org.opencontainers.image.title"]'<<<"$artifact") "$expect_second_title" "org.opencontainers.image.title annotation on zeroes layer in artifact manifest with artifact_exclude_titles arg \"${artifact_exclude_titles}\"" + + t DELETE /v5.0.0/libpod/manifests/$id_artifacts 200 +} + +function test_artifacts() { + local artifact_annotations + local artifact_config + local artifact_config_type + local artifact_exclude_titles + local artifact_layer_type + local artifact_type + for artifact_annotations in "" artifact_annotations='{"foo":"bar"}' ; do + test_artifacts_with_args + done + for artifact_config in "" artifact_config= artifact_config="{}"; do + for artifact_config_type in "" artifact_config_type= artifact_config_type=text/plain ; do + test_artifacts_with_args + done + done + for artifact_exclude_titles in "" artifact_exclude_titles=true ; do + test_artifacts_with_args + done + for artifact_layer_type in "" artifact_layer_type= artifact_layer_type=text/plain artifact_layer_type=application/octet-stream ; do + test_artifacts_with_args + done + for artifact_type in "" artifact_type= artifact_type=text/plain artifact_type=application/octet-stream ; do + test_artifacts_with_args + done +} +test_artifacts + podman rmi -a stop_registry diff --git a/test/apiv2/26-containersWait.at b/test/apiv2/26-containersWait.at index 3bd4165d06..81ba304a63 100644 --- a/test/apiv2/26-containersWait.at +++ b/test/apiv2/26-containersWait.at @@ -33,7 +33,7 @@ wait "${child_pid}" APIV2_TEST_EXPECT_TIMEOUT=2 t POST "containers/${CTR}/wait?condition=next-exit" 999 like "$(<$WORKDIR/curl.headers.out)" ".*HTTP.* 200 OK.*" \ "Received headers from /wait" -if [[ -e $WORKDIR/curl.result.out ]]; then +if [[ -s $WORKDIR/curl.result.out ]]; then _show_ok 0 "UNEXPECTED: curl on /wait returned results" fi diff --git a/test/apiv2/test-apiv2 b/test/apiv2/test-apiv2 index c4d04d6d4a..08377c6f5e 100755 --- a/test/apiv2/test-apiv2 +++ b/test/apiv2/test-apiv2 @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Usage: test-apiv2 [PORT] +# Usage: test-apiv2 testglob # # DEVELOPER NOTE: you almost certainly don't need to play in here. See README. # @@ -238,17 +238,17 @@ function jsonify() { function t() { local method=$1; shift local path=$1; shift - local -a curl_args + local -a curl_args form_args local content_type="application/json" local testname="$method $path" # POST and PUT requests may be followed by one or more key=value pairs. # Slurp the command line until we see a 3-digit status code. - if [[ $method = "POST" || $method == "PUT" || $method = "DELETE" ]]; then + if [[ $method = "POST" || $method == "PUT" || $method == "DELETE" ]]; then local -a post_args - if [[ $method = "POST" ]]; then + if [[ $method == "POST" ]]; then function _add_curl_args() { curl_args+=(--data-binary @$1); } else function _add_curl_args() { curl_args+=(--upload-file $1); } @@ -260,6 +260,10 @@ function t() { # --disable makes curl not lookup the curlrc file, it shouldn't affect the tests in any way. -) curl_args+=(--disable); shift;; + --form=*) form_args+=(--form); + form_args+=("${arg#--form=}"); + content_type="multipart/form-data"; + shift;; *=*) post_args+=("$arg"); shift;; *.json) _add_curl_args $arg; @@ -276,9 +280,12 @@ function t() { *) die "Internal error: invalid POST arg '$arg'" ;; esac done - if [[ -z "$curl_args" ]]; then + if [[ -z "${curl_args[*]}" && -z "${form_args[*]}" ]]; then curl_args=(-d $(jsonify ${post_args[*]})) testname="$testname [${curl_args[*]}]" + elif [[ -z "${curl_args[*]}" ]]; then + curl_args=(--form request.json=$(jsonify ${post_args[*]}) "${form_args[@]}") + testname="$testname [${curl_args[*]} ${form_args[*]}]" fi fi @@ -319,14 +326,29 @@ function t() { echo "-------------------------------------------------------------" >>$LOG echo "\$ $testname" >>$LOG rm -f $WORKDIR/curl.* - # -s = silent, but --write-out 'format' gives us important response data + # The --write-out 'format' gives us important response data. # The hairy "{ ...;rc=$?; } || :" lets us capture curl's exit code and # give a helpful diagnostic if it fails. - { response=$(curl -s -X $method "${curl_args[@]}" \ + : > $WORKDIR/curl.result.out + : > $WORKDIR/curl.result.err + { response=$(curl -X $method "${curl_args[@]}" \ -H "Content-type: $content_type" \ --dump-header $WORKDIR/curl.headers.out \ + -v --stderr $WORKDIR/curl.result.err \ --write-out '%{http_code}^%{content_type}^%{time_total}' \ -o $WORKDIR/curl.result.out "$url"); rc=$?; } || : + if [ -n "$PODMAN_TESTS_DUMP_TRACES" ]; then + # Dump the results we got back, exactly as we got them back. + echo "\$ begin stdout" >>$LOG + test -s $WORKDIR/curl.result.out && od -t x1c $WORKDIR/curl.result.out 2>&1 >>$LOG + echo "\$ end stdout" >>$LOG + echo "\$ begin stderr" >>$LOG + test -s $WORKDIR/curl.result.err && cat $WORKDIR/curl.result.err >>$LOG + echo "\$ end stderr" >>$LOG + echo "\$ begin response code^content_type^time_total" >>$LOG + od -t x1c <<< "$response" >>$LOG + echo "\$ end response" >>$LOG + fi # Special case: this means we *expect and want* a timeout if [[ -n "$APIV2_TEST_EXPECT_TIMEOUT" ]]; then diff --git a/test/system/012-manifest.bats b/test/system/012-manifest.bats index 30e16e9423..8e1a507bcb 100644 --- a/test/system/012-manifest.bats +++ b/test/system/012-manifest.bats @@ -150,4 +150,134 @@ EOF run_podman image prune -f } +function manifestListAddArtifactOnce() { + echo listFlags="$listFlags" + echo platformFlags="$platformFlags" + echo typeFlag="$typeFlag" + echo layerTypeFlag="$layerTypeFlag" + echo configTypeFlag="$configTypeFlag" + echo configFlag="$configFlag" + echo titleFlag="$titleFlag" + local index artifact firstdigest seconddigest config configSize defaulttype filetitle requested expected actual + run_podman manifest create $listFlags $list + run_podman manifest add $list ${platformFlags} --artifact ${typeFlag} ${layerTypeFlag} ${configTypeFlag} ${configFlag} ${titleFlag} ${PODMAN_TMPDIR}/listed.txt + run_podman manifest add $list ${platformFlags} --artifact ${typeFlag} ${layerTypeFlag} ${configTypeFlag} ${configFlag} ${titleFlag} ${PODMAN_TMPDIR}/zeroes + run_podman manifest inspect $list + run_podman tag $list localhost:${PODMAN_LOGIN_REGISTRY_PORT}/test + run_podman manifest push --tls-verify=false localhost:${PODMAN_LOGIN_REGISTRY_PORT}/test + run skopeo inspect --tls-verify=false --raw docker://localhost:${PODMAN_LOGIN_REGISTRY_PORT}/test + assert $status -eq 0 + echo "$output" + index="$output" + if [[ -n "$listFlags" ]] ; then + assert $(jq -r '.annotations["global"]' <<<"$index") == local + fi + if [[ -n "$platformFlags" ]] ; then + assert $(jq -r '.manifests[1].platform.os' <<<"$index") == linux + assert $(jq -r '.manifests[1].platform.architecture' <<<"$index") == amd64 + fi + if [[ -n "$typeFlag" ]] ; then + actual=$(jq -r '.manifests[0].artifactType' <<<"$index") + assert "${actual#null}" == "${typeFlag#--artifact-type=}" + actual=$(jq -r '.manifests[1].artifactType' <<<"$index") + assert "${actual#null}" == "${typeFlag#--artifact-type=}" + fi + firstdigest=$(jq -r '.manifests[0].digest' <<<"$index") + seconddigest=$(jq -r '.manifests[1].digest' <<<"$index") + for digest in $firstdigest $seconddigest ; do + case $digest in + $firstdigest) + filetitle=listed.txt + defaulttype=text/plain + ;; + $seconddigest) + filetitle=zeroes + defaulttype=application/octet-stream + ;; + *) + false + ;; + esac + run skopeo inspect --raw --tls-verify=false docker://localhost:${PODMAN_LOGIN_REGISTRY_PORT}/test@${digest} + assert $status -eq 0 + echo "$output" + artifact="$output" + if [[ -n "$typeFlag" ]] ; then + actual=$(jq -r '.artifactType' <<<"$artifact") + assert "${actual#null}" == "${typeFlag#--artifact-type=}" + else + actual=$(jq -r '.artifactType' <<<"$artifact") + assert "${actual}" == application/vnd.unknown.artifact.v1 + fi + if [ -n "$layerTypeFlag" ] ; then + actual=$(jq -r '.layers[0].mediaType' <<<"$artifact") + assert "${actual}" == "${layerTypeFlag#--artifact-layer-type=}" + else + actual=$(jq -r '.layers[0].mediaType' <<<"$artifact") + assert "${actual}" == "$defaulttype" + fi + requested=${configTypeFlag#--artifact-config-type=} + actual=$(jq -r '.config.mediaType' <<<"$artifact") + if test -n "$requested" ; then + assert "$actual" == "$requested" + else + config=${configFlag#--artifact-config=} + if [ -z "$config" ] ; then + expected=application/vnd.oci.empty.v1+json + else + configSize=$(wc -c <"$config") + if [ $configSize -gt 0 ] ; then + expected=application/vnd.oci.image.config.v1+json + else + expected=application/vnd.oci.empty.v1+json + fi + fi + assert "$actual" == "$expected" + fi + if test -n "$titleFlag" ; then + assert $(jq -r '.layers[0].annotations["org.opencontainers.image.title"]' <<<"$artifact") == null + else + assert $(jq -r '.layers[0].annotations["org.opencontainers.image.title"]' <<<"$artifact") == $filetitle + fi + done + run_podman rmi $list localhost:${PODMAN_LOGIN_REGISTRY_PORT}/test +} + +@test "manifest list --add --artifact" { + # Build a list and add some files to it, making sure to exercise and verify + # every flag available. + skip_if_remote "running a local registry doesn't work with podman-remote" + start_registry + run_podman login --tls-verify=false \ + --username ${PODMAN_LOGIN_USER} \ + --password-stdin \ + --authfile=$authfile \ + localhost:${PODMAN_LOGIN_REGISTRY_PORT} <<<"${PODMAN_LOGIN_PASS}" + local list="test:1.0" + truncate -s 20M ${PODMAN_TMPDIR}/zeroes + echo oh yeah > ${PODMAN_TMPDIR}/listed.txt + echo '{}' > ${PODMAN_TMPDIR}/minimum-config.json + local listFlags platformFlags typeFlag configTypeFlag configFlag layerTypeFlag titleFlag + for listFlags in "" "--annotation global=local" ; do + manifestListAddArtifactOnce + done + for platformFlags in "" "--os=linux --arch=amd64" ; do + manifestListAddArtifactOnce + done + for typeFlag in "" --artifact-type="" --artifact-type=application/octet-stream --artifact-type=text/plain ; do + manifestListAddArtifactOnce + done + for configTypeFlag in "" --artifact-config-type=application/octet-stream --artifact-config-type=text/plain ; do + for configFlag in "" --artifact-config= --artifact-config=${PODMAN_TMPDIR}/minimum-config.json ; do + manifestListAddArtifactOnce + done + done + for layerTypeFlag in "" --artifact-layer-type=application/octet-stream --artifact-layer-type=text/plain ; do + manifestListAddArtifactOnce + done + for titleFlag in "" "--artifact-exclude-titles" ; do + manifestListAddArtifactOnce + done + stop_registry +} # vim: filetype=sh