빌드와 배포 문서의 검증 코드다.
//! 50 빌드와 배포: env!·option_env!로 빌드할 때의 값을 실행 파일에 넣기, cfg!와 #[cfg]의 차이(E0425),
//! build.rs의 cargo::rustc-env, 환경 변수가 바뀌면 다시 빌드되는 것, --locked, release의 기본 strip,
//! musl 타깃의 기본 crt-static을 rustc·cargo로 확인한다. Dockerfile은 실행하지 않고 글자로 점검한다.
//! 의존성: 없음 (표준 라이브러리만)
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/3.개념집/50_build-deploy_verify.rs"
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
// ---------- 1. 이 실행 파일에 박힌 빌드 정보 ----------
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 빌드할 때 VAULT_DEMO_GIT_HASH가 없었으면 "unknown". 빌드는 어느 쪽이든 된다.
const GIT_HASH: &str = match option_env!("VAULT_DEMO_GIT_HASH") {
Some(h) => h,
None => "unknown",
};
fn version_line() -> String {
format!("{PKG_NAME} {PKG_VERSION} ({GIT_HASH})")
}
/// `[package]` 아래 `key = "값"`을 읽는다.
fn package_value(manifest: &str, key: &str) -> Option<String> {
let mut in_package = false;
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
in_package = line == "[package]";
continue;
}
if in_package
&& let Some((k, v)) = line.split_once('=')
&& k.trim() == key
{
return Some(v.trim().trim_matches('"').to_string());
}
}
None
}
fn check_embedded() {
let manifest =
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")).unwrap();
assert_eq!(
package_value(&manifest, "version").as_deref(),
Some(PKG_VERSION)
);
assert_eq!(package_value(&manifest, "name").as_deref(), Some(PKG_NAME));
assert_eq!(GIT_HASH, "unknown");
println!(
"이 파일: {} / debug_assertions={}",
version_line(),
cfg!(debug_assertions)
);
}
// ---------- 2. rustc로 env!·option_env!·cfg! 확인 ----------
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vr50_{}_{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
fn stdout(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned()
}
/// 실행 파일로 컴파일한다. `env`가 Some이면 APP_GIT_HASH를 그 값으로 준다.
fn rustc_bin(dir: &Path, src: &str, env: Option<&str>) -> Output {
let file = dir.join("main.rs");
std::fs::write(&file, src).unwrap();
let mut cmd = Command::new("rustc");
cmd.args(["--edition", "2024", "-o"])
.arg(dir.join("app"))
.arg(&file)
.env_remove("APP_GIT_HASH");
if let Some(v) = env {
cmd.env("APP_GIT_HASH", v);
}
cmd.output().expect("rustc 실행")
}
fn run_app(dir: &Path) -> String {
stdout(&Command::new(dir.join("app")).output().unwrap())
.trim()
.to_string()
}
fn check_env_macros() {
let d = tmp("env");
let strict = "fn main() { println!(\"{}\", env!(\"APP_GIT_HASH\")); }\n";
let missing = rustc_bin(&d, strict, None);
assert!(!missing.status.success());
let e = stderr(&missing);
assert!(
e.contains("environment variable `APP_GIT_HASH` not defined at compile time"),
"{e}"
);
let given = rustc_bin(&d, strict, Some("abc1234"));
assert!(given.status.success(), "{}", stderr(&given));
assert_eq!(run_app(&d), "abc1234");
// 실행할 때의 환경 변수는 상관없다. 값은 이미 실행 파일 안에 있다.
let at_run = Command::new(d.join("app"))
.env("APP_GIT_HASH", "zzz")
.output()
.unwrap();
assert_eq!(stdout(&at_run).trim(), "abc1234");
let custom = "fn main() { println!(\"{}\", env!(\"APP_GIT_HASH\", \"set APP_GIT_HASH to the commit hash\")); }\n";
let e = stderr(&rustc_bin(&d, custom, None));
assert!(
e.contains("error: set APP_GIT_HASH to the commit hash"),
"{e}"
);
let loose =
"fn main() { println!(\"{}\", option_env!(\"APP_GIT_HASH\").unwrap_or(\"unknown\")); }\n";
assert!(rustc_bin(&d, loose, None).status.success());
assert_eq!(run_app(&d), "unknown");
assert!(rustc_bin(&d, loose, Some("abc1234")).status.success());
assert_eq!(run_app(&d), "abc1234");
println!(
"env!: 없으면 컴파일 오류(두 번째 인자로 메시지), option_env!: 없으면 None, 실행 때 값은 무관"
);
}
fn rustc_lib(dir: &Path, src: &str) -> Output {
let file = dir.join("lib.rs");
std::fs::write(&file, src).unwrap();
Command::new("rustc")
.args(["--edition", "2024", "--crate-type=lib", "--emit=metadata"])
.arg("--out-dir")
.arg(dir)
.arg(&file)
.output()
.expect("rustc 실행")
}
fn check_cfg_macro() {
let d = tmp("cfg");
// cfg!는 true/false만 낸다. 두 갈래 모두 이 플랫폼에서 컴파일되어야 한다.
let with_macro = "#[cfg(windows)]\nfn only_windows() -> u32 { 1 }\n\
pub fn f() -> u32 { if cfg!(windows) { only_windows() } else { 0 } }\n";
let with_attr = "#[cfg(windows)]\nfn only_windows() -> u32 { 1 }\n\
pub fn f() -> u32 {\n #[cfg(windows)]\n return only_windows();\n #[cfg(not(windows))]\n return 0;\n}\n";
if cfg!(windows) {
println!("windows에서는 cfg 비교를 건너뛴다");
return;
}
let bad = rustc_lib(&d, with_macro);
let e = stderr(&bad);
assert!(
e.contains("error[E0425]: cannot find function `only_windows` in this scope"),
"{e}"
);
let ok = rustc_lib(&d, with_attr);
assert!(ok.status.success(), "{}", stderr(&ok));
println!("cfg!(windows) 갈래에서 #[cfg(windows)] 함수를 부르면 E0425, #[cfg]로 가르면 통과");
}
fn check_musl_static() {
let cfg_of = |target: &str, extra: &[&str]| {
stdout(
&Command::new("rustc")
.args(["--print", "cfg", "--target", target])
.args(extra)
.output()
.unwrap(),
)
};
let musl = cfg_of("x86_64-unknown-linux-musl", &[]);
let musl_arm = cfg_of("aarch64-unknown-linux-musl", &[]);
let gnu = cfg_of("x86_64-unknown-linux-gnu", &[]);
let musl_dynamic = cfg_of(
"x86_64-unknown-linux-musl",
&["-C", "target-feature=-crt-static"],
);
let has = |s: &str| s.lines().any(|l| l == "target_feature=\"crt-static\"");
assert!(musl.contains("target_env=\"musl\""));
assert!(has(&musl) && has(&musl_arm));
assert!(gnu.contains("target_env=\"gnu\""));
assert!(!has(&gnu));
// 호출이 실패하면 stdout이 비어 `!has`가 그냥 참이 되므로, 같은 출력에 musl 대상이 찍혔는지 먼저 본다.
assert!(
musl_dynamic.contains("target_env=\"musl\""),
"{musl_dynamic}"
);
assert!(!has(&musl_dynamic));
println!(
"crt-static 기본값: x86_64·aarch64 musl 켬, x86_64 gnu 끔, musl에 -crt-static 주면 끔"
);
}
// ---------- 3. cargo 패키지로 build.rs·프로필·--locked 확인 ----------
const DEMO_MANIFEST: &str =
"[package]\nname = \"buildinfo-demo\"\nversion = \"1.4.2\"\nedition = \"2024\"\n";
const DEMO_BUILD_RS: &str = r#"fn main() {
println!("cargo::rerun-if-changed=build.rs");
let target = std::env::var("TARGET").expect("cargo가 TARGET을 준다");
println!("cargo::rustc-env=BUILD_TARGET={target}");
}
"#;
const DEMO_MAIN_RS: &str = r#"const VERSION: &str = env!("CARGO_PKG_VERSION");
const GIT_HASH: Option<&str> = option_env!("GIT_HASH");
fn main() {
println!(
"{} {} ({}) target={} debug_assertions={}",
env!("CARGO_PKG_NAME"),
VERSION,
GIT_HASH.unwrap_or("unknown"),
env!("BUILD_TARGET"),
cfg!(debug_assertions),
);
}
"#;
fn cargo(dir: &Path, args: &[&str], git_hash: Option<&str>) -> Output {
let mut cmd = Command::new("cargo");
cmd.args(args)
.arg("--offline")
.current_dir(dir)
.env("CARGO_TARGET_DIR", dir.join("target"))
.env_remove("GIT_HASH");
if let Some(h) = git_hash {
cmd.env("GIT_HASH", h);
}
cmd.output().expect("cargo 실행")
}
/// `cargo -v`의 출력에서 이 패키지의 실행 파일을 컴파일한 rustc 줄을 찾는다.
fn bin_rustc_line(err: &str) -> String {
err.lines()
.find(|l| l.contains("--crate-name buildinfo_demo") && l.contains("--crate-type bin"))
.unwrap_or_else(|| panic!("rustc 줄이 없다:\n{err}"))
.to_string()
}
fn host_triple() -> String {
let out = stdout(&Command::new("rustc").arg("-vV").output().unwrap());
out.lines()
.find_map(|l| l.strip_prefix("host: "))
.unwrap()
.to_string()
}
fn check_cargo_project() {
let d = tmp("cargo");
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("Cargo.toml"), DEMO_MANIFEST).unwrap();
std::fs::write(d.join("build.rs"), DEMO_BUILD_RS).unwrap();
std::fs::write(d.join("src/main.rs"), DEMO_MAIN_RS).unwrap();
// --locked: Cargo.lock이 없으면 만들지 않고 멈춘다.
let locked = cargo(&d, &["build", "--locked", "-q"], None);
assert!(!locked.status.success());
assert!(
stderr(&locked).contains("cannot create the lock file"),
"{}",
stderr(&locked)
);
// release의 기본: debug = false이므로 cargo가 -C strip=debuginfo를 넘긴다.
let rel = cargo(&d, &["build", "--release", "-v"], None);
assert!(rel.status.success(), "{}", stderr(&rel));
let line = bin_rustc_line(&stderr(&rel));
assert!(line.contains("-C opt-level=3"), "{line}");
assert!(line.contains("-C strip=debuginfo"), "{line}");
let sym = cargo(
&d,
&[
"build",
"--release",
"-v",
"--config",
"profile.release.strip=\"symbols\"",
],
None,
);
let line = bin_rustc_line(&stderr(&sym));
assert!(line.contains("-C strip=symbols"), "{line}");
let lines = cargo(
&d,
&[
"build",
"--release",
"-v",
"--config",
"profile.release.debug=\"line-tables-only\"",
],
None,
);
assert!(lines.status.success(), "{}", stderr(&lines));
let line = bin_rustc_line(&stderr(&lines));
assert!(line.contains("-C debuginfo=line-tables-only"), "{line}");
assert!(!line.contains("-C strip="), "{line}");
println!(
"release 기본 -C strip=debuginfo, strip=\"symbols\"면 -C strip=symbols, 줄 정보를 켜면 strip 없음"
);
let host = host_triple();
let dev = cargo(&d, &["run", "-q"], None);
assert!(dev.status.success(), "{}", stderr(&dev));
assert_eq!(
stdout(&dev).trim(),
format!("buildinfo-demo 1.4.2 (unknown) target={host} debug_assertions=true")
);
// 환경 변수만 바꿔도 cargo가 알아채고 다시 컴파일한다.
let dev_hash = cargo(&d, &["run", "-q"], Some("abc1234"));
assert_eq!(
stdout(&dev_hash).trim(),
format!("buildinfo-demo 1.4.2 (abc1234) target={host} debug_assertions=true")
);
let rel_hash = cargo(&d, &["run", "--release", "-q"], Some("abc1234"));
let rel_out = stdout(&rel_hash);
assert_eq!(
rel_out.trim(),
format!("buildinfo-demo 1.4.2 (abc1234) target={host} debug_assertions=false")
);
println!("데모 패키지: {}", rel_out.trim());
// 이제 Cargo.lock이 있다. --locked는 통과하고, 버전을 올려 lock이 바뀌어야 하면 멈춘다.
let ok = cargo(&d, &["build", "--locked", "-q"], None);
assert!(ok.status.success(), "{}", stderr(&ok));
std::fs::write(
d.join("Cargo.toml"),
DEMO_MANIFEST.replace("1.4.2", "1.4.3"),
)
.unwrap();
let stale = cargo(&d, &["build", "--locked", "-q"], None);
assert!(!stale.status.success());
assert!(
stderr(&stale).contains("cannot update the lock file"),
"{}",
stderr(&stale)
);
println!("--locked: lock 없음 → 멈춤, lock 있음 → 통과, lock을 고쳐야 함 → 멈춤");
}
// ---------- 4. Dockerfile 글자 점검 (실행하지 않는다) ----------
/// 문서에 실은 Dockerfile과 같은 글자.
const DOCKERFILE: &str = r#"# syntax=docker/dockerfile:1
# 1단계: 빌드. 툴체인과 소스가 있는 큰 이미지
FROM rust:1.99-slim-trixie AS builder
WORKDIR /app
COPY . .
ARG GIT_HASH=unknown
ENV GIT_HASH=${GIT_HASH}
RUN cargo build --release --locked --bin shortener
# 2단계: 실행. 실행 파일 하나만 옮긴다
FROM debian:trixie-slim AS runtime
COPY --from=builder /app/target/release/shortener /usr/local/bin/shortener
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/shortener", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/shortener"]
"#;
const DOCKERIGNORE: &str = "target/\n.git/\n";
/// 배포 이미지가 지켜야 할 것. 어긴 항목의 이름을 돌려준다.
fn lint_dockerfile(text: &str) -> Vec<&'static str> {
let lines: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
let froms: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with("FROM "))
.map(|(i, _)| i)
.collect();
let mut problems = Vec::new();
if froms.len() < 2 {
problems.push("다단계 빌드가 아니다");
return problems;
}
let last = &lines[*froms.last().unwrap()..];
let builder_image = lines[froms[0]];
if !builder_image.contains(" AS builder") {
problems.push("빌드 단계에 이름이 없다");
}
if !lines
.iter()
.any(|l| l.contains("cargo build --release --locked"))
{
problems.push("--release --locked로 빌드하지 않는다");
}
let release_of = |image: &str| {
["bookworm", "trixie", "forky"]
.into_iter()
.find(|r| image.contains(r))
};
if release_of(builder_image).is_none() || release_of(builder_image) != release_of(last[0]) {
problems.push("두 단계의 Debian 판이 다르다");
}
if !last.iter().any(|l| l.starts_with("COPY --from=builder")) {
problems.push("빌드 결과를 옮기지 않는다");
}
if last
.iter()
.any(|l| l.contains("cargo ") || l.contains("rustup"))
{
problems.push("실행 단계에 툴체인을 쓴다");
}
match last.iter().find_map(|l| l.strip_prefix("USER ")) {
Some(u) if !u.starts_with("root") && !u.starts_with('0') => {}
_ => problems.push("root로 돈다"),
}
if text.matches("HEALTHCHECK").count() != 1 {
problems.push("HEALTHCHECK가 정확히 하나가 아니다");
}
if !last.iter().any(|l| l.starts_with("ENTRYPOINT [")) {
problems.push("ENTRYPOINT가 exec 형식이 아니다");
}
problems
}
fn check_dockerfile() {
let problems = lint_dockerfile(DOCKERFILE);
assert!(problems.is_empty(), "{problems:?}");
assert!(DOCKERIGNORE.lines().any(|l| l == "target/"));
let bad = DOCKERFILE
.replace("USER 10001:10001\n", "")
.replace(
"ENTRYPOINT [\"/usr/local/bin/shortener\"]",
"ENTRYPOINT /usr/local/bin/shortener",
)
.replace("debian:trixie-slim", "debian:bookworm-slim");
let got = lint_dockerfile(&bad);
assert_eq!(
got,
[
"두 단계의 Debian 판이 다르다",
"root로 돈다",
"ENTRYPOINT가 exec 형식이 아니다"
]
);
println!("Dockerfile 글자 점검: 문제 0, 망가뜨린 판은 {got:?}");
}
fn main() {
check_embedded();
check_env_macros();
check_cfg_macro();
check_musl_static();
check_cargo_project();
check_dockerfile();
println!("ALL CHECKS PASSED");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_stage_is_rejected() {
let one = "FROM rust:1.99-trixie\nRUN cargo build --release --locked\nCMD [\"app\"]\n";
assert_eq!(lint_dockerfile(one), ["다단계 빌드가 아니다"]);
}
#[test]
fn package_value_reads_only_package() {
let m = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n[dependencies]\nversion = \"9\"\n";
assert_eq!(package_value(m, "version").as_deref(), Some("1.0.0"));
assert_eq!(package_value(m, "edition"), None);
}
#[test]
fn version_line_has_three_parts() {
assert_eq!(version_line().split(' ').count(), 3);
}
}