Skip to main content

dsd_util/
commands.rs

1use crate::printer::{color_println, color_println_fmt, Color};
2use crate::utils::{
3    get_containers_from_stack, get_timestamp, is_terminal, kill_containers, list_containers,
4    parse_inspect_data, parse_stats_data, spawn_container_logger, update_container_by_name,
5    InspectData, StatsData,
6};
7use anyhow::Context;
8use std::collections::hash_map::HashMap;
9use std::io::{self, BufRead, BufReader, Write};
10use std::process::{Command, Stdio};
11
12pub const DOCKER: &str = "docker";
13const DSD: &str = "docker-stack-deploy";
14const PATH_DSD_COMPOSE: &str = "/var/lib/docker-stack-deploy/compose.yml";
15
16/// Initializes a new instance of docker-stack-deploy using bootstrap script
17pub fn init(project_dir: String, git_url: String) -> anyhow::Result<()> {
18    Command::new(DOCKER)
19        .args(["run", "--rm", "-it"])
20        .args(["-v", "/var/run/docker.sock:/var/run/docker.sock"])
21        .args(["-v", &format!("{project_dir}:{project_dir}")])
22        .args(["ghcr.io/wez/docker-stack-deploy"])
23        .args([DSD, "bootstrap"])
24        .args(["--project-dir", &project_dir])
25        .args(["--git-url", &git_url])
26        .status()
27        .context("Failed to bootstrap docker-stack-deploy")?;
28
29    println!();
30
31    let use_color = is_terminal();
32
33    if use_color {
34        color_println(
35            Color::Green,
36            "Bootstrap success! Following docker-stack-deploy logs...",
37        );
38    } else {
39        println!("Bootstrap success! Following docker-stack-deploy logs...")
40    }
41
42    println!();
43
44    let start_time = std::time::SystemTime::now()
45        .duration_since(std::time::UNIX_EPOCH)
46        .context("Failed to get current time")?
47        .as_secs();
48
49    // follow docker-stack-deploy logs until first update check has happened
50    let mut logs_process = Command::new(DOCKER)
51        .args([
52            "compose",
53            "-f",
54            PATH_DSD_COMPOSE,
55            "logs",
56            "--follow",
57            "--no-log-prefix",
58            "--since",
59            &start_time.to_string(),
60        ])
61        .stdout(Stdio::piped())
62        .spawn()
63        .context("Failed to start following logs")?;
64
65    if let Some(stdout) = logs_process.stdout.take() {
66        let reader = BufReader::new(stdout);
67        for (i, line) in reader.lines().map_while(Result::ok).enumerate() {
68            if use_color {
69                println!(
70                    "[{} | {}] {}",
71                    color_println_fmt(Color::Cyan, &get_timestamp()),
72                    color_println_fmt(Color::Magenta, DSD),
73                    line
74                );
75            } else {
76                println!("[{} | {}] {}", &get_timestamp(), DSD, line);
77            }
78            if line.contains("Already up to date") && i > 0 {
79                // first update check has happened after deployment
80                break;
81            }
82        }
83    }
84
85    let _ = logs_process.kill();
86    let _ = logs_process.wait();
87
88    Ok(())
89}
90
91/// Shows logs for specified containers
92pub fn logs(
93    containers: Option<Vec<String>>,
94    stacks: Option<Vec<String>>,
95    tail: u32,
96    all: bool,
97) -> anyhow::Result<()> {
98    let use_color = is_terminal();
99
100    let containers = if all {
101        let container_ids = list_containers()?;
102
103        if container_ids.is_empty() {
104            if use_color {
105                color_println(Color::Red, "No containers running");
106            } else {
107                println!("No containers running");
108            }
109            return Ok(());
110        }
111
112        container_ids
113    } else if let Some(containers) = containers {
114        containers
115    } else if let Some(stacks) = stacks {
116        let mut containers = vec![];
117
118        for stack in &stacks {
119            let container_names = get_containers_from_stack(stack)?;
120            containers.extend(container_names);
121        }
122
123        containers
124    } else {
125        anyhow::bail!("Must specify containers, use --stacks (-s) or use --all (-a)")
126    };
127
128    if use_color {
129        color_println(
130            Color::Cyan,
131            &format!("Following logs for container: {}", &containers.len()),
132        );
133    } else {
134        println!("Following logs for container: {}", &containers.len());
135    }
136    let (tx, rx) = std::sync::mpsc::channel::<String>();
137    let mut handles: Vec<std::thread::JoinHandle<()>> = vec![];
138
139    for container in containers {
140        let tx = tx.clone();
141        let is_container_id = all;
142        let handle = spawn_container_logger(&container, is_container_id, use_color, tail, tx)
143            .with_context(|| format!("Failed to spawn container logger for {container}"))?;
144        handles.push(handle);
145    }
146
147    drop(tx);
148
149    for log_line in rx {
150        println!("{log_line}");
151    }
152
153    for handle in handles {
154        let _ = handle.join();
155    }
156
157    Ok(())
158}
159
160/// Kills all running containers, and then redeploys docker-stack-deploy
161pub fn nuke() -> anyhow::Result<()> {
162    // ask user to confirm action
163    color_println(
164        Color::Yellow,
165        "WARNING: All of your containers will be forcefully removed!",
166    );
167    println!(
168        "After removal, {} will be restarted to redeploy all associated containers.\n",
169        color_println_fmt(Color::Magenta, DSD)
170    );
171    print!("Are you sure you want to nuke your docker stacks? [y/N]: ");
172    let _ = io::stdout().flush();
173
174    // capture user input
175    let mut input = String::new();
176    let _ = io::stdin().read_line(&mut input);
177    let response = input.trim().to_lowercase();
178
179    // evaluate response
180    match response.as_str() {
181        "yes" | "y" => {
182            color_println(Color::Yellow, "Nuking docker containers");
183        }
184        _ => {
185            color_println(Color::Green, "Nuke aborted!");
186            return Ok(());
187        }
188    };
189
190    // get list of currently running docker containers by id
191    let container_ids = list_containers()?;
192
193    // if docker containers are running, kill them
194    if container_ids.is_empty() {
195        color_println(Color::Red, "No containers running");
196        return Ok(());
197    } else {
198        kill_containers(container_ids)?
199    }
200
201    color_println(Color::Green, "Running docker-stack-deploy...");
202
203    // run docker-stack-deploy
204    Command::new(DOCKER)
205        .args(["compose", "-f", PATH_DSD_COMPOSE, "up", "-d"])
206        .status()
207        .context("Failed to start docker-stack-deploy")?;
208
209    color_println(
210        Color::Green,
211        "Following logs until all containers deployed...",
212    );
213
214    let start_time = std::time::SystemTime::now()
215        .duration_since(std::time::UNIX_EPOCH)
216        .context("Failed to get current time")?
217        .as_secs();
218
219    // follow docker-stack-deploy logs until first update check has happened
220    let mut logs_process = Command::new(DOCKER)
221        .args([
222            "compose",
223            "-f",
224            PATH_DSD_COMPOSE,
225            "logs",
226            "--follow",
227            "--no-log-prefix",
228            "--since",
229            &start_time.to_string(),
230        ])
231        .stdout(Stdio::piped())
232        .spawn()
233        .context("Failed to start following logs")?;
234
235    if let Some(stdout) = logs_process.stdout.take() {
236        let reader = BufReader::new(stdout);
237        for (i, line) in reader.lines().map_while(Result::ok).enumerate() {
238            println!(
239                "[{} | {}] {}",
240                color_println_fmt(Color::Cyan, &get_timestamp()),
241                color_println_fmt(Color::Magenta, DSD),
242                line
243            );
244            if line.contains("Already up to date") && i > 0 {
245                // first update check has happened after deployment
246                break;
247            }
248        }
249    }
250
251    let _ = logs_process.kill();
252    let _ = logs_process.wait();
253
254    Ok(())
255}
256
257/// Restarts specified docker containers
258pub fn restart(
259    containers: Option<Vec<String>>,
260    stacks: Option<Vec<String>>,
261    all: bool,
262) -> anyhow::Result<()> {
263    let containers = if all {
264        list_containers()?
265    } else if let Some(containers) = containers {
266        containers
267    } else if let Some(stacks) = stacks {
268        let mut containers = vec![];
269
270        for stack in &stacks {
271            let container_names = get_containers_from_stack(stack)?;
272            containers.extend(container_names);
273        }
274
275        containers
276    } else {
277        anyhow::bail!("Must specify containers, use --stacks (-s) or use --all (-a)")
278    };
279
280    let use_color = is_terminal();
281
282    for container in &containers {
283        if use_color {
284            color_println(
285                Color::Cyan,
286                &format!("Restarting container: {}", &container),
287            );
288        } else {
289            println!("Restarting container: {}", &container)
290        }
291
292        Command::new(DOCKER)
293            .args(["restart", container])
294            .status()
295            .context(format!("Failed to restart {}", &container))?;
296    }
297
298    Ok(())
299}
300
301/// Container stats to be gathered
302#[derive(Debug, Clone)]
303struct ContainerStats {
304    name: String,
305    status: String,
306    health: String,
307    restart_policy: String,
308    uptime: String,
309    cpu_usage: String,
310    memory_usage: String,
311    ports: String,
312}
313
314/// View stats for docker containers
315pub fn stats(
316    containers: Option<Vec<String>>,
317    stacks: Option<Vec<String>>,
318    all: bool,
319) -> anyhow::Result<()> {
320    let use_color = is_terminal();
321    let containers = if all {
322        let container_ids = list_containers()?;
323
324        if container_ids.is_empty() {
325            if use_color {
326                color_println(Color::Red, "No containers running");
327            } else {
328                println!("No containers running");
329            }
330            return Ok(());
331        }
332
333        container_ids
334    } else if let Some(containers) = containers {
335        containers
336    } else if let Some(stacks) = stacks {
337        let mut containers = vec![];
338
339        for stack in &stacks {
340            let container_names = get_containers_from_stack(stack)?;
341            containers.extend(container_names);
342        }
343
344        containers
345    } else {
346        anyhow::bail!("Must specify containers, use --stacks (-s) or use --all (-a)")
347    };
348
349    // stats format from docker cli
350    let stats_output = Command::new(DOCKER)
351        .args([
352            "stats",
353            "--no-stream",
354            "--format",
355            "table {{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}",
356        ])
357        .args(&containers)
358        .output()
359        .context("Failed to get stats for containers")?;
360
361    // inspect format from docker cli
362    let inspect_format = concat!(
363        "{{.Name}},",
364        "{{.State.Status}},",
365        "{{if .HostConfig.RestartPolicy}}{{if .HostConfig.RestartPolicy.Name}}{{.HostConfig.RestartPolicy.Name}}{{else}}no{{end}}{{else}}no{{end}},",
366        "{{if index .State \"Health\"}}{{.State.Health.Status}}{{else}}N/A{{end}},",
367        "{{.State.StartedAt}},",
368        "{{if .NetworkSettings.Ports}}{{range $key, $value := .NetworkSettings.Ports}}{{$key}}{{if $value}}:{{(index $value 0).HostPort}}{{end}} {{end}}{{else}}N/A{{end}}"
369        );
370
371    let inspect_output = Command::new(DOCKER)
372        .arg("inspect")
373        .args(&containers)
374        .args(["--format", inspect_format])
375        .output()
376        .context("Failed to inspect containers")?;
377
378    let stats_string = String::from_utf8(stats_output.stdout)?;
379    let inspect_string = String::from_utf8(inspect_output.stdout)?;
380
381    let mut temp_stats_map: HashMap<String, StatsData> = HashMap::new();
382    let mut temp_inspect_map: HashMap<String, InspectData> = HashMap::new();
383
384    // skip header line
385    for line in stats_string.lines().skip(1) {
386        let parsed = parse_stats_data(line)?;
387        temp_stats_map.insert(
388            parsed.container_name.clone(),
389            StatsData {
390                container_name: parsed.container_name,
391                cpu: parsed.cpu,
392                memory: parsed.memory,
393            },
394        );
395    }
396
397    for line in inspect_string.lines() {
398        let parsed = parse_inspect_data(line)?;
399        temp_inspect_map.insert(
400            parsed.container_name.clone(),
401            InspectData {
402                container_name: parsed.container_name,
403                status: parsed.status,
404                restart_policy: parsed.restart_policy,
405                health: parsed.health,
406                uptime: parsed.uptime,
407                ports: parsed.ports,
408            },
409        );
410    }
411
412    assert_eq!(&temp_stats_map.len(), &temp_inspect_map.len());
413
414    let mut total_stats_map: HashMap<String, ContainerStats> = HashMap::new();
415
416    for key in temp_stats_map.keys() {
417        let stats = temp_stats_map
418            .get(key)
419            .with_context(|| format!("Failed to get stats for {key}"))?;
420        let inspect = temp_inspect_map
421            .get(key)
422            .with_context(|| format!("Failed to get stats for {key}"))?;
423
424        let container_stats = if use_color {
425            ContainerStats {
426                name: color_println_fmt(Color::Cyan, &stats.container_name),
427                status: {
428                    if &inspect.status.to_lowercase() == "running" {
429                        color_println_fmt(Color::Green, &inspect.status)
430                    } else if &inspect.status.to_lowercase() == "created" {
431                        color_println_fmt(Color::Cyan, &inspect.status)
432                    } else if &inspect.status.to_lowercase() == "paused"
433                        || &inspect.status.to_lowercase() == "restarting"
434                    {
435                        color_println_fmt(Color::Yellow, &inspect.status)
436                    } else {
437                        color_println_fmt(Color::Red, &inspect.status)
438                    }
439                },
440                restart_policy: inspect.restart_policy.to_string(),
441                health: {
442                    if &inspect.health.to_lowercase() == "healthy" {
443                        color_println_fmt(Color::Green, &inspect.health)
444                    } else if &inspect.health.to_lowercase() == "unhealthy" {
445                        color_println_fmt(Color::Red, &inspect.health)
446                    } else if &inspect.health.to_lowercase() == "starting" {
447                        color_println_fmt(Color::Cyan, &inspect.health)
448                    } else {
449                        color_println_fmt(Color::White, &inspect.health)
450                    }
451                },
452                uptime: inspect.uptime.to_string(),
453                cpu_usage: stats.cpu.to_string(),
454                memory_usage: stats.memory.to_string(),
455                ports: inspect.ports.to_string(),
456            }
457        } else {
458            ContainerStats {
459                name: stats.container_name.to_string(),
460                status: inspect.status.to_string(),
461                restart_policy: inspect.restart_policy.to_string(),
462                health: inspect.health.to_string(),
463                uptime: inspect.uptime.to_string(),
464                cpu_usage: stats.cpu.to_string(),
465                memory_usage: stats.memory.to_string(),
466                ports: inspect.ports.to_string(),
467            }
468        };
469
470        total_stats_map.insert(key.to_string(), container_stats);
471    }
472    if use_color {
473        println!(
474            "{:<35} {:<20} {:<16} {:<20} {:<18} {:<8} {:<8} {:<20}",
475            &color_println_fmt(Color::White, "NAME"),
476            &color_println_fmt(Color::White, "STATUS"),
477            "RESTART",
478            &color_println_fmt(Color::White, "HEALTH"),
479            "UPTIME",
480            "CPU %",
481            "MEM %",
482            "PORTS"
483        );
484    } else {
485        println!(
486            "{:<35} {:<20} {:<16} {:<20} {:<18} {:<8} {:<8} {:<20}",
487            "NAME", "STATUS", "RESTART", "HEALTH", "UPTIME", "CPU %", "MEM %", "PORTS"
488        );
489    }
490
491    println!();
492
493    // TODO: sort - probably want to use BTreeMap instead
494    for key in total_stats_map.keys() {
495        let container = total_stats_map.get(key).context("Failed to get item")?;
496
497        println!(
498            "{:<35} {:<20} {:<16} {:<20} {:<18} {:<8} {:<8} {:<20}",
499            container.name,
500            container.status,
501            container.restart_policy,
502            container.health,
503            container.uptime,
504            container.cpu_usage,
505            container.memory_usage,
506            container.ports
507        );
508    }
509
510    Ok(())
511}
512
513/// Updates images of specified docker containers
514pub fn update(
515    containers: Option<Vec<String>>,
516    stacks: Option<Vec<String>>,
517    all: bool,
518) -> anyhow::Result<()> {
519    let containers = if all {
520        list_containers()?
521    } else if let Some(containers) = containers {
522        containers
523    } else if let Some(stacks) = stacks {
524        let mut containers = vec![];
525
526        for stack in &stacks {
527            let container_names = get_containers_from_stack(stack)?;
528            containers.extend(container_names);
529        }
530
531        containers
532    } else {
533        anyhow::bail!("Must specify containers, use --stacks (-s) or use --all (-a)")
534    };
535
536    let use_color = is_terminal();
537
538    let mut num_containers_updated = 0;
539
540    for container in &containers {
541        num_containers_updated += update_container_by_name(container)?;
542    }
543
544    if num_containers_updated == 0 {
545        if use_color {
546            color_println(Color::Yellow, "No new container images to update");
547        } else {
548            println!("No new container images to pull");
549        }
550
551        return Ok(());
552    }
553
554    if use_color {
555        println!(
556            "{}: {}",
557            &color_println_fmt(Color::Cyan, "New images pulled"),
558            &color_println_fmt(Color::Green, &num_containers_updated.to_string())
559        );
560        println!();
561        color_println(Color::Green, &format!("Restarting {DSD}"));
562    } else {
563        println!("New images pulled: {num_containers_updated}");
564        println!();
565        println!("Restarting {DSD}");
566    }
567
568    // containers updated, restart docker-stack-deploy to deploy new image
569    Command::new(DOCKER)
570        .args(["restart", DSD])
571        .status()
572        .context(format!("Failed to restart {DSD}"))?;
573
574    Ok(())
575}