| 74 |
74 |
|
use alloy_tui::{Cursor, FocusRing};
|
| 75 |
75 |
|
|
| 76 |
76 |
|
use crate::cli::{CommandLog, Invocation, Secret};
|
|
77 |
+ |
use crate::preseed::{ANSWERS, Preseed};
|
| 77 |
78 |
|
use crate::profile::Profile;
|
| 78 |
79 |
|
use crate::recovery;
|
| 79 |
80 |
|
use crate::run::{Sequence, Stage};
|
| 530 |
531 |
|
pub encrypt: bool,
|
| 531 |
532 |
|
}
|
| 532 |
533 |
|
|
|
534 |
+ |
/// Which of the answers came from the medium rather than from a person.
|
|
535 |
+ |
///
|
|
536 |
+ |
/// Parallel to [`Answers`] and deliberately separate from it: `Answers` is what
|
|
537 |
+ |
/// the install will do, and this is who decided. Nothing in the plan reads it.
|
|
538 |
+ |
/// The review step does, because an answer nobody typed is the one most worth
|
|
539 |
+ |
/// naming on a screen whose whole job is "confirm this before a disk is erased".
|
|
540 |
+ |
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
541 |
+ |
pub(crate) struct FromMedium {
|
|
542 |
+ |
pub disk: bool,
|
|
543 |
+ |
pub hostname: bool,
|
|
544 |
+ |
pub username: bool,
|
|
545 |
+ |
pub pubkey: bool,
|
|
546 |
+ |
pub encrypt: bool,
|
|
547 |
+ |
pub locate_timezone: bool,
|
|
548 |
+ |
}
|
|
549 |
+ |
|
| 533 |
550 |
|
/// Where the installer mounts the target's root filesystem to configure it.
|
| 534 |
551 |
|
///
|
| 535 |
552 |
|
/// A directory the installer creates, not one bootc provides. Upstream is
|
| 3226 |
3243 |
|
password: TextField,
|
| 3227 |
3244 |
|
confirm: TextField,
|
| 3228 |
3245 |
|
pubkey: TextField,
|
| 3229 |
|
- |
/// Whether the key field arrived seeded from the medium.
|
|
3246 |
+ |
/// Which answers arrived from the medium rather than from a person.
|
| 3230 |
3247 |
|
///
|
| 3231 |
|
- |
/// Only to say so on screen. The field is editable either way, and what
|
|
3248 |
+ |
/// Only to say so on screen. Every seeded field stays editable, and what
|
| 3232 |
3249 |
|
/// gets planted is whatever it holds when the step is confirmed, so nothing
|
| 3233 |
|
- |
/// downstream reads this.
|
| 3234 |
|
- |
pubkey_from_medium: bool,
|
|
3250 |
+ |
/// downstream reads this. The review step does: an answer nobody typed is
|
|
3251 |
+ |
/// exactly the one worth naming, because the user is being asked to confirm
|
|
3252 |
+ |
/// a decision that was made somewhere else.
|
|
3253 |
+ |
from_medium: FromMedium,
|
|
3254 |
+ |
/// The answer sheet the medium carries, if it carries one.
|
|
3255 |
+ |
///
|
|
3256 |
+ |
/// Held rather than consumed at construction because the disk rule is
|
|
3257 |
+ |
/// re-resolved against the disk list, which refreshes.
|
|
3258 |
+ |
preseed: Preseed,
|
|
3259 |
+ |
/// A sheet that is present and wrong, or a disk rule that named no single
|
|
3260 |
+ |
/// disk.
|
|
3261 |
+ |
///
|
|
3262 |
+ |
/// Shown on the step it failed to answer. Both are non-fatal by design: the
|
|
3263 |
+ |
/// installer falls back to asking, and the one thing it must never do is
|
|
3264 |
+ |
/// fall back silently, because a builder whose recipe did nothing would have
|
|
3265 |
+ |
/// no way to find that out.
|
|
3266 |
+ |
medium_note: Option<String>,
|
|
3267 |
+ |
/// Which steps the user has stood on.
|
|
3268 |
+ |
///
|
|
3269 |
+ |
/// A step answered by the medium is skipped only while nobody has visited
|
|
3270 |
+ |
/// it. Stepping back into one hands it to the user, and it stops being
|
|
3271 |
+ |
/// skipped, so a prefilled answer can always be corrected. Without this,
|
|
3272 |
+ |
/// Esc from the account step would land on a step that immediately advanced
|
|
3273 |
+ |
/// again and the user could never reach the thing they wanted to change.
|
|
3274 |
+ |
visited: [bool; STEPS.len()],
|
| 3235 |
3275 |
|
/// Which of the two hostname-step slots has focus.
|
| 3236 |
3276 |
|
machine: FocusRing,
|
| 3237 |
3277 |
|
/// The checkbox, until the step is confirmed and it becomes an answer.
|
| 3331 |
3371 |
|
|
| 3332 |
3372 |
|
impl InstallView {
|
| 3333 |
3373 |
|
pub(crate) fn new(log: &mut CommandLog) -> Self {
|
|
3374 |
+ |
// The medium's answer sheet, before anything is seeded from it. A sheet
|
|
3375 |
+ |
// that is present and wrong becomes a note on screen rather than a
|
|
3376 |
+ |
// refusal to install: see `Preseed::load`.
|
|
3377 |
+ |
let (preseed, mut medium_note) = match Preseed::load(ANSWERS) {
|
|
3378 |
+ |
Ok(sheet) => (sheet.unwrap_or_default(), None),
|
|
3379 |
+ |
Err(message) => (Preseed::default(), Some(message)),
|
|
3380 |
+ |
};
|
|
3381 |
+ |
let mut from_medium = FromMedium::default();
|
|
3382 |
+ |
|
| 3334 |
3383 |
|
let mut hostname = TextField::new();
|
| 3335 |
3384 |
|
// Seeded rather than blank: the default is what most installs want, and
|
| 3336 |
3385 |
|
// a field arriving pre-filled says what shape of answer is expected.
|
| 3337 |
|
- |
hostname.set(DEFAULT_HOSTNAME);
|
|
3386 |
+ |
//
|
|
3387 |
+ |
// The sheet and `DEFAULT_HOSTNAME` are written by one Containerfile
|
|
3388 |
+ |
// step from one build argument, so they agree by construction; the sheet
|
|
3389 |
+ |
// is preferred anyway, because it is the file that says the question was
|
|
3390 |
+ |
// answered rather than merely defaulted.
|
|
3391 |
+ |
match &preseed.hostname {
|
|
3392 |
+ |
Some(name) => {
|
|
3393 |
+ |
hostname.set(name);
|
|
3394 |
+ |
from_medium.hostname = true;
|
|
3395 |
+ |
}
|
|
3396 |
+ |
None => hostname.set(DEFAULT_HOSTNAME),
|
|
3397 |
+ |
}
|
|
3398 |
+ |
|
|
3399 |
+ |
let mut username = TextField::new();
|
|
3400 |
+ |
if let Some(name) = &preseed.username {
|
|
3401 |
+ |
username.set(name);
|
|
3402 |
+ |
from_medium.username = true;
|
|
3403 |
+ |
}
|
| 3338 |
3404 |
|
|
| 3339 |
3405 |
|
// Seeded from the medium for the same reason the hostname is seeded
|
| 3340 |
3406 |
|
// from its default, and for a sharper one besides. On the headless flow
|
| 3350 |
3416 |
|
let baked = baked_pubkey_at(BAKED_KEY);
|
| 3351 |
3417 |
|
if let Some(key) = &baked {
|
| 3352 |
3418 |
|
pubkey.set(key);
|
|
3419 |
+ |
from_medium.pubkey = true;
|
|
3420 |
+ |
}
|
|
3421 |
+ |
|
|
3422 |
+ |
// The checkbox defaults, before the sheet has been near them. Both are
|
|
3423 |
+ |
// the offered default rather than the derived one: Alloy encrypts unless
|
|
3424 |
+ |
// told not to, and it looks nothing up over the network unless asked.
|
|
3425 |
+ |
let mut encrypt = true;
|
|
3426 |
+ |
if let Some(answer) = preseed.encrypt {
|
|
3427 |
+ |
encrypt = answer;
|
|
3428 |
+ |
from_medium.encrypt = true;
|
|
3429 |
+ |
}
|
|
3430 |
+ |
let mut locate_timezone = false;
|
|
3431 |
+ |
if let Some(answer) = preseed.locate_timezone {
|
|
3432 |
+ |
locate_timezone = answer;
|
|
3433 |
+ |
from_medium.locate_timezone = true;
|
| 3353 |
3434 |
|
}
|
| 3354 |
3435 |
|
|
| 3355 |
3436 |
|
let mut view = Self {
|
| 3358 |
3439 |
|
disks: Vec::new(),
|
| 3359 |
3440 |
|
cursor: Cursor::new(),
|
| 3360 |
3441 |
|
hostname,
|
| 3361 |
|
- |
username: TextField::new(),
|
|
3442 |
+ |
username,
|
| 3362 |
3443 |
|
password: TextField::new(),
|
| 3363 |
3444 |
|
confirm: TextField::new(),
|
| 3364 |
3445 |
|
pubkey,
|
| 3365 |
|
- |
pubkey_from_medium: baked.is_some(),
|
|
3446 |
+ |
from_medium,
|
|
3447 |
+ |
preseed,
|
|
3448 |
+ |
medium_note: medium_note.take(),
|
|
3449 |
+ |
visited: [false; STEPS.len()],
|
| 3366 |
3450 |
|
machine: FocusRing::new(HOSTNAME_SLOTS),
|
| 3367 |
|
- |
locate_timezone: false,
|
|
3451 |
+ |
locate_timezone,
|
| 3368 |
3452 |
|
fields: FocusRing::new(ACCOUNT_FIELDS),
|
| 3369 |
3453 |
|
passphrase: TextField::new(),
|
| 3370 |
3454 |
|
passphrase_confirm: TextField::new(),
|
| 3374 |
3458 |
|
recovery_ack: false,
|
| 3375 |
3459 |
|
reboot_pending: false,
|
| 3376 |
3460 |
|
disk_intact: Arc::new(AtomicBool::new(false)),
|
| 3377 |
|
- |
encrypt: true,
|
|
3461 |
+ |
encrypt,
|
| 3378 |
3462 |
|
secure_boot: secure_boot(),
|
| 3379 |
3463 |
|
profile: Profile::current(),
|
| 3380 |
3464 |
|
credits_scroll: 0,
|
| 3387 |
3471 |
|
blink: 0,
|
| 3388 |
3472 |
|
};
|
| 3389 |
3473 |
|
view.refresh(log);
|
|
3474 |
+ |
// The disk list has to exist before a disk rule can be resolved against
|
|
3475 |
+ |
// it, and the first step is the disk, so the skip runs here rather than
|
|
3476 |
+ |
// in the initialiser above.
|
|
3477 |
+ |
view.skip_answered();
|
| 3390 |
3478 |
|
view
|
| 3391 |
3479 |
|
}
|
| 3392 |
3480 |
|
|
| 3395 |
3483 |
|
STEPS[self.steps.current()]
|
| 3396 |
3484 |
|
}
|
| 3397 |
3485 |
|
|
|
3486 |
+ |
/// Move to the next question, then past any the medium already answered.
|
|
3487 |
+ |
///
|
|
3488 |
+ |
/// Every step's confirm goes through here rather than calling
|
|
3489 |
+ |
/// [`Steps::advance`] directly, so the skip cannot be forgotten on a step
|
|
3490 |
+ |
/// added later.
|
|
3491 |
+ |
fn advance(&mut self) {
|
|
3492 |
+ |
self.steps.advance();
|
|
3493 |
+ |
self.skip_answered();
|
|
3494 |
+ |
}
|
|
3495 |
+ |
|
|
3496 |
+ |
/// Walk forward over steps the medium answers in full.
|
|
3497 |
+ |
///
|
|
3498 |
+ |
/// Forward only, and never over a step the user has stood on. Those two
|
|
3499 |
+ |
/// rules are what keep a prefilled answer correctable: Esc walks back into
|
|
3500 |
+ |
/// a skipped step, which marks it visited, and from then on it is an
|
|
3501 |
+ |
/// ordinary question.
|
|
3502 |
+ |
///
|
|
3503 |
+ |
/// A step that is only partly answered is not skipped. The account step is
|
|
3504 |
+ |
/// the permanent example: the medium may carry the username and the key,
|
|
3505 |
+ |
/// and it never carries the password, so the step is shown with one field
|
|
3506 |
+ |
/// left to fill.
|
|
3507 |
+ |
fn skip_answered(&mut self) {
|
|
3508 |
+ |
while !self.visited[self.steps.current()] && self.answer_from_medium(self.step()) {
|
|
3509 |
+ |
if !self.steps.advance() {
|
|
3510 |
+ |
break;
|
|
3511 |
+ |
}
|
|
3512 |
+ |
}
|
|
3513 |
+ |
}
|
|
3514 |
+ |
|
|
3515 |
+ |
/// Take the medium's answer for one step, and say whether it answered it
|
|
3516 |
+ |
/// in full.
|
|
3517 |
+ |
///
|
|
3518 |
+ |
/// Writing into [`Answers`] here rather than at the step's own confirm is
|
|
3519 |
+ |
/// what makes a skipped step still produce an answer. The summary then reads
|
|
3520 |
+ |
/// the same fields whichever way they were filled, and marks the ones nobody
|
|
3521 |
+ |
/// typed.
|
|
3522 |
+ |
fn answer_from_medium(&mut self, step: Step) -> bool {
|
|
3523 |
+ |
match step {
|
|
3524 |
+ |
Step::Disk => {
|
|
3525 |
+ |
let Some(rule) = self.preseed.disk else {
|
|
3526 |
+ |
return false;
|
|
3527 |
+ |
};
|
|
3528 |
+ |
match rule.resolve(&self.disks) {
|
|
3529 |
+ |
Ok(path) => {
|
|
3530 |
+ |
self.answers.disk = Some(path);
|
|
3531 |
+ |
self.from_medium.disk = true;
|
|
3532 |
+ |
true
|
|
3533 |
+ |
}
|
|
3534 |
+ |
Err(failure) => {
|
|
3535 |
+ |
// Falling back to asking, and saying why. A recipe that
|
|
3536 |
+ |
// describes a machine this is not must never resolve to
|
|
3537 |
+ |
// "some disk"; it must resolve to a question.
|
|
3538 |
+ |
self.medium_note = Some(failure.reason(rule));
|
|
3539 |
+ |
false
|
|
3540 |
+ |
}
|
|
3541 |
+ |
}
|
|
3542 |
+ |
}
|
|
3543 |
+ |
Step::Hostname => {
|
|
3544 |
+ |
// Both of the step's answers or neither. The timezone checkbox
|
|
3545 |
+ |
// shares this screen, and skipping past an unanswered checkbox
|
|
3546 |
+ |
// would decide it by default while looking like the recipe had
|
|
3547 |
+ |
// decided it.
|
|
3548 |
+ |
let (Some(name), Some(locate)) =
|
|
3549 |
+ |
(self.preseed.hostname.clone(), self.preseed.locate_timezone)
|
|
3550 |
+ |
else {
|
|
3551 |
+ |
return false;
|
|
3552 |
+ |
};
|
|
3553 |
+ |
if let Err(message) = validate_hostname(&name) {
|
|
3554 |
+ |
self.medium_note =
|
|
3555 |
+ |
Some(format!("the medium's hostname is unusable: {message}"));
|
|
3556 |
+ |
return false;
|
|
3557 |
+ |
}
|
|
3558 |
+ |
self.answers.hostname = Some(name);
|
|
3559 |
+ |
self.answers.locate_timezone = locate;
|
|
3560 |
+ |
true
|
|
3561 |
+ |
}
|
|
3562 |
+ |
// Never skipped: the password is a secret and a secret is never on
|
|
3563 |
+ |
// the medium. The username and the key arrive seeded, so what is
|
|
3564 |
+ |
// left is the one field only a person can supply.
|
|
3565 |
+ |
Step::Account => false,
|
|
3566 |
+ |
Step::Encryption => {
|
|
3567 |
+ |
// `encrypt = false` answers the whole step, because an
|
|
3568 |
+ |
// unencrypted install has no second question. `encrypt = true`
|
|
3569 |
+ |
// answers only the checkbox: the passphrase is a secret, so the
|
|
3570 |
+ |
// step is shown with the decision already made and the secret
|
|
3571 |
+ |
// still to type.
|
|
3572 |
+ |
matches!(self.preseed.encrypt, Some(false)) && {
|
|
3573 |
+ |
self.answers.encrypt = false;
|
|
3574 |
+ |
true
|
|
3575 |
+ |
}
|
|
3576 |
+ |
}
|
|
3577 |
+ |
// Not questions. The review exists to be read before a disk is
|
|
3578 |
+ |
// erased, and skipping it would be the one prefill that removes a
|
|
3579 |
+ |
// safeguard rather than a keystroke.
|
|
3580 |
+ |
Step::Summary | Step::Credits => false,
|
|
3581 |
+ |
}
|
|
3582 |
+ |
}
|
|
3583 |
+ |
|
| 3398 |
3584 |
|
fn refresh(&mut self, log: &mut CommandLog) {
|
| 3399 |
3585 |
|
match self.backend.list(log) {
|
| 3400 |
3586 |
|
Ok(disks) => {
|
| 3430 |
3616 |
|
|
| 3431 |
3617 |
|
self.answers.disk = Some(disk.path.clone());
|
| 3432 |
3618 |
|
self.error = None;
|
| 3433 |
|
- |
self.steps.advance();
|
|
3619 |
+ |
self.advance();
|
| 3434 |
3620 |
|
Flow::Continue
|
| 3435 |
3621 |
|
}
|
| 3436 |
3622 |
|
|
| 3445 |
3631 |
|
self.answers.hostname = Some(self.hostname.value().to_string());
|
| 3446 |
3632 |
|
self.answers.locate_timezone = self.locate_timezone;
|
| 3447 |
3633 |
|
self.error = None;
|
| 3448 |
|
- |
self.steps.advance();
|
|
3634 |
+ |
self.advance();
|
| 3449 |
3635 |
|
}
|
| 3450 |
3636 |
|
Err(message) => self.error = Some(message),
|
| 3451 |
3637 |
|
}
|
| 3538 |
3724 |
|
let key = self.pubkey.value().trim();
|
| 3539 |
3725 |
|
self.answers.pubkey = (!key.is_empty()).then(|| key.to_string());
|
| 3540 |
3726 |
|
self.error = None;
|
| 3541 |
|
- |
self.steps.advance();
|
|
3727 |
+ |
self.advance();
|
| 3542 |
3728 |
|
Flow::Continue
|
| 3543 |
3729 |
|
}
|
| 3544 |
3730 |
|
|
| 3601 |
3787 |
|
|
| 3602 |
3788 |
|
self.answers.encrypt = self.encrypt;
|
| 3603 |
3789 |
|
self.error = None;
|
| 3604 |
|
- |
self.steps.advance();
|
|
3790 |
+ |
self.advance();
|
| 3605 |
3791 |
|
Flow::Continue
|
| 3606 |
3792 |
|
}
|
| 3607 |
3793 |
|
|
| 3796 |
3982 |
|
"or later from the console. A machine with no console is a",
|
| 3797 |
3983 |
|
"machine nobody can reach.",
|
| 3798 |
3984 |
|
]
|
| 3799 |
|
- |
} else if self.pubkey_from_medium {
|
|
3985 |
+ |
} else if self.from_medium.pubkey {
|
| 3800 |
3986 |
|
// Said because a field that filled itself in is a field people
|
| 3801 |
3987 |
|
// scroll past. Where it came from is also the check worth making:
|
| 3802 |
3988 |
|
// if this is not the key you expect, the medium is not the one you
|
| 4014 |
4200 |
|
])
|
| 4015 |
4201 |
|
}
|
| 4016 |
4202 |
|
|
|
4203 |
+ |
/// Mark a row whose answer nobody typed.
|
|
4204 |
+ |
///
|
|
4205 |
+ |
/// This screen exists to be checked, and a row that was answered
|
|
4206 |
+ |
/// somewhere else is the one a person cannot check from memory. Said on
|
|
4207 |
+ |
/// every such row rather than once at the top, because a summary with a
|
|
4208 |
+ |
/// banner still leaves the reader working out which lines it applies to.
|
|
4209 |
+ |
fn sourced(note: String, from_medium: bool) -> String {
|
|
4210 |
+ |
if from_medium {
|
|
4211 |
+ |
format!("{note} from the medium")
|
|
4212 |
+ |
} else {
|
|
4213 |
+ |
note
|
|
4214 |
+ |
}
|
|
4215 |
+ |
}
|
|
4216 |
+ |
|
| 4017 |
4217 |
|
let disk = self.answers.disk.as_deref().unwrap_or("-");
|
| 4018 |
4218 |
|
let detail = match self.disks.iter().find(|d| d.path == disk) {
|
| 4019 |
4219 |
|
Some(d) => format!(" {} {}", format_size(d.size), d.model_or_dash()),
|
| 4021 |
4221 |
|
};
|
| 4022 |
4222 |
|
|
| 4023 |
4223 |
|
let mut lines = vec![
|
| 4024 |
|
- |
row(theme, "disk", disk, detail),
|
|
4224 |
+ |
row(theme, "disk", disk, sourced(detail, self.from_medium.disk)),
|
| 4025 |
4225 |
|
row(
|
| 4026 |
4226 |
|
theme,
|
| 4027 |
4227 |
|
"hostname",
|
| 4028 |
4228 |
|
self.answers.hostname.as_deref().unwrap_or("-"),
|
| 4029 |
|
- |
String::new(),
|
|
4229 |
+ |
sourced(String::new(), self.from_medium.hostname),
|
| 4030 |
4230 |
|
),
|
| 4031 |
4231 |
|
row(
|
| 4032 |
4232 |
|
theme,
|
| 4033 |
4233 |
|
"account",
|
| 4034 |
4234 |
|
self.answers.username.as_deref().unwrap_or("-"),
|
| 4035 |
|
- |
String::new(),
|
|
4235 |
+ |
sourced(String::new(), self.from_medium.username),
|
| 4036 |
4236 |
|
),
|
| 4037 |
4237 |
|
// Same rule as the timezone below: named whichever way it went. The
|
| 4038 |
4238 |
|
// no-key case is the one worth a review screen, since it decides
|
| 4039 |
4239 |
|
// whether this machine is reachable at all, so it says what it means
|
| 4040 |
4240 |
|
// rather than printing a dash.
|
| 4041 |
4241 |
|
match self.answers.pubkey.as_deref() {
|
| 4042 |
|
- |
Some(key) => row(theme, "ssh key", &pubkey_summary(key), String::new()),
|
|
4242 |
+ |
Some(key) => row(
|
|
4243 |
+ |
theme,
|
|
4244 |
+ |
"ssh key",
|
|
4245 |
+ |
&pubkey_summary(key),
|
|
4246 |
+ |
sourced(String::new(), self.from_medium.pubkey),
|
|
4247 |
+ |
),
|
| 4043 |
4248 |
|
None => row(
|
| 4044 |
4249 |
|
theme,
|
| 4045 |
4250 |
|
"ssh key",
|
| 4056 |
4261 |
|
theme,
|
| 4057 |
4262 |
|
"encryption",
|
| 4058 |
4263 |
|
"on",
|
| 4059 |
|
- |
" TPM, passphrase, recovery phrase".into(),
|
|
4264 |
+ |
sourced(
|
|
4265 |
+ |
" TPM, passphrase, recovery phrase".into(),
|
|
4266 |
+ |
self.from_medium.encrypt,
|
|
4267 |
+ |
),
|
| 4060 |
4268 |
|
)
|
| 4061 |
4269 |
|
} else {
|
| 4062 |
4270 |
|
row(
|
| 4063 |
4271 |
|
theme,
|
| 4064 |
4272 |
|
"encryption",
|
| 4065 |
4273 |
|
"off",
|
| 4066 |
|
- |
" cannot be added without reinstalling".into(),
|
|
4274 |
+ |
sourced(
|
|
4275 |
+ |
" cannot be added without reinstalling".into(),
|
|
4276 |
+ |
self.from_medium.encrypt,
|
|
4277 |
+ |
),
|
| 4067 |
4278 |
|
)
|
| 4068 |
4279 |
|
},
|
| 4069 |
4280 |
|
// Named on the summary whichever way it went. "UTC" is a decision
|
| 4075 |
4286 |
|
theme,
|
| 4076 |
4287 |
|
"timezone",
|
| 4077 |
4288 |
|
"from location",
|
| 4078 |
|
- |
format!(" asks {GEO_HOST}"),
|
|
4289 |
+ |
sourced(
|
|
4290 |
+ |
format!(" asks {GEO_HOST}"),
|
|
4291 |
+ |
self.from_medium.locate_timezone,
|
|
4292 |
+ |
),
|
| 4079 |
4293 |
|
)
|
| 4080 |
4294 |
|
} else {
|
| 4081 |
4295 |
|
row(
|
| 4082 |
4296 |
|
theme,
|
| 4083 |
4297 |
|
"timezone",
|
| 4084 |
4298 |
|
"UTC",
|
| 4085 |
|
- |
" change it with `alloy settings`".into(),
|
|
4299 |
+ |
sourced(
|
|
4300 |
+ |
" change it with `alloy settings`".into(),
|
|
4301 |
+ |
self.from_medium.locate_timezone,
|
|
4302 |
+ |
),
|
| 4086 |
4303 |
|
)
|
| 4087 |
4304 |
|
},
|
| 4088 |
4305 |
|
];
|
| 4793 |
5010 |
|
if let Some(message) = &self.error {
|
| 4794 |
5011 |
|
return Some((Severity::Error, message.clone()));
|
| 4795 |
5012 |
|
}
|
|
5013 |
+ |
// Below an error and above the target, because it explains why a
|
|
5014 |
+ |
// question is being asked rather than reporting something the user did.
|
|
5015 |
+ |
// A medium whose answers did not apply has to say so: an installer that
|
|
5016 |
+ |
// silently asked everything would look exactly like one that was never
|
|
5017 |
+ |
// given a recipe, and the builder would have no way to tell the
|
|
5018 |
+ |
// difference from here.
|
|
5019 |
+ |
if let Some(note) = &self.medium_note {
|
|
5020 |
+ |
return Some((Severity::Warn, note.clone()));
|
|
5021 |
+ |
}
|
| 4796 |
5022 |
|
self.answers
|
| 4797 |
5023 |
|
.disk
|
| 4798 |
5024 |
|
.as_ref()
|
| 4879 |
5105 |
|
// screen apart is the whole of what moved here.
|
| 4880 |
5106 |
|
Step::Summary => {
|
| 4881 |
5107 |
|
if key.code == KeyCode::Enter {
|
| 4882 |
|
- |
self.steps.advance();
|
|
5108 |
+ |
self.advance();
|
| 4883 |
5109 |
|
}
|
| 4884 |
5110 |
|
return Flow::Continue;
|
| 4885 |
5111 |
|
}
|
| 5031 |
5257 |
|
};
|
| 5032 |
5258 |
|
}
|
| 5033 |
5259 |
|
if self.steps.back() {
|
|
5260 |
+ |
// Stepping into a skipped step hands it to the user. Without this
|
|
5261 |
+ |
// the skip would fire again on the way forward and the answer could
|
|
5262 |
+ |
// never be corrected.
|
|
5263 |
+ |
self.visited[self.steps.current()] = true;
|
| 5034 |
5264 |
|
self.error = None;
|
| 5035 |
5265 |
|
Flow::Continue
|
| 5036 |
5266 |
|
} else {
|
| 5338 |
5568 |
|
password: TextField::new(),
|
| 5339 |
5569 |
|
confirm: TextField::new(),
|
| 5340 |
5570 |
|
pubkey: TextField::new(),
|
| 5341 |
|
- |
pubkey_from_medium: false,
|
|
5571 |
+ |
from_medium: FromMedium::default(),
|
|
5572 |
+ |
preseed: Preseed::default(),
|
|
5573 |
+ |
medium_note: None,
|
|
5574 |
+ |
visited: [false; STEPS.len()],
|
| 5342 |
5575 |
|
machine: FocusRing::new(HOSTNAME_SLOTS),
|
| 5343 |
5576 |
|
locate_timezone: false,
|
| 5344 |
5577 |
|
fields: FocusRing::new(ACCOUNT_FIELDS),
|
| 5367 |
5600 |
|
(view, CommandLog::new())
|
| 5368 |
5601 |
|
}
|
| 5369 |
5602 |
|
|
|
5603 |
+ |
use crate::preseed::DiskRule;
|
|
5604 |
+ |
|
|
5605 |
+ |
/// A view whose disks and answer sheet are both the test's own.
|
|
5606 |
+ |
///
|
|
5607 |
+ |
/// The shared fixture's only internal disk is mounted, which is correct for
|
|
5608 |
+ |
/// what that fixture is for and useless here: every rule below would resolve
|
|
5609 |
+ |
/// to "no match" and the skips would never fire.
|
|
5610 |
+ |
fn preseeded(sheet: Preseed, disks: Vec<Disk>) -> InstallView {
|
|
5611 |
+ |
let (mut view, _log) = view();
|
|
5612 |
+ |
view.disks = disks;
|
|
5613 |
+ |
view.cursor.resize(view.disks.len());
|
|
5614 |
+ |
if let Some(name) = &sheet.hostname {
|
|
5615 |
+ |
view.hostname.set(name);
|
|
5616 |
+ |
view.from_medium.hostname = true;
|
|
5617 |
+ |
}
|
|
5618 |
+ |
if let Some(name) = &sheet.username {
|
|
5619 |
+ |
view.username.set(name);
|
|
5620 |
+ |
view.from_medium.username = true;
|
|
5621 |
+ |
}
|
|
5622 |
+ |
if let Some(answer) = sheet.encrypt {
|
|
5623 |
+ |
view.encrypt = answer;
|
|
5624 |
+ |
view.from_medium.encrypt = true;
|
|
5625 |
+ |
}
|
|
5626 |
+ |
if let Some(answer) = sheet.locate_timezone {
|
|
5627 |
+ |
view.locate_timezone = answer;
|
|
5628 |
+ |
view.from_medium.locate_timezone = true;
|
|
5629 |
+ |
}
|
|
5630 |
+ |
view.preseed = sheet;
|
|
5631 |
+ |
view.skip_answered();
|
|
5632 |
+ |
view
|
|
5633 |
+ |
}
|
|
5634 |
+ |
|
|
5635 |
+ |
fn target(name: &str, transport: Option<&str>) -> Disk {
|
|
5636 |
+ |
Disk {
|
|
5637 |
+ |
path: format!("/dev/{name}"),
|
|
5638 |
+ |
name: name.to_string(),
|
|
5639 |
+ |
size: 2_048_408_248_320,
|
|
5640 |
+ |
model: Some("WD_BLACK SN7100 2TB".into()),
|
|
5641 |
+ |
removable: false,
|
|
5642 |
+ |
transport: transport.map(str::to_string),
|
|
5643 |
+ |
read_only: false,
|
|
5644 |
+ |
mountpoints: Vec::new(),
|
|
5645 |
+ |
}
|
|
5646 |
+ |
}
|
|
5647 |
+ |
|
|
5648 |
+ |
fn fw12_sheet() -> Preseed {
|
|
5649 |
+ |
Preseed {
|
|
5650 |
+ |
hostname: Some("fw12".into()),
|