Skip to content

Commit

Permalink
fix: Refactor Outdated command
Browse files Browse the repository at this point in the history
Add support in outdated for public OCI images
  • Loading branch information
Brian May committed Apr 10, 2024
1 parent cfb5203 commit 663d4e8
Show file tree
Hide file tree
Showing 10 changed files with 516 additions and 78 deletions.
313 changes: 312 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ semver = "1.0.22"
url = "2.5.0"
futures = "0.3.30"
hyper-util = "0.1.3"
tap = "1.0.1"
reqwest = { version = "0.12.3", features = ["json"] }
6 changes: 4 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
overlays = [ (import rust-overlay) ];
};

osxlibs = pkgs.lib.lists.optional pkgs.stdenv.isDarwin
pkgs.darwin.apple_sdk.frameworks.Security;
osxlibs = pkgs.lib.lists.optionals pkgs.stdenv.isDarwin [
pkgs.darwin.apple_sdk.frameworks.Security
pkgs.darwin.apple_sdk.frameworks.Foundation
];

src = ./.;

Expand Down
238 changes: 176 additions & 62 deletions src/helm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::{
sync::Arc,
time::Duration,
};
use tap::Pipe;
use tracing::{debug, error};
use url::Url;

Expand Down Expand Up @@ -490,6 +491,9 @@ async fn outdated_helm_chart(
chart_version: &str,
tx: &MultiOutput,
) -> Result<()> {
let chart_version = Version::parse(chart_version)
.map_err(|err| anyhow::anyhow!("Failed to parse version {chart_version:?} {err:?}"))?;

let args = vec![
"search".into(),
"repo".into(),
Expand All @@ -503,10 +507,16 @@ async fn outdated_helm_chart(

if let Ok(CommandSuccess { stdout, .. }) = &result {
let version: Vec<HelmVersionInfo> = serde_json::from_str(stdout)?;
let version = version.get(0).ok_or_else(|| {
anyhow::anyhow!("No version information found for chart {chart_name}")
})?;
let version = parse_version(&version.version)
.map_err(|err| anyhow::anyhow!("Failed to parse version {version:?} {err:?}"))?;

tx.send(Message::InstallationVersion(
installation.clone(),
chart_version.to_owned(),
version[0].version.clone(),
version,
))
.await;
};
Expand Down Expand Up @@ -546,6 +556,133 @@ struct ImageDetails {
artifact_media_type: String,
}

/// Public AWS token
#[derive(Deserialize, Debug)]
struct AwsToken {
token: String,
}

/// Parsed Public OCI tags
#[allow(dead_code)]
#[derive(Deserialize, Debug)]
struct AwsTags {
name: String,
tags: Vec<String>,
}

enum ParsedOci {
Private {
account: String,
region: String,
path: String,
},
Public {
path: String,
},
}

impl ParsedOci {
fn new(url: &Url, chart_name: &str) -> Result<Self> {
let host = url
.host()
.ok_or_else(|| anyhow::anyhow!("invalid repo url"))?;
let host_split = match host {
url::Host::Domain(host) => host.split('.').collect::<Vec<_>>(),
_ => return Err(anyhow::anyhow!("invalid repo url, expected hostname")),
};
if host_split.len() == 6
&& host_split[1] == "dkr"
&& host_split[2] == "ecr"
&& host_split[4] == "amazonaws"
&& host_split[5] == "com"
{
let account = host_split[0].to_string();
let region = host_split[3].to_string();
let path = format!("{}/{chart_name}", url.path().trim_start_matches('/'));

Self::Private {
account,
region,
path,
}
.pipe(Ok)
} else if host_split == ["public", "ecr", "aws"] {
let path = format!("{chart_name}/{chart_name}");
Self::Public { path }.pipe(Ok)
} else {
return Err(anyhow::anyhow!(
"Unsupported OCI repo url {url}",
url = url.to_string()
));
}
}

async fn get_latest_version(
&self,
installation: &Arc<Installation>,
tx: &MultiOutput,
) -> Result<Version> {
match self {
ParsedOci::Private {
account,
region,
path,
} => {
let args: Vec<OsString> = vec![
"ecr".into(),
"describe-images".into(),
"--registry-id".into(),
account.into(),
"--region".into(),
region.into(),
"--repository-name".into(),
path.into(),
];

let command_line = CommandLine(aws_path(), args);
let result = command_line.run().await;

let rc = match &result {
Ok(CommandSuccess { stdout, .. }) => {
let details: OciDetails = serde_json::from_str(&stdout)?;
if let Some(version) = get_latest_version_from_details(details) {
Ok(version)
} else {
Err(anyhow::anyhow!("no versions found"))
}
}
Err(err) => Err(anyhow::anyhow!("The describe-images command failed: {err}")),
};

let i_result = HelmResult::from_result(installation, result, Command::Outdated);
let i_result = Arc::new(i_result);
tx.send(Message::InstallationResult(i_result)).await;

rc
}
ParsedOci::Public { path } => {
let token: AwsToken = reqwest::get("https://public.ecr.aws/token/")
.await?
.json()
.await?;

let tags: AwsTags = reqwest::Client::new()
.get(&format!("https://public.ecr.aws/v2/{}/tags/list", path))
.header("Authorization", format!("Bearer {}", token.token))
.send()
.await?
.json()
.await?;

match get_latest_version_from_tags(tags) {
Some(version) => Ok(version),
None => Err(anyhow::anyhow!("no versions found")),
}
}
}
}
}

/// Generate the outdated report for an OCI chart reference stored on ECR.
async fn outdated_oci_chart(
installation: &Arc<Installation>,
Expand All @@ -560,83 +697,60 @@ async fn outdated_oci_chart(
return Ok(());
}

let url = Url::parse(repo_url)?;
let host = url
.host()
.ok_or_else(|| anyhow::anyhow!("invalid repo url"))?;
let host_split = match host {
url::Host::Domain(host) => host.split('.').collect::<Vec<_>>(),
_ => return Err(anyhow::anyhow!("invalid repo url, expected hostname")),
};
if host_split.len() != 6
|| host_split[1] != "dkr"
|| host_split[2] != "ecr"
|| host_split[4] != "amazonaws"
|| host_split[5] != "com"
{
return Err(anyhow::anyhow!(
"invalid repo url, expected <account>.dkr.ecr.<region>.amazonaws.com"
));
}
let account = host_split[0];
let region = host_split[3];

let args: Vec<OsString> = vec![
"ecr".into(),
"describe-images".into(),
"--registry-id".into(),
account.into(),
"--region".into(),
region.into(),
"--repository-name".into(),
format!("{}/{chart_name}", url.path().trim_start_matches('/')).into(),
];

let command_line = CommandLine(aws_path(), args);
let result = command_line.run().await;
let has_errors = result.is_err();
let chart_version = parse_version(chart_version)
.map_err(|err| anyhow::anyhow!("Failed to parse version {chart_version:?} {err:?}"))?;

if let Ok(CommandSuccess { stdout, .. }) = &result {
let details: OciDetails = serde_json::from_str(stdout)?;
if let Some(version) = get_latest_version(details) {
tx.send(Message::InstallationVersion(
installation.clone(),
chart_version.to_owned(),
version,
))
.await;
}
};

let i_result = HelmResult::from_result(installation, result, Command::Outdated);
let i_result = Arc::new(i_result);
tx.send(Message::InstallationResult(i_result)).await;
let url = Url::parse(repo_url)?;
let parsed = ParsedOci::new(&url, chart_name)?;

let latest_version = parsed
.get_latest_version(installation, &tx)
.await
.map_err(|err| anyhow::anyhow!("Get latest version failed {err:?}"))?;
tx.send(Message::InstallationVersion(
installation.clone(),
chart_version,
latest_version,
))
.await;

if has_errors {
Err(anyhow::anyhow!("outdated operation failed"))
} else {
Ok(())
}
Ok(())
}

/// Parse a semver complaint version.
fn parse_version(tag: &str) -> Option<Version> {
Version::parse(tag).ok()
fn parse_version(tag: &str) -> Result<Version> {
let tag = tag.strip_prefix('v').unwrap_or(tag);
Version::parse(tag)?.pipe(Ok)
}

/// Get the latest version for the given `OciDetails`.
fn get_latest_version(details: OciDetails) -> Option<String> {
fn get_latest_version_from_details(details: OciDetails) -> Option<Version> {
let mut versions = vec![];
for image in details.image_details {
if let Some(tags) = image.image_tags {
for tag in tags {
if let Some(version) = parse_version(&tag) {
versions.push(version);
match parse_version(&tag) {
Ok(version) => versions.push(version),
Err(err) => error!("Cannot parse version {tag} {err}"),
}
}
}
}

versions.sort();
versions.last().map(std::string::ToString::to_string)
versions.last().cloned()
}

/// Get the latest version for the given `AwsTags`.
fn get_latest_version_from_tags(tags: AwsTags) -> Option<Version> {
let mut versions = vec![];
for tag in tags.tags {
match parse_version(&tag) {
Ok(version) => versions.push(version),
Err(err) => error!("Cannot parse version {tag} {err}"),
}
}

versions.sort();
versions.last().cloned()
}
12 changes: 10 additions & 2 deletions src/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,19 @@ pub struct LogEntry {
pub message: String,
}

macro_rules! log {
($level:expr, $message:expr) => {
$crate::layer::raw_log($level, format!("{}:{}", file!(), line!()), $message)
};
}

pub(crate) use log;

/// Create a log entry object.
pub fn log(level: Level, message: &str) -> LogEntry {
pub fn raw_log(level: Level, name: impl Into<String>, message: impl Into<String>) -> LogEntry {
LogEntry {
target: "helmci".into(),
name: "log tui.rs".into(),
name: name.into(),
level,
message: message.into(),
}
Expand Down
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,9 +759,9 @@ async fn worker_thread(
}
Err(err) => {
output
.send(Message::Log(log(
.send(Message::Log(log!(
Level::ERROR,
&format!("job failed: {err}"),
&format!("job failed: {err}")
)))
.await;
errors = true;
Expand Down
3 changes: 2 additions & 1 deletion src/output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! A job may consist of one or more commands that need to be executed for successful completion of the job.
use anyhow::Result;
use async_trait::async_trait;
use semver::Version;
use std::{sync::Arc, time::Duration};
use tokio::{sync::mpsc, time::Instant};

Expand All @@ -26,7 +27,7 @@ pub enum Message {
/// A new job that is not going to be skipped.
NewJob(Arc<Installation>),
/// The version data for a job - only if outdated report requested.
InstallationVersion(Arc<Installation>, String, String),
InstallationVersion(Arc<Installation>, Version, Version),
/// The result of running a single command for a job.
InstallationResult(Arc<HelmResult>),
/// Notification that we started a job.
Expand Down
2 changes: 1 addition & 1 deletion src/output/slack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ async fn process_message(msg: &Arc<Message>, state: &mut State, slack: &SlackSta
if our_version != upstream_version {
state.versions.insert(
installation.id,
(our_version.clone(), upstream_version.clone()),
(our_version.to_string(), upstream_version.to_string()),
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/output/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ fn process_message(msg: &Arc<Message>, state: &mut State) {
if our_version != upstream_version {
state.versions.insert(
installation.id,
(our_version.clone(), upstream_version.clone()),
(our_version.to_string(), upstream_version.to_string()),
);
}
}
Expand Down
Loading

0 comments on commit 663d4e8

Please sign in to comment.