]> git.proxmox.com Git - pve-installer.git/blame - proxmox-tui-installer/src/views/install_progress.rs
tui: install progress: add tests for UI^2 stdio protocol
[pve-installer.git] / proxmox-tui-installer / src / views / install_progress.rs
CommitLineData
dba905bf
CH
1use cursive::{
2 utils::Counter,
091c64a7 3 view::{Nameable, Resizable, ViewWrapper},
dba905bf 4 views::{Dialog, DummyView, LinearLayout, PaddedView, ProgressBar, TextContent, TextView},
c59da6c8 5 CbSink, Cursive,
dba905bf 6};
8fcdc5b2
CH
7use serde::Deserialize;
8use std::{
9 io::{BufRead, BufReader, Write},
10 sync::{Arc, Mutex},
11 thread,
12 time::Duration,
13};
dba905bf 14
28a55aea 15use crate::{abort_install_button, prompt_dialog, setup::InstallConfig, InstallerState};
923be763 16use proxmox_installer_common::setup::spawn_low_level_installer;
dba905bf
CH
17
18pub struct InstallProgressView {
19 view: PaddedView<LinearLayout>,
20}
21
22impl InstallProgressView {
23 pub fn new(siv: &mut Cursive) -> Self {
24 let cb_sink = siv.cb_sink().clone();
25 let state = siv.user_data::<InstallerState>().unwrap();
26 let progress_text = TextContent::new("starting the installation ..");
27
28 let progress_task = {
29 let progress_text = progress_text.clone();
30 let state = state.clone();
c59da6c8 31 move |counter: Counter| Self::progress_task(counter, cb_sink, state, progress_text)
dba905bf
CH
32 };
33
34 let progress_bar = ProgressBar::new().with_task(progress_task).full_width();
35 let view = PaddedView::lrtb(
36 1,
37 1,
38 1,
39 1,
40 LinearLayout::vertical()
41 .child(PaddedView::lrtb(1, 1, 0, 0, progress_bar))
42 .child(DummyView)
43 .child(TextView::new_with_content(progress_text).center())
44 .child(PaddedView::lrtb(
45 1,
46 1,
47 1,
48 0,
49 LinearLayout::horizontal().child(abort_install_button()),
50 )),
51 );
52
53 Self { view }
54 }
c59da6c8
CH
55
56 fn progress_task(
57 counter: Counter,
58 cb_sink: CbSink,
59 state: InstallerState,
60 progress_text: TextContent,
61 ) {
923be763 62 let mut child = match spawn_low_level_installer(state.in_test_mode) {
c59da6c8
CH
63 Ok(child) => child,
64 Err(err) => {
65 let _ = cb_sink.send(Box::new(move |siv| {
66 siv.add_layer(
67 Dialog::text(err.to_string())
68 .title("Error")
69 .button("Ok", Cursive::quit),
70 );
71 }));
72 return;
73 }
74 };
75
76 let inner = || {
c0517345
CH
77 let reader = child
78 .stdout
79 .take()
80 .map(BufReader::new)
81 .ok_or("failed to get stdin reader")?;
c59da6c8 82
c0517345
CH
83 let mut writer = child.stdin.take().ok_or("failed to get stdin writer")?;
84
85 serde_json::to_writer(&mut writer, &InstallConfig::from(state.options))
86 .map_err(|err| format!("failed to serialize install config: {err}"))?;
87 writeln!(writer).map_err(|err| format!("failed to write install config: {err}"))?;
c59da6c8
CH
88
89 let writer = Arc::new(Mutex::new(writer));
90
91 for line in reader.lines() {
92 let line = match line {
93 Ok(line) => line,
c0517345 94 Err(err) => return Err(format!("low-level installer exited early: {err}")),
c59da6c8
CH
95 };
96
8fcdc5b2 97 let msg = match serde_json::from_str::<UiMessage>(&line) {
c59da6c8
CH
98 Ok(msg) => msg,
99 Err(stray) => {
c0517345 100 // Not a fatal error, so don't abort the installation by returning
c59da6c8
CH
101 eprintln!("low-level installer: {stray}");
102 continue;
103 }
104 };
105
c0517345 106 let result = match msg.clone() {
8fcdc5b2
CH
107 UiMessage::Info { message } => cb_sink.send(Box::new(|siv| {
108 siv.add_layer(Dialog::info(message).title("Information"));
c59da6c8 109 })),
8fcdc5b2
CH
110 UiMessage::Error { message } => cb_sink.send(Box::new(|siv| {
111 siv.add_layer(Dialog::info(message).title("Error"));
c59da6c8 112 })),
8fcdc5b2 113 UiMessage::Prompt { query } => cb_sink.send({
c59da6c8 114 let writer = writer.clone();
8fcdc5b2 115 Box::new(move |siv| Self::show_prompt(siv, &query, writer))
c59da6c8 116 }),
8fcdc5b2
CH
117 UiMessage::Progress { ratio, text } => {
118 counter.set((ratio * 100.).floor() as usize);
119 progress_text.set_content(text);
c59da6c8
CH
120 Ok(())
121 }
8fcdc5b2 122 UiMessage::Finished { state, message } => {
c59da6c8 123 counter.set(100);
8fcdc5b2 124 progress_text.set_content(message.to_owned());
c59da6c8 125 cb_sink.send(Box::new(move |siv| {
8fcdc5b2 126 Self::prepare_for_reboot(siv, state == "ok", &message);
c59da6c8
CH
127 }))
128 }
c0517345
CH
129 };
130
131 if let Err(err) = result {
132 eprintln!("error during message handling: {err}");
133 eprintln!(" message was: '{msg:?}");
c59da6c8 134 }
c59da6c8
CH
135 }
136
c0517345 137 Ok(())
c59da6c8
CH
138 };
139
c0517345
CH
140 if let Err(err) = inner() {
141 let message = format!("installation failed: {err}");
c59da6c8
CH
142 cb_sink
143 .send(Box::new(|siv| {
144 siv.add_layer(
c0517345 145 Dialog::text(message)
c59da6c8
CH
146 .title("Error")
147 .button("Exit", Cursive::quit),
148 );
149 }))
150 .unwrap();
151 }
152 }
7d09f0ab
CH
153
154 fn prepare_for_reboot(siv: &mut Cursive, success: bool, msg: &str) {
091c64a7 155 const DIALOG_ID: &str = "autoreboot-dialog";
7d09f0ab
CH
156 let title = if success { "Success" } else { "Failure" };
157
091c64a7
CH
158 // If the dialog was previously created, just update its content and we're done.
159 if let Some(mut dialog) = siv.find_name::<Dialog>(DIALOG_ID) {
160 dialog.set_content(TextView::new(msg));
161 return;
162 }
163
7d09f0ab
CH
164 // For rebooting, we just need to quit the installer,
165 // our caller does the actual reboot.
166 siv.add_layer(
167 Dialog::text(msg)
168 .title(title)
091c64a7
CH
169 .button("Reboot now", Cursive::quit)
170 .with_name(DIALOG_ID),
7d09f0ab
CH
171 );
172
173 let autoreboot = siv
174 .user_data::<InstallerState>()
175 .map(|state| state.options.autoreboot)
176 .unwrap_or_default();
177
178 if autoreboot && success {
179 let cb_sink = siv.cb_sink();
180 thread::spawn({
181 let cb_sink = cb_sink.clone();
182 move || {
183 thread::sleep(Duration::from_secs(5));
184 let _ = cb_sink.send(Box::new(Cursive::quit));
185 }
186 });
187 }
188 }
4c808a1b
CH
189
190 fn show_prompt<W: Write + 'static>(siv: &mut Cursive, text: &str, writer: Arc<Mutex<W>>) {
8fcdc5b2
CH
191 let send_answer = |writer: Arc<Mutex<W>>, answer| {
192 if let Ok(mut writer) = writer.lock() {
193 let _ = writeln!(
194 writer,
195 "{}",
196 serde_json::json!({
197 "type" : "prompt-answer",
198 "answer" : answer,
199 })
200 );
201 }
202 };
203
28a55aea 204 prompt_dialog(
4c808a1b
CH
205 siv,
206 "Prompt",
207 text,
28a55aea 208 "OK",
4c808a1b
CH
209 Box::new({
210 let writer = writer.clone();
211 move |_| {
8fcdc5b2 212 send_answer(writer.clone(), "ok");
4c808a1b
CH
213 }
214 }),
28a55aea 215 "Cancel",
4c808a1b 216 Box::new(move |_| {
8fcdc5b2 217 send_answer(writer.clone(), "cancel");
4c808a1b
CH
218 }),
219 );
220 }
dba905bf
CH
221}
222
223impl ViewWrapper for InstallProgressView {
224 cursive::wrap_impl!(self.view: PaddedView<LinearLayout>);
225}
226
8fcdc5b2
CH
227#[derive(Clone, Debug, Deserialize, PartialEq)]
228#[serde(tag = "type", rename_all = "lowercase")]
dba905bf 229enum UiMessage {
8fcdc5b2
CH
230 #[serde(rename = "message")]
231 Info {
232 message: String,
233 },
234 Error {
235 message: String,
236 },
237 Prompt {
238 query: String,
239 },
240 Finished {
241 state: String,
242 message: String,
243 },
244 Progress {
245 ratio: f32,
246 text: String,
247 },
dba905bf 248}
240f1f00
CH
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use std::env;
254
255 #[test]
256 fn run_low_level_installer_test_session() {
257 env::set_current_dir("..").expect("failed to change working directory");
258 let mut child = spawn_low_level_installer(true)
259 .expect("failed to run low-level installer test session");
260
261 let mut reader = child
262 .stdout
263 .take()
264 .map(BufReader::new)
265 .expect("failed to get stdin reader");
266
267 let mut writer = child.stdin.take().expect("failed to get stdin writer");
268
269 serde_json::to_writer(&mut writer, &serde_json::json!({ "autoreboot": false }))
270 .expect("failed to serialize install config");
271
272 writeln!(writer).expect("failed to write install config: {err}");
273
274 let mut next_msg = || {
275 let mut line = String::new();
276 reader.read_line(&mut line).expect("a line");
277
278 match serde_json::from_str::<UiMessage>(&line) {
279 Ok(msg) => Some(msg),
280 Err(err) => panic!("unexpected error: '{err}'"),
281 }
282 };
283
284 assert_eq!(
285 next_msg(),
286 Some(UiMessage::Prompt {
287 query: "Reply anything?".to_owned()
288 }),
289 );
290
291 serde_json::to_writer(
292 &mut writer,
293 &serde_json::json!({"type": "prompt-answer", "answer": "ok"}),
294 )
295 .expect("failed to write prompt answer");
296 writeln!(writer).expect("failed to write prompt answer");
297
298 assert_eq!(
299 next_msg(),
300 Some(UiMessage::Info {
301 message: "Test Message - got ok".to_owned()
302 }),
303 );
304
305 for i in (1..=1000).step_by(3) {
306 assert_eq!(
307 next_msg(),
308 Some(UiMessage::Progress {
309 ratio: (i as f32) / 1000.,
310 text: format!("foo {i}"),
311 }),
312 );
313 }
314
315 assert_eq!(
316 next_msg(),
317 Some(UiMessage::Finished {
318 state: "ok".to_owned(),
319 message: "Installation finished - reboot now?".to_owned(),
320 }),
321 );
322
323 // Should be nothing left to read now
324 let mut line = String::new();
325 assert_eq!(reader.read_line(&mut line).expect("success"), 0);
326
327 // Give the low-level installer some time to exit properly
328 std::thread::sleep(Duration::new(1, 0));
329
330 match child.try_wait() {
331 Ok(Some(status)) => assert!(
332 status.success(),
333 "low-level installer did not exit successfully"
334 ),
335 Ok(None) => {
336 child.kill().expect("could not kill low-level installer");
337 panic!("low-level install was not successful");
338 }
339 Err(err) => panic!("failed to wait for low-level installer: {err}"),
340 }
341 }
342}