use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use tracing::{debug, info, warn};
use super::buildkit::ensure_buildx_builder;
use super::proxy;
use super::registry::docker_push;
use super::ssl::embed_ssl_cert_in_plan;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum BuildctlFrontend {
Dockerfile,
Railpack,
}
pub(crate) struct RailpackBuildOptions<'a> {
pub app_path: &'a str,
pub image_tag: &'a str,
pub container_cli: &'a str,
pub buildx_supports_push: bool,
pub use_buildctl: bool,
pub push: bool,
pub buildkit_host: Option<&'a str>,
pub env: &'a [String],
pub no_cache: bool,
}
struct CleanupGuard {
path: std::path::PathBuf,
is_directory: bool,
}
impl Drop for CleanupGuard {
fn drop(&mut self) {
if self.path.exists() {
if self.is_directory {
let _ = std::fs::remove_dir_all(&self.path);
debug!("Cleaned up temp directory: {}", self.path.display());
} else {
let _ = std::fs::remove_file(&self.path);
debug!("Cleaned up temp file: {}", self.path.display());
}
}
}
}
pub(crate) fn build_image_with_railpacks(options: RailpackBuildOptions) -> Result<()> {
let railpack_check = Command::new("railpack").arg("--version").output();
if railpack_check.is_err() {
bail!(
"railpack CLI not found. Ensure the railpack CLI is installed and available in PATH.\n\
In production, this should be available in the rise-builder image."
);
}
let build_dir = Path::new(options.app_path).join(".railpack-build");
let dir_existed = build_dir.exists();
if !dir_existed {
fs::create_dir(&build_dir).with_context(|| {
format!("Failed to create build directory: {}", build_dir.display())
})?;
}
let plan_file = build_dir.join("railpack-plan.json");
let info_file = build_dir.join("info.json");
let _cleanup_guard = if !dir_existed {
CleanupGuard {
path: build_dir,
is_directory: true,
}
} else {
CleanupGuard {
path: plan_file.clone(),
is_directory: false,
}
};
let _info_guard = if dir_existed {
Some(CleanupGuard {
path: info_file.clone(),
is_directory: false,
})
} else {
None
};
let mut all_secrets = proxy::read_and_transform_proxy_vars();
let user_env_vars = proxy::parse_env_vars(options.env)?;
all_secrets.extend(user_env_vars);
if let Some(ssl_cert_file) = super::env_var_non_empty("SSL_CERT_FILE") {
if Path::new(&ssl_cert_file).exists() {
let ssl_cert_target = super::ssl::SSL_CERT_PATHS[0];
for var in super::ssl::SSL_ENV_VARS {
all_secrets
.entry(var.to_string())
.or_insert_with(|| ssl_cert_target.to_string());
}
}
}
info!("Running railpack prepare for: {}", options.app_path);
let mut cmd = Command::new("railpack");
cmd.arg("prepare")
.arg(options.app_path)
.arg("--plan-out")
.arg(&plan_file)
.arg("--info-out")
.arg(&info_file);
for key in all_secrets.keys() {
cmd.arg("--env")
.arg(format!("{}={}", key, all_secrets[key]));
}
if tracing::enabled!(tracing::Level::DEBUG) {
let redacted_env: Vec<String> = all_secrets
.keys()
.map(|k| format!("--env {}=<redacted>", k))
.collect();
debug!(
"Executing: railpack prepare {} --plan-out {} --info-out {} {}",
options.app_path,
plan_file.display(),
info_file.display(),
redacted_env.join(" ")
);
}
let status = cmd.status().context("Failed to execute railpack prepare")?;
if !status.success() {
bail!("railpack prepare failed with status: {}", status);
}
if !plan_file.exists() {
bail!(
"railpack prepare did not create plan file at {}",
plan_file.display()
);
}
info!("✓ Railpack prepare completed");
if let Some(ssl_cert_file) = super::env_var_non_empty("SSL_CERT_FILE") {
let cert_path = Path::new(&ssl_cert_file);
if cert_path.exists() {
embed_ssl_cert_in_plan(&plan_file, cert_path)?;
} else {
warn!(
"SSL_CERT_FILE set to '{}' but file not found",
ssl_cert_file
);
}
}
if let Ok(plan_contents) = fs::read_to_string(&plan_file) {
debug!("Railpack plan.json contents:\n{}", plan_contents);
}
if options.use_buildctl {
build_with_buildctl(
options.app_path,
&plan_file,
options.image_tag,
options.push,
options.buildkit_host,
&all_secrets,
&HashMap::new(), BuildctlFrontend::Railpack,
options.no_cache,
options.container_cli,
)?;
} else {
build_with_buildx(
options.app_path,
&plan_file,
options.image_tag,
options.container_cli,
options.buildx_supports_push,
options.push,
options.buildkit_host,
&all_secrets,
options.no_cache,
)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn build_with_buildx(
app_path: &str,
plan_file: &Path,
image_tag: &str,
container_cli: &str,
buildx_supports_push: bool,
push: bool,
buildkit_host: Option<&str>,
secrets: &HashMap<String, String>,
no_cache: bool,
) -> Result<()> {
if !super::docker::is_buildx_available(container_cli) {
bail!(
"{} buildx not available. Install buildx or use railpack:buildctl backend instead.",
container_cli
);
}
info!(
"Building image with {} buildx: {}",
container_cli, image_tag
);
let builder_name = if let Some(host) = buildkit_host {
Some(ensure_buildx_builder(container_cli, host)?)
} else {
None
};
let mut cmd = Command::new(container_cli);
cmd.arg("buildx")
.arg("build")
.arg("--build-arg")
.arg("BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend")
.arg("-f")
.arg(plan_file)
.arg("-t")
.arg(image_tag)
.arg("--platform")
.arg("linux/amd64");
if let Some(ref builder) = builder_name {
cmd.arg("--builder").arg(builder);
}
if no_cache {
cmd.arg("--no-cache");
}
let needs_fallback_push =
super::docker::configure_buildx_output(&mut cmd, push, buildx_supports_push);
let buildkit_host_env = std::env::var("BUILDKIT_HOST").ok();
let container_name = buildkit_host_env
.as_deref()
.and_then(|h| h.strip_prefix("docker-container://"))
.or(builder_name.as_deref());
let effective_secrets = super::buildkit::resolve_and_apply_host_gateway(
&mut cmd,
container_cli,
secrets,
container_name,
buildkit_host_env.is_some(),
);
proxy::add_secrets_to_command(&mut cmd, &effective_secrets);
cmd.arg(app_path);
debug!("Executing command: {:?}", cmd);
let status = cmd
.status()
.with_context(|| format!("Failed to execute {} buildx build", container_cli))?;
if !status.success() {
bail!(
"{} buildx build failed with status: {}",
container_cli,
status
);
}
if needs_fallback_push {
docker_push(container_cli, image_tag)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_with_buildctl(
app_path: &str,
dockerfile_or_plan: &Path,
image_tag: &str,
push: bool,
buildkit_host: Option<&str>,
secrets: &HashMap<String, String>,
local_contexts: &HashMap<String, String>,
frontend: BuildctlFrontend,
no_cache: bool,
container_cli: &str,
) -> Result<()> {
let buildctl_check = Command::new("buildctl").arg("--version").output();
if buildctl_check.is_err() {
bail!("buildctl not found. Install buildctl or use docker:buildx backend instead.");
}
info!("Building image with buildctl: {}", image_tag);
let mut cmd = Command::new("buildctl");
cmd.arg("build")
.arg("--local")
.arg(format!("context={}", app_path))
.arg("--local")
.arg(format!(
"dockerfile={}",
dockerfile_or_plan
.parent()
.unwrap_or(Path::new(app_path))
.display()
));
match frontend {
BuildctlFrontend::Dockerfile => {
cmd.arg("--frontend=dockerfile.v0");
if let Some(filename) = dockerfile_or_plan.file_name() {
let filename_str = filename.to_string_lossy();
if filename_str != "Dockerfile" {
cmd.arg("--opt").arg(format!("filename={}", filename_str));
}
}
}
BuildctlFrontend::Railpack => {
cmd.arg("--frontend=gateway.v0")
.arg("--opt")
.arg("source=ghcr.io/railwayapp/railpack-frontend");
}
}
if let Some(host) = buildkit_host {
cmd.env("BUILDKIT_HOST", host);
}
for (name, path) in local_contexts {
cmd.arg("--local").arg(format!("{}={}", name, path));
cmd.arg("--opt")
.arg(format!("context:{}=local:{}", name, name));
}
proxy::add_secrets_to_command(&mut cmd, secrets);
if no_cache {
cmd.arg("--opt").arg("no-cache=");
}
if push {
cmd.arg("--output").arg(format!(
"type=image,name={},push=true,platform=linux/amd64",
image_tag
));
debug!("Executing command: {:?}", cmd);
let status = cmd.status().context("Failed to execute buildctl build")?;
if !status.success() {
bail!("buildctl build failed with status: {}", status);
}
} else {
cmd.arg("--output").arg(format!(
"type=docker,name={},platform=linux/amd64",
image_tag
));
cmd.stdout(std::process::Stdio::piped());
debug!("Executing command: {:?} | {} load", cmd, container_cli);
let mut buildctl_child = cmd.spawn().context("Failed to execute buildctl build")?;
let buildctl_stdout = buildctl_child
.stdout
.take()
.context("Failed to capture buildctl stdout")?;
let docker_load = Command::new(container_cli)
.arg("load")
.stdin(buildctl_stdout)
.status()
.with_context(|| format!("Failed to execute {} load", container_cli))?;
let buildctl_status = buildctl_child
.wait()
.context("Failed to wait for buildctl")?;
if !buildctl_status.success() {
bail!("buildctl build failed with status: {}", buildctl_status);
}
if !docker_load.success() {
bail!("{} load failed with status: {}", container_cli, docker_load);
}
}
Ok(())
}