1use anyhow::{Context, Result, bail};
2use base64::Engine;
3
4use crate::config::{Config, Host};
5use crate::probe::State;
6use crate::transport::Transport;
7use crate::wrapper::{JOBS_ROOT, JobId, state_dir};
8
9const DEFAULT_LS_WINDOW_SECS: u64 = 24 * 60 * 60;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Row {
19 pub id: String,
20 pub host: String,
21 pub state: State,
22 pub age_secs: u64,
23 pub runtime_secs: Option<u64>,
24 pub cmd: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Unreachable {
29 pub host: String,
30 pub why: String,
31 pub remedy: Option<String>,
37}
38
39pub fn list(
40 cfg: &Config,
41 transport: &dyn Transport,
42 host_filter: Option<&str>,
43 all: bool,
44) -> Result<(Vec<Row>, Vec<Unreachable>)> {
45 let (rows, unreachable, _) = list_with_hidden(cfg, transport, host_filter, all)?;
46 Ok((rows, unreachable))
47}
48
49pub fn list_with_hidden(
50 cfg: &Config,
51 transport: &dyn Transport,
52 host_filter: Option<&str>,
53 all: bool,
54) -> Result<(Vec<Row>, Vec<Unreachable>, usize)> {
55 let hosts: Vec<&Host> = match host_filter {
56 Some(name) => vec![cfg.host(Some(name))?],
57 None => cfg.hosts().iter().collect(),
58 };
59 let mut rows = Vec::new();
60 let mut unreachable = Vec::new();
61 let mut hidden = 0;
62
63 for host in hosts {
66 if !transport.master_alive(host) {
67 unreachable.push(Unreachable {
68 host: host.name.clone(),
69 why: "no control master".into(),
70 remedy: Some(crate::errors::master_command(host)),
71 });
72 continue;
73 }
74 let output = transport.run(host, &list_script(host))?;
75 if output.code != 0 {
76 bail!(
77 "listing jobs on {} failed: {}",
78 host.name,
79 output.stderr.trim()
80 );
81 }
82 hidden += parse_rows(host, &output.text(), all, &mut rows)?;
83 }
84 Ok((rows, unreachable, hidden))
85}
86
87fn list_script(host: &Host) -> String {
114 format!(
115 "root={JOBS_ROOT}; [ -d \"$root\" ] || exit 0; \
116 live=$(tmux -L {} list-sessions -F '#{{session_name}}' 2>/dev/null | sed 's/^coop-//'); \
117 {{ find \"$root\" -mindepth 1 -maxdepth 2 \\( \\( -type d ! -path \"$root/*/*\" \\) -o \\( -type f \\( -name cmd -o -name rc \\) \\) \\) -exec stat -c '%Y %n' {{}} + 2>/dev/null \
118 || find \"$root\" -mindepth 1 -maxdepth 2 \\( \\( -type d ! -path \"$root/*/*\" \\) -o \\( -type f \\( -name cmd -o -name rc \\) \\) \\) -exec stat -f '%m %N' {{}} + ; }} \
119 | awk -v now=\"$(date +%s)\" -v live=\"$live\" '\
120 BEGIN {{ \
121 n = split(live, L, \"\\n\"); \
122 for (i = 1; i <= n; i++) if (L[i] != \"\") alive[L[i]] = 1; \
123 alphabet = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"; \
124 for (i = 0; i < 256; i++) ord[sprintf(\"%c\", i)] = i; \
125 }} \
126 function b64(s, out, i, n, a, b, c) {{ \
127 for (i = 1; i <= length(s); i += 3) {{ \
128 n = length(s) - i + 1; \
129 a = ord[substr(s, i, 1)]; \
130 b = n > 1 ? ord[substr(s, i + 1, 1)] : 0; \
131 c = n > 2 ? ord[substr(s, i + 2, 1)] : 0; \
132 out = out substr(alphabet, int(a / 4) + 1, 1); \
133 out = out substr(alphabet, (a % 4) * 16 + int(b / 16) + 1, 1); \
134 out = out (n > 1 ? substr(alphabet, (b % 16) * 4 + int(c / 64) + 1, 1) : \"=\"); \
135 out = out (n > 2 ? substr(alphabet, c % 64 + 1, 1) : \"=\"); \
136 }} \
137 return out; \
138 }} \
139 {{ \
140 mtime = $1; path = substr($0, length($1) + 2); \
141 dir = path; kind = \"dir\"; \
142 if (sub(/\\/cmd$/, \"\", dir)) kind = \"cmd\"; \
143 else if (sub(/\\/rc$/, \"\", dir)) kind = \"rc\"; \
144 id = dir; sub(/.*\\//, \"\", id); \
145 dirs[id] = dir; \
146 if (kind == \"dir\") dir_mtime[id] = mtime; \
147 else if (kind == \"cmd\") cmd_mtime[id] = mtime; \
148 else rc_mtime[id] = mtime; \
149 }} \
150 END {{ \
151 for (id in dirs) {{ \
152 dir = dirs[id]; \
153 rc = \"\"; if ((getline l < (dir \"/rc\")) > 0) rc = l; \
154 close(dir \"/rc\"); \
155 cmd = \"\"; \
156 while ((getline l < (dir \"/cmd\")) > 0) cmd = (cmd == \"\") ? l : cmd \"\\n\" l; \
157 close(dir \"/cmd\"); \
158 runtime = \"\"; \
159 if (rc != \"\" && (id in cmd_mtime) && (id in rc_mtime)) \
160 runtime = rc_mtime[id] - cmd_mtime[id]; \
161 else if (rc == \"\" && (id in alive) && (id in cmd_mtime)) \
162 runtime = now - cmd_mtime[id]; \
163 if (runtime != \"\" && runtime < 0) runtime = 0; \
164 printf \"%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n\", \
165 id, now - dir_mtime[id], runtime, rc, (id in alive) ? 1 : 0, b64(cmd); \
166 }} \
167 }}'",
168 host.tmux_socket
169 )
170}
171
172fn parse_rows(host: &Host, reply: &str, all: bool, rows: &mut Vec<Row>) -> Result<usize> {
173 let mut hidden = 0;
174 for line in reply.lines() {
175 let mut fields = line.splitn(6, '\t');
176 let id = fields.next().context("invalid ls reply: missing id")?;
177 let age_secs = fields
178 .next()
179 .context("invalid ls reply: missing age")?
180 .parse()
181 .context("invalid age in ls reply")?;
182 let runtime_text = fields.next().context("invalid ls reply: missing runtime")?;
183 let runtime_secs = if runtime_text.is_empty() {
184 None
185 } else {
186 Some(
187 runtime_text
188 .parse()
189 .context("invalid runtime in ls reply")?,
190 )
191 };
192 let rc_text = fields.next().context("invalid ls reply: missing rc")?;
193 let alive = fields.next().context("invalid ls reply: missing alive")? == "1";
194 let cmd = String::from_utf8_lossy(&decode_command(
195 fields.next().context("invalid ls reply: missing cmd")?,
196 )?)
197 .into_owned();
198 let rc = if rc_text.is_empty() {
199 None
200 } else {
201 Some(rc_text.parse().context("invalid rc in ls reply")?)
202 };
203 let state = match rc {
204 Some(code) => State::Done(code),
205 None if alive => State::Running,
206 None => State::Orphan,
207 };
208 let runtime_secs = (!matches!(state, State::Orphan))
212 .then_some(runtime_secs)
213 .flatten();
214 let recent = age_secs < DEFAULT_LS_WINDOW_SECS;
224 if all || recent || !matches!(state, State::Done(_)) {
225 rows.push(Row {
226 id: id.into(),
227 host: host.name.clone(),
228 state,
229 age_secs,
230 runtime_secs,
231 cmd,
232 });
233 } else {
234 hidden += 1;
235 }
236 }
237 Ok(hidden)
238}
239
240pub fn kill(transport: &dyn Transport, host: &Host, id: &JobId) -> Result<i32> {
241 crate::errors::require_master(transport, host)?;
242 let dir = state_dir(id);
243 let script = format!(
248 "d={dir}; [ -f $d/rc ] || echo 137 > $d/rc; \
249 tmux -L {socket} kill-session -t coop-{id} 2>/dev/null; \
250 tmux -L {socket} kill-session -t watch-{id} 2>/dev/null; \
251 cat $d/rc",
252 socket = host.tmux_socket
253 );
254 let output = transport.run(host, &script)?;
255 if output.code != 0 {
256 bail!("kill failed: {}", output.stderr.trim());
257 }
258 output
259 .text()
260 .trim()
261 .parse()
262 .context("kill returned an invalid exit code")
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub enum Target {
268 One(JobId),
269 AllDone,
277}
278
279pub fn remove(transport: &dyn Transport, host: &Host, target: &Target) -> Result<Vec<String>> {
285 crate::errors::require_master(transport, host)?;
286
287 let script = match target {
288 Target::One(id) => {
291 let dir = state_dir(id);
292 format!(
293 "tmux -L {} kill-session -t coop-{id} 2>/dev/null; \
294 if [ -d {dir} ]; then rm -rf {dir} && echo {id}; fi; exit 0",
295 host.tmux_socket
296 )
297 }
298 Target::AllDone => format!(
301 "root={JOBS_ROOT}; [ -d \"$root\" ] || exit 0; \
302 for d in \"$root\"/*; do \
303 [ -d \"$d\" ] && [ -f \"$d/rc\" ] || continue; \
304 rm -rf \"$d\" && echo \"${{d##*/}}\"; \
305 done; exit 0"
306 ),
307 };
308
309 let output = transport.run(host, &script)?;
310 if output.code != 0 {
311 bail!("rm failed on {}: {}", host.name, output.stderr.trim());
312 }
313 Ok(output
314 .text()
315 .lines()
316 .map(str::trim)
317 .filter(|line| !line.is_empty())
318 .map(str::to_string)
319 .collect())
320}
321
322const ORPHAN_KEEP_MULTIPLIER: u32 = 4;
331
332pub fn prune(host: &Host) -> String {
333 let orphan_days = host.keep_days.saturating_mul(ORPHAN_KEEP_MULTIPLIER);
334 format!(
342 "root={JOBS_ROOT}; [ ! -d \"$root\" ] || {{ \
343 live=$(tmux -L {} list-sessions -F '#{{session_name}}' 2>/dev/null); \
344 find \"$root\" -mindepth 1 -maxdepth 1 -type d -mtime +{} \
345 -exec test -f '{{}}/rc' \\; -exec rm -rf '{{}}' + ; \
346 find \"$root\" -mindepth 1 -maxdepth 1 -type d -mtime +{orphan_days} \
347 -exec test ! -f '{{}}/rc' \\; -print | while IFS= read -r d; do \
348 id=\"${{d##*/}}\"; \
349 echo \"$live\" | grep -qx \"coop-$id\" && continue; \
350 rm -rf \"$d\"; \
351 done; }}",
352 host.tmux_socket, host.keep_days
353 )
354}
355
356pub fn decode_command(input: &str) -> Result<Vec<u8>> {
357 base64::engine::general_purpose::STANDARD
358 .decode(input)
359 .context("invalid command encoding in ls reply")
360}