Skip to content

feat: Add OCI Artifact support to the Podman REST API #26111

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion cmd/podman/artifact/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package artifact

import (
"fmt"
"path/filepath"

"github.com/containers/common/pkg/completion"
"github.com/containers/podman/v5/cmd/podman/common"
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
336 changes: 336 additions & 0 deletions pkg/api/handlers/libpod/artifacts.go
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should there be a fall through and something like an error here?

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would love // comments in here as to these conditions. maybe im on the only like that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I will add comments, it's not super clear.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about using named constants like http.StatusUnauthorized instead of hard-coding numbers? That might even be self-documenting enough that the separate comments wouldn’t be necessary.


Paying more attention to what is going on here, one thing to consider is that

  • c/image doesn’t quite promise that returned errors match github.com/docker/distribution/registry/api/errcode
  • just recently, github.com/docker/distribution released a new major version, and a version of these types will be github.com/distribution/distribution/v3/… .

Now, that puts us in a somewhat awkward position in that we either keep using an obsolete and unmaintained package, or break (an undocumented aspect of an) API.

I don’t know what we are going to do here longer-term; one thing I’d recommend doing right now is checking for c/image/docker.ErrUnauthorizedForCredentials instead of 401 via the ErrCode dependency. That’s not a comprehensive solution because c/image does not provide a stable API for “manifest not found” yet — that would need to be added there.


(Pedantically, ErrorCode().Descriptor().HTTPStatusCode documents status codes that a registry server should return in these situations. They don’t strictly speaking are defined to affect what a container engine HTTP API should return, although there is obviously some similarity.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mtrmac Great call the on the named constants, much more readable.

Regarding using c/image/docker.ErrUnauthorizedForCredentials, I don't see that error in the wrapped error tree returned from imageEngine.ArtifactPull() [1]

I used the errcode method because it's what we use in image pull. Not to say that changes anything, just to let you know the dependency is else where as well.

[1]

- *fmt.wrapError: initializing source docker://quay.io/libpod_secret/testartifact:latest: reading manifest latest in quay.io/libpod_secret/testartifact: unauthorized: access to the requested resource is not authorized
  - *fmt.wrapError: reading manifest latest in quay.io/libpod_secret/testartifact: unauthorized: access to the requested resource is not authorized
    - errcode.Error: unauthorized: access to the requested resource is not authorized

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My mistake, ErrUnauthorizedForCredentials is returned when the credentials are invalid, i.e. when the user uses some credentials

The quoted error seems to be when the user did not try to log in at all, it’s just the repo’s access that is refused (and the same error is returned by Quay when the repo outright does not exist[1]). So, that’s a different case, and currently needs to be determined by looking for the errcode.Error value.


([1] Given this ambiguity, Podman might want to be careful about promising to return specific error types for “not found” vs. “not authorized” from its API, because various registries intentionally conflate the two. I suppose doing the same thing that the “image pull” code does is, by definition, acceptable … but that code, AFAICS, doesn’t make (or at least doesn’t fulfill) such a promise.)

// 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth a dedicated error for "name was not provided" if name == ""

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that is needed here because name is provided on the path


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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the name be part of the query structure? As in PullArtifact.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to change it, the reason I did it like these is just to mimic the image pull and push endpoints. This might be something we can discuss on the Change Request document?


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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need a fallthrough?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe?

query.TLSVerify can only be OptionalBoolTrue, OptionalBoolFalse or OptionalBoolUndefined. We handle the first two because we have to switch the logic and we pass OptionalBoolUndefined to be handled by the backend code as suggested by Paul: #26111 (comment)

I looked into this some more and it's quite confusing where the default would be defined in the backend code.

If you would like I can add a switch for OptionalBoolUndefined and have it set artifactsPushOptions.SkipTLSVerify = types.NewOptionalBool(false) so the default from the handler would be having TLS enabled.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason we must pass undefined in the backed is because registries.conf allows user to specify insecure option on a per registry basis, for example in newDockerClient() in reads the config file and sets the options accordingly. If a caller always hards code true or false then this gets always overwritten and the config file is ignored which would be a bug.
As undefined is the default anyway for this option I don't think we must handle the case here explicitly

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, undefined must map to undefined, as Paul says.


Ideally, I’d like there to be a default: that explicitly fails, or explicitly turns on TLS, for any unexpected values. And I’d like the logic centralized into a single helper, not copy&pasted all over the place; it’s easy enough to miss a ! somewhere.

Compare #26111 (comment) .

That’s probably out of scope of this PR, or at least the “centralized logic” part is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks folks, I added a default case that will fall back to enabling TLS if unexpected handling occurred.

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
}
}
14 changes: 14 additions & 0 deletions pkg/api/handlers/swagger/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading