mirror of https://github.com/containers/podman.git
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:
parent
6a39f37845
commit
99cfdc04db
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,336 @@
|
|||
//go:build !remote
|
||||
|
||||
package libpod
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/containers/image/v5/oci/layout"
|
||||
"github.com/containers/image/v5/types"
|
||||
"github.com/containers/podman/v5/libpod"
|
||||
"github.com/containers/podman/v5/pkg/api/handlers/utils"
|
||||
api "github.com/containers/podman/v5/pkg/api/types"
|
||||
"github.com/containers/podman/v5/pkg/auth"
|
||||
"github.com/containers/podman/v5/pkg/domain/entities"
|
||||
"github.com/containers/podman/v5/pkg/domain/infra/abi"
|
||||
domain_utils "github.com/containers/podman/v5/pkg/domain/utils"
|
||||
libartifact_types "github.com/containers/podman/v5/pkg/libartifact/types"
|
||||
"github.com/docker/distribution/registry/api/errcode"
|
||||
"github.com/gorilla/schema"
|
||||
)
|
||||
|
||||
func InspectArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
|
||||
|
||||
name := utils.GetName(r)
|
||||
|
||||
imageEngine := abi.ImageEngine{Libpod: runtime}
|
||||
|
||||
report, err := imageEngine.ArtifactInspect(r.Context(), name, entities.ArtifactInspectOptions{})
|
||||
if err != nil {
|
||||
if errors.Is(err, libartifact_types.ErrArtifactNotExist) {
|
||||
utils.ArtifactNotFound(w, name, err)
|
||||
return
|
||||
} else {
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.WriteResponse(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func ListArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
|
||||
|
||||
imageEngine := abi.ImageEngine{Libpod: runtime}
|
||||
|
||||
artifacts, err := imageEngine.ArtifactList(r.Context(), entities.ArtifactListOptions{})
|
||||
if err != nil {
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteResponse(w, http.StatusOK, artifacts)
|
||||
}
|
||||
|
||||
func PullArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
|
||||
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
|
||||
|
||||
query := struct {
|
||||
Name string `schema:"name"`
|
||||
Retry uint `schema:"retry"`
|
||||
RetryDelay string `schema:"retryDelay"`
|
||||
TLSVerify types.OptionalBool `schema:"tlsVerify"`
|
||||
}{}
|
||||
|
||||
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
|
||||
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err))
|
||||
return
|
||||
}
|
||||
|
||||
if query.Name == "" {
|
||||
utils.Error(w, http.StatusBadRequest, errors.New("name parameter is required"))
|
||||
return
|
||||
}
|
||||
|
||||
artifactsPullOptions := entities.ArtifactPullOptions{}
|
||||
|
||||
// If TLS verification is explicitly specified (True or False) in the query,
|
||||
// set the InsecureSkipTLSVerify option accordingly.
|
||||
// If TLSVerify was not set in the query, OptionalBoolUndefined is used and
|
||||
// handled later based off the target registry configuration.
|
||||
switch query.TLSVerify {
|
||||
case types.OptionalBoolTrue:
|
||||
artifactsPullOptions.InsecureSkipTLSVerify = types.NewOptionalBool(false)
|
||||
case types.OptionalBoolFalse:
|
||||
artifactsPullOptions.InsecureSkipTLSVerify = types.NewOptionalBool(true)
|
||||
case types.OptionalBoolUndefined:
|
||||
// If the user doesn't define TLSVerify in the query, do nothing and pass
|
||||
// it to the backend code to handle.
|
||||
default: // Should never happen
|
||||
panic("Unexpected handling occurred for TLSVerify")
|
||||
}
|
||||
|
||||
if _, found := r.URL.Query()["retry"]; found {
|
||||
artifactsPullOptions.MaxRetries = &query.Retry
|
||||
}
|
||||
|
||||
if len(query.RetryDelay) != 0 {
|
||||
artifactsPullOptions.RetryDelay = query.RetryDelay
|
||||
}
|
||||
|
||||
authConf, authfile, err := auth.GetCredentials(r)
|
||||
if err != nil {
|
||||
utils.Error(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer auth.RemoveAuthfile(authfile)
|
||||
|
||||
artifactsPullOptions.AuthFilePath = authfile
|
||||
if authConf != nil {
|
||||
artifactsPullOptions.Username = authConf.Username
|
||||
artifactsPullOptions.Password = authConf.Password
|
||||
artifactsPullOptions.IdentityToken = authConf.IdentityToken
|
||||
}
|
||||
|
||||
imageEngine := abi.ImageEngine{Libpod: runtime}
|
||||
|
||||
artifacts, err := imageEngine.ArtifactPull(r.Context(), query.Name, artifactsPullOptions)
|
||||
if err != nil {
|
||||
var errcd errcode.ErrorCoder
|
||||
// Check to see if any of the wrapped errors is an errcode.ErrorCoder returned from the registry
|
||||
if errors.As(err, &errcd) {
|
||||
rc := errcd.ErrorCode().Descriptor().HTTPStatusCode
|
||||
// Check if the returned error is 401 StatusUnauthorized indicating the request was unauthorized
|
||||
if rc == http.StatusUnauthorized {
|
||||
utils.Error(w, http.StatusUnauthorized, errcd.ErrorCode())
|
||||
return
|
||||
}
|
||||
// Check if the returned error is 404 StatusNotFound indicating the artifact was not found
|
||||
if rc == http.StatusNotFound {
|
||||
utils.Error(w, http.StatusNotFound, errcd.ErrorCode())
|
||||
return
|
||||
}
|
||||
}
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteResponse(w, http.StatusOK, artifacts)
|
||||
}
|
||||
|
||||
func RemoveArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
|
||||
imageEngine := abi.ImageEngine{Libpod: runtime}
|
||||
|
||||
name := utils.GetName(r)
|
||||
|
||||
artifacts, err := imageEngine.ArtifactRm(r.Context(), name, entities.ArtifactRemoveOptions{})
|
||||
if err != nil {
|
||||
if errors.Is(err, libartifact_types.ErrArtifactNotExist) {
|
||||
utils.ArtifactNotFound(w, name, err)
|
||||
return
|
||||
}
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteResponse(w, http.StatusOK, artifacts)
|
||||
}
|
||||
|
||||
func AddArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
|
||||
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
|
||||
|
||||
query := struct {
|
||||
Name string `schema:"name"`
|
||||
FileName string `schema:"fileName"`
|
||||
FileMIMEType string `schema:"fileMIMEType"`
|
||||
Annotations []string `schema:"annotations"`
|
||||
ArtifactMIMEType string `schema:"artifactMIMEType"`
|
||||
Append bool `schema:"append"`
|
||||
}{}
|
||||
|
||||
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
|
||||
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err))
|
||||
return
|
||||
}
|
||||
|
||||
if query.Name == "" || query.FileName == "" {
|
||||
utils.Error(w, http.StatusBadRequest, errors.New("name and file parameters are required"))
|
||||
return
|
||||
}
|
||||
|
||||
annotations, err := domain_utils.ParseAnnotations(query.Annotations)
|
||||
if err != nil {
|
||||
utils.Error(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
artifactAddOptions := &entities.ArtifactAddOptions{
|
||||
Append: query.Append,
|
||||
Annotations: annotations,
|
||||
ArtifactType: query.ArtifactMIMEType,
|
||||
FileType: query.FileMIMEType,
|
||||
}
|
||||
|
||||
artifactBlobs := []entities.ArtifactBlob{{
|
||||
BlobReader: r.Body,
|
||||
FileName: query.FileName,
|
||||
}}
|
||||
|
||||
imageEngine := abi.ImageEngine{Libpod: runtime}
|
||||
|
||||
artifacts, err := imageEngine.ArtifactAdd(r.Context(), query.Name, artifactBlobs, artifactAddOptions)
|
||||
if err != nil {
|
||||
if errors.Is(err, libartifact_types.ErrArtifactNotExist) {
|
||||
utils.ArtifactNotFound(w, query.Name, err)
|
||||
return
|
||||
}
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteResponse(w, http.StatusCreated, artifacts)
|
||||
}
|
||||
|
||||
func PushArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
|
||||
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
|
||||
|
||||
query := struct {
|
||||
Retry uint `schema:"retry"`
|
||||
RetryDelay string `schema:"retrydelay"`
|
||||
TLSVerify types.OptionalBool `schema:"tlsVerify"`
|
||||
}{}
|
||||
|
||||
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
|
||||
utils.Error(w, http.StatusBadRequest, errors.New("name parameter is required"))
|
||||
return
|
||||
}
|
||||
|
||||
name := utils.GetName(r)
|
||||
|
||||
artifactsPushOptions := entities.ArtifactPushOptions{}
|
||||
|
||||
// If TLS verification is explicitly specified (True or False) in the query,
|
||||
// set the SkipTLSVerify option accordingly.
|
||||
// If TLSVerify was not set in the query, OptionalBoolUndefined is used and
|
||||
// handled later based off the target registry configuration.
|
||||
switch query.TLSVerify {
|
||||
case types.OptionalBoolTrue:
|
||||
artifactsPushOptions.SkipTLSVerify = types.NewOptionalBool(false)
|
||||
case types.OptionalBoolFalse:
|
||||
artifactsPushOptions.SkipTLSVerify = types.NewOptionalBool(true)
|
||||
case types.OptionalBoolUndefined:
|
||||
// If the user doesn't define TLSVerify in the query, do nothing and pass
|
||||
// it to the backend code to handle.
|
||||
default: // Should never happen
|
||||
panic("Unexpected handling occurred for TLSVerify")
|
||||
}
|
||||
|
||||
if _, found := r.URL.Query()["retry"]; found {
|
||||
artifactsPushOptions.Retry = &query.Retry
|
||||
}
|
||||
|
||||
if len(query.RetryDelay) != 0 {
|
||||
artifactsPushOptions.RetryDelay = query.RetryDelay
|
||||
}
|
||||
|
||||
authConf, authfile, err := auth.GetCredentials(r)
|
||||
if err != nil {
|
||||
utils.Error(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer auth.RemoveAuthfile(authfile)
|
||||
|
||||
if authConf != nil {
|
||||
artifactsPushOptions.Username = authConf.Username
|
||||
artifactsPushOptions.Password = authConf.Password
|
||||
}
|
||||
|
||||
imageEngine := abi.ImageEngine{Libpod: runtime}
|
||||
|
||||
artifacts, err := imageEngine.ArtifactPush(r.Context(), name, artifactsPushOptions)
|
||||
if err != nil {
|
||||
var errcd errcode.ErrorCoder
|
||||
// Check to see if any of the wrapped errors is an errcode.ErrorCoder returned from the registry
|
||||
if errors.As(err, &errcd) {
|
||||
rc := errcd.ErrorCode().Descriptor().HTTPStatusCode
|
||||
// Check if the returned error is 401 indicating the request was unauthorized
|
||||
if rc == 401 {
|
||||
utils.Error(w, 401, errcd.ErrorCode())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var notFoundErr layout.ImageNotFoundError
|
||||
if errors.As(err, ¬FoundErr) {
|
||||
utils.ArtifactNotFound(w, name, notFoundErr)
|
||||
return
|
||||
}
|
||||
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteResponse(w, http.StatusOK, artifacts)
|
||||
}
|
||||
|
||||
func ExtractArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
|
||||
decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder)
|
||||
|
||||
query := struct {
|
||||
Digest string `schema:"digest"`
|
||||
Title string `schema:"title"`
|
||||
}{}
|
||||
|
||||
if err := decoder.Decode(&query, r.URL.Query()); err != nil {
|
||||
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err))
|
||||
return
|
||||
}
|
||||
|
||||
extractOpts := entities.ArtifactExtractOptions{
|
||||
Title: query.Title,
|
||||
Digest: query.Digest,
|
||||
}
|
||||
|
||||
name := utils.GetName(r)
|
||||
|
||||
imageEngine := abi.ImageEngine{Libpod: runtime}
|
||||
|
||||
err := imageEngine.ArtifactExtractTarStream(r.Context(), w, name, &extractOpts)
|
||||
if err != nil {
|
||||
if errors.Is(err, libartifact_types.ErrArtifactNotExist) {
|
||||
utils.ArtifactNotFound(w, name, err)
|
||||
return
|
||||
}
|
||||
utils.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package types
|
||||
|
||||
import "github.com/containers/podman/v5/pkg/libartifact"
|
||||
|
||||
type ArtifactInspectReport struct {
|
||||
*libartifact.Artifact
|
||||
Digest string
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
||||
err = artStore.Push(ctx, name, name, copyOpts)
|
||||
return &entities.ArtifactPushReport{}, err
|
||||
artifactDigest, err := artStore.Push(ctx, name, name, copyOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []string, opts *entities.ArtifactAddOptions) (*entities.ArtifactAddReport, error) {
|
||||
|
||||
return &entities.ArtifactPushReport{
|
||||
ArtifactDigest: &artifactDigest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 copyer.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Add takes one or more local files and adds them to the local artifact store. The empty
|
||||
err = copyer.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
artifactDigest := digest.FromBytes(artifactBytes)
|
||||
return artifactDigest, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
Loading…
Reference in New Issue