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