1mod baseline;
2mod journeys;
3mod script;
4
5use std::collections::BTreeSet;
6use std::ffi::OsStr;
7use std::fs::{self, File};
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, Stdio};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use serde_json::json;
14use sofie_model::App;
15
16pub use baseline::{Comparison, Measurement, RouteResult, Verdict};
17
18use baseline::compare;
19
20use crate::build_command::{BuildOptions, build_binary};
21use crate::dev_command::app_command;
22use crate::gen_command::{GenError, io_error, read_existing};
23use crate::local_app::{
24 MEMBER_PASSWORD, bench_member, free_address, private_directory, refuse_remote_database,
25 wait_answering,
26};
27use crate::source_edits::{read, write};
28use script::bench_script;
29
30const BENCHABLE: [&str; 3] = ["local", "testing", "staging"];
31const STOP_WAIT: Duration = Duration::from_secs(30);
32const LOG_TAIL: usize = 40;
33
34pub struct BenchReport {
35 pub skipped: Vec<String>,
36 pub comparison: Comparison,
37 pub baseline: PathBuf,
38}
39
40pub fn bench(
45 root: &Path,
46 target: Option<&str>,
47 environment: Option<&str>,
48) -> Result<BenchReport, GenError> {
49 let variable = std::env::var("SOFIE_ENVIRONMENT").ok();
50 let environment = bench_environment(target, environment, variable.as_deref())?;
51 if target.is_none() {
52 refuse_remote_database(root, "bench")?;
53 }
54 let app = sofie_model::load(root).map_err(GenError::Model)?;
55 let scripts = Scripts::write(root, &app)?;
56 let measured = if let Some(target) = target {
57 scripts.run(target)?
58 } else {
59 let built = build_binary(root, &app, BuildOptions::default())?;
60 let running = serve(root, &built.path, &environment, &[])?;
61 scripts.run(&running.target)?
62 };
63 let baseline_file = root.join("bench").join("baseline.json");
64 let baseline = match read_existing(&baseline_file)? {
65 Some(text) => baseline::parse(&text)
66 .map_err(|error| GenError::App(format!("{}: {error}", baseline_file.display())))?,
67 None => baseline::Measurements::new(),
68 };
69 let comparison = compare(&baseline, &measured);
70 if !comparison.regressed() {
71 write(&baseline_file, baseline::render(&comparison.next_baseline))?;
72 }
73 Ok(BenchReport {
74 skipped: scripts.skipped,
75 comparison,
76 baseline: baseline_file,
77 })
78}
79
80pub(crate) struct Scripts {
82 endpoints: PathBuf,
83 journeys: Option<PathBuf>,
84 skipped: Vec<String>,
86 member_areas: BTreeSet<String>,
87}
88
89impl Scripts {
90 pub(crate) fn write(root: &Path, app: &App) -> Result<Self, GenError> {
91 let folder = root.join("bench").join("gen");
92 let script = bench_script(app)?;
93 let endpoints = folder.join("endpoints.js");
94 write(&endpoints, &script.text)?;
95 let client = journeys::client(app)?;
96 write(&folder.join("client.ts"), &client.text)?;
97 let mut member_areas: BTreeSet<String> = script.member_areas.into_iter().collect();
98 let found = journeys::journeys(app)?;
99 let runner = folder.join("journeys.ts");
100 let journeys = if found.is_empty() {
101 remove_stale(&runner)?;
102 None
103 } else {
104 write(&runner, journeys::runner(&found))?;
105 member_areas.extend(client.member_areas);
106 Some(runner)
107 };
108 Ok(Self {
109 endpoints,
110 journeys,
111 skipped: script.skipped,
112 member_areas,
113 })
114 }
115
116 pub(crate) fn run(&self, target: &str) -> Result<baseline::Measurements, GenError> {
117 let accounts = accounts(target, &self.member_areas, |name| std::env::var(name).ok())?;
118 let mut measured = run_k6(&self.endpoints, target, &accounts)?;
119 if let Some(journeys) = &self.journeys {
120 measured.extend(run_k6(journeys, target, &accounts)?);
121 }
122 Ok(measured)
123 }
124}
125
126fn accounts(
130 target: &str,
131 areas: &BTreeSet<String>,
132 variable: impl Fn(&str) -> Option<String>,
133) -> Result<String, GenError> {
134 let local = is_local_target(target);
135 let mut accounts = serde_json::Map::new();
136 let mut missing = Vec::new();
137 for area in areas {
138 let account = if local {
139 json!({ "email": bench_member(area), "password": MEMBER_PASSWORD, "signUp": true })
140 } else {
141 let prefix = format!("SOFIE_BENCH_{}", area.to_ascii_uppercase());
142 let (email, password) = (format!("{prefix}_EMAIL"), format!("{prefix}_PASSWORD"));
143 let (Some(email_value), Some(password_value)) = (variable(&email), variable(&password))
144 else {
145 missing.push(format!("{email} and {password} for area `{area}`"));
146 continue;
147 };
148 json!({ "email": email_value, "password": password_value, "signUp": false })
149 };
150 accounts.insert(area.clone(), account);
151 }
152 if !missing.is_empty() {
153 return Err(GenError::Usage(format!(
154 "sofie bench signs up nobody on {target}, a server not on this machine; \
155 name an existing account with {}",
156 missing.join("; ")
157 )));
158 }
159 Ok(serde_json::Value::Object(accounts).to_string())
160}
161
162fn is_local_target(target: &str) -> bool {
163 let rest = target.split_once("://").map_or(target, |(_, rest)| rest);
164 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
165 let host_and_port = authority
166 .rsplit_once('@')
167 .map_or(authority, |(_, host)| host);
168 let host = match host_and_port.strip_prefix('[') {
169 Some(bracketed) => bracketed.split(']').next().unwrap_or_default(),
170 None => host_and_port.split(':').next().unwrap_or_default(),
171 };
172 matches!(
173 host.to_ascii_lowercase().as_str(),
174 "localhost" | "127.0.0.1" | "::1"
175 )
176}
177
178fn remove_stale(path: &Path) -> Result<(), GenError> {
179 match fs::remove_file(path) {
180 Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(io_error(path, error)),
181 _ => Ok(()),
182 }
183}
184
185pub fn bench_environment(
187 target: Option<&str>,
188 flag: Option<&str>,
189 variable: Option<&str>,
190) -> Result<String, GenError> {
191 let environment = match (flag.or(variable), target) {
192 (Some(named), _) => named,
193 (None, None) => "local",
194 (None, Some(target)) => {
195 return Err(GenError::Usage(format!(
196 "name the environment {target} runs in with `--environment` or SOFIE_ENVIRONMENT; \
197 sofie bench loads only {}",
198 BENCHABLE.join(", ")
199 )));
200 }
201 };
202 if let Some(target) = target.filter(|target| environment == "local" && !is_local_target(target))
203 {
204 return Err(GenError::Usage(format!(
205 "{target} is not local, yet its environment is named `local`; name the environment \
206 it runs in with `--environment`"
207 )));
208 }
209 if !BENCHABLE.contains(&environment) {
210 return Err(GenError::Usage(format!(
211 "sofie bench refuses the `{environment}` environment; it loads only {}",
212 BENCHABLE.join(", ")
213 )));
214 }
215 Ok(environment.to_owned())
216}
217
218pub(crate) struct Running {
219 child: Child,
220 pub(crate) target: String,
221 directory: PathBuf,
223}
224
225impl Running {
226 pub(crate) fn stop(mut self) -> Result<(), GenError> {
228 let pid = self.child.id().to_string();
229 let sent = Command::new("kill")
230 .args(["-TERM", &pid])
231 .status()
232 .map_err(|source| GenError::App(format!("sending SIGTERM to {pid}: {source}")))?;
233 if !sent.success() {
234 return Err(GenError::App(format!(
235 "`kill -TERM {pid}` failed with {sent}"
236 )));
237 }
238 let deadline = Instant::now() + STOP_WAIT;
239 while Instant::now() < deadline {
240 let exited = self
241 .child
242 .try_wait()
243 .map_err(|source| GenError::App(format!("waiting for {pid}: {source}")))?;
244 match exited {
245 Some(status) if status.success() => return Ok(()),
246 Some(status) => {
247 return Err(GenError::App(format!(
248 "the app (pid {pid}) ended with {status} after SIGTERM"
249 )));
250 }
251 None => {}
252 }
253 thread::sleep(Duration::from_millis(100));
254 }
255 Err(GenError::App(format!(
256 "the app (pid {pid}) did not stop within {} s of SIGTERM",
257 STOP_WAIT.as_secs()
258 )))
259 }
260}
261
262impl Drop for Running {
265 fn drop(&mut self) {
266 if let Err(error) = self.child.kill().and_then(|()| self.child.wait().map(drop)) {
267 eprintln!(
268 "sofie bench: stopping the app (pid {}): {error}",
269 self.child.id()
270 );
271 }
272 if let Err(error) = fs::remove_dir_all(&self.directory) {
273 eprintln!(
274 "sofie bench: removing {}: {error}",
275 self.directory.display()
276 );
277 }
278 }
279}
280
281pub(crate) fn serve(
282 root: &Path,
283 binary: &Path,
284 environment: &str,
285 variables: &[(&str, &OsStr)],
286) -> Result<Running, GenError> {
287 let address = free_address()?;
288 let directory = private_directory("sofie-bench")?;
289 let log = directory.join("app.log");
290 let output = File::create(&log).map_err(|source| io_error(&log, source))?;
291 let errors = output
292 .try_clone()
293 .map_err(|source| io_error(&log, source))?;
294 let child = app_command(root, binary)?
295 .arg("serve")
296 .envs(variables.iter().copied())
297 .env("SOFIE_ADDRESS", address.to_string())
298 .env("SOFIE_ENVIRONMENT", environment)
299 .env("SOFIE_MIGRATE", "on")
300 .env("SOFIE_SHIELD", "off")
301 .stdout(output)
302 .stderr(errors)
303 .spawn()
304 .map_err(|source| io_error(binary, source))?;
305 let mut running = Running {
306 child,
307 target: format!("http://{address}"),
308 directory,
309 };
310 let child = &mut running.child;
311 if let Err(error) = wait_answering(address, "/ready", || {
312 child.try_wait().map_err(|source| io_error(binary, source))
313 }) {
314 let text = read(&log)?;
315 let lines: Vec<&str> = text.lines().collect();
316 let last = lines[lines.len().saturating_sub(LOG_TAIL)..].join("\n");
317 return Err(GenError::App(format!("{error}; its last lines:\n{last}")));
318 }
319 Ok(running)
320}
321
322fn run_k6(script: &Path, target: &str, accounts: &str) -> Result<baseline::Measurements, GenError> {
323 let directory = private_directory("sofie-bench-summary")?;
324 let measured = run_k6_into(script, target, accounts, &directory.join("summary.json"));
325 fs::remove_dir_all(&directory).map_err(|source| io_error(&directory, source))?;
326 measured
327}
328
329fn run_k6_into(
330 script: &Path,
331 target: &str,
332 accounts: &str,
333 summary: &Path,
334) -> Result<baseline::Measurements, GenError> {
335 let status = Command::new("k6")
336 .arg("run")
337 .arg("--env")
338 .arg(format!(
339 "SOFIE_BENCH_TARGET={}",
340 target.trim_end_matches('/')
341 ))
342 .arg("--env")
343 .arg(format!("SOFIE_BENCH_SUMMARY={}", summary.display()))
344 .arg(script)
345 .env("SOFIE_BENCH_ACCOUNTS", accounts)
346 .stdin(Stdio::null())
347 .status()
348 .map_err(|source| {
349 GenError::Usage(format!("running k6: {source}; install k6 (https://k6.io)"))
350 })?;
351 if !status.success() {
352 return Err(GenError::App(format!("k6 failed with {status}")));
353 }
354 let text = read(summary)?;
355 baseline::parse(&text)
356 .map_err(|error| GenError::App(format!("k6's summary {}: {error}", summary.display())))
357}
358
359#[cfg(test)]
360mod tests {
361 use std::collections::BTreeSet;
362
363 use super::{accounts, bench_environment};
364
365 #[test]
366 fn signs_up_only_on_this_machine_and_takes_named_accounts_elsewhere() {
367 let areas = BTreeSet::from(["customer".to_owned()]);
368 let none = |_: &str| None;
369 let named = |name: &str| Some(format!("{name}-value"));
370
371 let local = accounts("http://127.0.0.1:4000", &areas, none).unwrap();
372 let refused = accounts("https://staging.example.com", &areas, none).unwrap_err();
373 let remote = accounts("https://staging.example.com", &areas, named).unwrap();
374
375 assert!(local.contains("\"signUp\":true"), "{local}");
376 assert!(local.contains("bench-customer@example.com"), "{local}");
377 assert!(
378 refused
379 .to_string()
380 .contains("SOFIE_BENCH_CUSTOMER_EMAIL and SOFIE_BENCH_CUSTOMER_PASSWORD"),
381 "{refused}"
382 );
383 assert!(remote.contains("\"signUp\":false"), "{remote}");
384 assert!(
385 remote.contains("SOFIE_BENCH_CUSTOMER_PASSWORD-value"),
386 "{remote}"
387 );
388 assert!(
389 !remote.contains(crate::local_app::MEMBER_PASSWORD),
390 "{remote}"
391 );
392 }
393
394 #[test]
395 fn a_target_is_local_only_on_a_loopback_host() {
396 assert!(super::is_local_target("http://localhost:3000/"));
397 assert!(super::is_local_target("http://[::1]:3000"));
398 assert!(!super::is_local_target("https://localhost.example.com"));
399 assert!(!super::is_local_target(
400 "https://127.0.0.1@shop.example.com"
401 ));
402 }
403
404 #[test]
405 fn refuses_production_and_a_target_whose_environment_is_unnamed() {
406 let production = bench_environment(None, Some("production"), None).unwrap_err();
407 let unnamed = bench_environment(Some("https://shop.example.com"), None, None).unwrap_err();
408 let from_variable = bench_environment(Some("https://shop.example.com"), None, Some("prod"));
409
410 assert!(
411 production
412 .to_string()
413 .contains("refuses the `production` environment")
414 );
415 assert!(
416 unnamed
417 .to_string()
418 .contains("name the environment https://shop.example.com")
419 );
420 assert!(from_variable.is_err());
421 }
422
423 #[test]
425 fn refuses_calling_a_remote_target_local() {
426 let remote = "https://shop.example.com";
427
428 let from_variable = bench_environment(Some(remote), None, Some("local")).unwrap_err();
429 let from_flag = bench_environment(Some(remote), Some("local"), None);
430 let here = bench_environment(Some("http://127.0.0.1:8080"), None, Some("local"));
431
432 assert!(
433 from_variable.to_string().contains("is not local"),
434 "{from_variable}"
435 );
436 assert!(from_flag.is_err());
437 assert_eq!(here.unwrap(), "local");
438 }
439
440 #[test]
441 fn accepts_local_testing_and_staging_with_the_flag_over_the_variable() {
442 assert_eq!(bench_environment(None, None, None).unwrap(), "local");
443 assert_eq!(
444 bench_environment(
445 Some("https://staging.example.com"),
446 Some("staging"),
447 Some("production")
448 )
449 .unwrap(),
450 "staging"
451 );
452 assert_eq!(
453 bench_environment(None, None, Some("testing")).unwrap(),
454 "testing"
455 );
456 }
457}