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