diff --git a/cmd/podman/artifact/add.go b/cmd/podman/artifact/add.go index 899a35440d..98211547ad 100644 --- a/cmd/podman/artifact/add.go +++ b/cmd/podman/artifact/add.go @@ -2,6 +2,7 @@ package artifact import ( "fmt" + "path/filepath" "github.com/containers/common/pkg/completion" "github.com/containers/podman/v5/cmd/podman/common" @@ -61,6 +62,8 @@ func init() { } func add(cmd *cobra.Command, args []string) error { + artifactName := args[0] + blobs := args[1:] opts := new(entities.ArtifactAddOptions) annots, err := utils.ParseAnnotations(addOpts.Annotations) @@ -72,7 +75,18 @@ func add(cmd *cobra.Command, args []string) error { opts.Append = addOpts.Append opts.FileType = addOpts.FileType - report, err := registry.ImageEngine().ArtifactAdd(registry.Context(), args[0], args[1:], opts) + artifactBlobs := make([]entities.ArtifactBlob, 0, len(blobs)) + + for _, blobPath := range blobs { + artifactBlob := entities.ArtifactBlob{ + BlobFilePath: blobPath, + FileName: filepath.Base(blobPath), + } + + artifactBlobs = append(artifactBlobs, artifactBlob) + } + + report, err := registry.ImageEngine().ArtifactAdd(registry.Context(), artifactName, artifactBlobs, opts) if err != nil { return err } diff --git a/pkg/api/handlers/libpod/artifacts.go b/pkg/api/handlers/libpod/artifacts.go new file mode 100644 index 0000000000..9d0d52cef1 --- /dev/null +++ b/pkg/api/handlers/libpod/artifacts.go @@ -0,0 +1,336 @@ +//go:build !remote + +package libpod + +import ( + "errors" + "fmt" + "net/http" + + "github.com/containers/image/v5/oci/layout" + "github.com/containers/image/v5/types" + "github.com/containers/podman/v5/libpod" + "github.com/containers/podman/v5/pkg/api/handlers/utils" + api "github.com/containers/podman/v5/pkg/api/types" + "github.com/containers/podman/v5/pkg/auth" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/containers/podman/v5/pkg/domain/infra/abi" + domain_utils "github.com/containers/podman/v5/pkg/domain/utils" + libartifact_types "github.com/containers/podman/v5/pkg/libartifact/types" + "github.com/docker/distribution/registry/api/errcode" + "github.com/gorilla/schema" +) + +func InspectArtifact(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + + name := utils.GetName(r) + + imageEngine := abi.ImageEngine{Libpod: runtime} + + report, err := imageEngine.ArtifactInspect(r.Context(), name, entities.ArtifactInspectOptions{}) + if err != nil { + if errors.Is(err, libartifact_types.ErrArtifactNotExist) { + utils.ArtifactNotFound(w, name, err) + return + } else { + utils.InternalServerError(w, err) + return + } + } + + utils.WriteResponse(w, http.StatusOK, report) +} + +func ListArtifact(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + + imageEngine := abi.ImageEngine{Libpod: runtime} + + artifacts, err := imageEngine.ArtifactList(r.Context(), entities.ArtifactListOptions{}) + if err != nil { + utils.InternalServerError(w, err) + return + } + + utils.WriteResponse(w, http.StatusOK, artifacts) +} + +func PullArtifact(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"` + Retry uint `schema:"retry"` + RetryDelay string `schema:"retryDelay"` + TLSVerify types.OptionalBool `schema:"tlsVerify"` + }{} + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err)) + return + } + + if query.Name == "" { + utils.Error(w, http.StatusBadRequest, errors.New("name parameter is required")) + return + } + + artifactsPullOptions := entities.ArtifactPullOptions{} + + // If TLS verification is explicitly specified (True or False) in the query, + // set the InsecureSkipTLSVerify option accordingly. + // If TLSVerify was not set in the query, OptionalBoolUndefined is used and + // handled later based off the target registry configuration. + switch query.TLSVerify { + case types.OptionalBoolTrue: + artifactsPullOptions.InsecureSkipTLSVerify = types.NewOptionalBool(false) + case types.OptionalBoolFalse: + artifactsPullOptions.InsecureSkipTLSVerify = types.NewOptionalBool(true) + case types.OptionalBoolUndefined: + // If the user doesn't define TLSVerify in the query, do nothing and pass + // it to the backend code to handle. + default: // Should never happen + panic("Unexpected handling occurred for TLSVerify") + } + + if _, found := r.URL.Query()["retry"]; found { + artifactsPullOptions.MaxRetries = &query.Retry + } + + if len(query.RetryDelay) != 0 { + artifactsPullOptions.RetryDelay = query.RetryDelay + } + + authConf, authfile, err := auth.GetCredentials(r) + if err != nil { + utils.Error(w, http.StatusBadRequest, err) + return + } + defer auth.RemoveAuthfile(authfile) + + artifactsPullOptions.AuthFilePath = authfile + if authConf != nil { + artifactsPullOptions.Username = authConf.Username + artifactsPullOptions.Password = authConf.Password + artifactsPullOptions.IdentityToken = authConf.IdentityToken + } + + imageEngine := abi.ImageEngine{Libpod: runtime} + + artifacts, err := imageEngine.ArtifactPull(r.Context(), query.Name, artifactsPullOptions) + if err != nil { + var errcd errcode.ErrorCoder + // Check to see if any of the wrapped errors is an errcode.ErrorCoder returned from the registry + if errors.As(err, &errcd) { + rc := errcd.ErrorCode().Descriptor().HTTPStatusCode + // Check if the returned error is 401 StatusUnauthorized indicating the request was unauthorized + if rc == http.StatusUnauthorized { + utils.Error(w, http.StatusUnauthorized, errcd.ErrorCode()) + return + } + // Check if the returned error is 404 StatusNotFound indicating the artifact was not found + if rc == http.StatusNotFound { + utils.Error(w, http.StatusNotFound, errcd.ErrorCode()) + return + } + } + utils.InternalServerError(w, err) + return + } + + utils.WriteResponse(w, http.StatusOK, artifacts) +} + +func RemoveArtifact(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + imageEngine := abi.ImageEngine{Libpod: runtime} + + name := utils.GetName(r) + + artifacts, err := imageEngine.ArtifactRm(r.Context(), name, entities.ArtifactRemoveOptions{}) + if err != nil { + if errors.Is(err, libartifact_types.ErrArtifactNotExist) { + utils.ArtifactNotFound(w, name, err) + return + } + utils.InternalServerError(w, err) + return + } + + utils.WriteResponse(w, http.StatusOK, artifacts) +} + +func AddArtifact(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"` + FileName string `schema:"fileName"` + FileMIMEType string `schema:"fileMIMEType"` + Annotations []string `schema:"annotations"` + ArtifactMIMEType string `schema:"artifactMIMEType"` + Append bool `schema:"append"` + }{} + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err)) + return + } + + if query.Name == "" || query.FileName == "" { + utils.Error(w, http.StatusBadRequest, errors.New("name and file parameters are required")) + return + } + + annotations, err := domain_utils.ParseAnnotations(query.Annotations) + if err != nil { + utils.Error(w, http.StatusBadRequest, err) + return + } + + artifactAddOptions := &entities.ArtifactAddOptions{ + Append: query.Append, + Annotations: annotations, + ArtifactType: query.ArtifactMIMEType, + FileType: query.FileMIMEType, + } + + artifactBlobs := []entities.ArtifactBlob{{ + BlobReader: r.Body, + FileName: query.FileName, + }} + + imageEngine := abi.ImageEngine{Libpod: runtime} + + artifacts, err := imageEngine.ArtifactAdd(r.Context(), query.Name, artifactBlobs, artifactAddOptions) + if err != nil { + if errors.Is(err, libartifact_types.ErrArtifactNotExist) { + utils.ArtifactNotFound(w, query.Name, err) + return + } + utils.InternalServerError(w, err) + return + } + + utils.WriteResponse(w, http.StatusCreated, artifacts) +} + +func PushArtifact(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) + + query := struct { + Retry uint `schema:"retry"` + RetryDelay string `schema:"retrydelay"` + TLSVerify types.OptionalBool `schema:"tlsVerify"` + }{} + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusBadRequest, errors.New("name parameter is required")) + return + } + + name := utils.GetName(r) + + artifactsPushOptions := entities.ArtifactPushOptions{} + + // If TLS verification is explicitly specified (True or False) in the query, + // set the SkipTLSVerify option accordingly. + // If TLSVerify was not set in the query, OptionalBoolUndefined is used and + // handled later based off the target registry configuration. + switch query.TLSVerify { + case types.OptionalBoolTrue: + artifactsPushOptions.SkipTLSVerify = types.NewOptionalBool(false) + case types.OptionalBoolFalse: + artifactsPushOptions.SkipTLSVerify = types.NewOptionalBool(true) + case types.OptionalBoolUndefined: + // If the user doesn't define TLSVerify in the query, do nothing and pass + // it to the backend code to handle. + default: // Should never happen + panic("Unexpected handling occurred for TLSVerify") + } + + if _, found := r.URL.Query()["retry"]; found { + artifactsPushOptions.Retry = &query.Retry + } + + if len(query.RetryDelay) != 0 { + artifactsPushOptions.RetryDelay = query.RetryDelay + } + + authConf, authfile, err := auth.GetCredentials(r) + if err != nil { + utils.Error(w, http.StatusBadRequest, err) + return + } + defer auth.RemoveAuthfile(authfile) + + if authConf != nil { + artifactsPushOptions.Username = authConf.Username + artifactsPushOptions.Password = authConf.Password + } + + imageEngine := abi.ImageEngine{Libpod: runtime} + + artifacts, err := imageEngine.ArtifactPush(r.Context(), name, artifactsPushOptions) + if err != nil { + var errcd errcode.ErrorCoder + // Check to see if any of the wrapped errors is an errcode.ErrorCoder returned from the registry + if errors.As(err, &errcd) { + rc := errcd.ErrorCode().Descriptor().HTTPStatusCode + // Check if the returned error is 401 indicating the request was unauthorized + if rc == 401 { + utils.Error(w, 401, errcd.ErrorCode()) + return + } + } + + var notFoundErr layout.ImageNotFoundError + if errors.As(err, ¬FoundErr) { + utils.ArtifactNotFound(w, name, notFoundErr) + return + } + + utils.InternalServerError(w, err) + return + } + + utils.WriteResponse(w, http.StatusOK, artifacts) +} + +func ExtractArtifact(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) + + query := struct { + Digest string `schema:"digest"` + Title string `schema:"title"` + }{} + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err)) + return + } + + extractOpts := entities.ArtifactExtractOptions{ + Title: query.Title, + Digest: query.Digest, + } + + name := utils.GetName(r) + + imageEngine := abi.ImageEngine{Libpod: runtime} + + err := imageEngine.ArtifactExtractTarStream(r.Context(), w, name, &extractOpts) + if err != nil { + if errors.Is(err, libartifact_types.ErrArtifactNotExist) { + utils.ArtifactNotFound(w, name, err) + return + } + utils.InternalServerError(w, err) + return + } +} diff --git a/pkg/api/handlers/swagger/errors.go b/pkg/api/handlers/swagger/errors.go index 2fac8b36fa..deaec2f1c4 100644 --- a/pkg/api/handlers/swagger/errors.go +++ b/pkg/api/handlers/swagger/errors.go @@ -23,6 +23,20 @@ type containerNotFound struct { Body errorhandling.ErrorModel } +// No such artifact +// swagger:response +type artifactNotFound struct { + // in:body + Body errorhandling.ErrorModel +} + +// error in authentication +// swagger:response +type artifactBadAuth struct { + // in:body + Body errorhandling.ErrorModel +} + // No such network // swagger:response type networkNotFound struct { diff --git a/pkg/api/handlers/swagger/responses.go b/pkg/api/handlers/swagger/responses.go index 6c9b40e740..cd4804f204 100644 --- a/pkg/api/handlers/swagger/responses.go +++ b/pkg/api/handlers/swagger/responses.go @@ -478,3 +478,45 @@ type networkPruneResponse struct { // in:body Body []entities.NetworkPruneReport } + +// Inspect Artifact +// swagger:response +type inspectArtifactResponse struct { + // in:body + Body entities.ArtifactInspectReport +} + +// Artifact list +// swagger:response +type artifactListResponse struct { + // in:body + Body []entities.ArtifactListReport +} + +// Artifact Pull +// swagger:response +type artifactPullResponse struct { + // in:body + Body entities.ArtifactPullReport +} + +// Artifact Remove +// swagger:response +type artifactRemoveResponse struct { + // in:body + Body entities.ArtifactRemoveReport +} + +// Artifact Add +// swagger:response +type artifactAddResponse struct { + // in:body + Body entities.ArtifactAddReport +} + +// Artifact Push +// swagger:response +type artifactPushResponse struct { + // in:body + Body entities.ArtifactPushReport +} diff --git a/pkg/api/handlers/utils/errors.go b/pkg/api/handlers/utils/errors.go index 82255ac410..c7bdec946d 100644 --- a/pkg/api/handlers/utils/errors.go +++ b/pkg/api/handlers/utils/errors.go @@ -59,6 +59,10 @@ func ImageNotFound(w http.ResponseWriter, name string, err error) { Error(w, http.StatusNotFound, err) } +func ArtifactNotFound(w http.ResponseWriter, name string, err error) { + Error(w, http.StatusNotFound, err) +} + func NetworkNotFound(w http.ResponseWriter, name string, err error) { if !errors.Is(err, define.ErrNoSuchNetwork) { InternalServerError(w, err) diff --git a/pkg/api/server/register_artifacts.go b/pkg/api/server/register_artifacts.go new file mode 100644 index 0000000000..c6d0ae2e5b --- /dev/null +++ b/pkg/api/server/register_artifacts.go @@ -0,0 +1,256 @@ +//go:build !remote + +package server + +import ( + "net/http" + + "github.com/containers/podman/v5/pkg/api/handlers/libpod" + "github.com/gorilla/mux" +) + +func (s *APIServer) registerArtifactHandlers(r *mux.Router) error { + // swagger:operation GET /libpod/artifacts/{name}/json libpod ArtifactInspectLibpod + // --- + // tags: + // - artifacts + // summary: Inspect an artifact + // description: Obtain low-level information about an artifact + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: The name or ID of the artifact + // required: true + // type: string + // responses: + // 200: + // $ref: "#/responses/inspectArtifactResponse" + // 404: + // $ref: "#/responses/artifactNotFound" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/libpod/artifacts/{name:.*}/json"), s.APIHandler(libpod.InspectArtifact)).Methods(http.MethodGet) + // swagger:operation GET /libpod/artifacts/json libpod ArtifactListLibpod + // --- + // tags: + // - artifacts + // summary: List artifacts + // description: Returns a list of artifacts on the server. + // produces: + // - application/json + // responses: + // 200: + // $ref: "#/responses/artifactListResponse" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/libpod/artifacts/json"), s.APIHandler(libpod.ListArtifact)).Methods(http.MethodGet) + // swagger:operation POST /libpod/artifacts/pull libpod ArtifactPullLibpod + // --- + // tags: + // - artifacts + // summary: Pull an OCI artifact + // description: Pulls an artifact from a registry and stores it locally. + // produces: + // - application/json + // parameters: + // - name: name + // in: query + // description: Mandatory reference to the artifact (e.g., quay.io/image/artifact:tag) + // required: true + // type: string + // - name: retry + // in: query + // description: Number of times to retry in case of failure when performing pull + // type: integer + // default: 3 + // - name: retryDelay + // in: query + // description: Delay between retries in case of pull failures (e.g., 10s) + // type: string + // default: 1s + // - name: tlsVerify + // in: query + // description: Require TLS verification. + // type: boolean + // default: true + // - name: X-Registry-Auth + // in: header + // description: | + // base-64 encoded auth config. + // Must include the following four values: username, password, email and server address + // OR simply just an identity token. + // type: string + // responses: + // 200: + // $ref: "#/responses/artifactPullResponse" + // 400: + // $ref: "#/responses/badParamError" + // 401: + // $ref: "#/responses/artifactBadAuth" + // 404: + // $ref: "#/responses/artifactNotFound" + // 500: + // $ref: "#/responses/internalError" + r.Handle(VersionedPath("/libpod/artifacts/pull"), s.APIHandler(libpod.PullArtifact)).Methods(http.MethodPost) + // swagger:operation DELETE /libpod/artifacts/{name} libpod ArtifactDeleteLibpod + // --- + // tags: + // - artifacts + // summary: Remove Artifact + // description: Delete an Artifact from local storage + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: name or ID of artifact to delete + // required: true + // type: string + // responses: + // 200: + // $ref: "#/responses/artifactRemoveResponse" + // 404: + // $ref: "#/responses/artifactNotFound" + // 500: + // $ref: "#/responses/internalError" + r.Handle(VersionedPath("/libpod/artifacts/{name:.*}"), s.APIHandler(libpod.RemoveArtifact)).Methods(http.MethodDelete) + // swagger:operation POST /libpod/artifacts/add libpod ArtifactAddLibpod + // --- + // tags: + // - artifacts + // summary: Add an OCI artifact to the local store + // description: Add an OCI artifact to the local store from the local filesystem + // produces: + // - application/json + // consumes: + // - application/octet-stream + // parameters: + // - name: name + // in: query + // description: Mandatory reference to the artifact (e.g., quay.io/image/artifact:tag) + // required: true + // type: string + // - name: fileName + // in: query + // description: File to be added to the artifact + // required: true + // type: string + // - name: fileMIMEType + // in: query + // description: Optionally set the type of file + // type: string + // - name: annotations + // in: query + // description: Array of annotation strings e.g "test=true" + // type: array + // items: + // type: string + // - name: artifactMIMEType + // in: query + // description: Use type to describe an artifact + // type: string + // - name: append + // in: query + // description: Append files to an existing artifact + // type: boolean + // default: false + // - name: inputStream + // in: body + // description: A binary stream of the blob to add to artifact + // schema: + // type: string + // format: binary + // responses: + // 201: + // $ref: "#/responses/artifactAddResponse" + // 400: + // $ref: "#/responses/badParamError" + // 404: + // $ref: "#/responses/artifactNotFound" + // 500: + // $ref: "#/responses/internalError" + r.Handle(VersionedPath("/libpod/artifacts/add"), s.APIHandler(libpod.AddArtifact)).Methods(http.MethodPost) + // swagger:operation POST /libpod/artifacts/{name}/push libpod ArtifactPushLibpod + // --- + // tags: + // - artifacts + // summary: Push an OCI artifact + // description: Push an OCI artifact from local storage to an image registry. + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: Mandatory reference to the artifact (e.g., quay.io/image/artifact:tag) + // required: true + // type: string + // - name: retry + // in: query + // description: Number of times to retry in case of failure when performing pull + // type: integer + // default: 3 + // - name: retryDelay + // in: query + // description: Delay between retries in case of pull failures (e.g., 10s) + // type: string + // default: 1s + // - name: tlsVerify + // in: query + // description: Require TLS verification. + // type: boolean + // default: true + // - name: X-Registry-Auth + // in: header + // description: | + // base-64 encoded auth config. + // Must include the following four values: username, password, email and server address + // OR simply just an identity token. + // type: string + // responses: + // 200: + // $ref: "#/responses/artifactPushResponse" + // 400: + // $ref: "#/responses/badParamError" + // 401: + // $ref: "#/responses/artifactBadAuth" + // 404: + // $ref: "#/responses/artifactNotFound" + // 500: + // $ref: "#/responses/internalError" + r.Handle(VersionedPath("/libpod/artifacts/{name:.*}/push"), s.APIHandler(libpod.PushArtifact)).Methods(http.MethodPost) + // swagger:operation GET /libpod/artifacts/{name}/extract libpod ArtifactExtractLibpod + // --- + // tags: + // - artifacts + // summary: Extract an OCI artifact to a local path + // description: Extract the blobs of an OCI artifact to a local file or directory + // produces: + // - application/x-tar + // parameters: + // - name: name + // in: path + // description: The name or digest of artifact + // required: true + // type: string + // - name: title + // in: query + // description: Only extract blob with the given title + // type: string + // - name: digest + // in: query + // description: Only extract blob with the given digest + // type: string + // responses: + // 200: + // description: Extract successful + // 400: + // $ref: "#/responses/badParamError" + // 404: + // $ref: "#/responses/artifactNotFound" + // 500: + // $ref: "#/responses/internalError" + r.Handle(VersionedPath("/libpod/artifacts/{name:.*}/extract"), s.APIHandler(libpod.ExtractArtifact)).Methods(http.MethodGet) + return nil +} diff --git a/pkg/api/server/server.go b/pkg/api/server/server.go index 41dff40cef..41d001efe8 100644 --- a/pkg/api/server/server.go +++ b/pkg/api/server/server.go @@ -119,6 +119,7 @@ func newServer(runtime *libpod.Runtime, listener net.Listener, opts entities.Ser for _, fn := range []func(*mux.Router) error{ server.registerAuthHandlers, + server.registerArtifactHandlers, server.registerArchiveHandlers, server.registerContainersHandlers, server.registerDistributionHandlers, diff --git a/pkg/domain/entities/artifact.go b/pkg/domain/entities/artifact.go index 0d699c8c6c..fda6ac1975 100644 --- a/pkg/domain/entities/artifact.go +++ b/pkg/domain/entities/artifact.go @@ -5,6 +5,7 @@ import ( "github.com/containers/image/v5/types" encconfig "github.com/containers/ocicrypt/config" + entityTypes "github.com/containers/podman/v5/pkg/domain/entities/types" "github.com/containers/podman/v5/pkg/libartifact" "github.com/opencontainers/go-digest" ) @@ -25,6 +26,12 @@ type ArtifactExtractOptions struct { Digest string } +type ArtifactBlob struct { + BlobReader io.Reader + BlobFilePath string + FileName string +} + type ArtifactInspectOptions struct { Remote bool } @@ -34,18 +41,41 @@ type ArtifactListOptions struct { } type ArtifactPullOptions struct { - Architecture string - AuthFilePath string - CertDirPath string + // containers-auth.json(5) file to use when authenticating against + // container registries. + AuthFilePath string + // Path to the certificates directory. + CertDirPath string + // Allow contacting registries over HTTP, or HTTPS with failed TLS + // verification. Note that this does not affect other TLS connections. InsecureSkipTLSVerify types.OptionalBool - MaxRetries *uint - OciDecryptConfig *encconfig.DecryptConfig - Password string - Quiet bool - RetryDelay string - SignaturePolicyPath string - Username string - Writer io.Writer + // Maximum number of retries with exponential backoff when facing + // transient network errors. + // Default 3. + MaxRetries *uint + // RetryDelay used for the exponential back off of MaxRetries. + // Default 1 time.Second. + RetryDelay string + // OciDecryptConfig contains the config that can be used to decrypt an image if it is + // encrypted if non-nil. If nil, it does not attempt to decrypt an image. + OciDecryptConfig *encconfig.DecryptConfig + // Quiet can be specified to suppress pull progress when pulling. Ignored + // for remote calls. //TODO: Verify that claim + Quiet bool + // SignaturePolicyPath to overwrite the default one. + SignaturePolicyPath string + // Writer is used to display copy information including progress bars. + Writer io.Writer + + // ----- credentials -------------------------------------------------- + + // Username to use when authenticating at a container registry. + Username string + // Password to use when authenticating at a container registry. + Password string + // IdentityToken is used to authenticate the user and get + // an access token for the registry. + IdentityToken string `json:"identitytoken,omitempty"` } type ArtifactPushOptions struct { @@ -64,15 +94,16 @@ type ArtifactRemoveOptions struct { All bool } -type ArtifactPullReport struct{} - -type ArtifactPushReport struct{} +type ArtifactPullReport struct { + ArtifactDigest *digest.Digest +} -type ArtifactInspectReport struct { - *libartifact.Artifact - Digest string +type ArtifactPushReport struct { + ArtifactDigest *digest.Digest } +type ArtifactInspectReport = entityTypes.ArtifactInspectReport + type ArtifactListReport struct { *libartifact.Artifact } diff --git a/pkg/domain/entities/engine_image.go b/pkg/domain/entities/engine_image.go index 796911b108..366da29587 100644 --- a/pkg/domain/entities/engine_image.go +++ b/pkg/domain/entities/engine_image.go @@ -2,6 +2,7 @@ package entities import ( "context" + "io" "github.com/containers/common/libimage/define" "github.com/containers/common/pkg/config" @@ -9,8 +10,9 @@ import ( ) type ImageEngine interface { //nolint:interfacebloat - ArtifactAdd(ctx context.Context, name string, paths []string, opts *ArtifactAddOptions) (*ArtifactAddReport, error) + ArtifactAdd(ctx context.Context, name string, artifactBlobs []ArtifactBlob, opts *ArtifactAddOptions) (*ArtifactAddReport, error) ArtifactExtract(ctx context.Context, name string, target string, opts *ArtifactExtractOptions) error + ArtifactExtractTarStream(ctx context.Context, w io.Writer, name string, opts *ArtifactExtractOptions) error ArtifactInspect(ctx context.Context, name string, opts ArtifactInspectOptions) (*ArtifactInspectReport, error) ArtifactList(ctx context.Context, opts ArtifactListOptions) ([]*ArtifactListReport, error) ArtifactPull(ctx context.Context, name string, opts ArtifactPullOptions) (*ArtifactPullReport, error) diff --git a/pkg/domain/entities/types/artifacts.go b/pkg/domain/entities/types/artifacts.go new file mode 100644 index 0000000000..19e42f6d6f --- /dev/null +++ b/pkg/domain/entities/types/artifacts.go @@ -0,0 +1,8 @@ +package types + +import "github.com/containers/podman/v5/pkg/libartifact" + +type ArtifactInspectReport struct { + *libartifact.Artifact + Digest string +} diff --git a/pkg/domain/infra/abi/artifact.go b/pkg/domain/infra/abi/artifact.go index fefa161f26..366a896556 100644 --- a/pkg/domain/infra/abi/artifact.go +++ b/pkg/domain/infra/abi/artifact.go @@ -4,6 +4,8 @@ package abi import ( "context" + "fmt" + "io" "os" "time" @@ -66,7 +68,7 @@ func (ir *ImageEngine) ArtifactPull(ctx context.Context, name string, opts entit if opts.RetryDelay != "" { duration, err := time.ParseDuration(opts.RetryDelay) if err != nil { - return nil, err + return nil, fmt.Errorf("unable to parse value provided %q: %w", opts.RetryDelay, err) } pullOptions.RetryDelay = &duration } @@ -78,7 +80,14 @@ func (ir *ImageEngine) ArtifactPull(ctx context.Context, name string, opts entit if err != nil { return nil, err } - return nil, artStore.Pull(ctx, name, *pullOptions) + artifactDigest, err := artStore.Pull(ctx, name, *pullOptions) + if err != nil { + return nil, err + } + + return &entities.ArtifactPullReport{ + ArtifactDigest: &artifactDigest, + }, nil } func (ir *ImageEngine) ArtifactRm(ctx context.Context, name string, opts entities.ArtifactRemoveOptions) (*entities.ArtifactRemoveReport, error) { @@ -178,16 +187,26 @@ func (ir *ImageEngine) ArtifactPush(ctx context.Context, name string, opts entit IdentityToken: "", Writer: opts.Writer, } + artifactDigest, err := artStore.Push(ctx, name, name, copyOpts) + if err != nil { + return nil, err + } - err = artStore.Push(ctx, name, name, copyOpts) - return &entities.ArtifactPushReport{}, err + return &entities.ArtifactPushReport{ + ArtifactDigest: &artifactDigest, + }, nil } -func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []string, opts *entities.ArtifactAddOptions) (*entities.ArtifactAddReport, error) { + +func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, artifactBlobs []entities.ArtifactBlob, opts *entities.ArtifactAddOptions) (*entities.ArtifactAddReport, error) { artStore, err := ir.Libpod.ArtifactStore() if err != nil { return nil, err } + if opts.Annotations == nil { + opts.Annotations = make(map[string]string) + } + addOptions := types.AddOptions{ Annotations: opts.Annotations, ArtifactType: opts.ArtifactType, @@ -195,7 +214,7 @@ func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []str FileType: opts.FileType, } - artifactDigest, err := artStore.Add(ctx, name, paths, &addOptions) + artifactDigest, err := artStore.Add(ctx, name, artifactBlobs, &addOptions) if err != nil { return nil, err } @@ -218,3 +237,21 @@ func (ir *ImageEngine) ArtifactExtract(ctx context.Context, name string, target return artStore.Extract(ctx, name, target, extractOpt) } + +func (ir *ImageEngine) ArtifactExtractTarStream(ctx context.Context, w io.Writer, name string, opts *entities.ArtifactExtractOptions) error { + if opts == nil { + opts = &entities.ArtifactExtractOptions{} + } + artStore, err := ir.Libpod.ArtifactStore() + if err != nil { + return err + } + extractOpt := &types.ExtractOptions{ + FilterBlobOptions: types.FilterBlobOptions{ + Digest: opts.Digest, + Title: opts.Title, + }, + } + + return artStore.ExtractTarStream(ctx, w, name, extractOpt) +} diff --git a/pkg/domain/infra/tunnel/artifact.go b/pkg/domain/infra/tunnel/artifact.go index c55a328fe6..55af28fde0 100644 --- a/pkg/domain/infra/tunnel/artifact.go +++ b/pkg/domain/infra/tunnel/artifact.go @@ -3,6 +3,7 @@ package tunnel import ( "context" "fmt" + "io" "github.com/containers/podman/v5/pkg/domain/entities" ) @@ -13,6 +14,10 @@ func (ir *ImageEngine) ArtifactExtract(ctx context.Context, name string, target return fmt.Errorf("not implemented") } +func (ir *ImageEngine) ArtifactExtractTarStream(ctx context.Context, w io.Writer, name string, opts *entities.ArtifactExtractOptions) error { + return fmt.Errorf("not implemented") +} + func (ir *ImageEngine) ArtifactInspect(ctx context.Context, name string, opts entities.ArtifactInspectOptions) (*entities.ArtifactInspectReport, error) { return nil, fmt.Errorf("not implemented") } @@ -33,6 +38,6 @@ func (ir *ImageEngine) ArtifactPush(ctx context.Context, name string, opts entit return nil, fmt.Errorf("not implemented") } -func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []string, opts *entities.ArtifactAddOptions) (*entities.ArtifactAddReport, error) { +func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, artifactBlob []entities.ArtifactBlob, opts *entities.ArtifactAddOptions) (*entities.ArtifactAddReport, error) { return nil, fmt.Errorf("not implemented") } diff --git a/pkg/libartifact/store/store.go b/pkg/libartifact/store/store.go index 7e6061832b..3a98098e75 100644 --- a/pkg/libartifact/store/store.go +++ b/pkg/libartifact/store/store.go @@ -3,6 +3,8 @@ package store import ( + "archive/tar" + "bufio" "context" "encoding/json" "errors" @@ -14,13 +16,16 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/containers/common/libimage" "github.com/containers/image/v5/image" "github.com/containers/image/v5/manifest" "github.com/containers/image/v5/oci/layout" + "github.com/containers/image/v5/pkg/blobinfocache/none" "github.com/containers/image/v5/transports/alltransports" "github.com/containers/image/v5/types" + "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/libartifact" libartTypes "github.com/containers/podman/v5/pkg/libartifact/types" "github.com/containers/storage/pkg/fileutils" @@ -121,56 +126,66 @@ func (as ArtifactStore) List(ctx context.Context) (libartifact.ArtifactList, err } // Pull an artifact from an image registry to a local store -func (as ArtifactStore) Pull(ctx context.Context, name string, opts libimage.CopyOptions) error { +func (as ArtifactStore) Pull(ctx context.Context, name string, opts libimage.CopyOptions) (digest.Digest, error) { if len(name) == 0 { - return ErrEmptyArtifactName + return "", ErrEmptyArtifactName } srcRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", name)) if err != nil { - return err + return "", err } destRef, err := layout.NewReference(as.storePath, name) if err != nil { - return err + return "", err } copyer, err := libimage.NewCopier(&opts, as.SystemContext) if err != nil { - return err + return "", err } - _, err = copyer.Copy(ctx, srcRef, destRef) + artifactBytes, err := copyer.Copy(ctx, srcRef, destRef) if err != nil { - return err + return "", err } - return copyer.Close() + err = copyer.Close() + if err != nil { + return "", err + } + return digest.FromBytes(artifactBytes), nil } // Push an artifact to an image registry -func (as ArtifactStore) Push(ctx context.Context, src, dest string, opts libimage.CopyOptions) error { +func (as ArtifactStore) Push(ctx context.Context, src, dest string, opts libimage.CopyOptions) (digest.Digest, error) { if len(dest) == 0 { - return ErrEmptyArtifactName + return "", ErrEmptyArtifactName } destRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", dest)) if err != nil { - return err + return "", err } srcRef, err := layout.NewReference(as.storePath, src) if err != nil { - return err + return "", err } copyer, err := libimage.NewCopier(&opts, as.SystemContext) if err != nil { - return err + return "", err } - _, err = copyer.Copy(ctx, srcRef, destRef) + artifactBytes, err := copyer.Copy(ctx, srcRef, destRef) if err != nil { - return err + return "", err + } + + err = copyer.Close() + if err != nil { + return "", err } - return copyer.Close() + artifactDigest := digest.FromBytes(artifactBytes) + return artifactDigest, nil } -// Add takes one or more local files and adds them to the local artifact store. The empty +// Add takes one or more artifact blobs and add them to the local artifact store. The empty // string input is for possible custom artifact types. -func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, options *libartTypes.AddOptions) (*digest.Digest, error) { +func (as ArtifactStore) Add(ctx context.Context, dest string, artifactBlobs []entities.ArtifactBlob, options *libartTypes.AddOptions) (*digest.Digest, error) { if len(dest) == 0 { return nil, ErrEmptyArtifactName } @@ -229,8 +244,8 @@ func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, op } } - for _, path := range paths { - fileName := filepath.Base(path) + for _, artifact := range artifactBlobs { + fileName := artifact.FileName if _, ok := fileNames[fileName]; ok { return nil, fmt.Errorf("%s: %w", fileName, libartTypes.ErrArtifactFileExists) } @@ -250,31 +265,45 @@ func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, op // ImageDestination, in general, requires the caller to write a full image; here we may write only the added layers. // This works for the oci/layout transport we hard-code. - for _, path := range paths { - mediaType := options.FileType - // get the new artifact into the local store - newBlobDigest, newBlobSize, err := layout.PutBlobFromLocalFile(ctx, imageDest, path) - if err != nil { - return nil, err + for _, artifactBlob := range artifactBlobs { + if artifactBlob.BlobFilePath == "" && artifactBlob.BlobReader == nil || artifactBlob.BlobFilePath != "" && artifactBlob.BlobReader != nil { + return nil, fmt.Errorf("Artifact.BlobFile or Artifact.BlobReader must be provided") + } + + annotations := maps.Clone(options.Annotations) + annotations[specV1.AnnotationTitle] = artifactBlob.FileName + + newLayer := specV1.Descriptor{ + MediaType: options.FileType, + Annotations: annotations, } // If we did not receive an override for the layer's mediatype, use // detection to determine it. - if len(mediaType) < 1 { - mediaType, err = determineManifestType(path) + if options.FileType == "" { + artifactBlob.BlobReader, newLayer.MediaType, err = determineBlobMIMEType(artifactBlob) if err != nil { return nil, err } } - annotations := maps.Clone(options.Annotations) - annotations[specV1.AnnotationTitle] = filepath.Base(path) - newLayer := specV1.Descriptor{ - MediaType: mediaType, - Digest: newBlobDigest, - Size: newBlobSize, - Annotations: annotations, + // get the new artifact into the local store + if artifactBlob.BlobFilePath != "" { + newBlobDigest, newBlobSize, err := layout.PutBlobFromLocalFile(ctx, imageDest, artifactBlob.BlobFilePath) + if err != nil { + return nil, err + } + newLayer.Digest = newBlobDigest + newLayer.Size = newBlobSize + } else { + blobInfo, err := imageDest.PutBlob(ctx, artifactBlob.BlobReader, types.BlobInfo{Size: -1}, none.NoCache, false) + if err != nil { + return nil, err + } + newLayer.Digest = blobInfo.Digest + newLayer.Size = blobInfo.Size } + artifactManifest.Layers = append(artifactManifest.Layers, newLayer) } @@ -471,6 +500,60 @@ func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target return nil } +// Extract an artifact to tar stream +func (as ArtifactStore) ExtractTarStream(ctx context.Context, w io.Writer, nameOrDigest string, options *libartTypes.ExtractOptions) error { + if options == nil { + options = &libartTypes.ExtractOptions{} + } + + arty, imgSrc, err := getArtifactAndImageSource(ctx, as, nameOrDigest, &options.FilterBlobOptions) + if err != nil { + return err + } + defer imgSrc.Close() + + tw := tar.NewWriter(w) + defer tw.Close() + + // Return early if only a single blob is requested via title or digest + if len(options.Digest) > 0 || len(options.Title) > 0 { + digest, err := findDigest(arty, &options.FilterBlobOptions) + if err != nil { + return err + } + + // In case the digest is set we always use it as target name + // so we do not have to get the actual title annotation form the blob. + // Passing options.Title is enough because we know it is empty when digest + // is set as we only allow either one. + filename, err := generateArtifactBlobName(options.Title, digest) + if err != nil { + return err + } + + err = copyTrustedImageBlobToTarStream(ctx, imgSrc, digest, filename, tw) + if err != nil { + return err + } + + return nil + } + + for _, l := range arty.Manifest.Layers { + title := l.Annotations[specV1.AnnotationTitle] + filename, err := generateArtifactBlobName(title, l.Digest) + if err != nil { + return err + } + err = copyTrustedImageBlobToTarStream(ctx, imgSrc, l.Digest, filename, tw) + if err != nil { + return err + } + } + + return nil +} + func generateArtifactBlobName(title string, digest digest.Digest) (string, error) { filename := title if len(filename) == 0 { @@ -546,6 +629,45 @@ func copyTrustedImageBlobToFile(ctx context.Context, imgSrc types.ImageSource, d return err } +// copyTrustedImageBlobToStream copies blob identified by digest in imgSrc to io.writer target. +// +// WARNING: This does not validate the contents against the expected digest, so it should only +// be used to read from trusted sources! +func copyTrustedImageBlobToTarStream(ctx context.Context, imgSrc types.ImageSource, digest digest.Digest, filename string, tw *tar.Writer) error { + src, srcSize, err := imgSrc.GetBlob(ctx, types.BlobInfo{Digest: digest}, nil) + if err != nil { + return fmt.Errorf("failed to get artifact blob: %w", err) + } + defer src.Close() + + if srcSize == -1 { + return fmt.Errorf("internal error: oci layout image is missing blob size") + } + + // Note: We can't assume imgSrc will return an *os.File so we must generate the tar header + now := time.Now() + header := tar.Header{ + Name: filename, + Mode: 0600, + Size: srcSize, + ModTime: now, + ChangeTime: now, + AccessTime: now, + } + + if err := tw.WriteHeader(&header); err != nil { + return fmt.Errorf("error writing tar header for %s: %w", filename, err) + } + + // Copy the file content to the tar archive. + _, err = io.Copy(tw, src) + if err != nil { + return fmt.Errorf("error copying content of %s to tar archive: %w", filename, err) + } + + return nil +} + // readIndex is currently unused but I want to keep this around until // the artifact code is more mature. func (as ArtifactStore) readIndex() (*specV1.Index, error) { //nolint:unused @@ -636,19 +758,52 @@ func createEmptyStanza(path string) error { return os.WriteFile(path, specV1.DescriptorEmptyJSON.Data, 0644) } -func determineManifestType(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err +// determineBlobMIMEType reads up to 512 bytes into a buffer +// without advancing the read position of the io.Reader. +// If http.DetectContentType is unable to determine a valid +// MIME type, the default of "application/octet-stream" will be +// returned. +// Either an io.Reader or *os.File can be provided, if an io.Reader +// is provided, a new io.Reader will be returned to be used for +// subsequent reads. +func determineBlobMIMEType(ab entities.ArtifactBlob) (io.Reader, string, error) { + if ab.BlobFilePath == "" && ab.BlobReader == nil || ab.BlobFilePath != "" && ab.BlobReader != nil { + return nil, "", fmt.Errorf("Artifact.BlobFile or Artifact.BlobReader must be provided") } - defer f.Close() - // DetectContentType looks at the first 512 bytes - b := make([]byte, 512) - // Because DetectContentType will return a default value - // we don't sweat the error - n, err := f.Read(b) - if err != nil && !errors.Is(err, io.EOF) { - return "", err + + var ( + err error + mimeBuf []byte + peekBuffer *bufio.Reader + ) + + maxBytes := 512 + + if ab.BlobFilePath != "" { + f, err := os.Open(ab.BlobFilePath) + if err != nil { + return nil, "", err + } + defer f.Close() + + buf := make([]byte, maxBytes) + + n, err := f.Read(buf) + if err != nil && err != io.EOF { + return nil, "", err + } + + mimeBuf = buf[:n] } - return http.DetectContentType(b[:n]), nil + + if ab.BlobReader != nil { + peekBuffer = bufio.NewReader(ab.BlobReader) + + mimeBuf, err = peekBuffer.Peek(maxBytes) + if err != nil && !errors.Is(err, bufio.ErrBufferFull) && !errors.Is(err, io.EOF) { + return nil, "", err + } + } + + return peekBuffer, http.DetectContentType(mimeBuf), nil } diff --git a/test/apiv2/python/rest_api/fixtures/api_testcase.py b/test/apiv2/python/rest_api/fixtures/api_testcase.py index edb34b31ec..6fb037339e 100644 --- a/test/apiv2/python/rest_api/fixtures/api_testcase.py +++ b/test/apiv2/python/rest_api/fixtures/api_testcase.py @@ -1,14 +1,97 @@ import json +import os +import random import subprocess +import sys +import time import unittest import requests -import sys -import time from .podman import Podman +class ArtifactFile: + __test__: bool = False + + name: str | None + size: int | None + sig: bytes | None + + def __init__( + self, name: str | None = None, size: int | None = None, sig: bytes | None = None + ) -> None: + self.name = name + self.size = size + self.sig = sig + self.render_test_file() + + def render_test_file(self) -> None: + if self.name is None: + self.name = "test_file_1" + if self.size is None: + self.size = 1048576 + + file_data = None + if self.sig is not None: + random_bytes = random.randbytes(self.size - len(self.sig)) + + file_data = bytearray(self.sig) + file_data.extend(random_bytes) + else: + file_data = os.urandom(self.size) + + try: + with open(self.name, "wb") as f: + _ = f.write(file_data) + except Exception as e: + print(f"File write error for {self.name}: {e}") + raise + + +class Artifact: + __test__: bool = False + + uri: str + name: str + parameters: dict[str, str | list[str]] + file: ArtifactFile + + def __init__( + self, + uri: str, + name: str, + parameters: dict[str, str | list[str]], + file: ArtifactFile, + ) -> None: + self.uri = uri + self.name = name + self.parameters = parameters + self.file = file + + def add(self) -> requests.Response: + try: + with open(self.file.name, "rb") as file_to_upload: + file_content = file_to_upload.read() + r = requests.post( + self.uri + "/artifacts/add", + data=file_content, + params=self.parameters, + ) + except Exception: + pass + + os.remove(self.file.name) + return r + + def do_artifact_inspect_request(self) -> requests.Response: + r = requests.get( + self.uri + "/artifacts/" + self.name + "/json", + ) + + return r + + class APITestCase(unittest.TestCase): PODMAN_URL = "http://localhost:8080" podman = None # initialized podman configuration for tests @@ -40,7 +123,7 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): APITestCase.service.terminate() - stdout, stderr = APITestCase.service.communicate(timeout=0.5) + stdout, stderr = APITestCase.service.communicate(timeout=1) if stdout: sys.stdout.write("\nService Stdout:\n" + stdout.decode("utf-8")) if stderr: @@ -61,7 +144,7 @@ def podman_url(self): return "http://localhost:8080" @staticmethod - def uri(path): + def uri(path: str) -> str: return APITestCase.PODMAN_URL + "/v2.0.0/libpod" + path @staticmethod diff --git a/test/apiv2/python/rest_api/test_v2_0_0_artifact.py b/test/apiv2/python/rest_api/test_v2_0_0_artifact.py new file mode 100644 index 0000000000..e0dc7925d3 --- /dev/null +++ b/test/apiv2/python/rest_api/test_v2_0_0_artifact.py @@ -0,0 +1,582 @@ +import os +import tarfile +import unittest +from typing import cast + +import requests + +from .fixtures import APITestCase +from .fixtures.api_testcase import Artifact, ArtifactFile + + +class ArtifactTestCase(APITestCase): + def test_add(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + } + + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add() + + # Assert correct response code + self.assertEqual(add_response.status_code, 201, add_response.text) + + # Assert return response is json and contains digest + add_response_json = add_response.json() + self.assertIn("sha256:", cast(str, add_response_json["ArtifactDigest"])) + + inspect_response_json = artifact.do_artifact_inspect_request().json() + artifact_layer = inspect_response_json["Manifest"]["layers"][0] + + # Assert uploaded artifact blob is expected size + self.assertEqual(artifact_layer["size"], file.size) + + # Assert uploaded artifact blob has expected title annotation + self.assertEqual( + artifact_layer["annotations"]["org.opencontainers.image.title"], file.name + ) + + # Assert blob media type fallback detection is working + self.assertEqual(artifact_layer["mediaType"], "application/octet-stream") + + def test_add_with_append(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + file = ArtifactFile(name="test_file_2") + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + "append": "true", + } + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add() + + # Assert correct response code + self.assertEqual(add_response.status_code, 201, add_response.text) + + # Assert return response is json and contains digest + add_response_json = add_response.json() + self.assertIn("sha256:", cast(str, add_response_json["ArtifactDigest"])) + + inspect_response_json = artifact.do_artifact_inspect_request().json() + artifact_layers = inspect_response_json["Manifest"]["layers"] + + # Assert artifact now has two layers + self.assertEqual(len(artifact_layers), 2) + + def test_add_with_artifactMIMEType_override(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact_artifactType:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + "artifactMIMEType": "application/testType", + } + + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add() + + # Assert correct response code + self.assertEqual(add_response.status_code, 201, add_response.text) + + inspect_response_json = artifact.do_artifact_inspect_request().json() + + # Assert added artifact has correct mediaType + self.assertEqual( + inspect_response_json["Manifest"]["artifactType"], "application/testType" + ) + + def test_add_with_annotations(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact_annotation:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + "annotations": ["test=test", "foo=bar"], + } + + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add() + + # Assert correct response code + self.assertEqual(add_response.status_code, 201, add_response.text) + + inspect_response_json = artifact.do_artifact_inspect_request().json() + artifact_layer = inspect_response_json["Manifest"]["layers"][0] + + # Assert artifactBlobAnnotation is set correctly + anno = { + "foo": "bar", + "org.opencontainers.image.title": artifact.file.name, + "test": "test", + } + self.assertEqual(artifact_layer["annotations"], anno) + + def test_add_with_empty_file(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact_empty_file:latest" + file = ArtifactFile(size=0) + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + } + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add() + + # Assert correct response code + self.assertEqual(add_response.status_code, 201, add_response.text) + + # Assert return response is json and contains digest + add_response_json = add_response.json() + self.assertIn("sha256:", cast(str, add_response_json["ArtifactDigest"])) + + inspect_response_json = artifact.do_artifact_inspect_request().json() + artifact_layer = inspect_response_json["Manifest"]["layers"][0] + + # Assert uploaded artifact blob is expected size + self.assertEqual(artifact_layer["size"], file.size) + + # Assert uploaded artifact blob has expected title annotation + self.assertEqual( + artifact_layer["annotations"]["org.opencontainers.image.title"], file.name + ) + + def test_add_with_fileMIMEType_override(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact_mime_type:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + "fileMIMEType": "fake/type", + } + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add() + + # Assert correct response code + self.assertEqual(add_response.status_code, 201, add_response.text) + + # Assert return response is json and contains digest + add_response_json = add_response.json() + self.assertIn("sha256:", cast(str, add_response_json["ArtifactDigest"])) + + inspect_response_json = artifact.do_artifact_inspect_request().json() + artifact_layer = inspect_response_json["Manifest"]["layers"][0] + + # Assert uploaded artifact blob is expected MIME type + self.assertEqual(artifact_layer["mediaType"], "fake/type") + + def test_add_with_auto_fileMIMEType_discovery(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact_image_blob:latest" + FILE_SIG = bytes([137, 80, 78, 71, 13, 10, 26, 10]) + file = ArtifactFile(sig=FILE_SIG) + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + } + + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add() + + # Assert correct response code + self.assertEqual(add_response.status_code, 201, add_response.text) + + # Assert return response is json and contains digest + add_response_json = add_response.json() + self.assertIn("sha256:", cast(str, add_response_json["ArtifactDigest"])) + + inspect_response_json = artifact.do_artifact_inspect_request().json() + artifact_layer = inspect_response_json["Manifest"]["layers"][0] + + # Assert uploaded artifact blob is automatically recognised as image + self.assertEqual(artifact_layer["mediaType"], "image/png") + + def test_add_append_with_type_fails(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + "artifactMIMEType": "application/octet-stream", + "append": "true", + } + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + r = artifact.add() + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 500, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "append option is not compatible with ArtifactType option", + ) + + def test_add_with_append_to_missing_artifact_fails(self): + ARTIFACT_NAME = "quay.io/myimage/missing:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + "append": "true", + } + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + r = artifact.add() + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 404, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual(rjson["cause"], "artifact does not exist") + + def test_add_without_name_and_filename_fails(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = {"fake": "fake"} + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + r = artifact.add() + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 400, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "name and file parameters are required", + ) + + def test_inspect(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact_mime_type:latest" + + url = self.uri( + "/artifacts/" + ARTIFACT_NAME + "/json", + ) + r = requests.get(url) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 200, r.text) + + # Define expected layout keys + expected_top_level = {"Manifest", "Name", "Digest"} + expected_manifest = { + "schemaVersion", + "mediaType", + "config", + "layers", + } + expected_config = {"mediaType", "digest", "size", "data"} + expected_layer = {"mediaType", "digest", "size", "annotations"} + + # Compare returned keys with expected + missing_top = expected_top_level - rjson.keys() + manifest = rjson.get("Manifest", {}) + missing_manifest = expected_manifest - manifest.keys() + config = manifest.get("config", {}) + missing_config = expected_config - config.keys() + + layers = manifest.get("layers", []) + for i, layer in enumerate(layers): + missing_layer = expected_layer - layer.keys() + self.assertFalse(missing_layer) + + # Assert all missing dicts are empty meaning all expected keys were present + self.assertFalse(missing_top) + self.assertFalse(missing_manifest) + self.assertFalse(missing_config) + + def test_inspect_absent_artifact_fails(self): + ARTIFACT_NAME = "fake_artifact" + url = self.uri("/artifacts/" + ARTIFACT_NAME + "/json") + r = requests.get(url) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 404, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "artifact does not exist", + ) + + def test_list(self): + url = self.uri("/artifacts/json") + r = requests.get(url) + rjson = r.json() + + self.assertEqual(r.status_code, 200, r.text) + + expected_top_level = {"Manifest", "Name"} + expected_manifest = {"schemaVersion", "mediaType", "config", "layers"} + expected_config = {"mediaType", "digest", "size", "data"} + expected_layer = {"mediaType", "digest", "size", "annotations"} + + for data in rjson: + missing_top = expected_top_level - data.keys() + manifest = data.get("Manifest", {}) + missing_manifest = expected_manifest - manifest.keys() + config = manifest.get("config", {}) + missing_config = expected_config - config.keys() + + layers = manifest.get("layers", []) + for _, layer in enumerate(layers): + missing_layer = expected_layer - layer.keys() + self.assertFalse(missing_layer) + + # assert all missing dicts are empty + self.assertFalse(missing_top) + self.assertFalse(missing_manifest) + self.assertFalse(missing_config) + + def test_pull(self): + ARTIFACT_NAME = "quay.io/libpod/testartifact:20250206-single" + url = self.uri("/artifacts/pull") + parameters = { + "name": ARTIFACT_NAME, + } + r = requests.post(url, params=parameters) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 200, r.text) + + # Assert return error response is json and contains correct message + self.assertIn("sha256:", rjson["ArtifactDigest"]) + + def test_pull_with_retry(self): + ARTIFACT_NAME = "localhost/fake/artifact:latest" + + # Note: Default retry is 3 attempts with 1s delay. + url = self.uri("/artifacts/pull") + parameters = { + "name": ARTIFACT_NAME, + "retryDelay": "3s", + "retry": "2", + } + r = requests.post(url, params=parameters) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 500, r.text) + + # Assert request took expected time with retries + self.assertTrue(5 < r.elapsed.total_seconds() < 7) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "connection refused", + ) + + def test_pull_unauthorised_fails(self): + ARTIFACT_NAME = "quay.io/libpod_secret/testartifact:latest" + url = self.uri("/artifacts/pull") + parameters = { + "name": ARTIFACT_NAME, + } + r = requests.post(url, params=parameters) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 401, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "unauthorized", + ) + + def test_pull_missing_fails(self): + ARTIFACT_NAME = "quay.io/libpod/testartifact:superfake" + url = self.uri("/artifacts/pull") + parameters = { + "name": ARTIFACT_NAME, + } + r = requests.post(url, params=parameters) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 404, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "manifest unknown", + ) + + def test_remove(self): + ARTIFACT_NAME = "quay.io/libpod/testartifact:20250206-single" + url = self.uri("/artifacts/" + ARTIFACT_NAME) + r = requests.delete(url) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 200, r.text) + + # Assert return response is json and contains digest + self.assertIn("sha256:", rjson["ArtifactDigests"][0]) + + def test_remove_absent_artifact_fails(self): + ARTIFACT_NAME = "localhost/fake/artifact:latest" + url = self.uri("/artifacts/" + ARTIFACT_NAME) + + r = requests.delete(url) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 404, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "artifact does not exist", + ) + + def test_push_unauthorised(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + + url = self.uri( + "/artifacts/" + ARTIFACT_NAME + "/push", + ) + r = requests.post(url) + rjson = r.json() + + # Assert return error response is json and contains correct message + self.assertEqual(r.status_code, 401, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "unauthorized", + ) + + def test_push_bad_param(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + parameters = { + "retry": "abc", + } + url = self.uri( + "/artifacts/" + ARTIFACT_NAME + "/push", + ) + r = requests.post( + url, + params=parameters, + ) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 400, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "name parameter is required", + ) + + def test_push_missing_artifact(self): + ARTIFACT_NAME = "localhost/fake/artifact:latest" + url = self.uri( + "/artifacts/" + ARTIFACT_NAME + "/push", + ) + r = requests.post( + url, + ) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 404, r.text) + + # Assert return error response is json and contains correct message + self.assertIn( + "no descriptor found for reference", + rjson["cause"], + ) + + def test_extract(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + + url = self.uri( + "/artifacts/" + ARTIFACT_NAME + "/extract", + ) + r = requests.get(url) + + # Assert correct response code + self.assertEqual(r.status_code, 200, r.text) + + tar_file = "test.tar" + tar_file_sizes = None + + with open(tar_file, "wb") as f: + _ = f.write(r.content) + + with tarfile.open(tar_file, "r") as tar: + tar_file_sizes = {m.name: m.size for m in tar.getmembers() if m.isfile()} + + self.assertEqual( + tar_file_sizes, {"test_file_1": 1048576, "test_file_2": 1048576} + ) + + os.remove(tar_file) + + def test_extract_with_title(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + + parameters: dict[str, str] = { + "title": "test_file_1", + } + url = self.uri( + "/artifacts/" + ARTIFACT_NAME + "/extract", + ) + r = requests.get(url, parameters) + + # Assert correct response code + self.assertEqual(r.status_code, 200, r.text) + + tar_file = "test.tar" + tar_file_sizes = None + + with open(tar_file, "wb") as f: + _ = f.write(r.content) + + with tarfile.open(tar_file, "r") as tar: + tar_file_sizes = {m.name: m.size for m in tar.getmembers() if m.isfile()} + + self.assertEqual(tar_file_sizes, {"test_file_1": 1048576}) + + os.remove(tar_file) + + def test_extract_absent_fails(self): + ARTIFACT_NAME = "localhost/fake/artifact:latest" + + url = self.uri( + "/artifacts/" + ARTIFACT_NAME + "/extract", + ) + r = requests.get(url) + rjson = r.json() + + # Assert correct response code + self.assertEqual(r.status_code, 404, r.text) + + # Assert return error response is json and contains correct message + self.assertEqual( + rjson["cause"], + "artifact does not exist", + ) + + +if __name__ == "__main__": + unittest.main()