Skip to content
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

Add support for OCI Image Manifest #261

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ log = "0.4"
mime = "0.3"
regex = "^1.1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_json = { version = "1", features = ["raw_value"] }
serde_ignored = "0.1"
strum = "0.23"
strum_macros = "0.23"
Expand Down
2 changes: 2 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#[non_exhaustive]
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Api Error: {0}")]
Api(#[from] crate::v2::ApiErrors),
#[error("base64 decode error")]
Base64Decode(#[from] base64::DecodeError),
#[error("header parse error")]
Expand Down
10 changes: 10 additions & 0 deletions src/mediatypes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ pub enum MediaTypes {
#[strum(serialize = "application/vnd.docker.container.image.v1+json")]
#[strum(props(Sub = "vnd.docker.container.image.v1+json"))]
ContainerConfigV1,
/// OCI Manifest
#[strum(serialize = "application/vnd.oci.image.manifest.v1+json")]
#[strum(props(Sub = "vnd.oci.image.manifest.v1+json"))]
OciImageManifest,
// OCI Image index
#[strum(serialize = "application/vnd.oci.image.index.v1+json")]
#[strum(props(Sub = "vnd.oci.image.index.v1+json"))]
OciImageIndexV1,
/// Generic JSON
#[strum(serialize = "application/json")]
#[strum(props(Sub = "json"))]
Expand All @@ -55,6 +63,8 @@ impl MediaTypes {
}
("vnd.docker.image.rootfs.diff.tar.gzip", _) => Ok(MediaTypes::ImageLayerTgz),
("vnd.docker.container.image.v1", "json") => Ok(MediaTypes::ContainerConfigV1),
("vnd.oci.image.manifest.v1", "json") => Ok(MediaTypes::OciImageManifest),
("vnd.oci.image.index.v1", "json") => Ok(MediaTypes::OciImageIndexV1),
_ => Err(crate::Error::UnknownMimeType(mtype.clone())),
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/v2/blobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ impl Client {
}
Ok(BlobResponse::new(resp, ContentDigest::try_new(digest)?))
}
Err(_) if status.is_client_error() => Err(Error::Client { status }),
Err(_) if status.is_client_error() => Err(ApiErrors::from(resp).await),
Err(_) if status.is_server_error() => Err(Error::Server { status }),
Err(_) => {
error!("Received unexpected HTTP status '{}'", status);
Expand Down
4 changes: 4 additions & 0 deletions src/v2/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ impl Config {
(MediaTypes::ManifestV2S2, Some(0.5)),
(MediaTypes::ManifestV2S1Signed, Some(0.4)),
(MediaTypes::ManifestList, Some(0.5)),
(MediaTypes::OciImageManifest, Some(0.5)),
(MediaTypes::OciImageIndexV1, Some(0.5)),
],
// GCR incorrectly parses `q` parameters, so we use special Accept for it.
// Bug: https://issuetracker.google.com/issues/159827510.
Expand All @@ -122,6 +124,8 @@ impl Config {
(MediaTypes::ManifestV2S2, None),
(MediaTypes::ManifestV2S1Signed, None),
(MediaTypes::ManifestList, None),
(MediaTypes::OciImageManifest, None),
(MediaTypes::OciImageIndexV1, None),
],
},
};
Expand Down
6 changes: 4 additions & 2 deletions src/v2/manifest/manifest_schema2.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::errors::{Error, Result};
use crate::errors::Result;
use reqwest::Method;

pub use crate::v2::ApiErrors;

/// Manifest version 2 schema 2.
///
/// Specification is at <https://docs.docker.com/registry/spec/manifest-v2-2/>.
Expand Down Expand Up @@ -112,7 +114,7 @@ impl ManifestSchema2Spec {
trace!("GET {:?}: {}", url, &status);

if !status.is_success() {
return Err(Error::UnexpectedHttpStatus(status));
return Err(ApiErrors::from(r).await);
}

let config_blob = r.json::<ConfigBlob>().await?;
Expand Down
10 changes: 5 additions & 5 deletions src/v2/manifest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ impl Client {

match status {
StatusCode::OK => {}
_ => return Err(Error::UnexpectedHttpStatus(status)),
_ => return Err(ApiErrors::from(res).await),
}

let headers = res.headers();
Expand Down Expand Up @@ -78,7 +78,7 @@ impl Client {
.map(Manifest::S1Signed)?,
content_digest,
)),
mediatypes::MediaTypes::ManifestV2S2 => {
mediatypes::MediaTypes::ManifestV2S2 | mediatypes::MediaTypes::OciImageManifest => {
let m = res.json::<ManifestSchema2Spec>().await?;
Ok((
m.fetch_config_blob(client_spare0, name.to_string())
Expand All @@ -87,7 +87,7 @@ impl Client {
content_digest,
))
}
mediatypes::MediaTypes::ManifestList => Ok((
mediatypes::MediaTypes::ManifestList | mediatypes::MediaTypes::OciImageIndexV1 => Ok((
res.json::<ManifestList>().await.map(Manifest::ML)?,
content_digest,
)),
Expand Down Expand Up @@ -122,7 +122,7 @@ impl Client {

match status {
StatusCode::OK => {}
_ => return Err(Error::UnexpectedHttpStatus(status)),
_ => return Err(ApiErrors::from(res).await),
}

let headers = res.headers();
Expand Down Expand Up @@ -190,7 +190,7 @@ impl Client {
Ok(Some(media_type))
}
StatusCode::NOT_FOUND => Ok(None),
_ => Err(Error::UnexpectedHttpStatus(status)),
_ => Err(ApiErrors::from(r).await),
}
}
}
Expand Down
70 changes: 62 additions & 8 deletions src/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@
//! # run().await.unwrap();
//! # }
//! ```
use std::fmt;

use crate::errors::*;
use crate::errors::{self, *};
use crate::mediatypes::MediaTypes;
use futures::prelude::*;
use reqwest::{Method, StatusCode, Url};
use reqwest::{Method, StatusCode, Url, Response};

mod config;
pub use self::config::Config;
Expand Down Expand Up @@ -128,13 +129,66 @@ impl Client {
}

#[derive(Debug, Default, Deserialize, Serialize)]
struct ApiError {
pub struct ApiError {
code: String,
message: String,
detail: String,
message: Option<String>,
detail: Option<Box<serde_json::value::RawValue>>,
}

#[derive(Debug, Default, Deserialize, Serialize)]
struct Errors {
errors: Vec<ApiError>,
#[derive(Debug, Default, Deserialize, Serialize, thiserror::Error)]
pub struct ApiErrors {
errors: Option<Vec<ApiError>>,
}

impl ApiError {
/// Return the API error code.
pub fn code(&self) -> &str {
&self.code
}

pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({})", self.code)?;
if let Some(message) = &self.message {
write!(f, ", message: {}", message)?;
}
if let Some(detail) = &self.detail {
write!(f, ", detail: {}", detail)?;
}
Ok(())
}
}

impl ApiErrors {
/// Create a new ApiErrors from a API Json response.
/// Returns an ApiError if the content is a valid per
/// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
pub async fn from(r: Response) -> errors::Error {
match r.json::<ApiErrors>().await {
Ok(e) => errors::Error::Api(e),
Err(e) => errors::Error::Reqwest(e),
}
}

/// Returns the errors returned by the API.
pub fn errors(&self) -> &Option<Vec<ApiError>> {
&self.errors
}
}


impl fmt::Display for ApiErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.errors.is_none() {
return Ok(());
}
for error in self.errors.as_ref().unwrap().iter() {
write!(f, "({})", error)?
}
Ok(())
}
}
16 changes: 16 additions & 0 deletions tests/fixtures/api_error_fixture_with_detail.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"errors": [
{
"code": "UNAUTHORIZED",
"message": "authentication required",
"detail": [
{
"Type": "repository",
"Class":"",
"Name":"some/image",
"Action":"some_action"
}
]
}
]
}
8 changes: 8 additions & 0 deletions tests/fixtures/api_error_fixture_without_detail.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"errors": [
{
"code": "UNAUTHORIZED",
"message": "authentication required"
}
]
}
16 changes: 16 additions & 0 deletions tests/fixtures/manifest_oci_image_manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 2297,
"digest": "sha256:7324f32f94760ec1dc237858203ea520fc4e6dfbd0bc018f392e54b1392ac722"
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 28028344,
"digest": "sha256:b2afc8f0dccbc5496c814ae03ac3fff7e86393abd18b2d2910a9c489bfe64311"
}
]
}
7 changes: 7 additions & 0 deletions tests/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ fn test_manifest_v2s2() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}

#[test]
fn test_deserialize_oci_image_manifest() {
let f = fs::File::open("tests/fixtures/manifest_oci_image_manifest.json").expect("Missing fixture");
let bufrd = io::BufReader::new(f);
let _manif: dkregistry::v2::manifest::ManifestSchema2Spec = serde_json::from_reader(bufrd).unwrap();
}

#[test]
fn test_deserialize_manifest_list_v2() {
let f = fs::File::open("tests/fixtures/manifest_list_v2.json").expect("Missing fixture");
Expand Down
40 changes: 40 additions & 0 deletions tests/mock/base_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ extern crate tokio;
use self::mockito::mock;
use self::tokio::runtime::Runtime;

use dkregistry::errors;

static API_VERSION_K: &'static str = "Docker-Distribution-API-Version";
static API_VERSION_V: &'static str = "registry/2.0";

Expand Down Expand Up @@ -91,6 +93,44 @@ fn test_base_custom_useragent() {
mockito::reset();
}

/// Test that we properly deserialize API error payload and can access error contents.
#[test_case::test_case("tests/fixtures/api_error_fixture_with_detail.json".to_string() ; "API error with detail")]
#[test_case::test_case("tests/fixtures/api_error_fixture_without_detail.json".to_string() ; "API error without detail")]
fn test_base_api_error(fixture: String) {
let ua = "custom-ua/1.0";
let image = "fake/image";
let version = "fakeversion";
let addr = mockito::server_address().to_string();
let _m = mock("GET", format!("/v2/{}/manifests/{}", image, version).as_str())
.match_header("user-agent", ua)
.with_status(404)
.with_header(API_VERSION_K, API_VERSION_V)
.with_body_from_file(fixture)
.create();

let runtime = Runtime::new().unwrap();
let dclient = dkregistry::v2::Client::configure()
.registry(&addr)
.insecure_registry(true)
.user_agent(Some(ua.to_string()))
.username(None)
.password(None)
.build()
.unwrap();

let futcheck = dclient.get_manifest(image, version);

let res = runtime.block_on(futcheck);
assert_eq!(res.is_err(), true);

assert!(matches!(res, Err(errors::Error::Api(_))));
if let errors::Error::Api(e) = res.unwrap_err() {
assert_eq!(e.errors().as_ref().unwrap()[0].code(), "UNAUTHORIZED");
assert_eq!(e.errors().as_ref().unwrap()[0].message().unwrap(), "authentication required");
}
mockito::reset();
}

mod test_custom_root_certificate {
use dkregistry::v2::Client;
use native_tls::{HandshakeError, Identity, TlsStream};
Expand Down
52 changes: 52 additions & 0 deletions tests/net/docker_io/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ extern crate dkregistry;
extern crate tokio;

use self::tokio::runtime::Runtime;
use dkregistry::errors;

static REGISTRY: &'static str = "registry-1.docker.io";

Expand Down Expand Up @@ -85,3 +86,54 @@ fn test_dockerio_anonymous_auth() {
let res = runtime.block_on(futcheck);
assert_eq!(res.is_ok(), true);
}

/// Check that when requesting an image that does not exist
/// we get an Api error.
#[test]
fn test_dockerio_anonymous_non_existent_image() {
let runtime = Runtime::new().unwrap();
let image = "bad/image";
let version = "latest";
let login_scope = format!("repository:{}:pull", image);
let scopes = vec![login_scope.as_str()];
let dclient_future = dkregistry::v2::Client::configure()
.registry(REGISTRY)
.insecure_registry(false)
.username(None)
.password(None)
.build()
.unwrap()
.authenticate(scopes.as_slice());

let dclient = runtime.block_on(dclient_future).unwrap();
let futcheck = dclient.get_manifest(image, version);

let res = runtime.block_on(futcheck);
assert_eq!(res.is_ok(), false);
assert!(matches!(res, Err(errors::Error::Api(_))));
}

/// Test that we can deserialize OCI image manifest, as is
/// returned for s390x/ubuntu image.
#[test]
fn test_dockerio_anonymous_auth_oci_manifest() {
let runtime = Runtime::new().unwrap();
let image = "s390x/ubuntu";
let version = "latest";
let login_scope = format!("repository:{}:pull", image);
let scopes = vec![login_scope.as_str()];
let dclient_future = dkregistry::v2::Client::configure()
.registry(REGISTRY)
.insecure_registry(false)
.username(None)
.password(None)
.build()
.unwrap()
.authenticate(scopes.as_slice());

let dclient = runtime.block_on(dclient_future).unwrap();
let futcheck = dclient.get_manifest(image, version);

let res = runtime.block_on(futcheck);
assert_eq!(res.is_ok(), true);
}