particle-os-cli/src/commands/init.rs
2024-11-14 20:15:12 -05:00

536 lines
15 KiB
Rust

use std::{
env,
fmt::{Display, Write as FmtWrite},
fs::{self, OpenOptions},
io::{BufWriter, Write as IoWrite},
path::PathBuf,
str::FromStr,
};
use blue_build_process_management::drivers::{
opts::GenerateKeyPairOpts, CiDriver, Driver, DriverArgs, GitlabDriver, SigningDriver,
};
use blue_build_template::{GitlabCiTemplate, InitReadmeTemplate, Template};
use blue_build_utils::{
cmd,
constants::{COSIGN_PUB_PATH, RECIPE_FILE, RECIPE_PATH, TEMPLATE_REPO_URL},
};
use bon::Builder;
use clap::{crate_version, Args, ValueEnum};
use log::{debug, info, trace};
use miette::{bail, miette, Context, IntoDiagnostic, Report, Result};
use requestty::{questions, Answer, Answers, OnEsc};
use semver::Version;
use crate::commands::BlueBuildCommand;
#[derive(Debug, Default, Clone, Copy, ValueEnum)]
pub enum CiProvider {
#[default]
Github,
Gitlab,
None,
}
impl CiProvider {
fn default_ci_file_path(self) -> std::path::PathBuf {
match self {
Self::Gitlab => GitlabDriver::default_ci_file_path(),
Self::None | Self::Github => unimplemented!(),
}
}
fn render_file(self) -> Result<String> {
match self {
Self::Gitlab => GitlabCiTemplate::builder()
.version({
let version = crate_version!();
let version: Version = version.parse().into_diagnostic()?;
format!("v{}.{}", version.major, version.minor)
})
.build()
.render()
.into_diagnostic(),
Self::None | Self::Github => unimplemented!(),
}
}
}
impl TryFrom<&str> for CiProvider {
type Error = Report;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
Ok(match value {
"Gitlab" => Self::Gitlab,
"Github" => Self::Github,
"None" => Self::None,
_ => bail!("Unable to parse for CiProvider"),
})
}
}
impl TryFrom<&String> for CiProvider {
type Error = Report;
fn try_from(value: &String) -> std::result::Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl FromStr for CiProvider {
type Err = Report;
fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
Self::try_from(s)
}
}
impl Display for CiProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match *self {
Self::Github => "Github",
Self::Gitlab => "Gitlab",
Self::None => "None",
}
)
}
}
#[derive(Debug, Clone, Default, Args, Builder)]
#[builder(on(String, into))]
pub struct NewInitCommon {
/// The name of the image for the recipe.
#[arg(long)]
image_name: Option<String>,
/// The name of the org where your repo will be located.
/// This could end up being your username.
#[arg(long)]
org_name: Option<String>,
/// Optional description for the GitHub repository.
#[arg(long)]
description: Option<String>,
/// The registry to store the image.
#[arg(long)]
registry: Option<String>,
/// The CI provider that will be building the image.
///
/// GitHub Actions and Gitlab CI are currently the
/// officially supported CI providers.
#[arg(long, short)]
ci_provider: Option<CiProvider>,
/// Disable setting up git.
#[arg(long)]
no_git: bool,
#[clap(flatten)]
#[builder(default)]
drivers: DriverArgs,
}
#[derive(Debug, Clone, Args, Builder)]
pub struct NewCommand {
#[arg()]
dir: PathBuf,
#[clap(flatten)]
common: NewInitCommon,
}
impl BlueBuildCommand for NewCommand {
fn try_run(&mut self) -> Result<()> {
InitCommand::builder()
.dir(self.dir.clone())
.common(self.common.clone())
.build()
.try_run()
}
}
#[derive(Debug, Clone, Args, Builder)]
pub struct InitCommand {
#[clap(skip)]
#[builder(into)]
dir: Option<PathBuf>,
#[clap(flatten)]
common: NewInitCommon,
}
impl BlueBuildCommand for InitCommand {
fn try_run(&mut self) -> Result<()> {
Driver::init(self.common.drivers);
let base_dir = self
.dir
.get_or_insert(env::current_dir().into_diagnostic()?);
if base_dir.exists() && fs::read_dir(base_dir).is_ok_and(|dir| dir.count() != 0) {
bail!("Must be in an empty directory!");
}
self.start(&self.questions()?)
}
}
macro_rules! when {
($check:expr) => {
|_answers: &::requestty::Answers| $check
};
}
impl InitCommand {
const CI_PROVIDER: &str = "ci_provider";
const REGISTRY: &str = "registry";
const IMAGE_NAME: &str = "image_name";
const ORG_NAME: &str = "org_name";
const DESCRIPTION: &str = "description";
fn questions(&self) -> Result<Answers> {
let questions = questions![
Input {
name: Self::IMAGE_NAME,
message: "What would you like to name your image?",
when: when!(self.common.image_name.is_none()),
on_esc: OnEsc::Terminate,
},
Input {
name: Self::REGISTRY,
message:
"What is the registry for the image? (e.g. ghcr.io or registry.gitlab.com)",
when: when!(self.common.registry.is_none()),
on_esc: OnEsc::Terminate,
},
Input {
name: Self::ORG_NAME,
message: "What is the name of your org/username?",
when: when!(self.common.org_name.is_none()),
on_esc: OnEsc::Terminate,
},
Input {
name: Self::DESCRIPTION,
message: "Write a short description of your image:",
when: when!(self.common.description.is_none()),
on_esc: OnEsc::Terminate,
},
Select {
name: Self::CI_PROVIDER,
message: "Are you building on Github or Gitlab?",
when: when!(!self.common.no_git && self.common.ci_provider.is_none()),
on_esc: OnEsc::Terminate,
choices: vec!["Github", "Gitlab", "None"],
}
];
requestty::prompt(questions).into_diagnostic()
}
fn start(&self, answers: &Answers) -> Result<()> {
self.clone_repository()?;
self.remove_git_directory()?;
self.template_readme(answers)?;
self.template_ci_file(answers)?;
self.update_recipe_file(answers)?;
self.generate_signing_files()?;
if !self.common.no_git {
self.initialize_git()?;
self.add_files()?;
self.initial_commit()?;
}
info!(
"Created new BlueBuild project in {}",
self.dir.as_ref().unwrap().display()
);
Ok(())
}
fn clone_repository(&self) -> Result<()> {
let dir = self.dir.as_ref().unwrap();
trace!("clone_repository()");
let mut command = cmd!("git", "clone", "-q", TEMPLATE_REPO_URL, dir);
trace!("{command:?}");
let status = command
.status()
.into_diagnostic()
.context("Failed to execute git clone")?;
if !status.success() {
bail!("Failed to clone template repo");
}
Ok(())
}
fn remove_git_directory(&self) -> Result<()> {
trace!("remove_git_directory()");
let dir = self.dir.as_ref().unwrap();
let git_path = dir.join(".git");
if git_path.exists() {
fs::remove_dir_all(&git_path)
.into_diagnostic()
.context("Failed to remove .git directory")?;
debug!(".git directory removed.");
}
Ok(())
}
fn initialize_git(&self) -> Result<()> {
trace!("initialize_git()");
let dir = self.dir.as_ref().unwrap();
let mut command = cmd!("git", "init", "-q", "-b", "main", dir);
trace!("{command:?}");
let status = command
.status()
.into_diagnostic()
.context("Failed to execute git init")?;
if !status.success() {
bail!("Error initializing git");
}
debug!("Initialized git in {}", dir.display());
Ok(())
}
fn initial_commit(&self) -> Result<()> {
trace!("initial_commit()");
let dir = self.dir.as_ref().unwrap();
let mut command = cmd!(
"git",
"commit",
"-a",
"-m",
"chore: Initial Commit",
current_dir = dir,
);
trace!("{command:?}");
let status = command
.status()
.into_diagnostic()
.context("Failed to run git commit")?;
if !status.success() {
bail!("Failed to commit initial changes");
}
debug!("Created initial commit");
Ok(())
}
fn add_files(&self) -> Result<()> {
trace!("add_files()");
let dir = self.dir.as_ref().unwrap();
let mut command = cmd!("git", "add", ".", current_dir = dir,);
trace!("{command:?}");
let status = command
.status()
.into_diagnostic()
.context("Failed to run git add")?;
if !status.success() {
bail!("Failed to add files to initial commit");
}
debug!("Added files for initial commit");
Ok(())
}
fn template_readme(&self, answers: &Answers) -> Result<()> {
trace!("template_readme()");
let readme_path = self.dir.as_ref().unwrap().join("README.md");
let readme = InitReadmeTemplate::builder()
.repo_name(
self.common
.org_name
.as_deref()
.or_else(|| answers.get(Self::ORG_NAME).and_then(Answer::as_string))
.ok_or_else(|| miette!("Failed to get organization name"))?,
)
.image_name(
self.common
.image_name
.as_deref()
.or_else(|| answers.get(Self::IMAGE_NAME).and_then(Answer::as_string))
.ok_or_else(|| miette!("Failed to get image name"))?,
)
.registry(
self.common
.registry
.as_deref()
.or_else(|| answers.get(Self::REGISTRY).and_then(Answer::as_string))
.ok_or_else(|| miette!("Failed to get registry"))?,
)
.build();
debug!("Templating README");
let readme = readme.render().into_diagnostic()?;
debug!("Writing README to {}", readme_path.display());
fs::write(readme_path, readme).into_diagnostic()
}
fn template_ci_file(&self, answers: &Answers) -> Result<()> {
trace!("template_ci_file()");
let ci_provider = self
.common
.ci_provider
.ok_or("CLI Arg not set")
.or_else(|e| {
answers
.get(Self::CI_PROVIDER)
.and_then(Answer::as_list_item)
.map(|li| &li.text)
.ok_or_else(|| miette!("Failed to get CI Provider answer:\n{e}"))
.and_then(CiProvider::try_from)
})?;
if matches!(ci_provider, CiProvider::Github) {
fs::remove_file(self.dir.as_ref().unwrap().join(".github/CODEOWNERS"))
.into_diagnostic()?;
return Ok(());
}
fs::remove_dir_all(self.dir.as_ref().unwrap().join(".github")).into_diagnostic()?;
// Never run for None
if matches!(ci_provider, CiProvider::None) {
return Ok(());
}
let ci_file_path = self
.dir
.as_ref()
.unwrap()
.join(ci_provider.default_ci_file_path());
let parent_path = ci_file_path
.parent()
.ok_or_else(|| miette!("Couldn't get parent directory from {ci_file_path:?}"))?;
fs::create_dir_all(parent_path)
.into_diagnostic()
.with_context(|| format!("Couldn't create directory path {parent_path:?}"))?;
let file = &mut BufWriter::new(
OpenOptions::new()
.truncate(true)
.create(true)
.write(true)
.open(&ci_file_path)
.into_diagnostic()
.with_context(|| format!("Failed to open file at {ci_file_path:?}"))?,
);
let template = ci_provider.render_file()?;
writeln!(file, "{template}")
.into_diagnostic()
.with_context(|| format!("Failed to write CI file {ci_file_path:?}"))
}
fn update_recipe_file(&self, answers: &Answers) -> Result<()> {
trace!("update_recipe_file()");
let recipe_path = self
.dir
.as_ref()
.unwrap()
.join(RECIPE_PATH)
.join(RECIPE_FILE);
debug!("Reading {recipe_path:?}");
let file = fs::read_to_string(&recipe_path)
.into_diagnostic()
.with_context(|| format!("Failed to read {recipe_path:?}"))?;
let description = self
.common
.description
.as_deref()
.ok_or("Description arg not set")
.or_else(|e| {
answers
.get(Self::DESCRIPTION)
.and_then(Answer::as_string)
.ok_or_else(|| miette!("Failed to get description:\n{e}"))
})?;
let name = self
.common
.image_name
.as_deref()
.ok_or("Description arg not set")
.or_else(|e| {
answers
.get(Self::IMAGE_NAME)
.and_then(Answer::as_string)
.ok_or_else(|| miette!("Failed to get description:\n{e}"))
})?;
let mut new_file_str = String::with_capacity(file.capacity());
for line in file.lines() {
if line.starts_with("description:") {
writeln!(&mut new_file_str, "description: {description}").into_diagnostic()?;
} else if line.starts_with("name: ") {
writeln!(&mut new_file_str, "name: {name}").into_diagnostic()?;
} else {
writeln!(&mut new_file_str, "{line}").into_diagnostic()?;
}
}
let file = &mut BufWriter::new(
OpenOptions::new()
.truncate(true)
.write(true)
.open(&recipe_path)
.into_diagnostic()
.with_context(|| format!("Failed to open {recipe_path:?}"))?,
);
write!(file, "{new_file_str}")
.into_diagnostic()
.with_context(|| format!("Failed to write to file {recipe_path:?}"))
}
fn generate_signing_files(&self) -> Result<()> {
trace!("generate_signing_files()");
debug!("Removing old cosign files {COSIGN_PUB_PATH}");
fs::remove_file(self.dir.as_ref().unwrap().join(COSIGN_PUB_PATH))
.into_diagnostic()
.with_context(|| format!("Failed to delete old public file {COSIGN_PUB_PATH}"))?;
Driver::generate_key_pair(
&GenerateKeyPairOpts::builder()
.maybe_dir(self.dir.as_ref())
.build(),
)
}
}