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
15 changed files with 1649 additions and 79 deletions

View File

@ -2,6 +2,7 @@ package artifact
import ( import (
"fmt" "fmt"
"path/filepath"
"github.com/containers/common/pkg/completion" "github.com/containers/common/pkg/completion"
"github.com/containers/podman/v5/cmd/podman/common" "github.com/containers/podman/v5/cmd/podman/common"
@ -61,6 +62,8 @@ func init() {
} }
func add(cmd *cobra.Command, args []string) error { func add(cmd *cobra.Command, args []string) error {
artifactName := args[0]
blobs := args[1:]
opts := new(entities.ArtifactAddOptions) opts := new(entities.ArtifactAddOptions)
annots, err := utils.ParseAnnotations(addOpts.Annotations) annots, err := utils.ParseAnnotations(addOpts.Annotations)
@ -72,7 +75,18 @@ func add(cmd *cobra.Command, args []string) error {
opts.Append = addOpts.Append opts.Append = addOpts.Append
opts.FileType = addOpts.FileType 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 { if err != nil {
return err 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 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 // No such network
// swagger:response // swagger:response
type networkNotFound struct { type networkNotFound struct {

View File

@ -478,3 +478,45 @@ type networkPruneResponse struct {
// in:body // in:body
Body []entities.NetworkPruneReport 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) 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) { func NetworkNotFound(w http.ResponseWriter, name string, err error) {
if !errors.Is(err, define.ErrNoSuchNetwork) { if !errors.Is(err, define.ErrNoSuchNetwork) {
InternalServerError(w, err) 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{ for _, fn := range []func(*mux.Router) error{
server.registerAuthHandlers, server.registerAuthHandlers,
server.registerArtifactHandlers,
server.registerArchiveHandlers, server.registerArchiveHandlers,
server.registerContainersHandlers, server.registerContainersHandlers,
server.registerDistributionHandlers, server.registerDistributionHandlers,

View File

@ -5,6 +5,7 @@ import (
"github.com/containers/image/v5/types" "github.com/containers/image/v5/types"
encconfig "github.com/containers/ocicrypt/config" 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/containers/podman/v5/pkg/libartifact"
"github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
) )
@ -25,6 +26,12 @@ type ArtifactExtractOptions struct {
Digest string Digest string
} }
type ArtifactBlob struct {
BlobReader io.Reader
BlobFilePath string
FileName string
}
type ArtifactInspectOptions struct { type ArtifactInspectOptions struct {
Remote bool Remote bool
} }
@ -34,18 +41,41 @@ type ArtifactListOptions struct {
} }
type ArtifactPullOptions struct { type ArtifactPullOptions struct {
Architecture string // containers-auth.json(5) file to use when authenticating against
AuthFilePath string // container registries.
CertDirPath string 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 InsecureSkipTLSVerify types.OptionalBool
MaxRetries *uint // Maximum number of retries with exponential backoff when facing
OciDecryptConfig *encconfig.DecryptConfig // transient network errors.
Password string // Default 3.
Quiet bool MaxRetries *uint
RetryDelay string // RetryDelay used for the exponential back off of MaxRetries.
SignaturePolicyPath string // Default 1 time.Second.
Username string RetryDelay string
Writer io.Writer // 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 { type ArtifactPushOptions struct {
@ -64,15 +94,16 @@ type ArtifactRemoveOptions struct {
All bool All bool
} }
type ArtifactPullReport struct{} type ArtifactPullReport struct {
ArtifactDigest *digest.Digest
type ArtifactPushReport struct{}
type ArtifactInspectReport struct {
*libartifact.Artifact
Digest string
} }
type ArtifactPushReport struct {
ArtifactDigest *digest.Digest
}
type ArtifactInspectReport = entityTypes.ArtifactInspectReport
type ArtifactListReport struct { type ArtifactListReport struct {
*libartifact.Artifact *libartifact.Artifact
} }

View File

@ -2,6 +2,7 @@ package entities
import ( import (
"context" "context"
"io"
"github.com/containers/common/libimage/define" "github.com/containers/common/libimage/define"
"github.com/containers/common/pkg/config" "github.com/containers/common/pkg/config"
@ -9,8 +10,9 @@ import (
) )
type ImageEngine interface { //nolint:interfacebloat 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 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) ArtifactInspect(ctx context.Context, name string, opts ArtifactInspectOptions) (*ArtifactInspectReport, error)
ArtifactList(ctx context.Context, opts ArtifactListOptions) ([]*ArtifactListReport, error) ArtifactList(ctx context.Context, opts ArtifactListOptions) ([]*ArtifactListReport, error)
ArtifactPull(ctx context.Context, name string, opts ArtifactPullOptions) (*ArtifactPullReport, 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 ( import (
"context" "context"
"fmt"
"io"
"os" "os"
"time" "time"
@ -66,7 +68,7 @@ func (ir *ImageEngine) ArtifactPull(ctx context.Context, name string, opts entit
if opts.RetryDelay != "" { if opts.RetryDelay != "" {
duration, err := time.ParseDuration(opts.RetryDelay) duration, err := time.ParseDuration(opts.RetryDelay)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("unable to parse value provided %q: %w", opts.RetryDelay, err)
} }
pullOptions.RetryDelay = &duration pullOptions.RetryDelay = &duration
} }
@ -78,7 +80,14 @@ func (ir *ImageEngine) ArtifactPull(ctx context.Context, name string, opts entit
if err != nil { if err != nil {
return nil, err 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) { 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: "", IdentityToken: "",
Writer: opts.Writer, 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{
return &entities.ArtifactPushReport{}, err 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() artStore, err := ir.Libpod.ArtifactStore()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if opts.Annotations == nil {
opts.Annotations = make(map[string]string)
}
addOptions := types.AddOptions{ addOptions := types.AddOptions{
Annotations: opts.Annotations, Annotations: opts.Annotations,
ArtifactType: opts.ArtifactType, ArtifactType: opts.ArtifactType,
@ -195,7 +214,7 @@ func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []str
FileType: opts.FileType, FileType: opts.FileType,
} }
artifactDigest, err := artStore.Add(ctx, name, paths, &addOptions) artifactDigest, err := artStore.Add(ctx, name, artifactBlobs, &addOptions)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -218,3 +237,21 @@ func (ir *ImageEngine) ArtifactExtract(ctx context.Context, name string, target
return artStore.Extract(ctx, name, target, extractOpt) 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 ( import (
"context" "context"
"fmt" "fmt"
"io"
"github.com/containers/podman/v5/pkg/domain/entities" "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") 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) { func (ir *ImageEngine) ArtifactInspect(ctx context.Context, name string, opts entities.ArtifactInspectOptions) (*entities.ArtifactInspectReport, error) {
return nil, fmt.Errorf("not implemented") 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") 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") return nil, fmt.Errorf("not implemented")
} }

View File

@ -3,6 +3,8 @@
package store package store
import ( import (
"archive/tar"
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -14,13 +16,16 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/containers/common/libimage" "github.com/containers/common/libimage"
"github.com/containers/image/v5/image" "github.com/containers/image/v5/image"
"github.com/containers/image/v5/manifest" "github.com/containers/image/v5/manifest"
"github.com/containers/image/v5/oci/layout" "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/transports/alltransports"
"github.com/containers/image/v5/types" "github.com/containers/image/v5/types"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/containers/podman/v5/pkg/libartifact" "github.com/containers/podman/v5/pkg/libartifact"
libartTypes "github.com/containers/podman/v5/pkg/libartifact/types" libartTypes "github.com/containers/podman/v5/pkg/libartifact/types"
"github.com/containers/storage/pkg/fileutils" "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 // 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 { if len(name) == 0 {
return ErrEmptyArtifactName return "", ErrEmptyArtifactName
} }
srcRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", name)) srcRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", name))
if err != nil { if err != nil {
return err return "", err
} }
destRef, err := layout.NewReference(as.storePath, name) destRef, err := layout.NewReference(as.storePath, name)
if err != nil { if err != nil {
return err return "", err
} }
copyer, err := libimage.NewCopier(&opts, as.SystemContext) copyer, err := libimage.NewCopier(&opts, as.SystemContext)
if err != nil { if err != nil {
return err return "", err
} }
_, err = copyer.Copy(ctx, srcRef, destRef) artifactBytes, err := copyer.Copy(ctx, srcRef, destRef)
if err != nil { 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 // 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 { if len(dest) == 0 {
return ErrEmptyArtifactName return "", ErrEmptyArtifactName
} }
destRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", dest)) destRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", dest))
if err != nil { if err != nil {
return err return "", err
} }
srcRef, err := layout.NewReference(as.storePath, src) srcRef, err := layout.NewReference(as.storePath, src)
if err != nil { if err != nil {
return err return "", err
} }
copyer, err := libimage.NewCopier(&opts, as.SystemContext) copyer, err := libimage.NewCopier(&opts, as.SystemContext)
if err != nil { if err != nil {
return err return "", err
} }
_, err = copyer.Copy(ctx, srcRef, destRef) artifactBytes, err := copyer.Copy(ctx, srcRef, destRef)
if err != nil { 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. // 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 { if len(dest) == 0 {
return nil, ErrEmptyArtifactName return nil, ErrEmptyArtifactName
} }
@ -229,8 +244,8 @@ func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, op
} }
} }
for _, path := range paths { for _, artifact := range artifactBlobs {
fileName := filepath.Base(path) fileName := artifact.FileName
if _, ok := fileNames[fileName]; ok { if _, ok := fileNames[fileName]; ok {
return nil, fmt.Errorf("%s: %w", fileName, libartTypes.ErrArtifactFileExists) 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. // 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. // This works for the oci/layout transport we hard-code.
for _, path := range paths { for _, artifactBlob := range artifactBlobs {
mediaType := options.FileType if artifactBlob.BlobFilePath == "" && artifactBlob.BlobReader == nil || artifactBlob.BlobFilePath != "" && artifactBlob.BlobReader != nil {
// get the new artifact into the local store return nil, fmt.Errorf("Artifact.BlobFile or Artifact.BlobReader must be provided")
newBlobDigest, newBlobSize, err := layout.PutBlobFromLocalFile(ctx, imageDest, path) }
if err != nil {
return nil, err 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 // If we did not receive an override for the layer's mediatype, use
// detection to determine it. // detection to determine it.
if len(mediaType) < 1 { if options.FileType == "" {
mediaType, err = determineManifestType(path) artifactBlob.BlobReader, newLayer.MediaType, err = determineBlobMIMEType(artifactBlob)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
annotations := maps.Clone(options.Annotations) // get the new artifact into the local store
annotations[specV1.AnnotationTitle] = filepath.Base(path) if artifactBlob.BlobFilePath != "" {
newLayer := specV1.Descriptor{ newBlobDigest, newBlobSize, err := layout.PutBlobFromLocalFile(ctx, imageDest, artifactBlob.BlobFilePath)
MediaType: mediaType, if err != nil {
Digest: newBlobDigest, return nil, err
Size: newBlobSize, }
Annotations: annotations, 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) artifactManifest.Layers = append(artifactManifest.Layers, newLayer)
} }
@ -471,6 +500,60 @@ func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target
return nil 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) { func generateArtifactBlobName(title string, digest digest.Digest) (string, error) {
filename := title filename := title
if len(filename) == 0 { if len(filename) == 0 {
@ -546,6 +629,45 @@ func copyTrustedImageBlobToFile(ctx context.Context, imgSrc types.ImageSource, d
return err 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 // readIndex is currently unused but I want to keep this around until
// the artifact code is more mature. // the artifact code is more mature.
func (as ArtifactStore) readIndex() (*specV1.Index, error) { //nolint:unused 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) return os.WriteFile(path, specV1.DescriptorEmptyJSON.Data, 0644)
} }
func determineManifestType(path string) (string, error) { // determineBlobMIMEType reads up to 512 bytes into a buffer
f, err := os.Open(path) // without advancing the read position of the io.Reader.
if err != nil { // If http.DetectContentType is unable to determine a valid
return "", err // 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 var (
b := make([]byte, 512) err error
// Because DetectContentType will return a default value mimeBuf []byte
// we don't sweat the error peekBuffer *bufio.Reader
n, err := f.Read(b) )
if err != nil && !errors.Is(err, io.EOF) {
return "", err 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
} }

View File

@ -1,14 +1,97 @@
import json import json
import os
import random
import subprocess import subprocess
import sys
import time
import unittest import unittest
import requests import requests
import sys
import time
from .podman import Podman 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): class APITestCase(unittest.TestCase):
PODMAN_URL = "http://localhost:8080" PODMAN_URL = "http://localhost:8080"
podman = None # initialized podman configuration for tests podman = None # initialized podman configuration for tests
@ -40,7 +123,7 @@ class APITestCase(unittest.TestCase):
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
APITestCase.service.terminate() APITestCase.service.terminate()
stdout, stderr = APITestCase.service.communicate(timeout=0.5) stdout, stderr = APITestCase.service.communicate(timeout=1)
if stdout: if stdout:
sys.stdout.write("\nService Stdout:\n" + stdout.decode("utf-8")) sys.stdout.write("\nService Stdout:\n" + stdout.decode("utf-8"))
if stderr: if stderr:
@ -61,7 +144,7 @@ class APITestCase(unittest.TestCase):
return "http://localhost:8080" return "http://localhost:8080"
@staticmethod @staticmethod
def uri(path): def uri(path: str) -> str:
return APITestCase.PODMAN_URL + "/v2.0.0/libpod" + path return APITestCase.PODMAN_URL + "/v2.0.0/libpod" + path
@staticmethod @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()