]> git.proxmox.com Git - pve-installer.git/blob - proxmox-tui-installer/src/main.rs
tui: simplify installer data path construction
[pve-installer.git] / proxmox-tui-installer / src / main.rs
1 #![forbid(unsafe_code)]
2
3 use std::{collections::HashMap, env, net::IpAddr, path::PathBuf};
4
5 use cursive::{
6 event::Event,
7 view::{Nameable, Resizable, ViewWrapper},
8 views::{
9 Button, Checkbox, Dialog, DummyView, EditView, LinearLayout, PaddedView, Panel,
10 ProgressBar, ResizedView, ScrollView, SelectView, TextContent, TextView, ViewRef,
11 },
12 Cursive, CursiveRunnable, ScreenId, View,
13 };
14
15 mod options;
16 use options::*;
17
18 mod setup;
19 use setup::{LocaleInfo, ProxmoxProduct, RuntimeInfo, SetupInfo};
20
21 mod system;
22
23 mod utils;
24 use utils::Fqdn;
25
26 mod views;
27 use views::{
28 BootdiskOptionsView, CidrAddressEditView, FormView, TableView, TableViewItem,
29 TimezoneOptionsView,
30 };
31
32 // TextView::center() seems to garble the first two lines, so fix it manually here.
33 const LOGO_PVE: &str = r#"
34 ____ _ __ _____
35 / __ \_________ _ ______ ___ ____ _ __ | | / / ____/
36 / /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ | | / / __/
37 / ____/ / / /_/ /> </ / / / / / /_/ /> < | |/ / /___
38 /_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| |___/_____/
39 "#;
40
41 const LOGO_PBS: &str = r#"
42 ____ ____ _____
43 / __ \_________ _ ______ ___ ____ _ __ / __ ) ___/
44 / /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ / __ \__ \
45 / ____/ / / /_/ /> </ / / / / / /_/ /> < / /_/ /__/ /
46 /_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| /_____/____/
47 "#;
48
49 const LOGO_PMG: &str = r#"
50 ____ __ _________
51 / __ \_________ _ ______ ___ ____ _ __ / |/ / ____/
52 / /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ / /|_/ / / __
53 / ____/ / / /_/ /> </ / / / / / /_/ /> < / / / / /_/ /
54 /_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| /_/ /_/\____/
55 "#;
56
57 struct InstallerView {
58 view: ResizedView<LinearLayout>,
59 }
60
61 impl InstallerView {
62 pub fn new<T: View>(
63 state: &InstallerState,
64 view: T,
65 next_cb: Box<dyn Fn(&mut Cursive)>,
66 ) -> Self {
67 let inner = LinearLayout::vertical()
68 .child(PaddedView::lrtb(0, 0, 1, 1, view))
69 .child(PaddedView::lrtb(
70 1,
71 1,
72 0,
73 0,
74 LinearLayout::horizontal()
75 .child(abort_install_button())
76 .child(DummyView.full_width())
77 .child(Button::new("Previous", switch_to_prev_screen))
78 .child(DummyView)
79 .child(Button::new("Next", next_cb)),
80 ));
81
82 Self::with_raw(state, inner)
83 }
84
85 pub fn with_raw(state: &InstallerState, view: impl View) -> Self {
86 let setup = &state.setup_info;
87
88 let logo = match setup.config.product {
89 ProxmoxProduct::PVE => LOGO_PVE,
90 ProxmoxProduct::PBS => LOGO_PBS,
91 ProxmoxProduct::PMG => LOGO_PMG,
92 };
93
94 let title = format!(
95 "{} ({}-{}) Installer",
96 setup.config.fullname, setup.iso_info.release, setup.iso_info.isorelease
97 );
98
99 let inner = LinearLayout::vertical()
100 .child(PaddedView::lrtb(1, 1, 0, 1, TextView::new(logo).center()))
101 .child(Dialog::around(view).title(title));
102
103 Self {
104 // Limit the maximum to something reasonable, such that it won't get spread out much
105 // depending on the screen.
106 view: ResizedView::with_max_size((120, 40), inner),
107 }
108 }
109 }
110
111 impl ViewWrapper for InstallerView {
112 cursive::wrap_impl!(self.view: ResizedView<LinearLayout>);
113 }
114
115 #[derive(Clone, Eq, Hash, PartialEq)]
116 enum InstallerStep {
117 Licence,
118 Bootdisk,
119 Timezone,
120 Password,
121 Network,
122 Summary,
123 Install,
124 }
125
126 #[derive(Clone)]
127 struct InstallerState {
128 options: InstallerOptions,
129 available_disks: Vec<Disk>,
130 setup_info: SetupInfo,
131 runtime_info: RuntimeInfo,
132 locales: LocaleInfo,
133 steps: HashMap<InstallerStep, ScreenId>,
134 in_test_mode: bool,
135 }
136
137 fn main() {
138 let mut siv = cursive::termion();
139
140 let in_test_mode = match env::args().nth(1).as_deref() {
141 Some("-t") => true,
142
143 // Always force the test directory in debug builds
144 #[cfg(debug_assertions)]
145 _ => true,
146
147 #[cfg(not(debug_assertions))]
148 _ => false,
149 };
150
151 let (setup_info, locales, runtime_info) = match installer_setup(in_test_mode) {
152 Ok(result) => result,
153 Err(err) => initial_setup_error(&mut siv, &err),
154 };
155
156 siv.clear_global_callbacks(Event::CtrlChar('c'));
157 siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog);
158
159 let available_disks: Vec<Disk> = runtime_info
160 .disks
161 .iter()
162 .map(|(name, info)| Disk {
163 path: format!("/dev/{name}"),
164 size: info.size,
165 })
166 .collect();
167
168 siv.set_user_data(InstallerState {
169 options: InstallerOptions {
170 bootdisk: BootdiskOptions::defaults_from(&available_disks[0]),
171 timezone: TimezoneOptions::default(),
172 password: PasswordOptions::default(),
173 network: NetworkOptions::default(),
174 reboot: false,
175 },
176 available_disks,
177 setup_info,
178 runtime_info,
179 locales,
180 steps: HashMap::new(),
181 in_test_mode,
182 });
183
184 switch_to_next_screen(&mut siv, InstallerStep::Licence, &license_dialog);
185 siv.run();
186 }
187
188 fn installer_setup(in_test_mode: bool) -> Result<(SetupInfo, LocaleInfo, RuntimeInfo), String> {
189 system::has_min_requirements()?;
190
191 let base_path = if in_test_mode { "./testdir" } else { "/" };
192 let mut path = PathBuf::from(base_path);
193
194 path.push("run");
195 path.push("proxmox-installer");
196
197 let installer_info = {
198 let mut path = path.clone();
199 path.push("iso-info.json");
200
201 setup::read_json(&path).map_err(|err| format!("Failed to retrieve setup info: {err}"))?
202 };
203
204 let locale_info = {
205 let mut path = path.clone();
206 path.push("locales.json");
207
208 setup::read_json(&path).map_err(|err| format!("Failed to retrieve locale info: {err}"))?
209 };
210
211 let runtime_info = {
212 let mut path = path.clone();
213 path.push("run-env-info.json");
214
215 setup::read_json(&path)
216 .map_err(|err| format!("Failed to retrieve runtime environment info: {err}"))?
217 };
218
219 Ok((installer_info, locale_info, runtime_info))
220 }
221
222 fn initial_setup_error(siv: &mut CursiveRunnable, message: &str) -> ! {
223 siv.add_layer(
224 Dialog::around(TextView::new(message))
225 .title("Installer setup error")
226 .button("Ok", Cursive::quit),
227 );
228 siv.run();
229
230 std::process::exit(1);
231 }
232
233 fn switch_to_next_screen(
234 siv: &mut Cursive,
235 step: InstallerStep,
236 constructor: &dyn Fn(&mut Cursive) -> InstallerView,
237 ) {
238 // Check if the screen already exists; if yes, then simply switch to it.
239 if let Some(state) = siv.user_data::<InstallerState>().cloned() {
240 if let Some(screen_id) = state.steps.get(&step) {
241 siv.set_screen(*screen_id);
242 return;
243 }
244 }
245
246 let v = constructor(siv);
247 let screen = siv.add_active_screen();
248 siv.with_user_data(|state: &mut InstallerState| state.steps.insert(step, screen));
249 siv.screen_mut().add_layer(v);
250 }
251
252 fn switch_to_prev_screen(siv: &mut Cursive) {
253 let id = siv.active_screen().saturating_sub(1);
254 siv.set_screen(id);
255 }
256
257 #[cfg(not(debug_assertions))]
258 fn yes_no_dialog(
259 siv: &mut Cursive,
260 title: &str,
261 text: &str,
262 callback: &'static dyn Fn(&mut Cursive),
263 ) {
264 siv.add_layer(
265 Dialog::around(TextView::new(text))
266 .title(title)
267 .dismiss_button("No")
268 .button("Yes", callback),
269 )
270 }
271
272 fn trigger_abort_install_dialog(siv: &mut Cursive) {
273 #[cfg(debug_assertions)]
274 siv.quit();
275
276 #[cfg(not(debug_assertions))]
277 yes_no_dialog(
278 siv,
279 "Abort installation?",
280 "Are you sure you want to abort the installation?",
281 &Cursive::quit,
282 )
283 }
284
285 fn abort_install_button() -> Button {
286 Button::new("Abort", trigger_abort_install_dialog)
287 }
288
289 fn get_eula() -> String {
290 // TODO: properly using info from Proxmox::Install::Env::setup()
291 std::fs::read_to_string("/cdrom/EULA")
292 .unwrap_or_else(|_| "< Debug build - ignoring non-existing EULA >".to_owned())
293 }
294
295 fn license_dialog(siv: &mut Cursive) -> InstallerView {
296 let state = siv.user_data::<InstallerState>().unwrap();
297
298 let inner = LinearLayout::vertical()
299 .child(PaddedView::lrtb(
300 0,
301 0,
302 1,
303 0,
304 TextView::new("END USER LICENSE AGREEMENT (EULA)").center(),
305 ))
306 .child(Panel::new(ScrollView::new(
307 TextView::new(get_eula()).center(),
308 )))
309 .child(PaddedView::lrtb(
310 1,
311 1,
312 1,
313 0,
314 LinearLayout::horizontal()
315 .child(abort_install_button())
316 .child(DummyView.full_width())
317 .child(Button::new("I agree", |siv| {
318 switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog)
319 })),
320 ));
321
322 InstallerView::with_raw(state, inner)
323 }
324
325 fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
326 let state = siv.user_data::<InstallerState>().cloned().unwrap();
327
328 InstallerView::new(
329 &state,
330 BootdiskOptionsView::new(&state.available_disks, &state.options.bootdisk)
331 .with_name("bootdisk-options"),
332 Box::new(|siv| {
333 let options = siv
334 .call_on_name("bootdisk-options", BootdiskOptionsView::get_values)
335 .flatten();
336
337 if let Some(options) = options {
338 siv.with_user_data(|state: &mut InstallerState| {
339 state.options.bootdisk = options;
340 });
341
342 switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog);
343 } else {
344 siv.add_layer(Dialog::info("Invalid values"));
345 }
346 }),
347 )
348 }
349
350 fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
351 let state = siv.user_data::<InstallerState>().unwrap();
352 let options = &state.options.timezone;
353
354 InstallerView::new(
355 state,
356 TimezoneOptionsView::new(&state.locales, options).with_name("timezone-options"),
357 Box::new(|siv| {
358 let options = siv.call_on_name("timezone-options", TimezoneOptionsView::get_values);
359
360 match options {
361 Some(Ok(options)) => {
362 siv.with_user_data(|state: &mut InstallerState| {
363 state.options.timezone = options;
364 });
365
366 switch_to_next_screen(siv, InstallerStep::Password, &password_dialog);
367 }
368 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
369 _ => siv.add_layer(Dialog::info("Invalid values")),
370 }
371 }),
372 )
373 }
374
375 fn password_dialog(siv: &mut Cursive) -> InstallerView {
376 let state = siv.user_data::<InstallerState>().unwrap();
377 let options = &state.options.password;
378
379 let inner = FormView::new()
380 .child("Root password", EditView::new().secret())
381 .child("Confirm root password", EditView::new().secret())
382 .child(
383 "Administator email",
384 EditView::new().content(&options.email),
385 )
386 .with_name("password-options");
387
388 InstallerView::new(
389 state,
390 inner,
391 Box::new(|siv| {
392 let options = siv.call_on_name("password-options", |view: &mut FormView| {
393 let root_password = view
394 .get_value::<EditView, _>(0)
395 .ok_or("failed to retrieve password")?;
396
397 let confirm_password = view
398 .get_value::<EditView, _>(1)
399 .ok_or("failed to retrieve password confirmation")?;
400
401 let email = view
402 .get_value::<EditView, _>(2)
403 .ok_or("failed to retrieve email")?;
404
405 if root_password.len() < 5 {
406 Err("password too short")
407 } else if root_password != confirm_password {
408 Err("passwords do not match")
409 } else if email == "mail@example.invalid" {
410 Err("invalid email address")
411 } else {
412 Ok(PasswordOptions {
413 root_password,
414 email,
415 })
416 }
417 });
418
419 match options {
420 Some(Ok(options)) => {
421 siv.with_user_data(|state: &mut InstallerState| {
422 state.options.password = options;
423 });
424
425 switch_to_next_screen(siv, InstallerStep::Network, &network_dialog);
426 }
427 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
428 _ => siv.add_layer(Dialog::info("Invalid values")),
429 }
430 }),
431 )
432 }
433
434 fn network_dialog(siv: &mut Cursive) -> InstallerView {
435 let state = siv.user_data::<InstallerState>().unwrap();
436 let options = &state.options.network;
437
438 let inner = FormView::new()
439 .child(
440 "Management interface",
441 SelectView::new().popup().with_all_str(vec!["eth0"]),
442 )
443 .child(
444 "Hostname (FQDN)",
445 EditView::new().content(options.fqdn.to_string()),
446 )
447 .child(
448 "IP address (CIDR)",
449 CidrAddressEditView::new().content(options.address.clone()),
450 )
451 .child(
452 "Gateway address",
453 EditView::new().content(options.gateway.to_string()),
454 )
455 .child(
456 "DNS server address",
457 EditView::new().content(options.dns_server.to_string()),
458 )
459 .with_name("network-options");
460
461 InstallerView::new(
462 state,
463 inner,
464 Box::new(|siv| {
465 let options = siv.call_on_name("network-options", |view: &mut FormView| {
466 let ifname = view
467 .get_value::<SelectView, _>(0)
468 .ok_or("failed to retrieve management interface name")?;
469
470 let fqdn = view
471 .get_value::<EditView, _>(1)
472 .ok_or("failed to retrieve host FQDN")?
473 .parse::<Fqdn>()
474 .map_err(|_| "failed to parse hostname".to_owned())?;
475
476 let address = view
477 .get_value::<CidrAddressEditView, _>(2)
478 .ok_or("failed to retrieve host address")?;
479
480 let gateway = view
481 .get_value::<EditView, _>(3)
482 .ok_or("failed to retrieve gateway address")?
483 .parse::<IpAddr>()
484 .map_err(|err| err.to_string())?;
485
486 let dns_server = view
487 .get_value::<EditView, _>(3)
488 .ok_or("failed to retrieve DNS server address")?
489 .parse::<IpAddr>()
490 .map_err(|err| err.to_string())?;
491
492 if address.addr().is_ipv4() != gateway.is_ipv4() {
493 Err("host and gateway IP address version must not differ".to_owned())
494 } else if address.addr().is_ipv4() != dns_server.is_ipv4() {
495 Err("host and DNS IP address version must not differ".to_owned())
496 } else if fqdn.to_string().chars().all(|c| c.is_ascii_digit()) {
497 // Not supported/allowed on Debian
498 Err("hostname cannot be purely numeric".to_owned())
499 } else if fqdn.to_string().ends_with(".invalid") {
500 Err("hostname does not look valid".to_owned())
501 } else {
502 Ok(NetworkOptions {
503 ifname,
504 fqdn,
505 address,
506 gateway,
507 dns_server,
508 })
509 }
510 });
511
512 match options {
513 Some(Ok(options)) => {
514 siv.with_user_data(|state: &mut InstallerState| {
515 state.options.network = options;
516 });
517
518 switch_to_next_screen(siv, InstallerStep::Summary, &summary_dialog);
519 }
520 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
521 _ => siv.add_layer(Dialog::info("Invalid values")),
522 }
523 }),
524 )
525 }
526
527 pub struct SummaryOption {
528 name: &'static str,
529 value: String,
530 }
531
532 impl SummaryOption {
533 pub fn new<S: Into<String>>(name: &'static str, value: S) -> Self {
534 Self {
535 name,
536 value: value.into(),
537 }
538 }
539 }
540
541 impl TableViewItem for SummaryOption {
542 fn get_column(&self, name: &str) -> String {
543 match name {
544 "name" => self.name.to_owned(),
545 "value" => self.value.clone(),
546 _ => unreachable!(),
547 }
548 }
549 }
550
551 fn summary_dialog(siv: &mut Cursive) -> InstallerView {
552 let state = siv.user_data::<InstallerState>().unwrap();
553
554 let inner = LinearLayout::vertical()
555 .child(PaddedView::lrtb(
556 0,
557 0,
558 1,
559 2,
560 TableView::new()
561 .columns(&[
562 ("name".to_owned(), "Option".to_owned()),
563 ("value".to_owned(), "Selected value".to_owned()),
564 ])
565 .items(state.options.to_summary(&state.locales)),
566 ))
567 .child(
568 LinearLayout::horizontal()
569 .child(DummyView.full_width())
570 .child(Checkbox::new().with_name("reboot-after-install"))
571 .child(
572 TextView::new(" Automatically reboot after successful installation").no_wrap(),
573 )
574 .child(DummyView.full_width()),
575 )
576 .child(PaddedView::lrtb(
577 1,
578 1,
579 1,
580 0,
581 LinearLayout::horizontal()
582 .child(abort_install_button())
583 .child(DummyView.full_width())
584 .child(Button::new("Previous", switch_to_prev_screen))
585 .child(DummyView)
586 .child(Button::new("Install", |siv| {
587 let reboot = siv
588 .find_name("reboot-after-install")
589 .map(|v: ViewRef<Checkbox>| v.is_checked())
590 .unwrap_or_default();
591
592 siv.with_user_data(|state: &mut InstallerState| {
593 state.options.reboot = reboot;
594 });
595
596 switch_to_next_screen(siv, InstallerStep::Install, &install_progress_dialog);
597 })),
598 ));
599
600 InstallerView::with_raw(state, inner)
601 }
602
603 fn install_progress_dialog(siv: &mut Cursive) -> InstallerView {
604 // Ensure the screen is updated independently of keyboard events and such
605 siv.set_autorefresh(true);
606
607 let state = siv.user_data::<InstallerState>().unwrap();
608 let progress_text = TextContent::new("extracting ..");
609 let progress_bar = ProgressBar::new()
610 .with_task({
611 move |counter| {
612 for _ in 0..100 {
613 std::thread::sleep(std::time::Duration::from_millis(50));
614 counter.tick(1);
615 }
616 }
617 })
618 .full_width();
619
620 let inner = PaddedView::lrtb(
621 1,
622 1,
623 1,
624 1,
625 LinearLayout::vertical()
626 .child(PaddedView::lrtb(1, 1, 0, 0, progress_bar))
627 .child(DummyView)
628 .child(TextView::new_with_content(progress_text).center()),
629 );
630
631 InstallerView::with_raw(state, inner)
632 }