1use std::ffi::OsString;
12use std::fs;
13use std::path::PathBuf;
14use std::time::Instant;
15
16use zeroize::Zeroizing;
17
18use crate::cli::setup::{SetupAction, SetupArgs};
19use crate::detect::Forge;
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::digest::Digest;
22use crate::embedded;
23use crate::error::RkError;
24use crate::events::{ChildStream, Event, EventKind};
25use crate::output::Output;
26use crate::setup::app_jwt::{self, AppApi};
27use crate::setup::context::{Ctx, SECRET_VARS};
28use crate::setup::journal::Journal;
29use crate::setup::observe::{self, StepState};
30use crate::setup::process::{self, Exec, Outcome};
31use crate::setup::secrets;
32use crate::setup::steps::{Mutates, STEPS, StepSpec, spec};
33
34pub fn run(args: &SetupArgs) -> Result<(), RkError> {
40 match &args.action {
41 Some(SetupAction::Script { name, forge }) => script(name, forge.as_deref()),
42 Some(SetupAction::Check {
43 target,
44 repo,
45 forge,
46 required_check,
47 json,
48 }) => {
49 let ctx = Ctx::resolve(
50 target,
51 repo.as_deref(),
52 forge.as_deref(),
53 required_check.as_deref(),
54 )?;
55 reject_check_flag_on_gitlab(&ctx)?;
56 check(Output::new(*json), ctx)
57 }
58 Some(SetupAction::Step {
59 name,
60 target,
61 repo,
62 forge,
63 required_check,
64 apply,
65 json,
66 }) => {
67 let selected = spec(name).ok_or_else(|| {
68 RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
69 })?;
70 let ctx = Ctx::resolve(
71 target,
72 repo.as_deref(),
73 forge.as_deref(),
74 required_check.as_deref(),
75 )?;
76 reject_check_flag_on_gitlab(&ctx)?;
77 if *apply {
78 require_check_for(&ctx, &[selected])?;
79 execute(Output::new(*json), ctx, &[selected], "setup step")
80 } else {
81 preview(Output::new(*json), &ctx, &[selected])
82 }
83 }
84 None if args.list => list(args.forge.as_deref()),
85 None => {
86 let target = args.target.clone().ok_or_else(|| {
87 RkError::Usage("name a --target, or pass --list to see the steps".into())
88 })?;
89 let ctx = Ctx::resolve(
90 &target,
91 args.repo.as_deref(),
92 args.forge.as_deref(),
93 args.required_check.as_deref(),
94 )?;
95 reject_check_flag_on_gitlab(&ctx)?;
96 let all: Vec<&StepSpec> = STEPS.iter().collect();
97 if args.apply {
98 require_check_for(&ctx, &all)?;
99 execute(Output::new(args.json), ctx, &all, "setup")
100 } else {
101 preview(Output::new(args.json), &ctx, &all)
102 }
103 }
104 }
105}
106
107fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
111 if ctx.forge == Forge::Gitlab && ctx.required_check.is_some() {
112 return Err(RkError::Usage(
113 "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
114 ));
115 }
116 Ok(())
117}
118
119fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
124 let needs = ctx.forge == Forge::Github
125 && ctx.required_check.is_none()
126 && steps.iter().any(|step| step.name == "protect-trunk");
127 if needs {
128 return Err(RkError::refusal(
129 Diagnostic::new(
130 Reason::PrerequisiteUnmet,
131 "protect-trunk refuses without --required-check, and nothing was written",
132 )
133 .expected("the name of the CI check the release merge must pass")
134 .action(format!(
135 "pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
136 ctx.repo
137 ))
138 .step("protect-trunk"),
139 ));
140 }
141 Ok(())
142}
143
144fn list(forge: Option<&str>) -> Result<(), RkError> {
148 let forge = forge
149 .map(|name| {
150 Forge::parse(name).ok_or_else(|| {
151 RkError::Usage(format!(
152 "unknown forge '{name}'; the forges are: github, gitlab"
153 ))
154 })
155 })
156 .transpose()?;
157 let out = Output::human();
158 for (idx, step) in STEPS.iter().enumerate() {
159 let mut line = format!(
160 "{:2}. {} [{}] proves: {}",
161 idx + 1,
162 step.name,
163 step.chapter,
164 step.proves
165 );
166 if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
167 line.push_str(" (needs --required-check on github)");
168 }
169 if step.destructive {
170 line.push_str(" (destructive)");
171 }
172 if step.optional {
173 line.push_str(" (optional; a full apply skips it)");
174 }
175 out.result_line(line);
176 }
177 out.next(&[
178 "rk setup --target . previews every step".to_owned(),
179 "rk setup script <name> prints one embedded script".to_owned(),
180 ]);
181 Ok(())
182}
183
184fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
187 if name == "package-check" {
188 return Err(RkError::Usage(
189 "package-check reads its command from the technology binding and has no script".into(),
190 ));
191 }
192 let forge = match forge {
193 Some(value) => Forge::parse(value).ok_or_else(|| {
194 RkError::Usage(format!(
195 "unknown forge '{value}'; the forges are: github, gitlab"
196 ))
197 })?,
198 None => Forge::Github,
199 };
200 let path = format!("{}/{name}", forge.as_str());
201 let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
202 kind: "setup step",
203 name: name.to_owned(),
204 })?;
205 Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
206 Ok(())
207}
208
209struct Engine {
212 out: Output,
213 ctx: Ctx,
214 journal: Option<Journal>,
215 secrets: Vec<Zeroizing<Vec<u8>>>,
216 key: Option<secrets::KeyFile>,
218 app_jwt: Option<String>,
220 seq: u64,
221 command: &'static str,
222 run_id: String,
223}
224
225impl Engine {
226 fn open(
231 out: Output,
232 ctx: Ctx,
233 command: &'static str,
234 journal_required: bool,
235 ) -> Result<Self, RkError> {
236 secrets::refuse_legacy_key()?;
239 let journal =
240 match Journal::create(command, ctx.target.as_str(), ctx.forge.as_str(), &ctx.repo) {
241 Ok(journal) => Some(journal),
242 Err(source) if journal_required => {
243 return Err(RkError::refusal(
244 Diagnostic::new(
245 Reason::JournalUnavailable,
246 format!("the run journal cannot be created: {source}"),
247 )
248 .expected("a writable state root for the journal")
249 .target_state("nothing was run and nothing changed"),
250 ));
251 }
252 Err(source) => {
253 out.warn(format!("no run journal for this run: {source}"));
254 None
255 }
256 };
257 let run_id = journal
258 .as_ref()
259 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
260 let mut engine = Self {
261 out,
262 ctx,
263 journal,
264 secrets: Ctx::secret_values(),
265 key: None,
266 app_jwt: None,
267 seq: 0,
268 command,
269 run_id,
270 };
271 let opening = Event::opening(
272 engine.next_seq(),
273 crate::applog::now_utc(),
274 engine.run_id.clone(),
275 engine.command,
276 );
277 engine.emit(&opening);
278 if engine.ctx.self_hosted_gitlab() {
279 engine.out.warn(
280 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
281 );
282 }
283 Ok(engine)
284 }
285
286 const fn next_seq(&mut self) -> u64 {
287 let seq = self.seq;
288 self.seq += 1;
289 seq
290 }
291
292 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
293 let mut event = Event::opening(
294 self.next_seq(),
295 crate::applog::now_utc(),
296 self.run_id.clone(),
297 self.command,
298 );
299 event.kind = kind;
300 event.step = step.map(str::to_owned);
301 event
302 }
303
304 fn emit(&mut self, event: &Event) {
305 self.out.event(event);
306 if let Some(journal) = &mut self.journal {
307 if let Ok(line) = serde_json::to_string(event) {
308 journal.event_line(&line);
309 }
310 }
311 }
312
313 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
316 let echo = exec.echo();
317 self.out.frame(&echo);
318 if let Some(journal) = &mut self.journal {
319 journal.transcript(echo.as_bytes());
320 journal.transcript(b"\n");
321 }
322 let secrets = std::mem::take(&mut self.secrets);
323 let step_name: Option<String> = None;
324 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
325 let spawned = process::run(exec, |stream, chunk| {
326 chunks.push((stream, process::redact(chunk, &secrets)));
327 });
328 self.secrets = secrets;
329 for (stream, chunk) in chunks {
330 if passthrough {
331 self.out.child_passthrough(stream, &chunk);
332 }
333 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
334 let event = event.child_output(stream, &chunk);
335 self.emit(&event);
336 if let Some(journal) = &mut self.journal {
337 journal.transcript(&chunk);
338 }
339 }
340 spawned.map_err(|source| {
341 RkError::refusal(
342 Diagnostic::new(
343 Reason::SubprocessSpawn,
344 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
345 )
346 .expected("a POSIX sh and the forge CLI on PATH")
347 .run(self.run_path()),
348 )
349 })
350 }
351
352 fn run_path(&self) -> String {
353 self.journal.as_ref().map_or_else(
354 || "no journal was written".to_owned(),
355 |j| j.dir.display().to_string(),
356 )
357 }
358
359 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
360 let mut event = self.event(EventKind::RunFinished, None);
361 event.exit_code = Some(exit_code);
362 event.status = Some(if exit_code == 0 {
363 "ok".into()
364 } else {
365 "failed".into()
366 });
367 self.emit(&event);
368 if let Some(journal) = &mut self.journal {
369 journal.finish(exit_code, reason);
370 }
371 }
372}
373
374fn fail(engine: &mut Engine, error: RkError) -> RkError {
376 let error = match error {
377 RkError::Refusal(mut diagnostic) => {
378 diagnostic.run.get_or_insert_with(|| engine.run_path());
379 RkError::Refusal(diagnostic)
380 }
381 RkError::Subprocess(mut diagnostic) => {
382 diagnostic.run.get_or_insert_with(|| engine.run_path());
383 RkError::Subprocess(diagnostic)
384 }
385 RkError::CheckFailed(mut diagnostic) => {
386 diagnostic.run.get_or_insert_with(|| engine.run_path());
387 RkError::CheckFailed(diagnostic)
388 }
389 other => other,
390 };
391 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
392 error
393}
394
395fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
401 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
402 out.result_line(format!(
403 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
404 engine.ctx.repo,
405 engine.ctx.forge.as_str()
406 ));
407 for (idx, step) in steps.iter().enumerate() {
408 out.result_line(format!(
409 "step {}/{} {} — proves {}",
410 idx + 1,
411 steps.len(),
412 step.name,
413 step.proves
414 ));
415 if step.name == "bot-secrets" && engine.ctx.forge == Forge::Github {
419 secrets::resolve_key_file(&engine.ctx.target)?;
420 }
421 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
422 if step.name == "protect-trunk"
423 && engine.ctx.forge == Forge::Github
424 && engine.ctx.required_check.is_none()
425 {
426 out.result_line(" needs: --required-check <name> before apply");
427 }
428 if step.optional && steps.len() > 1 {
429 out.result_line(format!(
430 " optional: a full apply skips it; rk setup step {} --apply runs it",
431 step.name
432 ));
433 }
434 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
435 event.status = Some("previewed".into());
436 engine.emit(&event);
437 }
438 let next = next_for_apply(&engine.ctx, steps);
439 out.next(&[
440 next,
441 "rk setup check --target . proves what is already true".to_owned(),
442 ]);
443 engine.finish(0, None);
444 Ok(())
445}
446
447fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
450 match step.name {
451 "package-check" => match ctx.tech {
452 Some("rust") => "would run: cargo publish --dry-run --allow-dirty".to_owned(),
453 Some("python") => "would run: python3 -m build".to_owned(),
454 Some("bash") => "nothing to run: no registry for this technology".to_owned(),
455 _ => "needs: a version file naming the technology".to_owned(),
456 },
457 name => {
458 let check = ctx
459 .required_check
460 .as_ref()
461 .filter(|_| ctx.forge == Forge::Github && name == "protect-trunk")
462 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
463 .unwrap_or_default();
464 format!(
465 "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH=master{check}",
466 ctx.forge.as_str(),
467 ctx.repo
468 )
469 }
470 }
471}
472
473fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
474 let check = ctx
475 .required_check
476 .as_ref()
477 .map(|value| format!(" --required-check {value}"))
478 .unwrap_or_default();
479 if steps.len() == 1 {
480 format!(
481 "rk setup step {} --target {} --apply{check}",
482 steps[0].name, ctx.target
483 )
484 } else {
485 format!("rk setup --target {} --apply{check}", ctx.target)
486 }
487}
488
489fn clone_ctx(ctx: &Ctx) -> Ctx {
491 Ctx {
492 target: ctx.target.clone(),
493 repo: ctx.repo.clone(),
494 forge: ctx.forge,
495 host: ctx.host.clone(),
496 required_check: ctx.required_check.clone(),
497 cli: ctx.cli.clone(),
498 tech: ctx.tech,
499 }
500}
501
502fn execute(
504 out: Output,
505 ctx: Ctx,
506 steps: &[&StepSpec],
507 command: &'static str,
508) -> Result<(), RkError> {
509 guard_sh()?;
510 let mut engine = Engine::open(out, ctx, command, true)?;
511 let mut done: Vec<(String, String)> = Vec::new();
512 for (idx, step) in steps.iter().enumerate() {
513 if step.optional && steps.len() > 1 {
516 engine.out.frame(format!(
517 "step {}/{} {} — skipped (optional; rk setup step {} --apply runs it)",
518 idx + 1,
519 steps.len(),
520 step.name,
521 step.name
522 ));
523 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
524 finished.status = Some("skipped".into());
525 engine.emit(&finished);
526 done.push((step.name.to_owned(), "skipped".to_owned()));
527 continue;
528 }
529 engine.out.frame(format!(
530 "step {}/{} {} — {}",
531 idx + 1,
532 steps.len(),
533 step.name,
534 step.proves
535 ));
536 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
537 started.status = Some("running".into());
538 engine.emit(&started);
539 let clock = Instant::now();
540 let status = match apply_step(&mut engine, step) {
541 Ok(status) => status,
542 Err(error) => {
543 let error = attach_progress(error, &done, step, steps);
544 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
545 finished.status = Some("failed".into());
546 finished.reason = Some(error.reason());
547 finished.duration_ms = Some(elapsed_ms(clock));
548 engine.emit(&finished);
549 return Err(fail(&mut engine, error));
550 }
551 };
552 engine
553 .out
554 .frame(format!("ok {}: {}", step.name, status.line()));
555 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
556 finished.status = Some(status.wire().into());
557 finished.exit_code = Some(0);
558 finished.duration_ms = Some(elapsed_ms(clock));
559 engine.emit(&finished);
560 done.push((step.name.to_owned(), status.wire().to_owned()));
561 }
562 engine.out.result_line(format!(
563 "setup: {} completed against {}",
564 step_count(done.len()),
565 engine.ctx.repo
566 ));
567 for (name, status) in &done {
568 engine.out.result_line(format!(" {status} {name}"));
569 }
570 engine.out.next(&[
571 format!("rk setup check --target {}", engine.ctx.target),
572 "rk guide setup orders what no command performs".to_owned(),
573 ]);
574 engine.finish(0, None);
575 Ok(())
576}
577
578fn step_count(count: usize) -> String {
581 format!("{count} {}", if count == 1 { "step" } else { "steps" })
582}
583
584fn elapsed_ms(clock: Instant) -> u64 {
585 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
586}
587
588enum Done {
590 Satisfied(String),
592 Changed(String, Option<String>),
594 Passed(String),
596}
597
598impl Done {
599 const fn wire(&self) -> &'static str {
600 match self {
601 Self::Satisfied(_) => "satisfied",
602 Self::Changed(..) => "applied",
603 Self::Passed(_) => "passed",
604 }
605 }
606
607 fn line(&self) -> String {
608 match self {
609 Self::Satisfied(detail) | Self::Passed(detail) => detail.clone(),
610 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
611 || detail.clone(),
612 |limit| format!("{detail} (limitation: {limit})"),
613 ),
614 }
615 }
616}
617
618#[allow(clippy::too_many_lines)]
620fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
621 for prereq in step.prereqs {
624 let state = observe_with(engine, prereq)?;
625 if !state.satisfied() {
626 return Err(RkError::refusal(
627 Diagnostic::new(
628 Reason::PrerequisiteUnmet,
629 format!(
630 "{} requires {prereq} first: {}",
631 step.name,
632 state_detail(&state)
633 ),
634 )
635 .expected(format!("{prereq} satisfied before {}", step.name))
636 .action(format!(
637 "rk setup step {prereq} --target {} --apply",
638 engine.ctx.target
639 ))
640 .step(step.name),
641 ));
642 }
643 }
644 match step.name {
645 "package-check" => {
646 if engine.ctx.tech.is_none() {
647 return Err(RkError::Usage(
648 "no version file names a technology; rk binding --list names the bindings"
649 .into(),
650 ));
651 }
652 let state = observe_with(engine, "package-check")?;
653 match state {
654 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
655 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
656 Err(RkError::subprocess(
657 Diagnostic::new(
658 Reason::SubprocessFailed,
659 format!("package-check failed: {detail}"),
660 )
661 .expected(step.proves.to_owned())
662 .step(step.name),
663 ))
664 }
665 StepState::Unknown { detail } => Err(RkError::subprocess(
666 Diagnostic::new(
667 Reason::SubprocessFailed,
668 format!("package-check could not run: {detail}"),
669 )
670 .step(step.name),
671 )),
672 }
673 }
674 "single-trunk" => {
675 let guard = {
676 let ctx = clone_ctx(&engine.ctx);
677 let mut runner = |exec: &Exec| engine.exec(exec, false);
678 observe::single_trunk_guard(&ctx, &mut runner)?
679 };
680 match &guard {
683 StepState::Satisfied { .. } => {}
684 StepState::Unsatisfied { detail }
685 | StepState::Inapplicable { detail }
686 | StepState::Unknown { detail } => {
687 return Err(RkError::refusal(
688 Diagnostic::new(
689 Reason::DestructiveRefusal,
690 format!("single-trunk refuses: {detail}"),
691 )
692 .expected(
693 "proof that every candidate branch is absent, or an ancestor of the trunk",
694 )
695 .step(step.name),
696 ));
697 }
698 }
699 run_forge_step(engine, step)
700 }
701 "bot-secrets" => {
702 let key = match engine.ctx.forge {
708 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
712 Forge::Gitlab => None,
713 };
714 let provided = match engine.ctx.forge {
715 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
718 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
719 };
720 let state = observe_with(engine, step.name)?;
721 if !provided {
722 if state.satisfied() {
723 return Ok(Done::Satisfied(state_detail(&state)));
724 }
725 let wanted = match engine.ctx.forge {
726 Forge::Github => {
727 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
728 }
729 Forge::Gitlab => {
730 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
731 }
732 };
733 return Err(RkError::refusal(
734 Diagnostic::new(
735 Reason::PrerequisiteUnmet,
736 "bot-secrets has no credentials to store",
737 )
738 .expected("the bot credentials in the environment, the key as a path")
739 .action(wanted.to_owned())
740 .step(step.name),
741 ));
742 }
743 if let Some(journal) = &mut engine.journal {
744 for name in SECRET_VARS {
745 if secrets::value_of(name).is_some() {
746 journal.record_secret(name, true, "environment");
747 }
748 }
749 if key.is_some() {
750 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
751 }
752 }
753 let stdin = key;
757 run_forge_step_with(engine, step, stdin, Vec::new())
758 }
759 "protections-check" => {
760 let (outcome, _) = run_script(engine, step)?;
761 if !outcome.success() {
762 return Err(classify_failure(engine, step, &outcome));
763 }
764 match observe_with(engine, step.name)? {
768 StepState::Satisfied { detail, limitation } => {
769 Ok(Done::Passed(limitation.map_or_else(
770 || detail.clone(),
771 |limit| format!("{detail} (limitation: {limit})"),
772 )))
773 }
774 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
775 Err(RkError::refusal(
776 Diagnostic::new(
777 Reason::StateDrift,
778 format!("protections-check passed its script and the observation disagrees: {detail}"),
779 )
780 .expected(step.proves.to_owned())
781 .step(step.name),
782 ))
783 }
784 StepState::Unknown { detail } => Err(RkError::refusal(
787 Diagnostic::new(
788 Reason::ForgeTemporary,
789 format!(
790 "protections-check passed its script and the readback could not confirm it: {detail}"
791 ),
792 )
793 .expected(step.proves.to_owned())
794 .action("check authentication and connectivity, then rerun")
795 .step(step.name),
796 )),
797 }
798 }
799 "install-bot" if engine.ctx.forge == Forge::Github => {
805 match observe_with(engine, step.name)? {
806 StepState::Satisfied { detail, .. } => {
807 return Ok(Done::Satisfied(detail));
808 }
809 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
810 StepState::Unknown { detail } => {
811 return Err(RkError::refusal(
812 Diagnostic::new(
813 Reason::ForgeTemporary,
814 format!("{} cannot observe the current state: {detail}", step.name),
815 )
816 .expected("a readable forge answer before anything mutates")
817 .action("check the App credentials and connectivity, then rerun")
818 .step(step.name),
819 ));
820 }
821 }
822 let installation = github_installation_id(engine, step)?;
823 run_forge_step_with(
824 engine,
825 step,
826 None,
827 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
828 )
829 }
830 _ => {
831 if step.mutates == Mutates::Forge {
832 match observe_with(engine, step.name)? {
837 StepState::Satisfied { detail, .. } => {
838 return Ok(Done::Satisfied(detail));
839 }
840 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
841 StepState::Unknown { detail } => {
842 return Err(RkError::refusal(
843 Diagnostic::new(
844 Reason::ForgeTemporary,
845 format!("{} cannot observe the current state: {detail}", step.name),
846 )
847 .expected("a readable forge answer before anything mutates")
848 .action("check authentication and connectivity, then rerun")
849 .step(step.name),
850 ));
851 }
852 }
853 }
854 run_forge_step(engine, step)
855 }
856 }
857}
858
859fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
867 let refuse = |message: String, action: &str| {
868 RkError::refusal(
869 Diagnostic::new(Reason::PrerequisiteUnmet, message)
870 .expected("the App installed on the repository's owner")
871 .action(action.to_owned())
872 .step(step.name),
873 )
874 };
875 let jwt = match app_jwt_for(engine)? {
876 Ok(jwt) => jwt,
877 Err(detail) => {
878 return Err(refuse(
879 format!("install-bot has no App token: {detail}"),
880 app_jwt::REMEDIATION,
881 ));
882 }
883 };
884 let owner = engine
885 .ctx
886 .repo
887 .split('/')
888 .next()
889 .unwrap_or_default()
890 .to_owned();
891 let ctx = clone_ctx(&engine.ctx);
892 for path in [
893 format!("users/{owner}/installation"),
894 format!("orgs/{owner}/installation"),
895 ] {
896 match app_jwt::api_get(&ctx, &jwt, &path) {
897 AppApi::Ok(body) => {
898 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
899 refuse(
900 format!("the forge answered {path} without an installation id"),
901 "check RK_BOT_APP_ID and the key file name the same App",
902 )
903 });
904 }
905 AppApi::Missing => {}
906 AppApi::Refused(detail) => {
907 return Err(refuse(
908 detail,
909 "check RK_BOT_APP_ID and the key file name the same App",
910 ));
911 }
912 AppApi::Failed(detail) => {
913 return Err(RkError::refusal(
914 Diagnostic::new(
915 Reason::ForgeTemporary,
916 format!("install-bot cannot read the App's installation: {detail}"),
917 )
918 .action("check connectivity, then rerun")
919 .step(step.name),
920 ));
921 }
922 }
923 }
924 Err(refuse(
925 format!("the App has no installation on {owner}"),
926 "install the App on the account first; the setup guide's step 5 walks it",
927 ))
928}
929
930fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
932 run_forge_step_with(engine, step, None, Vec::new())
933}
934
935fn run_forge_step_with(
938 engine: &mut Engine,
939 step: &StepSpec,
940 stdin: Option<Zeroizing<Vec<u8>>>,
941 extra_env: Vec<(OsString, OsString)>,
942) -> Result<Done, RkError> {
943 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
944 if !outcome.success() {
945 return Err(classify_failure(engine, step, &outcome));
946 }
947 let state = observe_with(engine, step.name)?;
948 match state {
949 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
950 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
951 Err(RkError::refusal(
952 Diagnostic::new(
953 Reason::StateDrift,
954 format!(
955 "{} ran and its postcondition does not hold: {detail}",
956 step.name
957 ),
958 )
959 .expected(step.proves.to_owned())
960 .step(step.name),
961 ))
962 }
963 StepState::Unknown { detail } => Err(RkError::refusal(
967 Diagnostic::new(
968 Reason::ForgeTemporary,
969 format!(
970 "{} ran and the readback could not confirm it: {detail}",
971 step.name
972 ),
973 )
974 .expected(step.proves.to_owned())
975 .action(format!(
976 "rk setup step {} --target {} --apply re-asserts and re-proves it",
977 step.name, engine.ctx.target
978 ))
979 .step(step.name),
980 )),
981 }
982}
983
984fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
991 if step == "install-bot" && engine.ctx.forge == Forge::Github {
992 let jwt = match app_jwt_for(engine)? {
993 Ok(jwt) => jwt,
994 Err(detail) => return Ok(StepState::Unknown { detail }),
995 };
996 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
997 }
998 let ctx = clone_ctx(&engine.ctx);
999 let mut runner = |exec: &Exec| engine.exec(exec, false);
1000 observe::observe(&ctx, step, &mut runner)
1001}
1002
1003fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1009 if engine.key.is_none() {
1010 engine.key = secrets::resolve_key_file(&engine.ctx.target)?;
1011 if let Some(key) = &engine.key {
1012 engine.secrets.push(key.bytes.clone());
1013 }
1014 }
1015 Ok(engine.key.as_ref())
1016}
1017
1018fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1028 if let Some(jwt) = &engine.app_jwt {
1029 return Ok(Ok(jwt.clone()));
1030 }
1031 let app_id = app_jwt::app_id()?;
1032 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1033 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1034 return Ok(Err(format!(
1035 "the installation is readable only to the App itself; {}",
1036 app_jwt::REMEDIATION
1037 )));
1038 };
1039 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1040 let ctx = clone_ctx(&engine.ctx);
1041 Ok(match app_jwt::mint(&ctx, &credentials) {
1042 Ok(jwt) => {
1043 engine
1044 .secrets
1045 .push(Zeroizing::new(jwt.clone().into_bytes()));
1046 if let Some(signature) = jwt.rsplit('.').next() {
1047 engine
1048 .secrets
1049 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1050 }
1051 engine.app_jwt = Some(jwt.clone());
1052 Ok(jwt)
1053 }
1054 Err(detail) => Err(detail),
1055 })
1056}
1057
1058fn state_detail(state: &StepState) -> String {
1059 match state {
1060 StepState::Satisfied { detail, .. }
1061 | StepState::Unsatisfied { detail }
1062 | StepState::Inapplicable { detail }
1063 | StepState::Unknown { detail } => detail.clone(),
1064 }
1065}
1066
1067fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1070 run_script_with(engine, step, None, Vec::new())
1071}
1072
1073fn run_script_with(
1079 engine: &mut Engine,
1080 step: &StepSpec,
1081 stdin: Option<Zeroizing<Vec<u8>>>,
1082 extra_env: Vec<(OsString, OsString)>,
1083) -> Result<(Outcome, PathBuf), RkError> {
1084 let rel = format!("{}/{}", engine.ctx.forge.as_str(), step.name);
1085 let bytes = embedded::SETUP
1086 .get_file(&rel)
1087 .map(include_dir::File::contents)
1088 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1089 let journal = engine
1090 .journal
1091 .as_mut()
1092 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1093 let dir = journal.scripts_dir().join(engine.ctx.forge.as_str());
1094 fs::create_dir_all(&dir)?;
1095 restrict(&dir, 0o700);
1096 let path = dir.join(step.name);
1097 fs::write(&path, bytes)?;
1098 restrict(&path, 0o600);
1099 let written = fs::read(&path)?;
1100 let digest = Digest::of(&written);
1101 if digest != Digest::of(bytes) {
1102 return Err(RkError::Other(anyhow::anyhow!(
1103 "the materialized script at {} differs from the embedded bytes",
1104 path.display()
1105 )));
1106 }
1107 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1108 let mut env = engine.ctx.child_env(step.name);
1109 env.extend(extra_env);
1110 let exec = Exec {
1111 program: "sh".into(),
1112 args: vec![path.clone().into_os_string()],
1113 env,
1114 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1115 stdin,
1116 };
1117 let outcome = engine.exec(&exec, true)?;
1118 Ok((outcome, path))
1119}
1120
1121fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1126 let stderr = String::from_utf8_lossy(&outcome.stderr);
1127 let last = if outcome.exit_code >= 128 {
1131 format!("killed by signal {}", outcome.exit_code - 128)
1132 } else {
1133 stderr
1134 .lines()
1135 .rev()
1136 .find(|line| !line.trim().is_empty())
1137 .unwrap_or("no output")
1138 .to_owned()
1139 };
1140 let reason = if (engine.ctx.forge == Forge::Github && outcome.exit_code == 4)
1141 || stderr.contains("HTTP 401")
1142 {
1143 Reason::ForgeAuthentication
1144 } else if stderr.contains("HTTP 403") {
1145 Reason::ForgePermission
1146 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1147 Reason::ForgeRateLimit
1148 } else {
1149 Reason::SubprocessFailed
1150 };
1151 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1152 .expected(step.proves.to_owned())
1153 .action(format!(
1154 "rk setup step {} --target {} --apply",
1155 step.name, engine.ctx.target
1156 ))
1157 .step(step.name);
1158 let diagnostic = match reason {
1159 Reason::ForgePermission => diagnostic.expected(format!(
1160 "repository administration write on {} for the authenticated account",
1161 engine.ctx.repo
1162 )),
1163 _ => diagnostic,
1164 };
1165 match reason {
1166 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1167 _ => RkError::refusal(diagnostic),
1168 }
1169}
1170
1171fn attach_progress(
1174 error: RkError,
1175 done: &[(String, String)],
1176 failed: &StepSpec,
1177 steps: &[&StepSpec],
1178) -> RkError {
1179 let remaining = steps.len().saturating_sub(done.len() + 1);
1180 let state = format!(
1181 "{} completed; {} failed; {remaining} not attempted",
1182 step_count(done.len()),
1183 failed.name
1184 );
1185 match error {
1186 RkError::Refusal(mut diagnostic) => {
1187 diagnostic.target_state.get_or_insert(state);
1188 RkError::Refusal(diagnostic)
1189 }
1190 RkError::Subprocess(mut diagnostic) => {
1191 diagnostic.target_state.get_or_insert(state);
1192 RkError::Subprocess(diagnostic)
1193 }
1194 other => other,
1195 }
1196}
1197
1198fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1202 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1203 let mut unsatisfied = 0usize;
1204 let mut unverifiable = 0usize;
1205 for step in &STEPS {
1206 let clock = Instant::now();
1207 let state = observe_with(&mut engine, step.name)?;
1208 let (label, wire) = match &state {
1209 StepState::Satisfied { .. } => ("ok", "satisfied"),
1210 StepState::Inapplicable { .. } => ("skipped", "skipped"),
1213 StepState::Unsatisfied { .. } => {
1214 unsatisfied += 1;
1215 ("unsatisfied", "unsatisfied")
1216 }
1217 StepState::Unknown { .. } => {
1220 unverifiable += 1;
1221 ("unknown", "unknown")
1222 }
1223 };
1224 let mut line = format!("{label} {} — {}", step.name, state_detail(&state));
1225 if let StepState::Satisfied {
1226 limitation: Some(limit),
1227 ..
1228 } = &state
1229 {
1230 use std::fmt::Write as _;
1231 let _ = write!(line, " (limitation: {limit})");
1232 }
1233 engine.out.result_line(line);
1234 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1235 finished.status = Some(wire.into());
1236 finished.duration_ms = Some(elapsed_ms(clock));
1237 engine.emit(&finished);
1238 }
1239 if unsatisfied > 0 || unverifiable > 0 {
1240 let error = RkError::check_failed(
1241 Diagnostic::new(
1242 Reason::StateDrift,
1243 format!(
1244 "{} {} not satisfied and {unverifiable} could not be verified",
1245 step_count(unsatisfied),
1246 if unsatisfied == 1 { "is" } else { "are" }
1247 ),
1248 )
1249 .expected("every step's proof column to hold and to be readable")
1250 .action(format!(
1251 "rk setup --target {} --apply re-asserts them",
1252 engine.ctx.target
1253 )),
1254 );
1255 return Err(fail(&mut engine, error));
1256 }
1257 engine
1258 .out
1259 .next(&["rk guide release orders the first release".to_owned()]);
1260 engine.finish(0, None);
1261 Ok(())
1262}
1263
1264fn restrict(path: &std::path::Path, mode: u32) {
1267 #[cfg(unix)]
1268 {
1269 use std::os::unix::fs::PermissionsExt as _;
1270 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1271 }
1272 #[cfg(not(unix))]
1273 let _ = (path, mode);
1274}
1275
1276fn guard_sh() -> Result<(), RkError> {
1279 let ok = std::process::Command::new("sh")
1280 .args(["-c", "exit 0"])
1281 .status()
1282 .is_ok_and(|status| status.success());
1283 if ok {
1284 Ok(())
1285 } else {
1286 Err(RkError::refusal(
1287 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1288 .expected("a working sh on PATH; every step spawns through it")
1289 .action("install a POSIX shell, then rerun")
1290 .target_state("nothing was run and nothing changed"),
1291 ))
1292 }
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297 #[test]
1300 fn a_step_count_carries_a_noun_that_agrees_with_it() {
1301 assert_eq!(super::step_count(0), "0 steps");
1302 assert_eq!(super::step_count(1), "1 step");
1303 assert_eq!(super::step_count(2), "2 steps");
1304 }
1305}