Skip to content

Commit 7e0677c

Browse files
feat(vdev): add release check to verify APT publish across arches
1 parent f56f4fa commit 7e0677c

4 files changed

Lines changed: 214 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vdev/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ clap.workspace = true
2323
clap-verbosity-flag = "3.0.4"
2424
clap_complete.workspace = true
2525
directories = "6.0.0"
26+
flate2.workspace = true
2627
glob.workspace = true
2728
hex = "0.4.3"
2829
indexmap.workspace = true

vdev/src/commands/release/check.rs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
use std::{io::Read, time::Duration};
2+
3+
use anyhow::{Context as _, Result, bail};
4+
use flate2::read::GzDecoder;
5+
6+
use crate::utils::git;
7+
8+
const DEFAULT_BASE_URL: &str = "https://apt.vector.dev";
9+
const DEFAULT_COMPONENT: &str = "vector-0";
10+
const DEFAULT_SUITE: &str = "stable";
11+
12+
// Architectures the Datadog APT repo actually serves a Vector deb for. `binary-all` and
13+
// `binary-i386` exist in the repo layout but have always been empty for Vector, so we skip them.
14+
// `x86_64` is a legacy Datadog alias that points at the same amd64 deb.
15+
const ARCHES: &[&str] = &["amd64", "arm64", "armhf", "x86_64"];
16+
17+
/// Check that a Vector release is fully published to the Datadog APT repo.
18+
///
19+
/// For each expected architecture, fetches `Packages.gz`, confirms the version is
20+
/// indexed, and verifies the referenced `.deb` is reachable.
21+
#[derive(clap::Args, Debug)]
22+
#[command()]
23+
pub struct Cli {
24+
/// Version to check (e.g. `0.55.0`). Defaults to the most recent `v*` git tag,
25+
/// since `Cargo.toml`'s version is bumped to the next development version
26+
/// immediately after a release.
27+
version: Option<String>,
28+
29+
/// Base URL of the APT repo.
30+
#[arg(long, default_value = DEFAULT_BASE_URL)]
31+
url: String,
32+
33+
/// APT suite name.
34+
#[arg(long, default_value = DEFAULT_SUITE)]
35+
suite: String,
36+
37+
/// APT component name.
38+
#[arg(long, default_value = DEFAULT_COMPONENT)]
39+
component: String,
40+
}
41+
42+
impl Cli {
43+
pub fn exec(self) -> Result<()> {
44+
let version = if let Some(v) = self.version {
45+
v
46+
} else {
47+
latest_release_tag()?
48+
};
49+
let debian_version = format!("{version}-1");
50+
51+
let client = reqwest::blocking::Client::builder()
52+
.timeout(Duration::from_secs(30))
53+
.build()?;
54+
55+
info!(
56+
"Checking Vector {version} in {}/dists/{}/{}",
57+
self.url, self.suite, self.component
58+
);
59+
60+
let mut failures = 0usize;
61+
for arch in ARCHES {
62+
match check_arch(
63+
&client,
64+
&self.url,
65+
&self.suite,
66+
&self.component,
67+
arch,
68+
&debian_version,
69+
) {
70+
Ok(ArchResult { filename, deb_size }) => {
71+
println!(" {arch:<8} OK {filename} ({deb_size} bytes)");
72+
}
73+
Err(e) => {
74+
failures += 1;
75+
println!(" {arch:<8} FAIL {e:#}");
76+
}
77+
}
78+
}
79+
80+
if failures > 0 {
81+
bail!("{failures}/{} architectures missing {version}", ARCHES.len());
82+
}
83+
Ok(())
84+
}
85+
}
86+
87+
struct ArchResult {
88+
filename: String,
89+
deb_size: u64,
90+
}
91+
92+
// Return the most recent Vector release tag (e.g. `0.55.0`) with the leading `v` stripped.
93+
//
94+
// We use `for-each-ref` sorted by creation date rather than `git describe`, because Vector
95+
// tags releases on release branches (not master), so HEAD-reachability is the wrong signal.
96+
// The glob `v[0-9]*` matches tags like `v0.55.0` while excluding `vdev-v*` (second char is
97+
// `d`, not a digit).
98+
fn latest_release_tag() -> Result<String> {
99+
let tag = git::run_and_check_output(&[
100+
"for-each-ref",
101+
"--sort=-creatordate",
102+
"--count=1",
103+
"--format=%(refname:short)",
104+
"refs/tags/v[0-9]*",
105+
])
106+
.context("finding latest Vector release tag via `git for-each-ref`")?;
107+
let tag = tag.trim();
108+
if tag.is_empty() {
109+
bail!("no matching release tags found (pattern: `v[0-9]*`)");
110+
}
111+
let version = tag
112+
.strip_prefix('v')
113+
.ok_or_else(|| anyhow::anyhow!("expected tag {tag:?} to start with 'v'"))?;
114+
Ok(version.to_string())
115+
}
116+
117+
fn check_arch(
118+
client: &reqwest::blocking::Client,
119+
base_url: &str,
120+
suite: &str,
121+
component: &str,
122+
arch: &str,
123+
debian_version: &str,
124+
) -> Result<ArchResult> {
125+
let index_url = format!("{base_url}/dists/{suite}/{component}/binary-{arch}/Packages.gz");
126+
let body = client
127+
.get(&index_url)
128+
.send()
129+
.with_context(|| format!("fetching {index_url}"))?
130+
.error_for_status()
131+
.with_context(|| format!("fetching {index_url}"))?
132+
.bytes()
133+
.with_context(|| format!("reading {index_url}"))?;
134+
135+
let mut decoded = String::new();
136+
GzDecoder::new(body.as_ref())
137+
.read_to_string(&mut decoded)
138+
.with_context(|| format!("gunzipping {index_url}"))?;
139+
140+
let filename = find_filename(&decoded, debian_version)
141+
.with_context(|| format!("version {debian_version} not found in {index_url}"))?;
142+
143+
let deb_url = format!("{base_url}/{filename}");
144+
let head = client
145+
.head(&deb_url)
146+
.send()
147+
.with_context(|| format!("HEAD {deb_url}"))?
148+
.error_for_status()
149+
.with_context(|| format!("HEAD {deb_url}"))?;
150+
let deb_size = head
151+
.headers()
152+
.get(reqwest::header::CONTENT_LENGTH)
153+
.and_then(|v| v.to_str().ok())
154+
.and_then(|v| v.parse::<u64>().ok())
155+
.unwrap_or(0);
156+
157+
Ok(ArchResult { filename, deb_size })
158+
}
159+
160+
// Walk the Packages stanzas (separated by blank lines) and return the `Filename:` of
161+
// the one whose `Version:` matches.
162+
fn find_filename(packages: &str, debian_version: &str) -> Option<String> {
163+
for stanza in packages.split("\n\n") {
164+
let mut version = None;
165+
let mut filename = None;
166+
for line in stanza.lines() {
167+
if let Some(rest) = line.strip_prefix("Version:") {
168+
version = Some(rest.trim().to_string());
169+
} else if let Some(rest) = line.strip_prefix("Filename:") {
170+
filename = Some(rest.trim().to_string());
171+
}
172+
}
173+
if version.as_deref() == Some(debian_version) {
174+
return filename;
175+
}
176+
}
177+
None
178+
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::find_filename;
183+
184+
#[test]
185+
fn finds_matching_stanza() {
186+
let packages = "\
187+
Package: vector
188+
Version: 0.54.0-1
189+
Filename: pool/v/ve/vector_0.54.0-1_amd64.deb
190+
191+
Package: vector
192+
Version: 0.55.0-1
193+
Filename: pool/v/ve/vector_0.55.0-1_amd64.deb
194+
";
195+
assert_eq!(
196+
find_filename(packages, "0.55.0-1").as_deref(),
197+
Some("pool/v/ve/vector_0.55.0-1_amd64.deb"),
198+
);
199+
assert_eq!(
200+
find_filename(packages, "0.54.0-1").as_deref(),
201+
Some("pool/v/ve/vector_0.54.0-1_amd64.deb"),
202+
);
203+
assert_eq!(find_filename(packages, "0.56.0-1"), None);
204+
}
205+
206+
#[test]
207+
fn empty_index_has_no_match() {
208+
assert_eq!(find_filename("", "0.55.0-1"), None);
209+
}
210+
}

vdev/src/commands/release/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod channel;
2+
mod check;
23
mod github;
34
mod homebrew;
45
mod prepare;
@@ -8,6 +9,7 @@ crate::cli_subcommands! {
89
"Manage the release process..."
910
generate_cue,
1011
channel,
12+
check,
1113
commit,
1214
docker,
1315
github,

0 commit comments

Comments
 (0)