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

feat: install extensions using the catalog #3868

Merged
merged 5 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/update-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
cargo run -- schema --for dfx-metadata --outfile docs/dfx-metadata-schema.json
cargo run -- schema --for extension-manifest --outfile docs/extension-manifest-schema.json
cargo run -- schema --for extension-dependencies --outfile docs/extension-dependencies-schema.json
cargo run -- schema --for extension-catalog --outfile docs/extension-catalog-schema.json
echo "JSON Schema changes:"
if git diff --exit-code ; then
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ A test key id `Ed25519:dfx_test_key` is ready to be used by locally created cani

### feat: Added settings_digest field to the network-id file

### feat: install extensions using the catalog

`dfx extension install` now locates extensions using the
[extension catalog](https://github.com/dfinity/dfx-extensions/blob/main/catalog.json).
This can be overridden with the `--catalog-url` parameter.

## Dependencies

### Replica
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ tempfile = "3.3.0"
thiserror = "1.0.24"
time = "0.3.9"
tokio = "1.35"
url = "2.1.0"
url = { version="2.1.0", features=["serde"] }
walkdir = "2.3.2"

[profile.release]
Expand Down
8 changes: 8 additions & 0 deletions docs/extension-catalog-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ExtensionCatalog",
"type": "object",
"additionalProperties": {
"type": "string"
}
}
77 changes: 72 additions & 5 deletions e2e/tests-dfx/extension.bash
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ EOF
dfx_start
}

install_extension_from_official_registry() {
install_extension_from_dfx_extensions_repo() {
EXTENSION=$1

assert_command_fail dfx snsx
Expand All @@ -206,12 +206,12 @@ install_extension_from_official_registry() {
assert_match 'No extensions installed'
}

@test "install extension by name from official registry" {
install_extension_from_official_registry sns
@test "install extension by name from official catalog" {
install_extension_from_dfx_extensions_repo sns
}

@test "install extension by url from official registry" {
install_extension_from_official_registry https://raw.githubusercontent.com/dfinity/dfx-extensions/main/extensions/sns/extension.json
@test "install hosted extension by url" {
install_extension_from_dfx_extensions_repo https://raw.githubusercontent.com/dfinity/dfx-extensions/main/extensions/sns/extension.json
}

get_extension_architecture() {
Expand All @@ -234,6 +234,73 @@ get_extension_architecture() {
echo "$_cputype"
}

@test "install extension from catalog" {
start_webserver --directory www

CATALOG_URL="http://localhost:$E2E_WEB_SERVER_PORT/arbitrary-1/catalog.json"
mkdir -p www/arbitrary-1
cat > www/arbitrary-1/catalog.json <<EOF
{
"foo": "http://localhost:$E2E_WEB_SERVER_PORT/arbitrary-2/foo/extension.json",
"bar": "http://localhost:$E2E_WEB_SERVER_PORT/arbitrary-2/bar/extension.json"
}
EOF


EXTENSION_URL="http://localhost:$E2E_WEB_SERVER_PORT/arbitrary-2/foo/extension.json"
mkdir -p www/arbitrary-2/foo/downloads

cat > www/arbitrary-2/foo/extension.json <<EOF
{
"name": "foo",
"version": "0.1.0",
"homepage": "https://github.com/dfinity/dfx-extensions",
"authors": "DFINITY",
"summary": "Test extension for e2e purposes.",
"categories": [],
"keywords": [],
"download_url_template": "http://localhost:$E2E_WEB_SERVER_PORT/arbitrary/downloads/{{tag}}/{{basename}}.{{archive-format}}"
}
EOF
cat www/arbitrary-2/foo/extension.json
cat > www/arbitrary-2/foo/foo <<EOF
#!/usr/bin/env bash

echo "the foo output"
EOF
chmod +x www/arbitrary-2/foo/foo

cat > www/arbitrary-2/foo/dependencies.json <<EOF
{
"0.1.0": {
"dfx": {
"version": ">=0.8.0"
}
}
}
EOF

arch=$(get_extension_architecture)

if [ "$(uname)" == "Darwin" ]; then
ARCHIVE_BASENAME="foo-$arch-apple-darwin"
else
ARCHIVE_BASENAME="foo-$arch-unknown-linux-gnu"
fi

mkdir "$ARCHIVE_BASENAME"
cp www/arbitrary-2/foo/extension.json "$ARCHIVE_BASENAME"
cp www/arbitrary-2/foo/foo "$ARCHIVE_BASENAME"
tar -czf "$ARCHIVE_BASENAME".tar.gz "$ARCHIVE_BASENAME"
rm -rf "$ARCHIVE_BASENAME"

mkdir -p www/arbitrary/downloads/foo-v0.1.0
mv "$ARCHIVE_BASENAME".tar.gz www/arbitrary/downloads/foo-v0.1.0/

assert_command dfx extension install foo --catalog-url "$CATALOG_URL"
}


@test "install extension by url from elsewhere" {
start_webserver --directory www
EXTENSION_URL="http://localhost:$E2E_WEB_SERVER_PORT/arbitrary/extension.json"
Expand Down
18 changes: 18 additions & 0 deletions src/dfx-core/src/error/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,15 @@ pub enum DownloadAndInstallExtensionToTempdirError {

#[derive(Error, Debug)]
pub enum InstallExtensionError {
#[error("extension '{0}' not found in catalog")]
ExtensionNotFound(String),

#[error("Extension '{0}' is already installed at version {1}.")]
OtherVersionAlreadyInstalled(String, Version),

#[error(transparent)]
FetchCatalog(#[from] FetchCatalogError),

#[error(transparent)]
GetExtensionArchiveName(#[from] GetExtensionArchiveNameError),

Expand Down Expand Up @@ -216,3 +222,15 @@ pub enum FetchExtensionCompatibilityMatrixError {
#[derive(Error, Debug)]
#[error(transparent)]
pub struct UninstallExtensionError(#[from] RemoveDirectoryAndContentsError);

#[derive(Error, Debug)]
pub enum FetchCatalogError {
#[error(transparent)]
ParseUrl(#[from] url::ParseError),

#[error(transparent)]
Get(reqwest::Error),

#[error(transparent)]
ParseJson(reqwest::Error),
}
38 changes: 38 additions & 0 deletions src/dfx-core/src/extension/catalog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use crate::error::extension::FetchCatalogError;
use crate::extension::url::ExtensionJsonUrl;
use crate::http::get::get_with_retries;
use crate::json::structure::UrlWithJsonSchema;
use backoff::exponential::ExponentialBackoff;
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
use std::time::Duration;
use url::Url;

const DEFAULT_CATALOG_URL: &str =
"https://raw.githubusercontent.com/dfinity/dfx-extensions/main/catalog.json";

#[derive(Deserialize, Debug, JsonSchema)]
pub struct ExtensionCatalog(pub HashMap<String, UrlWithJsonSchema>);

impl ExtensionCatalog {
pub async fn fetch(url: Option<&Url>) -> Result<Self, FetchCatalogError> {
let url: Option<Url> = url.cloned();
let url = url.unwrap_or_else(|| Url::parse(DEFAULT_CATALOG_URL).unwrap());
let retry_policy = ExponentialBackoff {
max_elapsed_time: Some(Duration::from_secs(60)),
..Default::default()
};
let resp = get_with_retries(url, retry_policy)
.await
.map_err(FetchCatalogError::Get)?;

resp.json().await.map_err(FetchCatalogError::ParseJson)
}

pub fn lookup(&self, name: &str) -> Option<ExtensionJsonUrl> {
self.0
.get(name)
.map(|url| ExtensionJsonUrl::new(url.0.clone()))
}
}
17 changes: 14 additions & 3 deletions src/dfx-core/src/extension/manager/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::error::extension::{
GetHighestCompatibleVersionError, GetTopLevelDirectoryError, InstallExtensionError,
};
use crate::extension::{
catalog::ExtensionCatalog,
manager::ExtensionManager,
manifest::{ExtensionDependencies, ExtensionManifest},
url::ExtensionJsonUrl,
Expand All @@ -29,11 +30,21 @@ pub enum InstallOutcome {
impl ExtensionManager {
pub async fn install_extension(
&self,
url: &ExtensionJsonUrl,
name: &str,
catalog_url: Option<&Url>,
install_as: Option<&str>,
version: Option<&Version>,
) -> Result<InstallOutcome, InstallExtensionError> {
let manifest = Self::get_extension_manifest(url).await?;
let url = if let Ok(url) = Url::parse(name) {
ExtensionJsonUrl::new(url)
} else {
ExtensionCatalog::fetch(catalog_url)
.await?
.lookup(name)
.ok_or(InstallExtensionError::ExtensionNotFound(name.to_string()))?
};

let manifest = Self::get_extension_manifest(&url).await?;
let extension_name: &str = &manifest.name;

let effective_extension_name = install_as.unwrap_or(extension_name);
Expand All @@ -59,7 +70,7 @@ impl ExtensionManager {

let extension_version = match version {
Some(version) => version.clone(),
None => self.get_highest_compatible_version(url).await?,
None => self.get_highest_compatible_version(&url).await?,
};
let github_release_tag = get_git_release_tag(extension_name, &extension_version);
let extension_archive = get_extension_archive_name(extension_name)?;
Expand Down
1 change: 1 addition & 0 deletions src/dfx-core/src/extension/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod catalog;
pub mod manager;
pub mod manifest;
pub mod url;
Expand Down
19 changes: 19 additions & 0 deletions src/dfx-core/src/json/structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde::Serialize;
use std::fmt::Display;
use std::ops::{Deref, DerefMut};
use std::str::FromStr;
use url::Url;

#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, PartialEq, Eq)]
#[serde(untagged)]
Expand Down Expand Up @@ -91,3 +92,21 @@ impl DerefMut for VersionWithJsonSchema {
&mut self.0
}
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
pub struct UrlWithJsonSchema(#[schemars(with = "String")] pub Url);

impl Deref for UrlWithJsonSchema {
type Target = Url;

fn deref(&self) -> &Self::Target {
&self.0
}
}

impl DerefMut for UrlWithJsonSchema {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
19 changes: 10 additions & 9 deletions src/dfx/src/commands/extension/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use clap::Parser;
use clap::Subcommand;
use dfx_core::error::extension::InstallExtensionError::OtherVersionAlreadyInstalled;
use dfx_core::extension::manager::InstallOutcome;
use dfx_core::extension::url::ExtensionJsonUrl;
use semver::Version;
use slog::{error, info, warn};
use tokio::runtime::Runtime;
Expand All @@ -23,6 +22,9 @@ pub struct InstallOpts {
/// Installs a specific version of the extension, bypassing version checks
#[clap(long)]
version: Option<Version>,
/// Specifies the URL of the catalog to use to find the extension.
#[clap(long)]
catalog_url: Option<Url>,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be catalog

}

pub fn exec(env: &dyn Environment, opts: InstallOpts) -> DfxResult<()> {
Expand All @@ -36,17 +38,16 @@ pub fn exec(env: &dyn Environment, opts: InstallOpts) -> DfxResult<()> {
bail!("Extension '{}' cannot be installed because it conflicts with an existing command. Consider using '--install-as' flag to install this extension under different name.", opts.name)
}

let url = if let Ok(url) = Url::parse(&opts.name) {
ExtensionJsonUrl::new(url)
} else {
ExtensionJsonUrl::registered(&opts.name)?
};

let runtime = Runtime::new().expect("Unable to create a runtime");

let install_outcome = runtime.block_on(async {
mgr.install_extension(&url, opts.install_as.as_deref(), opts.version.as_ref())
.await
mgr.install_extension(
&opts.name,
opts.catalog_url.as_ref(),
opts.install_as.as_deref(),
opts.version.as_ref(),
)
.await
});
spinner.finish_and_clear();
let logger = env.get_logger();
Expand Down
3 changes: 3 additions & 0 deletions src/dfx/src/commands/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::lib::{error::DfxResult, metadata::dfx::DfxMetadata};
use anyhow::Context;
use clap::{Parser, ValueEnum};
use dfx_core::config::model::dfinity::{ConfigInterface, TopLevelConfigNetworks};
use dfx_core::extension::catalog::ExtensionCatalog;
use dfx_core::extension::manifest::{ExtensionDependencies, ExtensionManifest};
use schemars::schema_for;
use std::path::PathBuf;
Expand All @@ -13,6 +14,7 @@ enum ForFile {
DfxMetadata,
ExtensionDependencies,
ExtensionManifest,
ExtensionCatalog,
}

/// Prints the schema for dfx.json.
Expand All @@ -32,6 +34,7 @@ pub fn exec(opts: SchemaOpts) -> DfxResult {
Some(ForFile::DfxMetadata) => schema_for!(DfxMetadata),
Some(ForFile::ExtensionDependencies) => schema_for!(ExtensionDependencies),
Some(ForFile::ExtensionManifest) => schema_for!(ExtensionManifest),
Some(ForFile::ExtensionCatalog) => schema_for!(ExtensionCatalog),
_ => schema_for!(ConfigInterface),
};
let nice_schema =
Expand Down
Loading