use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use zeroize::Zeroizing;
use crate::cli::setup::{SetupAction, SetupArgs};
use crate::detect::Forge;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::embedded;
use crate::error::RkError;
use crate::events::{ChildStream, Event, EventKind};
use crate::output::Output;
use crate::setup::app_jwt::{self, AppApi};
use crate::setup::context::{Ctx, SECRET_VARS};
use crate::setup::journal::Journal;
use crate::setup::observe::{self, StepState};
use crate::setup::process::{self, Exec, Outcome};
use crate::setup::secrets;
use crate::setup::steps::{Mutates, STEPS, StepSpec, spec};
pub fn run(args: &SetupArgs) -> Result<(), RkError> {
match &args.action {
Some(SetupAction::Script { name, forge }) => script(name, forge.as_deref()),
Some(SetupAction::Check {
target,
repo,
forge,
required_check,
json,
}) => {
let ctx = Ctx::resolve(
target,
repo.as_deref(),
forge.as_deref(),
required_check.as_deref(),
)?;
reject_check_flag_on_gitlab(&ctx)?;
check(Output::new(*json), ctx)
}
Some(SetupAction::Step {
name,
target,
repo,
forge,
required_check,
apply,
json,
}) => {
let selected = spec(name).ok_or_else(|| {
RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
})?;
let ctx = Ctx::resolve(
target,
repo.as_deref(),
forge.as_deref(),
required_check.as_deref(),
)?;
reject_check_flag_on_gitlab(&ctx)?;
if *apply {
require_check_for(&ctx, &[selected])?;
execute(Output::new(*json), ctx, &[selected], "setup step")
} else {
preview(Output::new(*json), &ctx, &[selected])
}
}
None if args.list => list(args.forge.as_deref()),
None => {
let target = args.target.clone().ok_or_else(|| {
RkError::Usage("name a --target, or pass --list to see the steps".into())
})?;
let ctx = Ctx::resolve(
&target,
args.repo.as_deref(),
args.forge.as_deref(),
args.required_check.as_deref(),
)?;
reject_check_flag_on_gitlab(&ctx)?;
let all: Vec<&StepSpec> = STEPS.iter().collect();
if args.apply {
require_check_for(&ctx, &all)?;
execute(Output::new(args.json), ctx, &all, "setup")
} else {
preview(Output::new(args.json), &ctx, &all)
}
}
}
}
fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
if ctx.forge == Forge::Gitlab && ctx.required_check.is_some() {
return Err(RkError::Usage(
"--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
));
}
Ok(())
}
fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
let needs = ctx.forge == Forge::Github
&& ctx.required_check.is_none()
&& steps.iter().any(|step| step.name == "protect-trunk");
if needs {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"protect-trunk refuses without --required-check, and nothing was written",
)
.expected("the name of the CI check the release merge must pass")
.action(format!(
"pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
ctx.repo
))
.step("protect-trunk"),
));
}
Ok(())
}
fn list(forge: Option<&str>) -> Result<(), RkError> {
let forge = forge
.map(|name| {
Forge::parse(name).ok_or_else(|| {
RkError::Usage(format!(
"unknown forge '{name}'; the forges are: github, gitlab"
))
})
})
.transpose()?;
let out = Output::human();
for (idx, step) in STEPS.iter().enumerate() {
let mut line = format!(
"{:2}. {} [{}] proves: {}",
idx + 1,
step.name,
step.chapter,
step.proves
);
if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
line.push_str(" (needs --required-check on github)");
}
if step.destructive {
line.push_str(" (destructive)");
}
if step.optional {
line.push_str(" (optional; a full apply skips it)");
}
out.result_line(line);
}
out.next(&[
"rk setup --target . previews every step".to_owned(),
"rk setup script <name> prints one embedded script".to_owned(),
]);
Ok(())
}
fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
if name == "package-check" {
return Err(RkError::Usage(
"package-check reads its command from the technology binding and has no script".into(),
));
}
let forge = match forge {
Some(value) => Forge::parse(value).ok_or_else(|| {
RkError::Usage(format!(
"unknown forge '{value}'; the forges are: github, gitlab"
))
})?,
None => Forge::Github,
};
let path = format!("{}/{name}", forge.as_str());
let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
kind: "setup step",
name: name.to_owned(),
})?;
Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
Ok(())
}
struct Engine {
out: Output,
ctx: Ctx,
journal: Option<Journal>,
secrets: Vec<Zeroizing<Vec<u8>>>,
key: Option<secrets::KeyFile>,
app_jwt: Option<String>,
seq: u64,
command: &'static str,
run_id: String,
}
impl Engine {
fn open(
out: Output,
ctx: Ctx,
command: &'static str,
journal_required: bool,
) -> Result<Self, RkError> {
secrets::refuse_legacy_key()?;
let journal =
match Journal::create(command, ctx.target.as_str(), ctx.forge.as_str(), &ctx.repo) {
Ok(journal) => Some(journal),
Err(source) if journal_required => {
return Err(RkError::refusal(
Diagnostic::new(
Reason::JournalUnavailable,
format!("the run journal cannot be created: {source}"),
)
.expected("a writable state root for the journal")
.target_state("nothing was run and nothing changed"),
));
}
Err(source) => {
out.warn(format!("no run journal for this run: {source}"));
None
}
};
let run_id = journal
.as_ref()
.map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
let mut engine = Self {
out,
ctx,
journal,
secrets: Ctx::secret_values(),
key: None,
app_jwt: None,
seq: 0,
command,
run_id,
};
let opening = Event::opening(
engine.next_seq(),
crate::applog::now_utc(),
engine.run_id.clone(),
engine.command,
);
engine.emit(&opening);
if engine.ctx.self_hosted_gitlab() {
engine.out.warn(
"this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
);
}
Ok(engine)
}
const fn next_seq(&mut self) -> u64 {
let seq = self.seq;
self.seq += 1;
seq
}
fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
let mut event = Event::opening(
self.next_seq(),
crate::applog::now_utc(),
self.run_id.clone(),
self.command,
);
event.kind = kind;
event.step = step.map(str::to_owned);
event
}
fn emit(&mut self, event: &Event) {
self.out.event(event);
if let Some(journal) = &mut self.journal {
if let Ok(line) = serde_json::to_string(event) {
journal.event_line(&line);
}
}
}
fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
let echo = exec.echo();
self.out.frame(&echo);
if let Some(journal) = &mut self.journal {
journal.transcript(echo.as_bytes());
journal.transcript(b"\n");
}
let secrets = std::mem::take(&mut self.secrets);
let step_name: Option<String> = None;
let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
let spawned = process::run(exec, |stream, chunk| {
chunks.push((stream, process::redact(chunk, &secrets)));
});
self.secrets = secrets;
for (stream, chunk) in chunks {
if passthrough {
self.out.child_passthrough(stream, &chunk);
}
let event = self.event(EventKind::ChildOutput, step_name.as_deref());
let event = event.child_output(stream, &chunk);
self.emit(&event);
if let Some(journal) = &mut self.journal {
journal.transcript(&chunk);
}
}
spawned.map_err(|source| {
RkError::refusal(
Diagnostic::new(
Reason::SubprocessSpawn,
format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
)
.expected("a POSIX sh and the forge CLI on PATH")
.run(self.run_path()),
)
})
}
fn run_path(&self) -> String {
self.journal.as_ref().map_or_else(
|| "no journal was written".to_owned(),
|j| j.dir.display().to_string(),
)
}
fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
let mut event = self.event(EventKind::RunFinished, None);
event.exit_code = Some(exit_code);
event.status = Some(if exit_code == 0 {
"ok".into()
} else {
"failed".into()
});
self.emit(&event);
if let Some(journal) = &mut self.journal {
journal.finish(exit_code, reason);
}
}
}
fn fail(engine: &mut Engine, error: RkError) -> RkError {
let error = match error {
RkError::Refusal(mut diagnostic) => {
diagnostic.run.get_or_insert_with(|| engine.run_path());
RkError::Refusal(diagnostic)
}
RkError::Subprocess(mut diagnostic) => {
diagnostic.run.get_or_insert_with(|| engine.run_path());
RkError::Subprocess(diagnostic)
}
RkError::CheckFailed(mut diagnostic) => {
diagnostic.run.get_or_insert_with(|| engine.run_path());
RkError::CheckFailed(diagnostic)
}
other => other,
};
engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
error
}
fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
out.result_line(format!(
"DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
engine.ctx.repo,
engine.ctx.forge.as_str()
));
for (idx, step) in steps.iter().enumerate() {
out.result_line(format!(
"step {}/{} {} — proves {}",
idx + 1,
steps.len(),
step.name,
step.proves
));
if step.name == "bot-secrets" && engine.ctx.forge == Forge::Github {
secrets::resolve_key_file(&engine.ctx.target)?;
}
out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
if step.name == "protect-trunk"
&& engine.ctx.forge == Forge::Github
&& engine.ctx.required_check.is_none()
{
out.result_line(" needs: --required-check <name> before apply");
}
if step.optional && steps.len() > 1 {
out.result_line(format!(
" optional: a full apply skips it; rk setup step {} --apply runs it",
step.name
));
}
let mut event = engine.event(EventKind::StepFinished, Some(step.name));
event.status = Some("previewed".into());
engine.emit(&event);
}
let next = next_for_apply(&engine.ctx, steps);
out.next(&[
next,
"rk setup check --target . proves what is already true".to_owned(),
]);
engine.finish(0, None);
Ok(())
}
fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
match step.name {
"package-check" => match ctx.tech {
Some("rust") => "would run: cargo publish --dry-run --allow-dirty".to_owned(),
Some("python") => "would run: python3 -m build".to_owned(),
Some("bash") => "nothing to run: no registry for this technology".to_owned(),
_ => "needs: a version file naming the technology".to_owned(),
},
name => {
let check = ctx
.required_check
.as_ref()
.filter(|_| ctx.forge == Forge::Github && name == "protect-trunk")
.map(|value| format!(" RK_REQUIRED_CHECK={value}"))
.unwrap_or_default();
format!(
"would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH=master{check}",
ctx.forge.as_str(),
ctx.repo
)
}
}
}
fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
let check = ctx
.required_check
.as_ref()
.map(|value| format!(" --required-check {value}"))
.unwrap_or_default();
if steps.len() == 1 {
format!(
"rk setup step {} --target {} --apply{check}",
steps[0].name, ctx.target
)
} else {
format!("rk setup --target {} --apply{check}", ctx.target)
}
}
fn clone_ctx(ctx: &Ctx) -> Ctx {
Ctx {
target: ctx.target.clone(),
repo: ctx.repo.clone(),
forge: ctx.forge,
host: ctx.host.clone(),
required_check: ctx.required_check.clone(),
cli: ctx.cli.clone(),
tech: ctx.tech,
}
}
fn execute(
out: Output,
ctx: Ctx,
steps: &[&StepSpec],
command: &'static str,
) -> Result<(), RkError> {
guard_sh()?;
let mut engine = Engine::open(out, ctx, command, true)?;
let mut done: Vec<(String, String)> = Vec::new();
for (idx, step) in steps.iter().enumerate() {
if step.optional && steps.len() > 1 {
engine.out.frame(format!(
"step {}/{} {} — skipped (optional; rk setup step {} --apply runs it)",
idx + 1,
steps.len(),
step.name,
step.name
));
let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
finished.status = Some("skipped".into());
engine.emit(&finished);
done.push((step.name.to_owned(), "skipped".to_owned()));
continue;
}
engine.out.frame(format!(
"step {}/{} {} — {}",
idx + 1,
steps.len(),
step.name,
step.proves
));
let mut started = engine.event(EventKind::StepStarted, Some(step.name));
started.status = Some("running".into());
engine.emit(&started);
let clock = Instant::now();
let status = match apply_step(&mut engine, step) {
Ok(status) => status,
Err(error) => {
let error = attach_progress(error, &done, step, steps);
let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
finished.status = Some("failed".into());
finished.reason = Some(error.reason());
finished.duration_ms = Some(elapsed_ms(clock));
engine.emit(&finished);
return Err(fail(&mut engine, error));
}
};
engine
.out
.frame(format!("ok {}: {}", step.name, status.line()));
let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
finished.status = Some(status.wire().into());
finished.exit_code = Some(0);
finished.duration_ms = Some(elapsed_ms(clock));
engine.emit(&finished);
done.push((step.name.to_owned(), status.wire().to_owned()));
}
engine.out.result_line(format!(
"setup: {} completed against {}",
step_count(done.len()),
engine.ctx.repo
));
for (name, status) in &done {
engine.out.result_line(format!(" {status} {name}"));
}
engine.out.next(&[
format!("rk setup check --target {}", engine.ctx.target),
"rk guide setup orders what no command performs".to_owned(),
]);
engine.finish(0, None);
Ok(())
}
fn step_count(count: usize) -> String {
format!("{count} {}", if count == 1 { "step" } else { "steps" })
}
fn elapsed_ms(clock: Instant) -> u64 {
u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
}
enum Done {
Satisfied(String),
Changed(String, Option<String>),
Passed(String),
}
impl Done {
const fn wire(&self) -> &'static str {
match self {
Self::Satisfied(_) => "satisfied",
Self::Changed(..) => "applied",
Self::Passed(_) => "passed",
}
}
fn line(&self) -> String {
match self {
Self::Satisfied(detail) | Self::Passed(detail) => detail.clone(),
Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
|| detail.clone(),
|limit| format!("{detail} (limitation: {limit})"),
),
}
}
}
#[allow(clippy::too_many_lines)]
fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
for prereq in step.prereqs {
let state = observe_with(engine, prereq)?;
if !state.satisfied() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
format!(
"{} requires {prereq} first: {}",
step.name,
state_detail(&state)
),
)
.expected(format!("{prereq} satisfied before {}", step.name))
.action(format!(
"rk setup step {prereq} --target {} --apply",
engine.ctx.target
))
.step(step.name),
));
}
}
match step.name {
"package-check" => {
if engine.ctx.tech.is_none() {
return Err(RkError::Usage(
"no version file names a technology; rk binding --list names the bindings"
.into(),
));
}
let state = observe_with(engine, "package-check")?;
match state {
StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
Err(RkError::subprocess(
Diagnostic::new(
Reason::SubprocessFailed,
format!("package-check failed: {detail}"),
)
.expected(step.proves.to_owned())
.step(step.name),
))
}
StepState::Unknown { detail } => Err(RkError::subprocess(
Diagnostic::new(
Reason::SubprocessFailed,
format!("package-check could not run: {detail}"),
)
.step(step.name),
)),
}
}
"single-trunk" => {
let guard = {
let ctx = clone_ctx(&engine.ctx);
let mut runner = |exec: &Exec| engine.exec(exec, false);
observe::single_trunk_guard(&ctx, &mut runner)?
};
match &guard {
StepState::Satisfied { .. } => {}
StepState::Unsatisfied { detail }
| StepState::Inapplicable { detail }
| StepState::Unknown { detail } => {
return Err(RkError::refusal(
Diagnostic::new(
Reason::DestructiveRefusal,
format!("single-trunk refuses: {detail}"),
)
.expected(
"proof that every candidate branch is absent, or an ancestor of the trunk",
)
.step(step.name),
));
}
}
run_forge_step(engine, step)
}
"bot-secrets" => {
let key = match engine.ctx.forge {
Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
Forge::Gitlab => None,
};
let provided = match engine.ctx.forge {
Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
};
let state = observe_with(engine, step.name)?;
if !provided {
if state.satisfied() {
return Ok(Done::Satisfied(state_detail(&state)));
}
let wanted = match engine.ctx.forge {
Forge::Github => {
"export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
}
Forge::Gitlab => {
"rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
}
};
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"bot-secrets has no credentials to store",
)
.expected("the bot credentials in the environment, the key as a path")
.action(wanted.to_owned())
.step(step.name),
));
}
if let Some(journal) = &mut engine.journal {
for name in SECRET_VARS {
if secrets::value_of(name).is_some() {
journal.record_secret(name, true, "environment");
}
}
if key.is_some() {
journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
}
}
let stdin = key;
run_forge_step_with(engine, step, stdin, Vec::new())
}
"protections-check" => {
let (outcome, _) = run_script(engine, step)?;
if !outcome.success() {
return Err(classify_failure(engine, step, &outcome));
}
match observe_with(engine, step.name)? {
StepState::Satisfied { detail, limitation } => {
Ok(Done::Passed(limitation.map_or_else(
|| detail.clone(),
|limit| format!("{detail} (limitation: {limit})"),
)))
}
StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!("protections-check passed its script and the observation disagrees: {detail}"),
)
.expected(step.proves.to_owned())
.step(step.name),
))
}
StepState::Unknown { detail } => Err(RkError::refusal(
Diagnostic::new(
Reason::ForgeTemporary,
format!(
"protections-check passed its script and the readback could not confirm it: {detail}"
),
)
.expected(step.proves.to_owned())
.action("check authentication and connectivity, then rerun")
.step(step.name),
)),
}
}
"install-bot" if engine.ctx.forge == Forge::Github => {
match observe_with(engine, step.name)? {
StepState::Satisfied { detail, .. } => {
return Ok(Done::Satisfied(detail));
}
StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
StepState::Unknown { detail } => {
return Err(RkError::refusal(
Diagnostic::new(
Reason::ForgeTemporary,
format!("{} cannot observe the current state: {detail}", step.name),
)
.expected("a readable forge answer before anything mutates")
.action("check the App credentials and connectivity, then rerun")
.step(step.name),
));
}
}
let installation = github_installation_id(engine, step)?;
run_forge_step_with(
engine,
step,
None,
vec![("RK_BOT_INSTALLATION".into(), installation.into())],
)
}
_ => {
if step.mutates == Mutates::Forge {
match observe_with(engine, step.name)? {
StepState::Satisfied { detail, .. } => {
return Ok(Done::Satisfied(detail));
}
StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
StepState::Unknown { detail } => {
return Err(RkError::refusal(
Diagnostic::new(
Reason::ForgeTemporary,
format!("{} cannot observe the current state: {detail}", step.name),
)
.expected("a readable forge answer before anything mutates")
.action("check authentication and connectivity, then rerun")
.step(step.name),
));
}
}
}
run_forge_step(engine, step)
}
}
}
fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
let refuse = |message: String, action: &str| {
RkError::refusal(
Diagnostic::new(Reason::PrerequisiteUnmet, message)
.expected("the App installed on the repository's owner")
.action(action.to_owned())
.step(step.name),
)
};
let jwt = match app_jwt_for(engine)? {
Ok(jwt) => jwt,
Err(detail) => {
return Err(refuse(
format!("install-bot has no App token: {detail}"),
app_jwt::REMEDIATION,
));
}
};
let owner = engine
.ctx
.repo
.split('/')
.next()
.unwrap_or_default()
.to_owned();
let ctx = clone_ctx(&engine.ctx);
for path in [
format!("users/{owner}/installation"),
format!("orgs/{owner}/installation"),
] {
match app_jwt::api_get(&ctx, &jwt, &path) {
AppApi::Ok(body) => {
return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
refuse(
format!("the forge answered {path} without an installation id"),
"check RK_BOT_APP_ID and the key file name the same App",
)
});
}
AppApi::Missing => {}
AppApi::Refused(detail) => {
return Err(refuse(
detail,
"check RK_BOT_APP_ID and the key file name the same App",
));
}
AppApi::Failed(detail) => {
return Err(RkError::refusal(
Diagnostic::new(
Reason::ForgeTemporary,
format!("install-bot cannot read the App's installation: {detail}"),
)
.action("check connectivity, then rerun")
.step(step.name),
));
}
}
}
Err(refuse(
format!("the App has no installation on {owner}"),
"install the App on the account first; the setup guide's step 5 walks it",
))
}
fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
run_forge_step_with(engine, step, None, Vec::new())
}
fn run_forge_step_with(
engine: &mut Engine,
step: &StepSpec,
stdin: Option<Zeroizing<Vec<u8>>>,
extra_env: Vec<(OsString, OsString)>,
) -> Result<Done, RkError> {
let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
if !outcome.success() {
return Err(classify_failure(engine, step, &outcome));
}
let state = observe_with(engine, step.name)?;
match state {
StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"{} ran and its postcondition does not hold: {detail}",
step.name
),
)
.expected(step.proves.to_owned())
.step(step.name),
))
}
StepState::Unknown { detail } => Err(RkError::refusal(
Diagnostic::new(
Reason::ForgeTemporary,
format!(
"{} ran and the readback could not confirm it: {detail}",
step.name
),
)
.expected(step.proves.to_owned())
.action(format!(
"rk setup step {} --target {} --apply re-asserts and re-proves it",
step.name, engine.ctx.target
))
.step(step.name),
)),
}
}
fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
if step == "install-bot" && engine.ctx.forge == Forge::Github {
let jwt = match app_jwt_for(engine)? {
Ok(jwt) => jwt,
Err(detail) => return Ok(StepState::Unknown { detail }),
};
return Ok(observe::github_install_bot(&engine.ctx, &jwt));
}
let ctx = clone_ctx(&engine.ctx);
let mut runner = |exec: &Exec| engine.exec(exec, false);
observe::observe(&ctx, step, &mut runner)
}
fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
if engine.key.is_none() {
engine.key = secrets::resolve_key_file(&engine.ctx.target)?;
if let Some(key) = &engine.key {
engine.secrets.push(key.bytes.clone());
}
}
Ok(engine.key.as_ref())
}
fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
if let Some(jwt) = &engine.app_jwt {
return Ok(Ok(jwt.clone()));
}
let app_id = app_jwt::app_id()?;
let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
return Ok(Err(format!(
"the installation is readable only to the App itself; {}",
app_jwt::REMEDIATION
)));
};
let credentials = app_jwt::AppCredentials { app_id, key_bytes };
let ctx = clone_ctx(&engine.ctx);
Ok(match app_jwt::mint(&ctx, &credentials) {
Ok(jwt) => {
engine
.secrets
.push(Zeroizing::new(jwt.clone().into_bytes()));
if let Some(signature) = jwt.rsplit('.').next() {
engine
.secrets
.push(Zeroizing::new(signature.as_bytes().to_vec()));
}
engine.app_jwt = Some(jwt.clone());
Ok(jwt)
}
Err(detail) => Err(detail),
})
}
fn state_detail(state: &StepState) -> String {
match state {
StepState::Satisfied { detail, .. }
| StepState::Unsatisfied { detail }
| StepState::Inapplicable { detail }
| StepState::Unknown { detail } => detail.clone(),
}
}
fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
run_script_with(engine, step, None, Vec::new())
}
fn run_script_with(
engine: &mut Engine,
step: &StepSpec,
stdin: Option<Zeroizing<Vec<u8>>>,
extra_env: Vec<(OsString, OsString)>,
) -> Result<(Outcome, PathBuf), RkError> {
let rel = format!("{}/{}", engine.ctx.forge.as_str(), step.name);
let bytes = embedded::SETUP
.get_file(&rel)
.map(include_dir::File::contents)
.ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
let journal = engine
.journal
.as_mut()
.ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
let dir = journal.scripts_dir().join(engine.ctx.forge.as_str());
fs::create_dir_all(&dir)?;
restrict(&dir, 0o700);
let path = dir.join(step.name);
fs::write(&path, bytes)?;
restrict(&path, 0o600);
let written = fs::read(&path)?;
let digest = Digest::of(&written);
if digest != Digest::of(bytes) {
return Err(RkError::Other(anyhow::anyhow!(
"the materialized script at {} differs from the embedded bytes",
path.display()
)));
}
journal.record_script(format!("scripts/{rel}"), digest.to_string());
let mut env = engine.ctx.child_env(step.name);
env.extend(extra_env);
let exec = Exec {
program: "sh".into(),
args: vec![path.clone().into_os_string()],
env,
cwd: engine.ctx.target.as_std_path().to_path_buf(),
stdin,
};
let outcome = engine.exec(&exec, true)?;
Ok((outcome, path))
}
fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
let stderr = String::from_utf8_lossy(&outcome.stderr);
let last = if outcome.exit_code >= 128 {
format!("killed by signal {}", outcome.exit_code - 128)
} else {
stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("no output")
.to_owned()
};
let reason = if (engine.ctx.forge == Forge::Github && outcome.exit_code == 4)
|| stderr.contains("HTTP 401")
{
Reason::ForgeAuthentication
} else if stderr.contains("HTTP 403") {
Reason::ForgePermission
} else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
Reason::ForgeRateLimit
} else {
Reason::SubprocessFailed
};
let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
.expected(step.proves.to_owned())
.action(format!(
"rk setup step {} --target {} --apply",
step.name, engine.ctx.target
))
.step(step.name);
let diagnostic = match reason {
Reason::ForgePermission => diagnostic.expected(format!(
"repository administration write on {} for the authenticated account",
engine.ctx.repo
)),
_ => diagnostic,
};
match reason {
Reason::SubprocessFailed => RkError::subprocess(diagnostic),
_ => RkError::refusal(diagnostic),
}
}
fn attach_progress(
error: RkError,
done: &[(String, String)],
failed: &StepSpec,
steps: &[&StepSpec],
) -> RkError {
let remaining = steps.len().saturating_sub(done.len() + 1);
let state = format!(
"{} completed; {} failed; {remaining} not attempted",
step_count(done.len()),
failed.name
);
match error {
RkError::Refusal(mut diagnostic) => {
diagnostic.target_state.get_or_insert(state);
RkError::Refusal(diagnostic)
}
RkError::Subprocess(mut diagnostic) => {
diagnostic.target_state.get_or_insert(state);
RkError::Subprocess(diagnostic)
}
other => other,
}
}
fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
let mut engine = Engine::open(out, ctx, "setup check", false)?;
let mut unsatisfied = 0usize;
let mut unverifiable = 0usize;
for step in &STEPS {
let clock = Instant::now();
let state = observe_with(&mut engine, step.name)?;
let (label, wire) = match &state {
StepState::Satisfied { .. } => ("ok", "satisfied"),
StepState::Inapplicable { .. } => ("skipped", "skipped"),
StepState::Unsatisfied { .. } => {
unsatisfied += 1;
("unsatisfied", "unsatisfied")
}
StepState::Unknown { .. } => {
unverifiable += 1;
("unknown", "unknown")
}
};
let mut line = format!("{label} {} — {}", step.name, state_detail(&state));
if let StepState::Satisfied {
limitation: Some(limit),
..
} = &state
{
use std::fmt::Write as _;
let _ = write!(line, " (limitation: {limit})");
}
engine.out.result_line(line);
let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
finished.status = Some(wire.into());
finished.duration_ms = Some(elapsed_ms(clock));
engine.emit(&finished);
}
if unsatisfied > 0 || unverifiable > 0 {
let error = RkError::check_failed(
Diagnostic::new(
Reason::StateDrift,
format!(
"{} {} not satisfied and {unverifiable} could not be verified",
step_count(unsatisfied),
if unsatisfied == 1 { "is" } else { "are" }
),
)
.expected("every step's proof column to hold and to be readable")
.action(format!(
"rk setup --target {} --apply re-asserts them",
engine.ctx.target
)),
);
return Err(fail(&mut engine, error));
}
engine
.out
.next(&["rk guide release orders the first release".to_owned()]);
engine.finish(0, None);
Ok(())
}
fn restrict(path: &std::path::Path, mode: u32) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
let _ = (path, mode);
}
fn guard_sh() -> Result<(), RkError> {
let ok = std::process::Command::new("sh")
.args(["-c", "exit 0"])
.status()
.is_ok_and(|status| status.success());
if ok {
Ok(())
} else {
Err(RkError::refusal(
Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
.expected("a working sh on PATH; every step spawns through it")
.action("install a POSIX shell, then rerun")
.target_state("nothing was run and nothing changed"),
))
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_step_count_carries_a_noun_that_agrees_with_it() {
assert_eq!(super::step_count(0), "0 steps");
assert_eq!(super::step_count(1), "1 step");
assert_eq!(super::step_count(2), "2 steps");
}
}