feat: Add OCI Artifact support to the Podman REST API

This patch adds a new endpoint to the REST API called "artifacts" with
the following methods:
- Add
- Extract
- Inspect
- List
- Pull
- Push
- Remove

This API will be utilised by the Podman bindings to add OCI Artifact
support to our remote clients.

Jira: https://issues.redhat.com/browse/RUN-2711

Signed-off-by: Lewis Roy <lewis@redhat.com>
This commit is contained in:
Lewis Roy 2025-05-12 21:54:15 +10:00
parent 6a39f37845
commit 99cfdc04db
No known key found for this signature in database
15 changed files with 1649 additions and 79 deletions

View File

@ -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
}

View File

@ -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, &notFoundErr) {
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
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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,

View File

@ -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
// 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
// Maximum number of retries with exponential backoff when facing
// transient network errors.
// Default 3.
MaxRetries *uint
OciDecryptConfig *encconfig.DecryptConfig
Password string
Quiet bool
// 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
Username 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 ArtifactInspectReport struct {
*libartifact.Artifact
Digest string
type ArtifactPullReport struct {
ArtifactDigest *digest.Digest
}
type ArtifactPushReport struct {
ArtifactDigest *digest.Digest
}
type ArtifactInspectReport = entityTypes.ArtifactInspectReport
type ArtifactListReport struct {
*libartifact.Artifact
}

View File

@ -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)

View File

@ -0,0 +1,8 @@
package types
import "github.com/containers/podman/v5/pkg/libartifact"
type ArtifactInspectReport struct {
*libartifact.Artifact
Digest string
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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
}
return copyer.Close()
err = copyer.Close()
if err != nil {
return "", err
}
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)
// 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")
}
var (
err error
mimeBuf []byte
peekBuffer *bufio.Reader
)
maxBytes := 512
if ab.BlobFilePath != "" {
f, err := os.Open(ab.BlobFilePath)
if err != nil {
return "", err
return nil, "", err
}
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
buf := make([]byte, maxBytes)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return nil, "", err
}
return http.DetectContentType(b[:n]), nil
mimeBuf = buf[:n]
}
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
}

View File

@ -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 @@ class APITestCase(unittest.TestCase):
@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 @@ class APITestCase(unittest.TestCase):
return "http://localhost:8080"
@staticmethod
def uri(path):
def uri(path: str) -> str:
return APITestCase.PODMAN_URL + "/v2.0.0/libpod" + path
@staticmethod

View File

@ -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()