max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+328 insertions,
-19 deletions
| @@ -120,6 +120,15 @@ | |||
| 120 | 120 | const FIELD_CONFIRM: usize = 2; | |
| 121 | 121 | const ACCOUNT_FIELDS: usize = 3; | |
| 122 | 122 | ||
| 123 | + | /// Which slot of the hostname step has focus. | |
| 124 | + | /// | |
| 125 | + | /// Two, because the step carries the machine's name and the one question that | |
| 126 | + | /// would otherwise have been a step of its own. Folding the timezone in here | |
| 127 | + | /// rather than after it is what keeps the wizard at four screens. | |
| 128 | + | const SLOT_HOSTNAME: usize = 0; | |
| 129 | + | const SLOT_TIMEZONE: usize = 1; | |
| 130 | + | const HOSTNAME_SLOTS: usize = 2; | |
| 131 | + | ||
| 123 | 132 | /// Longest username `useradd` accepts. | |
| 124 | 133 | const USERNAME_MAX: usize = 32; | |
| 125 | 134 | ||
| @@ -231,6 +240,16 @@ | |||
| 231 | 240 | /// [`TextField`] until the plan is built, which keeps the number of | |
| 232 | 241 | /// places holding a readable copy to one. See [`Secret`]. | |
| 233 | 242 | pub username: Option<String>, | |
| 243 | + | /// Whether the install may ask the network where this machine is, to set | |
| 244 | + | /// the timezone from it. | |
| 245 | + | /// | |
| 246 | + | /// `false` unless the user ticked the box, and `Default` gives that for | |
| 247 | + | /// free. Off is the honest default because the lookup is not local: it | |
| 248 | + | /// sends this machine's address to a third party (see [`GEO_HOST`]), and an | |
| 249 | + | /// installer that did that unasked would be doing the thing Alloy exists | |
| 250 | + | /// not to do. Left off, the install sets no timezone at all and the system | |
| 251 | + | /// comes up UTC, which the hostname pane says on screen. | |
| 252 | + | pub locate_timezone: bool, | |
| 234 | 253 | } | |
| 235 | 254 | ||
| 236 | 255 | /// Where the installer mounts the target's root filesystem to configure it. | |
| @@ -585,19 +604,150 @@ | |||
| 585 | 604 | /// overshooting costs. | |
| 586 | 605 | const SALT_BYTES: usize = SALT_MAX / 4 * 3; | |
| 587 | 606 | ||
| 607 | + | // ---- timezone from location ---- | |
| 608 | + | ||
| 609 | + | /// Who the optional location lookup asks. | |
| 610 | + | /// | |
| 611 | + | /// geojs.io answers an unauthenticated GET with the caller's approximate | |
| 612 | + | /// location as JSON, one field of which is an IANA zone name. No key and no | |
| 613 | + | /// account, so nothing identifies the install beyond the request itself, which | |
| 614 | + | /// is still a request to a third party and is why the box that sends it starts | |
| 615 | + | /// unticked. | |
| 616 | + | /// | |
| 617 | + | /// Split from the path because the host is the part the checkbox names on | |
| 618 | + | /// screen: the user is being asked to accept a party, not a URL. Naming it once | |
| 619 | + | /// keeps the screen and the request from drifting apart. | |
| 620 | + | const GEO_HOST: &str = "get.geojs.io"; | |
| 621 | + | ||
| 622 | + | /// The endpoint on [`GEO_HOST`] that answers with a zone name. | |
| 623 | + | const GEO_PATH: &str = "/v1/ip/geo.json"; | |
| 624 | + | ||
| 625 | + | /// How long the lookup may take before the install gives up on it, in seconds. | |
| 626 | + | /// | |
| 627 | + | /// Bounded because of *when* it runs: the disk has already been wiped and | |
| 628 | + | /// deployed. A curl left waiting on a network that will never answer would hang | |
| 629 | + | /// the install at its least interruptible moment, so an absent network has to | |
| 630 | + | /// cost a few seconds and then be over. | |
| 631 | + | const GEO_TIMEOUT: &str = "10"; | |
| 632 | + | ||
| 633 | + | /// Where zone names live inside the target, relative to its deployment root. | |
| 634 | + | const ZONEINFO: &str = "usr/share/zoneinfo"; | |
| 635 | + | ||
| 636 | + | /// Ask the network where this machine is. | |
| 637 | + | /// | |
| 638 | + | /// curl rather than an HTTP client crate: it is already in the image, the rest | |
| 639 | + | /// of this file already works by running commands, and one optional GET does | |
| 640 | + | /// not justify carrying a TLS stack in the console binary. | |
| 641 | + | fn geo_lookup() -> Invocation { | |
| 642 | + | Invocation::new("curl") | |
| 643 | + | .args(["--silent", "--show-error", "--fail"]) | |
| 644 | + | .args(["--max-time", GEO_TIMEOUT]) | |
| 645 | + | .arg(format!("https://{GEO_HOST}{GEO_PATH}")) | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | /// The timezone field of a geo response, if it holds a usable one. | |
| 649 | + | /// | |
| 650 | + | /// `Option` rather than `Result` on purpose: every caller treats a failure the | |
| 651 | + | /// same way, by leaving the timezone unset, so there is no error worth | |
| 652 | + | /// distinguishing. A truncated body, a JSON object without the field, an empty | |
| 653 | + | /// string, and a 404 page all mean the same thing here. | |
| 654 | + | fn timezone_of(response: &str) -> Option<String> { | |
| 655 | + | #[derive(serde::Deserialize)] | |
| 656 | + | struct Geo { | |
| 657 | + | timezone: Option<String>, | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | let geo: Geo = serde_json::from_str(response).ok()?; | |
| 661 | + | let zone = geo.timezone?; | |
| 662 | + | is_zone_name(&zone).then_some(zone) | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | /// Whether a string is shaped like an IANA zone name. | |
| 666 | + | /// | |
| 667 | + | /// This is a network response on its way into a command line, so it is checked | |
| 668 | + | /// rather than trusted. [`Invocation`] passes arguments as argv and never | |
| 669 | + | /// through a shell, so the risk is not injection so much as a nonsense value | |
| 670 | + | /// reaching `systemd-firstboot`, which would fail a stage that runs *after* the | |
| 671 | + | /// disk is written. Cheaper to refuse it here. | |
| 672 | + | /// | |
| 673 | + | /// The accepted shape is what the zoneinfo tree actually contains: one or more | |
| 674 | + | /// slash-separated components of letters, digits, `_`, `-` and `+` | |
| 675 | + | /// (`America/Argentina/Buenos_Aires`, `Etc/GMT+5`). No leading or trailing | |
| 676 | + | /// slash, no empty component, and no `.` anywhere, which is what keeps `..` | |
| 677 | + | /// from walking out of the zoneinfo directory. | |
| 678 | + | fn is_zone_name(zone: &str) -> bool { | |
| 679 | + | !zone.is_empty() | |
| 680 | + | && zone.split('/').all(|part| { | |
| 681 | + | !part.is_empty() | |
| 682 | + | && part | |
| 683 | + | .chars() | |
| 684 | + | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+')) | |
| 685 | + | }) | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | /// Whether the target carries this zone. | |
| 689 | + | /// | |
| 690 | + | /// Checked against the installed tree rather than the live medium's, because | |
| 691 | + | /// the two are different filesystems and it is the target that has to resolve | |
| 692 | + | /// the name at boot. | |
| 693 | + | fn zone_available_in(root: &str, zone: &str) -> bool { | |
| 694 | + | std::path::Path::new(root) | |
| 695 | + | .join(ZONEINFO) | |
| 696 | + | .join(zone) | |
| 697 | + | .exists() | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | /// The stage that sets the timezone from the network, if asked for. | |
| 701 | + | /// | |
| 702 | + | /// Empty when the box was not ticked, which is the whole of the opt-in: no | |
| 703 | + | /// request is built, so none can be made. | |
| 704 | + | /// | |
| 705 | + | /// **The resolver never returns `Err`.** By the time it runs, `bootc install | |
| 706 | + | /// to-disk --wipe` has finished and the user's disk is gone. Failing the | |
| 707 | + | /// sequence over a timezone would abort a completed install for the least | |
| 708 | + | /// important answer it collected, and would read on screen as the install | |
| 709 | + | /// having failed. Every failure path — no network, a body that is not JSON, a | |
| 710 | + | /// zone the target does not carry — resolves to no stages, which leaves the | |
| 711 | + | /// system on UTC exactly as if the box had been left alone. | |
| 712 | + | fn timezone_stages(locate: bool, root: &str) -> Vec<Stage> { | |
| 713 | + | if !locate { | |
| 714 | + | return Vec::new(); | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | let root = root.to_string(); | |
| 718 | + | vec![Stage::Resolve { | |
| 719 | + | invocation: geo_lookup(), | |
| 720 | + | then: Box::new(move |response| { | |
| 721 | + | let Some(zone) = timezone_of(response).filter(|zone| zone_available_in(&root, zone)) | |
| 722 | + | else { | |
| 723 | + | return Ok(Vec::new()); | |
| 724 | + | }; | |
| 725 | + | Ok(vec![Stage::Run( | |
| 726 | + | Invocation::new("systemd-firstboot") | |
| 727 | + | .arg(format!("--root={root}")) | |
| 728 | + | .arg(format!("--timezone={zone}")) | |
| 729 | + | // Same reason as the hostname call: without --force | |
| 730 | + | // firstboot skips a setting the image already carries. | |
| 731 | + | .arg("--force"), | |
| 732 | + | )]) | |
| 733 | + | }), | |
| 734 | + | }] | |
| 735 | + | } | |
| 736 | + | ||
| 588 | 737 | /// The commands that configure an already-deployed system. | |
| 589 | 738 | /// | |
| 590 | 739 | /// `root` is the ostree deployment directory, not the mountpoint. See | |
| 591 | 740 | /// [`deployment_dir`] for why that distinction is the whole ballgame, and | |
| 592 | 741 | /// [`stateroot_var`] for the half of it that `--root` does not cover. | |
| 593 | 742 | /// | |
| 594 | - | /// [`Stage`]s rather than plain [`Invocation`]s because two of these depend on | |
| 595 | - | /// values that only exist once the ones before them have run: the account's | |
| 596 | - | /// numeric ids, and the password hash. | |
| 743 | + | /// [`Stage`]s rather than plain [`Invocation`]s because three of these depend | |
| 744 | + | /// on values that only exist once the ones before them have run: the account's | |
| 745 | + | /// numeric ids, the password hash, and the timezone the network was asked for. | |
| 597 | 746 | fn configure_plan( | |
| 598 | 747 | hostname: &str, | |
| 599 | 748 | username: &str, | |
| 600 | 749 | password: &str, | |
| 750 | + | locate_timezone: bool, | |
| 601 | 751 | root: &str, | |
| 602 | 752 | ) -> Result<Vec<Stage>, String> { | |
| 603 | 753 | let var = stateroot_var(root)?; | |
| @@ -612,7 +762,7 @@ | |||
| 612 | 762 | let ids_user = username.to_string(); | |
| 613 | 763 | let ids_home = home.clone(); | |
| 614 | 764 | ||
| 615 | - | Ok(vec![ | |
| 765 | + | let mut stages = vec![ | |
| 616 | 766 | // Before anything is written: a shell the target will not hand out | |
| 617 | 767 | // makes an account nobody can log into, and useradd will not catch it. | |
| 618 | 768 | Stage::Resolve { | |
| @@ -687,6 +837,14 @@ | |||
| 687 | 837 | .arg("--encrypted") | |
| 688 | 838 | .stdin(Secret::new(format!("{username}:{hash}\n"))), | |
| 689 | 839 | ), | |
| 840 | + | ]; | |
| 841 | + | ||
| 842 | + | // Last of the answers, and deliberately after the account: it is the only | |
| 843 | + | // stage that can decline to do anything, and the only one that touches the | |
| 844 | + | // network. Nothing below it depends on the result. | |
| 845 | + | stages.extend(timezone_stages(locate_timezone, root)); | |
| 846 | + | ||
| 847 | + | stages.extend([ | |
| 690 | 848 | // Upstream: "optional, but recommended to run as the penultimate step | |
| 691 | 849 | // before unmounting the target filesystem. This command will perform | |
| 692 | 850 | // some basic sanity checks and may also perform fixups on the target | |
| @@ -705,7 +863,9 @@ | |||
| 705 | 863 | // Leaving the target mounted would strand the filesystem dirty across | |
| 706 | 864 | // the reboot the user is about to perform. | |
| 707 | 865 | Stage::Run(Invocation::new("umount").arg(TARGET_MOUNT)), | |
| 708 | - | ]) | |
| 866 | + | ]); | |
| 867 | + | ||
| 868 | + | Ok(stages) | |
| 709 | 869 | } | |
| 710 | 870 | ||
| 711 | 871 | /// Where the installer ISO carries the image it installs. | |
| @@ -758,7 +918,13 @@ | |||
| 758 | 918 | /// while it partitions, and names the ostree deployment after a checksum that | |
| 759 | 919 | /// does not exist until the deploy finishes. Neither can be an argument written | |
| 760 | 920 | /// in advance, which is why this returns [`Stage`]s rather than a flat list. | |
| 761 | - | fn install_plan(disk: &str, hostname: &str, username: &str, password: &str) -> Vec<Stage> { | |
| 921 | + | fn install_plan( | |
| 922 | + | disk: &str, | |
| 923 | + | hostname: &str, | |
| 924 | + | username: &str, | |
| 925 | + | password: &str, | |
| 926 | + | locate_timezone: bool, | |
| 927 | + | ) -> Vec<Stage> { | |
| 762 | 928 | let hostname = hostname.to_string(); | |
| 763 | 929 | let username = username.to_string(); | |
| 764 | 930 | let password = password.to_string(); | |
| @@ -838,7 +1004,13 @@ | |||
| 838 | 1004 | if deployment.is_empty() { | |
| 839 | 1005 | return Err("ostree reported no current deployment".into()); | |
| 840 | 1006 | } | |
| 841 | - | configure_plan(&hostname, &username, &password, deployment) | |
| 1007 | + | configure_plan( | |
| 1008 | + | &hostname, | |
| 1009 | + | &username, | |
| 1010 | + | &password, | |
| 1011 | + | locate_timezone, | |
| 1012 | + | deployment, | |
| 1013 | + | ) | |
| 842 | 1014 | }), | |
| 843 | 1015 | }]) | |
| 844 | 1016 | }), | |
| @@ -1184,6 +1356,10 @@ | |||
| 1184 | 1356 | username: TextField, | |
| 1185 | 1357 | password: TextField, | |
| 1186 | 1358 | confirm: TextField, | |
| 1359 | + | /// Which of the two hostname-step slots has focus. | |
| 1360 | + | machine: FocusRing, | |
| 1361 | + | /// The checkbox, until the step is confirmed and it becomes an answer. | |
| 1362 | + | locate_timezone: bool, | |
| 1187 | 1363 | /// Which of the three account fields has focus. | |
| 1188 | 1364 | fields: FocusRing, | |
| 1189 | 1365 | answers: Answers, | |
| @@ -1218,6 +1394,8 @@ | |||
| 1218 | 1394 | username: TextField::new(), | |
| 1219 | 1395 | password: TextField::new(), | |
| 1220 | 1396 | confirm: TextField::new(), | |
| 1397 | + | machine: FocusRing::new(HOSTNAME_SLOTS), | |
| 1398 | + | locate_timezone: false, | |
| 1221 | 1399 | fields: FocusRing::new(ACCOUNT_FIELDS), | |
| 1222 | 1400 | answers: Answers::default(), | |
| 1223 | 1401 | error: None, | |
| @@ -1281,6 +1459,7 @@ | |||
| 1281 | 1459 | match validate_hostname(self.hostname.value()) { | |
| 1282 | 1460 | Ok(()) => { | |
| 1283 | 1461 | self.answers.hostname = Some(self.hostname.value().to_string()); | |
| 1462 | + | self.answers.locate_timezone = self.locate_timezone; | |
| 1284 | 1463 | self.error = None; | |
| 1285 | 1464 | self.steps.advance(); | |
| 1286 | 1465 | } | |
| @@ -1296,6 +1475,24 @@ | |||
| 1296 | 1475 | /// this step is on screen. | |
| 1297 | 1476 | fn edit_hostname(&mut self, key: KeyEvent) -> Flow { | |
| 1298 | 1477 | match key.code { | |
| 1478 | + | KeyCode::Tab | KeyCode::Down => self.machine.next(), | |
| 1479 | + | KeyCode::BackTab | KeyCode::Up => self.machine.prev(), | |
| 1480 | + | // Enter advances off the field and submits from the checkbox, which | |
| 1481 | + | // is the shape the account step already uses: on both screens the | |
| 1482 | + | // last slot is the one that commits. | |
| 1483 | + | KeyCode::Enter => { | |
| 1484 | + | if self.machine.current() == SLOT_TIMEZONE { | |
| 1485 | + | return self.name_machine(); | |
| 1486 | + | } | |
| 1487 | + | self.machine.next(); | |
| 1488 | + | } | |
| 1489 | + | KeyCode::Char(' ') if self.machine.current() == SLOT_TIMEZONE => { | |
| 1490 | + | self.locate_timezone = !self.locate_timezone; | |
| 1491 | + | } | |
| 1492 | + | // Everything else is the field's, and reaches it only while the | |
| 1493 | + | // field has focus. Typing into a checkbox should do nothing rather | |
| 1494 | + | // than edit a name that is not on screen under the caret. | |
| 1495 | + | _ if self.machine.current() == SLOT_TIMEZONE => {} | |
| 1299 | 1496 | KeyCode::Char(c) => self.hostname.insert(c), | |
| 1300 | 1497 | KeyCode::Backspace => self.hostname.backspace(), | |
| 1301 | 1498 | KeyCode::Delete => self.hostname.delete(), | |
| @@ -1303,7 +1500,6 @@ | |||
| 1303 | 1500 | KeyCode::Right => self.hostname.right(), | |
| 1304 | 1501 | KeyCode::Home => self.hostname.home(), | |
| 1305 | 1502 | KeyCode::End => self.hostname.end(), | |
| 1306 | - | KeyCode::Enter => return self.name_machine(), | |
| 1307 | 1503 | _ => {} | |
| 1308 | 1504 | } | |
| 1309 | 1505 | Flow::Continue | |
| @@ -1472,6 +1668,25 @@ | |||
| 1472 | 1668 | self.answers.username.as_deref().unwrap_or("-"), | |
| 1473 | 1669 | String::new(), | |
| 1474 | 1670 | ), | |
| 1671 | + | // Named on the summary whichever way it went. "UTC" is a decision | |
| 1672 | + | // the install is about to make, and a review screen that only | |
| 1673 | + | // listed the answers someone changed would hide the defaults it is | |
| 1674 | + | // asking them to confirm. | |
| 1675 | + | if self.answers.locate_timezone { | |
| 1676 | + | row( | |
| 1677 | + | theme, | |
| 1678 | + | "timezone", | |
| 1679 | + | "from location", | |
| 1680 | + | format!(" asks {GEO_HOST}"), | |
| 1681 | + | ) | |
| 1682 | + | } else { | |
| 1683 | + | row( | |
| 1684 | + | theme, | |
| 1685 | + | "timezone", | |
| 1686 | + | "UTC", | |
| 1687 | + | " change it with `alloy settings`".into(), | |
| 1688 | + | ) | |
| 1689 | + | }, | |
| 1475 | 1690 | Line::default(), | |
| 1476 | 1691 | Line::from(Span::styled( | |
| 1477 | 1692 | format!("Everything on {disk} will be erased."), | |
| @@ -1579,7 +1794,13 @@ | |||
| 1579 | 1794 | return Vec::new(); | |
| 1580 | 1795 | }; | |
| 1581 | 1796 | ||
| 1582 | - | install_plan(disk, hostname, username, password) | |
| 1797 | + | install_plan( | |
| 1798 | + | disk, | |
| 1799 | + | hostname, | |
| 1800 | + | username, | |
| 1801 | + | password, | |
| 1802 | + | self.answers.locate_timezone, | |
| 1803 | + | ) | |
| 1583 | 1804 | } | |
| 1584 | 1805 | ||
| 1585 | 1806 | /// The same commands, as the summary shows them. | |
| @@ -1605,21 +1826,65 @@ | |||
| 1605 | 1826 | fn render_hostname(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 1606 | 1827 | let (before, under, after) = self.hostname.split(); | |
| 1607 | 1828 | let caret = under.unwrap_or(' '); | |
| 1829 | + | let named = self.machine.is_focused(SLOT_HOSTNAME); | |
| 1608 | 1830 | ||
| 1609 | - | let lines = vec![ | |
| 1831 | + | let mut name = vec![ | |
| 1832 | + | if named { | |
| 1833 | + | text::bold(theme, "hostname ") | |
| 1834 | + | } else { | |
| 1835 | + | text::muted(theme, "hostname ") | |
| 1836 | + | }, | |
| 1837 | + | text::primary(theme, before.to_string()), | |
| 1838 | + | ]; | |
| 1839 | + | // Only the focused slot draws a caret, so the block cursor always says | |
| 1840 | + | // where typing lands. Same rule as the account step's three fields. | |
| 1841 | + | if named { | |
| 1842 | + | name.push(Span::styled( | |
| 1843 | + | caret.to_string(), | |
| 1844 | + | Style::default().add_modifier(Modifier::REVERSED), | |
| 1845 | + | )); | |
| 1846 | + | } else if let Some(c) = under { | |
| 1847 | + | name.push(text::primary(theme, c.to_string())); | |
| 1848 | + | } | |
| 1849 | + | name.push(text::primary(theme, after.to_string())); | |
| 1850 | + | ||
| 1851 | + | let ticked = self.machine.is_focused(SLOT_TIMEZONE); | |
| 1852 | + | let box_glyph = if self.locate_timezone { "[x]" } else { "[ ]" }; | |
| 1853 | + | let label = "set timezone from my location"; | |
| 1854 | + | ||
| 1855 | + | let mut lines = vec![ | |
| 1610 | 1856 | Line::from(text::muted(theme, "This machine's name on the network.")), | |
| 1611 | 1857 | Line::default(), | |
| 1858 | + | Line::from(name), | |
| 1859 | + | Line::default(), | |
| 1612 | 1860 | Line::from(vec![ | |
| 1613 | - | text::secondary(theme, "hostname "), | |
| 1614 | - | text::primary(theme, before.to_string()), | |
| 1615 | - | Span::styled( | |
| 1616 | - | caret.to_string(), | |
| 1617 | - | Style::default().add_modifier(Modifier::REVERSED), | |
| 1618 | - | ), | |
| 1619 | - | text::primary(theme, after.to_string()), | |
| 1861 | + | if ticked { | |
| 1862 | + | text::bold(theme, format!("{box_glyph} ")) | |
| 1863 | + | } else { | |
| 1864 | + | text::muted(theme, format!("{box_glyph} ")) | |
| 1865 | + | }, | |
| 1866 | + | if ticked { | |
| 1867 | + | text::bold(theme, label) | |
| 1868 | + | } else { | |
| 1869 | + | text::primary(theme, label) | |
| 1870 | + | }, | |
| 1620 | 1871 | ]), | |
| 1621 | 1872 | ]; | |
| 1622 | 1873 | ||
| 1874 | + | // What the box costs, on screen, next to the box. A checkbox whose | |
| 1875 | + | // consequence is a request to a third party has to say so where it is | |
| 1876 | + | // ticked, not in a manual nobody has read yet. | |
| 1877 | + | lines.push(Line::from(text::muted( | |
| 1878 | + | theme, | |
| 1879 | + | format!(" asks {GEO_HOST} where this machine is"), | |
| 1880 | + | ))); | |
| 1881 | + | if !self.locate_timezone { | |
| 1882 | + | lines.push(Line::from(text::muted( | |
| 1883 | + | theme, | |
| 1884 | + | " otherwise UTC, set it later with `alloy settings`", | |
| 1885 | + | ))); | |
| 1886 | + | } | |
| 1887 | + | ||
| 1623 | 1888 | frame.render_widget(Paragraph::new(lines), area); | |
| 1624 | 1889 | } | |
| 1625 | 1890 | ||
| @@ -1662,7 +1927,11 @@ | |||
| 1662 | 1927 | hint("enter", "choose"), | |
| 1663 | 1928 | hint("r", "refresh"), | |
| 1664 | 1929 | ], | |
| 1665 | - | Step::Hostname => vec![hint("enter", "confirm")], | |
| 1930 | + | Step::Hostname => vec![ | |
| 1931 | + | hint("tab", "field"), | |
| 1932 | + | hint("space", "toggle"), | |
| 1933 | + | hint("enter", "next"), | |
| 1934 | + | ], | |
| 1666 | 1935 | Step::Account => vec![hint("tab", "field"), hint("enter", "next")], | |
| 1667 | 1936 | Step::Summary => vec![hint("enter", "install")], | |
| 1668 | 1937 | }; | |
| @@ -2039,6 +2308,8 @@ | |||
| 2039 | 2308 | username: TextField::new(), | |
| 2040 | 2309 | password: TextField::new(), | |
| 2041 | 2310 | confirm: TextField::new(), | |
| 2311 | + | machine: FocusRing::new(HOSTNAME_SLOTS), | |
| 2312 | + | locate_timezone: false, | |
| 2042 | 2313 | fields: FocusRing::new(ACCOUNT_FIELDS), | |
| 2043 | 2314 | answers: Answers::default(), | |
| 2044 | 2315 | error: None, | |
| @@ -2159,6 +2430,7 @@ | |||
| 2159 | 2430 | } | |
| 2160 | 2431 | type_into(&mut view, "workshop", &mut log); | |
| 2161 | 2432 | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 2433 | + | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 2162 | 2434 | ||
| 2163 | 2435 | assert_eq!(view.answers.hostname.as_deref(), Some("workshop")); | |
| 2164 | 2436 | assert!(view.error.is_none()); | |
| @@ -2198,6 +2470,7 @@ | |||
| 2198 | 2470 | let (mut view, mut log) = at_hostname(); | |
| 2199 | 2471 | type_into(&mut view, "_", &mut log); | |
| 2200 | 2472 | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 2473 | + | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 2201 | 2474 | ||
| 2202 | 2475 | assert_eq!(view.step(), Step::Hostname); | |
| 2203 | 2476 | assert_eq!(view.answers.hostname, None); | |
| @@ -2273,6 +2546,10 @@ | |||
| 2273 | 2546 | ||
| 2274 | 2547 | fn at_account() -> (InstallView, CommandLog) { | |
| 2275 | 2548 | let (mut view, mut log) = at_hostname(); | |
| 2549 | + | // Twice: the step has two slots now, and Enter walks off the field | |
| 2550 | + | // before it submits. The checkbox is left alone, so this arrives at the | |
| 2551 | + | // account with the timezone answer at its default. | |
| 2552 | + | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 2276 | 2553 | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 2277 | 2554 | assert_eq!(view.step(), Step::Account, "stalled on the hostname"); | |
| 2278 | 2555 | (view, log) | |
| @@ -2489,8 +2766,11 @@ | |||
| 2489 | 2766 | const DEPLOYMENT: &str = "/mnt/alloy-target/ostree/deploy/default/deploy/abc123.0"; | |
| 2490 | 2767 | ||
| 2491 | 2768 | /// The configure half, against a deployment directory as discovered. | |
| 2769 | + | /// | |
| 2770 | + | /// Without the location lookup, which is the default and what almost every | |
| 2771 | + | /// assertion below is about. `configured_locating` is the other one. | |
| 2492 | 2772 | fn configured() -> Vec<String> { | |
| 2493 | - | configure_plan("workshop", "max", "hunter2", DEPLOYMENT) | |
| 2773 | + | configure_plan("workshop", "max", "hunter2", false, DEPLOYMENT) | |
| 2494 | 2774 | .expect("a well-formed deployment path") | |
| 2495 | 2775 | .iter() | |
| 2496 | 2776 | .map(Stage::display) | |
| @@ -2810,6 +3090,182 @@ | |||
| 2810 | 3090 | std::fs::remove_dir_all(&dir).unwrap(); | |
| 2811 | 3091 | } | |
| 2812 | 3092 | ||
| 3093 | + | // ---- timezone from location ---- | |
| 3094 | + | ||
| 3095 | + | /// A target root carrying one zone, so the availability check has something | |
| 3096 | + | /// true to find. Returns the root and the zone in it. | |
| 3097 | + | fn target_with_zone(name: &str, zone: &str) -> std::path::PathBuf { | |
| 3098 | + | let root = std::env::temp_dir().join(name); | |
| 3099 | + | std::fs::create_dir_all(root.join(ZONEINFO).join(zone)).unwrap(); | |
| 3100 | + | root | |
| 3101 | + | } | |
| 3102 | + | ||
| 3103 | + | /// Pull the resolver out of a one-stage plan and run it on `response`. | |
| 3104 | + | fn resolve(stages: Vec<Stage>, response: &str) -> Vec<String> { | |
| 3105 | + | let [stage] = <[Stage; 1]>::try_from(stages).ok().expect("one stage"); | |
| 3106 | + | let Stage::Resolve { then, .. } = stage else { | |
| 3107 | + | panic!("the lookup is not a discovery"); | |
| 3108 | + | }; | |
| 3109 | + | then(response) | |
| 3110 | + | .expect("the lookup refused to fail") | |
| 3111 | + | .iter() | |
| 3112 | + | .map(Stage::display) | |
| 3113 | + | .collect() | |
| 3114 | + | } | |
| 3115 | + | ||
| 3116 | + | // The opt-in, stated as the absence of a request. Not "the checkbox | |
| 3117 | + | // defaults to false" — that a plan built without it contains nothing that | |
| 3118 | + | // could reach the network. | |
| 3119 | + | #[test] | |
| 3120 | + | fn an_install_that_was_not_asked_does_not_look_anything_up() { | |
| 3121 | + | for line in configured() { |
Lines truncated