Skip to main content

blue_build/commands/
init.rs

1use std::{
2    env,
3    fmt::{Display, Write as FmtWrite},
4    fs::{self, OpenOptions},
5    io::{BufWriter, Write as IoWrite},
6    path::PathBuf,
7    str::FromStr,
8};
9
10use blue_build_process_management::drivers::{
11    CiDriver, Driver, DriverArgs, GitlabDriver, SigningDriver, opts::GenerateKeyPairOpts,
12};
13use blue_build_template::{GitlabCiTemplate, InitReadmeTemplate, Template};
14use blue_build_utils::constants::{COSIGN_PUB_PATH, RECIPE_FILE, RECIPE_PATH, TEMPLATE_REPO_URL};
15use bon::Builder;
16use clap::{Args, ValueEnum, crate_version};
17use comlexr::cmd;
18use log::{debug, info, trace};
19use miette::{Context, IntoDiagnostic, Report, Result, bail, miette};
20use requestty::{Answer, Answers, OnEsc, questions};
21use semver::Version;
22
23use crate::commands::BlueBuildCommand;
24
25#[derive(Debug, Default, Clone, Copy, ValueEnum)]
26pub enum CiProvider {
27    #[default]
28    Github,
29    Gitlab,
30    None,
31}
32
33impl CiProvider {
34    fn default_ci_file_path(self) -> std::path::PathBuf {
35        match self {
36            Self::Gitlab => GitlabDriver::default_ci_file_path(),
37            Self::None | Self::Github => unimplemented!(),
38        }
39    }
40
41    fn render_file(self) -> Result<String> {
42        match self {
43            Self::Gitlab => GitlabCiTemplate::builder()
44                .version({
45                    let version = crate_version!();
46                    let version: Version = version.parse().into_diagnostic()?;
47
48                    format!("v{}.{}", version.major, version.minor)
49                })
50                .build()
51                .render()
52                .into_diagnostic(),
53            Self::None | Self::Github => unimplemented!(),
54        }
55    }
56}
57
58impl TryFrom<&str> for CiProvider {
59    type Error = Report;
60
61    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
62        Ok(match value {
63            "Gitlab" => Self::Gitlab,
64            "Github" => Self::Github,
65            "None" => Self::None,
66            _ => bail!("Unable to parse for CiProvider"),
67        })
68    }
69}
70
71impl TryFrom<&String> for CiProvider {
72    type Error = Report;
73
74    fn try_from(value: &String) -> std::result::Result<Self, Self::Error> {
75        Self::try_from(value.as_str())
76    }
77}
78
79impl FromStr for CiProvider {
80    type Err = Report;
81
82    fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
83        Self::try_from(s)
84    }
85}
86
87impl Display for CiProvider {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(
90            f,
91            "{}",
92            match *self {
93                Self::Github => "Github",
94                Self::Gitlab => "Gitlab",
95                Self::None => "None",
96            }
97        )
98    }
99}
100
101#[derive(Debug, Clone, Default, Args, Builder)]
102#[builder(on(String, into))]
103pub struct NewInitCommon {
104    /// The name of the image for the recipe.
105    #[arg(long)]
106    image_name: Option<String>,
107
108    /// The name of the org where your repo will be located.
109    /// This could end up being your username.
110    #[arg(long)]
111    org_name: Option<String>,
112
113    /// Optional description for the GitHub repository.
114    #[arg(long)]
115    description: Option<String>,
116
117    /// The registry to store the image.
118    #[arg(long)]
119    registry: Option<String>,
120
121    /// The CI provider that will be building the image.
122    ///
123    /// GitHub Actions and Gitlab CI are currently the
124    /// officially supported CI providers.
125    #[arg(long, short)]
126    ci_provider: Option<CiProvider>,
127
128    /// Disable setting up git.
129    #[arg(long)]
130    no_git: bool,
131
132    #[clap(flatten)]
133    #[builder(default)]
134    drivers: DriverArgs,
135}
136
137#[derive(Debug, Clone, Args, Builder)]
138pub struct NewCommand {
139    #[arg()]
140    dir: PathBuf,
141
142    #[clap(flatten)]
143    common: NewInitCommon,
144}
145
146impl BlueBuildCommand for NewCommand {
147    fn try_run(&mut self) -> Result<()> {
148        InitCommand::builder()
149            .dir(self.dir.clone())
150            .common(self.common.clone())
151            .build()
152            .try_run()
153    }
154}
155
156#[derive(Debug, Clone, Args, Builder)]
157pub struct InitCommand {
158    #[clap(skip)]
159    #[builder(into)]
160    dir: Option<PathBuf>,
161
162    #[clap(flatten)]
163    common: NewInitCommon,
164}
165
166impl BlueBuildCommand for InitCommand {
167    fn try_run(&mut self) -> Result<()> {
168        Driver::init(self.common.drivers);
169
170        let base_dir = self
171            .dir
172            .get_or_insert(env::current_dir().into_diagnostic()?);
173
174        if base_dir.exists() && fs::read_dir(base_dir).is_ok_and(|dir| dir.count() != 0) {
175            bail!("Must be in an empty directory!");
176        }
177
178        self.start(&self.questions()?)
179    }
180}
181
182macro_rules! when {
183    ($check:expr) => {
184        |_answers: &::requestty::Answers| $check
185    };
186}
187
188impl InitCommand {
189    const CI_PROVIDER: &str = "ci_provider";
190    const REGISTRY: &str = "registry";
191    const IMAGE_NAME: &str = "image_name";
192    const ORG_NAME: &str = "org_name";
193    const DESCRIPTION: &str = "description";
194
195    fn questions(&self) -> Result<Answers> {
196        let questions = questions![
197            Input {
198                name: Self::IMAGE_NAME,
199                message: "What would you like to name your image?",
200                when: when!(self.common.image_name.is_none()),
201                on_esc: OnEsc::Terminate,
202            },
203            Input {
204                name: Self::REGISTRY,
205                message: "What is the registry for the image? (e.g. ghcr.io or registry.gitlab.com)",
206                when: when!(self.common.registry.is_none()),
207                on_esc: OnEsc::Terminate,
208            },
209            Input {
210                name: Self::ORG_NAME,
211                message: "What is the name of your org/username?",
212                when: when!(self.common.org_name.is_none()),
213                on_esc: OnEsc::Terminate,
214            },
215            Input {
216                name: Self::DESCRIPTION,
217                message: "Write a short description of your image:",
218                when: when!(self.common.description.is_none()),
219                on_esc: OnEsc::Terminate,
220            },
221            Select {
222                name: Self::CI_PROVIDER,
223                message: "Are you building on Github or Gitlab?",
224                when: when!(!self.common.no_git && self.common.ci_provider.is_none()),
225                on_esc: OnEsc::Terminate,
226                choices: vec!["Github", "Gitlab", "None"],
227            }
228        ];
229
230        requestty::prompt(questions).into_diagnostic()
231    }
232
233    fn start(&self, answers: &Answers) -> Result<()> {
234        self.clone_repository()?;
235        self.remove_git_directory()?;
236        self.template_readme(answers)?;
237        self.template_ci_file(answers)?;
238        self.update_recipe_file(answers)?;
239        self.generate_signing_files()?;
240
241        if !self.common.no_git {
242            self.initialize_git()?;
243            self.add_files()?;
244            self.initial_commit()?;
245        }
246
247        info!(
248            "Created new BlueBuild project in {}",
249            self.dir.as_ref().unwrap().display()
250        );
251
252        Ok(())
253    }
254
255    fn clone_repository(&self) -> Result<()> {
256        let dir = self.dir.as_ref().unwrap();
257        trace!("clone_repository()");
258
259        let mut command = cmd!("git", "clone", "-q", TEMPLATE_REPO_URL, dir);
260        trace!("{command:?}");
261
262        let status = command
263            .status()
264            .into_diagnostic()
265            .context("Failed to execute git clone")?;
266
267        if !status.success() {
268            bail!("Failed to clone template repo");
269        }
270
271        Ok(())
272    }
273
274    fn remove_git_directory(&self) -> Result<()> {
275        trace!("remove_git_directory()");
276
277        let dir = self.dir.as_ref().unwrap();
278        let git_path = dir.join(".git");
279
280        if git_path.exists() {
281            fs::remove_dir_all(&git_path)
282                .into_diagnostic()
283                .context("Failed to remove .git directory")?;
284            debug!(".git directory removed.");
285        }
286        Ok(())
287    }
288
289    fn initialize_git(&self) -> Result<()> {
290        trace!("initialize_git()");
291
292        let dir = self.dir.as_ref().unwrap();
293
294        let mut command = cmd!("git", "init", "-q", "-b", "main", dir);
295        trace!("{command:?}");
296
297        let status = command
298            .status()
299            .into_diagnostic()
300            .context("Failed to execute git init")?;
301
302        if !status.success() {
303            bail!("Error initializing git");
304        }
305
306        debug!("Initialized git in {}", dir.display());
307
308        Ok(())
309    }
310
311    fn initial_commit(&self) -> Result<()> {
312        trace!("initial_commit()");
313
314        let dir = self.dir.as_ref().unwrap();
315
316        let mut command = cmd!(cd dir; "git", "commit", "-a", "-m", "chore: Initial Commit");
317        trace!("{command:?}");
318
319        let status = command
320            .status()
321            .into_diagnostic()
322            .context("Failed to run git commit")?;
323
324        if !status.success() {
325            bail!("Failed to commit initial changes");
326        }
327
328        debug!("Created initial commit");
329
330        Ok(())
331    }
332
333    fn add_files(&self) -> Result<()> {
334        trace!("add_files()");
335
336        let dir = self.dir.as_ref().unwrap();
337
338        let mut command = cmd!(cd dir; "git", "add", ".");
339        trace!("{command:?}");
340
341        let status = command
342            .status()
343            .into_diagnostic()
344            .context("Failed to run git add")?;
345
346        if !status.success() {
347            bail!("Failed to add files to initial commit");
348        }
349
350        debug!("Added files for initial commit");
351
352        Ok(())
353    }
354
355    fn template_readme(&self, answers: &Answers) -> Result<()> {
356        trace!("template_readme()");
357
358        let readme_path = self.dir.as_ref().unwrap().join("README.md");
359
360        let readme = InitReadmeTemplate::builder()
361            .repo_name(
362                self.common
363                    .org_name
364                    .as_deref()
365                    .or_else(|| answers.get(Self::ORG_NAME).and_then(Answer::as_string))
366                    .ok_or_else(|| miette!("Failed to get organization name"))?,
367            )
368            .image_name(
369                self.common
370                    .image_name
371                    .as_deref()
372                    .or_else(|| answers.get(Self::IMAGE_NAME).and_then(Answer::as_string))
373                    .ok_or_else(|| miette!("Failed to get image name"))?,
374            )
375            .registry(
376                self.common
377                    .registry
378                    .as_deref()
379                    .or_else(|| answers.get(Self::REGISTRY).and_then(Answer::as_string))
380                    .ok_or_else(|| miette!("Failed to get registry"))?,
381            )
382            .build();
383
384        debug!("Templating README");
385        let readme = readme.render().into_diagnostic()?;
386
387        debug!("Writing README to {}", readme_path.display());
388        fs::write(readme_path, readme).into_diagnostic()
389    }
390
391    fn template_ci_file(&self, answers: &Answers) -> Result<()> {
392        trace!("template_ci_file()");
393
394        let ci_provider = self
395            .common
396            .ci_provider
397            .ok_or("CLI Arg not set")
398            .or_else(|e| {
399                answers
400                    .get(Self::CI_PROVIDER)
401                    .and_then(Answer::as_list_item)
402                    .map(|li| &li.text)
403                    .ok_or_else(|| miette!("Failed to get CI Provider answer:\n{e}"))
404                    .and_then(CiProvider::try_from)
405            })?;
406
407        if matches!(ci_provider, CiProvider::Github) {
408            fs::remove_file(self.dir.as_ref().unwrap().join(".github/CODEOWNERS"))
409                .into_diagnostic()?;
410            return Ok(());
411        }
412
413        fs::remove_dir_all(self.dir.as_ref().unwrap().join(".github")).into_diagnostic()?;
414
415        // Never run for None
416        if matches!(ci_provider, CiProvider::None) {
417            return Ok(());
418        }
419
420        let ci_file_path = self
421            .dir
422            .as_ref()
423            .unwrap()
424            .join(ci_provider.default_ci_file_path());
425        let parent_path = ci_file_path
426            .parent()
427            .ok_or_else(|| miette!("Couldn't get parent directory from {ci_file_path:?}"))?;
428        fs::create_dir_all(parent_path)
429            .into_diagnostic()
430            .with_context(|| format!("Couldn't create directory path {}", parent_path.display()))?;
431
432        let file = &mut BufWriter::new(
433            OpenOptions::new()
434                .truncate(true)
435                .create(true)
436                .write(true)
437                .open(&ci_file_path)
438                .into_diagnostic()
439                .with_context(|| format!("Failed to open file at {}", ci_file_path.display()))?,
440        );
441
442        let template = ci_provider.render_file()?;
443
444        writeln!(file, "{template}")
445            .into_diagnostic()
446            .with_context(|| format!("Failed to write CI file {}", ci_file_path.display()))
447    }
448
449    fn update_recipe_file(&self, answers: &Answers) -> Result<()> {
450        trace!("update_recipe_file()");
451
452        let recipe_path = self
453            .dir
454            .as_ref()
455            .unwrap()
456            .join(RECIPE_PATH)
457            .join(RECIPE_FILE);
458
459        debug!("Reading {}", recipe_path.display());
460        let file = fs::read_to_string(&recipe_path)
461            .into_diagnostic()
462            .with_context(|| format!("Failed to read {}", recipe_path.display()))?;
463
464        let description = self
465            .common
466            .description
467            .as_deref()
468            .ok_or("Description arg not set")
469            .or_else(|e| {
470                answers
471                    .get(Self::DESCRIPTION)
472                    .and_then(Answer::as_string)
473                    .ok_or_else(|| miette!("Failed to get description:\n{e}"))
474            })?;
475        let name = self
476            .common
477            .image_name
478            .as_deref()
479            .ok_or("Description arg not set")
480            .or_else(|e| {
481                answers
482                    .get(Self::IMAGE_NAME)
483                    .and_then(Answer::as_string)
484                    .ok_or_else(|| miette!("Failed to get description:\n{e}"))
485            })?;
486
487        let mut new_file_str = String::with_capacity(file.capacity());
488
489        for line in file.lines() {
490            if line.starts_with("description:") {
491                writeln!(&mut new_file_str, "description: {description}").into_diagnostic()?;
492            } else if line.starts_with("name: ") {
493                writeln!(&mut new_file_str, "name: {name}").into_diagnostic()?;
494            } else {
495                writeln!(&mut new_file_str, "{line}").into_diagnostic()?;
496            }
497        }
498
499        let file = &mut BufWriter::new(
500            OpenOptions::new()
501                .truncate(true)
502                .write(true)
503                .open(&recipe_path)
504                .into_diagnostic()
505                .with_context(|| format!("Failed to open {}", recipe_path.display()))?,
506        );
507        write!(file, "{new_file_str}")
508            .into_diagnostic()
509            .with_context(|| format!("Failed to write to file {}", recipe_path.display()))
510    }
511
512    fn generate_signing_files(&self) -> Result<()> {
513        trace!("generate_signing_files()");
514
515        debug!("Removing old cosign files {COSIGN_PUB_PATH}");
516        fs::remove_file(self.dir.as_ref().unwrap().join(COSIGN_PUB_PATH))
517            .into_diagnostic()
518            .with_context(|| format!("Failed to delete old public file {COSIGN_PUB_PATH}"))?;
519
520        Driver::generate_key_pair(
521            GenerateKeyPairOpts::builder()
522                .maybe_dir(self.dir.as_deref())
523                .build(),
524        )
525    }
526}