#![allow(clippy::print_stdout)]
#![allow(clippy::print_stderr)]
use crate::commands::release::generate_cue;
use crate::utils::{command::run_command, git, paths};
use anyhow::{Context, Result, anyhow, bail};
use semver::Version;
use std::{
env, fs,
path::{Path, PathBuf},
process::Command,
};
use toml_edit::DocumentMut;
const ALPINE_PREFIX: &str = "FROM docker.io/alpine:";
const ALPINE_DOCKERFILE: &str = "distribution/docker/alpine/Dockerfile";
const DEBIAN_PREFIX: &str = "FROM docker.io/debian:";
const DEBIAN_DOCKERFILE: &str = "distribution/docker/debian/Dockerfile";
const KUBECLT_CUE_FILE: &str = "website/cue/reference/administration/interfaces/kubectl.cue";
const INSTALL_SCRIPT: &str = "distribution/install.sh";
#[derive(clap::Args, Debug)]
#[command()]
pub struct Cli {
#[arg(long)]
version: Version,
#[arg(long)]
vrl_version: Version,
#[arg(long)]
alpine_version: Option<String>,
#[arg(long)]
debian_version: Option<String>,
#[arg(long, default_value_t = false)]
dry_run: bool,
}
struct Prepare {
new_vector_version: Version,
vrl_version: Version,
alpine_version: Option<String>,
debian_version: Option<String>,
repo_root: PathBuf,
latest_vector_version: Version,
release_branch: String,
release_preparation_branch: String,
dry_run: bool,
}
impl Cli {
pub fn exec(self) -> Result<()> {
let repo_root = paths::find_repo_root()?;
env::set_current_dir(&repo_root)?;
let prepare = Prepare {
new_vector_version: self.version.clone(),
vrl_version: self.vrl_version,
alpine_version: self.alpine_version,
debian_version: self.debian_version,
repo_root,
latest_vector_version: git::latest_release_version()?,
release_branch: format!("v{}.{}", self.version.major, self.version.minor),
release_preparation_branch: format!(
"prepare-v-{}-{}-{}-website",
self.version.major, self.version.minor, self.version.patch
),
dry_run: self.dry_run,
};
prepare.run()
}
}
impl Prepare {
pub fn run(&self) -> Result<()> {
debug!("run");
self.create_release_branches()?;
self.pin_vrl_version()?;
self.update_dockerfile_base_version(
&self.repo_root.join(ALPINE_DOCKERFILE),
self.alpine_version.as_deref(),
ALPINE_PREFIX,
)?;
self.update_dockerfile_base_version(
&self.repo_root.join(DEBIAN_DOCKERFILE),
self.debian_version.as_deref(),
DEBIAN_PREFIX,
)?;
self.generate_release_cue()?;
self.update_vector_version(&self.repo_root.join(KUBECLT_CUE_FILE))?;
self.update_vector_version(&self.repo_root.join(INSTALL_SCRIPT))?;
if !self.dry_run {
self.open_release_pr()?;
}
Ok(())
}
fn create_release_branches(&self) -> Result<()> {
debug!("create_release_branches");
if self.dry_run {
let head = git::run_and_check_output(&["rev-parse", "--abbrev-ref", "HEAD"])
.unwrap_or_else(|_| "<unknown>".to_string());
let last = &self.latest_vector_version;
warn!(
"dry-run: using HEAD ({}) as the release base; \
commits in the generated CUE will be `git log v{last}...HEAD`. \
Verify this matches what you'd expect from master.",
head.trim()
);
} else {
git::run_and_check_output(&["fetch"])?;
git::checkout_main_branch()?;
}
git::checkout_or_create_branch(self.release_branch.as_str())?;
if !self.dry_run {
git::push_and_set_upstream(self.release_branch.as_str())?;
}
git::checkout_or_create_branch(self.release_preparation_branch.as_str())?;
if !self.dry_run {
git::push_and_set_upstream(self.release_preparation_branch.as_str())?;
}
Ok(())
}
fn pin_vrl_version(&self) -> Result<()> {
debug!("pin_vrl_version");
let cargo_toml_path = &self.repo_root.join("Cargo.toml");
let contents = fs::read_to_string(cargo_toml_path).context("Failed to read Cargo.toml")?;
let vrl_version = self.vrl_version.to_string();
let updated_contents = update_vrl_to_version(&contents, &vrl_version)?;
fs::write(cargo_toml_path, updated_contents).context("Failed to write Cargo.toml")?;
run_command("cargo update -p vrl");
git::commit(&format!(
"chore(releasing): Pinned VRL version to {vrl_version}"
))?;
Ok(())
}
fn update_dockerfile_base_version(
&self,
dockerfile_path: &Path,
new_version: Option<&str>,
prefix: &str,
) -> Result<()> {
debug!(
"update_dockerfile_base_version for {}",
dockerfile_path.display()
);
if let Some(version) = new_version {
let contents = fs::read_to_string(dockerfile_path)?;
if !contents.starts_with(prefix) {
return Err(anyhow::anyhow!(
"Dockerfile at {} does not start with {prefix}",
dockerfile_path.display()
));
}
let mut lines = contents.lines();
let first_line = lines.next().expect("File should have at least one line");
let rest = lines.collect::<Vec<&str>>().join("\n");
let after_prefix = first_line.strip_prefix(prefix).ok_or_else(|| {
anyhow!("Failed to strip prefix in {}", dockerfile_path.display())
})?;
let parts: Vec<&str> = after_prefix.splitn(2, ' ').collect();
let suffix = parts.get(1).unwrap_or(&"");
let updated_version_line = format!("{prefix}{version} {suffix}");
let new_contents = format!("{updated_version_line}\n{rest}");
fs::write(dockerfile_path, &new_contents)?;
git::commit(&format!(
"chore(releasing): Bump {} version to {version}",
dockerfile_path
.strip_prefix(&self.repo_root)
.unwrap()
.display(),
))?;
} else {
debug!("No version specified for {dockerfile_path:?}; skipping update");
}
Ok(())
}
fn generate_release_cue(&self) -> Result<()> {
debug!("generate_release_cue");
generate_cue::run(&self.new_vector_version)?;
generate_cue::retire_all_fragments()?;
self.append_vrl_changelog_to_release_cue()?;
git::add_files_in_current_dir()?;
git::commit("chore(releasing): Generated release CUE file")?;
debug!("Generated release CUE file");
Ok(())
}
fn update_vector_version(&self, file_path: &Path) -> Result<()> {
debug!("update_vector_version for {file_path:?}");
let contents = fs::read_to_string(file_path)
.map_err(|e| anyhow!("Failed to read {}: {}", file_path.display(), e))?;
let latest_version = &self.latest_vector_version;
let new_version = &self.new_vector_version;
let old_version_str = format!("{}.{}", latest_version.major, latest_version.minor);
let new_version_str = format!("{}.{}", new_version.major, new_version.minor);
if !contents.contains(&old_version_str) {
return Err(anyhow!(
"Could not find version {} to update in {}",
latest_version,
file_path.display()
));
}
let updated_contents =
contents.replace(&latest_version.to_string(), &new_version.to_string());
let updated_contents = updated_contents.replace(&old_version_str, &new_version_str);
fs::write(file_path, updated_contents)
.map_err(|e| anyhow!("Failed to write {}: {}", file_path.display(), e))?;
git::commit(&format!(
"chore(releasing): Updated {} vector version to {new_version}",
file_path.strip_prefix(&self.repo_root).unwrap().display(),
))?;
Ok(())
}
fn open_release_pr(&self) -> Result<()> {
debug!("open_release_pr");
git::push()?;
let new_vector_version = &self.new_vector_version;
let pr_title = format!("chore(releasing): prepare v{new_vector_version} release");
let pr_body = format!("This PR prepares the release for Vector v{new_vector_version}");
let gh_status = Command::new("gh")
.arg("pr")
.arg("create")
.arg("--draft")
.arg("--base")
.arg(self.release_branch.as_str())
.arg("--head")
.arg(self.release_preparation_branch.as_str())
.arg("--title")
.arg(&pr_title)
.arg("--body")
.arg(&pr_body)
.arg("--label")
.arg("no-changelog")
.current_dir(&self.repo_root)
.status()?;
if !gh_status.success() {
return Err(anyhow!("Failed to create PR with gh CLI"));
}
info!("Successfully created PR against {}", self.release_branch);
Ok(())
}
fn append_vrl_changelog_to_release_cue(&self) -> Result<()> {
debug!("append_vrl_changelog_to_release_cue");
let releases_path = self.repo_root.join("website/cue/reference/releases");
let version = &self.new_vector_version;
let cue_path = releases_path.join(format!("{version}.cue"));
if !cue_path.is_file() {
return Err(anyhow!("{} not found", cue_path.display()));
}
let vrl_changelog = get_latest_vrl_tag_and_changelog()?;
let vrl_changelog_block = format_vrl_changelog_block(&vrl_changelog);
let original = fs::read_to_string(&cue_path)?;
let updated = insert_block_after_changelog(&original, &vrl_changelog_block);
let tmp_path = cue_path.with_extension("cue.tmp");
fs::write(&tmp_path, &updated)?;
fs::rename(&tmp_path, &cue_path)?;
run_command(&format!("cue fmt {}", cue_path.display()));
debug!("Successfully added VRL changelog to the release cue file.");
Ok(())
}
}
fn update_vrl_to_version(cargo_toml_contents: &str, vrl_version: &str) -> Result<String> {
let mut doc = cargo_toml_contents
.parse::<DocumentMut>()
.context("Failed to parse Cargo.toml")?;
let vrl_table = doc["workspace"]["dependencies"]["vrl"]
.as_inline_table_mut()
.context("vrl in workspace.dependencies should be an inline table")?;
vrl_table.remove("git");
vrl_table.remove("branch");
vrl_table.insert("version", vrl_version.into());
Ok(doc.to_string())
}
fn format_vrl_changelog_block(changelog: &str) -> String {
let double_tab = "\t\t";
let body = changelog
.lines()
.map(|line| {
let line = line.trim();
if line.starts_with('#') {
format!("{double_tab}#{line}")
} else {
format!("{double_tab}{line}")
}
})
.collect::<Vec<_>>()
.join("\n");
let opening = "\tvrl_changelog: #\"\"\"";
let closing = format!("{double_tab}\"\"\"#");
format!("{opening}\n{body}\n{closing}")
}
fn insert_block_after_changelog(original: &str, block: &str) -> String {
let mut result = Vec::new();
let mut inserted = false;
let mut in_changelog = false;
for line in original.lines() {
result.push(line.to_string());
if line.trim_start().starts_with("changelog:") {
in_changelog = true;
}
if !inserted && in_changelog && line.trim() == "]" {
result.push(String::new()); result.push(block.to_string());
inserted = true;
}
}
result.join("\n")
}
fn get_latest_vrl_tag_and_changelog() -> Result<String> {
let tag_output = Command::new("gh")
.args(["api", "repos/vectordotdev/vrl/tags", "--jq", ".[0].name"])
.output()
.context("Failed to run `gh api` for VRL tags")?;
if !tag_output.status.success() {
let stderr = String::from_utf8_lossy(&tag_output.stderr);
bail!("gh api tags failed: {stderr}");
}
let tag = String::from_utf8(tag_output.stdout).context("gh api output is not valid UTF-8")?;
let tag = tag.trim().to_string();
let changelog_output = Command::new("gh")
.args([
"api",
&format!("repos/vectordotdev/vrl/contents/CHANGELOG.md?ref={tag}"),
"-H",
"Accept: application/vnd.github.raw+json",
])
.output()
.context("Failed to run `gh api` for VRL CHANGELOG.md")?;
if !changelog_output.status.success() {
let stderr = String::from_utf8_lossy(&changelog_output.stderr);
bail!("gh api CHANGELOG.md failed: {stderr}");
}
let changelog =
String::from_utf8(changelog_output.stdout).context("CHANGELOG.md is not valid UTF-8")?;
let mut section = Vec::new();
let mut found_first = false;
for line in changelog.lines() {
if line.starts_with("## ") {
if found_first {
break;
}
found_first = true;
}
if found_first {
section.push(line);
}
}
if !found_first {
bail!("No ## headers found in VRL CHANGELOG.md");
}
Ok(section.join("\n"))
}
#[cfg(test)]
mod tests {
use crate::commands::release::prepare::{
format_vrl_changelog_block, insert_block_after_changelog, update_vrl_to_version,
};
use indoc::indoc;
#[test]
fn test_update_vrl_to_version() {
let input = indoc! {r#"
[workspace.dependencies]
some-other-dep = "1.0.0"
vrl = { git = "https://github.com/vectordotdev/vrl.git", branch = "main", features = ["arbitrary", "cli", "test", "test_framework"] }
another-dep = "2.0.0"
"#};
let result = update_vrl_to_version(input, "0.28.0").expect("should succeed");
let expected = indoc! {r#"
[workspace.dependencies]
some-other-dep = "1.0.0"
vrl = { features = ["arbitrary", "cli", "test", "test_framework"] , version = "0.28.0" }
another-dep = "2.0.0"
"#};
assert_eq!(result, expected);
}
#[test]
fn test_insert_block_after_changelog() {
let vrl_changelog = "### [0.2.0]\n- Feature\n- Fix";
let vrl_changelog_block = format_vrl_changelog_block(vrl_changelog);
let expected = concat!(
"\tvrl_changelog: #\"\"\"\n",
"\t\t#### [0.2.0]\n",
"\t\t- Feature\n",
"\t\t- Fix\n",
"\t\t\"\"\"#"
);
assert_eq!(vrl_changelog_block, expected);
let original = indoc! {r#"
version: "1.2.3"
changelog: [
{
type: "fix"
description: "Some fix"
},
]
"#};
let updated = insert_block_after_changelog(original, &vrl_changelog_block);
let expected_lines_len = 5;
let updated_tail: Vec<&str> = updated
.lines()
.rev()
.take(expected_lines_len)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let expected_lines: Vec<&str> = vrl_changelog_block.lines().collect();
assert_eq!(updated_tail, expected_lines);
}
}