From 6793f17e3aa80f9a0afb0182c0ee19f416d8c927 Mon Sep 17 00:00:00 2001 From: allanger Date: Mon, 16 Jan 2023 21:05:07 +0100 Subject: [PATCH] Make CDH great (...not again) (#3) --- .github/workflows/build-version.yaml | 61 +++++++ .github/workflows/container-stable.yaml | 53 ++++++ .github/workflows/container-version.yaml | 53 ++++++ .github/workflows/tests.yaml | 57 +++++++ Cargo.lock | 17 +- Cargo.toml | 3 +- README.md | 31 ++++ scripts/download_cdh.sh | 50 ++++++ scripts/rename_releases.sh | 10 ++ src/connectors/argo.rs | 23 +-- src/connectors/helmfile.rs | 2 +- src/main.rs | 209 ++++------------------- src/output/mod.rs | 61 +++++++ src/types/mod.rs | 24 ++- 14 files changed, 460 insertions(+), 194 deletions(-) create mode 100644 .github/workflows/build-version.yaml create mode 100644 .github/workflows/container-stable.yaml create mode 100644 .github/workflows/container-version.yaml create mode 100644 .github/workflows/tests.yaml create mode 100755 scripts/download_cdh.sh create mode 100755 scripts/rename_releases.sh create mode 100644 src/output/mod.rs diff --git a/.github/workflows/build-version.yaml b/.github/workflows/build-version.yaml new file mode 100644 index 0000000..2bca855 --- /dev/null +++ b/.github/workflows/build-version.yaml @@ -0,0 +1,61 @@ +--- +name: "Version build" + +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + + - uses: actions-rs/cargo@v1 + with: + command: build + args: --release --all-features --target=${{ matrix.target }} + + - name: Archive build artifacts + uses: actions/upload-artifact@v2 + with: + name: build-${{matrix.target}} + path: ${{ github.workspace }}/target/${{ matrix.target }}/release/cdh + + release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Download artifact + uses: actions/download-artifact@v2 + + - name: Set version variable + run: echo "CDH_VERSION=${GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Rename release to avoid name conflict + run: ./scripts/rename_releases.sh + + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: release/* diff --git a/.github/workflows/container-stable.yaml b/.github/workflows/container-stable.yaml new file mode 100644 index 0000000..9189fff --- /dev/null +++ b/.github/workflows/container-stable.yaml @@ -0,0 +1,53 @@ +--- +name: "Stable container" + +on: + push: + branches: + - main + paths: + - "src/**" + +jobs: + containerization: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set action link variable + run: echo "LINK=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@master + with: + platforms: all + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@master + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.CR_PAT }} + + - name: Build + uses: docker/build-push-action@v2 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/allanger/${{ env.GITHUB_REPOSITORY }}:stable + ghcr.io/allanger/${{ env.GITHUB_REPOSITORY }}:latest + labels: | + action_id=${{ github.action }} + action_link=${{ env.LINK }} + actor=${{ github.actor }} + sha=${{ github.sha }} + ref=${{ github.ref }} diff --git a/.github/workflows/container-version.yaml b/.github/workflows/container-version.yaml new file mode 100644 index 0000000..d40f07e --- /dev/null +++ b/.github/workflows/container-version.yaml @@ -0,0 +1,53 @@ +--- +name: "Version container" + +on: + push: + tags: + - "v*.*.*" + +jobs: + containerization: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set version variable + run: echo "TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Set action link variable + run: echo "LINK=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@master + with: + platforms: all + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@master + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.CR_PAT }} + + - name: Build + uses: docker/build-push-action@v2 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/allanger/${{ env.GITHUB_REPOSITORY }}:${{ env.TAG }} + labels: | + action_id=${{ github.action }} + action_link=${{ env.LINK }} + actor=${{ github.actor }} + sha=${{ github.sha }} + ref=${{ github.ref }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..b991a9b --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,57 @@ +--- +name: "Tests" + +on: + pull_request: + branches: + - main + paths: + - "src/**" + +jobs: + cargo_udeps: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + + - name: Install cargo-udeps + run: cargo install cargo-udeps --locked + + - name: Check dependencies + run: cargo +nightly udeps + + cargo_test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - uses: actions-rs/cargo@v1 + with: + command: build + args: --release --all-features + - run: cargo test + cargo_clippy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - uses: actions-rs/cargo@v1 + with: + command: build + args: --release --all-features + - run: cargo clippy diff --git a/Cargo.lock b/Cargo.lock index 011ec71..02db667 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,12 +32,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "build_html" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8ffb62af7b0911893e2d6126891b2a018e387078fa5d855cb42d0d90d88075" - [[package]] name = "bytecount" version = "0.6.3" @@ -54,8 +48,8 @@ checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4" name = "cdh" version = "0.1.0" dependencies = [ - "build_html", "clap", + "clap_complete", "env_logger", "handlebars", "log", @@ -87,6 +81,15 @@ dependencies = [ "termcolor", ] +[[package]] +name = "clap_complete" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8955d4e8cd4f28f9a01c93a050194c4d131e73ca02f6636bcddbed867014d7" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.1.0" diff --git a/Cargo.toml b/Cargo.toml index db36db3..a6e103e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,5 +14,6 @@ version-compare = "0.1.0" clap = { version = "4.1.1", features = ["derive", "env"] } serde_yaml = "0.9.16" tabled = "0.10.0" -build_html = "2.1.0" handlebars = "4.3.1" +clap_complete = "4.0.6" + diff --git a/README.md b/README.md index 4e4d1c1..a322e3e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,33 @@ # Check Da Helm > Your helm releases are outdated, aren't they? Now you can check! + +[![Version build](https://github.com/allanger/check-da-helm/actions/workflows/build-version.yaml/badge.svg)](https://github.com/allanger/check-da-helm/actions/workflows/build-version.yaml) +[![Version container](https://github.com/allanger/check-da-helm/actions/workflows/container-version.yaml/badge.svg)](https://github.com/allanger/check-da-helm/actions/workflows/container-version.yaml) +[![Stable container](https://github.com/allanger/check-da-helm/actions/workflows/container-stable.yaml/badge.svg)](https://github.com/allanger/check-da-helm/actions/workflows/container-stable.yaml) + +## What's this? +It's a simple command line tool that lets you check whether your helm releases (currently installed by helmfile or argo) are outdated or not. Why it's created? But the main reason why it's created, is a necessity to check if helm releases that you have installed in your cluster still exist in repos. Once `Bitnami` removed old charts from their main repo and, I believe, everybody needed then some time to understand what happened. So I decided to write this tool. I was checking helmfiles and testing if chart were still in repos. And in case something is broken, I would be notified in the morning. Of course, broken helm charts are something you'll eventually know about, but it just feels better to know about them with this simple cli. + +## Install +### Dependencies +Depending on the tool you want to use `cdm` with, you must have either `helmfile` or `argocd` installed. And in any case you need to have `helm` + +### Download + +Get executable from github releases + +Prebuilt binaries exist for **Linux x86_64** and **MacOS arm64** and **x86_64** + +Don't forget to add the binary to $PATH +```BASH +$ curl https://raw.githubusercontent.com/allanger/check-da-helm/main/scripts/download_cdm.sh | bash +$ cdm -h +``` + +### Build from source +1. Build binary +```BASH +$ cargo build --release +``` +2. Run `gum help` + diff --git a/scripts/download_cdh.sh b/scripts/download_cdh.sh new file mode 100755 index 0000000..3d47749 --- /dev/null +++ b/scripts/download_cdh.sh @@ -0,0 +1,50 @@ +#!/bin/bash +case "$(uname)" in + +"Darwin") + SYSTEM="apple-darwin" + case $(uname -m) in + "arm64") + TARGET="aarch64-$SYSTEM" + ;; + "x86_64") + TARGET="x86_64-$SYSTEM" + ;; + *) + echo "Unsuported target" + exit 1 + ;; + esac + ;; +"Linux") + SYSTEM="unknown-linux-gnu" + case $(uname -m) in + "x86_64") + TARGET="x86_64-$SYSTEM" + ;; + *) + echo "Unsuported target" + exit 1 + ;; + esac + ;; +*) + echo "Signal number $1 is not processed" + exit 1 + ;; +esac +LATEST_VERSION="v$(curl -s https://raw.githubusercontent.com/allanger/check-da-helm/main/Cargo.toml | awk -F ' = ' '$1 ~ /version/ { gsub(/[\"]/, "", $2); printf("%s",$2) }')" +echo "Downloading $LATEST_VERSION" + +RELEASE_NAME=cdh-$LATEST_VERSION-$TARGET +RELEASE_URL="https://github.com/allanger/check-da-helm/releases/download/$LATEST_VERSION/$RELEASE_NAME" +echo "Link for downloading: $RELEASE_URL" +curl -LJO $RELEASE_URL + +mv $RELEASE_NAME cdh +chmod +x cdh + +echo 'Make sure that cdh is in your $PATH' +echo 'Try: ' +echo ' $ export PATH=$PATH:$PWD' +echo ' $ cdh -h' \ No newline at end of file diff --git a/scripts/rename_releases.sh b/scripts/rename_releases.sh new file mode 100755 index 0000000..02f42b3 --- /dev/null +++ b/scripts/rename_releases.sh @@ -0,0 +1,10 @@ +#!/bin/bash +echo 'renaming cdh to cdh-$VERSION-$SYSTEM format' +mkdir -p release +echo "version - $CDH_VERSION" +for BUILD in build*; do + SYSTEM=$(echo $BUILD | sed -e 's/build-//g') + echo "system - $SYSTEM" + cp $BUILD/cdh release/cdh-$CDH_VERSION-$SYSTEM +done +ls release diff --git a/src/connectors/argo.rs b/src/connectors/argo.rs index d1a302e..ecd2ed0 100644 --- a/src/connectors/argo.rs +++ b/src/connectors/argo.rs @@ -1,10 +1,13 @@ -use clap::Arg; -use log::{debug, info, error}; -use serde_json::from_str; use crate::types::{self, HelmRepo}; +use log::{debug, error, info}; +use serde_json::from_str; use super::Connector; -use std::{borrow::Borrow, io::{Result, Error, ErrorKind}, process::Command}; +use std::{ + borrow::Borrow, + io::{Error, ErrorKind, Result}, + process::Command, +}; pub(crate) struct Argo; @@ -96,7 +99,7 @@ impl Connector for Argo { ); } } - + Ok(()) } else { Err(Error::new( @@ -104,12 +107,10 @@ impl Connector for Argo { String::from_utf8_lossy(&output.stderr), )) } - } - } -impl Argo{ -pub(crate) fn init() -> Argo { - Argo +impl Argo { + pub(crate) fn init() -> Argo { + Argo + } } -} \ No newline at end of file diff --git a/src/connectors/helmfile.rs b/src/connectors/helmfile.rs index fb8ff6e..bae5885 100644 --- a/src/connectors/helmfile.rs +++ b/src/connectors/helmfile.rs @@ -4,7 +4,7 @@ use serde_json::from_str; use crate::types; use super::Connector; -use std::{borrow::Borrow, fmt::format, io::Result, process::Command}; +use std::{borrow::Borrow, io::Result, process::Command}; pub(crate) struct Helmfile { path: String, diff --git a/src/main.rs b/src/main.rs index 49ee268..2031a8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,21 @@ mod connectors; +mod output; mod types; - -use clap::{Parser, ValueEnum}; +use clap::{arg, command, Parser, Subcommand, ValueEnum}; use connectors::{Argo, Connector, Helm, Helmfile}; -use handlebars::Handlebars; use log::{debug, error, info, warn}; +use output::Output; use serde::{Deserialize, Serialize}; use serde_json::from_str; use std::{ borrow::Borrow, - fmt::{self, format}, - io::{Error, ErrorKind, Result}, + io::Result, process::{exit, Command}, }; -use tabled::Tabled; +use types::ExecResult; use version_compare::{Cmp, Version}; -use crate::types::HelmChart; +use crate::types::{HelmChart, Status}; #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] enum Kinds { @@ -25,13 +24,22 @@ enum Kinds { Helmfile, } +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Outputs { + Yaml, + HTML, +} + /// Check you helm releaseas managed by Argo #[derive(Parser)] #[clap(author, version, about, long_about = None)] struct Args { - /// Type of the + /// How do you install your helm charts #[clap(long, value_enum)] kind: Kinds, + /// What kind of output would you like to receive? + #[clap(long, value_enum, default_value = "yaml")] + output: Outputs, /// Path to the helmfile #[clap(short, long, value_parser, default_value = "./")] path: String, @@ -43,6 +51,14 @@ struct Args { no_sync: bool, } +#[derive(Debug, Subcommand)] +enum Commands { + #[command(arg_required_else_help = true)] + Generate { + #[arg(value_name = "SHELL", default_missing_value = "zsh")] + shell: clap_complete::shells::Shell, + }, +} /// A struct to write helm repo description to #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] struct Repo { @@ -58,42 +74,7 @@ struct LocalCharts { version: Option, } -/// Three possible statuses of versions comparison -#[derive(Clone, Serialize)] -enum Status { - Uptodate, - Outdated, - Missing, -} - -impl fmt::Display for Status { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - Status::Uptodate => write!(f, "Up-to-date"), - Status::Outdated => write!(f, "Outdated"), - Status::Missing => write!(f, "Missing"), - } - } -} -#[derive(Clone, Tabled, Serialize)] -struct ExecResult { - name: String, - latest_version: String, - current_version: String, - status: Status, -} - // Implementation for the ExecResult struct -impl ExecResult { - fn new(name: String, latest_version: String, current_version: String, status: Status) -> Self { - Self { - name, - latest_version, - current_version, - status, - } - } -} fn main() { // Preparations step @@ -110,7 +91,7 @@ fn main() { if !args.no_sync { info!("syncing helm repositories"); - let res = match args.kind { + let res = match args.kind { Kinds::Argo => Argo::init().sync_repos(), Kinds::Helm => Helm::init().sync_repos(), Kinds::Helmfile => Helmfile::init(args.path).sync_repos(), @@ -122,12 +103,12 @@ fn main() { } charts.iter().for_each(|a| { - let err = check_chart(&mut result, a); + check_chart(&mut result, a).unwrap(); }); // Parse the helmfile // Handling the result - match handle_result(&result, args.outdated_fail) { + match handle_result(&result, args.outdated_fail, args.output) { Ok(result) => { if result { exit(1); @@ -141,7 +122,7 @@ fn main() { } fn check_chart(result: &mut Vec, local_chart: &types::HelmChart) -> Result<()> { - if local_chart.clone().name.is_some() { + if local_chart.name.is_some() { let version = local_chart.version.clone().unwrap(); let chart = local_chart.name.clone().unwrap(); return match version.is_empty() { @@ -215,7 +196,11 @@ fn check_chart(result: &mut Vec, local_chart: &types::HelmChart) -> } /// Handle result -fn handle_result(result: &Vec, outdated_fail: bool) -> Result { +fn handle_result( + result: &Vec, + outdated_fail: bool, + output_kind: Outputs, +) -> Result { let mut failed = false; for r in result.clone() { match r.status { @@ -238,135 +223,15 @@ fn handle_result(result: &Vec, outdated_fail: bool) -> Result } } } - let template = r#" - - - - - - - - {{#each this as |tr|}} - - - - - - - {{/each}} -
Chart NameCurrent VersionLatest VersionStatus
{{tr.name}}{{tr.current_version}}{{tr.latest_version}}{{tr.status}}
-"#; - let mut reg = Handlebars::new(); - // TODO: Handle this error - reg.register_template_string("html_table", template) - .unwrap(); - - match reg.render("html_table", &result) { - Ok(res) => println!("{}", res), - Err(err) => error!("{}", err), + match output_kind { + Outputs::Yaml => print!("{}", output::YAML::print(result)?), + Outputs::HTML => print!("{}", output::HTML::print(result)?), }; + Ok(failed) } -/// Downloading repos from repositories -fn repo_sync() -> Result<()> { - info!("syncing helm repos"); - let cmd: String = "argocd app list -o json | jq '[ .[] | {name: .spec.source.chart, url: .spec.source.repoURL} ]'".to_string(); - let output = Command::new("bash") - .arg("-c") - .arg(cmd) - .output() - .expect("helmfile is failed"); - info!("{:?}", output.clone()); - if output.status.success() { - let repos: Vec = serde_json::from_slice(&output.stdout).unwrap(); - info!("adding repositories"); - for repo in repos.iter() { - let name = repo.name.clone(); - if name.is_some() { - info!( - "syncing {} with the origin {}", - name.clone().unwrap(), - repo.url - ); - let cmd = format!( - "helm repo add {} {}", - name.clone().unwrap(), - repo.url.clone() - ); - debug!("running {}", cmd); - let output = Command::new("bash") - .arg("-c") - .arg(cmd) - .output() - .expect("helm repo sync is failed"); - match output.status.success() { - true => { - info!( - "{} with the origin {} is synced successfully", - name.unwrap(), - repo.url - ); - } - false => { - error!( - "{} with the origin {} can't be synced", - name.unwrap(), - repo.url - ) - } - } - } - } - let cmd = "helm repo update"; - let output = Command::new("bash") - .arg("-c") - .arg(cmd) - .output() - .expect("helm repo sync is failed"); - match output.status.success() { - true => { - info!("repositories are updated successfully"); - } - false => { - error!( - "repositories can't be updated, {}", - String::from_utf8_lossy(&output.stderr) - ); - } - } - - Ok(()) - } else { - Err(Error::new( - ErrorKind::Other, - String::from_utf8_lossy(&output.stderr), - )) - } -} - -/// Run helmfile list and write the result into struct -fn parse_argo_apps() -> Result> { - let cmd: String = "argocd app list -o json | jq '[.[] | {chart: .spec.source.chart, version: .spec.source.targetRevision}]'".to_string(); - - debug!("executing '${}'", cmd); - let output = Command::new("bash") - .arg("-c") - .arg(cmd) - .output() - .expect("helmfile is failed"); - let helm_stdout = String::from_utf8_lossy(&output.stdout); - - match from_str::>(Borrow::borrow(&helm_stdout)) { - Ok(mut charts) => { - charts.dedup(); - Ok(charts) - } - Err(err) => Err(err.into()), - } -} - /// Takes two version and returns the newer one. fn get_newer_version(v1: String, v2: String) -> String { match Version::from(&v1.replace('v', "")) diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 0000000..2423b5b --- /dev/null +++ b/src/output/mod.rs @@ -0,0 +1,61 @@ +use std::io::{Result, Error, ErrorKind}; + +use handlebars::Handlebars; +use log::error; + +use crate::types::ExecResult; + +pub(crate) trait Output { + fn print(data: &Vec) -> Result; +} + +pub(crate) struct HTML; + +impl Output for HTML { + fn print(data: &Vec) -> Result { + // To generate htlm output, I have to use templates because I haven't found any other good + // solution + let template = r#" + + + + + + + + {{#each this as |tr|}} + + + + + + + {{/each}} +
Chart NameCurrent VersionLatest VersionStatus
{{tr.name}}{{tr.current_version}}{{tr.latest_version}}{{tr.status}}
+"#; + + let mut reg = Handlebars::new(); + // TODO: Handle this error + reg.register_template_string("html_table", template) + .unwrap(); + + match reg.render("html_table", &data) { + Ok(res) => Ok(res), + Err(err) => { + error!("{}", err); + return Err(Error::new(ErrorKind::InvalidInput, err.to_string())); + } + } + } +} + +pub(crate) struct YAML; + +impl Output for YAML { + fn print(data: &Vec) -> Result { + match serde_yaml::to_string(&data) { + Ok(res) => return Ok(res), + Err(err) => return Err(Error::new(ErrorKind::InvalidData, err.to_string())), + } + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index a0be023..eaf52b4 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::fmt; +use tabled::Tabled; /// Struct for parsing charts info from helmfile #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] @@ -15,8 +16,8 @@ pub(crate) struct HelmRepo { pub(crate) url: String, } -#[derive(Clone, Serialize)] -enum Status { +#[derive(Clone, Serialize, Deserialize)] +pub(crate) enum Status { Uptodate, Outdated, Missing, @@ -31,3 +32,22 @@ impl fmt::Display for Status { } } } + +#[derive(Clone, Tabled, Serialize, Deserialize)] +pub(crate) struct ExecResult { + pub(crate) name: String, + pub(crate) latest_version: String, + pub(crate) current_version: String, + pub(crate) status: Status, +} + +impl ExecResult { + pub(crate) fn new(name: String, latest_version: String, current_version: String, status: Status) -> Self { + Self { + name, + latest_version, + current_version, + status, + } + } +}