1use std::{collections::HashMap, path::PathBuf};
2
3use anyhow::{bail, Context, Result};
4use docker_credential::{get_credential, DockerCredential};
5use oci_client::Reference;
6use serde_json::json;
7use tokio::fs::File;
8use tokio::io::AsyncWriteExt;
9use tracing::warn;
10
11use wash_lib::cli::registry::{RegistryPullCommand, RegistryPushCommand};
12use wash_lib::cli::{input_vec_to_hashmap, CommandOutput, OutputKind};
13use wash_lib::parser::{load_config, ProjectConfig};
14use wash_lib::registry::{
15 identify_artifact, pull_oci_artifact, push_oci_artifact, ArtifactType, OciPullOptions,
16 OciPushOptions,
17};
18use wasmcloud_control_interface::RegistryCredential;
19
20use crate::appearance::spinner::Spinner;
21
22pub const SHOWER_EMOJI: &str = "\u{1F6BF}";
23pub const PROVIDER_ARCHIVE_FILE_EXTENSION: &str = ".par.gz";
24pub const WASM_FILE_EXTENSION: &str = ".wasm";
25
26pub async fn registry_pull(
27 cmd: RegistryPullCommand,
28 output_kind: OutputKind,
29) -> Result<CommandOutput> {
30 let image: Reference = resolve_artifact_ref(&cmd.url, &cmd.registry.unwrap_or_default(), None)?;
31 let spinner = Spinner::new(&output_kind)?;
32 spinner.update_spinner_message(format!(" Downloading {} ...", image.whole()));
33
34 let credentials = match (cmd.opts.user, cmd.opts.password) {
35 (Some(user), Some(password)) => Ok(RegistryCredential::from_username_password(
36 &user, &password, "oci",
37 )),
38 _ => resolve_registry_credentials(image.registry()).await,
39 }?;
40
41 let artifact = pull_oci_artifact(
42 &image,
43 OciPullOptions {
44 digest: cmd.digest,
45 allow_latest: cmd.allow_latest,
46 user: credentials.username().map(String::from),
47 password: credentials.password().map(String::from),
48 insecure: cmd.opts.insecure,
49 insecure_skip_tls_verify: cmd.opts.insecure_skip_tls_verify,
50 },
51 )
52 .await?;
53
54 let outfile = write_artifact(&artifact, &image, cmd.destination).await?;
55
56 spinner.finish_and_clear();
57
58 let mut map = HashMap::new();
59 map.insert("file".to_string(), json!(outfile));
60 Ok(CommandOutput::new(
61 format!("\n{SHOWER_EMOJI} Successfully pulled and validated {outfile}"),
62 map,
63 ))
64}
65
66pub async fn write_artifact(
67 artifact: &[u8],
68 image: &Reference,
69 output: Option<String>,
70) -> Result<String> {
71 let file_extension = match identify_artifact(artifact).await? {
72 ArtifactType::Par => PROVIDER_ARCHIVE_FILE_EXTENSION,
73 ArtifactType::Wasm => WASM_FILE_EXTENSION,
74 };
75 let outfile = output.unwrap_or_else(|| {
77 format!(
78 "{}{file_extension}",
79 image.repository().split('/').last().unwrap(),
80 )
81 });
82 let mut f = File::create(&outfile).await?;
83 f.write_all(artifact).await?;
84 f.sync_all().await?;
87 Ok(outfile)
88}
89
90pub async fn registry_push(
91 cmd: RegistryPushCommand,
92 output_kind: OutputKind,
93) -> Result<CommandOutput> {
94 let project_config = load_config(cmd.project_config, Some(true)).await.ok();
95 let image: Reference = resolve_artifact_ref(
96 &cmd.url,
97 &cmd.registry.unwrap_or_default(),
98 project_config.as_ref(),
99 )?;
100 let artifact_url = image.whole();
101 if artifact_url.starts_with("localhost:") && !cmd.opts.insecure {
102 warn!(" Unless an SSL certificate has been installed, pushing to localhost without the --insecure option will fail")
103 }
104
105 let spinner = Spinner::new(&output_kind)?;
106 spinner.update_spinner_message(format!(" Pushing {} to {} ...", cmd.artifact, artifact_url));
107
108 let credentials = match (cmd.opts.user, cmd.opts.password) {
109 (Some(user), Some(password)) => Ok(RegistryCredential::from_username_password(
110 &user, &password, "oci",
111 )),
112 _ => resolve_registry_credentials(image.registry()).await,
113 }?;
114
115 let annotations = cmd.annotations.and_then(|annotations| {
116 Some(
117 input_vec_to_hashmap(annotations)
118 .ok()?
119 .into_iter()
120 .collect(),
121 )
122 });
123
124 let (maybe_tag, digest) = push_oci_artifact(
125 artifact_url.clone(),
126 cmd.artifact,
127 OciPushOptions {
128 config: cmd.config.map(PathBuf::from),
129 allow_latest: cmd.allow_latest,
130 user: credentials.username().map(String::from),
131 password: credentials.password().map(String::from),
132 insecure: cmd.opts.insecure
133 || project_config.is_some_and(|c| c.common.registry.push.push_insecure),
134 insecure_skip_tls_verify: cmd.opts.insecure_skip_tls_verify,
135 annotations,
136 monolithic_push: cmd.monolithic_push,
137 },
138 )
139 .await?;
140
141 spinner.finish_and_clear();
142
143 let mut map = HashMap::from_iter([
144 ("url".to_string(), json!(artifact_url)),
145 ("digest".to_string(), json!(digest)),
146 ]);
147 let text = if let Some(tag) = maybe_tag {
148 map.insert("tag".to_string(), json!(tag));
149 format!("{SHOWER_EMOJI} Successfully pushed {artifact_url}\n{tag}: digest: {digest}")
150 } else {
151 format!("{SHOWER_EMOJI} Successfully pushed {artifact_url}\ndigest: {digest}")
152 };
153 Ok(CommandOutput::new(text, map))
154}
155
156fn resolve_artifact_ref(
157 url: &str,
158 registry: &str,
159 project_config: Option<&ProjectConfig>,
160) -> Result<Reference> {
161 let url = url.trim().to_ascii_lowercase();
163 let registry = registry.trim().to_ascii_lowercase();
164
165 let image: Reference = url
166 .parse()
167 .context("failed to parse artifact url into oci image reference")?;
168
169 if url == image.whole() {
170 return Ok(image);
171 }
172
173 match project_config {
174 _ if !url.is_empty() && !registry.is_empty() => {
175 let image: Reference = format!("{}/{}", registry, url)
176 .parse()
177 .context("failed to parse artifact url from specified registry and repository")?;
178 Ok(image)
179 }
180 Some(project_config) if !url.is_empty() && registry.is_empty() => {
181 let registry = project_config
182 .common
183 .registry
184 .push
185 .url
186 .clone()
187 .unwrap_or_default();
188
189 if registry.is_empty() {
190 bail!("Missing or invalid registry url configuration")
191 }
192
193 let image: Reference = format!("{}/{}", registry, url).parse().context(
194 "failed to parse artifact url from specified repository and registry url configuration",
195 )?;
196 Ok(image)
197 }
198 _ => bail!("Unable to resolve artifact url from specified registry and repository"),
199 }
200}
201
202async fn resolve_registry_credentials(registry: &str) -> Result<RegistryCredential> {
203 let credentials = if let Ok(credentials) = load_config(None, Some(true))
204 .await
205 .and_then(|config| config.resolve_registry_credentials(registry))
206 {
207 credentials
208 } else {
209 match get_credential(registry) {
210 Ok(DockerCredential::UsernamePassword(username, password)) => {
211 RegistryCredential::from_username_password(&username, &password, "oci")
212 }
213 Ok(DockerCredential::IdentityToken(_)) | Err(_) => RegistryCredential::default(),
215 }
216 };
217 Ok(credentials)
218}
219
220#[cfg(test)]
221mod tests {
222 use anyhow::{ensure, Context as _, Result};
223 use clap::Parser;
224 use wash_lib::cli::registry::{RegistryCommand, RegistryPullCommand};
225
226 use crate::common::registry_cmd::RegistryPushCommand;
227
228 const ECHO_WASM: &str = "wasmcloud.azurecr.io/echo:0.2.0";
229 const LOCAL_REGISTRY: &str = "localhost:5001";
230 const TESTDIR: &str = "./tests/fixtures";
231
232 #[derive(Debug, Parser)]
234 struct Cmd {
235 #[clap(subcommand)]
236 sub: RegistryCommand,
237 }
238
239 #[test]
240 fn test_pull_comprehensive() -> Result<()> {
244 let pull_basic: Cmd = Parser::try_parse_from(["wash", "pull", ECHO_WASM])
246 .context("failed to perform wash pull")?;
247 ensure!(matches!(
248 pull_basic.sub,
249 RegistryCommand::Pull(RegistryPullCommand { url, .. }) if url == ECHO_WASM,
250 ));
251
252 let pull_all_flags: Cmd =
254 Parser::try_parse_from(["wash", "pull", ECHO_WASM, "--allow-latest", "--insecure"])
255 .context("failed to pull with all flags")?;
256 ensure!(matches!(
257 pull_all_flags.sub,
258 RegistryCommand::Pull(RegistryPullCommand {
259 url,
260 allow_latest,
261 opts,
262 ..
263 }) if url == ECHO_WASM && allow_latest && opts.insecure
264 ));
265
266 let pull_all_options: Cmd = Parser::try_parse_from([
268 "wash",
269 "pull",
270 ECHO_WASM,
271 "--destination",
272 TESTDIR,
273 "--digest",
274 "sha256:a17a163afa8447622055deb049587641a9e23243a6cc4411eb33bd4267214cf3",
275 "--password",
276 "password",
277 "--user",
278 "user",
279 ])
280 .context("wash pull with all options failed")?;
281 ensure!(matches!(
282 pull_all_options.sub,
283 RegistryCommand::Pull(RegistryPullCommand {
284 url,
285 destination,
286 digest,
287 opts,
288 ..
289 }) if url == ECHO_WASM
290 && destination == Some(TESTDIR.into())
291 && digest == Some("sha256:a17a163afa8447622055deb049587641a9e23243a6cc4411eb33bd4267214cf3".into())
292 && opts.user == Some("user".into())
293 && opts.password == Some("password".into())
294 ));
295
296 Ok(())
297 }
298
299 #[test]
300 fn test_push_comprehensive() {
304 const TESTDIR: &str = "./tests/fixtures";
306
307 let echo_push_basic = &format!("{LOCAL_REGISTRY}/echo:pushbasic");
309 let push_basic: Cmd = Parser::try_parse_from([
310 "wash",
311 "push",
312 echo_push_basic,
313 &format!("{TESTDIR}/echopush.wasm"),
314 "--insecure",
315 ])
316 .unwrap();
317 match push_basic.sub {
318 RegistryCommand::Push(RegistryPushCommand {
319 url,
320 artifact,
321 opts,
322 ..
323 }) => {
324 assert_eq!(&url, echo_push_basic);
325 assert_eq!(artifact, format!("{TESTDIR}/echopush.wasm"));
326 assert!(opts.insecure);
327 }
328 _ => panic!("`wash push` constructed incorrect command"),
329 };
330
331 let logging_push_all_flags = &format!("{LOCAL_REGISTRY}/logging:allflags");
333 let push_all_flags: Cmd = Parser::try_parse_from([
334 "wash",
335 "push",
336 logging_push_all_flags,
337 &format!("{TESTDIR}/logging.par.gz"),
338 "--insecure",
339 "--allow-latest",
340 ])
341 .unwrap();
342 match push_all_flags.sub {
343 RegistryCommand::Push(RegistryPushCommand {
344 url,
345 artifact,
346 opts,
347 allow_latest,
348 ..
349 }) => {
350 assert_eq!(&url, logging_push_all_flags);
351 assert_eq!(artifact, format!("{TESTDIR}/logging.par.gz"));
352 assert!(opts.insecure);
353 assert!(allow_latest);
354 }
355 _ => panic!("`wash push` constructed incorrect command"),
356 };
357
358 let logging_push_all_options = &format!("{LOCAL_REGISTRY}/logging:alloptions");
360 let push_all_options: Cmd = Parser::try_parse_from([
361 "wash",
362 "push",
363 logging_push_all_options,
364 &format!("{TESTDIR}/logging.par.gz"),
365 "--allow-latest",
366 "--insecure",
367 "--config",
368 &format!("{TESTDIR}/config.json"),
369 "--password",
370 "supers3cr3t",
371 "--user",
372 "localuser",
373 ])
374 .unwrap();
375 match push_all_options.sub {
376 RegistryCommand::Push(RegistryPushCommand {
377 url,
378 artifact,
379 opts,
380 allow_latest,
381 config,
382 ..
383 }) => {
384 assert_eq!(&url, logging_push_all_options);
385 assert_eq!(artifact, format!("{TESTDIR}/logging.par.gz"));
386 assert!(opts.insecure);
387 assert!(allow_latest);
388 assert_eq!(
389 format!("{}", config.unwrap().as_path().display()),
390 format!("{TESTDIR}/config.json")
391 );
392 assert_eq!(opts.user.unwrap(), "localuser");
393 assert_eq!(opts.password.unwrap(), "supers3cr3t");
394 }
395 _ => panic!("`wash push` constructed incorrect command"),
396 };
397 }
398}