use crate::api::{Client, seg};
use crate::error::Result;
use crate::transport::{Method, Request};
use crate::types::*;
use serde_json::{Value, json};
pub const ARTIFACTS_PLUGIN: &str = "artifacts";
impl Client {
pub fn artifacts(&self, namespace: impl Into<String>) -> ArtifactsClient {
ArtifactsClient {
client: self.clone(),
namespace: namespace.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct ArtifactsClient {
client: Client,
namespace: String,
}
fn reference(r: &str) -> String {
r.split('/').map(seg).collect::<Vec<_>>().join("/")
}
impl ArtifactsClient {
pub fn namespace(&self) -> &str {
&self.namespace
}
fn path(&self, rest: &str) -> String {
let base = format!(
"/namespaces/{}/plugins/{ARTIFACTS_PLUGIN}/api",
seg(&self.namespace)
);
if rest.is_empty() {
base
} else {
format!("{base}/{rest}")
}
}
pub async fn info(&self) -> Result<ArtifactStoreInfo> {
self.client
.read(
Request::new(Method::Get, self.path("")),
"namespace",
&self.namespace,
)
.await
}
pub async fn tags(&self) -> Result<Vec<ArtifactTag>> {
self.client
.read(
Request::new(Method::Get, self.path("tags")),
"namespace",
&self.namespace,
)
.await
}
pub async fn tag(&self, tag: &str) -> Result<ArtifactTag> {
self.client
.read(
Request::new(Method::Get, self.path(&format!("tags/{}", reference(tag)))),
"artifact",
tag,
)
.await
}
pub async fn set_tag(&self, tag: &str, digest: &str) -> Result<()> {
self.client
.unit(
Request::new(Method::Put, self.path(&format!("tags/{}", reference(tag))))
.text(digest),
"artifact",
tag,
)
.await
}
pub async fn delete_tag(&self, tag: &str) -> Result<()> {
self.client
.unit(
Request::new(
Method::Delete,
self.path(&format!("tags/{}", reference(tag))),
),
"artifact",
tag,
)
.await
}
pub async fn manifest(&self, tag_or_digest: &str) -> Result<Value> {
self.client
.read(
Request::new(
Method::Get,
self.path(&format!("manifests/{}", reference(tag_or_digest))),
),
"artifact",
tag_or_digest,
)
.await
}
pub async fn repo(&self, repo: &str) -> Result<ArtifactRepo> {
self.client
.read(
Request::new(
Method::Get,
self.path(&format!("repos/{}", reference(repo))),
),
"artifact",
repo,
)
.await
}
pub async fn set_repo(
&self,
repo: &str,
public: bool,
description: Option<&str>,
) -> Result<ArtifactRepo> {
self.client
.read(
Request::new(
Method::Put,
self.path(&format!("repos/{}", reference(repo))),
)
.json(json!({"public": public, "description": description})),
"artifact",
repo,
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::stub::Stub;
use std::sync::Arc;
const D: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn client(stub: Stub) -> (Client, Arc<Stub>) {
let stub = Arc::new(stub);
(Client::with_transport(stub.clone()), stub)
}
#[tokio::test]
async fn the_store_is_reached_through_the_namespace_plugin_prefix() {
let (c, stub) = client(
Stub::new()
.json(200, json!({"namespace": "team-a", "available": true, "writable": true, "prefix": "team-a/"}))
.json(200, json!([{"tag": "team-a/web:v2", "digest": D}]))
.json(200, json!({"tag": "team-a/web:v2", "digest": D}))
.reply(204, "")
.reply(204, "")
.json(200, json!({"kind": "heyvm.rootfs.v1"}))
.json(200, json!({"repo": "team-a/web", "public": false, "tags": [{"tag": "team-a/web:v2", "name": "v2", "digest": D}]}))
.json(200, json!({"repo": "team-a/web", "public": true, "description": "web", "updated": 5})),
);
let store = c.artifacts("team-a");
assert!(store.info().await.unwrap().writable);
assert_eq!(store.tags().await.unwrap()[0].digest, D);
let v2 = store.tag("team-a/web:v2").await.unwrap();
store
.set_tag("team-a/web:latest", &v2.digest)
.await
.unwrap();
store.delete_tag("team-a/web:old").await.unwrap();
assert_eq!(store.manifest(D).await.unwrap()["kind"], "heyvm.rootfs.v1");
assert_eq!(store.repo("team-a/web").await.unwrap().tags[0].name, "v2");
assert!(
store
.set_repo("team-a/web", true, Some("web"))
.await
.unwrap()
.public
);
let calls = stub.calls();
let seen: Vec<String> = calls
.iter()
.map(|c| format!("{} {}", c.method.as_str(), c.path))
.collect();
let base = "/namespaces/team-a/plugins/artifacts/api";
assert_eq!(
seen,
[
format!("GET {base}"),
format!("GET {base}/tags"),
format!("GET {base}/tags/team-a/web%3Av2"),
format!("PUT {base}/tags/team-a/web%3Alatest"),
format!("DELETE {base}/tags/team-a/web%3Aold"),
format!("GET {base}/manifests/{D}"),
format!("GET {base}/repos/team-a/web"),
format!("PUT {base}/repos/team-a/web"),
]
);
assert_eq!(calls[3].text.as_deref(), Some(D), "the digest goes as text");
assert_eq!(calls[3].body, None);
assert_eq!(
calls[7].body,
Some(json!({"public": true, "description": "web"}))
);
}
#[tokio::test]
async fn outside_the_namespace_is_forbidden_and_says_why() {
let (c, _) = client(Stub::new().json(
403,
json!({"error": "namespace team-a may only reach artifacts under team-a/; other/web:v1 is outside it. Publish under a namespaced tag such as team-a/app:latest"}),
));
let e = c.artifacts("team-a").tag("other/web:v1").await.unwrap_err();
assert!(matches!(e, crate::Error::Forbidden { .. }), "{e:?}");
assert!(e.to_string().contains("team-a/"), "{e}");
}
}