diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 7aaa8229..5491e8cd 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -141,69 +141,84 @@ pub async fn serve( // No static fallback — the gateway owns the dist; unmatched paths 404. ; + // `routes!()` folds every handler it's given into ONE shared + // `MethodRouter` for the whole macro invocation — it does NOT group + // by path internally, so passing it handlers for more than one + // distinct path panics at runtime ("Overlapping method route") + // the moment two of them share an HTTP method, which most + // GET-vs-GET or POST-vs-POST pairs across different paths do. Each + // call below is therefore scoped to exactly one path — two handlers + // in the same call only when they genuinely share a path with + // different methods (`schedules::api_schedules`/`post_schedule_new` + // on `/api/schedules`, `matrix_accounts::get_github_account`/ + // `post_github_account` on `/api/github-account`) — chained via + // repeated `.routes(...)` calls instead of one giant `routes!(...)` + // with everything in it. let (router, api) = OpenApiRouter::::with_openapi(ApiDoc::openapi()) + .routes(routes!(health::get_health_live)) + .routes(routes!(health::get_health_ready)) + .routes(routes!(journal::get_journal)) + .routes(routes!(journal::get_journal_host)) + .routes(routes!(state_snapshot::api_state)) + .routes(routes!(state_files::get_state_file)) + .routes(routes!(matrix_accounts::get_matrix_accounts)) + .routes(routes!(matrix_accounts::post_matrix_account_login)) .routes(routes!( - health::get_health_live, - health::get_health_ready, - journal::get_journal, - journal::get_journal_host, - state_snapshot::api_state, - state_files::get_state_file, - matrix_accounts::get_matrix_accounts, - matrix_accounts::post_matrix_account_login, matrix_accounts::post_github_account, - matrix_accounts::get_github_account, - extra_forges::get_extra_forges, - extra_forges::post_extra_forge_account, - misc_api::api_operator_inbox, - misc_api::api_stats_hive, - misc_api::api_container_resources, - misc_api::api_audit_log, - misc_api::post_mark_all_read, - misc_api::post_request_spawn, - misc_api::post_op_send, - build_logs::get_build_logs_all, - build_logs::get_build_log_for_node, - build_logs::get_build_log_raw_for_node, - build_logs::get_build_logs_agent, - build_logs::get_build_log_full, - build_logs::get_build_log_raw, - topology::post_set_parent, - topology::post_set_parent_bulk, - permissions::get_tool_groups, - permissions::post_tool_groups, - permissions::get_capabilities, - permissions::post_capabilities, - permissions::post_permissions, - permissions::get_stale_permissions, - permissions::delete_agent_permissions, - schedules::api_schedules, - schedules::post_schedule_new, - schedules::patch_schedule, - schedules::post_schedule_cancel, - schedules::post_schedule_pause, - schedules::post_schedule_resume, - schedules::post_schedule_fire_now, - schedules::post_rebuild_queue_cancel, - webhook::post_webhook_knowledge, - webhook::post_webhook_config_pr, - approvals::post_approve, - approvals::post_deny, - lifecycle_ops::post_destroy, - lifecycle_ops::post_kill, - lifecycle_ops::post_restart, - lifecycle_ops::post_start, - lifecycle_ops::post_rebuild, - lifecycle_ops::post_pause, - lifecycle_ops::post_resume, - lifecycle_ops::post_resource_limits, - lifecycle_ops::post_update_all, - infra_containers::post_infra_container, - questions::post_answer_question, - questions::post_cancel_question, - tombstones::post_purge_tombstone, - meta_inputs::post_meta_update, + matrix_accounts::get_github_account )) + .routes(routes!(extra_forges::get_extra_forges)) + .routes(routes!(extra_forges::post_extra_forge_account)) + .routes(routes!(misc_api::api_operator_inbox)) + .routes(routes!(misc_api::api_stats_hive)) + .routes(routes!(misc_api::api_container_resources)) + .routes(routes!(misc_api::api_audit_log)) + .routes(routes!(misc_api::post_mark_all_read)) + .routes(routes!(misc_api::post_request_spawn)) + .routes(routes!(misc_api::post_op_send)) + .routes(routes!(build_logs::get_build_logs_all)) + .routes(routes!(build_logs::get_build_log_for_node)) + .routes(routes!(build_logs::get_build_log_raw_for_node)) + .routes(routes!(build_logs::get_build_logs_agent)) + .routes(routes!(build_logs::get_build_log_full)) + .routes(routes!(build_logs::get_build_log_raw)) + .routes(routes!(topology::post_set_parent)) + .routes(routes!(topology::post_set_parent_bulk)) + .routes(routes!(permissions::get_tool_groups)) + .routes(routes!(permissions::post_tool_groups)) + .routes(routes!(permissions::get_capabilities)) + .routes(routes!(permissions::post_capabilities)) + .routes(routes!(permissions::post_permissions)) + .routes(routes!(permissions::get_stale_permissions)) + .routes(routes!(permissions::delete_agent_permissions)) + .routes(routes!( + schedules::api_schedules, + schedules::post_schedule_new + )) + .routes(routes!(schedules::patch_schedule)) + .routes(routes!(schedules::post_schedule_cancel)) + .routes(routes!(schedules::post_schedule_pause)) + .routes(routes!(schedules::post_schedule_resume)) + .routes(routes!(schedules::post_schedule_fire_now)) + .routes(routes!(schedules::post_rebuild_queue_cancel)) + .routes(routes!(webhook::post_webhook_knowledge)) + .routes(routes!(webhook::post_webhook_config_pr)) + .routes(routes!(approvals::post_approve)) + .routes(routes!(approvals::post_deny)) + .routes(routes!(lifecycle_ops::post_destroy)) + .routes(routes!(lifecycle_ops::post_kill)) + .routes(routes!(lifecycle_ops::post_restart)) + .routes(routes!(lifecycle_ops::post_start)) + .routes(routes!(lifecycle_ops::post_rebuild)) + .routes(routes!(lifecycle_ops::post_pause)) + .routes(routes!(lifecycle_ops::post_resume)) + .routes(routes!(lifecycle_ops::post_resource_limits)) + .routes(routes!(lifecycle_ops::post_update_all)) + .routes(routes!(infra_containers::post_infra_container)) + .routes(routes!(questions::post_answer_question)) + .routes(routes!(questions::post_cancel_question)) + .routes(routes!(tombstones::post_purge_tombstone)) + .routes(routes!(meta_inputs::post_meta_update)) .merge(app.into()) .split_for_parts(); let app = router @@ -360,13 +375,84 @@ mod tests { mod router_build_probe { use super::*; + /// Regression test for the exact panic this file's `serve()` used to + /// hit at startup: `routes!()` folds every handler passed to ONE + /// macro invocation into a single shared `MethodRouter`, so two + /// handlers on different paths but the same HTTP method panic with + /// "Overlapping method route" the moment they're in the same + /// `routes!(...)` call — see `serve()`'s comment above its own + /// `.routes(...)` chain. This mirrors that exact chain (one + /// `.routes()` call per distinct path, multi-method pairs grouped + /// only where they share a path) so a future regression that + /// accidentally merges two single-path calls back into one + /// multi-path `routes!(...)` call fails here instead of at daemon + /// startup. #[test] fn probe_multi_path_routes_macro_does_not_panic() { - let _ = OpenApiRouter::::with_openapi(ApiDoc::openapi()).routes(routes!( - health::get_health_live, - health::get_health_ready, - journal::get_journal, - journal::get_journal_host, - )); + let _ = OpenApiRouter::::with_openapi(ApiDoc::openapi()) + .routes(routes!(health::get_health_live)) + .routes(routes!(health::get_health_ready)) + .routes(routes!(journal::get_journal)) + .routes(routes!(journal::get_journal_host)) + .routes(routes!(state_snapshot::api_state)) + .routes(routes!(state_files::get_state_file)) + .routes(routes!(matrix_accounts::get_matrix_accounts)) + .routes(routes!(matrix_accounts::post_matrix_account_login)) + .routes(routes!( + matrix_accounts::post_github_account, + matrix_accounts::get_github_account + )) + .routes(routes!(extra_forges::get_extra_forges)) + .routes(routes!(extra_forges::post_extra_forge_account)) + .routes(routes!(misc_api::api_operator_inbox)) + .routes(routes!(misc_api::api_stats_hive)) + .routes(routes!(misc_api::api_container_resources)) + .routes(routes!(misc_api::api_audit_log)) + .routes(routes!(misc_api::post_mark_all_read)) + .routes(routes!(misc_api::post_request_spawn)) + .routes(routes!(misc_api::post_op_send)) + .routes(routes!(build_logs::get_build_logs_all)) + .routes(routes!(build_logs::get_build_log_for_node)) + .routes(routes!(build_logs::get_build_log_raw_for_node)) + .routes(routes!(build_logs::get_build_logs_agent)) + .routes(routes!(build_logs::get_build_log_full)) + .routes(routes!(build_logs::get_build_log_raw)) + .routes(routes!(topology::post_set_parent)) + .routes(routes!(topology::post_set_parent_bulk)) + .routes(routes!(permissions::get_tool_groups)) + .routes(routes!(permissions::post_tool_groups)) + .routes(routes!(permissions::get_capabilities)) + .routes(routes!(permissions::post_capabilities)) + .routes(routes!(permissions::post_permissions)) + .routes(routes!(permissions::get_stale_permissions)) + .routes(routes!(permissions::delete_agent_permissions)) + .routes(routes!( + schedules::api_schedules, + schedules::post_schedule_new + )) + .routes(routes!(schedules::patch_schedule)) + .routes(routes!(schedules::post_schedule_cancel)) + .routes(routes!(schedules::post_schedule_pause)) + .routes(routes!(schedules::post_schedule_resume)) + .routes(routes!(schedules::post_schedule_fire_now)) + .routes(routes!(schedules::post_rebuild_queue_cancel)) + .routes(routes!(webhook::post_webhook_knowledge)) + .routes(routes!(webhook::post_webhook_config_pr)) + .routes(routes!(approvals::post_approve)) + .routes(routes!(approvals::post_deny)) + .routes(routes!(lifecycle_ops::post_destroy)) + .routes(routes!(lifecycle_ops::post_kill)) + .routes(routes!(lifecycle_ops::post_restart)) + .routes(routes!(lifecycle_ops::post_start)) + .routes(routes!(lifecycle_ops::post_rebuild)) + .routes(routes!(lifecycle_ops::post_pause)) + .routes(routes!(lifecycle_ops::post_resume)) + .routes(routes!(lifecycle_ops::post_resource_limits)) + .routes(routes!(lifecycle_ops::post_update_all)) + .routes(routes!(infra_containers::post_infra_container)) + .routes(routes!(questions::post_answer_question)) + .routes(routes!(questions::post_cancel_question)) + .routes(routes!(tombstones::post_purge_tombstone)) + .routes(routes!(meta_inputs::post_meta_update)); } }