diff --git a/pkg/api/handlers/libpod/artifacts.go b/pkg/api/handlers/libpod/artifacts.go index 6622ba8820..d67ae86b52 100644 --- a/pkg/api/handlers/libpod/artifacts.go +++ b/pkg/api/handlers/libpod/artifacts.go @@ -5,8 +5,11 @@ package libpod import ( "errors" "fmt" + "io/fs" "net/http" + "path/filepath" + "github.com/containers/podman/v5/internal/localapi" "github.com/containers/podman/v5/libpod" "github.com/containers/podman/v5/pkg/api/handlers/utils" api "github.com/containers/podman/v5/pkg/api/types" @@ -212,19 +215,21 @@ func BatchRemoveArtifact(w http.ResponseWriter, r *http.Request) { utils.WriteResponse(w, http.StatusOK, artifacts) } +type artifactAddRequestQuery 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"` + Replace bool `schema:"replace"` + Path string `schema:"path"` +} + 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"` - Replace bool `schema:"replace"` - }{} + query := artifactAddRequestQuery{} 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)) @@ -236,6 +241,53 @@ func AddArtifact(w http.ResponseWriter, r *http.Request) { return } + artifactBlobs := []entities.ArtifactBlob{{ + BlobReader: r.Body, + FileName: query.FileName, + }} + + addArtifactHelper(query, artifactBlobs, w, r) +} + +func AddLocalArtifact(w http.ResponseWriter, r *http.Request) { + decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) + + query := artifactAddRequestQuery{} + + 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 + } + + cleanPath := filepath.Clean(query.Path) + // Check if the path exists on server side. + // Note: localapi.ValidatePathForLocalAPI returns nil if the file exists and path is absolute, not an error. + switch err := localapi.ValidatePathForLocalAPI(cleanPath); { + case err == nil: + // no error -> continue + case errors.Is(err, fs.ErrNotExist): + utils.Error(w, http.StatusNotFound, fmt.Errorf("file does not exist: %q", cleanPath)) + return + default: + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("failed to access file: %w", err)) + return + } + + artifactBlobs := []entities.ArtifactBlob{{ + BlobFilePath: cleanPath, + FileName: query.FileName, + }} + + addArtifactHelper(query, artifactBlobs, w, r) +} + +func addArtifactHelper(query artifactAddRequestQuery, artifactBlobs []entities.ArtifactBlob, w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) annotations, err := domain_utils.ParseAnnotations(query.Annotations) if err != nil { utils.Error(w, http.StatusBadRequest, err) @@ -250,13 +302,7 @@ func AddArtifact(w http.ResponseWriter, r *http.Request) { Replace: query.Replace, } - 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) { diff --git a/pkg/api/server/register_artifacts.go b/pkg/api/server/register_artifacts.go index 123984c6fb..d7e58ebbf1 100644 --- a/pkg/api/server/register_artifacts.go +++ b/pkg/api/server/register_artifacts.go @@ -212,6 +212,65 @@ func (s *APIServer) registerArtifactHandlers(r *mux.Router) error { // 500: // $ref: "#/responses/internalError" r.Handle(VersionedPath("/libpod/artifacts/add"), s.APIHandler(libpod.AddArtifact)).Methods(http.MethodPost) + // swagger:operation POST /libpod/artifacts/local/add libpod ArtifactLocalLibpod + // --- + // tags: + // - artifacts + // summary: Add a local file as an artifact + // description: | + // Add a file from the local filesystem as a new OCI artifact, or append to an existing artifact if 'append' is true. + // 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: path + // in: query + // description: Absolute path to the local file on the server filesystem to be added + // required: true + // type: string + // - name: fileName + // in: query + // description: Name/title of the file within the artifact + // required: true + // type: string + // - name: fileMIMEType + // in: query + // description: Optionally set the MIME type of the 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: replace + // in: query + // description: Replace an existing artifact with the same name + // type: boolean + // default: false + // responses: + // 201: + // $ref: "#/responses/artifactAddResponse" + // 400: + // $ref: "#/responses/badParamError" + // 404: + // $ref: "#/responses/artifactNotFound" + // 500: + // $ref: "#/responses/internalError" + r.Handle(VersionedPath("/libpod/artifacts/local/add"), s.APIHandler(libpod.AddLocalArtifact)).Methods(http.MethodPost) // swagger:operation POST /libpod/artifacts/{name}/push libpod ArtifactPushLibpod // --- // tags: diff --git a/pkg/bindings/artifacts/add.go b/pkg/bindings/artifacts/add.go index aa497811eb..4854482170 100644 --- a/pkg/bindings/artifacts/add.go +++ b/pkg/bindings/artifacts/add.go @@ -4,17 +4,31 @@ import ( "context" "io" "net/http" + "net/url" "github.com/containers/podman/v5/pkg/bindings" + "github.com/containers/podman/v5/pkg/domain/entities" entitiesTypes "github.com/containers/podman/v5/pkg/domain/entities/types" ) func Add(ctx context.Context, artifactName string, blobName string, artifactBlob io.Reader, options *AddOptions) (*entitiesTypes.ArtifactAddReport, error) { - conn, err := bindings.GetClient(ctx) + params, err := prepareParams(artifactName, blobName, options) if err != nil { return nil, err } + return helperAdd(ctx, "/artifacts/add", params, artifactBlob) +} +func AddLocal(ctx context.Context, artifactName string, blobName string, blobPath string, options *AddOptions) (*entitiesTypes.ArtifactAddReport, error) { + params, err := prepareParams(artifactName, blobName, options) + if err != nil { + return nil, err + } + params.Set("path", blobPath) + return helperAdd(ctx, "/artifacts/local/add", params, nil) +} + +func prepareParams(name string, fileName string, options *AddOptions) (url.Values, error) { if options == nil { options = new(AddOptions) } @@ -24,16 +38,25 @@ func Add(ctx context.Context, artifactName string, blobName string, artifactBlob return nil, err } - params.Set("name", artifactName) - params.Set("fileName", blobName) + params.Set("name", name) + params.Set("fileName", fileName) - response, err := conn.DoRequest(ctx, artifactBlob, http.MethodPost, "/artifacts/add", params, nil) + return params, nil +} + +func helperAdd(ctx context.Context, endpoint string, params url.Values, artifactBlob io.Reader) (*entities.ArtifactAddReport, error) { + conn, err := bindings.GetClient(ctx) + if err != nil { + return nil, err + } + + response, err := conn.DoRequest(ctx, artifactBlob, http.MethodPost, endpoint, params, nil) if err != nil { return nil, err } defer response.Body.Close() - var artifactAddReport entitiesTypes.ArtifactAddReport + var artifactAddReport entities.ArtifactAddReport if err := response.Process(&artifactAddReport); err != nil { return nil, err } diff --git a/pkg/domain/infra/tunnel/artifact.go b/pkg/domain/infra/tunnel/artifact.go index 19cbd2126c..41ccd661e6 100644 --- a/pkg/domain/infra/tunnel/artifact.go +++ b/pkg/domain/infra/tunnel/artifact.go @@ -5,10 +5,13 @@ import ( "errors" "fmt" "io" + "net/http" "os" + "github.com/containers/podman/v5/internal/localapi" "github.com/containers/podman/v5/pkg/bindings/artifacts" "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/containers/podman/v5/pkg/errorhandling" "go.podman.io/image/v5/types" ) @@ -101,26 +104,57 @@ func (ir *ImageEngine) ArtifactAdd(_ context.Context, name string, artifactBlob // When adding more than 1 blob, set append true after the first options.WithAppend(true) } - f, err := os.Open(blob.BlobFilePath) - if err != nil { - return nil, err - } - defer f.Close() - artifactAddReport, err = artifacts.Add(ir.ClientCtx, name, blob.FileName, f, &options) - if err != nil && i > 0 { - removeOptions := artifacts.RemoveOptions{ - Artifacts: []string{name}, + var err error + if localMap, ok := localapi.CheckPathOnRunningMachine(ir.ClientCtx, blob.BlobFilePath); ok { + artifactAddReport, err = artifacts.AddLocal(ir.ClientCtx, name, blob.FileName, localMap.RemotePath, &options) + if err == nil { + continue } - _, recoverErr := artifacts.Remove(ir.ClientCtx, "", &removeOptions) - if recoverErr != nil { - return nil, fmt.Errorf("failed to cleanup unfinished artifact add: %w", errors.Join(err, recoverErr)) + var errModel *errorhandling.ErrorModel + if errors.As(err, &errModel) { + switch errModel.ResponseCode { + case http.StatusNotFound, http.StatusMethodNotAllowed: + default: + return nil, artifactAddErrorCleanup(ir.ClientCtx, i, name, err) + } + } else { + return nil, artifactAddErrorCleanup(ir.ClientCtx, i, name, err) } - return nil, err } + + artifactAddReport, err = addArtifact(ir.ClientCtx, name, i, blob, &options) if err != nil { return nil, err } } return artifactAddReport, nil } + +func artifactAddErrorCleanup(ctx context.Context, index int, name string, err error) error { + if index == 0 { + return err + } + removeOptions := artifacts.RemoveOptions{ + Artifacts: []string{name}, + } + _, recoverErr := artifacts.Remove(ctx, "", &removeOptions) + if recoverErr != nil { + return fmt.Errorf("failed to cleanup unfinished artifact add: %w", errors.Join(err, recoverErr)) + } + return err +} + +func addArtifact(ctx context.Context, name string, index int, blob entities.ArtifactBlob, options *artifacts.AddOptions) (*entities.ArtifactAddReport, error) { + f, err := os.Open(blob.BlobFilePath) + if err != nil { + return nil, err + } + defer f.Close() + + artifactAddReport, err := artifacts.Add(ctx, name, blob.FileName, f, options) + if err != nil { + return nil, artifactAddErrorCleanup(ctx, index, name, err) + } + return artifactAddReport, nil +} diff --git a/test/apiv2/python/rest_api/fixtures/api_testcase.py b/test/apiv2/python/rest_api/fixtures/api_testcase.py index 6fb037339e..01330300b6 100644 --- a/test/apiv2/python/rest_api/fixtures/api_testcase.py +++ b/test/apiv2/python/rest_api/fixtures/api_testcase.py @@ -84,6 +84,19 @@ class Artifact: os.remove(self.file.name) return r + def add_local(self) -> requests.Response: + try: + r = requests.post( + self.uri + "/artifacts/local/add", + params=self.parameters, + ) + except Exception: + pass + + if self.file is not None and os.path.exists(self.file.name): + os.remove(self.file.name) + return r + def do_artifact_inspect_request(self) -> requests.Response: r = requests.get( self.uri + "/artifacts/" + self.name + "/json", diff --git a/test/apiv2/python/rest_api/test_v2_0_0_artifact.py b/test/apiv2/python/rest_api/test_v2_0_0_artifact.py index 919a578d7c..eca5170c1b 100644 --- a/test/apiv2/python/rest_api/test_v2_0_0_artifact.py +++ b/test/apiv2/python/rest_api/test_v2_0_0_artifact.py @@ -2,6 +2,7 @@ import os import tarfile import unittest from typing import cast +from pathlib import Path import requests @@ -43,6 +44,40 @@ class ArtifactTestCase(APITestCase): # Assert blob media type fallback detection is working self.assertEqual(artifact_layer["mediaType"], "application/octet-stream") + def test_add_local(self): + ARTIFACT_NAME = "quay.io/myimage/mylocalartifact:latest" + file = ArtifactFile() + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": file.name, + "path": Path(file.name).absolute(), + } + + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, file) + + add_response = artifact.add_local() + + # 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_replace(self): ARTIFACT_NAME = "quay.io/myimage/newartifact:latest" @@ -317,6 +352,52 @@ class ArtifactTestCase(APITestCase): "name and file parameters are required", ) + def test_add_local_with_not_existing_file(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": "notexistsfile", + "path": "/home/notexistsfile", + } + + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, None) + + r = artifact.add_local() + + 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"], + 'file does not exist: "/home/notexistsfile"', + ) + + def test_add_local_with_not_absolute_path(self): + ARTIFACT_NAME = "quay.io/myimage/myartifact:latest" + parameters: dict[str, str | list[str]] = { + "name": ARTIFACT_NAME, + "fileName": "notexistsfile", + "path": "../../etc/passwd", + } + + artifact = Artifact(self.uri(""), ARTIFACT_NAME, parameters, None) + + r = artifact.add_local() + + 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"], + 'path "../../etc/passwd" is not absolute', + ) + def test_inspect(self): ARTIFACT_NAME = "quay.io/myimage/myartifact_mime_type:latest" @@ -545,7 +626,6 @@ class ArtifactTestCase(APITestCase): "artifact does not exist", ) - def test_remove_all(self): # Create some artifacts to remove artifact_names = [ @@ -595,7 +675,6 @@ class ArtifactTestCase(APITestCase): url = self.uri("/artifacts/remove") r = requests.delete(url, params=removeparameters) - rjson = r.json() # Assert correct response code self.assertEqual(r.status_code, 200, r.text)