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