wash_cli/common/
registry_cmd.rs

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    // Output to provided file, or use artifact_name.file_extension
76    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    // https://github.com/wasmCloud/wash/issues/382 resolved by this
85    // Files must be synced to ensure all bytes are written to disk
86    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    // NOTE: Image URLs must be all lower case for `oci_client::Reference` to parse them properly
162    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            // IdentityTokens are not supported method.
214            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    // Partial wash command
233    #[derive(Debug, Parser)]
234    struct Cmd {
235        #[clap(subcommand)]
236        sub: RegistryCommand,
237    }
238
239    #[test]
240    /// Enumerates multiple options of the `pull` command to ensure API doesn't
241    /// change between versions. This test will fail if `wash pull`
242    /// changes syntax, ordering of required elements, or flags.
243    fn test_pull_comprehensive() -> Result<()> {
244        // test basic `wash reg pull`
245        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        // test `wash pull`
253        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        // test `wash pull`
267        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    /// Enumerates multiple options of the `push` command to ensure API doesn't
301    /// change between versions. This test will fail if `wash push`
302    /// changes syntax, ordering of required elements, or flags.
303    fn test_push_comprehensive() {
304        // Not explicitly used, just a placeholder for a directory
305        const TESTDIR: &str = "./tests/fixtures";
306
307        // Push echo.wasm and pull from local registry
308        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        // Push logging.par.gz and pull from local registry
332        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        // Push logging.par.gz to different tag and pull to confirm successful push
359        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}