Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions core/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions core/crates/ployz-build/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ sha2 = "0.11.0"
tokio-util = "0.7.19"
thiserror = "2.0.20"

[dev-dependencies]
tempfile = "3.27.0"

[lints]
workspace = true
103 changes: 88 additions & 15 deletions core/crates/ployz-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ pub use execution::{
};
mod railpack;
pub use railpack::Railpack;
mod scrub;
pub use scrub::OutputScrub;
mod sealed;
pub use sealed::{sealed_file, valid_name};
pub use sealed::{Staged, sealed_file, valid_name};
mod solve;
pub use solve::PlainRenderer;

Expand Down Expand Up @@ -172,6 +174,16 @@ enum Produce {
Cache,
}

/// Whether a Build solves with opened values that its cache or provenance would
/// record: a staged Sealed ARG override, or a Railpack setting planned with its value.
#[must_use]
pub fn reads_plaintext(sealed_override: Option<&Path>, railpack: &[Railpack]) -> bool {
sealed_override.is_some()
|| railpack
.iter()
.any(|recipe| recipe.sealed.keys().any(|name| railpack::planned(name)))
}

/// Whether the captured environment exposes a GitHub Actions runner's cache
/// service, which only `ployz build` passes through.
fn github_cache(request: &Request<'_>) -> bool {
Expand Down Expand Up @@ -329,44 +341,105 @@ impl BuildError {
pub fn execute(
request: &Request<'_>,
cancellation: &Cancellation,
scrub: Option<&OutputScrub>,
observe: &(dyn Fn(&Progress) + Sync),
) -> Result<Vec<BuiltImage>, BuildError> {
execute_rendered(
request,
Produce::Output(request.output),
cancellation,
observe,
Sinks::stderr(scrub, observe),
)
}

/// Upload a captured Build's cache to the GitHub Actions cache service by
/// running it again, producing no image; `request.output` is not consulted.
/// Without that service in the captured environment it does nothing. Progress
/// renders to stderr only.
/// Without that service in the captured environment, or when the Build
/// [reads plaintext](reads_plaintext), it does nothing: other workflows read that
/// cache. Progress renders to stderr only.
///
/// # Errors
/// Returns the same failures as [`execute`].
pub fn export_cache(request: &Request<'_>, cancellation: &Cancellation) -> Result<(), BuildError> {
if !github_cache(request) {
pub fn export_cache(
request: &Request<'_>,
cancellation: &Cancellation,
scrub: Option<&OutputScrub>,
) -> Result<(), BuildError> {
if !github_cache(request) || reads_plaintext(request.sealed_override, request.railpack) {
return Ok(());
}
execute_rendered(request, Produce::Cache, cancellation, &|_| {}).map(drop)
execute_rendered(
request,
Produce::Cache,
cancellation,
Sinks::stderr(scrub, &|_| {}),
)
.map(drop)
}

fn execute_rendered(
request: &Request<'_>,
produce: Produce,
cancellation: &Cancellation,
observe: &(dyn Fn(&Progress) + Sync),
sinks: Sinks<'_>,
) -> Result<Vec<BuiltImage>, BuildError> {
let renderer = PlainRenderer::default();
execute_produce(request, produce, Admission::wait(cancellation)?, &|event| {
if let Some(text) = renderer.render(&event) {
use std::io::Write as _;
let _ = std::io::stderr().write_all(text.as_bytes());
let built = Admission::wait(cancellation).and_then(|admission| {
execute_produce(request, produce, admission, &|event| sinks.emit(event))
});
sinks.finish();
built.map_err(|error| sinks.error(error))
}

/// Where a local Build's progress goes, after the scrub.
struct Sinks<'a> {
renderer: PlainRenderer,
write: &'a (dyn Fn(&[u8]) + Sync),
observe: &'a (dyn Fn(&Progress) + Sync),
scrub: Option<&'a OutputScrub>,
}

impl<'a> Sinks<'a> {
fn stderr(scrub: Option<&'a OutputScrub>, observe: &'a (dyn Fn(&Progress) + Sync)) -> Self {
Self {
renderer: PlainRenderer::default(),
write: &|text| {
use std::io::Write as _;
let _ = std::io::stderr().write_all(text);
},
observe,
scrub,
}
}

fn emit(&self, event: Progress) {
match self.scrub {
Some(scrub) => scrub
.progress(event)
.iter()
.for_each(|event| self.show(event)),
None => self.show(&event),
}
}

fn show(&self, event: &Progress) {
if let Some(text) = self.renderer.render(event) {
(self.write)(text.as_bytes());
}
observe(&event);
})
(self.observe)(event);
}

fn finish(&self) {
if let Some(scrub) = self.scrub {
scrub.finish().iter().for_each(|event| self.show(event));
}
}

fn error(&self, error: BuildError) -> BuildError {
match self.scrub {
Some(scrub) => scrub.error(error),
None => error,
}
}
}

/// Execute using admission acquired before source upload. The same deadline
Expand Down
51 changes: 49 additions & 2 deletions core/crates/ployz-build/src/railpack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ struct FrontendInputs {
hash: String,
}

/// A Railpack setting steers the plan, so it is planned with its value; any
/// other Sealed value reaches only the steps that mount it.
pub(crate) fn planned(name: &str) -> bool {
name.starts_with("RAILPACK_")
}

/// Text values ride in the script's environment and as secret files written
/// here. Sealed names ride only as mounts of the files the host opened, with
/// an empty placeholder so the plan still lists them.
Expand Down Expand Up @@ -194,9 +200,19 @@ fn frontend_inputs(
"Railpack sealed variables require distinct valid names".into(),
));
}
command.push(' ');
command.push_str(&quote(&format!("{name}=")));
let file = working_dir.join(crate::sealed_file(name));
let planned = if planned(name) {
fs::read_to_string(&file)
.ok()
.filter(|value| !value.contains('\0'))
.ok_or_else(|| {
BuildError::Request(format!("sealed Railpack setting {name} was not opened"))
})?
} else {
String::new()
};
command.push(' ');
command.push_str(&quote(&format!("{name}={planned}")));
secrets.push(format!("id={name},src={}", file.display()));
}
command.push_str(" /railpack prepare /app --plan-out /plan.json");
Expand Down Expand Up @@ -428,6 +444,37 @@ mod tests {
fs::remove_dir_all(&directory).unwrap();
}

#[test]
fn a_sealed_railpack_setting_is_planned_with_its_opened_value() {
let capture =
std::env::temp_dir().join(format!("ployz-railpack-capture-{}", uuid::Uuid::new_v4()));
let staged = capture.join(crate::sealed_file("RAILPACK_BUILD_CMD"));
fs::create_dir_all(staged.parent().unwrap()).unwrap();
fs::write(&staged, "npm run build -- --token marker").unwrap();
let directory = capture.join("private/frontend");
fs::create_dir_all(&directory).unwrap();
let recipe = recipe(
&[],
&[("RAILPACK_BUILD_CMD", "fp-1"), ("NPM_TOKEN", "fp-2")],
);
let planned = frontend_inputs(&recipe, &directory, &capture).unwrap();
let prefix = planned.script.split(" /railpack ").next().unwrap();
assert!(
prefix.contains("'RAILPACK_BUILD_CMD=npm run build -- --token marker'"),
"{prefix}"
);
assert!(prefix.contains("'NPM_TOKEN='"), "{prefix}");
fs::write(&staged, "npm run build").unwrap();
assert_eq!(
planned.hash,
frontend_inputs(&recipe, &directory, &capture).unwrap().hash,
"the cache key follows the ciphertext, not the opened setting"
);
fs::remove_file(&staged).unwrap();
assert!(frontend_inputs(&recipe, &directory, &capture).is_err());
fs::remove_dir_all(&capture).unwrap();
}

#[test]
fn preparation_sits_behind_the_build_bridge_firewall() {
assert!(super::NETWORK.starts_with(crate::BUILD_BRIDGE_PREFIX));
Expand Down
Loading
Loading