]> git.proxmox.com Git - pve-installer.git/blob - Proxmox/Sys/Command.pm
d2dcd42385f585fa10e0ed9f8d74d92834f61185
[pve-installer.git] / Proxmox / Sys / Command.pm
1 package Proxmox::Sys::Command;
2
3 use strict;
4 use warnings;
5
6 use Carp;
7 use IO::File;
8 use IPC::Open3;
9 use IO::Select;
10 use String::ShellQuote;
11 use POSIX ":sys_wait_h";
12 use Time::HiRes qw(usleep);
13
14 use Proxmox::Install::ISOEnv;
15 use Proxmox::Log;
16 use Proxmox::UI;
17
18 use base qw(Exporter);
19 our @EXPORT_OK = qw(run_command syscmd CMD_FINISHED);
20
21 use constant {
22 CMD_RESERVED => 1<<0, # reserve 1 as it's often the default return value of closures
23 CMD_FINISHED => 1<<1,
24 };
25
26 my sub shellquote {
27 my $str = shift;
28 return String::ShellQuote::shell_quote($str);
29 }
30
31 my sub cmd2string {
32 my ($cmd) = @_;
33
34 die "no arguments" if !$cmd;
35 return $cmd if !ref($cmd);
36
37 my $quoted_args = [ map { shellquote($_) } $cmd->@* ];
38
39 return join (' ', $quoted_args->@*);
40 }
41
42 # Safely for the (sub-)process specified by $pid to exit, using a timeout.
43 #
44 # When kill => 1 is set, at first a TERM-signal is sent to the process before
45 # checking if it exited.
46 # If that fails, KILL is sent to process and then up to timeout => $timeout
47 # seconds (default: 5) are waited for the process to exit.
48 #
49 # On sucess, the exitcode of the process is returned, otherwise `undef` (aka.
50 # the process was unkillable).
51 my sub wait_for_process {
52 my ($pid, %params) = @_;
53
54 kill('TERM', $pid) if $params{kill};
55
56 my $timeout = ($params{timeout} // 5) * 10; # waiting 0.1 secs per loop
57 for (0 .. $timeout) {
58 my $terminated = waitpid($pid, WNOHANG);
59 return $? if $terminated > 0;
60
61 usleep(100_000) if $_ != $timeout; # sleep 0.1 sec, on all but last round
62 kill('KILL', $pid) if $params{kill} && $_ == 1; # just first round
63 }
64
65 log_warn("failed to kill child pid $pid, probably stuck in D-state?\n");
66
67 # We tried our best, better let the child hang in the back then completely
68 # blocking installer progress .. it's a rather short-lived environment anyway
69 }
70
71 sub syscmd {
72 my ($cmd) = @_;
73
74 return run_command($cmd, undef, undef, 1);
75 }
76
77 # Runs a command an a subprocess, properly handling IO via piping, cleaning up and passing back the
78 # exit code.
79 #
80 # If $cmd contains a pipe |, the command will be executed inside a bash shell.
81 # If $cmd contains 'chpasswd', the input will be specially quoted for that purpose.
82 #
83 # Arguments:
84 # * $cmd - The command to run, either a single string or array with individual arguments
85 # * $func - Logging subroutine to call, receives both stdout and stderr.
86 # Can return CMD_FINISHED to exit early and ignore the rest of the process output.
87 # Should use an explicit `return;` to avoid misinterpretation of return value.
88 # * $input - Stdin contents for the spawned subprocess
89 # * $noout - Whether to append any process output to the return value
90 # * $noprint - Whether to print any process output to the parents stdout
91 sub run_command {
92 my ($cmd, $func, $input, $noout, $noprint) = @_;
93
94 my $cmdstr;
95 if (!ref($cmd)) {
96 $cmdstr = $cmd;
97 if ($cmd =~ m/|/) {
98 # see 'man bash' for option pipefail
99 $cmd = [ '/bin/bash', '-c', "set -o pipefail && $cmd" ];
100 } else {
101 $cmd = [ $cmd ];
102 }
103 } else {
104 $cmdstr = cmd2string($cmd);
105 }
106
107 my $cmdtxt;
108 if ($input && ($cmdstr !~ m/chpasswd/)) {
109 $cmdtxt = "# $cmdstr <<EOD\n$input";
110 chomp $cmdtxt;
111 $cmdtxt .= "\nEOD\n";
112 } else {
113 $cmdtxt = "# $cmdstr\n";
114 }
115
116 if (is_test_mode()) {
117 print $cmdtxt;
118 STDOUT->flush();
119 }
120 log_info($cmdtxt);
121
122 my ($reader, $writer, $error) = (IO::File->new(), IO::File->new(), IO::File->new());
123
124 my $orig_pid = $$;
125
126 my $pid = eval { open3($writer, $reader, $error, @$cmd) || die $!; };
127 my $err = $@;
128
129 if ($orig_pid != $$) { # catch exec errors
130 POSIX::_exit (1);
131 kill ('KILL', $$);
132 }
133 die $err if $err;
134
135 print $writer $input if defined $input;
136 close $writer;
137
138 my $select = IO::Select->new();
139 $select->add($reader);
140 $select->add($error);
141
142 my ($ostream, $logout) = ('', '', '');
143 my $caught_sig;
144
145 while ($select->count) {
146 my @handles = $select->can_read (0.2);
147
148 # If we catch a signal, stop processing & clean up
149 if ($!{EINTR}) {
150 $caught_sig = 1;
151 last;
152 }
153
154 Proxmox::UI::process_events();
155
156 next if !scalar (@handles); # timeout
157
158 foreach my $h (@handles) {
159 my $buf = '';
160 my $count = sysread ($h, $buf, 4096);
161 if (!defined ($count)) {
162 my $err = $!;
163 wait_for_process($pid, kill => 1);
164 die "command '$cmd' failed: $err";
165 }
166 $select->remove($h) if !$count;
167 if ($h eq $reader) {
168 $ostream .= $buf if !($noout || $func);
169 $logout .= $buf;
170 while ($logout =~ s/^([^\010\r\n]*)(\r|\n|(\010)+|\r\n)//s) {
171 my $line = $1;
172 if ($func) {
173 my $ret = $func->($line);
174 if (defined($ret) && $ret == CMD_FINISHED) {
175 wait_for_process($pid, kill => 1);
176 return $ostream;
177 }
178 };
179 }
180
181 } elsif ($h eq $error) {
182 $ostream .= $buf if !($noout || $func);
183 }
184 print $buf if !$noprint;
185 STDOUT->flush();
186 log_info($buf);
187 }
188 }
189
190 &$func($logout) if $func;
191
192 my $ec = wait_for_process($pid, kill => $caught_sig);
193
194 # behave like standard system(); returns -1 in case of errors too
195 return ($ec // -1) if $noout;
196
197 if (!defined($ec)) {
198 # Don't fail completely here to let the install continue
199 warn "command '$cmdstr' failed to exit properly\n";
200 } elsif ($ec == -1) {
201 croak "command '$cmdstr' failed to execute\n";
202 } elsif (my $sig = ($ec & 127)) {
203 croak "command '$cmdstr' failed - got signal $sig\n";
204 } elsif (my $exitcode = ($ec >> 8)) {
205 croak "command '$cmdstr' failed with exit code $exitcode";
206 }
207
208 return $ostream;
209 }
210
211 # forks and runs the provided coderef in the child
212 # do not use syscmd or run_command as both confuse the GTK mainloop if
213 # run from a child process
214 sub run_in_background {
215 my ($cmd) = @_;
216
217 my $pid = fork() // die "fork failed: $!\n";
218 if (!$pid) {
219 eval { $cmd->(); };
220 if (my $err = $@) {
221 warn "run_in_background error: $err\n";
222 POSIX::_exit(1);
223 }
224 POSIX::_exit(0);
225 }
226 }
227
228 1;