1use std::sync::Arc;
8use std::time::Duration;
9
10use rainier_console::{exit, Arguments, Command};
11use rainier_container::Application;
12use rainier_database::{Database, Migrator};
13use rainier_events::Dispatcher;
14use rainier_queue::{QueueManager, Worker, WorkerOptions};
15use rainier_routing::CompiledRouter;
16use rainier_server::{Kernel, Server, ServerOptions};
17use rainier_support::Result;
18
19#[derive(Debug, Default)]
21pub struct RouteListCommand;
22
23#[async_trait::async_trait]
24impl Command for RouteListCommand {
25 fn name(&self) -> &str {
26 "route:list"
27 }
28
29 fn description(&self) -> &str {
30 "List every registered route"
31 }
32
33 fn help(&self) -> Option<&str> {
34 Some("Usage:\n route:list [--json]\n\nOptions:\n --json Emit the table as JSON")
35 }
36
37 async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
38 let router = app.resolve::<CompiledRouter>()?;
39 let rows = router.describe();
40
41 if args.flag("json") {
42 let payload: Vec<serde_json::Value> = rows
43 .iter()
44 .map(|row| {
45 serde_json::json!({
46 "methods": row.methods,
47 "uri": row.uri,
48 "name": row.name,
49 "middleware": row.middleware,
50 })
51 })
52 .collect();
53 println!("{}", serde_json::to_string_pretty(&payload)?);
54 return Ok(exit::SUCCESS);
55 }
56
57 if rows.is_empty() {
58 println!("No routes are registered.");
59 return Ok(exit::SUCCESS);
60 }
61
62 let method_width = rows.iter().map(|r| r.methods.join("|").len()).max().unwrap_or(6);
65 let uri_width = rows.iter().map(|r| r.uri.len()).max().unwrap_or(3);
66 let name_width =
67 rows.iter().map(|r| r.name.as_deref().unwrap_or("").len()).max().unwrap_or(4);
68
69 println!(
70 "{:method_width$} {:uri_width$} {:name_width$} MIDDLEWARE",
71 "METHOD", "URI", "NAME"
72 );
73 for row in &rows {
74 println!(
75 "{:method_width$} {:uri_width$} {:name_width$} {}",
76 row.methods.join("|"),
77 row.uri,
78 row.name.as_deref().unwrap_or(""),
79 row.middleware.join(", "),
80 );
81 }
82 println!("\n{} route(s)", rows.len());
83 Ok(exit::SUCCESS)
84 }
85}
86
87#[derive(Debug, Default)]
89pub struct ServeCommand;
90
91#[async_trait::async_trait]
92impl Command for ServeCommand {
93 fn name(&self) -> &str {
94 "serve"
95 }
96
97 fn description(&self) -> &str {
98 "Start the HTTP server"
99 }
100
101 fn help(&self) -> Option<&str> {
102 Some(
103 "Usage:\n serve [--host=127.0.0.1] [--port=8000]\n\n\
104 Defaults come from `server.host` and `server.port` in the config.",
105 )
106 }
107
108 async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
109 let config = app.resolve::<rainier_config::Config>()?;
110 let kernel = app.resolve::<Kernel>()?;
111
112 let host = args
113 .option("host")
114 .map(str::to_string)
115 .unwrap_or_else(|| config.get_or("server.host", "127.0.0.1".to_string()));
116 let port: u16 = args.parsed_or("port", config.get_or("server.port", 8000u16));
117 let max_body: usize = config.get_or("server.max_body_bytes", 2 * 1024 * 1024usize);
118
119 let options = ServerOptions::default()
120 .bind_to(&host, port)?
121 .max_body_bytes(max_body)
122 .trust_forwarded_for(config.get_or("server.trust_proxy", false));
123
124 let mut server = Server::from_arc(kernel).with_options(options);
125
126 if let Ok(sockets) = app.resolve::<rainier_websocket::WebSocketRoutes>() {
129 if !sockets.is_empty() {
130 println!(" websockets: {}", sockets.patterns().join(", "));
131 server = server.with_websockets(sockets);
132 }
133 }
134
135 println!("Rainier is serving http://{host}:{port} — press Ctrl-C to stop");
136 server.run().await?;
137
138 app.terminate();
141 Ok(exit::SUCCESS)
142 }
143}
144
145#[derive(Debug, Default)]
147pub struct MigrateCommand;
148
149#[async_trait::async_trait]
150impl Command for MigrateCommand {
151 fn name(&self) -> &str {
152 "migrate"
153 }
154
155 fn description(&self) -> &str {
156 "Run any migrations that have not been applied"
157 }
158
159 fn help(&self) -> Option<&str> {
160 Some("Usage:\n migrate [--pretend]\n\nOptions:\n --pretend List what would run")
161 }
162
163 async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
164 let database = app.resolve::<Database>()?;
165 let migrator = app.resolve::<Migrator>()?;
166
167 if args.flag("pretend") {
168 let pending = migrator.pending(&database).await?;
169
170 if pending.is_empty() {
171 println!("Nothing to migrate.");
172 } else {
173 println!("Would run:");
174 for name in pending {
175 println!(" {name}");
176 }
177 }
178 return Ok(exit::SUCCESS);
179 }
180
181 let ran = migrator.run(&database).await?;
182 if ran.is_empty() {
183 println!("Nothing to migrate.");
184 } else {
185 for name in &ran {
186 println!("Migrated: {name}");
187 }
188 println!("\n{} migration(s) applied.", ran.len());
189
190 let irreversible = migrator.irreversible(database.dialect());
193 let stuck: Vec<&&str> =
194 irreversible.iter().filter(|name| ran.iter().any(|r| r == *name)).collect();
195 if !stuck.is_empty() {
196 println!("\nNot reversible, so `migrate:rollback` will refuse this batch:");
197 for name in stuck {
198 println!(" {name}");
199 }
200 }
201 }
202 Ok(exit::SUCCESS)
203 }
204}
205
206#[derive(Debug, Default)]
208pub struct MigrateRollbackCommand;
209
210#[async_trait::async_trait]
211impl Command for MigrateRollbackCommand {
212 fn name(&self) -> &str {
213 "migrate:rollback"
214 }
215
216 fn description(&self) -> &str {
217 "Undo the last batch of migrations"
218 }
219
220 fn help(&self) -> Option<&str> {
221 Some(
222 "Usage:\n migrate:rollback [--batches=1] [--pretend]\n\n\
223 Options:\n \
224 --batches=N How many batches to undo (default 1)\n \
225 --pretend List what would be undone\n\n\
226 A batch is everything one `migrate` applied. Steps are undone in\n\
227 reverse, and the whole rollback is refused up front if any step in\n\
228 range declared itself irreversible.",
229 )
230 }
231
232 async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
233 let database = app.resolve::<Database>()?;
234 let migrator = app.resolve::<Migrator>()?;
235 let batches: u32 = args.parsed_or("batches", 1);
236
237 if args.flag("pretend") {
238 let targets = migrator.rollback_targets(&database, batches).await?;
239 if targets.is_empty() {
240 println!("Nothing to roll back.");
241 return Ok(exit::SUCCESS);
242 }
243
244 let irreversible = migrator.irreversible(database.dialect());
248
249 println!("Would undo, in this order:");
250 for name in &targets {
251 let blocked = irreversible.contains(&name.as_str());
252 println!(" {name}{}", if blocked { " (NOT REVERSIBLE)" } else { "" });
253 }
254
255 let blocking: Vec<&String> =
256 targets.iter().filter(|name| irreversible.contains(&name.as_str())).collect();
257 if !blocking.is_empty() {
258 println!(
259 "
260{} step(s) in this range cannot be undone, so the rollback will refuse the whole batch rather than half-apply it.",
261 blocking.len()
262 );
263 }
264
265 return Ok(exit::SUCCESS);
266 }
267
268 let rolled_back = migrator.rollback(&database, batches).await?;
269 if rolled_back.is_empty() {
270 println!("Nothing to roll back.");
271 } else {
272 for name in &rolled_back {
273 println!("Rolled back: {name}");
274 }
275 println!("\n{} migration(s) rolled back.", rolled_back.len());
276 }
277 Ok(exit::SUCCESS)
278 }
279}
280
281#[derive(Debug, Default)]
283pub struct QueueWorkCommand;
284
285#[async_trait::async_trait]
286impl Command for QueueWorkCommand {
287 fn name(&self) -> &str {
288 "queue:work"
289 }
290
291 fn description(&self) -> &str {
292 "Process jobs from the queue"
293 }
294
295 fn help(&self) -> Option<&str> {
296 Some(
297 "Usage:\n queue:work [--queue=default,high] [--once] [--max-jobs=N] [--sleep=1]\n\n\
298 Options:\n \
299 --queue Comma-separated queues, in priority order\n \
300 --once Process what is waiting, then stop\n \
301 --max-jobs Stop after N jobs (a worker that recycles)\n \
302 --sleep Seconds to wait when the queue is empty",
303 )
304 }
305
306 async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
307 let manager = app.resolve::<QueueManager>()?;
308
309 let queues: Vec<String> = args
310 .option_or("queue", "default")
311 .split(',')
312 .map(|name| name.trim().to_string())
313 .filter(|name| !name.is_empty())
314 .collect();
315
316 let mut options = WorkerOptions::default()
317 .queues(queues.clone())
318 .sleep(Duration::from_secs(args.parsed_or("sleep", 1u64)));
319
320 if args.flag("once") {
321 options = options.stop_when_empty();
322 }
323 if let Some(max) = args.option("max-jobs").and_then(|value| value.parse().ok()) {
324 options = options.max_jobs(max);
325 }
326
327 let mut worker = Worker::new(
328 Arc::clone(manager.queue()),
329 Arc::clone(manager.registry()),
330 Arc::clone(app.container()),
331 )
332 .with_options(options);
333
334 if let Ok(events) = app.resolve::<Dispatcher>() {
337 worker = worker.with_events(events);
338 }
339
340 println!("Processing jobs from: {}", queues.join(", "));
341 let stats = worker.run().await?;
342
343 println!(
344 "\nProcessed {}, retried {}, failed {}.",
345 stats.processed, stats.released, stats.failed
346 );
347 Ok(if stats.failed > 0 { exit::FAILURE } else { exit::SUCCESS })
348 }
349}
350
351#[derive(Debug, Default)]
353pub struct KeyGenerateCommand;
354
355#[async_trait::async_trait]
356impl Command for KeyGenerateCommand {
357 fn name(&self) -> &str {
358 "key:generate"
359 }
360
361 fn description(&self) -> &str {
362 "Generate an application key for APP_KEY"
363 }
364
365 fn help(&self) -> Option<&str> {
366 Some(
367 "Usage:\n key:generate\n\n\
368 Prints a new key. Put it in `.env` as APP_KEY.\n\n\
369 Rotating: move the current APP_KEY into APP_PREVIOUS_KEYS (a\n\
370 comma-separated list), put the new one in APP_KEY, and deploy.\n\
371 Retired keys are still needed to read what they wrote, so do not\n\
372 remove one until nothing encrypted with it remains.",
373 )
374 }
375
376 async fn handle(&self, _args: &Arguments, _app: &Application) -> Result<i32> {
377 println!("{}", rainier_crypt::Key::generate().to_base64());
381 Ok(exit::SUCCESS)
382 }
383}
384
385pub fn console(name: impl Into<String>) -> rainier_console::Console {
387 rainier_console::Console::new(name)
388 .register(RouteListCommand)
389 .register(ServeCommand)
390 .register(MigrateCommand)
391 .register(MigrateRollbackCommand)
392 .register(QueueWorkCommand)
393 .register(crate::scheduling::ScheduleRunCommand)
394 .register(crate::scheduling::ScheduleWorkCommand)
395 .register(crate::scheduling::ScheduleListCommand)
396 .register(KeyGenerateCommand)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::Rainier;
403 use rainier_console::Console;
404 use rainier_database::testing::{fake_database, MemoryConnection};
405 use rainier_orm::Dialect;
406 use rainier_queue::{JobRegistry, MemoryQueue};
407
408 async fn app() -> Arc<Application> {
409 Rainier::new(".")
410 .without_facades()
411 .without_tracing()
412 .with_routes(|router| {
413 router.get("/", || async { "home" }).name("home");
414 router.get("/posts/{post}", || async { "post" }).name("posts.show");
415 })
416 .boot()
417 .await
418 .expect("boots")
419 }
420
421 #[tokio::test]
422 async fn route_list_reports_the_table() {
423 let app = app().await;
424 let console = Console::new("rainier").register(RouteListCommand);
425
426 assert_eq!(console.run_argv(&app, ["route:list"]).await, exit::SUCCESS);
427 assert_eq!(console.run_argv(&app, ["route:list", "--json"]).await, exit::SUCCESS);
428 }
429
430 #[tokio::test]
431 async fn route_list_sees_the_same_table_the_kernel_serves() {
432 let app = app().await;
436 let router = app.resolve::<CompiledRouter>().unwrap();
437
438 let names: Vec<String> = router.describe().into_iter().filter_map(|row| row.name).collect();
439 assert!(names.contains(&"home".to_string()), "{names:?}");
440 assert!(names.contains(&"posts.show".to_string()), "{names:?}");
441 }
442
443 #[tokio::test]
444 async fn route_list_fails_cleanly_with_nothing_bound() {
445 let bare = Application::new(".");
446 let console = Console::new("rainier").register(RouteListCommand);
447 assert_eq!(console.run_argv(&bare, ["route:list"]).await, exit::FAILURE);
448 }
449
450 #[tokio::test]
451 async fn migrate_reports_what_it_ran() {
452 let app = app().await;
453 let (database, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
454
455 app.instance(database);
456 app.instance(Migrator::new().raw(
457 "0001_a",
458 vec!["CREATE TABLE a (id INT)".into()],
459 vec!["DROP TABLE a".into()],
460 ));
461
462 let console = Console::new("rainier").register(MigrateCommand);
463 assert_eq!(console.run_argv(&app, ["migrate"]).await, exit::SUCCESS);
464 assert_eq!(console.run_argv(&app, ["migrate", "--pretend"]).await, exit::SUCCESS);
465 }
466
467 #[tokio::test]
468 async fn migrate_rollback_undoes_the_last_batch() {
469 use rainier_database::row::OwnedRow;
470
471 let app = app().await;
472 let (database, connection) = fake_database(
473 MemoryConnection::new(Dialect::Sqlite)
474 .returning([OwnedRow::new().with("name", "0001_a").with("batch", 1_i64)]),
475 );
476
477 app.instance(database);
478 app.instance(Migrator::new().raw(
479 "0001_a",
480 vec!["CREATE TABLE a (id INT)".into()],
481 vec!["DROP TABLE a".into()],
482 ));
483
484 let console = Console::new("rainier").register(MigrateRollbackCommand);
485 assert_eq!(console.run_argv(&app, ["migrate:rollback"]).await, exit::SUCCESS);
486 assert!(connection.statements().iter().any(|s| s == "DROP TABLE a"));
487 }
488
489 #[tokio::test]
490 async fn migrate_rollback_reports_an_irreversible_step_as_a_failure() {
491 use rainier_database::row::OwnedRow;
494
495 let app = app().await;
496 let (database, connection) = fake_database(
497 MemoryConnection::new(Dialect::Sqlite)
498 .returning([OwnedRow::new().with("name", "0001_a").with("batch", 1_i64)]),
499 );
500
501 app.instance(database);
502 app.instance(Migrator::new().raw_irreversible(
503 "0001_a",
504 vec!["UPDATE a SET b = NULL".into()],
505 "the old values are gone",
506 ));
507
508 let console = Console::new("rainier").register(MigrateRollbackCommand);
509 assert_eq!(console.run_argv(&app, ["migrate:rollback"]).await, exit::FAILURE);
510 assert!(
511 !connection.statements().iter().any(|s| s.starts_with("DELETE FROM")),
512 "the ledger row must survive a refused rollback"
513 );
514 }
515
516 #[tokio::test]
517 async fn queue_work_drains_the_queue_and_stops() {
518 use rainier_queue::{Job, JobContext};
519 use serde::{Deserialize, Serialize};
520
521 #[derive(Serialize, Deserialize)]
522 struct Ping;
523
524 #[async_trait::async_trait]
525 impl Job for Ping {
526 const NAME: &'static str = "test.ping";
527 async fn handle(&self, _: &JobContext) -> Result<()> {
528 Ok(())
529 }
530 }
531
532 let app = app().await;
533 let queue = Arc::new(MemoryQueue::new());
534 let registry = Arc::new(JobRegistry::new().with::<Ping>());
535 let manager = QueueManager::new(Arc::clone(&queue) as Arc<_>, registry);
536
537 manager.dispatch(Ping).await.unwrap();
538 app.instance(manager);
539
540 let console = Console::new("rainier").register(QueueWorkCommand);
541 assert_eq!(console.run_argv(&app, ["queue:work", "--once"]).await, exit::SUCCESS);
542
543 use rainier_queue::Queue as _;
544 assert_eq!(queue.size("default").await.unwrap(), 0);
545 }
546
547 #[tokio::test]
548 async fn queue_work_reports_a_failure_in_its_exit_code() {
549 use rainier_queue::{Job, JobContext};
550 use serde::{Deserialize, Serialize};
551
552 #[derive(Serialize, Deserialize)]
553 struct Boom;
554
555 #[async_trait::async_trait]
556 impl Job for Boom {
557 const NAME: &'static str = "test.boom";
558 const TRIES: u32 = 1;
559 async fn handle(&self, _: &JobContext) -> Result<()> {
560 Err(rainier_support::Error::internal("nope"))
561 }
562 }
563
564 let app = app().await;
565 let queue = Arc::new(MemoryQueue::new());
566 let manager = QueueManager::new(
567 Arc::clone(&queue) as Arc<_>,
568 Arc::new(JobRegistry::new().with::<Boom>()),
569 );
570 manager.dispatch(Boom).await.unwrap();
571 app.instance(manager);
572
573 let console = Console::new("rainier").register(QueueWorkCommand);
574 assert_eq!(console.run_argv(&app, ["queue:work", "--once"]).await, exit::FAILURE);
575 }
576
577 #[test]
578 fn the_built_in_console_registers_everything() {
579 let console = console("rainier");
580 for name in [
581 "route:list",
582 "serve",
583 "migrate",
584 "migrate:rollback",
585 "queue:work",
586 "schedule:run",
587 "schedule:work",
588 "schedule:list",
589 "key:generate",
590 ] {
591 assert!(console.find(name).is_some(), "`{name}` should be registered");
592 }
593 }
594}