Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apt - Minimum Viable Buildpack - 02 - Aptfile Parsing #2

Merged
merged 7 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
697 changes: 672 additions & 25 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ edition = "2021"
rust-version = "1.76"

[dependencies]
commons = { git = "https://github.com/heroku/buildpacks-ruby", branch = "main" }
libcnb = "=0.18.0"
regex-lite = "0.1"

[dev-dependencies]
libcnb-test = "=0.18.0"
indoc = "2"

[lints.rust]
unreachable_pub = "warn"
Expand Down
135 changes: 135 additions & 0 deletions src/aptfile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use std::collections::HashSet;
use std::str::FromStr;
use std::sync::OnceLock;

#[derive(Debug, Eq, PartialEq)]
pub(crate) struct Aptfile {
packages: HashSet<DebianPackageName>,
}

#[derive(Debug, PartialEq)]
pub(crate) struct ParseAptfileError(ParseDebianPackageNameError);

impl FromStr for Aptfile {
type Err = ParseAptfileError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
value
.lines()
.map(str::trim)
.filter(|line| !line.starts_with('#') && !line.is_empty())
.map(DebianPackageName::from_str)
.collect::<Result<HashSet<_>, _>>()
edmorley marked this conversation as resolved.
Show resolved Hide resolved
.map_err(ParseAptfileError)
.map(|packages| Aptfile { packages })
}
}

#[derive(Debug, Eq, PartialEq, Hash)]
pub(crate) struct DebianPackageName(String);

#[derive(Debug, PartialEq)]
pub(crate) struct ParseDebianPackageNameError(String);

impl FromStr for DebianPackageName {
type Err = ParseDebianPackageNameError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
colincasey marked this conversation as resolved.
Show resolved Hide resolved
if debian_package_name_regex().is_match(value) {
Ok(DebianPackageName(value.to_string()))
} else {
Err(ParseDebianPackageNameError(value.to_string()))
}
}
}

fn debian_package_name_regex() -> &'static regex_lite::Regex {
static LAZY: OnceLock<regex_lite::Regex> = OnceLock::new();
LAZY.get_or_init(|| {
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#source
// Package names (both source and binary, see Package) must consist only of
// lower case letters (a-z), digits (0-9), plus (+) and minus (-) signs,
// and periods (.). They must be at least two characters long and must
// start with an alphanumeric character.
regex_lite::Regex::new("^[a-z0-9][a-z0-9+.\\-]+$").expect("should be a valid regex pattern")
colincasey marked this conversation as resolved.
Show resolved Hide resolved
})
}

#[cfg(test)]
mod tests {
edmorley marked this conversation as resolved.
Show resolved Hide resolved
use super::*;
use indoc::indoc;

#[test]
fn parse_valid_debian_package_name() {
let valid_names = [
"a0", // min length, starting with number
"0a", // min length, starting with letter
"g++", // alphanumeric to start followed by non-alphanumeric characters
"libevent-2.1-6", // just a mix of allowed characters
"a0+.-", // all the allowed characters
];
for valid_name in valid_names {
assert_eq!(
DebianPackageName::from_str(valid_name).unwrap(),
DebianPackageName(valid_name.to_string())
);
}
}
#[test]
fn parse_invalid_debian_package_name() {
colincasey marked this conversation as resolved.
Show resolved Hide resolved
let invalid_names = [
"a", // too short
"+a", // can't start with non-alphanumeric character
"ab_c", // can't contain invalid characters
"aBc", // uppercase is not allowed
"package=1.2.3-1", // versioning is not allowed, package name only
];
for invalid_name in invalid_names {
colincasey marked this conversation as resolved.
Show resolved Hide resolved
assert_eq!(
DebianPackageName::from_str(invalid_name).unwrap_err(),
ParseDebianPackageNameError(invalid_name.to_string())
);
}
}

#[test]
fn parse_aptfile() {
let aptfile = Aptfile::from_str(indoc! { "
# comment line
# comment line with leading whitespace

package-name-1
package-name-2

# Package name has leading and trailing whitespace
package-name-3 \t
# Duplicates are allowed (at least for now)
package-name-1

" })
.unwrap();
assert_eq!(
aptfile.packages,
HashSet::from([
DebianPackageName("package-name-1".to_string()),
DebianPackageName("package-name-2".to_string()),
DebianPackageName("package-name-3".to_string()),
])
);
}

#[test]
fn parse_invalid_aptfile() {
let error = Aptfile::from_str(indoc! { "
invalid package name!
" })
.unwrap_err();
assert_eq!(
error,
ParseAptfileError(ParseDebianPackageNameError(
"invalid package name!".to_string()
))
);
}
}
15 changes: 14 additions & 1 deletion src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
use crate::aptfile::ParseAptfileError;

#[derive(Debug)]
pub(crate) enum AptBuildpackError {}
#[allow(clippy::enum_variant_names)]
pub(crate) enum AptBuildpackError {
DetectAptfile(std::io::Error),
ReadAptfile(std::io::Error),
ParseAptfile(ParseAptfileError),
}

impl From<AptBuildpackError> for libcnb::Error<AptBuildpackError> {
fn from(value: AptBuildpackError) -> Self {
Self::BuildpackError(value)
}
}
38 changes: 32 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,54 @@
use crate::aptfile::Aptfile;
use crate::errors::AptBuildpackError;
use libcnb::build::{BuildContext, BuildResult};
use libcnb::detect::{DetectContext, DetectResult};
use commons::output::build_log::{BuildLog, Logger};
use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder};
use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder};
use libcnb::generic::{GenericMetadata, GenericPlatform};
use libcnb::{buildpack_main, Buildpack};
use std::fs;
use std::io::stdout;

#[cfg(test)]
use libcnb_test as _;

mod aptfile;
mod errors;

buildpack_main!(AptBuildpack);

const APTFILE_PATH: &str = "Aptfile";

struct AptBuildpack;

impl Buildpack for AptBuildpack {
type Platform = GenericPlatform;
type Metadata = GenericMetadata;
type Error = AptBuildpackError;

fn detect(&self, _context: DetectContext<Self>) -> libcnb::Result<DetectResult, Self::Error> {
todo!()
fn detect(&self, context: DetectContext<Self>) -> libcnb::Result<DetectResult, Self::Error> {
let aptfile_exists = context
.app_dir
.join(APTFILE_PATH)
.try_exists()
.map_err(AptBuildpackError::DetectAptfile)?;

if aptfile_exists {
DetectResultBuilder::pass().build()
} else {
BuildLog::new(stdout())
.without_buildpack_name()
.announce()
.warning("No Aptfile found.");
DetectResultBuilder::fail().build()
colincasey marked this conversation as resolved.
Show resolved Hide resolved
}
}

fn build(&self, _context: BuildContext<Self>) -> libcnb::Result<BuildResult, Self::Error> {
todo!()
fn build(&self, context: BuildContext<Self>) -> libcnb::Result<BuildResult, Self::Error> {
let _aptfile: Aptfile = fs::read_to_string(context.app_dir.join(APTFILE_PATH))
.map_err(AptBuildpackError::ReadAptfile)?
.parse()
.map_err(AptBuildpackError::ParseAptfile)?;

BuildResultBuilder::new().build()
}
}
Empty file added tests/fixtures/basic/Aptfile
Empty file.
Empty file.
73 changes: 73 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,76 @@
// Required due to: https://github.com/rust-lang/rust/issues/95513
#![allow(unused_crate_dependencies)]

colincasey marked this conversation as resolved.
Show resolved Hide resolved
use libcnb::data::buildpack_id;
use libcnb_test::{
assert_contains, BuildConfig, BuildpackReference, PackResult, TestContext, TestRunner,
};
use std::path::PathBuf;

#[test]
#[ignore = "integration test"]
fn test_successful_detection() {
apt_integration_test_with_config(
"./fixtures/basic",
|config| {
config.expected_pack_result(PackResult::Success);
},
|_| {},
);
}

#[test]
#[ignore = "integration test"]
fn test_failed_detection() {
apt_integration_test_with_config(
"./fixtures/no_aptfile",
|config| {
config.expected_pack_result(PackResult::Failure);
},
|ctx| {
assert_contains!(ctx.pack_stdout, "No Aptfile found.");
},
);
}

const DEFAULT_BUILDER: &str = "heroku/builder:22";

fn get_integration_test_builder() -> String {
std::env::var("INTEGRATION_TEST_CNB_BUILDER").unwrap_or(DEFAULT_BUILDER.to_string())
}

fn apt_integration_test_with_config(
fixture: &str,
with_config: fn(&mut BuildConfig),
test_body: fn(TestContext),
) {
integration_test_with_config(
fixture,
with_config,
test_body,
&[BuildpackReference::WorkspaceBuildpack(buildpack_id!(
"heroku/apt"
))],
);
}

fn integration_test_with_config(
fixture: &str,
with_config: fn(&mut BuildConfig),
test_body: fn(TestContext),
buildpacks: &[BuildpackReference],
) {
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.expect("The CARGO_MANIFEST_DIR should be automatically set by Cargo when running tests but it was not");

let builder = get_integration_test_builder();
let app_dir = cargo_manifest_dir.join("tests").join(fixture);

let mut build_config = BuildConfig::new(builder, app_dir);
build_config.buildpacks(buildpacks);
with_config(&mut build_config);

TestRunner::default().build(build_config, test_body);
}
colincasey marked this conversation as resolved.
Show resolved Hide resolved