From c77b138a1b8c0445ea4b1d3f8d8e706df396ff16 Mon Sep 17 00:00:00 2001 From: Federico Ponzi Date: Wed, 30 Oct 2024 09:00:47 +0000 Subject: [PATCH] Fixes data loss for the log rotator --- DOCUMENTATION.md | 162 ++++++++++++++++------- example_services/sample_service.toml | 1 + src/horust/formats/service.rs | 20 ++- src/horust/supervisor/process_spawner.rs | 62 ++++++--- tests/section_general.rs | 61 +++++---- 5 files changed, 208 insertions(+), 98 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 2b5cf7f..3f58161 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1,5 +1,7 @@ # Documentation + ## Table of contents: + * [Service configuration](#service-configuration) * [State machine](#state-machine) * [Horust's configuration](#horusts-configuration) @@ -8,19 +10,31 @@ * [Plugins (WIP)](#plugins-wip) * [Checking system status (WIP) ](#checking-system-status-wip) -When starting horust, you can optionally specify where it should look for services and uses `/etc/horust/services` by default. +When starting horust, you can optionally specify where it should look for services and uses `/etc/horust/services` by +default. ## Service configuration + This section describes all the possible options you can put in a service.toml file. You should create one different service.toml for each command you want to run. Apart from the `user` parameter, everything should work even with an unprivileged user. ### Service templating -Services can, but not have to, be templated. Currently, this feature works only via environment variables. The templating engine uses [bash expansion mechanism](https://docs.rs/shellexpand/2.1.0/shellexpand/). Each part of the service configuration can be used in tandem with the templating. Additionally, multiple variables can be safely used if needed. The engine does not support processing the shell queries, for example `$(cat /proc/config.gz)` will not be processed and will be used on face value. -Before loading the service file, Horust will internally search and replace every value with environment variable if it exists. In this case, the `${USER}` block is replaced by the environment's `USER` variable. Alternatively `$USER` without braces is also accepted, although heavily discouraged. Variable names must follow rules of the operating system, which means that, in case of bash, variables are case-sensitive, and usage of special characters is somewhat restricted. You can use any variable your system provides, or you also can set and/or export those before running Horust. +Services can, but not have to, be templated. Currently, this feature works only via environment variables. The +templating engine uses [bash expansion mechanism](https://docs.rs/shellexpand/2.1.0/shellexpand/). Each part of the +service configuration can be used in tandem with the templating. Additionally, multiple variables can be safely used if +needed. The engine does not support processing the shell queries, for example `$(cat /proc/config.gz)` will not be +processed and will be used on face value. -Below are some simple strings that will be properly templated. Simple feature usage in configuration is showcased in the provided sample service. +Before loading the service file, Horust will internally search and replace every value with environment variable if it +exists. In this case, the `${USER}` block is replaced by the environment's `USER` variable. Alternatively `$USER` +without braces is also accepted, although heavily discouraged. Variable names must follow rules of the operating system, +which means that, in case of bash, variables are case-sensitive, and usage of special characters is somewhat restricted. +You can use any variable your system provides, or you also can set and/or export those before running Horust. + +Below are some simple strings that will be properly templated. Simple feature usage in configuration is showcased in the +provided sample service. ``` ... @@ -30,6 +44,7 @@ stderr = "${HOME}${HORUST_LOGDIR}/stderr" ``` ### Main section + ```toml # name = "myname" command = "/bin/bash -c 'echo hello world'" @@ -37,124 +52,160 @@ start-delay = "2s" start-after = ["database", "backend.toml"] stdout = "STDOUT" stderr = "/var/logs/hello_world_svc/stderr.log" +stdout-rotate-size = "100MB" +stdout_should_append_timestamp_to_filename = false user = "${USER}" working-directory = "/tmp/" ``` + * **`name` = `string`**: Name of the service. If missing, Horust will use the filename by default. -* **`command` = `string`**: Specify a command to run, or a full path. You can also add arguments. If a full path is not provided, the binary will be searched using the $PATH env variable. +* **`command` = `string`**: Specify a command to run, or a full path. You can also add arguments. If a full path is not + provided, the binary will be searched using the $PATH env variable. * **`start-after` = `list`**: Start after these other services. -If service `a` should start after service `b`, then `a` will be started as soon as `b` is considered Running or Finished. -If `b` goes in a `FinishedFailed` state (finished in an unsuccessful manner), `a` might not start at all. -* **`start-delay` = `time`**: Start this service with the specified delay. Check how to specify times [here](https://github.com/tailhook/humantime/blob/49f11fdc2a59746085d2457cb46bce204dec746a/src/duration.rs#L338) -* **`stdout` = `STDOUT|STDERR|file-path`**: Redirect stdout of this service. STDOUT and STDERR are special strings, pointing to stdout and stderr respectively. Otherwise, a file path is assumed. + If service `a` should start after service `b`, then `a` will be started as soon as `b` is considered Running or + Finished. + If `b` goes in a `FinishedFailed` state (finished in an unsuccessful manner), `a` might not start at all. +* **`start-delay` = `time`**: Start this service with the specified delay. Check how to specify + times [here](https://github.com/tailhook/humantime/blob/49f11fdc2a59746085d2457cb46bce204dec746a/src/duration.rs#L338) +* **`stdout` = `STDOUT|STDERR|file-path`**: Redirect stdout of this service. STDOUT and STDERR are special strings, + pointing to stdout and stderr respectively. Otherwise, a file path is assumed. * **`stdout-rotate-size` = `string`**: Chunk size of the file specified in `stdout`. -Once the file grows above the specified size it will be closed and a new file will be created with a suffix `.1`. -Once the new file also grows above the specified size it will also be closed and a next one will be created with the next suffix `.2`. -This allows adding external log rotation script, which can compress the old logs and maybe move them out to a different storage location. -The size is parsed using `bytefmt` - for example `100 MB`, `200 KB`, `110 MIB` or `200 GIB`. -If unset, the default value will be `100 MB`. -* **`stderr` = `STDOUT|STDERR|file-path`**: Redirect stderr of this service. Read `stdout` above for a complete reference. + Once the file grows above the specified size it will be closed and a new file will be created with a suffix `.1`. + Once the new file also grows above the specified size it will also be closed and a next one will be created with the + next suffix `.2`. + This allows adding an external log rotation script, which can compress the old logs and maybe move them out to a + different storage location. + The size is parsed using `bytefmt` - for example `100 MB`, `200 KB`, `110 MIB` or `200 GIB`. + If unset, the default value will be `100 MB`. +* **`stdout_should_append_timestamp_to_filename` = `boolean`**: If true, the log file will get the timestamp of the run + appended to the end. It's helpful to avoid overwriting logs from different runs. +* **`stderr` = `STDOUT|STDERR|file-path`**: Redirect stderr of this service. Read `stdout` above for a complete + reference. * **`user` = `uid|username`**: Will run this service as this user. Either an uid or a username (check it in /etc/passwd) -* **`working-directory` = `string`**: Will run this command in this directory. Defaults to the working directory of the horust process. +* **`working-directory` = `string`**: Will run this command in this directory. Defaults to the working directory of the + horust process. #### Restart section + ```toml [restart] strategy = "never" backoff = "0s" attempts = 0 ``` + * **`strategy` = `always|on-failure|never`**: Defines the restart strategy. * `always`: Failure or Success, it will be always restarted * `on-failure`: Only if it has failed. Please check the `attempts` parameter below. * `never`: It won't be restarted, no matter what's the exit status. Please check the `attempts` parameter below. -* **`backoff` = `string`**: Use this time before retrying restarting the service. -* **`attempts` = `number`**: How many attempts to start the service before considering it as FinishedFailed. Default is 10. -Attempts are useful if your service is failing too quickly. If you're in a start-stop loop, this will put and end to it. -If a service has failed too quickly and attempts > 0, it will be restarted even if the strategy is `never`. -And if the attempts are over, it will never be restarted even if the restart policy is: `On-Failure`/`Always`. +* **`backoff` = `string`**: Use this time before retrying restarting the service. +* **`attempts` = `number`**: How many attempts to start the service before considering it as FinishedFailed. Default is + 10. + Attempts are useful if your service is failing too quickly. If you're in a start-stop loop, this will put and end to + it. + If a service has failed too quickly and attempts > 0, it will be restarted even if the strategy is `never`. + And if the attempts are over, it will never be restarted even if the restart policy is: `On-Failure`/`Always`. The delay between attempts is calculated as: `backoff * attempts_made + start-delay`. For instance, using: + * backoff = 1s * attempts = 3 * start-delay = 1s" Will wait 1 second and then start the service. If it doesn't start: + * 1st attempt will start after 1*1 + 1 = 2 seconds. * 2nd attempt will start after 1*2 + 1 = 3 seconds. -* 3d and last attempt will start after 1*3 +1 = 4 seconds. +* 3d and last attempt will start after 1*3 +1 = 4 seconds. If the attempts are over, then the service will be considered FailedFinished and won't be restarted. The attempt count is reset as soon as the service's state changes to running. -This state change is driven by the health-check component, and a service with no health-check will be considered as `Healthy` and it will +This state change is driven by the health-check component, and a service with no health-check will be considered as +`Healthy` and it will immediately pass to the running state. ### Healthiness Check + ```toml [healthiness] http-endpoint = "http://localhost:8080/healthcheck" file-path = "/var/myservice/up" max-failed = 3 ``` - * **`http-endpoint` = ``**: It will send an HEAD request to the specified http endpoint. 200 means the service is healthy, otherwise it will change the status to failure. - This requires horust to be built with the `http-healthcheck` feature (included by default). - * **`file-path` = `/path/to/file`**: Before running the service, it will remove this file if it exists. Then, as soon as this file is created, the service will be considered running. - * **`max-failed` = `i32`**: How many unhealthy health-checks in a row are allowed before considering the service failed. - * You can check the healthiness of your system using a http endpoint or a flag file. - * You can use the enforce dependency to kill every dependent system. + +* **`http-endpoint` = ``**: It will send an HEAD request to the specified http endpoint. 200 means the + service is healthy, otherwise it will change the status to failure. + This requires horust to be built with the `http-healthcheck` feature (included by default). +* **`file-path` = `/path/to/file`**: Before running the service, it will remove this file if it exists. Then, as soon as + this file is created, the service will be considered running. +* **`max-failed` = `i32`**: How many unhealthy health-checks in a row are allowed before considering the service failed. +* You can check the healthiness of your system using a http endpoint or a flag file. +* You can use the enforce dependency to kill every dependent system. ### Failure section + ```toml [failure] -successful-exit-code = [ 0, 1, 255] +successful-exit-code = [0, 1, 255] strategy = "ignore" ``` -* **`successful-exit-code` = `[\]`**: A comma separated list of exit code. -Usually a program is considered failed if its exit code is different from zero. But not all fails are the same. -With this parameter you can specify which exit codes will make this service considered as failed. -* **`strategy` = `shutdown|kill-dependents|ignore`**': We might want to kill the whole system, or part of it, if some service fails. Default: `ignore` - * `kill-dependents`: Dependents are all the services start after this one. So if service `b` has service `a` in its `start-after` section, - and `a` has strategy=kill-dependents, then b will be stopped if `a` fails. - * `shutdown`: Shut down all the services and exit Horust if this service has failed. +* **`successful-exit-code` = `[\]`**: A comma separated list of exit code. + Usually a program is considered failed if its exit code is different from zero. But not all fails are the same. + With this parameter you can specify which exit codes will make this service considered as failed. + +* **`strategy` = `shutdown|kill-dependents|ignore`**': We might want to kill the whole system, or part of it, if some + service fails. Default: `ignore` + * `kill-dependents`: Dependents are all the services start after this one. So if service `b` has service `a` in its + `start-after` section, + and `a` has strategy=kill-dependents, then b will be stopped if `a` fails. + * `shutdown`: Shut down all the services and exit Horust if this service has failed. ### Environment section + ```toml [environment] keep-env = false -re-export = [ "PATH", "DB_PASS"] -additional = { key = "value"} +re-export = ["PATH", "DB_PASS"] +additional = { key = "value" } ``` + * **`keep-env` = `bool`**: default: false. Pass over all the environment variables. -Regardless of the value of keep-env, the following keys will be updated / defined: + Regardless of the value of keep-env, the following keys will be updated / defined: * `USER` * `HOSTNAME` * `HOME` * `PATH` -Use `re-export` for keeping them. + Use `re-export` for keeping them. * **`re-export` = `[\]`**: Environment variables to keep and re-export. -This is useful for fine-grained exports or if you want for example to re-export the `PATH`. + This is useful for fine-grained exports or if you want for example to re-export the `PATH`. * **`additional` = `{ key = }`**: Defined as key-values, other environment variables to use. ### Termination section + ```toml [termination] signal = "TERM" wait = "10s" die-if-failed = ["db.toml"] ``` -* **`signal` = `"TERM|HUP|INT|QUIT|USR1|USR2|WINCH|..."`**: The _friendly_ signal used for shutting down the process. The full list of supported signal can be found [here](https://docs.rs/nix/0.20.0/nix/sys/signal/enum.Signal.html). + +* **`signal` = `"TERM|HUP|INT|QUIT|USR1|USR2|WINCH|..."`**: The _friendly_ signal used for shutting down the process. + The full list of supported signal can be found [here](https://docs.rs/nix/0.20.0/nix/sys/signal/enum.Signal.html). * **`wait` = `"time"`**: How much time to wait before sending a SIGKILL after `signal` has been sent. -* **`die-if-failed` = `[""]`**: As soon as any of the services defined in this the array fails, this service will be terminated as well. +* **`die-if-failed` = `[""]`**: As soon as any of the services defined in this the array fails, this + service will be terminated as well. --- ## State machine + [![State machine](https://github.com/FedericoPonzi/Horust/raw/master/res/state-machine.png)](https://github.com/FedericoPonzi/Horust/raw/master/res/state-machine.png) You can compile this on https://state-machine-cat.js.org/ + ``` initial => Initial : "Will eventually be run"; Initial => Starting : "All dependencies are running, a thread has spawned and will run the fork/exec the process"; @@ -176,43 +227,58 @@ Failed => Initial : "restart = always|on-failure"; ``` ## Horust's configuration + Horust can be configured by using the following parameters: + ```toml # Default time to wait after sending a `sigterm` to a process before sending a SIGKILL. unsuccessful-exit-finished-failed = true ``` + All the parameters can be passed via the cli (use `horust --help`) or via a config file. The default path for the config file is `/etc/horust/horust.toml`. ## Running a single command + You can wrap a single command with horust by running: + ``` bash ./horust -- bash /tmp/myscript.sh ``` + This is equivalent to running a single service defined as: + ``` command= "bash /tmp/myscript.sh" ``` + This will run the specified command as a one shot service, so it won't be restarted after exiting. -_Commands have precedence over services, so if you specify both a command and a services-path, the command will be executed and the `--services-path` is ignored._ +_Commands have precedence over services, so if you specify both a command and a services-path, the command will be +executed and the `--services-path` is ignored._ ## Multiple service directories -You can you use the `--services-path` parameter to specify either a directory containing .toml services to run, or + +You can you use the `--services-path` parameter to specify either a directory containing .toml services to run, or point it to a .toml service file to run. You can specify multiple service directories by passing more than one `--services-path` arguments. + ```sh horust --services-path ./services/core --services-path ./services/extra --services-path ./my-service.toml ``` + These directories are loaded at once and treated just like all `*.toml` files were in single shared directory. It means that for example service from `./services/extra` can depend on service from `./services/core`. The last parameter is used to load a single service file instead of a directory. ## Plugins (WIP) -Horust works via message passing, it should be fairly easy to plug additional components connected to its bus. -At this time is unclear if there is the need for this. Please raise an issue if you're interested in seeing this feature. + +Horust works via message passing, it should be fairly easy to plug additional components connected to its bus. +At this time is unclear if there is the need for this. Please raise an issue if you're interested in seeing this +feature. ## Checking system status (WIP) + WIP: https://github.com/FedericoPonzi/Horust/issues/31 The idea is to create another binary, which will somehow report the system status. A `systemctl` for Horust. diff --git a/example_services/sample_service.toml b/example_services/sample_service.toml index bd98d7c..eb6309b 100644 --- a/example_services/sample_service.toml +++ b/example_services/sample_service.toml @@ -4,6 +4,7 @@ start-delay = "2s" start-after = ["database", "backend.toml"] stdout = "/var/logs/hello_world_svc/stdout.log" stdout-rotate-size = "100 MB" +stdout-should-append-timestamp-to-filename = false stderr = "STDERR" # Check also `templating.toml` user = "${USER}" diff --git a/src/horust/formats/service.rs b/src/horust/formats/service.rs index 09bf7ef..e1d0028 100644 --- a/src/horust/formats/service.rs +++ b/src/horust/formats/service.rs @@ -1,3 +1,8 @@ +use anyhow::{Context, Error, Result}; +use nix::sys::signal::Signal; +use nix::unistd; +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::HashMap; use std::ffi::OsStr; use std::fmt::{Debug, Formatter}; @@ -6,12 +11,6 @@ use std::str::FromStr; use std::time::Duration; use std::{env, os::fd::RawFd}; -use anyhow::{Context, Error, Result}; -use nix::sys::signal::Signal; -use nix::unistd; -use serde::de::{self, Visitor}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use crate::horust::error::{ValidationError, ValidationErrors}; pub fn get_sample_service() -> &'static str { @@ -33,8 +32,11 @@ pub struct Service { pub working_directory: PathBuf, #[serde(default = "Service::default_stdout_log")] pub stdout: LogOutput, + // todo: provide serialize_with #[serde(default, skip_serializing, deserialize_with = "str_to_bytes")] pub stdout_rotate_size: u64, + #[serde(default = "default_as_false")] + pub stdout_should_append_timestamp_to_filename: bool, #[serde(default = "Service::default_stderr_log")] pub stderr: LogOutput, #[serde(default, with = "humantime_serde")] @@ -55,6 +57,10 @@ pub struct Service { pub termination: Termination, } +fn default_as_false() -> bool { + false +} + impl Service { fn default_working_directory() -> PathBuf { env::current_dir().unwrap() @@ -107,6 +113,7 @@ impl Default for Service { working_directory: env::current_dir().unwrap(), stdout: Default::default(), stdout_rotate_size: 0, + stdout_should_append_timestamp_to_filename: Default::default(), stderr: Default::default(), user: Default::default(), restart: Default::default(), @@ -698,6 +705,7 @@ mod test { working_directory: "/tmp/".into(), stdout: "/var/logs/hello_world_svc/stdout.log".into(), stdout_rotate_size: 100_000_000, + stdout_should_append_timestamp_to_filename: false, stderr: "STDERR".into(), start_delay: Duration::from_secs(2), start_after: vec!["database".into(), "backend.toml".into()], diff --git a/src/horust/supervisor/process_spawner.rs b/src/horust/supervisor/process_spawner.rs index dd5701d..818f6b5 100644 --- a/src/horust/supervisor/process_spawner.rs +++ b/src/horust/supervisor/process_spawner.rs @@ -1,7 +1,13 @@ +use anyhow::{anyhow, Context, Result}; +use crossbeam::channel::{after, tick}; +use nix::errno::Errno; +use nix::fcntl; +use nix::unistd; +use nix::unistd::{fork, ForkResult, Pid, Uid}; use std::ffi::{CStr, CString}; use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::{fs::File, io::BufReader}; use std::{fs::OpenOptions, ops::Add}; use std::{ @@ -9,13 +15,6 @@ use std::{ os::fd::OwnedFd, }; -use anyhow::{anyhow, Context, Result}; -use crossbeam::channel::{after, tick}; -use nix::errno::Errno; -use nix::fcntl; -use nix::unistd; -use nix::unistd::{fork, ForkResult, Pid, Uid}; - use crate::horust::bus::BusConnector; use crate::horust::formats::{Event, LogOutput, Service}; use crate::horust::signal_safe::panic_ssafe; @@ -204,18 +203,26 @@ fn redirect_output( Ok(()) } -fn open_next_chunk(base_path: &Path) -> io::Result { - let mut count = 1; - let mut path = base_path; - let mut path_str; +fn open_next_chunk( + base_path: &Path, + timestamp: u64, + stdout_should_append_timestamp_to_filename: bool, + count: u32, +) -> io::Result { + let filename = match (stdout_should_append_timestamp_to_filename, count > 0) { + (true, true) => format!("{}.{timestamp}.{count}", base_path.to_string_lossy()), + (true, false) => format!("{}.{timestamp}", base_path.to_string_lossy()), + (false, true) => format!("{}.{count}", base_path.to_string_lossy()), + (false, false) => base_path.to_string_lossy().to_string(), + }; + let path = PathBuf::from(&filename); - while path.is_file() { - path_str = format!("{}.{count}", base_path.to_string_lossy()); - path = Path::new(&path_str); - count += 1; - } debug!("Opening next log output: {}", path.display()); - OpenOptions::new().create(true).append(true).open(path) + OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) } fn chunked_writer(fd: OwnedFd, service: Service) -> Result<()> { @@ -224,10 +231,23 @@ fn chunked_writer(fd: OwnedFd, service: Service) -> Result<()> { LogOutput::Path(path) => path, _ => return Err(anyhow!("Log output path is not set")), }; + let mut chunk = 0; + let mut reader = BufReader::new(&source); + // Get the current Unix timestamp + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); loop { - let mut reader = BufReader::new(&source).take(service.stdout_rotate_size); - let mut output = open_next_chunk(path)?; - let copied = io::copy(&mut reader, &mut output)?; + let mut capped = (&mut reader).take(service.stdout_rotate_size); + let mut output = open_next_chunk( + path, + timestamp, + service.stdout_should_append_timestamp_to_filename, + chunk, + )?; + chunk += 1; + let copied = io::copy(&mut capped, &mut output)?; if copied < service.stdout_rotate_size { debug!("EOF reached"); break; diff --git a/tests/section_general.rs b/tests/section_general.rs index ecced1c..5c3cca0 100644 --- a/tests/section_general.rs +++ b/tests/section_general.rs @@ -59,31 +59,29 @@ fn test_output_redirection() { #[test] fn test_output_log_rotation() { - // todo: review this test - let pattern = "Hello"; - let max_size = 50; - let num_logs = 4; + // Used as arg for printf. Each number will have up to this number of leading zeros. + let pattern_len = 4 + "\n".len(); // + 1 for the new line. + let patterns_per_file = 10; + + let max_log_size = patterns_per_file * pattern_len; + let num_logs = 10; let (mut cmd, temp_dir) = get_cli(); let output = temp_dir.path().join("out.log").display().to_string(); - let last_output = temp_dir - .path() - .join(format!("out.log.{}", num_logs - 2)) - .display() - .to_string(); + + // How many patterns do we need to repeat to reach the required file size. + let total_iterations = (max_log_size * num_logs) / (pattern_len); + let script = format!( r#"#!/usr/bin/env bash -for i in {{1..{}}}; do echo {} ; done + set -x +for i in {{1..{total_iterations}}}; do printf "%0{}d\n" "$i" ; done sync -sleep 10 -exit 0 "#, - // How many patterns do we need to repeat to reach required file size. - 15 + (max_size * num_logs) / (pattern.len() + 1), - pattern, + pattern_len - 1 // - 1 is the new line. ); let service = [ format!(r#"stdout="{}""#, output), - format!(r#"stdout-rotate-size="{}""#, max_size), + format!(r#"stdout-rotate-size="{}""#, max_log_size), ] .join("\n"); store_service_script( @@ -93,7 +91,7 @@ exit 0 None, ); cmd.assert().success().stdout(is_empty()); - + let last_output = temp_dir.path().join(format!("out.log.{}", num_logs - 1)); // print the content of temp_dir directory: let mut ls = std::process::Command::new("ls") .arg("-l") @@ -101,13 +99,30 @@ exit 0 .spawn() .unwrap(); let output = ls.wait_with_output().unwrap(); - println!("{}", String::from_utf8_lossy(&output.stdout)); - - let content = std::fs::read_to_string(last_output).unwrap(); - assert!( - content.starts_with(pattern), - "Expeccted '{content}' to start with '{pattern}'" + println!( + "{:?}:\n{}", + temp_dir.path(), + String::from_utf8_lossy(&output.stdout) ); + assert!(last_output.exists()); + + // it is effectively the last output + assert!(temp_dir + .path() + .join(format!("out.log.{}", num_logs)) + .exists()); + + let content = std::fs::read_to_string(&last_output).unwrap(); + let last_file_first_number = total_iterations - (patterns_per_file); + let mut expected_content = String::new(); + // the last patterns_file number, say from (90, 100] + for i in last_file_first_number + 1..=total_iterations { + let mut number = i.to_string(); + number = format!("{:0>4}", number); + expected_content.push_str(&number); + expected_content.push('\n'); + } + assert_eq!(content, expected_content); } #[test]