From 5dde2aac3648d95541195536e526041c99cf7937 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 08:30:46 +0200 Subject: [PATCH 01/13] fix: GET /api/templates/mine routing, auth guard, and test infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startup.rs: register mine_handler before detail_handler (/{slug}) so the literal path /mine is not swallowed by the wildcard route - creator.rs: use Option> so anonymous requests that slip through Casbin (via /:slug wildcard policy) return 403 instead of 500 - dockerhub_service.rs: add 3s timeout to Redis ConnectionManager::new so integration tests don't hang indefinitely when Redis is unreachable - tests/common/mod.rs: fix mock_auth_server to actually await the Server future so the mock OAuth endpoint is reachable during tests - tests/marketplace_mine.rs: new HTTP integration test suite for GET /api/templates/mine (empty list, user-scoped results, no-auth 403) Root cause of the production 404: the response body {"message":"Not Found"} is not Stacker's format — it originates from an external reverse proxy or an outdated binary at https://stacker.try.direct. The route is correctly registered in Stacker and the Casbin policies are present in migrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/connectors/dockerhub_service.rs | 7 ++++--- src/routes/marketplace/creator.rs | 3 ++- src/startup.rs | 4 ++-- tests/common/mod.rs | 4 +++- tests/marketplace_mine.rs | 3 ++- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/connectors/dockerhub_service.rs b/src/connectors/dockerhub_service.rs index e9aaefda..9a3c6be4 100644 --- a/src/connectors/dockerhub_service.rs +++ b/src/connectors/dockerhub_service.rs @@ -82,9 +82,10 @@ impl RedisCache { ConnectorError::Internal(format!("Invalid Redis URL for Docker Hub cache: {}", err)) })?; - let connection = ConnectionManager::new(client).await.map_err(|err| { - ConnectorError::ServiceUnavailable(format!("Redis unavailable: {}", err)) - })?; + let connection = tokio::time::timeout(Duration::from_secs(3), ConnectionManager::new(client)) + .await + .map_err(|_| ConnectorError::ServiceUnavailable("Redis connection timed out".to_string()))? + .map_err(|err| ConnectorError::ServiceUnavailable(format!("Redis unavailable: {}", err)))?; Ok(Self { connection: Arc::new(Mutex::new(connection)), diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index 3fcfad23..f754782d 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -285,9 +285,10 @@ pub async fn resubmit_handler( #[tracing::instrument(name = "List my templates")] #[get("/mine")] pub async fn mine_handler( - user: web::ReqData>, + user: Option>>, pg_pool: web::Data, ) -> Result { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; db::marketplace::list_mine(pg_pool.get_ref(), &user.id) .await .map_err(|err| { diff --git a/src/startup.rs b/src/startup.rs index d9f59399..b7713373 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -193,12 +193,12 @@ pub async fn run( .service( web::scope("/templates") .service(crate::routes::marketplace::public::list_handler) - .service(crate::routes::marketplace::public::detail_handler) + .service(crate::routes::marketplace::creator::mine_handler) .service(crate::routes::marketplace::creator::create_handler) .service(crate::routes::marketplace::creator::update_handler) .service(crate::routes::marketplace::creator::submit_handler) .service(crate::routes::marketplace::creator::resubmit_handler) - .service(crate::routes::marketplace::creator::mine_handler), + .service(crate::routes::marketplace::public::detail_handler), ) .service( web::scope("/v1/agent") diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3006212c..28774511 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -202,9 +202,11 @@ async fn mock_auth() -> actix_web::Result { Ok(web::Json(user_form)) } -async fn mock_auth_server(listener: TcpListener) -> actix_web::dev::Server { +async fn mock_auth_server(listener: TcpListener) { HttpServer::new(|| App::new().service(web::scope("/me").service(mock_auth))) .listen(listener) .unwrap() .run() + .await + .unwrap(); } diff --git a/tests/marketplace_mine.rs b/tests/marketplace_mine.rs index 2e415ca5..6c33b434 100644 --- a/tests/marketplace_mine.rs +++ b/tests/marketplace_mine.rs @@ -116,9 +116,10 @@ async fn mine_returns_forbidden_without_authorization_header() { .await .expect("Failed to send request"); + let status = response.status(); assert_eq!( StatusCode::FORBIDDEN, - response.status(), + status, "Missing auth should yield 403 Forbidden — Stacker never returns 404 for this route" ); } From 73e3810bf9e494d5f6d6e7118a4a0835fadcb923 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 09:01:10 +0200 Subject: [PATCH 02/13] fix: set context: . in cicd-linux-docker build-push step docker/build-push-action@v6 changed the default build context from '.' to the GitHub repo URL. For pull requests this resolves to refs/pull/N/merge which Docker's external buildx cannot fetch, causing: 'repository does not contain ref refs/pull/N/merge' Explicitly setting context: . uses the locally extracted artifact files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c7b54461..a54419df 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -192,6 +192,7 @@ jobs: name: Build and push uses: docker/build-push-action@v6 with: + context: . push: true tags: ${{ steps.docker_tags.outputs.tags }} From 8ac2379661790d6082ebfeb342e1b34f9e6d8849 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 09:45:44 +0200 Subject: [PATCH 03/13] fix: use MarketplaceFailed error variant for marketplace CLI operations. Replace DeployFailed{target: Cloud, ...} with MarketplaceFailed(String) --- src/cli/error.rs | 6 ++++ src/cli/stacker_client.rs | 76 +++++++++++++-------------------------- 2 files changed, 31 insertions(+), 51 deletions(-) diff --git a/src/cli/error.rs b/src/cli/error.rs index 8c90dd4c..c942e6a0 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -47,6 +47,9 @@ pub enum CliError { EnvFileNotFound { path: std::path::PathBuf }, SecretKeyNotFound { key: String }, + // Marketplace errors + MarketplaceFailed(String), + // Agent errors AgentNotFound { deployment_hash: String }, AgentOffline { deployment_hash: String }, @@ -148,6 +151,9 @@ impl fmt::Display for CliError { Self::SecretKeyNotFound { key } => { write!(f, "Secret key not found: {key}") } + Self::MarketplaceFailed(msg) => { + write!(f, "Marketplace error: {msg}") + } Self::AgentNotFound { deployment_hash } => { write!( f, diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index bad88d55..225cd0aa 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -1361,25 +1361,19 @@ impl StackerClient { .bearer_auth(&self.token) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server unreachable: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("GET /api/templates/mine failed ({}): {}", status, body), - }); + return Err(CliError::MarketplaceFailed(format!( + "GET /api/templates/mine failed ({}): {}", + status, body + ))); } let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Invalid response from Stacker server: {}", e), - } + CliError::MarketplaceFailed(format!("Invalid response from Stacker server: {}", e)) })?; Ok(api.list.unwrap_or_default()) @@ -1397,28 +1391,19 @@ impl StackerClient { .bearer_auth(&self.token) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server unreachable: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "GET /api/admin/templates/{} failed ({}): {}", - template_id, status, body - ), - }); + return Err(CliError::MarketplaceFailed(format!( + "GET /api/admin/templates/{} failed ({}): {}", + template_id, status, body + ))); } let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Invalid response from Stacker server: {}", e), - } + CliError::MarketplaceFailed(format!("Invalid response from Stacker server: {}", e)) })?; let item = api.item.unwrap_or(serde_json::json!({})); @@ -1445,31 +1430,23 @@ impl StackerClient { .json(&body) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("create template: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("create template: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Create template failed ({}): {}", status, body), - }); + return Err(CliError::MarketplaceFailed(format!( + "Create template failed ({}): {}", + status, body + ))); } let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("create template response: {}", e), - } + CliError::MarketplaceFailed(format!("create template response: {}", e)) })?; - api.item.ok_or_else(|| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: "No template in response".to_string(), - }) + api.item + .ok_or_else(|| CliError::MarketplaceFailed("No template in response".to_string())) } /// Submit a template for marketplace review. @@ -1481,18 +1458,15 @@ impl StackerClient { .bearer_auth(&self.token) .send() .await - .map_err(|e| CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server unreachable: {}", e), - })?; + .map_err(|e| CliError::MarketplaceFailed(format!("Stacker server unreachable: {}", e)))?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - return Err(CliError::DeployFailed { - target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Submit failed ({}): {}", status, body), - }); + return Err(CliError::MarketplaceFailed(format!( + "Submit failed ({}): {}", + status, body + ))); } Ok(()) From cdb1c6f09bf5b565e849c2e02f2daa8841f99af4 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 11:13:59 +0200 Subject: [PATCH 04/13] fix: include access_control.conf.dist in Docker build artifact The cicd-linux-docker job builds with context: . from the extracted app.tar.gz, but access_control.conf.dist was missing from the archive. The Dockerfile COPYs this file in the production stage, causing the build to fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a54419df..0f7fa697 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -141,6 +141,7 @@ jobs: cp target/release/server app/stacker/server if [ -d web/dist ]; then cp -a web/dist/. app/stacker; fi cp Dockerfile app/Dockerfile + cp access_control.conf.dist app/access_control.conf.dist cd app touch .env tar -czvf ../app.tar.gz . From bc6ab5989d49eaf093772bf8bc74b79418ad5a5a Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 11:22:54 +0200 Subject: [PATCH 05/13] fix: checkout source in cicd-linux-docker instead of using artifact The Docker builder stage needs the full source tree (Cargo.toml, src/, .sqlx/, docker/local/ etc). Replace artifact download with a direct checkout using the correct ref for both push and PR events. Also stop pushing Docker images on PRs (push: false for pull_request events) to avoid overwriting :latest with unreviewed code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docker.yml | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0f7fa697..ae6a719b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -141,7 +141,6 @@ jobs: cp target/release/server app/stacker/server if [ -d web/dist ]; then cp -a web/dist/. app/stacker; fi cp Dockerfile app/Dockerfile - cp access_control.conf.dist app/access_control.conf.dist cd app touch .env tar -czvf ../app.tar.gz . @@ -158,16 +157,10 @@ jobs: runs-on: ubuntu-latest needs: cicd-docker steps: - - name: Download app archive - uses: actions/download-artifact@v7 + - name: Checkout sources + uses: actions/checkout@v6 with: - name: artifact-linux-docker - - - name: Extract app archive - run: tar -zxvf app.tar.gz - - - name: Display structure of downloaded files - run: ls -R + ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} - name: Set up QEMU @@ -194,7 +187,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - push: true + push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.docker_tags.outputs.tags }} stackerdb-docker: From b10d32b0f2a0e042a31962059c79086e1467da78 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 12:02:22 +0200 Subject: [PATCH 06/13] feat: GET /api/templates/:id/reviews for template owners + marketplace submit subcommand - Add my_reviews_handler: GET /api/templates/{id}/reviews Accessible to authenticated users (group_user). Verifies the requesting user owns the template before returning review history. Fixes 403 error from marketplace logs which was wrongly calling the admin-only endpoint /api/admin/templates/{id}. - Migration: Casbin policy for group_user on /api/templates/:id/reviews GET - Update CLI marketplace_reviews() to call the new user endpoint /api/templates/{id}/reviews instead of /api/admin/templates/{id} - Add 'stacker marketplace submit' subcommand so submission is discoverable under 'stacker marketplace --help' (delegates to existing SubmitCommand) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...0000_casbin_template_reviews_rule.down.sql | 1 + ...100000_casbin_template_reviews_rule.up.sql | 2 ++ src/bin/stacker.rs | 31 +++++++++++++++++++ src/cli/stacker_client.rs | 16 +++------- src/routes/marketplace/creator.rs | 26 ++++++++++++++++ src/routes/marketplace/mod.rs | 2 +- src/routes/mod.rs | 2 +- src/startup.rs | 1 + 8 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 migrations/20260325100000_casbin_template_reviews_rule.down.sql create mode 100644 migrations/20260325100000_casbin_template_reviews_rule.up.sql diff --git a/migrations/20260325100000_casbin_template_reviews_rule.down.sql b/migrations/20260325100000_casbin_template_reviews_rule.down.sql new file mode 100644 index 00000000..012d81f2 --- /dev/null +++ b/migrations/20260325100000_casbin_template_reviews_rule.down.sql @@ -0,0 +1 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v0 = 'group_user' AND v1 = '/api/templates/:id/reviews' AND v2 = 'GET'; diff --git a/migrations/20260325100000_casbin_template_reviews_rule.up.sql b/migrations/20260325100000_casbin_template_reviews_rule.up.sql new file mode 100644 index 00000000..faf805ae --- /dev/null +++ b/migrations/20260325100000_casbin_template_reviews_rule.up.sql @@ -0,0 +1,2 @@ +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/api/templates/:id/reviews', 'GET', '', '', '') ON CONFLICT DO NOTHING; diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index b3d86add..29aa29ff 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -278,6 +278,27 @@ enum MarketplaceCommands { #[arg(long)] json: bool, }, + /// Submit current stack to the marketplace for review + Submit { + /// Path to stacker.yml (default: ./stacker.yml) + #[arg(long, value_name = "FILE")] + file: Option, + /// Stack version (default: from stacker.yml or "1.0.0") + #[arg(long)] + version: Option, + /// Short description for marketplace listing + #[arg(long)] + description: Option, + /// Category code (e.g. ai-agents, data-pipelines, saas-starter) + #[arg(long)] + category: Option, + /// Pricing: free, one_time, subscription (default: free) + #[arg(long, value_name = "TYPE")] + plan_type: Option, + /// Price amount (required if plan_type is not free) + #[arg(long)] + price: Option, + }, } #[derive(Debug, Subcommand)] @@ -1186,6 +1207,16 @@ fn get_command( name, json, ), ), + MarketplaceCommands::Submit { + file, + version, + description, + category, + plan_type, + price, + } => Box::new(stacker::console::commands::cli::submit::SubmitCommand::new( + file, version, description, category, plan_type, price, + )), }, // Completion is handled in main() before this function is called. StackerCommands::Completion { .. } => unreachable!(), diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 225cd0aa..0f462e03 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -1384,7 +1384,7 @@ impl StackerClient { &self, template_id: &str, ) -> Result, CliError> { - let url = format!("{}/api/admin/templates/{}", self.base_url, template_id); + let url = format!("{}/api/templates/{}/reviews", self.base_url, template_id); let resp = self .http .get(&url) @@ -1397,24 +1397,16 @@ impl StackerClient { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); return Err(CliError::MarketplaceFailed(format!( - "GET /api/admin/templates/{} failed ({}): {}", + "GET /api/templates/{}/reviews failed ({}): {}", template_id, status, body ))); } - let api: ApiResponse = resp.json().await.map_err(|e| { + let api: ApiResponse = resp.json().await.map_err(|e| { CliError::MarketplaceFailed(format!("Invalid response from Stacker server: {}", e)) })?; - let item = api.item.unwrap_or(serde_json::json!({})); - let reviews: Vec = serde_json::from_value( - item.get("reviews") - .cloned() - .unwrap_or(serde_json::json!([])), - ) - .unwrap_or_default(); - - Ok(reviews) + Ok(api.list.unwrap_or_default()) } /// Create or update a marketplace template (POST /api/templates). diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index f754782d..a65ab55e 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -296,3 +296,29 @@ pub async fn mine_handler( }) .map(|templates| JsonResponse::build().set_list(templates).ok("OK")) } + +#[tracing::instrument(name = "List reviews for my template")] +#[get("/{id}/reviews")] +pub async fn my_reviews_handler( + user: Option>>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let user = user.ok_or_else(|| JsonResponse::::forbidden("Authentication required"))?; + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let template = db::marketplace::get_by_id(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .ok_or_else(|| JsonResponse::::build().not_found("Template not found"))?; + + if template.creator_user_id != user.id { + return Err(JsonResponse::::build().forbidden("Access denied")); + } + + db::marketplace::list_reviews_by_template(pg_pool.get_ref(), id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err)) + .map(|reviews| JsonResponse::build().set_list(reviews).ok("OK")) +} diff --git a/src/routes/marketplace/mod.rs b/src/routes/marketplace/mod.rs index 1ed063d9..a7936dee 100644 --- a/src/routes/marketplace/mod.rs +++ b/src/routes/marketplace/mod.rs @@ -10,6 +10,6 @@ pub use admin::{ }; pub use creator::{ CreateTemplateRequest, ResubmitRequest, UpdateTemplateRequest, create_handler, mine_handler, - resubmit_handler, submit_handler, update_handler, + my_reviews_handler, resubmit_handler, submit_handler, update_handler, }; pub use public::TemplateListQuery; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9afe0852..16ba90b9 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -28,6 +28,6 @@ pub use deployment::{ pub use marketplace::{ AdminDecisionRequest, CreateTemplateRequest, ResubmitRequest, TemplateListQuery, UpdateTemplateRequest, UnapproveRequest, approve_handler, create_handler, list_plans_handler, - list_submitted_handler, mine_handler, reject_handler, resubmit_handler, + list_submitted_handler, mine_handler, my_reviews_handler, reject_handler, resubmit_handler, security_scan_handler, submit_handler, unapprove_handler, update_handler, }; diff --git a/src/startup.rs b/src/startup.rs index b7713373..de9bdc05 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -194,6 +194,7 @@ pub async fn run( web::scope("/templates") .service(crate::routes::marketplace::public::list_handler) .service(crate::routes::marketplace::creator::mine_handler) + .service(crate::routes::marketplace::creator::my_reviews_handler) .service(crate::routes::marketplace::creator::create_handler) .service(crate::routes::marketplace::creator::update_handler) .service(crate::routes::marketplace::creator::submit_handler) From 96bc33ede995d68ecc6d586bd1c6040103dbd66c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:06:13 +0000 Subject: [PATCH 07/13] Add comprehensive unit tests to model files Add #[cfg(test)] mod tests blocks to 8 model files: - project.rs: validate/sanitize project names, Project methods, error display - command.rs: status/priority enums, builder pattern, status transitions, serde - project_app.rs: defaults, enabled, env_map, vault sync, versioning - cloud.rs: mask_string, Display masking, new/default, serialization - agent.rs: Agent online/offline lifecycle, AuditLog builder pattern - server.rs: defaults, validation, Server->ServerWithProvider conversion - deployment.rs: new, default, serialization - client.rs: Debug masking, default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/models/agent.rs | 111 +++++++++++++++++++++ src/models/client.rs | 48 +++++++++ src/models/cloud.rs | 96 ++++++++++++++++++ src/models/command.rs | 199 ++++++++++++++++++++++++++++++++++++++ src/models/deployment.rs | 63 ++++++++++++ src/models/project.rs | 149 ++++++++++++++++++++++++++++ src/models/project_app.rs | 134 +++++++++++++++++++++++++ src/models/server.rs | 117 ++++++++++++++++++++++ 8 files changed, 917 insertions(+) diff --git a/src/models/agent.rs b/src/models/agent.rs index 8b8e6847..d0a7ab57 100644 --- a/src/models/agent.rs +++ b/src/models/agent.rs @@ -95,3 +95,114 @@ impl AuditLog { self } } + +#[cfg(test)] +mod tests { + use super::*; + + // Agent tests + #[test] + fn test_agent_new() { + let agent = Agent::new("deploy-hash-123".to_string()); + assert_eq!(agent.deployment_hash, "deploy-hash-123"); + assert_eq!(agent.status, "offline"); + assert!(agent.last_heartbeat.is_none()); + assert_eq!(agent.capabilities, Some(serde_json::json!([]))); + assert_eq!(agent.system_info, Some(serde_json::json!({}))); + assert!(agent.version.is_none()); + } + + #[test] + fn test_agent_is_online_when_offline() { + let agent = Agent::new("h".to_string()); + assert!(!agent.is_online()); + } + + #[test] + fn test_agent_mark_online() { + let mut agent = Agent::new("h".to_string()); + assert!(!agent.is_online()); + agent.mark_online(); + assert!(agent.is_online()); + assert!(agent.last_heartbeat.is_some()); + } + + #[test] + fn test_agent_mark_offline() { + let mut agent = Agent::new("h".to_string()); + agent.mark_online(); + assert!(agent.is_online()); + agent.mark_offline(); + assert!(!agent.is_online()); + } + + #[test] + fn test_agent_online_offline_cycle() { + let mut agent = Agent::new("h".to_string()); + for _ in 0..3 { + agent.mark_online(); + assert!(agent.is_online()); + agent.mark_offline(); + assert!(!agent.is_online()); + } + } + + // AuditLog tests + #[test] + fn test_audit_log_new() { + let agent_id = Uuid::new_v4(); + let log = AuditLog::new( + Some(agent_id), + Some("hash-1".to_string()), + "deploy".to_string(), + Some("success".to_string()), + ); + assert_eq!(log.agent_id, Some(agent_id)); + assert_eq!(log.deployment_hash, Some("hash-1".to_string())); + assert_eq!(log.action, "deploy"); + assert_eq!(log.status, Some("success".to_string())); + assert_eq!(log.details, serde_json::json!({})); + assert!(log.ip_address.is_none()); + assert!(log.user_agent.is_none()); + } + + #[test] + fn test_audit_log_new_minimal() { + let log = AuditLog::new(None, None, "heartbeat".to_string(), None); + assert!(log.agent_id.is_none()); + assert!(log.deployment_hash.is_none()); + assert!(log.status.is_none()); + } + + #[test] + fn test_audit_log_with_details() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_details(serde_json::json!({"error": "timeout"})); + assert_eq!(log.details, serde_json::json!({"error": "timeout"})); + } + + #[test] + fn test_audit_log_with_ip() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_ip("192.168.1.1".to_string()); + assert_eq!(log.ip_address, Some("192.168.1.1".to_string())); + } + + #[test] + fn test_audit_log_with_user_agent() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_user_agent("Mozilla/5.0".to_string()); + assert_eq!(log.user_agent, Some("Mozilla/5.0".to_string())); + } + + #[test] + fn test_audit_log_builder_chaining() { + let log = AuditLog::new(None, None, "test".to_string(), None) + .with_details(serde_json::json!({"key": "value"})) + .with_ip("10.0.0.1".to_string()) + .with_user_agent("curl/7.68".to_string()); + assert_eq!(log.details, serde_json::json!({"key": "value"})); + assert_eq!(log.ip_address, Some("10.0.0.1".to_string())); + assert_eq!(log.user_agent, Some("curl/7.68".to_string())); + } +} diff --git a/src/models/client.rs b/src/models/client.rs index d8815970..d91369bd 100644 --- a/src/models/client.rs +++ b/src/models/client.rs @@ -21,3 +21,51 @@ impl std::fmt::Debug for Client { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_debug_masks_secret() { + let client = Client { + id: 1, + user_id: "user1".to_string(), + secret: Some("mysecretvalue".to_string()), + }; + let debug = format!("{:?}", client); + assert!(debug.contains("myse****")); + assert!(!debug.contains("mysecretvalue")); + assert!(debug.contains("user1")); + } + + #[test] + fn test_client_debug_no_secret() { + let client = Client { + id: 2, + user_id: "user2".to_string(), + secret: None, + }; + let debug = format!("{:?}", client); + assert!(debug.contains("user2")); + } + + #[test] + fn test_client_debug_short_secret() { + let client = Client { + id: 3, + user_id: "u".to_string(), + secret: Some("ab".to_string()), + }; + let debug = format!("{:?}", client); + assert!(debug.contains("ab****")); + } + + #[test] + fn test_client_default() { + let client = Client::default(); + assert_eq!(client.id, 0); + assert_eq!(client.user_id, ""); + assert!(client.secret.is_none()); + } +} diff --git a/src/models/cloud.rs b/src/models/cloud.rs index 2108bc61..8916c689 100644 --- a/src/models/cloud.rs +++ b/src/models/cloud.rs @@ -77,3 +77,99 @@ impl Default for Cloud { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mask_string_some() { + assert_eq!(mask_string(Some(&"abcdefgh".to_string())), "abcd****"); + } + + #[test] + fn test_mask_string_short() { + assert_eq!(mask_string(Some(&"ab".to_string())), "ab****"); + } + + #[test] + fn test_mask_string_none() { + assert_eq!(mask_string(None), ""); + } + + #[test] + fn test_mask_string_empty() { + assert_eq!(mask_string(Some(&"".to_string())), "****"); + } + + #[test] + fn test_cloud_display_masks_credentials() { + let cloud = Cloud::new( + "user1".to_string(), + "my-cloud".to_string(), + "aws".to_string(), + Some("token12345".to_string()), + Some("key12345".to_string()), + Some("secret12345".to_string()), + Some(true), + ); + let display = format!("{}", cloud); + assert!(display.contains("aws")); + assert!(display.contains("toke****")); + assert!(display.contains("key1****")); + assert!(display.contains("secr****")); + assert!(!display.contains("token12345")); + assert!(!display.contains("key12345")); + assert!(!display.contains("secret12345")); + } + + #[test] + fn test_cloud_display_none_credentials() { + let cloud = Cloud::default(); + let display = format!("{}", cloud); + assert!(display.contains("cloud_key : ")); + } + + #[test] + fn test_cloud_new() { + let cloud = Cloud::new( + "user1".to_string(), + "test".to_string(), + "hetzner".to_string(), + None, + Some("key".to_string()), + None, + Some(false), + ); + assert_eq!(cloud.id, 0); + assert_eq!(cloud.user_id, "user1"); + assert_eq!(cloud.provider, "hetzner"); + assert!(cloud.cloud_token.is_none()); + assert_eq!(cloud.cloud_key, Some("key".to_string())); + assert!(cloud.cloud_secret.is_none()); + } + + #[test] + fn test_cloud_default() { + let cloud = Cloud::default(); + assert_eq!(cloud.id, 0); + assert_eq!(cloud.provider, ""); + assert_eq!(cloud.save_token, Some(false)); + } + + #[test] + fn test_cloud_serialization() { + let cloud = Cloud::new( + "u1".to_string(), + "c".to_string(), + "do".to_string(), + Some("tok".to_string()), + None, + None, + None, + ); + let json = serde_json::to_string(&cloud).unwrap(); + let deserialized: Cloud = serde_json::from_str(&json).unwrap(); + assert_eq!(cloud, deserialized); + } +} diff --git a/src/models/command.rs b/src/models/command.rs index 6611a2ce..05868748 100644 --- a/src/models/command.rs +++ b/src/models/command.rs @@ -203,3 +203,202 @@ pub struct CommandQueueEntry { pub priority: i32, pub created_at: DateTime, } + +#[cfg(test)] +mod tests { + use super::*; + + // CommandStatus tests + #[test] + fn test_command_status_display() { + assert_eq!(CommandStatus::Queued.to_string(), "queued"); + assert_eq!(CommandStatus::Sent.to_string(), "sent"); + assert_eq!(CommandStatus::Executing.to_string(), "executing"); + assert_eq!(CommandStatus::Completed.to_string(), "completed"); + assert_eq!(CommandStatus::Failed.to_string(), "failed"); + assert_eq!(CommandStatus::Cancelled.to_string(), "cancelled"); + } + + #[test] + fn test_command_status_serde() { + let json = serde_json::to_string(&CommandStatus::Queued).unwrap(); + assert_eq!(json, "\"queued\""); + let deserialized: CommandStatus = serde_json::from_str("\"completed\"").unwrap(); + assert_eq!(deserialized, CommandStatus::Completed); + } + + // CommandPriority tests + #[test] + fn test_priority_to_int() { + assert_eq!(CommandPriority::Low.to_int(), 0); + assert_eq!(CommandPriority::Normal.to_int(), 1); + assert_eq!(CommandPriority::High.to_int(), 2); + assert_eq!(CommandPriority::Critical.to_int(), 3); + } + + #[test] + fn test_priority_display() { + assert_eq!(CommandPriority::Low.to_string(), "low"); + assert_eq!(CommandPriority::Normal.to_string(), "normal"); + assert_eq!(CommandPriority::High.to_string(), "high"); + assert_eq!(CommandPriority::Critical.to_string(), "critical"); + } + + #[test] + fn test_priority_serde() { + let json = serde_json::to_string(&CommandPriority::High).unwrap(); + assert_eq!(json, "\"high\""); + let deserialized: CommandPriority = serde_json::from_str("\"low\"").unwrap(); + assert_eq!(deserialized, CommandPriority::Low); + } + + #[test] + fn test_priority_ordering() { + assert!(CommandPriority::Low.to_int() < CommandPriority::Normal.to_int()); + assert!(CommandPriority::Normal.to_int() < CommandPriority::High.to_int()); + assert!(CommandPriority::High.to_int() < CommandPriority::Critical.to_int()); + } + + // Command builder tests + #[test] + fn test_command_new_defaults() { + let cmd = Command::new( + "cmd-1".to_string(), + "hash-abc".to_string(), + "deploy".to_string(), + "admin".to_string(), + ); + assert_eq!(cmd.command_id, "cmd-1"); + assert_eq!(cmd.deployment_hash, "hash-abc"); + assert_eq!(cmd.r#type, "deploy"); + assert_eq!(cmd.created_by, "admin"); + assert_eq!(cmd.status, "queued"); + assert_eq!(cmd.priority, "normal"); + assert_eq!(cmd.timeout_seconds, Some(300)); + assert!(cmd.parameters.is_none()); + assert!(cmd.result.is_none()); + assert!(cmd.error.is_none()); + assert!(cmd.metadata.is_none()); + } + + #[test] + fn test_command_with_priority() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .with_priority(CommandPriority::Critical); + assert_eq!(cmd.priority, "critical"); + } + + #[test] + fn test_command_with_parameters() { + let params = serde_json::json!({"key": "value"}); + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .with_parameters(params.clone()); + assert_eq!(cmd.parameters, Some(params)); + } + + #[test] + fn test_command_with_timeout() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .with_timeout(600); + assert_eq!(cmd.timeout_seconds, Some(600)); + } + + #[test] + fn test_command_with_metadata() { + let meta = serde_json::json!({"retry_count": 3}); + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .with_metadata(meta.clone()); + assert_eq!(cmd.metadata, Some(meta)); + } + + #[test] + fn test_command_builder_chaining() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .with_priority(CommandPriority::High) + .with_timeout(120) + .with_parameters(serde_json::json!({"action": "restart"})) + .with_metadata(serde_json::json!({"source": "api"})); + + assert_eq!(cmd.priority, "high"); + assert_eq!(cmd.timeout_seconds, Some(120)); + assert!(cmd.parameters.is_some()); + assert!(cmd.metadata.is_some()); + } + + // Command status transitions + #[test] + fn test_command_mark_sent() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .mark_sent(); + assert_eq!(cmd.status, "sent"); + } + + #[test] + fn test_command_mark_executing() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .mark_executing(); + assert_eq!(cmd.status, "executing"); + } + + #[test] + fn test_command_mark_completed() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .mark_completed(); + assert_eq!(cmd.status, "completed"); + } + + #[test] + fn test_command_mark_failed() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .mark_failed(); + assert_eq!(cmd.status, "failed"); + } + + #[test] + fn test_command_mark_cancelled() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) + .mark_cancelled(); + assert_eq!(cmd.status, "cancelled"); + } + + #[test] + fn test_command_status_transition_chain() { + let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()); + assert_eq!(cmd.status, "queued"); + let cmd = cmd.mark_sent(); + assert_eq!(cmd.status, "sent"); + let cmd = cmd.mark_executing(); + assert_eq!(cmd.status, "executing"); + let cmd = cmd.mark_completed(); + assert_eq!(cmd.status, "completed"); + } + + // CommandResult and CommandError serde + #[test] + fn test_command_result_deserialization() { + let json = r#"{ + "command_id": "cmd-1", + "deployment_hash": "hash-1", + "status": "completed", + "result": {"output": "success"}, + "error": null, + "metadata": null + }"#; + let result: CommandResult = serde_json::from_str(json).unwrap(); + assert_eq!(result.command_id, "cmd-1"); + assert_eq!(result.status, CommandStatus::Completed); + assert!(result.error.is_none()); + } + + #[test] + fn test_command_error_deserialization() { + let json = r#"{ + "code": "TIMEOUT", + "message": "Command timed out", + "details": {"elapsed_seconds": 300} + }"#; + let error: CommandError = serde_json::from_str(json).unwrap(); + assert_eq!(error.code, "TIMEOUT"); + assert_eq!(error.message, "Command timed out"); + } +} diff --git a/src/models/deployment.rs b/src/models/deployment.rs index a9753830..0bc8e6ef 100644 --- a/src/models/deployment.rs +++ b/src/models/deployment.rs @@ -56,3 +56,66 @@ impl Default for Deployment { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deployment_new() { + let deployment = Deployment::new( + 1, + Some("user1".to_string()), + "hash-abc".to_string(), + "running".to_string(), + serde_json::json!({"apps": ["nginx"]}), + ); + assert_eq!(deployment.id, 0); + assert_eq!(deployment.project_id, 1); + assert_eq!(deployment.user_id, Some("user1".to_string())); + assert_eq!(deployment.deployment_hash, "hash-abc"); + assert_eq!(deployment.status, "running"); + assert_eq!(deployment.deleted, Some(false)); + assert!(deployment.last_seen_at.is_none()); + } + + #[test] + fn test_deployment_new_no_user() { + let deployment = Deployment::new( + 2, + None, + "hash-xyz".to_string(), + "pending".to_string(), + Value::Null, + ); + assert!(deployment.user_id.is_none()); + } + + #[test] + fn test_deployment_default() { + let deployment = Deployment::default(); + assert_eq!(deployment.id, 0); + assert_eq!(deployment.project_id, 0); + assert_eq!(deployment.deployment_hash, ""); + assert!(deployment.user_id.is_none()); + assert_eq!(deployment.deleted, Some(false)); + assert_eq!(deployment.status, "pending"); + assert_eq!(deployment.metadata, Value::Null); + } + + #[test] + fn test_deployment_serialization() { + let deployment = Deployment::new( + 1, + Some("user1".to_string()), + "test-hash".to_string(), + "active".to_string(), + serde_json::json!({}), + ); + let json = serde_json::to_string(&deployment).unwrap(); + let deserialized: Deployment = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.project_id, 1); + assert_eq!(deserialized.deployment_hash, "test-hash"); + assert_eq!(deserialized.status, "active"); + } +} diff --git a/src/models/project.rs b/src/models/project.rs index ee25abd2..25f690c6 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -222,3 +222,152 @@ impl Default for Project { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Test validate_project_name + #[test] + fn test_validate_empty_name() { + assert_eq!(validate_project_name(""), Err(ProjectNameError::Empty)); + } + + #[test] + fn test_validate_too_long_name() { + let long_name = "a".repeat(256); + assert_eq!(validate_project_name(&long_name), Err(ProjectNameError::TooLong(256))); + } + + #[test] + fn test_validate_reserved_names() { + for name in &["root", "tmp", "etc", "var", "dev", ".", ".."] { + assert!(matches!(validate_project_name(name), Err(ProjectNameError::ReservedName(_)))); + } + } + + #[test] + fn test_validate_reserved_names_case_insensitive() { + assert!(matches!(validate_project_name("ROOT"), Err(ProjectNameError::ReservedName(_)))); + assert!(matches!(validate_project_name("Tmp"), Err(ProjectNameError::ReservedName(_)))); + } + + #[test] + fn test_validate_invalid_characters() { + assert!(matches!(validate_project_name("my project"), Err(ProjectNameError::InvalidCharacters(_)))); + assert!(matches!(validate_project_name("name/path"), Err(ProjectNameError::InvalidCharacters(_)))); + assert!(matches!(validate_project_name("-starts-with-dash"), Err(ProjectNameError::InvalidCharacters(_)))); + } + + #[test] + fn test_validate_valid_names() { + assert!(validate_project_name("myproject").is_ok()); + assert!(validate_project_name("my-project").is_ok()); + assert!(validate_project_name("my_project").is_ok()); + assert!(validate_project_name("my.project").is_ok()); + assert!(validate_project_name("Project123").is_ok()); + assert!(validate_project_name("_private").is_ok()); + } + + #[test] + fn test_validate_max_length_name() { + let name = "a".repeat(255); + assert!(validate_project_name(&name).is_ok()); + } + + // Test sanitize_project_name + #[test] + fn test_sanitize_empty() { + assert_eq!(sanitize_project_name(""), "project"); + } + + #[test] + fn test_sanitize_lowercases() { + assert_eq!(sanitize_project_name("MyProject"), "myproject"); + } + + #[test] + fn test_sanitize_replaces_invalid_chars() { + assert_eq!(sanitize_project_name("my project"), "my_project"); + assert_eq!(sanitize_project_name("my/project"), "my_project"); + } + + #[test] + fn test_sanitize_reserved_name() { + assert_eq!(sanitize_project_name("root"), "project_root"); + assert_eq!(sanitize_project_name("tmp"), "project_tmp"); + } + + #[test] + fn test_sanitize_first_char_special() { + assert_eq!(sanitize_project_name("-myproject"), "_myproject"); + assert_eq!(sanitize_project_name(".myproject"), "_myproject"); + } + + #[test] + fn test_sanitize_truncates_long_name() { + let long_name = "a".repeat(300); + let result = sanitize_project_name(&long_name); + assert_eq!(result.len(), 255); + } + + // Test ProjectNameError Display + #[test] + fn test_error_display() { + assert_eq!(ProjectNameError::Empty.to_string(), "Project name cannot be empty"); + assert_eq!(ProjectNameError::TooLong(300).to_string(), "Project name too long (300 chars, max 255)"); + assert!(ProjectNameError::InvalidCharacters("bad name".to_string()).to_string().contains("bad name")); + assert!(ProjectNameError::ReservedName("root".to_string()).to_string().contains("root")); + } + + // Test Project methods + #[test] + fn test_project_new() { + let project = Project::new( + "user1".to_string(), + "test-project".to_string(), + serde_json::json!({}), + serde_json::json!({}), + ); + assert_eq!(project.id, 0); + assert_eq!(project.user_id, "user1"); + assert_eq!(project.name, "test-project"); + assert!(project.source_template_id.is_none()); + } + + #[test] + fn test_project_validate_name() { + let project = Project::new("u".to_string(), "valid-name".to_string(), Value::Null, Value::Null); + assert!(project.validate_name().is_ok()); + + let bad_project = Project::new("u".to_string(), "".to_string(), Value::Null, Value::Null); + assert!(bad_project.validate_name().is_err()); + } + + #[test] + fn test_project_safe_dir_name() { + let project = Project::new("u".to_string(), "My Project".to_string(), Value::Null, Value::Null); + assert_eq!(project.safe_dir_name(), "my_project"); + } + + #[test] + fn test_project_deploy_dir() { + let project = Project::new("u".to_string(), "myapp".to_string(), Value::Null, Value::Null); + assert_eq!(project.deploy_dir(Some("/deploy")), "/deploy/myapp"); + assert_eq!(project.deploy_dir(Some("/deploy/")), "/deploy/myapp"); + } + + #[test] + fn test_project_deploy_dir_with_hash() { + let project = Project::new("u".to_string(), "myapp".to_string(), Value::Null, Value::Null); + assert_eq!(project.deploy_dir_with_hash(Some("/deploy"), "abc123"), "/deploy/abc123"); + } + + #[test] + fn test_project_default() { + let project = Project::default(); + assert_eq!(project.id, 0); + assert_eq!(project.user_id, ""); + assert_eq!(project.name, ""); + } +} diff --git a/src/models/project_app.rs b/src/models/project_app.rs index 6882056c..588b73ff 100644 --- a/src/models/project_app.rs +++ b/src/models/project_app.rs @@ -209,3 +209,137 @@ impl Default for ProjectApp { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_defaults() { + let app = ProjectApp::new(1, "nginx".to_string(), "Nginx".to_string(), "nginx:latest".to_string()); + assert_eq!(app.project_id, 1); + assert_eq!(app.code, "nginx"); + assert_eq!(app.name, "Nginx"); + assert_eq!(app.image, "nginx:latest"); + assert_eq!(app.enabled, Some(true)); + assert_eq!(app.ssl_enabled, Some(false)); + assert_eq!(app.restart_policy, Some("unless-stopped".to_string())); + assert_eq!(app.config_version, Some(1)); + assert!(app.vault_synced_at.is_none()); + assert!(app.vault_sync_version.is_none()); + } + + #[test] + fn test_is_enabled_true() { + let app = ProjectApp { enabled: Some(true), ..Default::default() }; + assert!(app.is_enabled()); + } + + #[test] + fn test_is_enabled_false() { + let app = ProjectApp { enabled: Some(false), ..Default::default() }; + assert!(!app.is_enabled()); + } + + #[test] + fn test_is_enabled_none_defaults_true() { + let app = ProjectApp { enabled: None, ..Default::default() }; + assert!(app.is_enabled()); + } + + #[test] + fn test_env_map_with_data() { + let app = ProjectApp { + environment: Some(serde_json::json!({"DB_HOST": "localhost", "DB_PORT": "5432"})), + ..Default::default() + }; + let map = app.env_map(); + assert_eq!(map.len(), 2); + assert_eq!(map.get("DB_HOST").unwrap(), "localhost"); + } + + #[test] + fn test_env_map_empty() { + let app = ProjectApp { environment: None, ..Default::default() }; + let map = app.env_map(); + assert!(map.is_empty()); + } + + #[test] + fn test_env_map_non_object() { + let app = ProjectApp { + environment: Some(serde_json::json!("not an object")), + ..Default::default() + }; + let map = app.env_map(); + assert!(map.is_empty()); + } + + #[test] + fn test_needs_vault_sync_never_synced() { + let app = ProjectApp { + config_version: Some(1), + vault_sync_version: None, + ..Default::default() + }; + assert!(app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_outdated() { + let app = ProjectApp { + config_version: Some(3), + vault_sync_version: Some(2), + ..Default::default() + }; + assert!(app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_up_to_date() { + let app = ProjectApp { + config_version: Some(2), + vault_sync_version: Some(2), + ..Default::default() + }; + assert!(!app.needs_vault_sync()); + } + + #[test] + fn test_needs_vault_sync_no_version() { + let app = ProjectApp { + config_version: None, + vault_sync_version: None, + ..Default::default() + }; + assert!(!app.needs_vault_sync()); + } + + #[test] + fn test_increment_version() { + let mut app = ProjectApp { config_version: Some(1), ..Default::default() }; + app.increment_version(); + assert_eq!(app.config_version, Some(2)); + } + + #[test] + fn test_increment_version_from_none() { + let mut app = ProjectApp { config_version: None, ..Default::default() }; + app.increment_version(); + assert_eq!(app.config_version, Some(1)); + } + + #[test] + fn test_mark_synced() { + let mut app = ProjectApp { + config_version: Some(3), + vault_synced_at: None, + vault_sync_version: None, + ..Default::default() + }; + app.mark_synced(); + assert!(app.vault_synced_at.is_some()); + assert_eq!(app.vault_sync_version, Some(3)); + assert!(!app.needs_vault_sync()); + } +} diff --git a/src/models/server.rs b/src/models/server.rs index 57fb2523..ee450b1e 100644 --- a/src/models/server.rs +++ b/src/models/server.rs @@ -132,3 +132,120 @@ impl From for ServerWithProvider { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_valid::Validate; + + #[test] + fn test_server_default() { + let server = Server::default(); + assert_eq!(server.id, 0); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.key_status, "none"); + assert!(server.region.is_none()); + assert!(server.ssh_port.is_none()); + } + + #[test] + fn test_default_connection_mode() { + assert_eq!(default_connection_mode(), "ssh"); + } + + #[test] + fn test_default_key_status() { + assert_eq!(default_key_status(), "none"); + } + + #[test] + fn test_server_validation_valid() { + let server = Server { + region: Some("us-east-1".to_string()), + zone: Some("us-east-1a".to_string()), + server: Some("s-2vcpu-4gb".to_string()), + os: Some("ubuntu-22".to_string()), + disk_type: Some("ssd".to_string()), + srv_ip: Some("192.168.1.100".to_string()), + ssh_port: Some(22), + ssh_user: Some("root".to_string()), + ..Default::default() + }; + assert!(server.validate().is_ok()); + } + + #[test] + fn test_server_validation_short_region() { + let server = Server { + region: Some("a".to_string()), // too short, min 2 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_too_low() { + let server = Server { + ssh_port: Some(10), // minimum 20 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_too_high() { + let server = Server { + ssh_port: Some(70000), // maximum 65535 + ..Default::default() + }; + assert!(server.validate().is_err()); + } + + #[test] + fn test_server_validation_ssh_port_valid_range() { + let server = Server { + ssh_port: Some(22), + ..Default::default() + }; + assert!(server.validate().is_ok()); + + let server_max = Server { + ssh_port: Some(65535), + ..Default::default() + }; + assert!(server_max.validate().is_ok()); + } + + #[test] + fn test_server_to_server_with_provider() { + let server = Server { + id: 42, + user_id: "user1".to_string(), + project_id: 5, + cloud_id: Some(10), + region: Some("eu-west-1".to_string()), + connection_mode: "ssh".to_string(), + key_status: "active".to_string(), + name: Some("my-server".to_string()), + ..Default::default() + }; + let provider: ServerWithProvider = server.into(); + assert_eq!(provider.id, 42); + assert_eq!(provider.user_id, "user1"); + assert_eq!(provider.project_id, 5); + assert_eq!(provider.cloud_id, Some(10)); + assert!(provider.cloud.is_none()); // Populated by query later + assert_eq!(provider.region, Some("eu-west-1".to_string())); + assert_eq!(provider.connection_mode, "ssh"); + assert_eq!(provider.key_status, "active"); + assert_eq!(provider.name, Some("my-server".to_string())); + } + + #[test] + fn test_server_serialization_defaults() { + let json = r#"{"id":0,"user_id":"","project_id":0,"cloud_id":null,"region":null,"zone":null,"server":null,"os":null,"disk_type":null,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","srv_ip":null,"ssh_port":null,"ssh_user":null,"vault_key_path":null,"connection_mode":"ssh","key_status":"none","name":null}"#; + let server: Server = serde_json::from_str(json).unwrap(); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.key_status, "none"); + } +} From fa98d120f2bdca22e30b60559a3265678150b66c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:14:27 +0000 Subject: [PATCH 08/13] Add comprehensive unit tests to form and helper files Add #[cfg(test)] mod tests blocks to 8 files: - src/forms/project/docker_image.rs: Display trait, serialization - src/forms/project/port.rs: validation, TryInto, serialization - src/forms/project/network.rs: defaults, Into NetworkSettings - src/forms/server.rs: form<->model conversions, roundtrip - src/forms/rating/add.rs: Into Rating, serde_valid validation - src/helpers/compressor.rs: brotli compression behavior - src/helpers/cloud/security.rs: b64 encode/decode, AES encrypt/decrypt - src/models/ratecategory.rs: Into String, all variants, default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/forms/project/docker_image.rs | 90 +++++++++++++++++++++++ src/forms/project/network.rs | 57 +++++++++++++++ src/forms/project/port.rs | 101 ++++++++++++++++++++++++++ src/forms/rating/add.rs | 78 ++++++++++++++++++++ src/forms/server.rs | 93 ++++++++++++++++++++++++ src/helpers/cloud/security.rs | 115 ++++++++++++++++++++++++++++++ src/helpers/compressor.rs | 32 +++++++++ src/models/ratecategory.rs | 37 ++++++++++ 8 files changed, 603 insertions(+) diff --git a/src/forms/project/docker_image.rs b/src/forms/project/docker_image.rs index 9ed254d0..c2cb7c36 100644 --- a/src/forms/project/docker_image.rs +++ b/src/forms/project/docker_image.rs @@ -59,3 +59,93 @@ impl DockerImage { DockerHub::try_from(self)?.is_active().await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_full_image() { + let img = DockerImage { + dockerhub_user: Some("trydirect".to_string()), + dockerhub_name: Some("postgres:v8".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "trydirect/postgres:v8"); + } + + #[test] + fn test_display_image_only() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: None, + dockerhub_image: Some("nginx:latest".to_string()), + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "nginx:latest"); + } + + #[test] + fn test_display_name_without_tag_adds_latest() { + let img = DockerImage { + dockerhub_user: Some("myuser".to_string()), + dockerhub_name: Some("myapp".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "myuser/myapp:latest"); + } + + #[test] + fn test_display_name_with_tag_no_latest() { + let img = DockerImage { + dockerhub_user: Some("myuser".to_string()), + dockerhub_name: Some("myapp:v2".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "myuser/myapp:v2"); + } + + #[test] + fn test_display_no_user_with_name() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: Some("redis".to_string()), + dockerhub_image: None, + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "redis:latest"); + } + + #[test] + fn test_display_all_empty() { + let img = DockerImage::default(); + assert_eq!(format!("{}", img), ":latest"); + } + + #[test] + fn test_display_image_takes_precedence_when_name_empty() { + let img = DockerImage { + dockerhub_user: None, + dockerhub_name: None, + dockerhub_image: Some("custom/image:tag".to_string()), + dockerhub_password: None, + }; + assert_eq!(format!("{}", img), "custom/image:tag"); + } + + #[test] + fn test_docker_image_serialization() { + let img = DockerImage { + dockerhub_user: Some("user".to_string()), + dockerhub_name: Some("app".to_string()), + dockerhub_image: Some("user/app:1.0".to_string()), + dockerhub_password: None, + }; + let json = serde_json::to_string(&img).unwrap(); + let deserialized: DockerImage = serde_json::from_str(&json).unwrap(); + assert_eq!(img, deserialized); + } +} diff --git a/src/forms/project/network.rs b/src/forms/project/network.rs index d412f148..45160dd3 100644 --- a/src/forms/project/network.rs +++ b/src/forms/project/network.rs @@ -54,3 +54,60 @@ impl Into for Network { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_network_default_is_external() { + let net = Network::default(); + assert_eq!(net.id, "default_network"); + assert_eq!(net.name, "default_network"); + assert_eq!(net.external, Some(true)); + } + + #[test] + fn test_default_network_to_settings() { + let net = Network::default(); + let settings: dctypes::NetworkSettings = net.into(); + assert_eq!(settings.name, Some("default_network".to_string())); + // default_network is always external + assert!(matches!( + settings.external, + Some(dctypes::ComposeNetwork::Bool(true)) + )); + } + + #[test] + fn test_custom_network_not_external() { + let net = Network { + id: "custom_net".to_string(), + name: "my-network".to_string(), + external: Some(false), + driver: Some("bridge".to_string()), + attachable: Some(true), + enable_ipv6: Some(false), + internal: Some(false), + driver_opts: None, + ipam: None, + labels: None, + }; + let settings: dctypes::NetworkSettings = net.into(); + assert_eq!(settings.name, Some("my-network".to_string())); + assert!(matches!( + settings.external, + Some(dctypes::ComposeNetwork::Bool(false)) + )); + assert_eq!(settings.driver, Some("bridge".to_string())); + assert!(settings.attachable); + } + + #[test] + fn test_network_serialization() { + let net = Network::default(); + let json = serde_json::to_string(&net).unwrap(); + let deserialized: Network = serde_json::from_str(&json).unwrap(); + assert_eq!(net, deserialized); + } +} diff --git a/src/forms/project/port.rs b/src/forms/project/port.rs index 101eb8d0..dcad7d15 100644 --- a/src/forms/project/port.rs +++ b/src/forms/project/port.rs @@ -84,3 +84,104 @@ impl TryInto for &Port { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_non_empty_none() { + assert!(validate_non_empty(&None).is_ok()); + } + + #[test] + fn test_validate_non_empty_empty_string() { + assert!(validate_non_empty(&Some("".to_string())).is_ok()); + } + + #[test] + fn test_validate_non_empty_valid_port() { + assert!(validate_non_empty(&Some("8080".to_string())).is_ok()); + assert!(validate_non_empty(&Some("80".to_string())).is_ok()); + assert!(validate_non_empty(&Some("443".to_string())).is_ok()); + } + + #[test] + fn test_validate_non_empty_invalid_port() { + assert!(validate_non_empty(&Some("abc".to_string())).is_err()); + assert!(validate_non_empty(&Some("1".to_string())).is_err()); // too short (min 2 digits) + assert!(validate_non_empty(&Some("1234567".to_string())).is_err()); // too long (max 6 digits) + } + + #[test] + fn test_port_try_into_valid() { + let port = Port { + host_port: Some("8080".to_string()), + container_port: "80".to_string(), + protocol: Some("tcp".to_string()), + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 80); + } + + #[test] + fn test_port_try_into_no_host_port() { + let port = Port { + host_port: None, + container_port: "3000".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert_eq!(dc_port.target, 3000); + assert!(dc_port.published.is_none()); + } + + #[test] + fn test_port_try_into_empty_host_port() { + let port = Port { + host_port: Some("".to_string()), + container_port: "5432".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_ok()); + let dc_port = result.unwrap(); + assert!(dc_port.published.is_none()); + } + + #[test] + fn test_port_try_into_invalid_container_port() { + let port = Port { + host_port: None, + container_port: "not_a_number".to_string(), + protocol: None, + }; + let result: Result = (&port).try_into(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Could not parse container port")); + } + + #[test] + fn test_port_default() { + let port = Port::default(); + assert!(port.host_port.is_none()); + assert_eq!(port.container_port, ""); + assert!(port.protocol.is_none()); + } + + #[test] + fn test_port_serialization() { + let port = Port { + host_port: Some("8080".to_string()), + container_port: "80".to_string(), + protocol: Some("tcp".to_string()), + }; + let json = serde_json::to_string(&port).unwrap(); + let deserialized: Port = serde_json::from_str(&json).unwrap(); + assert_eq!(port, deserialized); + } +} diff --git a/src/forms/rating/add.rs b/src/forms/rating/add.rs index a2c90d2c..2011c1c0 100644 --- a/src/forms/rating/add.rs +++ b/src/forms/rating/add.rs @@ -25,3 +25,81 @@ impl Into for AddRating { rating } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_valid::Validate; + + #[test] + fn test_add_rating_into_model() { + let rating = AddRating { + obj_id: 42, + category: models::RateCategory::Application, + comment: Some("Great app!".to_string()), + rate: 8, + }; + let model: models::Rating = rating.into(); + assert_eq!(model.obj_id, 42); + assert_eq!(model.hidden, Some(false)); + assert_eq!(model.rate, Some(8)); + assert_eq!(model.comment, Some("Great app!".to_string())); + } + + #[test] + fn test_add_rating_no_comment() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Cloud, + comment: None, + rate: 5, + }; + let model: models::Rating = rating.into(); + assert!(model.comment.is_none()); + assert_eq!(model.rate, Some(5)); + } + + #[test] + fn test_add_rating_validation_valid() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Price, + comment: Some("OK".to_string()), + rate: 5, + }; + assert!(rating.validate().is_ok()); + } + + #[test] + fn test_add_rating_validation_rate_too_high() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Design, + comment: None, + rate: 11, // max is 10 + }; + assert!(rating.validate().is_err()); + } + + #[test] + fn test_add_rating_validation_rate_negative() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::Design, + comment: None, + rate: -1, // min is 0 + }; + assert!(rating.validate().is_err()); + } + + #[test] + fn test_add_rating_validation_comment_too_long() { + let rating = AddRating { + obj_id: 1, + category: models::RateCategory::TechSupport, + comment: Some("a".repeat(1001)), // max is 1000 + rate: 5, + }; + assert!(rating.validate().is_err()); + } +} diff --git a/src/forms/server.rs b/src/forms/server.rs index b7e6e2a4..b4367972 100644 --- a/src/forms/server.rs +++ b/src/forms/server.rs @@ -87,3 +87,96 @@ impl Into for models::Server { form } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_ssh_port() { + assert_eq!(default_ssh_port(), Some(22)); + } + + #[test] + fn test_server_form_to_model() { + let form = ServerForm { + server_id: None, + cloud_id: Some(5), + region: Some("us-east-1".to_string()), + zone: Some("us-east-1a".to_string()), + server: Some("s-2vcpu".to_string()), + os: Some("ubuntu".to_string()), + disk_type: Some("ssd".to_string()), + srv_ip: Some("10.0.0.1".to_string()), + ssh_port: Some(2222), + ssh_user: Some("admin".to_string()), + name: Some("my-server".to_string()), + connection_mode: Some("ssh".to_string()), + vault_key_path: Some("/vault/path".to_string()), + public_key: None, + ssh_private_key: None, + }; + let server: models::Server = (&form).into(); + assert_eq!(server.cloud_id, Some(5)); + assert_eq!(server.region, Some("us-east-1".to_string())); + assert_eq!(server.ssh_port, Some(2222)); + assert_eq!(server.ssh_user, Some("admin".to_string())); + assert_eq!(server.connection_mode, "ssh"); + assert_eq!(server.name, Some("my-server".to_string())); + } + + #[test] + fn test_server_form_to_model_defaults() { + let form = ServerForm::default(); + let server: models::Server = (&form).into(); + assert_eq!(server.ssh_port, Some(22)); // default_ssh_port fallback + assert_eq!(server.connection_mode, "ssh"); + } + + #[test] + fn test_model_to_server_form() { + let server = models::Server { + id: 42, + cloud_id: Some(10), + region: Some("eu-west-1".to_string()), + ssh_port: Some(22), + ssh_user: Some("root".to_string()), + connection_mode: "ssh".to_string(), + name: Some("prod".to_string()), + vault_key_path: Some("/v/k".to_string()), + ..Default::default() + }; + let form: ServerForm = server.into(); + assert_eq!(form.server_id, Some(42)); + assert_eq!(form.cloud_id, Some(10)); + assert_eq!(form.region, Some("eu-west-1".to_string())); + assert_eq!(form.ssh_port, Some(22)); + assert_eq!(form.connection_mode, Some("ssh".to_string())); + assert_eq!(form.name, Some("prod".to_string())); + } + + #[test] + fn test_server_form_roundtrip() { + let server = models::Server { + id: 1, + cloud_id: Some(3), + region: Some("us-west".to_string()), + zone: Some("a".to_string()), + server: Some("large".to_string()), + os: Some("debian".to_string()), + disk_type: Some("nvme".to_string()), + srv_ip: Some("1.2.3.4".to_string()), + ssh_port: Some(2222), + ssh_user: Some("deploy".to_string()), + connection_mode: "ssh".to_string(), + vault_key_path: Some("path".to_string()), + name: Some("test".to_string()), + ..Default::default() + }; + let form: ServerForm = server.into(); + let back: models::Server = (&form).into(); + assert_eq!(back.cloud_id, Some(3)); + assert_eq!(back.region, Some("us-west".to_string())); + assert_eq!(back.ssh_port, Some(2222)); + } +} diff --git a/src/helpers/cloud/security.rs b/src/helpers/cloud/security.rs index 0f0b4122..027817a3 100644 --- a/src/helpers/cloud/security.rs +++ b/src/helpers/cloud/security.rs @@ -120,3 +120,118 @@ impl Secret { String::from_utf8(plaintext).map_err(|e| format!("UTF-8 conversion failed: {:?}", e)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_new() { + let secret = Secret::new(); + assert_eq!(secret.user_id, ""); + assert_eq!(secret.provider, ""); + assert_eq!(secret.field, ""); + } + + #[test] + fn test_b64_encode() { + let data = vec![72, 101, 108, 108, 111]; // "Hello" + let encoded = Secret::b64_encode(&data); + assert_eq!(encoded, "SGVsbG8="); + } + + #[test] + fn test_b64_decode_valid() { + let result = Secret::b64_decode(&"SGVsbG8=".to_string()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec![72, 101, 108, 108, 111]); + } + + #[test] + fn test_b64_decode_invalid() { + let result = Secret::b64_decode(&"not!valid!base64!!!".to_string()); + assert!(result.is_err()); + } + + #[test] + fn test_b64_roundtrip() { + let original = vec![1, 2, 3, 4, 5, 255, 0, 128]; + let encoded = Secret::b64_encode(&original); + let decoded = Secret::b64_decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_b64_encode_empty() { + let data: Vec = vec![]; + let encoded = Secret::b64_encode(&data); + assert_eq!(encoded, ""); + } + + #[test] + fn test_b64_decode_empty() { + let result = Secret::b64_decode(&"".to_string()); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_encrypt_requires_security_key() { + // Remove SECURITY_KEY if it exists + std::env::remove_var("SECURITY_KEY"); + let secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + let result = secret.encrypt("my-token".to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("SECURITY_KEY")); + } + + #[test] + fn test_encrypt_invalid_key_length() { + std::env::set_var("SECURITY_KEY", "short-key"); + let secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + let result = secret.encrypt("my-token".to_string()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("32 bytes")); + std::env::remove_var("SECURITY_KEY"); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let key = "01234567890123456789012345678901"; // exactly 32 bytes + std::env::set_var("SECURITY_KEY", key); + + let mut secret = Secret { + user_id: "u1".to_string(), + provider: "aws".to_string(), + field: "cloud_token".to_string(), + }; + + let original = "my-super-secret-token-123"; + let encrypted = secret.encrypt(original.to_string()).unwrap(); + assert!(!encrypted.is_empty()); + assert!(encrypted.len() > 12); // nonce (12) + ciphertext + + let decrypted = secret.decrypt(encrypted).unwrap(); + assert_eq!(decrypted, original); + + std::env::remove_var("SECURITY_KEY"); + } + + #[test] + fn test_decrypt_too_short_data() { + std::env::set_var("SECURITY_KEY", "01234567890123456789012345678901"); + let mut secret = Secret::new(); + let result = secret.decrypt(vec![1, 2, 3]); // less than 12 bytes + assert!(result.is_err()); + assert!(result.unwrap_err().contains("too short")); + std::env::remove_var("SECURITY_KEY"); + } +} diff --git a/src/helpers/compressor.rs b/src/helpers/compressor.rs index d2065783..81c9a931 100644 --- a/src/helpers/compressor.rs +++ b/src/helpers/compressor.rs @@ -9,3 +9,35 @@ pub fn compress(input: &str) -> Vec { drop(compressor); compressed } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compress_non_empty() { + let result = compress("Hello, World!"); + assert!(!result.is_empty()); + } + + #[test] + fn test_compress_empty_string() { + let result = compress(""); + // Even empty input produces some compressed output (brotli header) + assert!(!result.is_empty()); + } + + #[test] + fn test_compress_reduces_size_for_repetitive_data() { + let input = "a".repeat(10000); + let result = compress(&input); + assert!(result.len() < input.len()); + } + + #[test] + fn test_compress_different_inputs_different_outputs() { + let result1 = compress("Hello"); + let result2 = compress("World"); + assert_ne!(result1, result2); + } +} diff --git a/src/models/ratecategory.rs b/src/models/ratecategory.rs index 397cd1de..c4c8df54 100644 --- a/src/models/ratecategory.rs +++ b/src/models/ratecategory.rs @@ -25,3 +25,40 @@ impl Default for RateCategory { RateCategory::Application } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rate_category_into_string() { + let s: String = RateCategory::Application.into(); + assert_eq!(s, "Application"); + } + + #[test] + fn test_rate_category_all_variants() { + let variants = vec![ + (RateCategory::Application, "Application"), + (RateCategory::Cloud, "Cloud"), + (RateCategory::Project, "Project"), + (RateCategory::DeploymentSpeed, "DeploymentSpeed"), + (RateCategory::Documentation, "Documentation"), + (RateCategory::Design, "Design"), + (RateCategory::TechSupport, "TechSupport"), + (RateCategory::Price, "Price"), + (RateCategory::MemoryUsage, "MemoryUsage"), + ]; + for (cat, expected) in variants { + let s: String = cat.into(); + assert_eq!(s, expected); + } + } + + #[test] + fn test_rate_category_default() { + let cat = RateCategory::default(); + let s: String = cat.into(); + assert_eq!(s, "Application"); + } +} From 321474a7d26ac1d0b4ea982e6564af6b339afe3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:21:54 +0000 Subject: [PATCH 09/13] Fix formatting in test files Co-authored-by: vsilent <42473+vsilent@users.noreply.github.com> Agent-Logs-Url: https://github.com/trydirect/stacker/sessions/07304b5d-2754-4f90-b791-562493cc6454 --- src/bin/stacker.rs | 287 +++++++----- src/cli/ai_client.rs | 115 +++-- src/cli/ai_scanner.rs | 33 +- src/cli/config_parser.rs | 117 ++--- src/cli/credentials.rs | 42 +- src/cli/deployment_lock.rs | 8 +- src/cli/detector.rs | 5 +- src/cli/error.rs | 82 +++- src/cli/generator/compose.rs | 33 +- src/cli/generator/dockerfile.rs | 11 +- src/cli/install_runner.rs | 108 +++-- src/cli/progress.rs | 5 +- src/cli/proxy_manager.rs | 40 +- src/cli/service_catalog.rs | 88 ++-- src/cli/stacker_client.rs | 308 +++++++------ src/connectors/user_service/app.rs | 4 +- src/connectors/user_service/install.rs | 5 +- src/connectors/user_service/mod.rs | 2 +- src/console/commands/cli/agent.rs | 186 +++++--- src/console/commands/cli/ai.rs | 148 ++++--- src/console/commands/cli/ci.rs | 20 +- src/console/commands/cli/config.rs | 58 ++- src/console/commands/cli/deploy.rs | 556 ++++++++++++++++-------- src/console/commands/cli/destroy.rs | 14 +- src/console/commands/cli/init.rs | 84 ++-- src/console/commands/cli/list.rs | 43 +- src/console/commands/cli/login.rs | 4 +- src/console/commands/cli/logs.rs | 30 +- src/console/commands/cli/marketplace.rs | 12 +- src/console/commands/cli/pipe.rs | 14 +- src/console/commands/cli/proxy.rs | 17 +- src/console/commands/cli/resolve.rs | 11 +- src/console/commands/cli/secrets.rs | 13 +- src/console/commands/cli/service.rs | 47 +- src/console/commands/cli/ssh_key.rs | 129 ++++-- src/console/commands/cli/status.rs | 33 +- src/console/commands/cli/submit.rs | 13 +- src/console/commands/mq/listener.rs | 6 +- src/console/main.rs | 4 +- src/db/agent_audit_log.rs | 5 +- src/db/marketplace.rs | 3 +- src/db/project_app.rs | 22 +- src/forms/project/port.rs | 4 +- src/forms/project/volume.rs | 41 +- src/forms/status_panel.rs | 26 +- src/helpers/security_validator.rs | 208 ++++++--- src/helpers/ssh_client.rs | 24 +- src/mcp/registry.rs | 58 +-- src/mcp/tools/agent_control.rs | 18 +- src/mcp/tools/ansible_roles.rs | 49 ++- src/mcp/tools/cloud.rs | 3 +- src/mcp/tools/firewall.rs | 4 +- src/mcp/tools/install_preview.rs | 14 +- src/mcp/tools/marketplace_admin.rs | 6 +- src/mcp/tools/project.rs | 6 +- src/mcp/tools/user_service/mcp.rs | 3 +- src/models/agent.rs | 4 +- src/models/command.rs | 103 ++++- src/models/pipe.rs | 6 +- src/models/project.rs | 86 +++- src/models/project_app.rs | 37 +- src/models/server.rs | 6 +- src/project_app/hydration.rs | 5 +- src/project_app/upsert.rs | 50 +-- src/routes/agent/link.rs | 33 +- src/routes/agent/login.rs | 18 +- src/routes/agent/snapshot.rs | 34 +- src/routes/cloud/add.rs | 5 +- src/routes/cloud/update.rs | 5 +- src/routes/command/create.rs | 14 +- src/routes/deployment/force_complete.rs | 12 +- src/routes/deployment/status.rs | 36 +- src/routes/marketplace/admin.rs | 15 +- src/routes/marketplace/creator.rs | 6 +- src/routes/marketplace/mod.rs | 8 +- src/routes/marketplace/public.rs | 5 +- src/routes/mod.rs | 12 +- src/routes/project/app.rs | 2 +- src/routes/project/deploy.rs | 63 +-- src/routes/project/discover.rs | 6 +- src/routes/server/delete.rs | 31 +- src/routes/server/get.rs | 4 +- src/routes/server/ssh_key.rs | 33 +- src/startup.rs | 11 +- tests/agent_login_link.rs | 6 +- tests/cli_config.rs | 10 +- tests/cli_deploy.rs | 14 +- tests/cli_destroy.rs | 5 +- tests/cli_init.rs | 18 +- tests/cli_logs.rs | 5 +- tests/cli_proxy.rs | 22 +- tests/cli_status.rs | 5 +- tests/cli_update.rs | 4 +- tests/common/mod.rs | 11 +- tests/marketplace_mine.rs | 24 +- tests/server_ssh.rs | 131 +++--- 96 files changed, 2568 insertions(+), 1566 deletions(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index b3d86add..3b35b4c6 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -887,9 +887,9 @@ fn get_command( org, domain, auth_url, - } => Box::new( - stacker::console::commands::cli::login::LoginCommand::new(org, domain, auth_url), - ), + } => Box::new(stacker::console::commands::cli::login::LoginCommand::new( + org, domain, auth_url, + )), StackerCommands::Init { app_type, with_proxy, @@ -945,37 +945,39 @@ fn get_command( stacker::console::commands::cli::destroy::DestroyCommand::new(volumes, confirm), ), StackerCommands::Config { command: cfg_cmd } => match cfg_cmd { - ConfigCommands::Validate { file } => Box::new( - stacker::console::commands::cli::config::ConfigValidateCommand::new(file), - ), - ConfigCommands::Show { file } => Box::new( - stacker::console::commands::cli::config::ConfigShowCommand::new(file), - ), - ConfigCommands::Example => Box::new( - stacker::console::commands::cli::config::ConfigExampleCommand::new(), - ), + ConfigCommands::Validate { file } => { + Box::new(stacker::console::commands::cli::config::ConfigValidateCommand::new(file)) + } + ConfigCommands::Show { file } => { + Box::new(stacker::console::commands::cli::config::ConfigShowCommand::new(file)) + } + ConfigCommands::Example => { + Box::new(stacker::console::commands::cli::config::ConfigExampleCommand::new()) + } ConfigCommands::Fix { file, interactive } => Box::new( stacker::console::commands::cli::config::ConfigFixCommand::new(file, interactive), ), - ConfigCommands::Lock { file } => Box::new( - stacker::console::commands::cli::config::ConfigLockCommand::new(file), - ), - ConfigCommands::Unlock { file } => Box::new( - stacker::console::commands::cli::config::ConfigUnlockCommand::new(file), - ), + ConfigCommands::Lock { file } => { + Box::new(stacker::console::commands::cli::config::ConfigLockCommand::new(file)) + } + ConfigCommands::Unlock { file } => { + Box::new(stacker::console::commands::cli::config::ConfigUnlockCommand::new(file)) + } ConfigCommands::Setup { command } => match command { ConfigSetupCommands::Cloud { file } => Box::new( stacker::console::commands::cli::config::ConfigSetupCloudCommand::new(file), ), ConfigSetupCommands::RemotePayload { file, out } => Box::new( - stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new(file, out), + stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new( + file, out, + ), ), }, }, StackerCommands::Ai(ai_args) => match ai_args.command { - None => Box::new( - stacker::console::commands::cli::ai::AiChatCommand::new(ai_args.write), - ), + None => Box::new(stacker::console::commands::cli::ai::AiChatCommand::new( + ai_args.write, + )), Some(AiCommands::Ask { question, context, @@ -987,40 +989,40 @@ fn get_command( .with_write(ai_args.write || write), ), }, - StackerCommands::Proxy { - command: proxy_cmd, - } => match proxy_cmd { + StackerCommands::Proxy { command: proxy_cmd } => match proxy_cmd { ProxyCommands::Add { domain, upstream, ssl, } => Box::new( - stacker::console::commands::cli::proxy::ProxyAddCommand::new( - domain, upstream, ssl, - ), + stacker::console::commands::cli::proxy::ProxyAddCommand::new(domain, upstream, ssl), ), ProxyCommands::Detect { json, deployment } => Box::new( stacker::console::commands::cli::proxy::ProxyDetectCommand::new(json, deployment), ), }, StackerCommands::List { command: list_cmd } => match list_cmd { - ListCommands::Projects { json } => Box::new( - stacker::console::commands::cli::list::ListProjectsCommand::new(json), - ), - ListCommands::Deployments { json, project, limit } => Box::new( + ListCommands::Projects { json } => { + Box::new(stacker::console::commands::cli::list::ListProjectsCommand::new(json)) + } + ListCommands::Deployments { + json, + project, + limit, + } => Box::new( stacker::console::commands::cli::list::ListDeploymentsCommand::new( json, project, limit, ), ), - ListCommands::Servers { json } => Box::new( - stacker::console::commands::cli::list::ListServersCommand::new(json), - ), - ListCommands::SshKeys { json } => Box::new( - stacker::console::commands::cli::list::ListSshKeysCommand::new(json), - ), - ListCommands::Clouds { json } => Box::new( - stacker::console::commands::cli::list::ListCloudsCommand::new(json), - ), + ListCommands::Servers { json } => { + Box::new(stacker::console::commands::cli::list::ListServersCommand::new(json)) + } + ListCommands::SshKeys { json } => { + Box::new(stacker::console::commands::cli::list::ListSshKeysCommand::new(json)) + } + ListCommands::Clouds { json } => { + Box::new(stacker::console::commands::cli::list::ListCloudsCommand::new(json)) + } }, StackerCommands::SshKey { command: ssh_cmd } => match ssh_cmd { SshKeyCommands::Generate { server_id, save_to } => Box::new( @@ -1037,7 +1039,9 @@ fn get_command( private_key, } => Box::new( stacker::console::commands::cli::ssh_key::SshKeyUploadCommand::new( - server_id, public_key, private_key, + server_id, + public_key, + private_key, ), ), SshKeyCommands::Inject { @@ -1058,12 +1062,18 @@ fn get_command( ServiceCommands::Remove { name, file } => Box::new( stacker::console::commands::cli::service::ServiceRemoveCommand::new(name, file), ), - ServiceCommands::List { online } => Box::new( - stacker::console::commands::cli::service::ServiceListCommand::new(online), - ), + ServiceCommands::List { online } => { + Box::new(stacker::console::commands::cli::service::ServiceListCommand::new(online)) + } }, - StackerCommands::Resolve { confirm, force, deployment } => Box::new( - stacker::console::commands::cli::resolve::ResolveCommand::new(confirm, force, deployment), + StackerCommands::Resolve { + confirm, + force, + deployment, + } => Box::new( + stacker::console::commands::cli::resolve::ResolveCommand::new( + confirm, force, deployment, + ), ), StackerCommands::Update { channel } => Box::new( stacker::console::commands::cli::update::UpdateCommand::new(channel), @@ -1096,36 +1106,91 @@ fn get_command( StackerCommands::Pipe { command: pipe_cmd } => { use stacker::console::commands::cli::pipe; match pipe_cmd { - PipeCommands::Scan { app, protocols, json, deployment } => Box::new( - pipe::PipeScanCommand::new(app, protocols, json, deployment), - ), - PipeCommands::Create { source, target, manual, json, deployment } => Box::new( - pipe::PipeCreateCommand::new(source, target, manual, json, deployment), - ), - PipeCommands::List { json, deployment } => Box::new( - pipe::PipeListCommand::new(json, deployment), - ), + PipeCommands::Scan { + app, + protocols, + json, + deployment, + } => Box::new(pipe::PipeScanCommand::new(app, protocols, json, deployment)), + PipeCommands::Create { + source, + target, + manual, + json, + deployment, + } => Box::new(pipe::PipeCreateCommand::new( + source, target, manual, json, deployment, + )), + PipeCommands::List { json, deployment } => { + Box::new(pipe::PipeListCommand::new(json, deployment)) + } } - }, + } StackerCommands::Agent { command: agent_cmd } => { use stacker::console::commands::cli::agent; match agent_cmd { - AgentCommands::Health { app, system, json, deployment } => Box::new( - agent::AgentHealthCommand::new(app, json, deployment, system), - ), - AgentCommands::Logs { app, limit, json, deployment } => Box::new( - agent::AgentLogsCommand::new(app, Some(limit), json, deployment), - ), - AgentCommands::Restart { app, force, json, deployment } => Box::new( - agent::AgentRestartCommand::new(app, force, json, deployment), - ), - AgentCommands::DeployApp { app, image, force, json, deployment } => Box::new( - agent::AgentDeployAppCommand::new(app, image, force, json, deployment), - ), - AgentCommands::RemoveApp { app, volumes, remove_image, force, json, deployment } => Box::new( - agent::AgentRemoveAppCommand::new(app, volumes, remove_image, force, json, deployment), - ), - AgentCommands::ConfigureFirewall { action, list, app, public_ports, private_ports, persist, force, json, deployment } => { + AgentCommands::Health { + app, + system, + json, + deployment, + } => Box::new(agent::AgentHealthCommand::new( + app, json, deployment, system, + )), + AgentCommands::Logs { + app, + limit, + json, + deployment, + } => Box::new(agent::AgentLogsCommand::new( + app, + Some(limit), + json, + deployment, + )), + AgentCommands::Restart { + app, + force, + json, + deployment, + } => Box::new(agent::AgentRestartCommand::new( + app, force, json, deployment, + )), + AgentCommands::DeployApp { + app, + image, + force, + json, + deployment, + } => Box::new(agent::AgentDeployAppCommand::new( + app, image, force, json, deployment, + )), + AgentCommands::RemoveApp { + app, + volumes, + remove_image, + force, + json, + deployment, + } => Box::new(agent::AgentRemoveAppCommand::new( + app, + volumes, + remove_image, + force, + json, + deployment, + )), + AgentCommands::ConfigureFirewall { + action, + list, + app, + public_ports, + private_ports, + persist, + force, + json, + deployment, + } => { let effective_action = if list { "list".to_string() } else { action }; Box::new(agent::AgentConfigureFirewallCommand::new( effective_action, @@ -1138,31 +1203,50 @@ fn get_command( deployment, )) } - AgentCommands::ConfigureProxy { app, domain, port, ssl, action, force, json, deployment } => Box::new( - agent::AgentConfigureProxyCommand::new(app, domain, port, ssl, action, force, json, deployment), - ), + AgentCommands::ConfigureProxy { + app, + domain, + port, + ssl, + action, + force, + json, + deployment, + } => Box::new(agent::AgentConfigureProxyCommand::new( + app, domain, port, ssl, action, force, json, deployment, + )), AgentCommands::List { command: list_cmd } => match list_cmd { - AgentListCommands::Apps { json, deployment } => Box::new( - agent::AgentListAppsCommand::new(json, deployment), - ), - AgentListCommands::Containers { json, deployment } => Box::new( - agent::AgentListContainersCommand::new(json, deployment), - ), + AgentListCommands::Apps { json, deployment } => { + Box::new(agent::AgentListAppsCommand::new(json, deployment)) + } + AgentListCommands::Containers { json, deployment } => { + Box::new(agent::AgentListContainersCommand::new(json, deployment)) + } }, - AgentCommands::Status { json, deployment } => Box::new( - agent::AgentStatusCommand::new(json, deployment), - ), - AgentCommands::History { json, deployment } => Box::new( - agent::AgentHistoryCommand::new(json, deployment), - ), - AgentCommands::Exec { command_type, params, timeout, json, deployment } => Box::new( - agent::AgentExecCommand::new(command_type, params, timeout, json, deployment), - ), - AgentCommands::Install { file, json } => Box::new( - agent::AgentInstallCommand::new(file, json), - ), + AgentCommands::Status { json, deployment } => { + Box::new(agent::AgentStatusCommand::new(json, deployment)) + } + AgentCommands::History { json, deployment } => { + Box::new(agent::AgentHistoryCommand::new(json, deployment)) + } + AgentCommands::Exec { + command_type, + params, + timeout, + json, + deployment, + } => Box::new(agent::AgentExecCommand::new( + command_type, + params, + timeout, + json, + deployment, + )), + AgentCommands::Install { file, json } => { + Box::new(agent::AgentInstallCommand::new(file, json)) + } } - }, + } StackerCommands::Submit { file, version, @@ -1170,11 +1254,14 @@ fn get_command( category, plan_type, price, - } => Box::new( - stacker::console::commands::cli::submit::SubmitCommand::new( - file, version, description, category, plan_type, price, - ), - ), + } => Box::new(stacker::console::commands::cli::submit::SubmitCommand::new( + file, + version, + description, + category, + plan_type, + price, + )), StackerCommands::Marketplace { command: mkt_cmd } => match mkt_cmd { MarketplaceCommands::Status { name, json } => Box::new( stacker::console::commands::cli::marketplace::MarketplaceStatusCommand::new( diff --git a/src/cli/ai_client.rs b/src/cli/ai_client.rs index 2139477c..206f0e70 100644 --- a/src/cli/ai_client.rs +++ b/src/cli/ai_client.rs @@ -169,13 +169,28 @@ pub struct ChatMessage { impl ChatMessage { pub fn system(content: impl Into) -> Self { - Self { role: "system".to_string(), content: content.into(), tool_calls: None, tool_call_id: None } + Self { + role: "system".to_string(), + content: content.into(), + tool_calls: None, + tool_call_id: None, + } } pub fn user(content: impl Into) -> Self { - Self { role: "user".to_string(), content: content.into(), tool_calls: None, tool_call_id: None } + Self { + role: "user".to_string(), + content: content.into(), + tool_calls: None, + tool_call_id: None, + } } pub fn tool_result(id: Option, content: impl Into) -> Self { - Self { role: "tool".to_string(), content: content.into(), tool_calls: None, tool_call_id: id } + Self { + role: "tool".to_string(), + content: content.into(), + tool_calls: None, + tool_call_id: id, + } } } @@ -209,7 +224,8 @@ pub enum AiResponse { pub fn write_file_tool() -> ToolDef { ToolDef { name: "write_file".to_string(), - description: "Write content to a file on disk. Creates parent directories as needed.".to_string(), + description: "Write content to a file on disk. Creates parent directories as needed." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -239,7 +255,8 @@ pub fn list_directory_tool() -> ToolDef { ToolDef { name: "list_directory".to_string(), description: "List files and folders in a directory within the project. \ - Use '.' for the project root.".to_string(), + Use '.' for the project root." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -256,7 +273,8 @@ pub fn list_directory_tool() -> ToolDef { pub fn config_validate_tool() -> ToolDef { ToolDef { name: "config_validate".to_string(), - description: "Validate the stacker.yml configuration file and report any errors.".to_string(), + description: "Validate the stacker.yml configuration file and report any errors." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": {}, @@ -268,7 +286,8 @@ pub fn config_validate_tool() -> ToolDef { pub fn config_show_tool() -> ToolDef { ToolDef { name: "config_show".to_string(), - description: "Show the fully-resolved stacker.yml configuration (with env vars expanded).".to_string(), + description: "Show the fully-resolved stacker.yml configuration (with env vars expanded)." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": {}, @@ -292,7 +311,9 @@ pub fn stacker_status_tool() -> ToolDef { pub fn stacker_logs_tool() -> ToolDef { ToolDef { name: "stacker_logs".to_string(), - description: "Retrieve container logs. Optionally filter by service name and limit line count.".to_string(), + description: + "Retrieve container logs. Optionally filter by service name and limit line count." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -314,7 +335,8 @@ pub fn stacker_deploy_tool() -> ToolDef { ToolDef { name: "stacker_deploy".to_string(), description: "Build and deploy the stack. Use dry_run=true to preview what would happen \ - without making changes.".to_string(), + without making changes." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -340,7 +362,8 @@ pub fn stacker_deploy_tool() -> ToolDef { pub fn proxy_add_tool() -> ToolDef { ToolDef { name: "proxy_add".to_string(), - description: "Add a reverse-proxy entry mapping a domain to an upstream service.".to_string(), + description: "Add a reverse-proxy entry mapping a domain to an upstream service." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -366,7 +389,8 @@ pub fn proxy_add_tool() -> ToolDef { pub fn proxy_detect_tool() -> ToolDef { ToolDef { name: "proxy_detect".to_string(), - description: "Detect running reverse-proxy containers (nginx, Traefik, etc.) on the host.".to_string(), + description: "Detect running reverse-proxy containers (nginx, Traefik, etc.) on the host." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": {}, @@ -381,7 +405,8 @@ pub fn agent_health_tool() -> ToolDef { ToolDef { name: "agent_health".to_string(), description: "Check container health on the remote deployment via the Status Panel agent. \ - Returns container states, resource usage, and health metrics.".to_string(), + Returns container states, resource usage, and health metrics." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -403,7 +428,8 @@ pub fn agent_status_tool() -> ToolDef { ToolDef { name: "agent_status".to_string(), description: "Get the Status Panel agent status, including agent version, \ - last heartbeat, container states, and recent command history.".to_string(), + last heartbeat, container states, and recent command history." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -421,7 +447,8 @@ pub fn agent_logs_tool() -> ToolDef { ToolDef { name: "agent_logs".to_string(), description: "Fetch container logs from the remote deployment via the Status Panel agent. \ - Logs are automatically redacted for safety.".to_string(), + Logs are automatically redacted for safety." + .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -658,11 +685,10 @@ impl AiProvider for OpenAiProvider { }); } - let json: serde_json::Value = - response.json().map_err(|e| CliError::AiProviderError { - provider: "openai".to_string(), - message: format!("Failed to parse response: {}", e), - })?; + let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Failed to parse response: {}", e), + })?; let msg = &json["choices"][0]["message"]; let content = msg["content"].as_str().unwrap_or("").to_string(); @@ -677,9 +703,13 @@ impl AiProvider for OpenAiProvider { let name = func["name"].as_str()?.to_string(); // OpenAI encodes arguments as a JSON string let arguments: serde_json::Value = - serde_json::from_str(func["arguments"].as_str().unwrap_or("{}") - ).unwrap_or(serde_json::json!({})); - Some(ToolCall { id, name, arguments }) + serde_json::from_str(func["arguments"].as_str().unwrap_or("{}")) + .unwrap_or(serde_json::json!({})); + Some(ToolCall { + id, + name, + arguments, + }) }) .collect(); return Ok(AiResponse::ToolCalls(content, calls)); @@ -897,7 +927,11 @@ impl OllamaProvider { let timeout_secs = resolve_timeout(config.timeout); - Self { endpoint, model, timeout_secs } + Self { + endpoint, + model, + timeout_secs, + } } } @@ -986,11 +1020,10 @@ impl AiProvider for OllamaProvider { }); } - let json: serde_json::Value = - response.json().map_err(|e| CliError::AiProviderError { - provider: "ollama".to_string(), - message: format!("Failed to parse response: {}", e), - })?; + let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to parse response: {}", e), + })?; let msg = &json["message"]; let content = msg["content"].as_str().unwrap_or("").to_string(); @@ -1013,7 +1046,11 @@ impl AiProvider for OllamaProvider { } else { serde_json::json!({}) }; - Some(ToolCall { id: None, name, arguments }) + Some(ToolCall { + id: None, + name, + arguments, + }) }) .collect(); return Ok(AiResponse::ToolCalls(content, calls)); @@ -1141,12 +1178,11 @@ pub fn ollama_complete_streaming( continue; } - let json: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| { - CliError::AiProviderError { + let json: serde_json::Value = + serde_json::from_str(trimmed).map_err(|e| CliError::AiProviderError { provider: "ollama".to_string(), message: format!("Invalid streaming chunk: {}", e), - } - })?; + })?; if let Some(chunk) = json["message"]["content"].as_str() { eprint!("{}", chunk); @@ -1280,10 +1316,7 @@ pub fn build_compose_prompt(ctx: &PromptContext) -> (String, String) { /// Build a prompt for troubleshooting deployment issues. pub fn build_troubleshoot_prompt(ctx: &PromptContext) -> (String, String) { - let error = ctx - .error_log - .as_deref() - .unwrap_or("No error log provided"); + let error = ctx.error_log.as_deref().unwrap_or("No error log provided"); let prompt = format!( "Diagnose and fix the following deployment issue.\n\ @@ -1393,7 +1426,9 @@ mod tests { #[test] fn test_mock_ai_complete() { let provider = MockAiProvider::with_response("Use FROM node:lts-alpine"); - let result = provider.complete("optimize dockerfile", "system context").unwrap(); + let result = provider + .complete("optimize dockerfile", "system context") + .unwrap(); assert!(result.contains("node:lts-alpine")); } @@ -1514,7 +1549,9 @@ mod tests { project_type: Some(AppType::Python), files: vec![], error_log: None, - current_config: Some("version: '3'\nservices:\n web:\n image: python:3.11".to_string()), + current_config: Some( + "version: '3'\nservices:\n web:\n image: python:3.11".to_string(), + ), }; let (_, prompt) = build_compose_prompt(&ctx); diff --git a/src/cli/ai_scanner.rs b/src/cli/ai_scanner.rs index deea3e6e..61a756bd 100644 --- a/src/cli/ai_scanner.rs +++ b/src/cli/ai_scanner.rs @@ -304,14 +304,12 @@ pub fn generate_config_with_ai_impl( // Validate that it's parseable YAML (but don't require it to be a valid StackerConfig // yet — the caller will do from_str() and report detailed errors) - serde_yaml::from_str::(&yaml).map_err(|e| { - CliError::AiProviderError { - provider: provider.name().to_string(), - message: format!( - "AI generated invalid YAML: {}. Raw response:\n{}", - e, raw_response - ), - } + serde_yaml::from_str::(&yaml).map_err(|e| CliError::AiProviderError { + provider: provider.name().to_string(), + message: format!( + "AI generated invalid YAML: {}. Raw response:\n{}", + e, raw_response + ), })?; Ok(yaml) @@ -323,19 +321,11 @@ pub fn strip_code_fences(text: &str) -> String { let trimmed = text.trim(); // Check for opening fence - let without_open = if trimmed.starts_with("```yaml") - || trimmed.starts_with("```yml") - { + let without_open = if trimmed.starts_with("```yaml") || trimmed.starts_with("```yml") { // Remove opening fence line - trimmed - .splitn(2, '\n') - .nth(1) - .unwrap_or(trimmed) + trimmed.splitn(2, '\n').nth(1).unwrap_or(trimmed) } else if trimmed.starts_with("```") { - trimmed - .splitn(2, '\n') - .nth(1) - .unwrap_or(trimmed) + trimmed.splitn(2, '\n').nth(1).unwrap_or(trimmed) } else { return trimmed.to_string(); }; @@ -695,7 +685,10 @@ env: assert_eq!(config.services.len(), 2); assert_eq!(config.services[0].name, "postgres"); assert_eq!(config.services[1].name, "redis"); - assert_eq!(config.proxy.proxy_type, crate::cli::config_parser::ProxyType::Nginx); + assert_eq!( + config.proxy.proxy_type, + crate::cli::config_parser::ProxyType::Nginx + ); assert!(config.monitoring.status_panel); } diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index bb01f828..03682af9 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -266,8 +266,9 @@ where match value { serde_yaml::Value::Null => Ok(Vec::new()), - serde_yaml::Value::Sequence(_) => serde_yaml::from_value(value) - .map_err(serde::de::Error::custom), + serde_yaml::Value::Sequence(_) => { + serde_yaml::from_value(value).map_err(serde::de::Error::custom) + } serde_yaml::Value::Mapping(map) => { let mut services = Vec::new(); @@ -639,7 +640,8 @@ impl StackerConfig { issues.push(ValidationIssue { severity: Severity::Error, code: "E001".to_string(), - message: "Cloud provider configuration is required for cloud deployment".to_string(), + message: "Cloud provider configuration is required for cloud deployment" + .to_string(), field: Some("deploy.cloud.provider".to_string()), }); } @@ -781,11 +783,7 @@ fn load_env_file_vars_from_yaml(path: &Path, raw_content: &str) -> HashMap String { - port_str - .split(':') - .next() - .unwrap_or(port_str) - .to_string() + port_str.split(':').next().unwrap_or(port_str).to_string() } /// Resolve `${VAR_NAME}` references in a string using process environment. @@ -837,15 +835,15 @@ fn resolve_env_vars_with_fallback( .collect(); for (full_match, var_name) in captures { - let value = match std::env::var(&var_name) { - Ok(v) => v, - Err(_) => fallback_vars - .get(&var_name) - .cloned() - .ok_or_else(|| CliError::EnvVarNotFound { - var_name: var_name.clone(), + let value = + match std::env::var(&var_name) { + Ok(v) => v, + Err(_) => fallback_vars.get(&var_name).cloned().ok_or_else(|| { + CliError::EnvVarNotFound { + var_name: var_name.clone(), + } })?, - }; + }; result = result.replace(&full_match, &value); } @@ -1194,12 +1192,12 @@ app: assert_eq!(config.app.app_type, AppType::Static); } - #[test] - fn test_from_file_resolves_env_from_env_file() { - let dir = TempDir::new().unwrap(); - fs::write(dir.path().join(".env"), "DOCKER_IMAGE=node:14-alpine\n").unwrap(); + #[test] + fn test_from_file_resolves_env_from_env_file() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join(".env"), "DOCKER_IMAGE=node:14-alpine\n").unwrap(); - let yaml = r#" + let yaml = r#" name: env-file-test env_file: .env app: @@ -1209,12 +1207,12 @@ app: deploy: target: local "#; - let config_path = dir.path().join("stacker.yml"); - fs::write(&config_path, yaml).unwrap(); + let config_path = dir.path().join("stacker.yml"); + fs::write(&config_path, yaml).unwrap(); - let config = StackerConfig::from_file(&config_path).unwrap(); - assert_eq!(config.app.image.as_deref(), Some("node:14-alpine")); - } + let config = StackerConfig::from_file(&config_path).unwrap(); + assert_eq!(config.app.image.as_deref(), Some("node:14-alpine")); + } #[test] fn test_parse_invalid_app_type_returns_error() { @@ -1261,9 +1259,9 @@ services: assert_eq!(config.services[2].ports.len(), 2); } - #[test] - fn test_parse_services_map() { - let yaml = r#" + #[test] + fn test_parse_services_map() { + let yaml = r#" name: svc-map-test services: web: @@ -1275,15 +1273,21 @@ services: image: redis:7-alpine "#; - let config = StackerConfig::from_str(yaml).unwrap(); - assert_eq!(config.services.len(), 2); - assert!(config.services.iter().any(|s| s.name == "web" && s.image == "nginx:alpine")); - assert!(config.services.iter().any(|s| s.name == "redis" && s.image == "redis:7-alpine")); - } + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.services.len(), 2); + assert!(config + .services + .iter() + .any(|s| s.name == "web" && s.image == "nginx:alpine")); + assert!(config + .services + .iter() + .any(|s| s.name == "redis" && s.image == "redis:7-alpine")); + } - #[test] - fn test_parse_services_map_infers_name_from_key() { - let yaml = r#" + #[test] + fn test_parse_services_map_infers_name_from_key() { + let yaml = r#" name: svc-map-key-test services: web: @@ -1291,11 +1295,11 @@ services: ports: ["8080:80"] "#; - let config = StackerConfig::from_str(yaml).unwrap(); - assert_eq!(config.services.len(), 1); - assert_eq!(config.services[0].name, "web"); - assert_eq!(config.services[0].image, "nginx:alpine"); - } + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.services.len(), 1); + assert_eq!(config.services[0].name, "web"); + assert_eq!(config.services[0].image, "nginx:alpine"); + } #[test] fn test_parse_proxy_domains() { @@ -1390,9 +1394,14 @@ ai: .iter() .filter(|i| i.severity == Severity::Error) .collect(); - assert!(!errors.is_empty(), "Expected validation error for missing cloud provider"); assert!( - errors.iter().any(|e| e.field.as_deref() == Some("deploy.cloud.provider")), + !errors.is_empty(), + "Expected validation error for missing cloud provider" + ); + assert!( + errors + .iter() + .any(|e| e.field.as_deref() == Some("deploy.cloud.provider")), "Expected field reference to deploy.cloud.provider" ); } @@ -1410,7 +1419,10 @@ ai: .iter() .filter(|i| i.severity == Severity::Error) .collect(); - assert!(!errors.is_empty(), "Expected validation error for missing server host"); + assert!( + !errors.is_empty(), + "Expected validation error for missing server host" + ); assert!( errors.iter().any(|e| e.message.contains("host")), "Expected 'host' mentioned in error" @@ -1438,10 +1450,7 @@ services: .iter() .filter(|i| i.severity == Severity::Warning) .collect(); - assert!( - !warnings.is_empty(), - "Expected warning about port conflict" - ); + assert!(!warnings.is_empty(), "Expected warning about port conflict"); assert!( warnings.iter().any(|w| w.message.contains("8080")), "Expected port 8080 in warning" @@ -1511,7 +1520,10 @@ services: .iter() .filter(|i| i.severity == Severity::Info) .collect(); - assert!(errors.is_empty(), "Expected no blocking errors, got: {errors:?}"); + assert!( + errors.is_empty(), + "Expected no blocking errors, got: {errors:?}" + ); assert!( infos .iter() @@ -1617,10 +1629,7 @@ services: assert_eq!(original.name, parsed.name); assert_eq!(original.app.app_type, parsed.app.app_type); assert_eq!(original.app.path, parsed.app.path); - assert_eq!( - original.env.get("PORT"), - parsed.env.get("PORT") - ); + assert_eq!(original.env.get("PORT"), parsed.env.get("PORT")); } #[test] diff --git a/src/cli/credentials.rs b/src/cli/credentials.rs index a95d0f68..a40999fc 100644 --- a/src/cli/credentials.rs +++ b/src/cli/credentials.rs @@ -122,9 +122,7 @@ impl FileCredentialStore { pub fn default_path() -> PathBuf { let base = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) - .or_else(|_| { - std::env::var("HOME").map(|h| PathBuf::from(h).join(".config")) - }) + .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".config"))) .unwrap_or_else(|_| PathBuf::from(".")); base.join("stacker").join("credentials.json") @@ -220,10 +218,7 @@ impl CredentialsManager { /// Load credentials and ensure they are present and not expired. /// Returns `CliError::LoginRequired` when absent, /// `CliError::TokenExpired` when expired. - pub fn require_valid_token( - &self, - feature: &str, - ) -> Result { + pub fn require_valid_token(&self, feature: &str) -> Result { let creds = self.store.load()?.ok_or_else(|| CliError::LoginRequired { feature: feature.to_string(), })?; @@ -261,7 +256,9 @@ const TOKEN_ENDPOINT: &str = "/auth/login"; fn is_direct_login_endpoint(auth_url: &str) -> bool { let url = auth_url.trim_end_matches('/').to_lowercase(); - url.ends_with("/auth/login") || url.ends_with("/server/user/auth/login") || url.ends_with("/login") + url.ends_with("/auth/login") + || url.ends_with("/server/user/auth/login") + || url.ends_with("/login") } /// Parameters for a login request. @@ -277,8 +274,12 @@ pub struct LoginRequest { /// Abstraction over the HTTP call to the OAuth token endpoint. /// Production uses `HttpOAuthClient`; tests can inject a mock. pub trait OAuthClient: Send + Sync { - fn request_token(&self, auth_url: &str, email: &str, password: &str) - -> Result; + fn request_token( + &self, + auth_url: &str, + email: &str, + password: &str, + ) -> Result; } /// Production OAuth client using `reqwest::blocking`. @@ -309,10 +310,7 @@ impl OAuthClient for HttpOAuthClient { let resp = if direct_login { client .post(&url) - .form(&[ - ("email", email), - ("password", password), - ]) + .form(&[("email", email), ("password", password)]) .send() } else { client @@ -400,8 +398,12 @@ mod tests { #[test] fn test_is_direct_login_endpoint_detection() { - assert!(is_direct_login_endpoint("https://dev.try.direct/server/user/auth/login")); - assert!(is_direct_login_endpoint("https://dev.try.direct/server/user/auth/login/")); + assert!(is_direct_login_endpoint( + "https://dev.try.direct/server/user/auth/login" + )); + assert!(is_direct_login_endpoint( + "https://dev.try.direct/server/user/auth/login/" + )); assert!(!is_direct_login_endpoint("https://api.try.direct")); } @@ -539,8 +541,9 @@ mod tests { }; let creds = StoredCredentials::from(resp); let diff = creds.expires_at - Utc::now(); - assert!(diff.num_seconds() > (ten_hours as i64) - 100 - && diff.num_seconds() <= ten_hours as i64); + assert!( + diff.num_seconds() > (ten_hours as i64) - 100 && diff.num_seconds() <= ten_hours as i64 + ); } #[test] @@ -809,7 +812,8 @@ mod tests { #[test] fn test_login_invalid_credentials_returns_error() { let (manager, _) = make_manager(); - let oauth = MockOAuthClient::failure("Authentication failed (HTTP 401 Unauthorized): invalid"); + let oauth = + MockOAuthClient::failure("Authentication failed (HTTP 401 Unauthorized): invalid"); let request = LoginRequest { email: "bad@example.com".into(), password: "wrong".into(), diff --git a/src/cli/deployment_lock.rs b/src/cli/deployment_lock.rs index 159f550f..7255e011 100644 --- a/src/cli/deployment_lock.rs +++ b/src/cli/deployment_lock.rs @@ -208,13 +208,7 @@ impl DeploymentLock { .server .as_ref() .and_then(|s| s.ssh_key.clone()) - .or_else(|| { - config - .deploy - .cloud - .as_ref() - .and_then(|c| c.ssh_key.clone()) - }); + .or_else(|| config.deploy.cloud.as_ref().and_then(|c| c.ssh_key.clone())); config.deploy.server = Some(ServerConfig { host: ip.clone(), diff --git a/src/cli/detector.rs b/src/cli/detector.rs index 335cbf9d..11951d60 100644 --- a/src/cli/detector.rs +++ b/src/cli/detector.rs @@ -124,10 +124,7 @@ const ENV_FILE_NAMES: &[&str] = &[".env"]; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /// Detect the project type and infrastructure files in a directory. -pub fn detect_project( - project_path: &Path, - fs: &dyn FileSystem, -) -> ProjectDetection { +pub fn detect_project(project_path: &Path, fs: &dyn FileSystem) -> ProjectDetection { let files = match fs.list_dir(project_path) { Ok(f) => f, Err(_) => return ProjectDetection::default(), diff --git a/src/cli/error.rs b/src/cli/error.rs index 8c90dd4c..6c38b113 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -10,27 +10,46 @@ use crate::cli::config_parser::DeployTarget; #[derive(Debug)] pub enum CliError { // Config errors - ConfigNotFound { path: PathBuf }, - ConfigParseFailed { source: serde_yaml::Error }, + ConfigNotFound { + path: PathBuf, + }, + ConfigParseFailed { + source: serde_yaml::Error, + }, ConfigValidation(String), - EnvVarNotFound { var_name: String }, + EnvVarNotFound { + var_name: String, + }, // Detection errors - DetectionFailed { path: PathBuf, reason: String }, + DetectionFailed { + path: PathBuf, + reason: String, + }, // Generator errors GeneratorError(String), - DockerfileExists { path: PathBuf }, + DockerfileExists { + path: PathBuf, + }, // Deployment errors - DeployFailed { target: DeployTarget, reason: String }, - LoginRequired { feature: String }, + DeployFailed { + target: DeployTarget, + reason: String, + }, + LoginRequired { + feature: String, + }, CloudProviderMissing, ServerHostMissing, // Runtime errors ContainerRuntimeUnavailable, - CommandFailed { command: String, exit_code: i32 }, + CommandFailed { + command: String, + exit_code: i32, + }, // Auth errors AuthFailed(String), @@ -38,18 +57,29 @@ pub enum CliError { // AI errors AiNotConfigured, - AiProviderError { provider: String, message: String }, + AiProviderError { + provider: String, + message: String, + }, // Proxy errors ProxyConfigFailed(String), // Secrets/env errors - EnvFileNotFound { path: std::path::PathBuf }, - SecretKeyNotFound { key: String }, + EnvFileNotFound { + path: std::path::PathBuf, + }, + SecretKeyNotFound { + key: String, + }, // Agent errors - AgentNotFound { deployment_hash: String }, - AgentOffline { deployment_hash: String }, + AgentNotFound { + deployment_hash: String, + }, + AgentOffline { + deployment_hash: String, + }, AgentCommandTimeout { command_id: String, /// Human-readable label for the command (e.g. "Fetching containers") @@ -58,7 +88,10 @@ pub enum CliError { last_status: String, deployment_hash: String, }, - AgentCommandFailed { command_id: String, error: String }, + AgentCommandFailed { + command_id: String, + error: String, + }, // IO errors Io(std::io::Error), @@ -235,7 +268,11 @@ pub struct ValidationIssue { impl fmt::Display for ValidationIssue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.field { - Some(field) => write!(f, "[{}] {}: {} ({})", self.severity, self.code, self.message, field), + Some(field) => write!( + f, + "[{}] {}: {} ({})", + self.severity, self.code, self.message, field + ), None => write!(f, "[{}] {}: {}", self.severity, self.code, self.message), } } @@ -286,10 +323,7 @@ mod tests { msg.contains("Configuration file not found"), "Expected 'Configuration file not found' in: {msg}" ); - assert!( - msg.contains("/tmp/stacker.yml"), - "Expected path in: {msg}" - ); + assert!(msg.contains("/tmp/stacker.yml"), "Expected path in: {msg}"); } #[test] @@ -298,10 +332,7 @@ mod tests { var_name: "DB_PASSWORD".to_string(), }; let msg = format!("{err}"); - assert!( - msg.contains("DB_PASSWORD"), - "Expected var name in: {msg}" - ); + assert!(msg.contains("DB_PASSWORD"), "Expected var name in: {msg}"); } #[test] @@ -394,7 +425,10 @@ mod tests { assert!(msg.contains("[error]"), "Expected severity in: {msg}"); assert!(msg.contains("E001"), "Expected code in: {msg}"); assert!(msg.contains("port conflict"), "Expected message in: {msg}"); - assert!(msg.contains("services[0].ports"), "Expected field in: {msg}"); + assert!( + msg.contains("services[0].ports"), + "Expected field in: {msg}" + ); } #[test] diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 6d0600c1..330b9467 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -3,9 +3,7 @@ use std::convert::TryFrom; use std::fmt; use std::path::Path; -use crate::cli::config_parser::{ - AppType, ProxyType, ServiceDefinition, StackerConfig, -}; +use crate::cli::config_parser::{AppType, ProxyType, ServiceDefinition, StackerConfig}; use crate::cli::error::CliError; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -350,9 +348,7 @@ impl fmt::Display for ComposeDefinition { #[cfg(test)] mod tests { use super::*; - use crate::cli::config_parser::{ - AppSource, ConfigBuilder, DeployConfig, ProxyConfig, SslMode, - }; + use crate::cli::config_parser::{AppSource, ConfigBuilder, DeployConfig, ProxyConfig, SslMode}; use std::collections::HashMap; fn minimal_config(app_type: AppType) -> StackerConfig { @@ -481,10 +477,7 @@ mod tests { let compose = ComposeDefinition::try_from(&config).unwrap(); let traefik = compose.services.iter().find(|s| s.name == "traefik"); assert!(traefik.is_some()); - assert_eq!( - traefik.unwrap().image.as_deref(), - Some("traefik:v2.10") - ); + assert_eq!(traefik.unwrap().image.as_deref(), Some("traefik:v2.10")); } #[test] @@ -548,8 +541,14 @@ mod tests { let compose = ComposeDefinition::try_from(&config).unwrap(); let app = &compose.services[0]; - assert_eq!(app.environment.get("NODE_ENV").map(|s| s.as_str()), Some("production")); - assert_eq!(app.environment.get("LOG_LEVEL").map(|s| s.as_str()), Some("debug")); + assert_eq!( + app.environment.get("NODE_ENV").map(|s| s.as_str()), + Some("production") + ); + assert_eq!( + app.environment.get("LOG_LEVEL").map(|s| s.as_str()), + Some("debug") + ); } #[test] @@ -601,7 +600,10 @@ mod tests { assert_eq!(compose_svc.image.as_deref(), Some("mysql:8")); assert!(compose_svc.ports.contains(&"3306:3306".to_string())); assert_eq!( - compose_svc.environment.get("MYSQL_ROOT_PASSWORD").map(|s| s.as_str()), + compose_svc + .environment + .get("MYSQL_ROOT_PASSWORD") + .map(|s| s.as_str()), Some("pass") ); } @@ -635,10 +637,7 @@ mod tests { .unwrap(); let compose = ComposeDefinition::try_from(&config).unwrap(); - let npm = compose - .services - .iter() - .find(|s| s.name == "proxy-manager"); + let npm = compose.services.iter().find(|s| s.name == "proxy-manager"); assert!(npm.is_some()); let npm = npm.unwrap(); assert!(npm.ports.contains(&"81:81".to_string())); // NPM admin port diff --git a/src/cli/generator/dockerfile.rs b/src/cli/generator/dockerfile.rs index 2ec6f42b..a043aef2 100644 --- a/src/cli/generator/dockerfile.rs +++ b/src/cli/generator/dockerfile.rs @@ -248,11 +248,7 @@ impl DockerfileBuilder { } /// Write Dockerfile to a path. Returns error if file already exists. - pub fn write_to( - &self, - path: &std::path::Path, - overwrite: bool, - ) -> Result<(), CliError> { + pub fn write_to(&self, path: &std::path::Path, overwrite: bool) -> Result<(), CliError> { if !overwrite && path.exists() { return Err(CliError::DockerfileExists { path: path.to_path_buf(), @@ -429,10 +425,7 @@ mod tests { #[test] fn test_multiple_expose_ports() { - let content = DockerfileBuilder::new() - .expose(80) - .expose(443) - .build(); + let content = DockerfileBuilder::new().expose(80).expose(443).build(); assert!(content.contains("EXPOSE 80")); assert!(content.contains("EXPOSE 443")); } diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index 7d684d3c..e857afd3 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -207,7 +207,11 @@ impl DeployStrategy for LocalDeploy { }); } - let action = if context.dry_run { "validated" } else { "started" }; + let action = if context.dry_run { + "validated" + } else { + "started" + }; Ok(DeployResult { target: DeployTarget::Local, message: format!("Local deployment {} successfully", action), @@ -426,12 +430,14 @@ impl DeployStrategy for CloudDeploy { if let Some(cloud_cfg) = &config.deploy.cloud { if cloud_cfg.orchestrator == CloudOrchestrator::Remote { let cred_manager = CredentialsManager::with_default_store(); - let creds = cred_manager.require_valid_token("remote cloud orchestrator deployment")?; + let creds = + cred_manager.require_valid_token("remote cloud orchestrator deployment")?; if context.dry_run { return Ok(DeployResult { target: DeployTarget::Cloud, - message: "Remote cloud deploy dry-run validated payload and credentials".to_string(), + message: "Remote cloud deploy dry-run validated payload and credentials" + .to_string(), server_ip: None, deployment_id: None, project_id: None, @@ -458,9 +464,7 @@ impl DeployStrategy for CloudDeploy { .clone() .or_else(|| cloud_cfg.server.clone()); - let base_url = normalize_stacker_server_url( - stacker_client::DEFAULT_STACKER_URL, - ); + let base_url = normalize_stacker_server_url(stacker_client::DEFAULT_STACKER_URL); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -748,7 +752,11 @@ impl DeployStrategy for CloudDeploy { }); } - let action_str = if context.dry_run { "plan completed" } else { "deployed" }; + let action_str = if context.dry_run { + "plan completed" + } else { + "deployed" + }; Ok(DeployResult { target: DeployTarget::Cloud, message: format!("Cloud deployment {}", action_str), @@ -825,7 +833,13 @@ fn normalize_user_service_base_url(raw: &str) -> String { pub fn normalize_stacker_server_url(raw: &str) -> String { let mut url = raw.trim_end_matches('/').to_string(); // Strip known auth endpoints that might be stored as server_url - for suffix in ["/oauth_server/token", "/auth/login", "/server/user/auth/login", "/login", "/api"] { + for suffix in [ + "/oauth_server/token", + "/auth/login", + "/server/user/auth/login", + "/login", + "/api", + ] { if url.ends_with(suffix) { let len = url.len() - suffix.len(); url = url[..len].to_string(); @@ -921,7 +935,10 @@ fn resolve_remote_cloud_credentials(provider: &str) -> serde_json::Map {} @@ -941,25 +958,16 @@ pub(crate) fn resolve_docker_registry_credentials( let registry = config.deploy.registry.as_ref(); // Username: env var > config - let username = first_non_empty_env(&[ - "STACKER_DOCKER_USERNAME", - "DOCKER_USERNAME", - ]) - .or_else(|| registry.and_then(|r| r.username.clone())); + let username = first_non_empty_env(&["STACKER_DOCKER_USERNAME", "DOCKER_USERNAME"]) + .or_else(|| registry.and_then(|r| r.username.clone())); // Password: env var > config - let password = first_non_empty_env(&[ - "STACKER_DOCKER_PASSWORD", - "DOCKER_PASSWORD", - ]) - .or_else(|| registry.and_then(|r| r.password.clone())); + let password = first_non_empty_env(&["STACKER_DOCKER_PASSWORD", "DOCKER_PASSWORD"]) + .or_else(|| registry.and_then(|r| r.password.clone())); // Registry server: env var > config > default "docker.io" - let server = first_non_empty_env(&[ - "STACKER_DOCKER_REGISTRY", - "DOCKER_REGISTRY", - ]) - .or_else(|| registry.and_then(|r| r.server.clone())); + let server = first_non_empty_env(&["STACKER_DOCKER_REGISTRY", "DOCKER_REGISTRY"]) + .or_else(|| registry.and_then(|r| r.server.clone())); if let Some(u) = username { creds.insert("docker_username".to_string(), serde_json::Value::String(u)); @@ -980,8 +988,12 @@ fn build_remote_deploy_payload(config: &StackerConfig) -> serde_json::Value { let provider = cloud .map(|c| provider_code_for_remote(&c.provider.to_string()).to_string()) .unwrap_or_else(|| "htz".to_string()); - let region = cloud.and_then(|c| c.region.clone()).unwrap_or_else(|| "nbg1".to_string()); - let server = cloud.and_then(|c| c.size.clone()).unwrap_or_else(|| "cpx11".to_string()); + let region = cloud + .and_then(|c| c.region.clone()) + .unwrap_or_else(|| "nbg1".to_string()); + let server = cloud + .and_then(|c| c.size.clone()) + .unwrap_or_else(|| "cpx11".to_string()); let stack_code = config .project .identity @@ -1049,12 +1061,7 @@ fn validate_remote_deploy_payload(payload: &serde_json::Value) -> Result<(), Cli if key == "subscriptions" && !v.is_array() { missing.push("subscriptions(array)"); } - if key == "stack_code" - && v - .as_str() - .map(|s| s.trim().is_empty()) - .unwrap_or(true) - { + if key == "stack_code" && v.as_str().map(|s| s.trim().is_empty()).unwrap_or(true) { missing.push("stack_code(non-empty)"); } } @@ -1063,10 +1070,7 @@ fn validate_remote_deploy_payload(payload: &serde_json::Value) -> Result<(), Cli } if !missing.is_empty() { - let identity_hint = if missing - .iter() - .any(|item| item.contains("stack_code")) - { + let identity_hint = if missing.iter().any(|item| item.contains("stack_code")) { " stack_code defaults to 'custom-stack'. Optionally set project.identity in stacker.yml to a registered catalog stack code." } else { "" @@ -1142,7 +1146,10 @@ fn validate_remote_deploy_payload(payload: &serde_json::Value) -> Result<(), Cli } #[allow(dead_code)] -fn persist_remote_payload_snapshot(project_dir: &Path, payload: &serde_json::Value) -> Option { +fn persist_remote_payload_snapshot( + project_dir: &Path, + payload: &serde_json::Value, +) -> Option { let stacker_dir = project_dir.join(".stacker"); let snapshot_path = stacker_dir.join("remote-payload.last.json"); @@ -1158,7 +1165,10 @@ fn persist_remote_payload_snapshot(project_dir: &Path, payload: &serde_json::Val let payload_str = match serde_json::to_string_pretty(payload) { Ok(s) => s, Err(err) => { - eprintln!("Warning: failed to serialize remote payload snapshot: {}", err); + eprintln!( + "Warning: failed to serialize remote payload snapshot: {}", + err + ); return None; } }; @@ -1215,13 +1225,13 @@ impl DeployStrategy for ServerDeploy { }); } - let server_host = config - .deploy - .server - .as_ref() - .map(|s| s.host.clone()); + let server_host = config.deploy.server.as_ref().map(|s| s.host.clone()); - let action_str = if context.dry_run { "plan completed" } else { "deployed" }; + let action_str = if context.dry_run { + "plan completed" + } else { + "deployed" + }; Ok(DeployResult { target: DeployTarget::Server, message: format!("Server deployment {}", action_str), @@ -1283,8 +1293,10 @@ fn extract_server_ip(stdout: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::cli::config_parser::{ + CloudConfig, CloudOrchestrator, CloudProvider, ConfigBuilder, ServerConfig, + }; use std::sync::Mutex; - use crate::cli::config_parser::{CloudConfig, CloudOrchestrator, CloudProvider, ConfigBuilder, ServerConfig}; // ── Mock executor ─────────────────────────────── @@ -1354,7 +1366,8 @@ mod tests { } fn sample_cloud_config() -> StackerConfig { - ConfigBuilder::new().name("test-cloud-app") + ConfigBuilder::new() + .name("test-cloud-app") .deploy_target(DeployTarget::Cloud) .cloud(CloudConfig { provider: CloudProvider::Hetzner, @@ -1509,7 +1522,8 @@ mod tests { } fn sample_server_config() -> StackerConfig { - ConfigBuilder::new().name("test-server-app") + ConfigBuilder::new() + .name("test-server-app") .deploy_target(DeployTarget::Server) .server(ServerConfig { host: "192.168.1.100".to_string(), diff --git a/src/cli/progress.rs b/src/cli/progress.rs index e27a1e2c..b94ede46 100644 --- a/src/cli/progress.rs +++ b/src/cli/progress.rs @@ -86,10 +86,7 @@ pub fn health_spinner(total_services: usize) -> ProgressBar { /// Update health check progress. pub fn update_health(pb: &ProgressBar, running: usize, total: usize) { - pb.set_message(format!( - "Container health: {}/{} running", - running, total - )); + pb.set_message(format!("Container health: {}/{} running", running, total)); } #[cfg(test)] diff --git a/src/cli/proxy_manager.rs b/src/cli/proxy_manager.rs index c9afaa9e..e700900c 100644 --- a/src/cli/proxy_manager.rs +++ b/src/cli/proxy_manager.rs @@ -53,7 +53,11 @@ impl ContainerRuntime for DockerCliRuntime { fn list_containers(&self) -> Result, CliError> { let output = std::process::Command::new("docker") - .args(["ps", "--format", "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}|{{.Status}}"]) + .args([ + "ps", + "--format", + "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}|{{.Status}}", + ]) .output() .map_err(|_| CliError::ContainerRuntimeUnavailable)?; @@ -294,7 +298,8 @@ pub fn generate_nginx_server_block(domain: &DomainConfig) -> String { block.push_str(&format!(" proxy_pass http://{};\n", domain.upstream)); block.push_str(" proxy_set_header Host $host;\n"); block.push_str(" proxy_set_header X-Real-IP $remote_addr;\n"); - block.push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); + block + .push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); block.push_str(" proxy_set_header X-Forwarded-Proto $scheme;\n"); block.push_str(" }\n"); block.push_str("}\n"); @@ -307,7 +312,8 @@ pub fn generate_nginx_server_block(domain: &DomainConfig) -> String { block.push_str(&format!(" proxy_pass http://{};\n", domain.upstream)); block.push_str(" proxy_set_header Host $host;\n"); block.push_str(" proxy_set_header X-Real-IP $remote_addr;\n"); - block.push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); + block + .push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); block.push_str(" proxy_set_header X-Forwarded-Proto $scheme;\n"); block.push_str(" }\n"); block.push_str("}\n"); @@ -319,9 +325,7 @@ pub fn generate_nginx_server_block(domain: &DomainConfig) -> String { /// Generate nginx configs for all domains in a proxy config. /// Returns a map of `filename → config content` for writing to `./nginx/conf.d/`. -pub fn generate_nginx_configs( - domains: &[DomainConfig], -) -> HashMap { +pub fn generate_nginx_configs(domains: &[DomainConfig]) -> HashMap { let mut configs = HashMap::new(); for domain in domains { @@ -418,10 +422,8 @@ mod tests { #[test] fn test_detect_proxy_nginx_from_containers() { - let runtime = MockContainerRuntime::available_with(vec![ - app_container(), - nginx_container(), - ]); + let runtime = + MockContainerRuntime::available_with(vec![app_container(), nginx_container()]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::Nginx); assert_eq!(detection.container_name.as_deref(), Some("nginx-proxy")); @@ -431,10 +433,7 @@ mod tests { #[test] fn test_detect_proxy_npm_from_containers() { - let runtime = MockContainerRuntime::available_with(vec![ - app_container(), - npm_container(), - ]); + let runtime = MockContainerRuntime::available_with(vec![app_container(), npm_container()]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); assert!(detection.ports.contains(&81)); @@ -442,9 +441,7 @@ mod tests { #[test] fn test_detect_proxy_traefik_from_containers() { - let runtime = MockContainerRuntime::available_with(vec![ - traefik_container(), - ]); + let runtime = MockContainerRuntime::available_with(vec![traefik_container()]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::Traefik); assert_eq!(detection.container_name.as_deref(), Some("traefik")); @@ -462,10 +459,8 @@ mod tests { fn test_detect_npm_takes_priority_over_nginx() { // NPM containers contain "nginx" in their image. NPM must be detected // first because its signature is checked before plain "nginx". - let runtime = MockContainerRuntime::available_with(vec![ - npm_container(), - nginx_container(), - ]); + let runtime = + MockContainerRuntime::available_with(vec![npm_container(), nginx_container()]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); } @@ -572,7 +567,8 @@ mod tests { #[test] fn test_parse_docker_ps_line() { - let line = "abc123|my-nginx|nginx:alpine|0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp|Up 2 hours"; + let line = + "abc123|my-nginx|nginx:alpine|0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp|Up 2 hours"; let info = parse_docker_ps_line(line); assert_eq!(info.id, "abc123"); assert_eq!(info.name, "my-nginx"); diff --git a/src/cli/service_catalog.rs b/src/cli/service_catalog.rs index dea421ef..bedad030 100644 --- a/src/cli/service_catalog.rs +++ b/src/cli/service_catalog.rs @@ -60,11 +60,12 @@ impl ServiceCatalog { } // Hardcoded catalog lookup - self.lookup_hardcoded(&canonical) - .ok_or_else(|| CliError::ConfigValidation(format!( + self.lookup_hardcoded(&canonical).ok_or_else(|| { + CliError::ConfigValidation(format!( "Unknown service '{}'. Run `stacker service list` to see available services.", service_name - ))) + )) + }) } /// List all available services from the hardcoded catalog. @@ -85,24 +86,33 @@ impl ServiceCatalog { if let Some(services) = stack_def.get("services") { if let Some(first_svc) = services.as_array().and_then(|arr| arr.first()) { let service = ServiceDefinition { - name: first_svc["name"].as_str() - .unwrap_or(slug).to_string(), - image: first_svc["image"].as_str() - .unwrap_or("").to_string(), - ports: first_svc["ports"].as_array() - .map(|arr| arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect()) + name: first_svc["name"].as_str().unwrap_or(slug).to_string(), + image: first_svc["image"].as_str().unwrap_or("").to_string(), + ports: first_svc["ports"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) .unwrap_or_default(), - environment: first_svc["environment"].as_object() - .map(|obj| obj.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect()) + environment: first_svc["environment"] + .as_object() + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| { + v.as_str().map(|s| (k.clone(), s.to_string())) + }) + .collect() + }) .unwrap_or_default(), - volumes: first_svc["volumes"].as_array() - .map(|arr| arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect()) + volumes: first_svc["volumes"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) .unwrap_or_default(), depends_on: Vec::new(), }; @@ -110,7 +120,9 @@ impl ServiceCatalog { return Ok(Some(CatalogEntry { code: slug.to_string(), name: template.name, - category: template.category_code.unwrap_or_else(|| "service".to_string()), + category: template + .category_code + .unwrap_or_else(|| "service".to_string()), description: template.description.unwrap_or_default(), service, related: vec![], @@ -243,7 +255,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── Cache ──────────────────────────────────────── CatalogEntry { code: "redis".into(), @@ -275,7 +286,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── Message Queues ─────────────────────────────── CatalogEntry { code: "rabbitmq".into(), @@ -295,7 +305,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── Proxies ────────────────────────────────────── CatalogEntry { code: "traefik".into(), @@ -348,7 +357,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── Web Applications ───────────────────────────── CatalogEntry { code: "wordpress".into(), @@ -370,7 +378,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec!["mysql".into(), "redis".into(), "traefik".into()], }, - // ── Search ─────────────────────────────────────── CatalogEntry { code: "elasticsearch".into(), @@ -400,15 +407,15 @@ fn build_hardcoded_catalog() -> Vec { name: "kibana".into(), image: "kibana:8.12.0".into(), ports: vec!["5601:5601".into()], - environment: HashMap::from([ - ("ELASTICSEARCH_HOSTS".into(), "http://elasticsearch:9200".into()), - ]), + environment: HashMap::from([( + "ELASTICSEARCH_HOSTS".into(), + "http://elasticsearch:9200".into(), + )]), volumes: vec![], depends_on: vec!["elasticsearch".into()], }, related: vec!["elasticsearch".into()], }, - // ── Vector Databases ───────────────────────────── CatalogEntry { code: "qdrant".into(), @@ -425,7 +432,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── Monitoring ─────────────────────────────────── CatalogEntry { code: "telegraf".into(), @@ -437,14 +443,11 @@ fn build_hardcoded_catalog() -> Vec { image: "telegraf:1.30-alpine".into(), ports: vec![], environment: HashMap::new(), - volumes: vec![ - "/var/run/docker.sock:/var/run/docker.sock:ro".into(), - ], + volumes: vec!["/var/run/docker.sock:/var/run/docker.sock:ro".into()], depends_on: vec![], }, related: vec![], }, - // ── Dev Tools ──────────────────────────────────── CatalogEntry { code: "phpmyadmin".into(), @@ -479,7 +482,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── Storage ────────────────────────────────────── CatalogEntry { code: "minio".into(), @@ -499,7 +501,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── Container Management ───────────────────────── CatalogEntry { code: "portainer".into(), @@ -519,7 +520,6 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, - // ── AI Assistants ───────────────────────────── CatalogEntry { code: "openclaw".into(), @@ -530,9 +530,7 @@ fn build_hardcoded_catalog() -> Vec { name: "openclaw".into(), image: "ghcr.io/openclaw/openclaw:latest".into(), ports: vec!["18789:18789".into()], - environment: HashMap::from([ - ("OPENCLAW_GATEWAY_BIND".into(), "lan".into()), - ]), + environment: HashMap::from([("OPENCLAW_GATEWAY_BIND".into(), "lan".into())]), volumes: vec![ "openclaw_config:/home/node/.openclaw".into(), "openclaw_workspace:/home/node/.openclaw/workspace".into(), @@ -588,13 +586,19 @@ mod tests { #[test] fn test_resolve_alias_hyphen_to_underscore() { - assert_eq!(ServiceCatalog::resolve_alias("nginx-proxy-manager"), "nginx_proxy_manager"); + assert_eq!( + ServiceCatalog::resolve_alias("nginx-proxy-manager"), + "nginx_proxy_manager" + ); } #[test] fn test_hardcoded_catalog_not_empty() { let catalog = build_hardcoded_catalog(); - assert!(catalog.len() > 10, "Expected at least 10 services in catalog"); + assert!( + catalog.len() > 10, + "Expected at least 10 services in catalog" + ); } #[test] diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index bad88d55..437699e1 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -234,12 +234,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.list.unwrap_or_default()) } @@ -288,12 +287,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.list.unwrap_or_default()) } @@ -360,19 +358,15 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Stacker server POST /project failed ({}): {}", - status, body - ), + reason: format!("Stacker server POST /project failed ({}): {}", status, body), }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -412,12 +406,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -450,12 +443,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.list.unwrap_or_default()) } @@ -467,14 +459,13 @@ impl StackerClient { ) -> Result, CliError> { let clouds = self.list_clouds().await?; let lower = provider.to_lowercase(); - Ok(clouds.into_iter().find(|c| c.provider.to_lowercase() == lower)) + Ok(clouds + .into_iter() + .find(|c| c.provider.to_lowercase() == lower)) } /// Find saved cloud credentials by name (e.g. "my-hetzner", "htz-4"). - pub async fn find_cloud_by_name( - &self, - name: &str, - ) -> Result, CliError> { + pub async fn find_cloud_by_name(&self, name: &str) -> Result, CliError> { let clouds = self.list_clouds().await?; let lower = name.to_lowercase(); Ok(clouds.into_iter().find(|c| c.name.to_lowercase() == lower)) @@ -510,12 +501,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.item) } @@ -528,7 +518,8 @@ impl StackerClient { cloud_key: Option<&str>, cloud_secret: Option<&str>, ) -> Result { - self.save_cloud_with_name(provider, None, cloud_token, cloud_key, cloud_secret).await + self.save_cloud_with_name(provider, None, cloud_token, cloud_key, cloud_secret) + .await } /// Save cloud credentials with an optional name. @@ -549,10 +540,7 @@ impl StackerClient { if let Some(obj) = payload.as_object_mut() { if let Some(n) = name { - obj.insert( - "name".to_string(), - serde_json::Value::String(n.to_string()), - ); + obj.insert("name".to_string(), serde_json::Value::String(n.to_string())); } if let Some(t) = cloud_token { obj.insert( @@ -591,19 +579,15 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Stacker server POST /cloud failed ({}): {}", - status, body - ), + reason: format!("Stacker server POST /cloud failed ({}): {}", status, body), }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -636,12 +620,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.list.unwrap_or_default()) } @@ -686,12 +669,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -725,12 +707,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -775,12 +756,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -828,12 +808,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.list.unwrap_or_default()) } @@ -864,19 +843,15 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Marketplace template fetch failed ({}): {}", - status, body - ), + reason: format!("Marketplace template fetch failed ({}): {}", status, body), }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.item) } @@ -912,19 +887,16 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Stacker server deploy failed ({}): {}", - status, body - ), + reason: format!("Stacker server deploy failed ({}): {}", status, body), }); } - resp.json::().await.map_err(|e| { - CliError::DeployFailed { + resp.json::() + .await + .map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid deploy response from Stacker server: {}", e), - } - }) + }) } // ── Deployment status ──────────────────────────── @@ -1026,10 +998,7 @@ impl StackerClient { &self, hash: &str, ) -> Result, CliError> { - let url = format!( - "{}/api/v1/deployments/hash/{}", - self.base_url, hash - ); + let url = format!("{}/api/v1/deployments/hash/{}", self.base_url, hash); let resp = self .http .get(&url) @@ -1098,10 +1067,7 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "Force-complete failed ({}): {}", - status, body - ), + reason: format!("Force-complete failed ({}): {}", status, body), }); } @@ -1149,10 +1115,12 @@ impl StackerClient { } let api: ApiResponse = - resp.json().await.map_err(|e| CliError::AgentCommandFailed { - command_id: String::new(), - error: format!("Invalid enqueue response: {}", e), - })?; + resp.json() + .await + .map_err(|e| CliError::AgentCommandFailed { + command_id: String::new(), + error: format!("Invalid enqueue response: {}", e), + })?; api.item.ok_or_else(|| CliError::AgentCommandFailed { command_id: String::new(), @@ -1200,10 +1168,12 @@ impl StackerClient { } let api: ApiResponse = - resp.json().await.map_err(|e| CliError::AgentCommandFailed { - command_id: command_id.to_string(), - error: format!("Invalid status response: {}", e), - })?; + resp.json() + .await + .map_err(|e| CliError::AgentCommandFailed { + command_id: command_id.to_string(), + error: format!("Invalid status response: {}", e), + })?; api.item.ok_or_else(|| CliError::AgentCommandFailed { command_id: command_id.to_string(), @@ -1225,8 +1195,7 @@ impl StackerClient { let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); let interval = std::time::Duration::from_secs(poll_interval_secs); let mut last_status = "pending".to_string(); @@ -1324,14 +1293,20 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("GET /api/v1/agent/project/{} failed ({}): {}", project_id, status, body), + reason: format!( + "GET /api/v1/agent/project/{} failed ({}): {}", + project_id, status, body + ), }); } - let json: serde_json::Value = resp.json().await.map_err(|e| CliError::AgentCommandFailed { - command_id: String::new(), - error: format!("Invalid project snapshot response: {}", e), - })?; + let json: serde_json::Value = + resp.json() + .await + .map_err(|e| CliError::AgentCommandFailed { + command_id: String::new(), + error: format!("Invalid project snapshot response: {}", e), + })?; // Extract deployment_hash from the nested agent object let hash = json @@ -1341,11 +1316,13 @@ impl StackerClient { .and_then(|a| a.get("deployment_hash")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .ok_or_else(|| CliError::ConfigValidation( - "No active agent found for this project. \ + .ok_or_else(|| { + CliError::ConfigValidation( + "No active agent found for this project. \ The agent may be offline or not yet deployed." - .to_string(), - ))?; + .to_string(), + ) + })?; Ok((json, hash)) } @@ -1375,12 +1352,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; Ok(api.list.unwrap_or_default()) } @@ -1414,12 +1390,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - } - })?; + })?; let item = api.item.unwrap_or(serde_json::json!({})); let reviews: Vec = serde_json::from_value( @@ -1459,12 +1434,11 @@ impl StackerClient { }); } - let api: ApiResponse = resp.json().await.map_err(|e| { - CliError::DeployFailed { + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("create template response: {}", e), - } - })?; + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -1649,11 +1623,7 @@ fn parse_volume_mapping(vol_str: &str) -> (String, String, bool) { let parts: Vec<&str> = vol_str.split(':').collect(); match parts.len() { // "source:target:mode" (e.g. "/host:/container:ro") - 3 => ( - parts[0].to_string(), - parts[1].to_string(), - parts[2] == "ro", - ), + 3 => (parts[0].to_string(), parts[1].to_string(), parts[2] == "ro"), // "source:target" 2 => (parts[0].to_string(), parts[1].to_string(), false), // bare path @@ -1950,7 +1920,13 @@ fn generate_server_name(project_name: &str) -> String { let sanitised: String = project_name .to_lowercase() .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) .collect::() .split('-') .filter(|s| !s.is_empty()) @@ -1987,10 +1963,16 @@ fn generate_server_name(project_name: &str) -> String { pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { let cloud = config.deploy.cloud.as_ref(); let provider = cloud - .map(|c| super::install_runner::provider_code_for_remote(&c.provider.to_string()).to_string()) + .map(|c| { + super::install_runner::provider_code_for_remote(&c.provider.to_string()).to_string() + }) .unwrap_or_else(|| "htz".to_string()); - let region = cloud.and_then(|c| c.region.clone()).unwrap_or_else(|| "nbg1".to_string()); - let server_size = cloud.and_then(|c| c.size.clone()).unwrap_or_else(|| "cpx11".to_string()); + let region = cloud + .and_then(|c| c.region.clone()) + .unwrap_or_else(|| "nbg1".to_string()); + let server_size = cloud + .and_then(|c| c.size.clone()) + .unwrap_or_else(|| "cpx11".to_string()); let os = match provider.as_str() { "do" => "docker-20-04", _ => "ubuntu-22.04", @@ -1998,7 +1980,11 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { // Auto-generate a server name from the project name so every // provisioned server gets a recognisable label in `stacker list servers`. - let project_name = config.project.identity.clone().unwrap_or_else(|| config.name.clone()); + let project_name = config + .project + .identity + .clone() + .unwrap_or_else(|| config.name.clone()); let server_name = generate_server_name(&project_name); let mut form = serde_json::json!({ @@ -2043,7 +2029,7 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { let features = stack_obj .entry("extended_features") - .or_insert_with(|| serde_json::json!([])); + .or_insert_with(|| serde_json::json!([])); if let Some(arr) = features.as_array_mut() { let npm = serde_json::Value::String("nginx_proxy_manager".to_string()); if !arr.contains(&npm) { @@ -2062,8 +2048,8 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { // status panel agent with the public Vault address (not the local Docker IP). if config.monitoring.status_panel { // Resolve public Vault URL: env override → default constant. - let vault_url = std::env::var("STACKER_VAULT_URL") - .unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); + let vault_url = + std::env::var("STACKER_VAULT_URL").unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { let features = stack_obj @@ -2133,8 +2119,16 @@ mod tests { assert_eq!(form["stack"]["stack_code"], "myproject"); // Auto-generated server name should start with the project name let name = form["server"]["name"].as_str().unwrap(); - assert!(name.starts_with("myproject-"), "server name should start with project name, got: {}", name); - assert_eq!(name.len(), "myproject-".len() + 4, "suffix should be 4 hex chars"); + assert!( + name.starts_with("myproject-"), + "server name should start with project name, got: {}", + name + ); + assert_eq!( + name.len(), + "myproject-".len() + 4, + "suffix should be 4 hex chars" + ); } #[test] @@ -2335,7 +2329,10 @@ mod tests { let body = build_project_body(&config); let features = body["custom"]["feature"].as_array().unwrap(); - assert!(features.is_empty(), "feature array should be empty when no proxy configured"); + assert!( + features.is_empty(), + "feature array should be empty when no proxy configured" + ); } #[test] @@ -2345,7 +2342,11 @@ mod tests { // 4 hex chars suffix let suffix = &name["website-".len()..]; assert_eq!(suffix.len(), 4); - assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "suffix should be hex, got: {}", suffix); + assert!( + suffix.chars().all(|c| c.is_ascii_hexdigit()), + "suffix should be hex, got: {}", + suffix + ); } #[test] @@ -2357,29 +2358,50 @@ mod tests { #[test] fn test_generate_server_name_empty() { let name = generate_server_name(""); - assert!(name.starts_with("srv-"), "empty input should fallback to 'srv', got: {}", name); + assert!( + name.starts_with("srv-"), + "empty input should fallback to 'srv', got: {}", + name + ); } #[test] fn test_generate_server_name_special_chars() { let name = generate_server_name("app___v2..beta"); - assert!(name.starts_with("app-v2-beta-"), "consecutive separators collapsed, got: {}", name); + assert!( + name.starts_with("app-v2-beta-"), + "consecutive separators collapsed, got: {}", + name + ); } #[test] fn test_generate_server_name_numeric_start() { // Hetzner requires name to start with a letter let name = generate_server_name("123app"); - assert!(name.starts_with("srv-123app-"), "numeric start should get 'srv-' prefix, got: {}", name); + assert!( + name.starts_with("srv-123app-"), + "numeric start should get 'srv-' prefix, got: {}", + name + ); } #[test] fn test_generate_server_name_max_length() { let long = "a".repeat(100); let name = generate_server_name(&long); - assert!(name.len() <= 63, "name must be ≤63 chars (Hetzner), got {} chars: {}", name.len(), name); + assert!( + name.len() <= 63, + "name must be ≤63 chars (Hetzner), got {} chars: {}", + name.len(), + name + ); assert!(name.starts_with("aaa"), "got: {}", name); // Must not end with hyphen - assert!(!name.ends_with('-'), "must not end with hyphen, got: {}", name); + assert!( + !name.ends_with('-'), + "must not end with hyphen, got: {}", + name + ); } } diff --git a/src/connectors/user_service/app.rs b/src/connectors/user_service/app.rs index ae83ed51..fb8be88c 100644 --- a/src/connectors/user_service/app.rs +++ b/src/connectors/user_service/app.rs @@ -137,7 +137,9 @@ impl UserServiceClient { let body = response.text().await.unwrap_or_default(); tracing::warn!( "Catalog endpoint error ({}) for code={}: {}, falling back to search_applications", - status, code, body + status, + code, + body ); return self.fallback_search_by_code(bearer_token, code).await; } diff --git a/src/connectors/user_service/install.rs b/src/connectors/user_service/install.rs index 588ba80b..52a25aa7 100644 --- a/src/connectors/user_service/install.rs +++ b/src/connectors/user_service/install.rs @@ -89,7 +89,10 @@ impl UserServiceClient { bearer_token: &str, installation_id: i64, ) -> Result { - let url = format!("{}/api/1.0/installations/{}", self.base_url, installation_id); + let url = format!( + "{}/api/1.0/installations/{}", + self.base_url, installation_id + ); let response = self .http_client diff --git a/src/connectors/user_service/mod.rs b/src/connectors/user_service/mod.rs index f86f0a2e..03376358 100644 --- a/src/connectors/user_service/mod.rs +++ b/src/connectors/user_service/mod.rs @@ -6,8 +6,8 @@ pub mod deployment_resolver; pub mod deployment_validator; pub mod init; pub mod install; -pub mod marketplace_webhook; pub mod marketplace_search; +pub mod marketplace_webhook; pub mod mock; pub mod notifications; pub mod plan; diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index be531682..b7e3db5b 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -52,7 +52,8 @@ fn resolve_deployment_hash( if config_path.exists() { if let Ok(config) = crate::cli::config_parser::StackerConfig::from_file(&config_path) { if let Some(ref project_name) = config.project.identity { - if let Ok(Some(proj)) = ctx.block_on(ctx.client.find_project_by_name(project_name)) { + if let Ok(Some(proj)) = ctx.block_on(ctx.client.find_project_by_name(project_name)) + { match ctx.block_on(ctx.client.agent_snapshot_by_project(proj.id)) { Ok((_, hash)) => { eprintln!( @@ -109,8 +110,7 @@ fn run_agent_command( let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); let interval = std::time::Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS); let mut last_status = "pending".to_string(); @@ -132,10 +132,7 @@ fn run_agent_command( .await?; last_status = status.status.clone(); - progress::update_message( - &pb, - &format!("{} [{}]", spinner_msg, status.status), - ); + progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); match status.status.as_str() { "completed" | "failed" => return Ok(status), @@ -175,7 +172,11 @@ fn print_command_result(info: &AgentCommandInfo, json: bool) { println!("Command: {}", info.command_id); println!("Type: {}", info.command_type); - println!("Status: {} {}", progress::status_icon(&info.status), info.status); + println!( + "Status: {} {}", + progress::status_icon(&info.status), + info.status + ); if let Some(ref result) = info.result { println!("\n{}", fmt::pretty_json(result)); @@ -194,11 +195,7 @@ fn print_command_result(info: &AgentCommandInfo, json: bool) { /// /// Returns `Ok(())` when it's safe to proceed, or a `CliError` when the user /// aborts or the prompt cannot be answered. -fn check_active_connections( - ctx: &CliRuntime, - hash: &str, - force: bool, -) -> Result<(), CliError> { +fn check_active_connections(ctx: &CliRuntime, hash: &str, force: bool) -> Result<(), CliError> { let params = crate::forms::status_panel::CheckConnectionsCommandRequest { ports: None }; let request = AgentEnqueueRequest::new(hash, "check_connections") .with_parameters(¶ms) @@ -208,9 +205,7 @@ fn check_active_connections( Ok(info) => info, Err(_) => { // Non-fatal: if the check times out or fails we warn but proceed. - eprintln!( - "\x1b[33m⚠ Connection check skipped (agent did not respond in time)\x1b[0m" - ); + eprintln!("\x1b[33m⚠ Connection check skipped (agent did not respond in time)\x1b[0m"); return Ok(()); } }; @@ -234,11 +229,17 @@ fn check_active_connections( } // Print a per-port table. - eprintln!("\n\x1b[33m⚠ {} active HTTP connection(s) detected:\x1b[0m", active); + eprintln!( + "\n\x1b[33m⚠ {} active HTTP connection(s) detected:\x1b[0m", + active + ); if let Some(ports) = result.get("ports").and_then(|v| v.as_array()) { for entry in ports { let port = entry.get("port").and_then(|v| v.as_u64()).unwrap_or(0); - let conns = entry.get("connections").and_then(|v| v.as_u64()).unwrap_or(0); + let conns = entry + .get("connections") + .and_then(|v| v.as_u64()) + .unwrap_or(0); if conns > 0 { eprintln!(" port {:5} — {} connection(s)", port, conns); } @@ -288,7 +289,12 @@ impl AgentHealthCommand { deployment: Option, include_system: bool, ) -> Self { - Self { app_code, json, deployment, include_system } + Self { + app_code, + json, + deployment, + include_system, + } } } @@ -331,7 +337,12 @@ impl AgentLogsCommand { json: bool, deployment: Option, ) -> Self { - Self { app_code, limit, json, deployment } + Self { + app_code, + limit, + json, + deployment, + } } } @@ -376,13 +387,13 @@ pub struct AgentRestartCommand { } impl AgentRestartCommand { - pub fn new( - app_code: String, - force: bool, - json: bool, - deployment: Option, - ) -> Self { - Self { app_code, force, json, deployment } + pub fn new(app_code: String, force: bool, json: bool, deployment: Option) -> Self { + Self { + app_code, + force, + json, + deployment, + } } } @@ -433,7 +444,13 @@ impl AgentDeployAppCommand { json: bool, deployment: Option, ) -> Self { - Self { app_code, image, force_recreate, json, deployment } + Self { + app_code, + image, + force_recreate, + json, + deployment, + } } } @@ -458,12 +475,7 @@ impl CallableTrait for AgentDeployAppCommand { .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))? .with_timeout(300); - let info = run_agent_command( - &ctx, - &request, - &format!("Deploying {}", self.app_code), - 300, - )?; + let info = run_agent_command(&ctx, &request, &format!("Deploying {}", self.app_code), 300)?; print_command_result(&info, self.json); Ok(()) } @@ -490,7 +502,14 @@ impl AgentRemoveAppCommand { json: bool, deployment: Option, ) -> Self { - Self { app_code, remove_volumes, remove_image, force, json, deployment } + Self { + app_code, + remove_volumes, + remove_image, + force, + json, + deployment, + } } } @@ -548,7 +567,16 @@ impl AgentConfigureFirewallCommand { json: bool, deployment: Option, ) -> Self { - Self { action, app_code, public_ports, private_ports, persist, force, json, deployment } + Self { + action, + app_code, + public_ports, + private_ports, + persist, + force, + json, + deployment, + } } /// Parse "80/tcp" or "443" into a FirewallPortRule (source defaults to 0.0.0.0/0). @@ -584,10 +612,14 @@ impl AgentConfigureFirewallCommand { fn parse_port_proto(s: &str) -> Result<(u16, String), String> { if let Some((port_s, proto)) = s.split_once('/') { - let port: u16 = port_s.parse().map_err(|_| format!("Invalid port number: {}", port_s))?; + let port: u16 = port_s + .parse() + .map_err(|_| format!("Invalid port number: {}", port_s))?; Ok((port, proto.to_string())) } else { - let port: u16 = s.parse().map_err(|_| format!("Invalid port number: {}", s))?; + let port: u16 = s + .parse() + .map_err(|_| format!("Invalid port number: {}", s))?; Ok((port, "tcp".to_string())) } } @@ -662,7 +694,16 @@ impl AgentConfigureProxyCommand { json: bool, deployment: Option, ) -> Self { - Self { app_code, domain, port, ssl, action, force, json, deployment } + Self { + app_code, + domain, + port, + ssl, + action, + force, + json, + deployment, + } } } @@ -767,7 +808,10 @@ impl CallableTrait for AgentStatusCommand { let mut output = item.clone(); if let Some(list) = &live_containers { if let Some(obj) = output.as_object_mut() { - obj.insert("containers_live".to_string(), serde_json::Value::Array(list.clone())); + obj.insert( + "containers_live".to_string(), + serde_json::Value::Array(list.clone()), + ); } else { output = serde_json::json!({ "snapshot": output, @@ -854,10 +898,7 @@ fn print_snapshot_summary( .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - let version = agent - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("-"); + let version = agent.get("version").and_then(|v| v.as_str()).unwrap_or("-"); let heartbeat = agent .get("last_heartbeat") .and_then(|v| v.as_str()) @@ -939,7 +980,11 @@ impl CallableTrait for AgentListAppsCommand { let snapshot = ctx.block_on(ctx.client.agent_snapshot(&hash))?; let item = snapshot_item(&snapshot); - let apps = item.get("apps").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let apps = item + .get("apps") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); if self.json { let value = serde_json::Value::Array(apps); @@ -968,8 +1013,7 @@ impl CallableTrait for AgentListContainersCommand { let ctx = CliRuntime::new("agent list containers")?; let hash = resolve_deployment_hash(&self.deployment, &ctx)?; - let containers = fetch_live_containers(&ctx, &hash)? - .unwrap_or_default(); + let containers = fetch_live_containers(&ctx, &hash)?.unwrap_or_default(); if self.json { let value = serde_json::Value::Array(containers); @@ -1055,7 +1099,13 @@ impl AgentExecCommand { json: bool, deployment: Option, ) -> Self { - Self { command_type, params, timeout, json, deployment } + Self { + command_type, + params, + timeout, + json, + deployment, + } } } @@ -1204,11 +1254,13 @@ impl CallableTrait for AgentInstallCommand { .client .find_project_by_name(&project_name) .await? - .ok_or_else(|| CliError::ConfigValidation(format!( - "Project '{}' not found on the Stacker server.\n\ + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Project '{}' not found on the Stacker server.\n\ Deploy the project first with: stacker deploy --target cloud", - project_name - )))?; + project_name + )) + })?; // 2. Find the server for this project progress::update_message(&pb, "Finding server..."); @@ -1216,17 +1268,21 @@ impl CallableTrait for AgentInstallCommand { let server = servers .into_iter() .find(|s| s.project_id == project.id) - .ok_or_else(|| CliError::ConfigValidation(format!( - "No server found for project '{}' (id={}).\n\ + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "No server found for project '{}' (id={}).\n\ Deploy the project first with: stacker deploy --target cloud", - project_name, project.id - )))?; + project_name, project.id + )) + })?; - let cloud_id = server.cloud_id.ok_or_else(|| CliError::ConfigValidation( - "Server has no associated cloud credentials.\n\ + let cloud_id = server.cloud_id.ok_or_else(|| { + CliError::ConfigValidation( + "Server has no associated cloud credentials.\n\ Cannot install Status Panel without cloud credentials." - .to_string(), - ))?; + .to_string(), + ) + })?; // 3. Build a minimal deploy form with only the statuspanel feature progress::update_message(&pb, "Preparing deploy payload..."); @@ -1264,7 +1320,10 @@ impl CallableTrait for AgentInstallCommand { // 4. Trigger the deploy progress::update_message(&pb, "Deploying Status Panel..."); - let resp = ctx.client.deploy(project.id, Some(cloud_id), deploy_form).await?; + let resp = ctx + .client + .deploy(project.id, Some(cloud_id), deploy_form) + .await?; Ok(resp) }); @@ -1273,7 +1332,10 @@ impl CallableTrait for AgentInstallCommand { progress::finish_success(&pb, "Status Panel agent installation triggered"); if self.json { - println!("{}", serde_json::to_string_pretty(&resp).unwrap_or_default()); + println!( + "{}", + serde_json::to_string_pretty(&resp).unwrap_or_default() + ); } else { println!("Status Panel deploy queued for project '{}'", project_name); if let Some(id) = resp.id { diff --git a/src/console/commands/cli/ai.rs b/src/console/commands/cli/ai.rs index 7081d4a6..8d01311f 100644 --- a/src/console/commands/cli/ai.rs +++ b/src/console/commands/cli/ai.rs @@ -1,13 +1,12 @@ -use std::path::{Path, PathBuf}; use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use crate::cli::ai_client::{ - AiProvider, AiResponse, ChatMessage, ToolCall, ToolDef, - all_write_mode_tools, create_provider, + all_write_mode_tools, create_provider, AiProvider, AiResponse, ChatMessage, ToolCall, ToolDef, }; use crate::cli::config_parser::{AiConfig, AiProviderType, StackerConfig}; use crate::cli::error::CliError; -use crate::cli::service_catalog::{ServiceCatalog, catalog_summary_for_ai}; +use crate::cli::service_catalog::{catalog_summary_for_ai, ServiceCatalog}; use crate::console::commands::CallableTrait; const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; @@ -213,7 +212,10 @@ fn configure_ai_interactive(config_path: &str) -> Result { Some(api_key_input) }; - let endpoint_default = current.endpoint.as_deref().unwrap_or("http://localhost:11434"); + let endpoint_default = current + .endpoint + .as_deref() + .unwrap_or("http://localhost:11434"); let endpoint_input = prompt_with_default("Endpoint", endpoint_default)?; let endpoint = if endpoint_input.trim().is_empty() { None @@ -356,8 +358,12 @@ fn try_extract_tool_calls_from_text(text: &str) -> Vec { // Try full string first, then scan for the first '{' / '[' let candidates: Vec<&str> = { let mut v = vec![stripped.as_str()]; - if let Some(idx) = stripped.find('{') { v.push(&stripped[idx..]); } - if let Some(idx) = stripped.find('[') { v.push(&stripped[idx..]); } + if let Some(idx) = stripped.find('{') { + v.push(&stripped[idx..]); + } + if let Some(idx) = stripped.find('[') { + v.push(&stripped[idx..]); + } v }; @@ -376,17 +382,17 @@ fn try_extract_tool_calls_from_text(text: &str) -> Vec { fn parse_tool_calls_from_json(json: &serde_json::Value) -> Vec { // Array of calls if let Some(arr) = json.as_array() { - let calls: Vec = arr.iter() + let calls: Vec = arr + .iter() .flat_map(|v| parse_tool_calls_from_json(v)) .collect(); - if !calls.is_empty() { return calls; } + if !calls.is_empty() { + return calls; + } } // {"name": ..., "arguments": {...}} - if let (Some(name), Some(args)) = ( - json["name"].as_str(), - json.get("arguments"), - ) { + if let (Some(name), Some(args)) = (json["name"].as_str(), json.get("arguments")) { let arguments = if args.is_object() { args.clone() } else if let Some(s) = args.as_str() { @@ -394,18 +400,23 @@ fn parse_tool_calls_from_json(json: &serde_json::Value) -> Vec { } else { serde_json::json!({}) }; - return vec![ToolCall { id: None, name: name.to_string(), arguments }]; + return vec![ToolCall { + id: None, + name: name.to_string(), + arguments, + }]; } // {"tool": ..., "parameters": {...}} - if let (Some(name), Some(args)) = ( - json["tool"].as_str(), - json.get("parameters"), - ) { + if let (Some(name), Some(args)) = (json["tool"].as_str(), json.get("parameters")) { return vec![ToolCall { id: None, name: name.to_string(), - arguments: if args.is_object() { args.clone() } else { serde_json::json!({}) }, + arguments: if args.is_object() { + args.clone() + } else { + serde_json::json!({}) + }, }]; } @@ -429,7 +440,7 @@ fn is_write_allowed(path_str: &str) -> bool { .trim_start_matches('/') .trim_start_matches('\\'); // Reject any path that tries to escape with "../" - if p.contains("../") || p.contains("..\\" ) || p == ".." { + if p.contains("../") || p.contains("..\\") || p == ".." { return false; } p == "stacker.yml" || p.starts_with(".stacker/") || p.starts_with(".stacker\\") @@ -443,16 +454,17 @@ fn run_subprocess(args: &[&str]) -> String { Err(e) => return format!("Error: could not resolve binary path: {}", e), }; - match std::process::Command::new(&exe) - .args(args) - .output() - { + match std::process::Command::new(&exe).args(args).output() { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr).trim().to_string(); if out.status.success() { - if combined.is_empty() { "OK (no output)".to_string() } else { combined } + if combined.is_empty() { + "OK (no output)".to_string() + } else { + combined + } } else { format!("Exit {}: {}", out.status.code().unwrap_or(-1), combined) } @@ -571,7 +583,11 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { // ── agent CLI tools ──────────────────────────────────────────────── "agent_health" => { - let mut args: Vec = vec!["agent".to_string(), "health".to_string(), "--json".to_string()]; + let mut args: Vec = vec![ + "agent".to_string(), + "health".to_string(), + "--json".to_string(), + ]; if let Some(app) = call.arguments["app"].as_str() { args.push("--app".to_string()); args.push(app.to_string()); @@ -584,7 +600,11 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) } "agent_status" => { - let mut args: Vec = vec!["agent".to_string(), "status".to_string(), "--json".to_string()]; + let mut args: Vec = vec![ + "agent".to_string(), + "status".to_string(), + "--json".to_string(), + ]; if let Some(dep) = call.arguments["deployment"].as_str() { args.push("--deployment".to_string()); args.push(dep.to_string()); @@ -597,7 +617,12 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { Some(a) => a, None => return "Error: missing 'app' argument".to_string(), }; - let mut args: Vec = vec!["agent".to_string(), "logs".to_string(), app.to_string(), "--json".to_string()]; + let mut args: Vec = vec![ + "agent".to_string(), + "logs".to_string(), + app.to_string(), + "--json".to_string(), + ]; if let Some(limit) = call.arguments["limit"].as_u64() { args.push("--limit".to_string()); args.push(limit.to_string()); @@ -624,7 +649,11 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { if call.arguments["force_rebuild"].as_bool().unwrap_or(false) { args.push("--force-rebuild".to_string()); } - let label = if dry_run { "stacker deploy --dry-run" } else { "stacker deploy" }; + let label = if dry_run { + "stacker deploy --dry-run" + } else { + "stacker deploy" + }; eprintln!(" ⚙ running: {}", label); run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) } @@ -633,7 +662,8 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { Some(d) => d, None => return "Error: missing 'domain' argument".to_string(), }; - let mut args: Vec = vec!["proxy".to_string(), "add".to_string(), domain.to_string()]; + let mut args: Vec = + vec!["proxy".to_string(), "add".to_string(), domain.to_string()]; if let Some(upstream) = call.arguments["upstream"].as_str() { args.push("--upstream".to_string()); args.push(upstream.to_string()); @@ -816,7 +846,10 @@ fn run_chat_turn( if iteration + 1 == MAX_TOOL_ITERATIONS { return Err(CliError::AiProviderError { provider: provider.name().to_string(), - message: format!("Reached maximum tool iterations ({})", MAX_TOOL_ITERATIONS), + message: format!( + "Reached maximum tool iterations ({})", + MAX_TOOL_ITERATIONS + ), }); } continue; @@ -846,7 +879,10 @@ fn run_chat_turn( if iteration + 1 == MAX_TOOL_ITERATIONS { return Err(CliError::AiProviderError { provider: provider.name().to_string(), - message: format!("Reached maximum tool iterations ({})", MAX_TOOL_ITERATIONS), + message: format!( + "Reached maximum tool iterations ({})", + MAX_TOOL_ITERATIONS + ), }); } } @@ -910,7 +946,11 @@ pub fn run_ai_ask_agentic( // strip the JSON before showing narration to user let narration = text .lines() - .filter(|l| !l.trim().starts_with('{') && !l.trim().starts_with('[') && !l.trim().starts_with('`')) + .filter(|l| { + !l.trim().starts_with('{') + && !l.trim().starts_with('[') + && !l.trim().starts_with('`') + }) .collect::>() .join("\n"); if !narration.trim().is_empty() { @@ -930,7 +970,10 @@ pub fn run_ai_ask_agentic( if iteration + 1 == MAX_TOOL_ITERATIONS { return Err(CliError::AiProviderError { provider: provider.name().to_string(), - message: format!("Reached maximum tool iterations ({})", MAX_TOOL_ITERATIONS), + message: format!( + "Reached maximum tool iterations ({})", + MAX_TOOL_ITERATIONS + ), }); } continue; @@ -952,10 +995,7 @@ pub fn run_ai_ask_agentic( // Execute each tool and append results for call in &calls { let result = execute_tool(call, &cwd); - messages.push(ChatMessage::tool_result( - call.id.clone(), - result, - )); + messages.push(ChatMessage::tool_result(call.id.clone(), result)); } if iteration + 1 == MAX_TOOL_ITERATIONS { @@ -1039,11 +1079,7 @@ impl CallableTrait for AiAskCommand { println!("{}", response); } } else { - let response = run_ai_ask( - &self.question, - self.context.as_deref(), - provider.as_ref(), - )?; + let response = run_ai_ask(&self.question, self.context.as_deref(), provider.as_ref())?; println!("{}", response); } Ok(()) @@ -1097,7 +1133,11 @@ impl CallableTrait for AiChatCommand { "Stacker AI ({provider} · {model}){tools}", provider = provider_name, model = model_name, - tools = if write_active { " [write mode — .stacker/ + stacker.yml]" } else { "" } + tools = if write_active { + " [write mode — .stacker/ + stacker.yml]" + } else { + "" + } ); eprintln!("Type your question and press Enter. `help` for tips, `exit` to quit."); eprintln!(); @@ -1111,10 +1151,7 @@ impl CallableTrait for AiChatCommand { "{}\n\n{}\n\n## Current project files\n{}", STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx, ctx ), - None => format!( - "{}\n\n{}", - STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx - ), + None => format!("{}\n\n{}", STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx), }; let mut messages: Vec = vec![ChatMessage::system(&system)]; @@ -1180,12 +1217,16 @@ mod tests { impl MockProvider { fn new(response: &str) -> Self { - Self { response: response.to_string() } + Self { + response: response.to_string(), + } } } impl AiProvider for MockProvider { - fn name(&self) -> &str { "mock" } + fn name(&self) -> &str { + "mock" + } fn complete(&self, _prompt: &str, _context: &str) -> Result { Ok(self.response.clone()) } @@ -1229,11 +1270,8 @@ mod tests { std::fs::write(&ctx_path, "FROM rust:1.75\nCOPY . .").unwrap(); let provider = MockProvider::new("Looks good!"); - let result = run_ai_ask( - "Review this", - Some(ctx_path.to_str().unwrap()), - &provider, - ).unwrap(); + let result = + run_ai_ask("Review this", Some(ctx_path.to_str().unwrap()), &provider).unwrap(); assert_eq!(result, "Looks good!"); } diff --git a/src/console/commands/cli/ci.rs b/src/console/commands/cli/ci.rs index aff8d14f..028e3fa2 100644 --- a/src/console/commands/cli/ci.rs +++ b/src/console/commands/cli/ci.rs @@ -96,7 +96,10 @@ impl CallableTrait for CiExportCommand { println!(" 1. Add STACKER_TOKEN to your GitHub repository secrets"); println!(" (Settings → Secrets and variables → Actions)"); println!(" 2. Commit and push the workflow file:"); - println!(" git add {} && git commit -m 'ci: add stacker deploy workflow'", output_path.display()); + println!( + " git add {} && git commit -m 'ci: add stacker deploy workflow'", + output_path.display() + ); } "gitlab" | "gitlab-ci" => { println!(); @@ -104,7 +107,10 @@ impl CallableTrait for CiExportCommand { println!(" 1. Add STACKER_TOKEN to your GitLab CI/CD variables"); println!(" (Settings → CI/CD → Variables)"); println!(" 2. Commit and push the pipeline file:"); - println!(" git add {} && git commit -m 'ci: add stacker deploy pipeline'", output_path.display()); + println!( + " git add {} && git commit -m 'ci: add stacker deploy pipeline'", + output_path.display() + ); } _ => {} } @@ -148,10 +154,7 @@ impl CallableTrait for CiValidateCommand { }; if !pipeline_path.exists() { - eprintln!( - "✗ Pipeline file not found: {}", - pipeline_path.display() - ); + eprintln!("✗ Pipeline file not found: {}", pipeline_path.display()); eprintln!(" Run: stacker ci export --platform {}", self.platform); return Err(Box::new(CliError::ConfigValidation(format!( "Pipeline file not found: {}", @@ -171,7 +174,10 @@ impl CallableTrait for CiValidateCommand { pipeline_path.display(), config.name ); - eprintln!(" Re-generate with: stacker ci export --platform {}", self.platform); + eprintln!( + " Re-generate with: stacker ci export --platform {}", + self.platform + ); Err(Box::new(CliError::ConfigValidation( "Pipeline may be out of sync with stacker.yml".to_string(), ))) diff --git a/src/console/commands/cli/config.rs b/src/console/commands/cli/config.rs index 4c1158d5..54f25c73 100644 --- a/src/console/commands/cli/config.rs +++ b/src/console/commands/cli/config.rs @@ -1,5 +1,5 @@ -use std::path::{Path, PathBuf}; use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use crate::cli::config_parser::{ CloudConfig, CloudOrchestrator, CloudProvider, DeployTarget, ServerConfig, StackerConfig, @@ -13,9 +13,7 @@ const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; /// Resolve config path from optional override. fn resolve_config_path(file: &Option) -> String { - file.as_deref() - .unwrap_or(DEFAULT_CONFIG_FILE) - .to_string() + file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE).to_string() } fn prompt_line(prompt: &str) -> Result { @@ -39,8 +37,7 @@ fn parse_cloud_provider(s: &str) -> Result { let json = format!("\"{}\"", s.trim().to_lowercase()); serde_json::from_str::(&json).map_err(|_| { CliError::ConfigValidation( - "Invalid cloud provider. Use: hetzner, digitalocean, aws, linode, vultr" - .to_string(), + "Invalid cloud provider. Use: hetzner, digitalocean, aws, linode, vultr".to_string(), ) }) } @@ -107,7 +104,9 @@ fn first_non_empty_env(keys: &[&str]) -> Option { }) } -fn resolve_remote_cloud_credentials(provider_code: &str) -> serde_json::Map { +fn resolve_remote_cloud_credentials( + provider_code: &str, +) -> serde_json::Map { let mut creds = serde_json::Map::new(); match provider_code { @@ -157,7 +156,10 @@ fn resolve_remote_cloud_credentials(provider_code: &str) -> serde_json::Map {} @@ -296,8 +298,7 @@ pub fn run_generate_remote_payload( "Generated remote payload (advanced/debug): {}", output_path.display() ), - "Set deploy.target=cloud and deploy.cloud.orchestrator=remote (advanced mode)" - .to_string(), + "Set deploy.target=cloud and deploy.cloud.orchestrator=remote (advanced mode)".to_string(), "Tip: regular users can skip this and run `stacker deploy --target cloud` directly" .to_string(), format!("Backup written to {}", backup_path), @@ -392,10 +393,8 @@ pub fn run_setup_cloud_interactive(config_path: &str) -> Result, Cli .and_then(|c| c.ssh_key.clone()) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "~/.ssh/id_rsa".to_string()); - let ssh_key_input = prompt_with_default( - "SSH key path (leave empty to skip)", - &ssh_key_default, - )?; + let ssh_key_input = + prompt_with_default("SSH key path (leave empty to skip)", &ssh_key_default)?; let region_opt = if region.trim().is_empty() { None @@ -482,11 +481,7 @@ pub fn run_fix_interactive(config_path: &str) -> Result, CliError> { .unwrap_or_else(|| "cpx11".to_string()); let size = prompt_with_default("Cloud size", &size_default)?; - let ssh_key = config - .deploy - .cloud - .as_ref() - .and_then(|c| c.ssh_key.clone()); + let ssh_key = config.deploy.cloud.as_ref().and_then(|c| c.ssh_key.clone()); let orchestrator = config .deploy @@ -621,9 +616,8 @@ pub fn run_show(config_path: &str) -> Result { } let config = StackerConfig::from_file(path)?; - let yaml = serde_yaml::to_string(&config).map_err(|e| { - CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) - })?; + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; Ok(yaml) } @@ -835,7 +829,9 @@ impl CallableTrait for ConfigLockCommand { eprintln!("Deployment lock exists but has no remote server details."); if lock.target == "cloud" { eprintln!("The cloud deployment may still be provisioning."); - eprintln!("Wait for it to complete, then run `stacker deploy --lock` to retry."); + eprintln!( + "Wait for it to complete, then run `stacker deploy --lock` to retry." + ); } return Ok(()); } @@ -844,9 +840,7 @@ impl CallableTrait for ConfigLockCommand { // 3. Load stacker.yml, apply lock, write back if !config_path.exists() { - return Err(Box::new(CliError::ConfigNotFound { - path: config_path, - })); + return Err(Box::new(CliError::ConfigNotFound { path: config_path })); } let mut config = StackerConfig::from_file_raw(&config_path)?; @@ -891,9 +885,7 @@ impl CallableTrait for ConfigUnlockCommand { let config_path = project_dir.join(&config_path_str); if !config_path.exists() { - return Err(Box::new(CliError::ConfigNotFound { - path: config_path, - })); + return Err(Box::new(CliError::ConfigNotFound { path: config_path })); } let mut config = StackerConfig::from_file_raw(&config_path)?; @@ -983,7 +975,10 @@ mod tests { #[test] fn test_parse_cloud_provider_valid() { - assert_eq!(parse_cloud_provider("hetzner").unwrap(), CloudProvider::Hetzner); + assert_eq!( + parse_cloud_provider("hetzner").unwrap(), + CloudProvider::Hetzner + ); assert_eq!(parse_cloud_provider("AWS").unwrap(), CloudProvider::Aws); } @@ -1022,7 +1017,8 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); let config_path = write_config(dir.path(), minimal_config_yaml()); - let applied = run_generate_remote_payload(&config_path, Some("stacker.remote.deploy.json")).unwrap(); + let applied = + run_generate_remote_payload(&config_path, Some("stacker.remote.deploy.json")).unwrap(); assert!(!applied.is_empty()); let payload_path = dir.path().join("stacker.remote.deploy.json"); diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index 7f31ad23..4f73a994 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -19,8 +19,8 @@ use crate::cli::install_runner::{ }; use crate::cli::progress; use crate::cli::stacker_client::{self, StackerClient}; -use crate::helpers::ssh_client; use crate::console::commands::CallableTrait; +use crate::helpers::ssh_client; /// Default config filename. const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; @@ -37,7 +37,10 @@ fn parse_ai_provider(s: &str) -> Result { }) } -fn resolve_ai_from_env_or_config(project_dir: &Path, config_file: Option<&str>) -> Result { +fn resolve_ai_from_env_or_config( + project_dir: &Path, + config_file: Option<&str>, +) -> Result { let config_path = match config_file { Some(f) => project_dir.join(f), None => project_dir.join(DEFAULT_CONFIG_FILE), @@ -112,10 +115,18 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { let mut hints = Vec::new(); if lower.contains("npm ci") { - hints.push("npm ci failed: ensure package-lock.json exists and is in sync with package.json".to_string()); - hints.push("Try locally: npm ci --production (or npm ci) to see the full dependency error".to_string()); + hints.push( + "npm ci failed: ensure package-lock.json exists and is in sync with package.json" + .to_string(), + ); + hints.push( + "Try locally: npm ci --production (or npm ci) to see the full dependency error" + .to_string(), + ); } - if lower.contains("the attribute `version` is obsolete") || lower.contains("attribute `version` is obsolete") { + if lower.contains("the attribute `version` is obsolete") + || lower.contains("attribute `version` is obsolete") + { hints.push("docker-compose version warning: remove top-level 'version:' from .stacker/docker-compose.yml".to_string()); } if lower.contains("failed to solve") { @@ -125,10 +136,14 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { hints.push("Permission issue detected: verify file ownership and executable bits for scripts copied into the image".to_string()); } if lower.contains("no such file") || lower.contains("not found") { - hints.push("Missing file in build context: confirm COPY paths and .dockerignore rules".to_string()); + hints.push( + "Missing file in build context: confirm COPY paths and .dockerignore rules".to_string(), + ); } if lower.contains("network") || lower.contains("timed out") { - hints.push("Network/timeout issue: retry build and verify registry connectivity".to_string()); + hints.push( + "Network/timeout issue: retry build and verify registry connectivity".to_string(), + ); } if lower.contains("port is already allocated") || lower.contains("bind for 0.0.0.0") @@ -151,11 +166,20 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { hints.push("Orphan containers detected: run docker compose -f .stacker/docker-compose.yml down --remove-orphans".to_string()); } if lower.contains("manifest unknown") || lower.contains("pull access denied") { - hints.push("Image pull failed: the configured image tag is not available in the registry".to_string()); + hints.push( + "Image pull failed: the configured image tag is not available in the registry" + .to_string(), + ); if let Some(image) = extract_missing_image(reason) { hints.push(format!("Missing image detected: {}", image)); - hints.push(format!("Build and tag locally: docker build -t {} .", image)); - hints.push(format!("If using a remote registry, push it first: docker push {}", image)); + hints.push(format!( + "Build and tag locally: docker build -t {} .", + image + )); + hints.push(format!( + "If using a remote registry, push it first: docker push {}", + image + )); } else { hints.push("Build locally first (docker build -t .) or use an existing published tag".to_string()); } @@ -165,7 +189,10 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { if hints.is_empty() { hints.push("Run docker compose -f .stacker/docker-compose.yml build --no-cache for detailed build logs".to_string()); hints.push("Inspect .stacker/Dockerfile and .stacker/docker-compose.yml for invalid paths and commands".to_string()); - hints.push("If the issue is dependency-related, run the failing install command locally first".to_string()); + hints.push( + "If the issue is dependency-related, run the failing install command locally first" + .to_string(), + ); } hints @@ -253,10 +280,7 @@ fn try_ssh_server_check(server: &ServerConfig) -> Option p.clone(), None => { @@ -311,9 +335,18 @@ fn print_server_unreachable_hint(server: &ServerConfig, check: &ssh_client::Syst eprintln!(" │ To deploy to this server, fix the connection issue and retry: │"); eprintln!(" │ │"); if let Some(ref key) = server.ssh_key { - eprintln!(" │ ssh -i {} -p {} {}@{}", key.display(), server.port, server.user, server.host); + eprintln!( + " │ ssh -i {} -p {} {}@{}", + key.display(), + server.port, + server.user, + server.host + ); } else { - eprintln!(" │ ssh -p {} {}@{}", server.port, server.user, server.host); + eprintln!( + " │ ssh -p {} {}@{}", + server.port, server.user, server.host + ); } eprintln!(" │ │"); eprintln!(" │ Or, to provision a new cloud server instead, remove the │"); @@ -341,7 +374,10 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError if let serde_yaml::Value::Mapping(ref mut root) = doc { // Remove obsolete compose version key. - if root.remove(serde_yaml::Value::String("version".to_string())).is_some() { + if root + .remove(serde_yaml::Value::String("version".to_string())) + .is_some() + { changed = true; } @@ -384,7 +420,9 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError .map(|d| d.starts_with(".stacker/")) .unwrap_or(false); - if dockerfile_points_to_stacker && (current_context == "." || current_context == "./") { + if dockerfile_points_to_stacker + && (current_context == "." || current_context == "./") + { build_map.insert( context_key.clone(), serde_yaml::Value::String("..".to_string()), @@ -393,10 +431,7 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError } if service_name == "app" && (current_context == "." || current_context == "./") { - build_map.insert( - context_key, - serde_yaml::Value::String("..".to_string()), - ); + build_map.insert(context_key, serde_yaml::Value::String("..".to_string())); let dockerfile_needs_rewrite = match dockerfile.as_deref() { None => true, @@ -418,8 +453,9 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError } if changed { - let updated = serde_yaml::to_string(&doc) - .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize compose file: {e}")))?; + let updated = serde_yaml::to_string(&doc).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize compose file: {e}")) + })?; std::fs::write(compose_path, updated)?; eprintln!(" Normalized {}/docker-compose.yml paths", OUTPUT_DIR); } @@ -539,11 +575,16 @@ fn print_ai_deploy_help(project_dir: &Path, config_file: Option<&str>, err: &Cli let ai_config = match resolve_ai_from_env_or_config(project_dir, config_file) { Ok(cfg) => cfg, Err(load_err) => { - eprintln!(" Could not load AI config for troubleshooting: {}", load_err); + eprintln!( + " Could not load AI config for troubleshooting: {}", + load_err + ); for hint in fallback_troubleshooting_hints(reason) { eprintln!(" - {}", hint); } - eprintln!(" Tip: enable AI with stacker init --with-ai or set STACKER_AI_PROVIDER=ollama"); + eprintln!( + " Tip: enable AI with stacker init --with-ai or set STACKER_AI_PROVIDER=ollama" + ); return; } }; @@ -560,7 +601,10 @@ fn print_ai_deploy_help(project_dir: &Path, config_file: Option<&str>, err: &Cli let error_log = build_troubleshoot_error_log(project_dir, reason); let ctx = PromptContext { project_type: None, - files: vec![".stacker/Dockerfile".to_string(), ".stacker/docker-compose.yml".to_string()], + files: vec![ + ".stacker/Dockerfile".to_string(), + ".stacker/docker-compose.yml".to_string(), + ], error_log: Some(error_log), current_config: None, }; @@ -629,9 +673,7 @@ fn cloud_provider_from_code(code: &str) -> Option { /// - `Ok(Some(cloud_info))` when the user picks an existing credential. /// - `Ok(None)` when the user picks "Connect a new cloud provider". /// - `Err(...)` on I/O or network errors. -fn prompt_select_cloud( - access_token: &str, -) -> Result, CliError> { +fn prompt_select_cloud(access_token: &str) -> Result, CliError> { let base_url = crate::cli::install_runner::normalize_stacker_server_url( stacker_client::DEFAULT_STACKER_URL, ); @@ -639,7 +681,9 @@ fn prompt_select_cloud( let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; let clouds = rt.block_on(async { let client = StackerClient::new(&base_url, access_token); @@ -665,8 +709,16 @@ fn prompt_select_cloud( let mut items: Vec = clouds .iter() - .map(|c| format!("{: = None; if deploy_target == DeployTarget::Cloud && !force_new { if let Some(ref server_cfg) = config.deploy.server { - eprintln!(" Found deploy.server section (host={}). Checking SSH connectivity...", server_cfg.host); + eprintln!( + " Found deploy.server section (host={}). Checking SSH connectivity...", + server_cfg.host + ); match try_ssh_server_check(server_cfg) { Some(check) if check.connected && check.authenticated => { - eprintln!(" ✓ Server {} is reachable ({})", server_cfg.host, check.summary()); + eprintln!( + " ✓ Server {} is reachable ({})", + server_cfg.host, + check.summary() + ); if !check.docker_installed { eprintln!(" ⚠ Docker is NOT installed on the server."); @@ -874,7 +930,9 @@ pub fn run_deploy( }); } - eprintln!(" Switching deploy target from 'cloud' → 'server' (using existing server)"); + eprintln!( + " Switching deploy target from 'cloud' → 'server' (using existing server)" + ); deploy_target = DeployTarget::Server; } Some(check) => { @@ -910,14 +968,24 @@ pub fn run_deploy( // Auto-inject the server name so the cloud deploy API reuses the same server. if let Ok(Some(lock)) = DeploymentLock::load(project_dir) { if let Some(ref name) = lock.server_name { - eprintln!(" ℹ Found previous deployment (server='{}') — reusing server", name); + eprintln!( + " ℹ Found previous deployment (server='{}') — reusing server", + name + ); eprintln!(" To provision a new server instead: stacker deploy --force-new"); lock_server_name = Some(name.clone()); } else if let Some(ref ip) = lock.server_ip { if ip != "127.0.0.1" { - eprintln!(" ℹ Found previous deployment to {} (from .stacker/deployment.lock)", ip); - eprintln!(" Server name unknown — cannot auto-reuse. Run: stacker config lock"); - eprintln!(" To provision a new server instead: stacker deploy --force-new"); + eprintln!( + " ℹ Found previous deployment to {} (from .stacker/deployment.lock)", + ip + ); + eprintln!( + " Server name unknown — cannot auto-reuse. Run: stacker config lock" + ); + eprintln!( + " To provision a new server instead: stacker deploy --force-new" + ); } } } @@ -1019,7 +1087,10 @@ pub fn run_deploy( let builder = DockerfileBuilder::from(config.app.app_type); builder.write_to(&dockerfile_path, force_rebuild)?; } else { - eprintln!(" Using existing {}/Dockerfile (use --force-rebuild to regenerate)", OUTPUT_DIR); + eprintln!( + " Using existing {}/Dockerfile (use --force-rebuild to regenerate)", + OUTPUT_DIR + ); } } @@ -1050,7 +1121,10 @@ pub fn run_deploy( let compose = ComposeDefinition::try_from(&config)?; compose.write_to(&compose_out, force_rebuild)?; } else { - eprintln!(" Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", OUTPUT_DIR); + eprintln!( + " Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", + OUTPUT_DIR + ); } compose_out }; @@ -1059,7 +1133,10 @@ pub fn run_deploy( // 5b.1 Surface build source paths to avoid confusion. if let Some(image) = &config.app.image { - eprintln!(" App image source: image={} (no local Dockerfile build)", image); + eprintln!( + " App image source: image={} (no local Dockerfile build)", + image + ); } else if let Some(build_src) = compose_app_build_source(&compose_path) { eprintln!(" App build source: {}", build_src); } else if let Some(dockerfile) = &config.app.dockerfile { @@ -1070,7 +1147,10 @@ pub fn run_deploy( }; eprintln!(" App build source: Dockerfile={}", dockerfile_display); } else { - eprintln!(" App build source: Dockerfile={}", dockerfile_path.display()); + eprintln!( + " App build source: Dockerfile={}", + dockerfile_path.display() + ); } eprintln!(" Compose file: {}", compose_path.display()); @@ -1095,10 +1175,7 @@ pub fn run_deploy( project_name_override: remote_overrides.project_name.clone(), key_name_override: remote_overrides.key_name.clone(), key_id_override: remote_overrides.key_id, - server_name_override: remote_overrides - .server_name - .clone() - .or(lock_server_name), + server_name_override: remote_overrides.server_name.clone().or(lock_server_name), }; let result = strategy.deploy(&config, &context, executor)?; @@ -1249,7 +1326,8 @@ impl DeployCommand { info.cloud_id, ); if let Some(ref ip) = info.srv_ip { - eprintln!(" Server details: {} ({}@{}:{})", + eprintln!( + " Server details: {} ({}@{}:{})", info.name.as_deref().unwrap_or("unnamed"), info.ssh_user.as_deref().unwrap_or("root"), ip, @@ -1258,7 +1336,9 @@ impl DeployCommand { } } Ok(None) => { - eprintln!(" ℹ Server details not yet available (may still be provisioning)."); + eprintln!( + " ℹ Server details not yet available (may still be provisioning)." + ); } Err(e) => { eprintln!(" ⚠ Could not fetch server details: {}", e); @@ -1301,9 +1381,7 @@ impl DeployCommand { None => project_dir.join(DEFAULT_CONFIG_FILE), }; - if lock.server_ip.is_some() - && lock.server_ip.as_deref() != Some("127.0.0.1") - { + if lock.server_ip.is_some() && lock.server_ip.as_deref() != Some("127.0.0.1") { match StackerConfig::from_file(&config_path) { Ok(mut config) => { lock.apply_to_config(&mut config); @@ -1465,7 +1543,11 @@ fn watch_local_containers( if let Ok(config) = StackerConfig::from_file(&config_path) { if let Some(ref existing) = config.deploy.compose_file { let p = project_dir.join(existing); - if p.exists() { p } else { output_dir.join("docker-compose.yml") } + if p.exists() { + p + } else { + output_dir.join("docker-compose.yml") + } } else { output_dir.join("docker-compose.yml") } @@ -1487,10 +1569,7 @@ fn watch_local_containers( let spin = progress::spinner("Checking container health..."); loop { - let args = vec![ - "compose", "-f", &compose_str, "ps", - "--format", "json", - ]; + let args = vec!["compose", "-f", &compose_str, "ps", "--format", "json"]; if let Ok(output) = executor.execute("docker", &args) { if output.success() { let stdout = output.stdout.trim(); @@ -1515,7 +1594,10 @@ fn watch_local_containers( } if start.elapsed() > timeout { - progress::finish_error(&spin, "Timeout waiting for containers — check `stacker status`"); + progress::finish_error( + &spin, + "Timeout waiting for containers — check `stacker status`", + ); return Ok(()); } @@ -1564,13 +1646,7 @@ fn print_container_summary(compose_str: &str, executor: &dyn CommandExecutor) { // ── Cloud deployment status polling after remote deploy ────── /// Terminal statuses — once reached, watching stops. -const TERMINAL_STATUSES: &[&str] = &[ - "completed", - "failed", - "cancelled", - "error", - "paused", -]; +const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "cancelled", "error", "paused"]; fn is_terminal(status: &str) -> bool { TERMINAL_STATUSES.iter().any(|s| *s == status) @@ -1649,10 +1725,7 @@ fn watch_cloud_deployment(result: &DeployResult) -> Result<(), Box Result<(), Box { if last_status.is_empty() { - progress::update_message( - &spin, - "Waiting for deployment to appear...", - ); + progress::update_message(&spin, "Waiting for deployment to appear..."); last_status = "".to_string(); } } Err(e) => { - progress::finish_error( - &spin, - &format!("Error polling status: {}", e), - ); + progress::finish_error(&spin, &format!("Error polling status: {}", e)); eprintln!(" Run `stacker status --watch` to retry."); return Ok(()); } } if start.elapsed() > timeout { - progress::finish_error( - &spin, - "Watch timeout (10m) — deployment still in progress", - ); + progress::finish_error(&spin, "Watch timeout (10m) — deployment still in progress"); eprintln!(" Run `stacker status --watch` to continue watching."); return Ok(()); } @@ -1773,7 +1837,16 @@ mod tests { ]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); // Generated files should exist @@ -1791,7 +1864,16 @@ mod tests { ]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); // Custom Dockerfile should not be overwritten @@ -1807,12 +1889,24 @@ mod tests { let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: docker-compose.yml\n"; let dir = setup_local_project(&[ ("index.html", "

hello

"), - ("docker-compose.yml", "version: '3.8'\nservices:\n web:\n image: nginx\n"), + ( + "docker-compose.yml", + "version: '3.8'\nservices:\n web:\n image: nginx\n", + ), ("stacker.yml", config), ]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); // .stacker/docker-compose.yml should NOT be generated @@ -1825,23 +1919,42 @@ mod tests { let dir = setup_local_project(&[ ("index.html", "

hello

"), ("stacker.yml", config), - (".stacker/docker-compose.yml", "services:\n app:\n image: nginx\n"), + ( + ".stacker/docker-compose.yml", + "services:\n app:\n image: nginx\n", + ), ]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); } #[test] fn test_deploy_local_with_image_skips_build() { let config = "name: test-app\napp:\n type: static\n path: .\n image: nginx:latest\n"; - let dir = setup_local_project(&[ - ("stacker.yml", config), - ]); + let dir = setup_local_project(&[("stacker.yml", config)]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); // No Dockerfile should be generated (using image) @@ -1850,12 +1963,19 @@ mod tests { #[test] fn test_deploy_cloud_requires_login() { - let dir = setup_local_project(&[ - ("stacker.yml", &cloud_config_yaml()), - ]); + let dir = setup_local_project(&[("stacker.yml", &cloud_config_yaml())]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); @@ -1870,30 +1990,47 @@ mod tests { fn test_deploy_cloud_requires_provider() { // Cloud target but no cloud config let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: cloud\n"; - let dir = setup_local_project(&[ - ("stacker.yml", config), - ]); + let dir = setup_local_project(&[("stacker.yml", config)]); let executor = MockExecutor::success(); // This should fail at validation since no credentials exist - let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_err()); } #[test] fn test_deploy_server_requires_host() { let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: server\n"; - let dir = setup_local_project(&[ - ("stacker.yml", config), - ]); + let dir = setup_local_project(&[("stacker.yml", config)]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); - assert!(err.contains("host") || err.contains("Host") || err.contains("server"), - "Expected server host error, got: {}", err); + assert!( + err.contains("host") || err.contains("Host") || err.contains("server"), + "Expected server host error, got: {}", + err + ); } #[test] @@ -1901,12 +2038,24 @@ mod tests { let dir = TempDir::new().unwrap(); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + None, + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); - assert!(err.contains("not found") || err.contains("Configuration"), - "Expected config not found error, got: {}", err); + assert!( + err.contains("not found") || err.contains("Configuration"), + "Expected config not found error, got: {}", + err + ); } #[test] @@ -1917,7 +2066,16 @@ mod tests { ]); let executor = MockExecutor::success(); - let result = run_deploy(dir.path(), Some("custom.yml"), Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + Some("custom.yml"), + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); } @@ -1930,15 +2088,42 @@ mod tests { let executor = MockExecutor::success(); // First deploy creates files - let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); // Second deploy without force_rebuild should succeed (reuses existing files) - let result2 = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result2 = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result2.is_ok()); // With force_rebuild should also succeed (regenerates files) - let result3 = run_deploy(dir.path(), None, Some("local"), true, true, false, &executor, &RemoteDeployOverrides::default()); + let result3 = run_deploy( + dir.path(), + None, + Some("local"), + true, + true, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result3.is_ok()); } @@ -1963,21 +2148,29 @@ mod tests { #[test] fn test_deploy_runs_pre_build_hook_noted() { - let config = "name: test-app\napp:\n type: static\n path: .\nhooks:\n pre_build: ./build.sh\n"; - let dir = setup_local_project(&[ - ("index.html", "

hello

"), - ("stacker.yml", config), - ]); + let config = + "name: test-app\napp:\n type: static\n path: .\nhooks:\n pre_build: ./build.sh\n"; + let dir = setup_local_project(&[("index.html", "

hello

"), ("stacker.yml", config)]); let executor = MockExecutor::success(); // Dry-run should succeed (hooks are just noted, not executed in dry-run) - let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); + let result = run_deploy( + dir.path(), + None, + Some("local"), + true, + false, + false, + &executor, + &RemoteDeployOverrides::default(), + ); assert!(result.is_ok()); } #[test] fn test_fallback_hints_for_npm_ci_error() { - let hints = fallback_troubleshooting_hints("failed to solve: /bin/sh -c npm ci --production"); + let hints = + fallback_troubleshooting_hints("failed to solve: /bin/sh -c npm ci --production"); assert!(hints.iter().any(|h| h.contains("npm ci failed"))); } @@ -2006,14 +2199,14 @@ mod tests { assert!(log.contains("(not found)")); } - #[test] - fn test_normalize_generated_compose_paths_fixes_stacker_context_and_version() { - let dir = TempDir::new().unwrap(); - let stacker_dir = dir.path().join(".stacker"); - std::fs::create_dir_all(&stacker_dir).unwrap(); + #[test] + fn test_normalize_generated_compose_paths_fixes_stacker_context_and_version() { + let dir = TempDir::new().unwrap(); + let stacker_dir = dir.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); - let compose_path = stacker_dir.join("docker-compose.yml"); - let compose = r#" + let compose_path = stacker_dir.join("docker-compose.yml"); + let compose = r#" version: "3.9" services: app: @@ -2021,37 +2214,37 @@ services: context: . dockerfile: .stacker/Dockerfile "#; - std::fs::write(&compose_path, compose).unwrap(); + std::fs::write(&compose_path, compose).unwrap(); - normalize_generated_compose_paths(&compose_path).unwrap(); + normalize_generated_compose_paths(&compose_path).unwrap(); - let normalized = std::fs::read_to_string(&compose_path).unwrap(); - assert!(!normalized.contains("version:")); - assert!(normalized.contains("context: ..")); - assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); - } + let normalized = std::fs::read_to_string(&compose_path).unwrap(); + assert!(!normalized.contains("version:")); + assert!(normalized.contains("context: ..")); + assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); + } - #[test] - fn test_normalize_generated_compose_paths_adds_stacker_dockerfile_for_app_when_missing() { - let dir = TempDir::new().unwrap(); - let stacker_dir = dir.path().join(".stacker"); - std::fs::create_dir_all(&stacker_dir).unwrap(); + #[test] + fn test_normalize_generated_compose_paths_adds_stacker_dockerfile_for_app_when_missing() { + let dir = TempDir::new().unwrap(); + let stacker_dir = dir.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); - let compose_path = stacker_dir.join("docker-compose.yml"); - let compose = r#" + let compose_path = stacker_dir.join("docker-compose.yml"); + let compose = r#" services: app: build: context: . "#; - std::fs::write(&compose_path, compose).unwrap(); + std::fs::write(&compose_path, compose).unwrap(); - normalize_generated_compose_paths(&compose_path).unwrap(); + normalize_generated_compose_paths(&compose_path).unwrap(); - let normalized = std::fs::read_to_string(&compose_path).unwrap(); - assert!(normalized.contains("context: ..")); - assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); - } + let normalized = std::fs::read_to_string(&compose_path).unwrap(); + assert!(normalized.contains("context: ..")); + assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); + } #[test] fn test_parse_deploy_target_valid() { @@ -2082,7 +2275,9 @@ services: "docker compose failed: manifest for optimum/optimumcode:latest not found: manifest unknown" ); assert!(hints.iter().any(|h| h.contains("Image pull failed"))); - assert!(hints.iter().any(|h| h.contains("docker build -t optimum/optimumcode:latest ."))); + assert!(hints + .iter() + .any(|h| h.contains("docker build -t optimum/optimumcode:latest ."))); } #[test] @@ -2097,7 +2292,7 @@ services: #[test] fn test_fallback_hints_for_orphan_containers() { let hints = fallback_troubleshooting_hints( - "Found orphan containers ([stackerdb]) for this project" + "Found orphan containers ([stackerdb]) for this project", ); assert!(hints.iter().any(|h| h.contains("--remove-orphans"))); } @@ -2115,7 +2310,7 @@ services: fn test_ensure_env_file_is_created_when_missing() { let dir = TempDir::new().unwrap(); let config = StackerConfig::from_str( - "name: env-app\napp:\n type: static\nenv_file: .env\nenv:\n APP_ENV: production\n" + "name: env-app\napp:\n type: static\nenv_file: .env\nenv:\n APP_ENV: production\n", ) .unwrap(); @@ -2185,18 +2380,39 @@ services: #[test] fn test_cloud_provider_from_code() { // Short codes - assert_eq!(cloud_provider_from_code("htz"), Some(CloudProvider::Hetzner)); - assert_eq!(cloud_provider_from_code("do"), Some(CloudProvider::Digitalocean)); + assert_eq!( + cloud_provider_from_code("htz"), + Some(CloudProvider::Hetzner) + ); + assert_eq!( + cloud_provider_from_code("do"), + Some(CloudProvider::Digitalocean) + ); assert_eq!(cloud_provider_from_code("aws"), Some(CloudProvider::Aws)); assert_eq!(cloud_provider_from_code("lo"), Some(CloudProvider::Linode)); assert_eq!(cloud_provider_from_code("vu"), Some(CloudProvider::Vultr)); // Full names - assert_eq!(cloud_provider_from_code("hetzner"), Some(CloudProvider::Hetzner)); - assert_eq!(cloud_provider_from_code("digitalocean"), Some(CloudProvider::Digitalocean)); - assert_eq!(cloud_provider_from_code("linode"), Some(CloudProvider::Linode)); - assert_eq!(cloud_provider_from_code("vultr"), Some(CloudProvider::Vultr)); + assert_eq!( + cloud_provider_from_code("hetzner"), + Some(CloudProvider::Hetzner) + ); + assert_eq!( + cloud_provider_from_code("digitalocean"), + Some(CloudProvider::Digitalocean) + ); + assert_eq!( + cloud_provider_from_code("linode"), + Some(CloudProvider::Linode) + ); + assert_eq!( + cloud_provider_from_code("vultr"), + Some(CloudProvider::Vultr) + ); // Case insensitive - assert_eq!(cloud_provider_from_code("HTZ"), Some(CloudProvider::Hetzner)); + assert_eq!( + cloud_provider_from_code("HTZ"), + Some(CloudProvider::Hetzner) + ); assert_eq!(cloud_provider_from_code("AWS"), Some(CloudProvider::Aws)); // Unknown assert_eq!(cloud_provider_from_code("unknown"), None); @@ -2205,21 +2421,17 @@ services: #[test] fn test_with_watch_flags() { - let cmd = DeployCommand::new(None, None, false, false) - .with_watch(false, false); + let cmd = DeployCommand::new(None, None, false, false).with_watch(false, false); assert_eq!(cmd.watch, None); // auto - let cmd = DeployCommand::new(None, None, false, false) - .with_watch(true, false); + let cmd = DeployCommand::new(None, None, false, false).with_watch(true, false); assert_eq!(cmd.watch, Some(true)); - let cmd = DeployCommand::new(None, None, false, false) - .with_watch(false, true); + let cmd = DeployCommand::new(None, None, false, false).with_watch(false, true); assert_eq!(cmd.watch, Some(false)); // --no-watch wins over --watch - let cmd = DeployCommand::new(None, None, false, false) - .with_watch(true, true); + let cmd = DeployCommand::new(None, None, false, false).with_watch(true, true); assert_eq!(cmd.watch, Some(false)); } } diff --git a/src/console/commands/cli/destroy.rs b/src/console/commands/cli/destroy.rs index ebfe354c..3dc4b58e 100644 --- a/src/console/commands/cli/destroy.rs +++ b/src/console/commands/cli/destroy.rs @@ -2,9 +2,7 @@ use std::path::Path; use crate::cli::config_parser::DeployTarget; use crate::cli::error::CliError; -use crate::cli::install_runner::{ - CommandExecutor, ShellExecutor, -}; +use crate::cli::install_runner::{CommandExecutor, ShellExecutor}; use crate::console::commands::CallableTrait; /// Output directory for generated artifacts. @@ -106,7 +104,9 @@ mod tests { impl MockExecutor { fn new() -> Self { - Self { calls: Mutex::new(Vec::new()) } + Self { + calls: Mutex::new(Vec::new()), + } } fn recorded_calls(&self) -> Vec<(String, Vec)> { @@ -120,7 +120,11 @@ mod tests { program.to_string(), args.iter().map(|s| s.to_string()).collect(), )); - Ok(CommandOutput { exit_code: 0, stdout: String::new(), stderr: String::new() }) + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) } } diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index d3a8bb9b..184b9144 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -1,7 +1,7 @@ use std::convert::TryFrom; use std::path::{Path, PathBuf}; -use crate::cli::ai_client::{AiProvider, create_provider, ollama_complete_streaming}; +use crate::cli::ai_client::{create_provider, ollama_complete_streaming, AiProvider}; use crate::cli::ai_scanner::{ build_generation_request, generate_config_with_ai, strip_code_fences, }; @@ -147,7 +147,12 @@ pub struct InitCommand { } impl InitCommand { - pub fn new(app_type: Option, with_proxy: bool, with_ai: bool, with_cloud: bool) -> Self { + pub fn new( + app_type: Option, + with_proxy: bool, + with_ai: bool, + with_cloud: bool, + ) -> Self { Self { app_type, with_proxy, @@ -393,10 +398,7 @@ fn generate_config_ai_path( \n\ {}\n", ai_config.provider, - ai_config - .model - .as_deref() - .unwrap_or("default"), + ai_config.model.as_deref().unwrap_or("default"), full_config_reference_example(), ); @@ -566,14 +568,22 @@ fn collect_generation_context(project_dir: &Path, config: &StackerConfig) -> Vec } )); - let lockfiles = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.lock"]; + let lockfiles = [ + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "Cargo.lock", + ]; let found_lockfiles: Vec<&str> = lockfiles .iter() .copied() .filter(|file| project_dir.join(file).exists()) .collect(); if !found_lockfiles.is_empty() { - context.push(format!("Detected lockfiles: {}", found_lockfiles.join(", "))); + context.push(format!( + "Detected lockfiles: {}", + found_lockfiles.join(", ") + )); } context @@ -635,7 +645,8 @@ fn generate_compose_with_ai( context.join("\n\n"), dockerfile_rel ); - let system = "You are an expert Docker Compose engineer. Return only valid docker compose YAML."; + let system = + "You are an expert Docker Compose engineer. Return only valid docker compose YAML."; let raw = if ai_config.provider == AiProviderType::Ollama { eprintln!("🧠 AI reasoning for compose (streaming)..."); let response = ollama_complete_streaming(ai_config, &prompt, system)?; @@ -646,9 +657,8 @@ fn generate_compose_with_ai( }; let compose = strip_code_fences(&raw); - let parsed: serde_yaml::Value = serde_yaml::from_str(&compose).map_err(|e| { - CliError::GeneratorError(format!("AI generated invalid compose YAML: {e}")) - })?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&compose) + .map_err(|e| CliError::GeneratorError(format!("AI generated invalid compose YAML: {e}")))?; if parsed.get("services").is_none() { return Err(CliError::GeneratorError( @@ -678,7 +688,8 @@ impl CallableTrait for InitCommand { if self.with_cloud { eprintln!("☁ Running cloud setup wizard..."); let path_str = config_path.to_string_lossy().to_string(); - let applied = crate::console::commands::cli::config::run_setup_cloud_interactive(&path_str)?; + let applied = + crate::console::commands::cli::config::run_setup_cloud_interactive(&path_str)?; for item in applied { eprintln!(" - {}", item); } @@ -695,7 +706,15 @@ impl CallableTrait for InitCommand { eprintln!(" AI: enabled ({})", config.ai.provider); } if !config.services.is_empty() { - eprintln!(" Services: {}", config.services.iter().map(|s| s.name.as_str()).collect::>().join(", ")); + eprintln!( + " Services: {}", + config + .services + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(", ") + ); } // Generate .stacker/ directory with Dockerfile and docker-compose.yml @@ -732,7 +751,8 @@ impl CallableTrait for InitCommand { let mut generated = false; if let Some((ref ai_cfg, ref provider)) = ai_runtime { - match generate_dockerfile_with_ai(&project_dir, &config, ai_cfg, provider.as_ref()) { + match generate_dockerfile_with_ai(&project_dir, &config, ai_cfg, provider.as_ref()) + { Ok(dockerfile) => { std::fs::write(&dockerfile_path, dockerfile)?; eprintln!("✓ Generated {}/Dockerfile (AI)", OUTPUT_DIR); @@ -821,10 +841,7 @@ mod tests { fn new(responses: Vec<&str>) -> Self { Self { responses: std::sync::Mutex::new( - responses - .into_iter() - .map(|s| s.to_string()) - .collect(), + responses.into_iter().map(|s| s.to_string()).collect(), ), } } @@ -972,7 +989,12 @@ mod tests { #[test] fn test_resolve_ai_config_explicit_provider() { - let config = resolve_ai_config(Some("anthropic"), Some("claude-sonnet-4-20250514"), Some("sk-ant-test")).unwrap(); + let config = resolve_ai_config( + Some("anthropic"), + Some("claude-sonnet-4-20250514"), + Some("sk-ant-test"), + ) + .unwrap(); assert_eq!(config.provider, AiProviderType::Anthropic); assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-20250514")); assert_eq!(config.api_key.as_deref(), Some("sk-ant-test")); @@ -1038,12 +1060,18 @@ mod tests { #[test] fn test_parse_ai_provider_all_valid() { assert_eq!(parse_ai_provider("openai").unwrap(), AiProviderType::Openai); - assert_eq!(parse_ai_provider("anthropic").unwrap(), AiProviderType::Anthropic); + assert_eq!( + parse_ai_provider("anthropic").unwrap(), + AiProviderType::Anthropic + ); assert_eq!(parse_ai_provider("ollama").unwrap(), AiProviderType::Ollama); assert_eq!(parse_ai_provider("custom").unwrap(), AiProviderType::Custom); // Case insensitive assert_eq!(parse_ai_provider("OpenAI").unwrap(), AiProviderType::Openai); - assert_eq!(parse_ai_provider("ANTHROPIC").unwrap(), AiProviderType::Anthropic); + assert_eq!( + parse_ai_provider("ANTHROPIC").unwrap(), + AiProviderType::Anthropic + ); } #[test] @@ -1053,8 +1081,13 @@ mod tests { // Use an explicit provider that will fail connection (port 1 is unreachable) // This avoids hitting a real running Ollama instance let result = generate_config_full( - dir.path(), None, false, true, - Some("custom"), None, Some("fake-key"), + dir.path(), + None, + false, + true, + Some("custom"), + None, + Some("fake-key"), ); assert!(result.is_ok()); @@ -1086,7 +1119,8 @@ mod tests { ..AiConfig::default() }; - let dockerfile = generate_dockerfile_with_ai(dir.path(), &config, &ai_cfg, &provider).unwrap(); + let dockerfile = + generate_dockerfile_with_ai(dir.path(), &config, &ai_cfg, &provider).unwrap(); assert!(dockerfile.contains("FROM node:20-alpine")); assert!(!dockerfile.contains("```")); } diff --git a/src/console/commands/cli/list.rs b/src/console/commands/cli/list.rs index e60cbbec..bb06e70c 100644 --- a/src/console/commands/cli/list.rs +++ b/src/console/commands/cli/list.rs @@ -32,7 +32,9 @@ impl CallableTrait for ListProjectsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -99,7 +101,9 @@ impl CallableTrait for ListServersCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -156,7 +160,11 @@ pub struct ListDeploymentsCommand { impl ListDeploymentsCommand { pub fn new(json: bool, project_id: Option, limit: Option) -> Self { - Self { json, project_id, limit } + Self { + json, + project_id, + limit, + } } } @@ -171,7 +179,9 @@ impl CallableTrait for ListDeploymentsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; let project_id = self.project_id; let limit = self.limit; @@ -243,7 +253,9 @@ impl CallableTrait for ListSshKeysCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -281,7 +293,10 @@ impl CallableTrait for ListSshKeysCommand { let mut active_count = 0; for s in &servers { let status_icon = match s.key_status.as_str() { - "active" => { active_count += 1; "✓ active" }, + "active" => { + active_count += 1; + "✓ active" + } "pending" => "◷ pending", "failed" => "✗ failed", _ => " none", @@ -291,7 +306,9 @@ impl CallableTrait for ListSshKeysCommand { s.id, truncate(&s.name.clone().unwrap_or_else(|| "-".to_string()), 18), s.srv_ip.clone().unwrap_or_else(|| "-".to_string()), - s.ssh_port.map(|p| p.to_string()).unwrap_or_else(|| "22".to_string()), + s.ssh_port + .map(|p| p.to_string()) + .unwrap_or_else(|| "22".to_string()), s.ssh_user.clone().unwrap_or_else(|| "root".to_string()), status_icon, &s.connection_mode, @@ -351,7 +368,9 @@ impl CallableTrait for ListCloudsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -359,8 +378,12 @@ impl CallableTrait for ListCloudsCommand { if clouds.is_empty() { eprintln!("No saved cloud credentials found."); - eprintln!("Cloud credentials are saved automatically when you deploy with env vars,"); - eprintln!("or via: stacker deploy --target cloud (with HCLOUD_TOKEN, etc. exported)."); + eprintln!( + "Cloud credentials are saved automatically when you deploy with env vars," + ); + eprintln!( + "or via: stacker deploy --target cloud (with HCLOUD_TOKEN, etc. exported)." + ); return Ok(()); } diff --git a/src/console/commands/cli/login.rs b/src/console/commands/cli/login.rs index fce156c4..56f23eb1 100644 --- a/src/console/commands/cli/login.rs +++ b/src/console/commands/cli/login.rs @@ -1,9 +1,7 @@ use std::io::{self, IsTerminal}; +use crate::cli::credentials::{login, CredentialsManager, HttpOAuthClient, LoginRequest}; use crate::console::commands::CallableTrait; -use crate::cli::credentials::{ - CredentialsManager, HttpOAuthClient, LoginRequest, login, -}; use dialoguer::Password; /// `stacker login [--org ] [--domain ] [--auth-url ]` diff --git a/src/console/commands/cli/logs.rs b/src/console/commands/cli/logs.rs index edf067c3..252c9800 100644 --- a/src/console/commands/cli/logs.rs +++ b/src/console/commands/cli/logs.rs @@ -125,10 +125,7 @@ impl CallableTrait for LogsCommand { // No local compose — try remote agent logs if is_remote_deployment(&project_dir) { - return run_remote_logs( - self.service.as_deref(), - self.tail, - ); + return run_remote_logs(self.service.as_deref(), self.tail); } // Neither local nor remote @@ -269,7 +266,10 @@ fn run_remote_logs( }; if app_codes.is_empty() { - eprintln!("No containers found for deployment {}.", &hash[..8.min(hash.len())]); + eprintln!( + "No containers found for deployment {}.", + &hash[..8.min(hash.len())] + ); eprintln!( "Tip: use 'stacker agent status --deployment {}' to check the deployment.", &hash[..8.min(hash.len())] @@ -315,8 +315,7 @@ fn run_remote_agent_command( let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); let interval = std::time::Duration::from_secs(REMOTE_POLL_INTERVAL_SECS); let mut last_status = "pending".to_string(); @@ -339,10 +338,7 @@ fn run_remote_agent_command( .await?; last_status = status.status.clone(); - progress::update_message( - &pb, - &format!("{} [{}]", spinner_msg, status.status), - ); + progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); match status.status.as_str() { "completed" | "failed" => return Ok(status), @@ -380,7 +376,11 @@ fn print_logs_result(app_code: &str, info: &AgentCommandInfo, multi: bool) { if info.status == "failed" { if let Some(ref error) = info.error { - eprintln!("Error fetching logs for {}: {}", app_code, fmt::pretty_json(error)); + eprintln!( + "Error fetching logs for {}: {}", + app_code, + fmt::pretty_json(error) + ); } return; } @@ -453,7 +453,11 @@ mod tests { struct MockExec; impl CommandExecutor for MockExec { fn execute(&self, _p: &str, _a: &[&str]) -> Result { - Ok(CommandOutput { exit_code: 0, stdout: String::new(), stderr: String::new() }) + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) } } diff --git a/src/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index 55bcfb81..5e0c8460 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -35,7 +35,9 @@ impl CallableTrait for MarketplaceStatusCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; let name = self.name.clone(); @@ -135,7 +137,9 @@ impl CallableTrait for MarketplaceLogsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; let name = self.name.clone(); @@ -144,9 +148,7 @@ impl CallableTrait for MarketplaceLogsCommand { // First, find the template by name to get its ID let templates = client.marketplace_list_mine().await?; - let template = templates - .iter() - .find(|t| t.name == name || t.slug == name); + let template = templates.iter().find(|t| t.name == name || t.slug == name); let template = match template { Some(t) => t, diff --git a/src/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index ba63ccc9..efde9765 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -99,8 +99,7 @@ fn run_agent_command( let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); let interval = std::time::Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS); let mut last_status = "pending".to_string(); @@ -122,10 +121,7 @@ fn run_agent_command( .await?; last_status = status.status.clone(); - progress::update_message( - &pb, - &format!("{} [{}]", spinner_msg, status.status), - ); + progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); match status.status.as_str() { "completed" | "failed" => return Ok(status), @@ -159,7 +155,11 @@ fn print_command_result(info: &AgentCommandInfo, json_output: bool) { println!("Command: {}", info.command_id); println!("Type: {}", info.command_type); - println!("Status: {} {}", progress::status_icon(&info.status), info.status); + println!( + "Status: {} {}", + progress::status_icon(&info.status), + info.status + ); if let Some(ref result) = info.result { println!("\n{}", fmt::pretty_json(result)); diff --git a/src/console/commands/cli/proxy.rs b/src/console/commands/cli/proxy.rs index 2ddb5979..3ff67295 100644 --- a/src/console/commands/cli/proxy.rs +++ b/src/console/commands/cli/proxy.rs @@ -4,8 +4,8 @@ use crate::cli::config_parser::{ use crate::cli::deployment_lock::DeploymentLock; use crate::cli::error::CliError; use crate::cli::proxy_manager::{ - ContainerRuntime, DockerCliRuntime, ProxyDetection, detect_proxy, - detect_proxy_from_snapshot, generate_nginx_server_block, + detect_proxy, detect_proxy_from_snapshot, generate_nginx_server_block, ContainerRuntime, + DockerCliRuntime, ProxyDetection, }; use crate::cli::runtime::CliRuntime; use crate::console::commands::CallableTrait; @@ -20,7 +20,11 @@ pub fn parse_ssl_mode(s: Option<&str>) -> SslMode { } /// Build a `DomainConfig` from CLI arguments. -pub fn build_domain_config(domain: &str, upstream: Option<&str>, ssl: Option<&str>) -> DomainConfig { +pub fn build_domain_config( + domain: &str, + upstream: Option<&str>, + ssl: Option<&str>, +) -> DomainConfig { DomainConfig { domain: domain.to_string(), ssl: parse_ssl_mode(ssl), @@ -54,11 +58,8 @@ impl ProxyAddCommand { impl CallableTrait for ProxyAddCommand { fn call(&self) -> Result<(), Box> { - let config = build_domain_config( - &self.domain, - self.upstream.as_deref(), - self.ssl.as_deref(), - ); + let config = + build_domain_config(&self.domain, self.upstream.as_deref(), self.ssl.as_deref()); let block = generate_nginx_server_block(&config); println!("{}", block); eprintln!("✓ Proxy entry generated for {}", self.domain); diff --git a/src/console/commands/cli/resolve.rs b/src/console/commands/cli/resolve.rs index 6e3c93a6..5fd968d5 100644 --- a/src/console/commands/cli/resolve.rs +++ b/src/console/commands/cli/resolve.rs @@ -19,7 +19,11 @@ pub struct ResolveCommand { impl ResolveCommand { pub fn new(confirm: bool, force: bool, deployment: Option) -> Self { - Self { confirm, force, deployment } + Self { + confirm, + force, + deployment, + } } } @@ -43,9 +47,8 @@ impl CallableTrait for ResolveCommand { } let config_str = std::fs::read_to_string(&config_path)?; - let config: StackerConfig = serde_yaml::from_str(&config_str).map_err(|e| { - CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)) - })?; + let config: StackerConfig = serde_yaml::from_str(&config_str) + .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; let project_name = config .project diff --git a/src/console/commands/cli/secrets.rs b/src/console/commands/cli/secrets.rs index 5c3e8af6..5f2dcf85 100644 --- a/src/console/commands/cli/secrets.rs +++ b/src/console/commands/cli/secrets.rs @@ -79,7 +79,10 @@ fn resolve_env_path(explicit: Option<&str>) -> PathBuf { for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("env_file:") { - let val = trimmed["env_file:".len()..].trim().trim_matches('"').trim_matches('\''); + let val = trimmed["env_file:".len()..] + .trim() + .trim_matches('"') + .trim_matches('\''); if !val.is_empty() { return PathBuf::from(val); } @@ -168,9 +171,7 @@ impl CallableTrait for SecretsGetCommand { let env_path = resolve_env_path(self.file.as_deref()); if !env_path.exists() { - return Err(Box::new(CliError::EnvFileNotFound { - path: env_path, - })); + return Err(Box::new(CliError::EnvFileNotFound { path: env_path })); } let lines = read_env_lines(&env_path)?; @@ -268,9 +269,7 @@ impl CallableTrait for SecretsDeleteCommand { let env_path = resolve_env_path(self.file.as_deref()); if !env_path.exists() { - return Err(Box::new(CliError::EnvFileNotFound { - path: env_path, - })); + return Err(Box::new(CliError::EnvFileNotFound { path: env_path })); } let lines = read_env_lines(&env_path)?; diff --git a/src/console/commands/cli/service.rs b/src/console/commands/cli/service.rs index 36679c9d..c9507820 100644 --- a/src/console/commands/cli/service.rs +++ b/src/console/commands/cli/service.rs @@ -8,7 +8,7 @@ use std::path::Path; -use crate::cli::config_parser::{StackerConfig, ServiceDefinition}; +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; use crate::cli::credentials::CredentialsManager; use crate::cli::error::CliError; use crate::cli::service_catalog::ServiceCatalog; @@ -93,7 +93,9 @@ impl CallableTrait for ServiceAddCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; let entry = rt.block_on(catalog.resolve(&canonical))?; @@ -119,8 +121,9 @@ impl CallableTrait for ServiceAddCommand { config.services.push(entry.service.clone()); // Serialize back to YAML - let yaml = serde_yaml::to_string(&config) - .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; // Backup and write let backup_path = format!("{}.bak", config_path); @@ -136,11 +139,21 @@ impl CallableTrait for ServiceAddCommand { println!(" Volumes: {}", entry.service.volumes.join(", ")); } if !entry.service.environment.is_empty() { - println!(" Env vars: {}", entry.service.environment.keys() - .cloned().collect::>().join(", ")); + println!( + " Env vars: {}", + entry + .service + .environment + .keys() + .cloned() + .collect::>() + .join(", ") + ); } if !entry.related.is_empty() { - let missing_related: Vec<&str> = entry.related.iter() + let missing_related: Vec<&str> = entry + .related + .iter() .filter(|r| !config.services.iter().any(|s| &s.name == *r)) .map(|r| r.as_str()) .collect(); @@ -183,7 +196,8 @@ impl CallableTrait for ServiceListCommand { let entries = catalog.list_available(); // Group by category - let mut by_category: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut by_category: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); for entry in &entries { by_category .entry(entry.category.clone()) @@ -216,7 +230,12 @@ impl CallableTrait for ServiceListCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!( + "Failed to create async runtime: {}", + e + )) + })?; match rt.block_on(client.list_marketplace_templates(None, None)) { Ok(templates) if templates.is_empty() => { @@ -285,10 +304,7 @@ impl CallableTrait for ServiceRemoveCommand { } let confirmed = Confirm::new() - .with_prompt(format!( - "Remove '{}' from {}?", - canonical, config_path - )) + .with_prompt(format!("Remove '{}' from {}?", canonical, config_path)) .default(false) .interact() .map_err(|e| CliError::ConfigValidation(format!("Prompt error: {}", e)))?; @@ -300,8 +316,9 @@ impl CallableTrait for ServiceRemoveCommand { config.services.retain(|s| s.name != canonical); - let yaml = serde_yaml::to_string(&config) - .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; let backup_path = format!("{}.bak", config_path); std::fs::copy(config_path, &backup_path)?; diff --git a/src/console/commands/cli/ssh_key.rs b/src/console/commands/cli/ssh_key.rs index 2c2c6b25..09ea2387 100644 --- a/src/console/commands/cli/ssh_key.rs +++ b/src/console/commands/cli/ssh_key.rs @@ -42,7 +42,9 @@ impl CallableTrait for SshKeyGenerateCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -109,7 +111,9 @@ impl CallableTrait for SshKeyShowCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -162,16 +166,18 @@ impl CallableTrait for SshKeyUploadCommand { let priv_path = self.private_key.clone(); // Read key files - let public_key = std::fs::read_to_string(&pub_path) - .map_err(|e| CliError::Io(std::io::Error::new( + let public_key = std::fs::read_to_string(&pub_path).map_err(|e| { + CliError::Io(std::io::Error::new( e.kind(), format!("Failed to read public key {}: {}", pub_path.display(), e), - )))?; - let private_key = std::fs::read_to_string(&priv_path) - .map_err(|e| CliError::Io(std::io::Error::new( + )) + })?; + let private_key = std::fs::read_to_string(&priv_path).map_err(|e| { + CliError::Io(std::io::Error::new( e.kind(), format!("Failed to read private key {}: {}", priv_path.display(), e), - )))?; + )) + })?; let cred_manager = CredentialsManager::with_default_store(); let creds = cred_manager.require_valid_token("ssh-key upload")?; @@ -180,7 +186,9 @@ impl CallableTrait for SshKeyUploadCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -224,13 +232,13 @@ pub struct SshKeyInjectCommand { } impl SshKeyInjectCommand { - pub fn new( - server_id: i32, - with_key: PathBuf, - user: Option, - port: Option, - ) -> Self { - Self { server_id, with_key, user, port } + pub fn new(server_id: i32, with_key: PathBuf, user: Option, port: Option) -> Self { + Self { + server_id, + with_key, + user, + port, + } } } @@ -242,11 +250,12 @@ impl CallableTrait for SshKeyInjectCommand { let override_port = self.port; // Read the local working private key - let local_private_key = std::fs::read_to_string(&key_path) - .map_err(|e| CliError::Io(std::io::Error::new( + let local_private_key = std::fs::read_to_string(&key_path).map_err(|e| { + CliError::Io(std::io::Error::new( e.kind(), format!("Failed to read key file {}: {}", key_path.display(), e), - )))?; + )) + })?; let cred_manager = CredentialsManager::with_default_store(); let creds = cred_manager.require_valid_token("ssh-key inject")?; @@ -255,7 +264,9 @@ impl CallableTrait for SshKeyInjectCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; + .map_err(|e| { + CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) + })?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -265,21 +276,23 @@ impl CallableTrait for SshKeyInjectCommand { let server_info = servers .into_iter() .find(|s| s.id == server_id) - .ok_or_else(|| CliError::ConfigValidation( - format!("Server {} not found", server_id) - ))?; + .ok_or_else(|| { + CliError::ConfigValidation(format!("Server {} not found", server_id)) + })?; let host = server_info .srv_ip .as_deref() .filter(|ip| !ip.is_empty()) - .ok_or_else(|| CliError::ConfigValidation( - format!("Server {} has no IP address — deploy it first", server_id) - ))? + .ok_or_else(|| { + CliError::ConfigValidation(format!( + "Server {} has no IP address — deploy it first", + server_id + )) + })? .to_string(); - let port = override_port - .unwrap_or_else(|| server_info.ssh_port.unwrap_or(22) as u16); + let port = override_port.unwrap_or_else(|| server_info.ssh_port.unwrap_or(22) as u16); let user = override_user .or_else(|| server_info.ssh_user.clone()) .unwrap_or_else(|| "root".to_string()); @@ -290,11 +303,21 @@ impl CallableTrait for SshKeyInjectCommand { println!("Server: {} (ID {})", host, server_id); println!("SSH user: {} port: {}", user, port); - println!("Vault key: {}", &vault_public_key[..vault_public_key.len().min(60)]); + println!( + "Vault key: {}", + &vault_public_key[..vault_public_key.len().min(60)] + ); println!(); println!("Connecting to inject key into authorized_keys…"); - inject_key_via_ssh(&host, port, &user, local_private_key.trim(), &vault_public_key).await + inject_key_via_ssh( + &host, + port, + &user, + local_private_key.trim(), + &vault_public_key, + ) + .await }) } } @@ -308,9 +331,9 @@ async fn inject_key_via_ssh( local_private_key: &str, vault_public_key: &str, ) -> Result<(), Box> { + use russh::client::{Config, Handle}; use std::sync::Arc; use std::time::Duration; - use russh::client::{Config, Handle}; struct AcceptAllKeys; @@ -332,19 +355,25 @@ async fn inject_key_via_ssh( }); let addr = format!("{}:{}", host, port); - let mut handle: Handle = - tokio::time::timeout(Duration::from_secs(4), russh::client::connect(config, addr, AcceptAllKeys)) - .await - .map_err(|_| CliError::ConfigValidation(format!("Connection to {}:{} timed out", host, port)))? - .map_err(|e| CliError::ConfigValidation(format!("Connection failed: {}", e)))?; + let mut handle: Handle = tokio::time::timeout( + Duration::from_secs(4), + russh::client::connect(config, addr, AcceptAllKeys), + ) + .await + .map_err(|_| CliError::ConfigValidation(format!("Connection to {}:{} timed out", host, port)))? + .map_err(|e| CliError::ConfigValidation(format!("Connection failed: {}", e)))?; let auth_res = handle .authenticate_publickey( username, russh::keys::key::PrivateKeyWithHashAlg::new( Arc::new(key), - handle.best_supported_rsa_hash().await - .map_err(|e| CliError::ConfigValidation(format!("RSA hash negotiation failed: {}", e)))? + handle + .best_supported_rsa_hash() + .await + .map_err(|e| { + CliError::ConfigValidation(format!("RSA hash negotiation failed: {}", e)) + })? .flatten(), ), ) @@ -365,9 +394,13 @@ async fn inject_key_via_ssh( safe_key, safe_key ); - let mut channel = handle.channel_open_session().await + let mut channel = handle + .channel_open_session() + .await .map_err(|e| CliError::ConfigValidation(format!("Failed to open SSH channel: {}", e)))?; - channel.exec(true, cmd).await + channel + .exec(true, cmd) + .await .map_err(|e| CliError::ConfigValidation(format!("Failed to exec command: {}", e)))?; // Drain channel output @@ -376,9 +409,10 @@ async fn inject_key_via_ssh( Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, Some(russh::ChannelMsg::ExitStatus { exit_status }) => { if exit_status != 0 { - return Err(Box::new(CliError::ConfigValidation( - format!("Remote command exited with status {}", exit_status), - ))); + return Err(Box::new(CliError::ConfigValidation(format!( + "Remote command exited with status {}", + exit_status + )))); } break; } @@ -387,9 +421,14 @@ async fn inject_key_via_ssh( } let _ = channel.eof().await; - let _ = handle.disconnect(russh::Disconnect::ByApplication, "", "English").await; + let _ = handle + .disconnect(russh::Disconnect::ByApplication, "", "English") + .await; - println!("✓ Vault public key injected into {}@{}:{} authorized_keys", username, host, port); + println!( + "✓ Vault public key injected into {}@{}:{} authorized_keys", + username, host, port + ); println!(); println!("You can now run: stacker deploy"); diff --git a/src/console/commands/cli/status.rs b/src/console/commands/cli/status.rs index 915bf49c..9c725bcc 100644 --- a/src/console/commands/cli/status.rs +++ b/src/console/commands/cli/status.rs @@ -72,13 +72,7 @@ pub fn run_status( // ── Cloud deployment status ───────────────────────── /// Terminal statuses — once reached, `--watch` stops polling. -const TERMINAL_STATUSES: &[&str] = &[ - "completed", - "failed", - "cancelled", - "error", - "paused", -]; +const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "cancelled", "error", "paused"]; /// Check if a status is terminal (deployment finished or failed). fn is_terminal(status: &str) -> bool { @@ -92,11 +86,7 @@ struct StatusContext<'a> { } /// Pretty-print a deployment status with optional server/config context. -fn print_deployment_status_rich( - info: &DeploymentStatusInfo, - json: bool, - ctx: &StatusContext<'_>, -) { +fn print_deployment_status_rich(info: &DeploymentStatusInfo, json: bool, ctx: &StatusContext<'_>) { if json { if let Ok(j) = serde_json::to_string_pretty(info) { println!("{}", j); @@ -241,9 +231,8 @@ fn run_cloud_status(json: bool, watch: bool) -> Result<(), Box Result<(), Box Result { - Ok(CommandOutput { exit_code: 0, stdout: String::new(), stderr: String::new() }) + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) } } diff --git a/src/console/commands/cli/submit.rs b/src/console/commands/cli/submit.rs index 0d94e5ee..e9f78a04 100644 --- a/src/console/commands/cli/submit.rs +++ b/src/console/commands/cli/submit.rs @@ -76,7 +76,13 @@ impl CallableTrait for SubmitCommand { let slug = name .to_lowercase() .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) .collect::() .split('-') .filter(|s| !s.is_empty()) @@ -126,10 +132,7 @@ impl CallableTrait for SubmitCommand { // Success message println!(); - println!( - "Submitted '{}' v{} for marketplace review.", - name, version - ); + println!("Submitted '{}' v{} for marketplace review.", name, version); println!( "Your stack will be published automatically once accepted by the review team." ); diff --git a/src/console/commands/mq/listener.rs b/src/console/commands/mq/listener.rs index d77c225b..e056f78f 100644 --- a/src/console/commands/mq/listener.rs +++ b/src/console/commands/mq/listener.rs @@ -271,10 +271,10 @@ impl ListenCommand { async fn connect_with_retry(connection_string: &str) -> Result { let max_retries = 10; let mut retry_delay = Duration::from_secs(1); - + for attempt in 1..=max_retries { println!("RabbitMQ connection attempt {}/{}", attempt, max_retries); - + match MqManager::try_new(connection_string.to_string()) { Ok(manager) => { println!("Connected to RabbitMQ"); @@ -289,7 +289,7 @@ impl ListenCommand { } } } - + Err(format!("Failed to connect after {} attempts", max_retries)) } } diff --git a/src/console/main.rs b/src/console/main.rs index ce2147f6..a0c117ae 100644 --- a/src/console/main.rs +++ b/src/console/main.rs @@ -294,7 +294,9 @@ fn main() -> Result<(), Box> { get_command(command)?.call() } -fn get_command(command: Commands) -> Result, String> { +fn get_command( + command: Commands, +) -> Result, String> { match command { Commands::AppClient { command } => match command { AppClientCommands::New { user_id } => Ok(Box::new( diff --git a/src/db/agent_audit_log.rs b/src/db/agent_audit_log.rs index 1eac7c04..7c4a81e4 100644 --- a/src/db/agent_audit_log.rs +++ b/src/db/agent_audit_log.rs @@ -19,7 +19,10 @@ pub async fn insert_batch( let span = tracing::info_span!("Inserting audit events into database"); for event in events { - let created_at = Utc.timestamp_opt(event.created_at, 0).single().unwrap_or_else(Utc::now); + let created_at = Utc + .timestamp_opt(event.created_at, 0) + .single() + .unwrap_or_else(Utc::now); sqlx::query_as::<_, AgentAuditLog>( r#" diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index 0b85346f..5b03010a 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -709,7 +709,8 @@ pub async fn admin_unapprove( reviewer_user_id: &str, reason: Option<&str>, ) -> Result { - let _query_span = tracing::info_span!("marketplace_admin_unapprove", template_id = %template_id); + let _query_span = + tracing::info_span!("marketplace_admin_unapprove", template_id = %template_id); let mut tx = pool.begin().await.map_err(|e| { tracing::error!("tx begin error: {:?}", e); diff --git a/src/db/project_app.rs b/src/db/project_app.rs index e17e535e..59d16bfc 100644 --- a/src/db/project_app.rs +++ b/src/db/project_app.rs @@ -255,18 +255,16 @@ pub async fn delete_by_project_and_code( code: &str, ) -> Result { let query_span = tracing::info_span!("Deleting app by project and code"); - let result = sqlx::query( - "DELETE FROM project_app WHERE project_id = $1 AND code = $2", - ) - .bind(project_id) - .bind(code) - .execute(pool) - .instrument(query_span) - .await - .map_err(|e| { - tracing::error!("Failed to delete app by project and code: {:?}", e); - format!("Failed to delete app: {}", e) - })?; + let result = sqlx::query("DELETE FROM project_app WHERE project_id = $1 AND code = $2") + .bind(project_id) + .bind(code) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to delete app by project and code: {:?}", e); + format!("Failed to delete app: {}", e) + })?; Ok(result.rows_affected() > 0) } diff --git a/src/forms/project/port.rs b/src/forms/project/port.rs index dcad7d15..27cbe30d 100644 --- a/src/forms/project/port.rs +++ b/src/forms/project/port.rs @@ -162,7 +162,9 @@ mod tests { }; let result: Result = (&port).try_into(); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Could not parse container port")); + assert!(result + .unwrap_err() + .contains("Could not parse container port")); } #[test] diff --git a/src/forms/project/volume.rs b/src/forms/project/volume.rs index cc684efe..28990304 100644 --- a/src/forms/project/volume.rs +++ b/src/forms/project/volume.rs @@ -88,7 +88,10 @@ impl Volume { }; } - tracing::debug!("Bind mount volume '{}' — adding driver_opts with base dir", host_path); + tracing::debug!( + "Bind mount volume '{}' — adding driver_opts with base dir", + host_path + ); let default_base = std::env::var("DEFAULT_DEPLOY_DIR").unwrap_or_else(|_| "/home/trydirect".to_string()); @@ -106,9 +109,7 @@ impl Volume { ); // Normalize to avoid duplicate slashes in bind-mount device paths. - let normalized_host = host_path - .trim_start_matches("./") - .trim_start_matches('/'); + let normalized_host = host_path.trim_start_matches("./").trim_start_matches('/'); let path = format!("{}/{}", base.trim_end_matches('/'), normalized_host); driver_opts.insert( String::from("device"), @@ -152,14 +153,16 @@ mod tests { }; let compose = volume.to_compose_volume(Some("/srv/trydirect")); - let device = compose - .driver_opts - .get("device") - .and_then(|v| v.as_ref()); + let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); assert_eq!(compose.driver.as_deref(), Some("local")); assert_eq!(compose.name.as_deref(), Some("projects/app")); - assert_eq!(device, Some(&SingleValue::String("/srv/trydirect/projects/app".to_string()))); + assert_eq!( + device, + Some(&SingleValue::String( + "/srv/trydirect/projects/app".to_string() + )) + ); } #[test] @@ -170,14 +173,14 @@ mod tests { }; let compose = volume.to_compose_volume(Some("/srv/trydirect")); - let device = compose - .driver_opts - .get("device") - .and_then(|v| v.as_ref()); + let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); assert!(!volume.is_named_docker_volume()); assert_eq!(compose.driver.as_deref(), Some("local")); - assert_eq!(device, Some(&SingleValue::String("/srv/trydirect/data".to_string()))); + assert_eq!( + device, + Some(&SingleValue::String("/srv/trydirect/data".to_string())) + ); } #[test] @@ -188,14 +191,14 @@ mod tests { }; let compose = volume.to_compose_volume(Some("/srv/trydirect")); - let device = compose - .driver_opts - .get("device") - .and_then(|v| v.as_ref()); + let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); assert!(!volume.is_named_docker_volume()); assert_eq!(compose.driver.as_deref(), Some("local")); - assert_eq!(device, Some(&SingleValue::String("/srv/trydirect/data".to_string()))); + assert_eq!( + device, + Some(&SingleValue::String("/srv/trydirect/data".to_string())) + ); } #[test] diff --git a/src/forms/status_panel.rs b/src/forms/status_panel.rs index c0ac7235..64aaa178 100644 --- a/src/forms/status_panel.rs +++ b/src/forms/status_panel.rs @@ -488,14 +488,16 @@ pub fn validate_command_parameters( } // Validate port rules - for rule in params.public_ports.iter().chain(params.private_ports.iter()) { + for rule in params + .public_ports + .iter() + .chain(params.private_ports.iter()) + { if rule.port == 0 { return Err("configure_firewall: port must be > 0".to_string()); } if !["tcp", "udp"].contains(&rule.protocol.as_str()) { - return Err( - "configure_firewall: protocol must be one of: tcp, udp".to_string(), - ); + return Err("configure_firewall: protocol must be one of: tcp, udp".to_string()); } } @@ -627,7 +629,9 @@ pub fn validate_command_result( .map_err(|err| format!("Invalid configure_firewall result: {}", err))?; if report.command_type != "configure_firewall" { - return Err("configure_firewall result must include type='configure_firewall'".to_string()); + return Err( + "configure_firewall result must include type='configure_firewall'".to_string(), + ); } if report.deployment_hash != deployment_hash { return Err("configure_firewall result deployment_hash mismatch".to_string()); @@ -645,7 +649,9 @@ pub fn validate_command_result( .map_err(|err| format!("Invalid probe_endpoints result: {}", err))?; if report.command_type != "probe_endpoints" { - return Err("probe_endpoints result must include type='probe_endpoints'".to_string()); + return Err( + "probe_endpoints result must include type='probe_endpoints'".to_string() + ); } if report.deployment_hash != deployment_hash { return Err("probe_endpoints result deployment_hash mismatch".to_string()); @@ -1102,11 +1108,9 @@ mod tests { #[test] fn check_connections_accepts_null_ports() { - let result = validate_command_parameters( - "check_connections", - &Some(json!({ "ports": null })), - ) - .expect("check_connections with null ports should validate"); + let result = + validate_command_parameters("check_connections", &Some(json!({ "ports": null }))) + .expect("check_connections with null ports should validate"); assert!(result.is_some()); } } diff --git a/src/helpers/security_validator.rs b/src/helpers/security_validator.rs index dcaef6a7..0ca27e15 100644 --- a/src/helpers/security_validator.rs +++ b/src/helpers/security_validator.rs @@ -37,53 +37,147 @@ impl SecurityReport { /// Patterns that indicate hardcoded secrets in environment variables or configs const SECRET_PATTERNS: &[(&str, &str)] = &[ - (r"(?i)(aws_secret_access_key|aws_access_key_id)\s*[:=]\s*[A-Za-z0-9/+=]{20,}", "AWS credentials"), - (r"(?i)(api[_-]?key|apikey)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", "API key"), - (r"(?i)(secret[_-]?key|secret_token)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", "Secret key/token"), + ( + r"(?i)(aws_secret_access_key|aws_access_key_id)\s*[:=]\s*[A-Za-z0-9/+=]{20,}", + "AWS credentials", + ), + ( + r"(?i)(api[_-]?key|apikey)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", + "API key", + ), + ( + r"(?i)(secret[_-]?key|secret_token)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", + "Secret key/token", + ), (r"(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}", "Bearer token"), - (r"(?i)(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}", "GitHub token"), + ( + r"(?i)(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}", + "GitHub token", + ), (r"(?i)sk-[A-Za-z0-9]{20,}", "OpenAI/Stripe secret key"), - (r"(?i)(-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----)", "Private key"), + ( + r"(?i)(-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----)", + "Private key", + ), (r"(?i)AKIA[0-9A-Z]{16}", "AWS Access Key ID"), (r"(?i)(slack[_-]?token|xox[bpas]-)", "Slack token"), - (r"(?i)(database_url|db_url)\s*[:=]\s*\S*:[^${\s]{8,}", "Database URL with credentials"), + ( + r"(?i)(database_url|db_url)\s*[:=]\s*\S*:[^${\s]{8,}", + "Database URL with credentials", + ), ]; /// Patterns for hardcoded credentials (passwords, default creds) const CRED_PATTERNS: &[(&str, &str)] = &[ - (r#"(?i)(password|passwd|pwd)\s*[:=]\s*['"]?(?!(\$\{|\$\(|changeme|CHANGE_ME|your_password|example))[A-Za-z0-9!@#$%^&*]{6,}['"]?"#, "Hardcoded password"), - (r#"(?i)(mysql_root_password|postgres_password|mongo_initdb_root_password)\s*[:=]\s*['"]?(?!(\$\{|\$\())[^\s'"$]{4,}"#, "Hardcoded database password"), - (r"(?i)root:(?!(\$\{|\$\())[^\s:$]{4,}", "Root password in plain text"), + ( + r#"(?i)(password|passwd|pwd)\s*[:=]\s*['"]?(?!(\$\{|\$\(|changeme|CHANGE_ME|your_password|example))[A-Za-z0-9!@#$%^&*]{6,}['"]?"#, + "Hardcoded password", + ), + ( + r#"(?i)(mysql_root_password|postgres_password|mongo_initdb_root_password)\s*[:=]\s*['"]?(?!(\$\{|\$\())[^\s'"$]{4,}"#, + "Hardcoded database password", + ), + ( + r"(?i)root:(?!(\$\{|\$\())[^\s:$]{4,}", + "Root password in plain text", + ), ]; /// Patterns indicating potentially malicious or dangerous configurations const MALICIOUS_PATTERNS: &[(&str, &str, &str)] = &[ - (r"(?i)privileged\s*:\s*true", "critical", "Container running in privileged mode"), - (r#"(?i)network_mode\s*:\s*['"]?host"#, "warning", "Container using host network"), - (r#"(?i)pid\s*:\s*['"]?host"#, "critical", "Container sharing host PID namespace"), - (r#"(?i)ipc\s*:\s*['"]?host"#, "critical", "Container sharing host IPC namespace"), - (r"(?i)cap_add\s*:.*SYS_ADMIN", "critical", "Container with SYS_ADMIN capability"), - (r"(?i)cap_add\s*:.*SYS_PTRACE", "warning", "Container with SYS_PTRACE capability"), - (r"(?i)cap_add\s*:.*ALL", "critical", "Container with ALL capabilities"), - (r"(?i)/var/run/docker\.sock", "critical", "Docker socket mounted (container escape risk)"), - (r"(?i)volumes\s*:.*:/host", "warning", "Suspicious host filesystem mount"), - (r"(?i)volumes\s*:.*:/etc(/|\s|$)", "warning", "Host /etc directory mounted"), - (r"(?i)volumes\s*:.*:/root", "critical", "Host /root directory mounted"), - (r"(?i)volumes\s*:.*:/proc", "critical", "Host /proc directory mounted"), - (r"(?i)volumes\s*:.*:/sys", "critical", "Host /sys directory mounted"), - (r"(?i)curl\s+.*\|\s*(sh|bash)", "warning", "Remote script execution via curl pipe"), - (r"(?i)wget\s+.*\|\s*(sh|bash)", "warning", "Remote script execution via wget pipe"), + ( + r"(?i)privileged\s*:\s*true", + "critical", + "Container running in privileged mode", + ), + ( + r#"(?i)network_mode\s*:\s*['"]?host"#, + "warning", + "Container using host network", + ), + ( + r#"(?i)pid\s*:\s*['"]?host"#, + "critical", + "Container sharing host PID namespace", + ), + ( + r#"(?i)ipc\s*:\s*['"]?host"#, + "critical", + "Container sharing host IPC namespace", + ), + ( + r"(?i)cap_add\s*:.*SYS_ADMIN", + "critical", + "Container with SYS_ADMIN capability", + ), + ( + r"(?i)cap_add\s*:.*SYS_PTRACE", + "warning", + "Container with SYS_PTRACE capability", + ), + ( + r"(?i)cap_add\s*:.*ALL", + "critical", + "Container with ALL capabilities", + ), + ( + r"(?i)/var/run/docker\.sock", + "critical", + "Docker socket mounted (container escape risk)", + ), + ( + r"(?i)volumes\s*:.*:/host", + "warning", + "Suspicious host filesystem mount", + ), + ( + r"(?i)volumes\s*:.*:/etc(/|\s|$)", + "warning", + "Host /etc directory mounted", + ), + ( + r"(?i)volumes\s*:.*:/root", + "critical", + "Host /root directory mounted", + ), + ( + r"(?i)volumes\s*:.*:/proc", + "critical", + "Host /proc directory mounted", + ), + ( + r"(?i)volumes\s*:.*:/sys", + "critical", + "Host /sys directory mounted", + ), + ( + r"(?i)curl\s+.*\|\s*(sh|bash)", + "warning", + "Remote script execution via curl pipe", + ), + ( + r"(?i)wget\s+.*\|\s*(sh|bash)", + "warning", + "Remote script execution via wget pipe", + ), ]; /// Known suspicious Docker images #[allow(dead_code)] const SUSPICIOUS_IMAGES: &[&str] = &[ - "alpine:latest", // not suspicious per se, but discouraged for reproducibility + "alpine:latest", // not suspicious per se, but discouraged for reproducibility ]; const KNOWN_CRYPTO_MINER_PATTERNS: &[&str] = &[ - "xmrig", "cpuminer", "cryptonight", "stratum+tcp", "minerd", "hashrate", - "monero", "coinhive", "coin-hive", + "xmrig", + "cpuminer", + "cryptonight", + "stratum+tcp", + "minerd", + "hashrate", + "monero", + "coinhive", + "coin-hive", ]; /// Normalize a JSON-pretty-printed string into a YAML-like format so that @@ -150,19 +244,29 @@ pub fn validate_stack_security(stack_definition: &Value) -> SecurityReport { let mut recommendations = Vec::new(); if !no_secrets.passed { - recommendations.push("Replace hardcoded secrets with environment variable references (e.g., ${SECRET_KEY})".to_string()); + recommendations.push( + "Replace hardcoded secrets with environment variable references (e.g., ${SECRET_KEY})" + .to_string(), + ); } if !no_hardcoded_creds.passed { - recommendations.push("Use Docker secrets or environment variable references for passwords".to_string()); + recommendations.push( + "Use Docker secrets or environment variable references for passwords".to_string(), + ); } if !valid_docker_syntax.passed { - recommendations.push("Fix Docker Compose syntax issues to ensure deployability".to_string()); + recommendations + .push("Fix Docker Compose syntax issues to ensure deployability".to_string()); } if !no_malicious_code.passed { - recommendations.push("Review and remove dangerous container configurations (privileged mode, host mounts)".to_string()); + recommendations.push( + "Review and remove dangerous container configurations (privileged mode, host mounts)" + .to_string(), + ); } if risk_score == 0 { - recommendations.push("Automated scan passed. AI review recommended for deeper analysis.".to_string()); + recommendations + .push("Automated scan passed. AI review recommended for deeper analysis.".to_string()); } SecurityReport { @@ -204,7 +308,10 @@ fn check_no_secrets(content: &str) -> SecurityCheckResult { message: if findings.is_empty() { "No exposed secrets detected".to_string() } else { - format!("Found {} potential secret(s) in stack definition", findings.len()) + format!( + "Found {} potential secret(s) in stack definition", + findings.len() + ) }, details: findings, } @@ -216,10 +323,7 @@ fn check_no_hardcoded_creds(content: &str) -> SecurityCheckResult { for (pattern, description) in CRED_PATTERNS { if let Ok(re) = Regex::new(pattern) { for mat in re.find_iter(content) { - let line = content[..mat.start()] - .lines() - .count() - + 1; + let line = content[..mat.start()].lines().count() + 1; findings.push(format!("[WARNING] {} near line {}", description, line)); } } @@ -249,10 +353,7 @@ fn check_no_hardcoded_creds(content: &str) -> SecurityCheckResult { message: if findings.is_empty() { "No hardcoded credentials detected".to_string() } else { - format!( - "Found {} potential hardcoded credential(s)", - findings.len() - ) + format!("Found {} potential hardcoded credential(s)", findings.len()) }, details: findings, } @@ -262,16 +363,16 @@ fn check_valid_docker_syntax(stack_definition: &Value, raw_content: &str) -> Sec let mut findings = Vec::new(); // Check if it looks like valid docker-compose structure - let has_services = stack_definition.get("services").is_some() - || raw_content.contains("services:"); + let has_services = + stack_definition.get("services").is_some() || raw_content.contains("services:"); if !has_services { - findings.push("[WARNING] Missing 'services' key — may not be valid Docker Compose".to_string()); + findings + .push("[WARNING] Missing 'services' key — may not be valid Docker Compose".to_string()); } // Check for 'version' key (optional in modern compose but common) - let has_version = stack_definition.get("version").is_some() - || raw_content.contains("version:"); + let has_version = stack_definition.get("version").is_some() || raw_content.contains("version:"); // Check that services have images or build contexts if let Some(services) = stack_definition.get("services") { @@ -303,7 +404,10 @@ fn check_valid_docker_syntax(stack_definition: &Value, raw_content: &str) -> Sec } } - let errors_only: Vec<&String> = findings.iter().filter(|f| f.contains("[WARNING]")).collect(); + let errors_only: Vec<&String> = findings + .iter() + .filter(|f| f.contains("[WARNING]")) + .collect(); SecurityCheckResult { passed: errors_only.is_empty(), @@ -351,14 +455,20 @@ fn check_no_malicious_code(content: &str) -> SecurityCheckResult { // Check for suspicious base64 encoded content (long base64 strings could hide payloads) if let Ok(re) = Regex::new(r"[A-Za-z0-9+/]{100,}={0,2}") { if re.is_match(content) { - findings.push("[WARNING] Long base64-encoded content detected — may contain hidden payload".to_string()); + findings.push( + "[WARNING] Long base64-encoded content detected — may contain hidden payload" + .to_string(), + ); } } // Check for outbound network calls in entrypoints/commands if let Ok(re) = Regex::new(r"(?i)(curl|wget|nc|ncat)\s+.*(http|ftp|tcp)") { if re.is_match(content) { - findings.push("[INFO] Outbound network call detected in command/entrypoint — review if expected".to_string()); + findings.push( + "[INFO] Outbound network call detected in command/entrypoint — review if expected" + .to_string(), + ); } } diff --git a/src/helpers/ssh_client.rs b/src/helpers/ssh_client.rs index a582a672..d07b6fe3 100644 --- a/src/helpers/ssh_client.rs +++ b/src/helpers/ssh_client.rs @@ -159,8 +159,11 @@ pub async fn check_server( let addr = format!("{}:{}", host, port); tracing::info!("Connecting to {} as {}", addr, username); - let connection_result = - timeout(connection_timeout, connect_and_auth(config, &addr, username, key)).await; + let connection_result = timeout( + connection_timeout, + connect_and_auth(config, &addr, username, key), + ) + .await; match connection_result { Ok(Ok(handle)) => { @@ -174,7 +177,10 @@ pub async fn check_server( Ok(Err(e)) => { tracing::warn!("SSH connection/auth failed: {}", e); let error_str = e.to_string().to_lowercase(); - if error_str.contains("auth") || error_str.contains("key") || error_str.contains("permission") { + if error_str.contains("auth") + || error_str.contains("key") + || error_str.contains("permission") + { result.connected = true; result.error = Some(format!("Authentication failed: {}", e)); } else { @@ -317,19 +323,25 @@ fn parse_disk_info(result: &mut SystemCheckResult, output: &str) { let parts: Vec<&str> = output.split_whitespace().collect(); if parts.len() >= 4 { // Parse total (index 1) - if let Some(total) = parts.get(1).and_then(|s| s.trim_end_matches('G').parse::().ok()) + if let Some(total) = parts + .get(1) + .and_then(|s| s.trim_end_matches('G').parse::().ok()) { result.disk_total_gb = Some(total); } // Parse available (index 3) - if let Some(avail) = parts.get(3).and_then(|s| s.trim_end_matches('G').parse::().ok()) + if let Some(avail) = parts + .get(3) + .and_then(|s| s.trim_end_matches('G').parse::().ok()) { result.disk_available_gb = Some(avail); } // Parse usage percentage (index 4) - if let Some(usage) = parts.get(4).and_then(|s| s.trim_end_matches('%').parse::().ok()) + if let Some(usage) = parts + .get(4) + .and_then(|s| s.trim_end_matches('%').parse::().ok()) { result.disk_usage_percent = Some(usage); } diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index 895cf011..fd6fdb3a 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -9,23 +9,24 @@ use std::sync::Arc; use super::protocol::{Tool, ToolContent}; use crate::mcp::tools::{ + AddAppToDeploymentTool, AddCloudTool, AdminApproveTemplateTool, AdminGetTemplateDetailTool, AdminListSubmittedTemplatesTool, AdminListTemplateReviewsTool, AdminListTemplateVersionsTool, + AdminRejectTemplateTool, AdminValidateTemplateSecurityTool, ApplyVaultConfigTool, - AddAppToDeploymentTool, CancelDeploymentTool, CloneProjectTool, - ConfigureProxyTool, - // Agent Control tools - ConfigureProxyAgentTool, + ConfigureFirewallFromRoleTool, // Firewall tools ConfigureFirewallTool, - ConfigureFirewallFromRoleTool, + // Agent Control tools + ConfigureProxyAgentTool, + ConfigureProxyTool, CreateProjectAppTool, CreateProjectTool, DeleteAppEnvVarTool, @@ -35,15 +36,13 @@ use crate::mcp::tools::{ // Ansible Roles tools DeployAppTool, DeployRoleTool, - // Stack Recommendations - RecommendStackServicesTool, - RemoveAppTool, DiagnoseDeploymentTool, DiscoverStackServicesTool, EscalateToSupportTool, - GetAppConfigTool, // Agent Control tools GetAgentStatusTool, + GetAnsibleRoleDefaultsTool, + GetAppConfigTool, // Phase 5: App Configuration tools GetAppEnvVarsTool, GetCloudTool, @@ -65,39 +64,40 @@ use crate::mcp::tools::{ GetUserProfileTool, // Phase 5: Vault Configuration tools GetVaultConfigTool, + InitiateDeploymentTool, ListAvailableRolesTool, - ListCloudsTool, ListCloudImagesTool, ListCloudRegionsTool, ListCloudServerSizesTool, + ListCloudsTool, ListContainersTool, - ListInstallationsTool, ListFirewallRulesTool, - InitiateDeploymentTool, + ListInstallationsTool, ListProjectAppsTool, ListProjectsTool, ListProxiesTool, ListTemplatesTool, ListVaultConfigsTool, - RestartContainerTool, + MarkAllNotificationsReadTool, + MarkNotificationReadTool, + PreviewInstallConfigTool, + // Stack Recommendations + RecommendStackServicesTool, + RemoveAppTool, RenderAnsibleTemplateTool, + RestartContainerTool, SearchApplicationsTool, SearchMarketplaceTemplatesTool, SetAppEnvVarTool, SetVaultConfigTool, - MarkAllNotificationsReadTool, - MarkNotificationReadTool, StartContainerTool, StartDeploymentTool, // Phase 5: Container Operations tools StopContainerTool, - TriggerRedeployTool, - AdminRejectTemplateTool, SuggestResourcesTool, + TriggerRedeployTool, UpdateAppDomainTool, UpdateAppPortsTool, - GetAnsibleRoleDefaultsTool, - PreviewInstallConfigTool, ValidateDomainTool, ValidateRoleVarsTool, // Phase 5: Stack Validation tool @@ -179,10 +179,7 @@ impl ToolRegistry { Box::new(SearchMarketplaceTemplatesTool), ); registry.register("get_notifications", Box::new(GetNotificationsTool)); - registry.register( - "mark_notification_read", - Box::new(MarkNotificationReadTool), - ); + registry.register("mark_notification_read", Box::new(MarkNotificationReadTool)); registry.register( "mark_all_notifications_read", Box::new(MarkAllNotificationsReadTool), @@ -268,14 +265,8 @@ impl ToolRegistry { "admin_get_template_detail", Box::new(AdminGetTemplateDetailTool), ); - registry.register( - "admin_approve_template", - Box::new(AdminApproveTemplateTool), - ); - registry.register( - "admin_reject_template", - Box::new(AdminRejectTemplateTool), - ); + registry.register("admin_approve_template", Box::new(AdminApproveTemplateTool)); + registry.register("admin_reject_template", Box::new(AdminRejectTemplateTool)); registry.register( "admin_list_template_versions", Box::new(AdminListTemplateVersionsTool), @@ -292,10 +283,7 @@ impl ToolRegistry { // Ansible Roles tools (SSH deployment method) registry.register("list_available_roles", Box::new(ListAvailableRolesTool)); registry.register("get_role_details", Box::new(GetRoleDetailsTool)); - registry.register( - "get_role_requirements", - Box::new(GetRoleRequirementsTool), - ); + registry.register("get_role_requirements", Box::new(GetRoleRequirementsTool)); registry.register("validate_role_vars", Box::new(ValidateRoleVarsTool)); registry.register("deploy_role", Box::new(DeployRoleTool)); diff --git a/src/mcp/tools/agent_control.rs b/src/mcp/tools/agent_control.rs index 33a2da60..ab9dfbc3 100644 --- a/src/mcp/tools/agent_control.rs +++ b/src/mcp/tools/agent_control.rs @@ -37,7 +37,11 @@ async fn wait_for_command_result( if let Some(cmd) = fetched { let status = cmd.status.to_lowercase(); - if status == "completed" || status == "failed" || cmd.result.is_some() || cmd.error.is_some() { + if status == "completed" + || status == "failed" + || cmd.result.is_some() + || cmd.error.is_some() + { return Ok(Some(cmd)); } } @@ -85,7 +89,9 @@ async fn enqueue_and_wait( .await .map_err(|e| format!("Failed to queue command: {}", e))?; - if let Some(cmd) = wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? { + if let Some(cmd) = + wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? + { let status = cmd.status.to_lowercase(); Ok(json!({ "status": status, @@ -312,8 +318,12 @@ impl ToolHandler for ConfigureProxyAgentTool { action: String, } - fn default_true() -> bool { true } - fn default_create() -> String { "create".to_string() } + fn default_true() -> bool { + true + } + fn default_create() -> String { + "create".to_string() + } let params: Args = serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; diff --git a/src/mcp/tools/ansible_roles.rs b/src/mcp/tools/ansible_roles.rs index c3ab987a..959dadb8 100644 --- a/src/mcp/tools/ansible_roles.rs +++ b/src/mcp/tools/ansible_roles.rs @@ -49,11 +49,17 @@ pub struct RoleVariable { async fn fetch_roles_from_db(context: &ToolContext) -> Result, String> { let user_service_url = &context.settings.user_service_url; let endpoint = format!("{}{}", user_service_url, POSTGREST_ROLE_ENDPOINT); - + let client = reqwest::Client::new(); let response = client .get(&endpoint) - .header("Authorization", format!("Bearer {}", context.user.access_token.as_deref().unwrap_or(""))) + .header( + "Authorization", + format!( + "Bearer {}", + context.user.access_token.as_deref().unwrap_or("") + ), + ) .send() .await .map_err(|e| format!("Failed to fetch roles from database: {}", e))?; @@ -93,13 +99,13 @@ async fn fetch_roles_from_db(context: &ToolContext) -> Result, /// Scan filesystem for available roles fn scan_roles_from_filesystem() -> Result, String> { let roles_path = Path::new(ROLES_BASE_PATH); - + if !roles_path.exists() { return Err(format!("Roles directory not found: {}", ROLES_BASE_PATH)); } let mut roles = vec![]; - + if let Ok(entries) = std::fs::read_dir(roles_path) { for entry in entries.flatten() { if let Ok(file_type) = entry.file_type() { @@ -114,7 +120,7 @@ fn scan_roles_from_filesystem() -> Result, String> { } } } - + roles.sort(); Ok(roles) } @@ -122,7 +128,7 @@ fn scan_roles_from_filesystem() -> Result, String> { /// Get detailed information about a specific role from filesystem fn get_role_details_from_fs(role_name: &str) -> Result { let role_path = PathBuf::from(ROLES_BASE_PATH).join(role_name); - + if !role_path.exists() { return Err(format!("Role '{}' not found in filesystem", role_name)); } @@ -134,7 +140,10 @@ fn get_role_details_from_fs(role_name: &str) -> Result { private_ports: vec![], variables: HashMap::new(), dependencies: vec![], - supported_os: vec!["ubuntu", "debian"].into_iter().map(|s| s.to_string()).collect(), // default + supported_os: vec!["ubuntu", "debian"] + .into_iter() + .map(|s| s.to_string()) + .collect(), // default }; // Parse README.md for description @@ -144,11 +153,12 @@ fn get_role_details_from_fs(role_name: &str) -> Result { // Extract first non-empty line after "Role Name" or "Description" for line in content.lines() { let trimmed = line.trim(); - if !trimmed.is_empty() + if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with('=') && !trimmed.starts_with('-') - && trimmed.len() > 10 { + && trimmed.len() > 10 + { role.description = Some(trimmed.to_string()); break; } @@ -187,16 +197,16 @@ fn parse_yaml_variable(line: &str) -> Option<(String, String)> { if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.is_empty() { return None; } - + if let Some(colon_pos) = trimmed.find(':') { let key = trimmed[..colon_pos].trim(); let value = trimmed[colon_pos + 1..].trim(); - + if !key.is_empty() && !value.is_empty() { return Some((key.to_string(), value.to_string())); } } - + None } @@ -213,12 +223,15 @@ impl ToolHandler for ListAvailableRolesTool { db_roles } Err(db_err) => { - tracing::warn!("Database fetch failed ({}), falling back to filesystem", db_err); - + tracing::warn!( + "Database fetch failed ({}), falling back to filesystem", + db_err + ); + // Fallback to filesystem scan let role_names = scan_roles_from_filesystem()?; tracing::info!("Scanned {} roles from filesystem", role_names.len()); - + role_names .into_iter() .map(|name| AnsibleRole { @@ -506,9 +519,11 @@ impl ToolHandler for DeployRoleTool { // TODO: Implement actual Ansible playbook execution // This would interface with the Install Service or execute ansible-playbook directly // For now, return a placeholder response - + let ssh_user = params.ssh_user.unwrap_or_else(|| "root".to_string()); - let ssh_key = params.ssh_key_path.unwrap_or_else(|| "/root/.ssh/id_rsa".to_string()); + let ssh_key = params + .ssh_key_path + .unwrap_or_else(|| "/root/.ssh/id_rsa".to_string()); let result = json!({ "status": "queued", diff --git a/src/mcp/tools/cloud.rs b/src/mcp/tools/cloud.rs index 32c12673..b78f8c72 100644 --- a/src/mcp/tools/cloud.rs +++ b/src/mcp/tools/cloud.rs @@ -342,8 +342,7 @@ impl ToolHandler for ListCloudRegionsTool { fn schema(&self) -> Tool { Tool { name: "list_cloud_regions".to_string(), - description: "List available regions from App Service for a cloud provider" - .to_string(), + description: "List available regions from App Service for a cloud provider".to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/src/mcp/tools/firewall.rs b/src/mcp/tools/firewall.rs index fea172c5..4a731384 100644 --- a/src/mcp/tools/firewall.rs +++ b/src/mcp/tools/firewall.rs @@ -17,13 +17,13 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use crate::connectors::user_service::UserServiceDeploymentResolver; use crate::db; use crate::forms::status_panel::{ConfigureFirewallCommandRequest, FirewallPortRule}; use crate::mcp::protocol::{Tool, ToolContent}; use crate::mcp::registry::{ToolContext, ToolHandler}; use crate::models::{Command, CommandPriority}; use crate::services::{DeploymentIdentifier, DeploymentResolver}; -use crate::connectors::user_service::UserServiceDeploymentResolver; /// Execution method for firewall commands #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -153,7 +153,7 @@ impl ToolHandler for ConfigureFirewallTool { // For SSH method, we would need to execute via Ansible // This requires the deploy_role infrastructure // For now, return a placeholder indicating SSH method - + let result = json!({ "status": "pending", "execution_method": "ssh", diff --git a/src/mcp/tools/install_preview.rs b/src/mcp/tools/install_preview.rs index 5e6fb23c..d7366d34 100644 --- a/src/mcp/tools/install_preview.rs +++ b/src/mcp/tools/install_preview.rs @@ -148,9 +148,12 @@ impl ToolHandler for RenderAnsibleTemplateTool { let params: Args = serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; - let response = - call_install_service(reqwest::Method::POST, "/api/render-templates", Some(params.payload)) - .await?; + let response = call_install_service( + reqwest::Method::POST, + "/api/render-templates", + Some(params.payload), + ) + .await?; Ok(ToolContent::Text { text: response.to_string(), @@ -160,8 +163,9 @@ impl ToolHandler for RenderAnsibleTemplateTool { fn schema(&self) -> Tool { Tool { name: "render_ansible_template".to_string(), - description: "Render Ansible templates by calling Install Service /api/render-templates" - .to_string(), + description: + "Render Ansible templates by calling Install Service /api/render-templates" + .to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/src/mcp/tools/marketplace_admin.rs b/src/mcp/tools/marketplace_admin.rs index 64a63617..dd342c6f 100644 --- a/src/mcp/tools/marketplace_admin.rs +++ b/src/mcp/tools/marketplace_admin.rs @@ -352,11 +352,7 @@ impl ToolHandler for AdminListTemplateReviewsTool { "reviews": reviews, }); - tracing::info!( - "Admin listed {} reviews for template {}", - reviews.len(), - id - ); + tracing::info!("Admin listed {} reviews for template {}", reviews.len(), id); Ok(ToolContent::Text { text: serde_json::to_string(&result).unwrap(), diff --git a/src/mcp/tools/project.rs b/src/mcp/tools/project.rs index d77af5f8..913104fb 100644 --- a/src/mcp/tools/project.rs +++ b/src/mcp/tools/project.rs @@ -389,7 +389,11 @@ impl ToolHandler for CreateProjectAppTool { let catalog_app = match client.fetch_app_catalog(token, code).await { Ok(app) => app, Err(e) => { - tracing::warn!("Could not fetch app catalog for code={}: {}, proceeding with defaults", code, e); + tracing::warn!( + "Could not fetch app catalog for code={}: {}, proceeding with defaults", + code, + e + ); None } }; diff --git a/src/mcp/tools/user_service/mcp.rs b/src/mcp/tools/user_service/mcp.rs index 15ba88af..df628684 100644 --- a/src/mcp/tools/user_service/mcp.rs +++ b/src/mcp/tools/user_service/mcp.rs @@ -280,7 +280,8 @@ impl ToolHandler for GetNotificationsTool { fn schema(&self) -> Tool { Tool { name: "get_notifications".to_string(), - description: "List user notifications with optional pagination and unread filter".to_string(), + description: "List user notifications with optional pagination and unread filter" + .to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/src/models/agent.rs b/src/models/agent.rs index d0a7ab57..af72a690 100644 --- a/src/models/agent.rs +++ b/src/models/agent.rs @@ -183,8 +183,8 @@ mod tests { #[test] fn test_audit_log_with_ip() { - let log = AuditLog::new(None, None, "test".to_string(), None) - .with_ip("192.168.1.1".to_string()); + let log = + AuditLog::new(None, None, "test".to_string(), None).with_ip("192.168.1.1".to_string()); assert_eq!(log.ip_address, Some("192.168.1.1".to_string())); } diff --git a/src/models/command.rs b/src/models/command.rs index 05868748..61016674 100644 --- a/src/models/command.rs +++ b/src/models/command.rs @@ -283,41 +283,66 @@ mod tests { #[test] fn test_command_with_priority() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .with_priority(CommandPriority::Critical); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_priority(CommandPriority::Critical); assert_eq!(cmd.priority, "critical"); } #[test] fn test_command_with_parameters() { let params = serde_json::json!({"key": "value"}); - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .with_parameters(params.clone()); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_parameters(params.clone()); assert_eq!(cmd.parameters, Some(params)); } #[test] fn test_command_with_timeout() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .with_timeout(600); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_timeout(600); assert_eq!(cmd.timeout_seconds, Some(600)); } #[test] fn test_command_with_metadata() { let meta = serde_json::json!({"retry_count": 3}); - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .with_metadata(meta.clone()); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_metadata(meta.clone()); assert_eq!(cmd.metadata, Some(meta)); } #[test] fn test_command_builder_chaining() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .with_priority(CommandPriority::High) - .with_timeout(120) - .with_parameters(serde_json::json!({"action": "restart"})) - .with_metadata(serde_json::json!({"source": "api"})); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .with_priority(CommandPriority::High) + .with_timeout(120) + .with_parameters(serde_json::json!({"action": "restart"})) + .with_metadata(serde_json::json!({"source": "api"})); assert_eq!(cmd.priority, "high"); assert_eq!(cmd.timeout_seconds, Some(120)); @@ -328,42 +353,72 @@ mod tests { // Command status transitions #[test] fn test_command_mark_sent() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .mark_sent(); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_sent(); assert_eq!(cmd.status, "sent"); } #[test] fn test_command_mark_executing() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .mark_executing(); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_executing(); assert_eq!(cmd.status, "executing"); } #[test] fn test_command_mark_completed() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .mark_completed(); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_completed(); assert_eq!(cmd.status, "completed"); } #[test] fn test_command_mark_failed() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .mark_failed(); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_failed(); assert_eq!(cmd.status, "failed"); } #[test] fn test_command_mark_cancelled() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()) - .mark_cancelled(); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ) + .mark_cancelled(); assert_eq!(cmd.status, "cancelled"); } #[test] fn test_command_status_transition_chain() { - let cmd = Command::new("c".to_string(), "h".to_string(), "t".to_string(), "u".to_string()); + let cmd = Command::new( + "c".to_string(), + "h".to_string(), + "t".to_string(), + "u".to_string(), + ); assert_eq!(cmd.status, "queued"); let cmd = cmd.mark_sent(); assert_eq!(cmd.status, "sent"); diff --git a/src/models/pipe.rs b/src/models/pipe.rs index 57bff71b..b59e553a 100644 --- a/src/models/pipe.rs +++ b/src/models/pipe.rs @@ -128,11 +128,7 @@ pub struct PipeInstance { } impl PipeInstance { - pub fn new( - deployment_hash: String, - source_container: String, - created_by: String, - ) -> Self { + pub fn new(deployment_hash: String, source_container: String, created_by: String) -> Self { Self { id: Uuid::new_v4(), template_id: None, diff --git a/src/models/project.rs b/src/models/project.rs index 25f690c6..19b66459 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -236,27 +236,48 @@ mod tests { #[test] fn test_validate_too_long_name() { let long_name = "a".repeat(256); - assert_eq!(validate_project_name(&long_name), Err(ProjectNameError::TooLong(256))); + assert_eq!( + validate_project_name(&long_name), + Err(ProjectNameError::TooLong(256)) + ); } #[test] fn test_validate_reserved_names() { for name in &["root", "tmp", "etc", "var", "dev", ".", ".."] { - assert!(matches!(validate_project_name(name), Err(ProjectNameError::ReservedName(_)))); + assert!(matches!( + validate_project_name(name), + Err(ProjectNameError::ReservedName(_)) + )); } } #[test] fn test_validate_reserved_names_case_insensitive() { - assert!(matches!(validate_project_name("ROOT"), Err(ProjectNameError::ReservedName(_)))); - assert!(matches!(validate_project_name("Tmp"), Err(ProjectNameError::ReservedName(_)))); + assert!(matches!( + validate_project_name("ROOT"), + Err(ProjectNameError::ReservedName(_)) + )); + assert!(matches!( + validate_project_name("Tmp"), + Err(ProjectNameError::ReservedName(_)) + )); } #[test] fn test_validate_invalid_characters() { - assert!(matches!(validate_project_name("my project"), Err(ProjectNameError::InvalidCharacters(_)))); - assert!(matches!(validate_project_name("name/path"), Err(ProjectNameError::InvalidCharacters(_)))); - assert!(matches!(validate_project_name("-starts-with-dash"), Err(ProjectNameError::InvalidCharacters(_)))); + assert!(matches!( + validate_project_name("my project"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + assert!(matches!( + validate_project_name("name/path"), + Err(ProjectNameError::InvalidCharacters(_)) + )); + assert!(matches!( + validate_project_name("-starts-with-dash"), + Err(ProjectNameError::InvalidCharacters(_)) + )); } #[test] @@ -314,10 +335,20 @@ mod tests { // Test ProjectNameError Display #[test] fn test_error_display() { - assert_eq!(ProjectNameError::Empty.to_string(), "Project name cannot be empty"); - assert_eq!(ProjectNameError::TooLong(300).to_string(), "Project name too long (300 chars, max 255)"); - assert!(ProjectNameError::InvalidCharacters("bad name".to_string()).to_string().contains("bad name")); - assert!(ProjectNameError::ReservedName("root".to_string()).to_string().contains("root")); + assert_eq!( + ProjectNameError::Empty.to_string(), + "Project name cannot be empty" + ); + assert_eq!( + ProjectNameError::TooLong(300).to_string(), + "Project name too long (300 chars, max 255)" + ); + assert!(ProjectNameError::InvalidCharacters("bad name".to_string()) + .to_string() + .contains("bad name")); + assert!(ProjectNameError::ReservedName("root".to_string()) + .to_string() + .contains("root")); } // Test Project methods @@ -337,7 +368,12 @@ mod tests { #[test] fn test_project_validate_name() { - let project = Project::new("u".to_string(), "valid-name".to_string(), Value::Null, Value::Null); + let project = Project::new( + "u".to_string(), + "valid-name".to_string(), + Value::Null, + Value::Null, + ); assert!(project.validate_name().is_ok()); let bad_project = Project::new("u".to_string(), "".to_string(), Value::Null, Value::Null); @@ -346,21 +382,39 @@ mod tests { #[test] fn test_project_safe_dir_name() { - let project = Project::new("u".to_string(), "My Project".to_string(), Value::Null, Value::Null); + let project = Project::new( + "u".to_string(), + "My Project".to_string(), + Value::Null, + Value::Null, + ); assert_eq!(project.safe_dir_name(), "my_project"); } #[test] fn test_project_deploy_dir() { - let project = Project::new("u".to_string(), "myapp".to_string(), Value::Null, Value::Null); + let project = Project::new( + "u".to_string(), + "myapp".to_string(), + Value::Null, + Value::Null, + ); assert_eq!(project.deploy_dir(Some("/deploy")), "/deploy/myapp"); assert_eq!(project.deploy_dir(Some("/deploy/")), "/deploy/myapp"); } #[test] fn test_project_deploy_dir_with_hash() { - let project = Project::new("u".to_string(), "myapp".to_string(), Value::Null, Value::Null); - assert_eq!(project.deploy_dir_with_hash(Some("/deploy"), "abc123"), "/deploy/abc123"); + let project = Project::new( + "u".to_string(), + "myapp".to_string(), + Value::Null, + Value::Null, + ); + assert_eq!( + project.deploy_dir_with_hash(Some("/deploy"), "abc123"), + "/deploy/abc123" + ); } #[test] diff --git a/src/models/project_app.rs b/src/models/project_app.rs index 588b73ff..9cd609c4 100644 --- a/src/models/project_app.rs +++ b/src/models/project_app.rs @@ -216,7 +216,12 @@ mod tests { #[test] fn test_new_defaults() { - let app = ProjectApp::new(1, "nginx".to_string(), "Nginx".to_string(), "nginx:latest".to_string()); + let app = ProjectApp::new( + 1, + "nginx".to_string(), + "Nginx".to_string(), + "nginx:latest".to_string(), + ); assert_eq!(app.project_id, 1); assert_eq!(app.code, "nginx"); assert_eq!(app.name, "Nginx"); @@ -231,19 +236,28 @@ mod tests { #[test] fn test_is_enabled_true() { - let app = ProjectApp { enabled: Some(true), ..Default::default() }; + let app = ProjectApp { + enabled: Some(true), + ..Default::default() + }; assert!(app.is_enabled()); } #[test] fn test_is_enabled_false() { - let app = ProjectApp { enabled: Some(false), ..Default::default() }; + let app = ProjectApp { + enabled: Some(false), + ..Default::default() + }; assert!(!app.is_enabled()); } #[test] fn test_is_enabled_none_defaults_true() { - let app = ProjectApp { enabled: None, ..Default::default() }; + let app = ProjectApp { + enabled: None, + ..Default::default() + }; assert!(app.is_enabled()); } @@ -260,7 +274,10 @@ mod tests { #[test] fn test_env_map_empty() { - let app = ProjectApp { environment: None, ..Default::default() }; + let app = ProjectApp { + environment: None, + ..Default::default() + }; let map = app.env_map(); assert!(map.is_empty()); } @@ -317,14 +334,20 @@ mod tests { #[test] fn test_increment_version() { - let mut app = ProjectApp { config_version: Some(1), ..Default::default() }; + let mut app = ProjectApp { + config_version: Some(1), + ..Default::default() + }; app.increment_version(); assert_eq!(app.config_version, Some(2)); } #[test] fn test_increment_version_from_none() { - let mut app = ProjectApp { config_version: None, ..Default::default() }; + let mut app = ProjectApp { + config_version: None, + ..Default::default() + }; app.increment_version(); assert_eq!(app.config_version, Some(1)); } diff --git a/src/models/server.rs b/src/models/server.rs index ee450b1e..9f8e27bd 100644 --- a/src/models/server.rs +++ b/src/models/server.rs @@ -177,7 +177,7 @@ mod tests { #[test] fn test_server_validation_short_region() { let server = Server { - region: Some("a".to_string()), // too short, min 2 + region: Some("a".to_string()), // too short, min 2 ..Default::default() }; assert!(server.validate().is_err()); @@ -186,7 +186,7 @@ mod tests { #[test] fn test_server_validation_ssh_port_too_low() { let server = Server { - ssh_port: Some(10), // minimum 20 + ssh_port: Some(10), // minimum 20 ..Default::default() }; assert!(server.validate().is_err()); @@ -195,7 +195,7 @@ mod tests { #[test] fn test_server_validation_ssh_port_too_high() { let server = Server { - ssh_port: Some(70000), // maximum 65535 + ssh_port: Some(70000), // maximum 65535 ..Default::default() }; assert!(server.validate().is_err()); diff --git a/src/project_app/hydration.rs b/src/project_app/hydration.rs index 960e9474..d7c98655 100644 --- a/src/project_app/hydration.rs +++ b/src/project_app/hydration.rs @@ -152,8 +152,9 @@ mod hydrate { env_config = Some(config); } - if let Some(config_bundle) = fetch_optional_config(&vault, &hash, &format!("{}_configs", app.code)) - .await? + if let Some(config_bundle) = + fetch_optional_config(&vault, &hash, &format!("{}_configs", app.code)) + .await? { hydrated.config_files = parse_config_bundle(&config_bundle.content); } diff --git a/src/project_app/upsert.rs b/src/project_app/upsert.rs index 8d77aa1f..06a06635 100644 --- a/src/project_app/upsert.rs +++ b/src/project_app/upsert.rs @@ -29,36 +29,32 @@ pub(crate) async fn upsert_app_config_for_deploy( // Resolve the actual deployment record ID from deployment_hash // (deployment_id parameter is actually project_id in the current code) - let actual_deployment_id = match crate::db::deployment::fetch_by_deployment_hash( - pg_pool, - deployment_hash, - ) - .await - { - Ok(Some(dep)) => { - tracing::info!( - "[UPSERT_APP_CONFIG] Resolved deployment.id={} from hash={}", - dep.id, - deployment_hash - ); - Some(dep.id) - } - Ok(None) => { - tracing::warn!( + let actual_deployment_id = + match crate::db::deployment::fetch_by_deployment_hash(pg_pool, deployment_hash).await { + Ok(Some(dep)) => { + tracing::info!( + "[UPSERT_APP_CONFIG] Resolved deployment.id={} from hash={}", + dep.id, + deployment_hash + ); + Some(dep.id) + } + Ok(None) => { + tracing::warn!( "[UPSERT_APP_CONFIG] No deployment found for hash={}, deployment_id will be NULL", deployment_hash ); - None - } - Err(e) => { - tracing::warn!( - "[UPSERT_APP_CONFIG] Failed to resolve deployment for hash={}: {}", - deployment_hash, - e - ); - None - } - }; + None + } + Err(e) => { + tracing::warn!( + "[UPSERT_APP_CONFIG] Failed to resolve deployment for hash={}: {}", + deployment_hash, + e + ); + None + } + }; // Fetch project from DB let project = match crate::db::project::fetch(pg_pool, deployment_id).await { diff --git a/src/routes/agent/link.rs b/src/routes/agent/link.rs index 7f42322e..23c4425b 100644 --- a/src/routes/agent/link.rs +++ b/src/routes/agent/link.rs @@ -38,7 +38,10 @@ fn generate_agent_token() -> String { /// The session_token proves the user authenticated via /api/v1/agent/login. /// Stacker validates token ownership, checks the user owns the deployment, /// then creates or returns an agent with credentials. -#[tracing::instrument(name = "Link agent to deployment", skip(agent_pool, vault_client, user_service, req))] +#[tracing::instrument( + name = "Link agent to deployment", + skip(agent_pool, vault_client, user_service, req) +)] #[post("/link")] pub async fn link_handler( payload: web::Json, @@ -59,19 +62,16 @@ pub async fn link_handler( })?; // 2. Verify user owns the requested deployment - let deployment = db::deployment::fetch_by_deployment_hash( - api_pool.get_ref(), - &payload.deployment_id, - ) - .await - .map_err(|e| { - helpers::JsonResponse::::build() - .internal_server_error(format!("Database error: {}", e)) - })?; + let deployment = + db::deployment::fetch_by_deployment_hash(api_pool.get_ref(), &payload.deployment_id) + .await + .map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("Database error: {}", e)) + })?; let deployment = deployment.ok_or_else(|| { - helpers::JsonResponse::::build() - .not_found("Deployment not found") + helpers::JsonResponse::::build().not_found("Deployment not found") })?; // Check ownership: deployment.user_id must match the authenticated user @@ -91,8 +91,7 @@ pub async fn link_handler( db::agent::fetch_by_deployment_hash(agent_pool.as_ref(), &deployment.deployment_hash) .await .map_err(|e| { - helpers::JsonResponse::::build() - .internal_server_error(e) + helpers::JsonResponse::::build().internal_server_error(e) })?; let (agent, agent_token) = if let Some(mut existing) = existing_agent { @@ -106,8 +105,7 @@ pub async fn link_handler( let existing = db::agent::update(agent_pool.as_ref(), existing) .await .map_err(|e| { - helpers::JsonResponse::::build() - .internal_server_error(e) + helpers::JsonResponse::::build().internal_server_error(e) })?; // Fetch existing token from Vault or regenerate @@ -140,8 +138,7 @@ pub async fn link_handler( let saved_agent = db::agent::insert(agent_pool.as_ref(), agent) .await .map_err(|e| { - helpers::JsonResponse::::build() - .internal_server_error(e) + helpers::JsonResponse::::build().internal_server_error(e) })?; // Store token in Vault diff --git a/src/routes/agent/login.rs b/src/routes/agent/login.rs index 4805fc01..0218494b 100644 --- a/src/routes/agent/login.rs +++ b/src/routes/agent/login.rs @@ -32,7 +32,10 @@ pub struct AgentLoginResponse { /// Proxy login for Status Panel agents. Authenticates the user against /// the TryDirect OAuth server, then returns a session token and the /// user's deployments so the agent can pick one to link to. -#[tracing::instrument(name = "Agent proxy login", skip(settings, api_pool, user_service, _req))] +#[tracing::instrument( + name = "Agent proxy login", + skip(settings, api_pool, user_service, _req) +)] #[post("/login")] pub async fn login_handler( payload: web::Json, @@ -106,13 +109,12 @@ pub async fn login_handler( })?; // 3. Fetch user's deployments from Stacker DB - let deployments = - db::deployment::fetch_by_user(api_pool.get_ref(), &profile.email, 50) - .await - .map_err(|e| { - helpers::JsonResponse::::build() - .internal_server_error(format!("Failed to fetch deployments: {}", e)) - })?; + let deployments = db::deployment::fetch_by_user(api_pool.get_ref(), &profile.email, 50) + .await + .map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("Failed to fetch deployments: {}", e)) + })?; let deployment_infos: Vec = deployments .into_iter() diff --git a/src/routes/agent/snapshot.rs b/src/routes/agent/snapshot.rs index e1cf74bb..5ce607f9 100644 --- a/src/routes/agent/snapshot.rs +++ b/src/routes/agent/snapshot.rs @@ -89,9 +89,13 @@ pub async fn snapshot_handler( tracing::debug!("[SNAPSHOT HANDLER] Deployment : {:?}", deployment); // Fetch apps scoped to this specific deployment (falls back to project-level if no deployment-scoped apps) let apps = if let Some(deployment) = &deployment { - db::project_app::fetch_by_deployment(agent_pool.get_ref(), deployment.project_id, deployment.id) - .await - .unwrap_or_default() + db::project_app::fetch_by_deployment( + agent_pool.get_ref(), + deployment.project_id, + deployment.id, + ) + .await + .unwrap_or_default() } else { vec![] }; @@ -237,14 +241,10 @@ pub async fn project_snapshot_handler( let (agent_snap, deployment_hash) = agent_snapshot; - let commands = db::command::fetch_recent_by_deployment( - agent_pool.get_ref(), - &deployment_hash, - 50, - true, - ) - .await - .unwrap_or_default(); + let commands = + db::command::fetch_recent_by_deployment(agent_pool.get_ref(), &deployment_hash, 50, true) + .await + .unwrap_or_default(); let deployment = db::deployment::fetch_by_deployment_hash(agent_pool.get_ref(), &deployment_hash) @@ -260,14 +260,10 @@ pub async fn project_snapshot_handler( vec![] }; - let health_commands = db::command::fetch_recent_by_deployment( - agent_pool.get_ref(), - &deployment_hash, - 10, - false, - ) - .await - .unwrap_or_default(); + let health_commands = + db::command::fetch_recent_by_deployment(agent_pool.get_ref(), &deployment_hash, 10, false) + .await + .unwrap_or_default(); let mut container_map: std::collections::HashMap = std::collections::HashMap::new(); diff --git a/src/routes/cloud/add.rs b/src/routes/cloud/add.rs index f6d34c7c..70e64c6a 100644 --- a/src/routes/cloud/add.rs +++ b/src/routes/cloud/add.rs @@ -37,9 +37,8 @@ pub async fn add( Check that SECURITY_KEY is set and is exactly 32 bytes.", cloud.provider ); - return Err(JsonResponse::::build().bad_request( - "Failed to encrypt cloud credentials. Please contact support.", - )); + return Err(JsonResponse::::build() + .bad_request("Failed to encrypt cloud credentials. Please contact support.")); } } diff --git a/src/routes/cloud/update.rs b/src/routes/cloud/update.rs index 42d4c26a..b284585e 100644 --- a/src/routes/cloud/update.rs +++ b/src/routes/cloud/update.rs @@ -46,9 +46,8 @@ pub async fn item( Check that SECURITY_KEY is set and is exactly 32 bytes.", cloud.provider ); - return Err(JsonResponse::::build().bad_request( - "Failed to encrypt cloud credentials. Please contact support.", - )); + return Err(JsonResponse::::build() + .bad_request("Failed to encrypt cloud credentials. Please contact support.")); } } diff --git a/src/routes/command/create.rs b/src/routes/command/create.rs index 259c2986..719536a6 100644 --- a/src/routes/command/create.rs +++ b/src/routes/command/create.rs @@ -560,15 +560,11 @@ pub async fn discover_and_register_child_services( deployment_hash: &str, ) -> usize { // Resolve actual deployment ID from hash for scoping apps per deployment - let actual_deployment_id = match crate::db::deployment::fetch_by_deployment_hash( - pg_pool, - deployment_hash, - ) - .await - { - Ok(Some(dep)) => Some(dep.id), - _ => None, - }; + let actual_deployment_id = + match crate::db::deployment::fetch_by_deployment_hash(pg_pool, deployment_hash).await { + Ok(Some(dep)) => Some(dep.id), + _ => None, + }; // Parse the compose file to extract services let services = match parse_compose_services(compose_content) { diff --git a/src/routes/deployment/force_complete.rs b/src/routes/deployment/force_complete.rs index 13a96c17..dd39fbc8 100644 --- a/src/routes/deployment/force_complete.rs +++ b/src/routes/deployment/force_complete.rs @@ -41,23 +41,19 @@ pub async fn force_complete_handler( let mut deployment = match deployment { Some(d) => { if d.user_id.as_deref() != Some(&user.id) { - return Err( - JsonResponse::::build() - .not_found("Deployment not found"), - ); + return Err(JsonResponse::::build() + .not_found("Deployment not found")); } d } None => { return Err( - JsonResponse::::build() - .not_found("Deployment not found"), + JsonResponse::::build().not_found("Deployment not found") ); } }; - let status_ok = query.force - || FORCE_COMPLETE_ALLOWED.contains(&deployment.status.as_str()); + let status_ok = query.force || FORCE_COMPLETE_ALLOWED.contains(&deployment.status.as_str()); if !status_ok { return Err(JsonResponse::::build().bad_request(format!( diff --git a/src/routes/deployment/status.rs b/src/routes/deployment/status.rs index 2ad00ef2..df92ff50 100644 --- a/src/routes/deployment/status.rs +++ b/src/routes/deployment/status.rs @@ -60,7 +60,9 @@ pub async fn status_by_hash_handler( let deployment = db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &hash) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; match deployment { Some(d) => { @@ -73,8 +75,9 @@ pub async fn status_by_hash_handler( .set_item(resp) .ok("Deployment status fetched")) } - None => Err(JsonResponse::::build() - .not_found("Deployment not found")), + None => { + Err(JsonResponse::::build().not_found("Deployment not found")) + } } } @@ -93,7 +96,9 @@ pub async fn status_handler( let deployment = db::deployment::fetch(pg_pool.get_ref(), deployment_id) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; match deployment { Some(d) => { @@ -107,8 +112,9 @@ pub async fn status_handler( .set_item(resp) .ok("Deployment status fetched")) } - None => Err(JsonResponse::::build() - .not_found("Deployment not found")), + None => { + Err(JsonResponse::::build().not_found("Deployment not found")) + } } } @@ -126,15 +132,21 @@ pub async fn list_handler( let deployments = if let Some(project_id) = query.project_id { db::deployment::fetch_by_user_and_project(pg_pool.get_ref(), &user.id, project_id, limit) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? } else { db::deployment::fetch_by_user(pg_pool.get_ref(), &user.id, limit) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))? + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })? }; - let list: Vec = - deployments.into_iter().map(DeploymentStatusResponse::from).collect(); + let list: Vec = deployments + .into_iter() + .map(DeploymentStatusResponse::from) + .collect(); Ok(JsonResponse::build() .set_list(list) @@ -156,7 +168,9 @@ pub async fn status_by_project_handler( let deployment = db::deployment::fetch_by_project_id(pg_pool.get_ref(), project_id) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + .map_err(|err| { + JsonResponse::::build().internal_server_error(err) + })?; match deployment { Some(d) => { diff --git a/src/routes/marketplace/admin.rs b/src/routes/marketplace/admin.rs index 9d6cf20c..3563cc7c 100644 --- a/src/routes/marketplace/admin.rs +++ b/src/routes/marketplace/admin.rs @@ -208,14 +208,10 @@ pub async fn unapprove_handler( .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; let req = body.into_inner(); - let updated = db::marketplace::admin_unapprove( - pg_pool.get_ref(), - &id, - &admin.id, - req.reason.as_deref(), - ) - .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + let updated = + db::marketplace::admin_unapprove(pg_pool.get_ref(), &id, &admin.id, req.reason.as_deref()) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; if !updated { return Err(JsonResponse::::build() @@ -245,7 +241,8 @@ pub async fn unapprove_handler( } }); - Ok(JsonResponse::::build().ok("Template unapproved and hidden from marketplace")) + Ok(JsonResponse::::build() + .ok("Template unapproved and hidden from marketplace")) } #[tracing::instrument(name = "Security scan template (admin)")] diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index 3fcfad23..ea811133 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -42,7 +42,11 @@ pub async fn create_handler( // Normalize pricing: plan_type "free" forces price to 0 let billing_cycle = req.plan_type.unwrap_or_else(|| "free".to_string()); - let price = if billing_cycle == "free" { 0.0 } else { req.price.unwrap_or(0.0) }; + let price = if billing_cycle == "free" { + 0.0 + } else { + req.price.unwrap_or(0.0) + }; let currency = req.currency.unwrap_or_else(|| "USD".to_string()); // Check if template with this slug already exists for this user diff --git a/src/routes/marketplace/mod.rs b/src/routes/marketplace/mod.rs index 1ed063d9..b1898a26 100644 --- a/src/routes/marketplace/mod.rs +++ b/src/routes/marketplace/mod.rs @@ -5,11 +5,11 @@ pub mod creator; pub mod public; pub use admin::{ - AdminDecisionRequest, UnapproveRequest, approve_handler, list_plans_handler, - list_submitted_handler, reject_handler, security_scan_handler, unapprove_handler, + approve_handler, list_plans_handler, list_submitted_handler, reject_handler, + security_scan_handler, unapprove_handler, AdminDecisionRequest, UnapproveRequest, }; pub use creator::{ - CreateTemplateRequest, ResubmitRequest, UpdateTemplateRequest, create_handler, mine_handler, - resubmit_handler, submit_handler, update_handler, + create_handler, mine_handler, resubmit_handler, submit_handler, update_handler, + CreateTemplateRequest, ResubmitRequest, UpdateTemplateRequest, }; pub use public::TemplateListQuery; diff --git a/src/routes/marketplace/public.rs b/src/routes/marketplace/public.rs index 0a6bdf98..878c3e8a 100644 --- a/src/routes/marketplace/public.rs +++ b/src/routes/marketplace/public.rs @@ -123,10 +123,7 @@ pub async fn download_stack_handler( .content_type("application/gzip") .insert_header(( "Content-Disposition", - format!( - "attachment; filename=\"stack-{}.tar.gz\"", - purchase_token - ), + format!("attachment; filename=\"stack-{}.tar.gz\"", purchase_token), )) .body("stack archive placeholder")) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9afe0852..9e57268f 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -22,12 +22,12 @@ pub(crate) mod pipe; pub use agreement::*; pub use deployment::{ - DeploymentListQuery, DeploymentStatusResponse, capabilities_handler, force_complete_handler, - list_handler, status_by_project_handler, status_handler, + capabilities_handler, force_complete_handler, list_handler, status_by_project_handler, + status_handler, DeploymentListQuery, DeploymentStatusResponse, }; pub use marketplace::{ - AdminDecisionRequest, CreateTemplateRequest, ResubmitRequest, TemplateListQuery, - UpdateTemplateRequest, UnapproveRequest, approve_handler, create_handler, list_plans_handler, - list_submitted_handler, mine_handler, reject_handler, resubmit_handler, - security_scan_handler, submit_handler, unapprove_handler, update_handler, + approve_handler, create_handler, list_plans_handler, list_submitted_handler, mine_handler, + reject_handler, resubmit_handler, security_scan_handler, submit_handler, unapprove_handler, + update_handler, AdminDecisionRequest, CreateTemplateRequest, ResubmitRequest, + TemplateListQuery, UnapproveRequest, UpdateTemplateRequest, }; diff --git a/src/routes/project/app.rs b/src/routes/project/app.rs index 4207995a..c922e457 100644 --- a/src/routes/project/app.rs +++ b/src/routes/project/app.rs @@ -15,7 +15,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models::{self, Project}; -use crate::services::{ProjectAppService}; +use crate::services::ProjectAppService; use actix_web::{delete, get, post, put, web, Responder, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index f42fe346..0e6e9683 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -13,7 +13,10 @@ use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; -#[tracing::instrument(name = "Deploy for every user", skip(user_service, install_service, vault_client))] +#[tracing::instrument( + name = "Deploy for every user", + skip(user_service, install_service, vault_client) +)] #[post("/{id}/deploy")] pub async fn item( user: web::ReqData>, @@ -97,11 +100,7 @@ pub async fn item( .cloud_token .as_ref() .map_or(true, |t| t.is_empty()); - let key_empty = form - .cloud - .cloud_key - .as_ref() - .map_or(true, |k| k.is_empty()); + let key_empty = form.cloud.cloud_key.as_ref().map_or(true, |k| k.is_empty()); let secret_empty = form .cloud .cloud_secret @@ -140,11 +139,10 @@ pub async fn item( let existing = db::server::fetch(pg_pool.get_ref(), server_id) .await .map_err(|_| { - JsonResponse::::build().internal_server_error("Failed to fetch server") + JsonResponse::::build() + .internal_server_error("Failed to fetch server") })? - .ok_or_else(|| { - JsonResponse::::build().not_found("Server not found") - })?; + .ok_or_else(|| JsonResponse::::build().not_found("Server not found"))?; // Verify ownership if existing.user_id != user.id { @@ -170,7 +168,8 @@ pub async fn item( db::server::update(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build().internal_server_error("Failed to update server") + JsonResponse::::build() + .internal_server_error("Failed to update server") })? } else { // Create new server @@ -185,7 +184,8 @@ pub async fn item( db::server::insert(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build().internal_server_error("Internal Server Error") + JsonResponse::::build() + .internal_server_error("Internal Server Error") })? }; @@ -319,13 +319,17 @@ pub async fn item( .await { Ok(pk) => { - tracing::info!("Fetched SSH private key from Vault for server {}", server.id); + tracing::info!( + "Fetched SSH private key from Vault for server {}", + server.id + ); Some(pk) } Err(e) => { tracing::warn!( "Failed to fetch SSH private key from Vault for server {}: {}", - server.id, e + server.id, + e ); None } @@ -362,7 +366,10 @@ pub async fn item( }) .map_err(|err| JsonResponse::::build().internal_server_error(err)) } -#[tracing::instrument(name = "Deploy, when cloud token is saved", skip(user_service, install_service, vault_client))] +#[tracing::instrument( + name = "Deploy, when cloud token is saved", + skip(user_service, install_service, vault_client) +)] #[post("/{id}/deploy/{cloud_id}")] pub async fn saved_item( user: web::ReqData>, @@ -465,10 +472,7 @@ pub async fn saved_item( .cloud_token .as_ref() .map_or(true, |t| t.is_empty()); - let key_empty = test_cloud - .cloud_key - .as_ref() - .map_or(true, |k| k.is_empty()); + let key_empty = test_cloud.cloud_key.as_ref().map_or(true, |k| k.is_empty()); let secret_empty = test_cloud .cloud_secret .as_ref() @@ -498,11 +502,10 @@ pub async fn saved_item( let existing = db::server::fetch(pg_pool.get_ref(), server_id) .await .map_err(|_| { - JsonResponse::::build().internal_server_error("Failed to fetch server") + JsonResponse::::build() + .internal_server_error("Failed to fetch server") })? - .ok_or_else(|| { - JsonResponse::::build().not_found("Server not found") - })?; + .ok_or_else(|| JsonResponse::::build().not_found("Server not found"))?; // Verify ownership if existing.user_id != user.id { @@ -529,7 +532,8 @@ pub async fn saved_item( db::server::update(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build().internal_server_error("Failed to update server") + JsonResponse::::build() + .internal_server_error("Failed to update server") })? } else { // Create new server @@ -541,7 +545,8 @@ pub async fn saved_item( db::server::insert(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build().internal_server_error("Failed to create server") + JsonResponse::::build() + .internal_server_error("Failed to create server") })? }; @@ -677,13 +682,17 @@ pub async fn saved_item( .await { Ok(pk) => { - tracing::info!("Fetched SSH private key from Vault for server {}", server.id); + tracing::info!( + "Fetched SSH private key from Vault for server {}", + server.id + ); Some(pk) } Err(e) => { tracing::warn!( "Failed to fetch SSH private key from Vault for server {}: {}", - server.id, e + server.id, + e ); None } diff --git a/src/routes/project/discover.rs b/src/routes/project/discover.rs index 9dbc3ef8..db216dec 100644 --- a/src/routes/project/discover.rs +++ b/src/routes/project/discover.rs @@ -354,7 +354,11 @@ pub async fn import_containers( let mut errors = Vec::new(); for container in &body.containers { - if is_blocked_system_container(&container.container_name, &container.image, Some(&container.app_code)) { + if is_blocked_system_container( + &container.container_name, + &container.image, + Some(&container.app_code), + ) { errors.push(format!( "Container '{}' is a system container and cannot be imported", container.container_name diff --git a/src/routes/server/delete.rs b/src/routes/server/delete.rs index ebc9d87e..275589cf 100644 --- a/src/routes/server/delete.rs +++ b/src/routes/server/delete.rs @@ -34,10 +34,9 @@ pub async fn delete_preview( .await .unwrap_or_default(); - user_servers.iter().any(|s| { - s.id != server.id - && s.vault_key_path.as_deref() == Some(vault_path.as_str()) - }) + user_servers + .iter() + .any(|s| s.id != server.id && s.vault_key_path.as_deref() == Some(vault_path.as_str())) } else { false }; @@ -63,11 +62,13 @@ pub async fn delete_preview( } } - Ok(JsonResponse::::build().set_item(serde_json::json!({ - "ssh_key_shared": ssh_key_shared, - "affected_deployments": affected_deployments, - "agent_count": agent_count, - })).ok("Delete preview")) + Ok(JsonResponse::::build() + .set_item(serde_json::json!({ + "ssh_key_shared": ssh_key_shared, + "affected_deployments": affected_deployments, + "agent_count": agent_count, + })) + .ok("Delete preview")) } #[tracing::instrument(name = "Delete user's server with cleanup.")] @@ -97,20 +98,16 @@ pub async fn item( .await .unwrap_or_default(); - user_servers.iter().any(|s| { - s.id != server.id - && s.vault_key_path.as_deref() == Some(vault_path.as_str()) - }) + user_servers + .iter() + .any(|s| s.id != server.id && s.vault_key_path.as_deref() == Some(vault_path.as_str())) } else { false }; // 2. Delete SSH key from Vault if not shared and key exists if !ssh_key_shared && server.vault_key_path.is_some() { - if let Err(e) = vault_client - .delete_ssh_key(&user.id, server.id) - .await - { + if let Err(e) = vault_client.delete_ssh_key(&user.id, server.id).await { tracing::warn!( "Failed to delete SSH key from Vault for server {}: {}. Continuing with server deletion.", server.id, diff --git a/src/routes/server/get.rs b/src/routes/server/get.rs index 9d3ef9dd..f96b2b43 100644 --- a/src/routes/server/get.rs +++ b/src/routes/server/get.rs @@ -41,7 +41,9 @@ pub async fn list( db::server::fetch_by_user_with_provider(pg_pool.get_ref(), user.id.as_ref()) .await .map(|servers| JsonResponse::build().set_list(servers).ok("OK")) - .map_err(|_err| JsonResponse::::build().internal_server_error("")) + .map_err(|_err| { + JsonResponse::::build().internal_server_error("") + }) } #[tracing::instrument(name = "Get servers by project.")] diff --git a/src/routes/server/ssh_key.rs b/src/routes/server/ssh_key.rs index fb1fca0a..9e3926e5 100644 --- a/src/routes/server/ssh_key.rs +++ b/src/routes/server/ssh_key.rs @@ -107,7 +107,10 @@ pub async fn generate_key( (Some(path), "active", "SSH key generated and stored in Vault successfully. Copy the public key to your server's authorized_keys.".to_string(), false) } Err(e) => { - tracing::warn!("Failed to store SSH key in Vault (continuing without Vault): {}", e); + tracing::warn!( + "Failed to store SSH key in Vault (continuing without Vault): {}", + e + ); (None, "active", format!("SSH key generated successfully, but could not be stored in Vault ({}). Please save the private key shown below - it will not be shown again!", e), true) } }; @@ -119,7 +122,11 @@ pub async fn generate_key( let response = GenerateKeyResponseWithPrivate { public_key: public_key.clone(), - private_key: if include_private_key { Some(private_key) } else { None }, + private_key: if include_private_key { + Some(private_key) + } else { + None + }, fingerprint: None, // TODO: Calculate fingerprint message, }; @@ -286,7 +293,7 @@ pub struct ValidateResponse { /// Validate SSH connection for a server /// POST /server/{id}/ssh-key/validate -/// +/// /// This endpoint: /// 1. Verifies the server exists and belongs to the user /// 2. Checks the SSH key is active and retrieves it from Vault @@ -359,7 +366,10 @@ pub async fn validate_key( { Ok(key) => key, Err(e) => { - tracing::warn!("Failed to fetch SSH key from Vault during validation: {}", e); + tracing::warn!( + "Failed to fetch SSH key from Vault during validation: {}", + e + ); let response = ValidateResponse { valid: false, server_id, @@ -382,7 +392,10 @@ pub async fn validate_key( // Get SSH connection parameters let ssh_port = server.ssh_port.unwrap_or(22) as u16; - let ssh_user = server.ssh_user.clone().unwrap_or_else(|| "root".to_string()); + let ssh_user = server + .ssh_user + .clone() + .unwrap_or_else(|| "root".to_string()); // Perform SSH connection and system check let check_result = ssh_client::check_server( @@ -399,7 +412,9 @@ pub async fn validate_key( let message = if valid { check_result.summary() } else { - check_result.error.unwrap_or_else(|| "SSH validation failed".to_string()) + check_result + .error + .unwrap_or_else(|| "SSH validation failed".to_string()) }; let response = ValidateResponse { @@ -410,7 +425,11 @@ pub async fn validate_key( connected: check_result.connected, authenticated: check_result.authenticated, // Include vault public key in response when auth fails (helps debug key mismatch) - vault_public_key: if !check_result.authenticated { vault_public_key } else { None }, + vault_public_key: if !check_result.authenticated { + vault_public_key + } else { + None + }, username: check_result.username, disk_total_gb: check_result.disk_total_gb, disk_available_gb: check_result.disk_available_gb, diff --git a/src/startup.rs b/src/startup.rs index d9f59399..e4a60b84 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -251,7 +251,9 @@ pub async fn run( .service(crate::routes::marketplace::admin::approve_handler) .service(crate::routes::marketplace::admin::reject_handler) .service(crate::routes::marketplace::admin::unapprove_handler) - .service(crate::routes::marketplace::admin::security_scan_handler), + .service( + crate::routes::marketplace::admin::security_scan_handler, + ), ) .service( web::scope("/marketplace") @@ -263,10 +265,9 @@ pub async fn run( web::scope("/api/v1/marketplace") .service(crate::routes::marketplace::public::install_script_handler) .service(crate::routes::marketplace::public::download_stack_handler) - .service( - web::scope("/agents") - .service(crate::routes::marketplace::agent::register_marketplace_agent_handler), - ), + .service(web::scope("/agents").service( + crate::routes::marketplace::agent::register_marketplace_agent_handler, + )), ) .service( web::scope("/cloud") diff --git a/tests/agent_login_link.rs b/tests/agent_login_link.rs index 5d452d73..fd05e3c7 100644 --- a/tests/agent_login_link.rs +++ b/tests/agent_login_link.rs @@ -128,7 +128,11 @@ async fn test_agent_link_rejects_invalid_token() { let status = resp.status(); println!("Link with invalid token response status: {}", status); // Should be 403 (invalid session token) — not 404 (route not found) - assert_ne!(status.as_u16(), 404, "Route /api/v1/agent/link should exist"); + assert_ne!( + status.as_u16(), + 404, + "Route /api/v1/agent/link should exist" + ); assert!( status.is_client_error(), "Expected client error for invalid token, got {}", diff --git a/tests/cli_config.rs b/tests/cli_config.rs index 2f29ffd2..f8b6ec6b 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -96,11 +96,11 @@ fn test_config_show_missing_file_returns_error() { .failure(); } - #[test] - fn test_config_example_prints_full_reference() { - let dir = TempDir::new().unwrap(); +#[test] +fn test_config_example_prints_full_reference() { + let dir = TempDir::new().unwrap(); - stacker_cmd() + stacker_cmd() .current_dir(dir.path()) .args(["config", "example"]) .assert() @@ -109,4 +109,4 @@ fn test_config_show_missing_file_returns_error() { .stdout(predicate::str::contains("monitoring:")) .stdout(predicate::str::contains("hooks:")) .stdout(predicate::str::contains("deploy:")); - } +} diff --git a/tests/cli_deploy.rs b/tests/cli_deploy.rs index 322edf0a..e0e9e132 100644 --- a/tests/cli_deploy.rs +++ b/tests/cli_deploy.rs @@ -98,7 +98,14 @@ deploy: stacker_cmd() .current_dir(dir.path()) - .args(["deploy", "--target", "local", "--file", "custom.yml", "--dry-run"]) + .args([ + "deploy", + "--target", + "local", + "--file", + "custom.yml", + "--dry-run", + ]) .assert() .success(); } @@ -127,7 +134,10 @@ deploy: .args(["deploy", "--target", "cloud"]) .assert() .failure() - .stderr(predicate::str::contains("login").or(predicate::str::contains("credential").or(predicate::str::contains("Login")))); + .stderr( + predicate::str::contains("login") + .or(predicate::str::contains("credential").or(predicate::str::contains("Login"))), + ); } #[test] diff --git a/tests/cli_destroy.rs b/tests/cli_destroy.rs index b2d82f0f..095cde35 100644 --- a/tests/cli_destroy.rs +++ b/tests/cli_destroy.rs @@ -33,7 +33,10 @@ fn test_destroy_no_deployment_returns_error() { .args(["destroy", "--confirm"]) .assert() .failure() - .stderr(predicate::str::contains("No deployment").or(predicate::str::contains("Nothing to destroy"))); + .stderr( + predicate::str::contains("No deployment") + .or(predicate::str::contains("Nothing to destroy")), + ); } #[test] diff --git a/tests/cli_init.rs b/tests/cli_init.rs index 6d4898f6..a97aad39 100644 --- a/tests/cli_init.rs +++ b/tests/cli_init.rs @@ -65,7 +65,14 @@ fn test_init_with_ai_flag() { // a real running Ollama which would take minutes to generate). stacker_cmd() .current_dir(dir.path()) - .args(["init", "--with-ai", "--ai-provider", "custom", "--ai-api-key", "fake"]) + .args([ + "init", + "--with-ai", + "--ai-provider", + "custom", + "--ai-api-key", + "fake", + ]) .assert() .success(); @@ -157,7 +164,14 @@ fn test_init_with_ai_and_provider_flags() { // then falls back to template-based generation. stacker_cmd() .current_dir(dir.path()) - .args(["init", "--with-ai", "--ai-provider", "custom", "--ai-api-key", "fake"]) + .args([ + "init", + "--with-ai", + "--ai-provider", + "custom", + "--ai-api-key", + "fake", + ]) .assert() .success() .stderr(predicate::str::contains("AI").or(predicate::str::contains("Created"))); diff --git a/tests/cli_logs.rs b/tests/cli_logs.rs index 56b4244c..2a7db39a 100644 --- a/tests/cli_logs.rs +++ b/tests/cli_logs.rs @@ -18,7 +18,10 @@ fn test_logs_no_deployment_returns_error() { .arg("logs") .assert() .failure() - .stderr(predicate::str::contains("No deployment found").or(predicate::str::contains("docker-compose"))); + .stderr( + predicate::str::contains("No deployment found") + .or(predicate::str::contains("docker-compose")), + ); } #[test] diff --git a/tests/cli_proxy.rs b/tests/cli_proxy.rs index 27cd03ee..fea73852 100644 --- a/tests/cli_proxy.rs +++ b/tests/cli_proxy.rs @@ -10,19 +10,31 @@ fn stacker_cmd() -> Command { #[test] fn test_proxy_add_generates_nginx_block() { stacker_cmd() - .args(["proxy", "add", "example.com", "--upstream", "http://app:3000"]) + .args([ + "proxy", + "add", + "example.com", + "--upstream", + "http://app:3000", + ]) .assert() .success() - .stdout(predicate::str::contains("server_name").or(predicate::str::contains("example.com"))); + .stdout( + predicate::str::contains("server_name").or(predicate::str::contains("example.com")), + ); } #[test] fn test_proxy_add_with_ssl() { stacker_cmd() .args([ - "proxy", "add", "secure.example.com", - "--upstream", "http://app:3000", - "--ssl", "auto", + "proxy", + "add", + "secure.example.com", + "--upstream", + "http://app:3000", + "--ssl", + "auto", ]) .assert() .success(); diff --git a/tests/cli_status.rs b/tests/cli_status.rs index 2883eec3..9c48b889 100644 --- a/tests/cli_status.rs +++ b/tests/cli_status.rs @@ -17,7 +17,10 @@ fn test_status_no_deployment_returns_error() { .arg("status") .assert() .failure() - .stderr(predicate::str::contains("No deployment").or(predicate::str::contains("docker-compose"))); + .stderr( + predicate::str::contains("No deployment") + .or(predicate::str::contains("docker-compose")), + ); } #[test] diff --git a/tests/cli_update.rs b/tests/cli_update.rs index 59c9ff4f..db8ecb49 100644 --- a/tests/cli_update.rs +++ b/tests/cli_update.rs @@ -31,7 +31,9 @@ fn test_update_invalid_channel_fails() { .args(["update", "--channel", "nightly"]) .assert() .failure() - .stderr(predicate::str::contains("Unknown channel").or(predicate::str::contains("nightly"))); + .stderr( + predicate::str::contains("Unknown channel").or(predicate::str::contains("nightly")), + ); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3006212c..2f9cb6d4 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -130,9 +130,14 @@ pub async fn spawn_app_with_vault() -> Option { let address = format!("http://127.0.0.1:{}", port); let agent_pool = AgentPgPool::new(connection_pool.clone()); - let server = stacker::startup::run(app_listener, connection_pool.clone(), agent_pool, configuration) - .await - .expect("Failed to bind address."); + let server = stacker::startup::run( + app_listener, + connection_pool.clone(), + agent_pool, + configuration, + ) + .await + .expect("Failed to bind address."); let _ = tokio::spawn(server); Some(TestAppWithVault { diff --git a/tests/marketplace_mine.rs b/tests/marketplace_mine.rs index 2e415ca5..a843fb23 100644 --- a/tests/marketplace_mine.rs +++ b/tests/marketplace_mine.rs @@ -39,8 +39,13 @@ async fn mine_returns_empty_list_for_new_user() { assert_eq!(StatusCode::OK, response.status()); - let body: serde_json::Value = response.json().await.expect("Response should be valid JSON"); - let list = body.get("list").expect("Response body should contain 'list' field"); + let body: serde_json::Value = response + .json() + .await + .expect("Response should be valid JSON"); + let list = body + .get("list") + .expect("Response body should contain 'list' field"); assert!(list.is_array(), "'list' should be a JSON array"); assert_eq!( 0, @@ -85,10 +90,19 @@ async fn mine_returns_only_the_authenticated_users_templates() { assert_eq!(StatusCode::OK, response.status()); - let body: serde_json::Value = response.json().await.expect("Response should be valid JSON"); - let list = body["list"].as_array().expect("'list' should be a JSON array"); + let body: serde_json::Value = response + .json() + .await + .expect("Response should be valid JSON"); + let list = body["list"] + .as_array() + .expect("'list' should be a JSON array"); - assert_eq!(1, list.len(), "Should return exactly the authenticated user's template"); + assert_eq!( + 1, + list.len(), + "Should return exactly the authenticated user's template" + ); assert_eq!( "my-test-stack", list[0]["slug"].as_str().unwrap_or_default(), diff --git a/tests/server_ssh.rs b/tests/server_ssh.rs index f0986cb7..3dafef38 100644 --- a/tests/server_ssh.rs +++ b/tests/server_ssh.rs @@ -10,10 +10,7 @@ use wiremock::{Mock, ResponseTemplate}; /// Vault path pattern for SSH keys: /v1/secret/users/{user_id}/ssh_keys/{server_id} fn vault_ssh_path_regex(user_id: &str, server_id: i32) -> String { - format!( - r"/v1/secret/users/{}/ssh_keys/{}", - user_id, server_id - ) + format!(r"/v1/secret/users/{}/ssh_keys/{}", user_id, server_id) } /// Successful Vault GET response body for a KV v1 SSH key read. @@ -50,7 +47,10 @@ async fn test_get_public_key_vault_path_null_returns_400() { let client = reqwest::Client::new(); let resp = client - .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .get(&format!( + "{}/server/{}/ssh-key/public", + &app.address, server_id + )) .header("Authorization", "Bearer test-token") .send() .await @@ -60,8 +60,11 @@ async fn test_get_public_key_vault_path_null_returns_400() { let body: Value = resp.json().await.unwrap(); let msg = body["message"].as_str().unwrap_or(""); assert!( - msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate") || msg.to_lowercase().contains("delete"), - "Error message should mention Vault or remediation: {}", msg + msg.to_lowercase().contains("vault") + || msg.to_lowercase().contains("regenerate") + || msg.to_lowercase().contains("delete"), + "Error message should mention Vault or remediation: {}", + msg ); // Vault server must NOT have been called (no vault_key_path to use) assert_eq!(app.vault_server.received_requests().await.unwrap().len(), 0); @@ -94,18 +97,26 @@ async fn test_get_public_key_vault_returns_404_propagates_as_404() { let client = reqwest::Client::new(); let resp = client - .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .get(&format!( + "{}/server/{}/ssh-key/public", + &app.address, server_id + )) .header("Authorization", "Bearer test-token") .send() .await .expect("request failed"); - assert_eq!(resp.status().as_u16(), 404, "Should be 404 when Vault returns 404"); + assert_eq!( + resp.status().as_u16(), + 404, + "Should be 404 when Vault returns 404" + ); let body: Value = resp.json().await.unwrap(); let msg = body["message"].as_str().unwrap_or(""); assert!( msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate"), - "Error message should mention Vault: {}", msg + "Error message should mention Vault: {}", + msg ); } @@ -118,18 +129,15 @@ async fn test_get_public_key_no_active_key_returns_404() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = common::create_test_server( - &app.db_pool, - "test_user_id", - project_id, - "none", - None, - ) - .await; + let server_id = + common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; let client = reqwest::Client::new(); let resp = client - .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .get(&format!( + "{}/server/{}/ssh-key/public", + &app.address, server_id + )) .header("Authorization", "Bearer test-token") .send() .await @@ -159,18 +167,19 @@ async fn test_get_public_key_success() { Mock::given(method("GET")) .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) - .respond_with( - ResponseTemplate::new(200).set_body_json(vault_key_response( - expected_pub_key, - "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", - )), - ) + .respond_with(ResponseTemplate::new(200).set_body_json(vault_key_response( + expected_pub_key, + "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + ))) .mount(&app.vault_server) .await; let client = reqwest::Client::new(); let resp = client - .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) + .get(&format!( + "{}/server/{}/ssh-key/public", + &app.address, server_id + )) .header("Authorization", "Bearer test-token") .send() .await @@ -198,14 +207,8 @@ async fn test_generate_key_vault_down_returns_private_key_inline() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = common::create_test_server( - &app.db_pool, - "test_user_id", - project_id, - "none", - None, - ) - .await; + let server_id = + common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; // Vault is down — POST returns 500 Mock::given(method("POST")) @@ -216,13 +219,20 @@ async fn test_generate_key_vault_down_returns_private_key_inline() { let client = reqwest::Client::new(); let resp = client - .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .post(&format!( + "{}/server/{}/ssh-key/generate", + &app.address, server_id + )) .header("Authorization", "Bearer test-token") .send() .await .expect("request failed"); - assert_eq!(resp.status().as_u16(), 200, "Generate should succeed even when Vault is down"); + assert_eq!( + resp.status().as_u16(), + 200, + "Generate should succeed even when Vault is down" + ); let body: Value = resp.json().await.unwrap(); // Private key must be returned inline so user can save it @@ -259,14 +269,8 @@ async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = common::create_test_server( - &app.db_pool, - "test_user_id", - project_id, - "none", - None, - ) - .await; + let server_id = + common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; // Vault is up — POST returns 204 Mock::given(method("POST")) @@ -277,7 +281,10 @@ async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { let client = reqwest::Client::new(); let resp = client - .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .post(&format!( + "{}/server/{}/ssh-key/generate", + &app.address, server_id + )) .header("Authorization", "Bearer test-token") .send() .await @@ -291,7 +298,10 @@ async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { body["item"]["private_key"].is_null() || !body["item"]["private_key"].is_string(), "Private key must NOT be returned when Vault stored it successfully" ); - assert!(body["item"]["public_key"].is_string(), "Public key must be present"); + assert!( + body["item"]["public_key"].is_string(), + "Public key must be present" + ); // DB: vault_key_path must be set let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") @@ -328,7 +338,10 @@ async fn test_generate_key_already_active_returns_400() { let client = reqwest::Client::new(); let resp = client - .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) + .post(&format!( + "{}/server/{}/ssh-key/generate", + &app.address, server_id + )) .header("Authorization", "Bearer test-token") .send() .await @@ -397,14 +410,8 @@ async fn test_delete_key_none_returns_400() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = common::create_test_server( - &app.db_pool, - "test_user_id", - project_id, - "none", - None, - ) - .await; + let server_id = + common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; let client = reqwest::Client::new(); let resp = client @@ -431,24 +438,26 @@ async fn test_ssh_key_endpoints_require_auth() { let client = reqwest::Client::new(); let endpoints: &[(&str, &str)] = &[ - ("GET", "/server/1/ssh-key/public"), - ("POST", "/server/1/ssh-key/generate"), + ("GET", "/server/1/ssh-key/public"), + ("POST", "/server/1/ssh-key/generate"), ("DELETE", "/server/1/ssh-key"), ]; for (verb, path) in endpoints { let req = match *verb { - "GET" => client.get(&format!("{}{}", &app.address, path)), - "POST" => client.post(&format!("{}{}", &app.address, path)), + "GET" => client.get(&format!("{}{}", &app.address, path)), + "POST" => client.post(&format!("{}{}", &app.address, path)), "DELETE" => client.delete(&format!("{}{}", &app.address, path)), - _ => unreachable!(), + _ => unreachable!(), }; let resp = req.send().await.expect("request failed"); let status = resp.status().as_u16(); assert!( status == 400 || status == 401 || status == 403 || status == 404, "{} {} without auth should return 400/401/403, got {}", - verb, path, status + verb, + path, + status ); } } From 20da7eabf26a89788ba6bfd20366f646b7b0849e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:23:37 +0000 Subject: [PATCH 10/13] Revert unrelated formatting changes, keep only test additions Co-authored-by: vsilent <42473+vsilent@users.noreply.github.com> Agent-Logs-Url: https://github.com/trydirect/stacker/sessions/07304b5d-2754-4f90-b791-562493cc6454 --- src/bin/stacker.rs | 287 +++++------- src/cli/ai_client.rs | 115 ++--- src/cli/ai_scanner.rs | 33 +- src/cli/config_parser.rs | 117 +++-- src/cli/credentials.rs | 42 +- src/cli/deployment_lock.rs | 8 +- src/cli/detector.rs | 5 +- src/cli/error.rs | 82 +--- src/cli/generator/compose.rs | 33 +- src/cli/generator/dockerfile.rs | 11 +- src/cli/install_runner.rs | 108 ++--- src/cli/progress.rs | 5 +- src/cli/proxy_manager.rs | 40 +- src/cli/service_catalog.rs | 88 ++-- src/cli/stacker_client.rs | 308 ++++++------- src/connectors/user_service/app.rs | 4 +- src/connectors/user_service/install.rs | 5 +- src/connectors/user_service/mod.rs | 2 +- src/console/commands/cli/agent.rs | 186 +++----- src/console/commands/cli/ai.rs | 148 +++---- src/console/commands/cli/ci.rs | 20 +- src/console/commands/cli/config.rs | 58 +-- src/console/commands/cli/deploy.rs | 556 ++++++++---------------- src/console/commands/cli/destroy.rs | 14 +- src/console/commands/cli/init.rs | 84 ++-- src/console/commands/cli/list.rs | 43 +- src/console/commands/cli/login.rs | 4 +- src/console/commands/cli/logs.rs | 30 +- src/console/commands/cli/marketplace.rs | 12 +- src/console/commands/cli/pipe.rs | 14 +- src/console/commands/cli/proxy.rs | 17 +- src/console/commands/cli/resolve.rs | 11 +- src/console/commands/cli/secrets.rs | 13 +- src/console/commands/cli/service.rs | 47 +- src/console/commands/cli/ssh_key.rs | 129 ++---- src/console/commands/cli/status.rs | 33 +- src/console/commands/cli/submit.rs | 13 +- src/console/commands/mq/listener.rs | 6 +- src/console/main.rs | 4 +- src/db/agent_audit_log.rs | 5 +- src/db/marketplace.rs | 3 +- src/db/project_app.rs | 22 +- src/forms/project/volume.rs | 41 +- src/forms/status_panel.rs | 26 +- src/helpers/security_validator.rs | 208 +++------ src/helpers/ssh_client.rs | 24 +- src/mcp/registry.rs | 58 ++- src/mcp/tools/agent_control.rs | 18 +- src/mcp/tools/ansible_roles.rs | 49 +-- src/mcp/tools/cloud.rs | 3 +- src/mcp/tools/firewall.rs | 4 +- src/mcp/tools/install_preview.rs | 14 +- src/mcp/tools/marketplace_admin.rs | 6 +- src/mcp/tools/project.rs | 6 +- src/mcp/tools/user_service/mcp.rs | 3 +- src/models/pipe.rs | 6 +- src/project_app/hydration.rs | 5 +- src/project_app/upsert.rs | 50 ++- src/routes/agent/link.rs | 33 +- src/routes/agent/login.rs | 18 +- src/routes/agent/snapshot.rs | 34 +- src/routes/cloud/add.rs | 5 +- src/routes/cloud/update.rs | 5 +- src/routes/command/create.rs | 14 +- src/routes/deployment/force_complete.rs | 12 +- src/routes/deployment/status.rs | 36 +- src/routes/marketplace/admin.rs | 15 +- src/routes/marketplace/creator.rs | 6 +- src/routes/marketplace/mod.rs | 8 +- src/routes/marketplace/public.rs | 5 +- src/routes/mod.rs | 12 +- src/routes/project/app.rs | 2 +- src/routes/project/deploy.rs | 63 ++- src/routes/project/discover.rs | 6 +- src/routes/server/delete.rs | 31 +- src/routes/server/get.rs | 4 +- src/routes/server/ssh_key.rs | 33 +- src/startup.rs | 11 +- tests/agent_login_link.rs | 6 +- tests/cli_config.rs | 10 +- tests/cli_deploy.rs | 14 +- tests/cli_destroy.rs | 5 +- tests/cli_init.rs | 18 +- tests/cli_logs.rs | 5 +- tests/cli_proxy.rs | 22 +- tests/cli_status.rs | 5 +- tests/cli_update.rs | 4 +- tests/common/mod.rs | 11 +- tests/marketplace_mine.rs | 24 +- tests/server_ssh.rs | 131 +++--- 90 files changed, 1513 insertions(+), 2381 deletions(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 3b35b4c6..b3d86add 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -887,9 +887,9 @@ fn get_command( org, domain, auth_url, - } => Box::new(stacker::console::commands::cli::login::LoginCommand::new( - org, domain, auth_url, - )), + } => Box::new( + stacker::console::commands::cli::login::LoginCommand::new(org, domain, auth_url), + ), StackerCommands::Init { app_type, with_proxy, @@ -945,39 +945,37 @@ fn get_command( stacker::console::commands::cli::destroy::DestroyCommand::new(volumes, confirm), ), StackerCommands::Config { command: cfg_cmd } => match cfg_cmd { - ConfigCommands::Validate { file } => { - Box::new(stacker::console::commands::cli::config::ConfigValidateCommand::new(file)) - } - ConfigCommands::Show { file } => { - Box::new(stacker::console::commands::cli::config::ConfigShowCommand::new(file)) - } - ConfigCommands::Example => { - Box::new(stacker::console::commands::cli::config::ConfigExampleCommand::new()) - } + ConfigCommands::Validate { file } => Box::new( + stacker::console::commands::cli::config::ConfigValidateCommand::new(file), + ), + ConfigCommands::Show { file } => Box::new( + stacker::console::commands::cli::config::ConfigShowCommand::new(file), + ), + ConfigCommands::Example => Box::new( + stacker::console::commands::cli::config::ConfigExampleCommand::new(), + ), ConfigCommands::Fix { file, interactive } => Box::new( stacker::console::commands::cli::config::ConfigFixCommand::new(file, interactive), ), - ConfigCommands::Lock { file } => { - Box::new(stacker::console::commands::cli::config::ConfigLockCommand::new(file)) - } - ConfigCommands::Unlock { file } => { - Box::new(stacker::console::commands::cli::config::ConfigUnlockCommand::new(file)) - } + ConfigCommands::Lock { file } => Box::new( + stacker::console::commands::cli::config::ConfigLockCommand::new(file), + ), + ConfigCommands::Unlock { file } => Box::new( + stacker::console::commands::cli::config::ConfigUnlockCommand::new(file), + ), ConfigCommands::Setup { command } => match command { ConfigSetupCommands::Cloud { file } => Box::new( stacker::console::commands::cli::config::ConfigSetupCloudCommand::new(file), ), ConfigSetupCommands::RemotePayload { file, out } => Box::new( - stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new( - file, out, - ), + stacker::console::commands::cli::config::ConfigSetupRemotePayloadCommand::new(file, out), ), }, }, StackerCommands::Ai(ai_args) => match ai_args.command { - None => Box::new(stacker::console::commands::cli::ai::AiChatCommand::new( - ai_args.write, - )), + None => Box::new( + stacker::console::commands::cli::ai::AiChatCommand::new(ai_args.write), + ), Some(AiCommands::Ask { question, context, @@ -989,40 +987,40 @@ fn get_command( .with_write(ai_args.write || write), ), }, - StackerCommands::Proxy { command: proxy_cmd } => match proxy_cmd { + StackerCommands::Proxy { + command: proxy_cmd, + } => match proxy_cmd { ProxyCommands::Add { domain, upstream, ssl, } => Box::new( - stacker::console::commands::cli::proxy::ProxyAddCommand::new(domain, upstream, ssl), + stacker::console::commands::cli::proxy::ProxyAddCommand::new( + domain, upstream, ssl, + ), ), ProxyCommands::Detect { json, deployment } => Box::new( stacker::console::commands::cli::proxy::ProxyDetectCommand::new(json, deployment), ), }, StackerCommands::List { command: list_cmd } => match list_cmd { - ListCommands::Projects { json } => { - Box::new(stacker::console::commands::cli::list::ListProjectsCommand::new(json)) - } - ListCommands::Deployments { - json, - project, - limit, - } => Box::new( + ListCommands::Projects { json } => Box::new( + stacker::console::commands::cli::list::ListProjectsCommand::new(json), + ), + ListCommands::Deployments { json, project, limit } => Box::new( stacker::console::commands::cli::list::ListDeploymentsCommand::new( json, project, limit, ), ), - ListCommands::Servers { json } => { - Box::new(stacker::console::commands::cli::list::ListServersCommand::new(json)) - } - ListCommands::SshKeys { json } => { - Box::new(stacker::console::commands::cli::list::ListSshKeysCommand::new(json)) - } - ListCommands::Clouds { json } => { - Box::new(stacker::console::commands::cli::list::ListCloudsCommand::new(json)) - } + ListCommands::Servers { json } => Box::new( + stacker::console::commands::cli::list::ListServersCommand::new(json), + ), + ListCommands::SshKeys { json } => Box::new( + stacker::console::commands::cli::list::ListSshKeysCommand::new(json), + ), + ListCommands::Clouds { json } => Box::new( + stacker::console::commands::cli::list::ListCloudsCommand::new(json), + ), }, StackerCommands::SshKey { command: ssh_cmd } => match ssh_cmd { SshKeyCommands::Generate { server_id, save_to } => Box::new( @@ -1039,9 +1037,7 @@ fn get_command( private_key, } => Box::new( stacker::console::commands::cli::ssh_key::SshKeyUploadCommand::new( - server_id, - public_key, - private_key, + server_id, public_key, private_key, ), ), SshKeyCommands::Inject { @@ -1062,18 +1058,12 @@ fn get_command( ServiceCommands::Remove { name, file } => Box::new( stacker::console::commands::cli::service::ServiceRemoveCommand::new(name, file), ), - ServiceCommands::List { online } => { - Box::new(stacker::console::commands::cli::service::ServiceListCommand::new(online)) - } - }, - StackerCommands::Resolve { - confirm, - force, - deployment, - } => Box::new( - stacker::console::commands::cli::resolve::ResolveCommand::new( - confirm, force, deployment, + ServiceCommands::List { online } => Box::new( + stacker::console::commands::cli::service::ServiceListCommand::new(online), ), + }, + StackerCommands::Resolve { confirm, force, deployment } => Box::new( + stacker::console::commands::cli::resolve::ResolveCommand::new(confirm, force, deployment), ), StackerCommands::Update { channel } => Box::new( stacker::console::commands::cli::update::UpdateCommand::new(channel), @@ -1106,91 +1096,36 @@ fn get_command( StackerCommands::Pipe { command: pipe_cmd } => { use stacker::console::commands::cli::pipe; match pipe_cmd { - PipeCommands::Scan { - app, - protocols, - json, - deployment, - } => Box::new(pipe::PipeScanCommand::new(app, protocols, json, deployment)), - PipeCommands::Create { - source, - target, - manual, - json, - deployment, - } => Box::new(pipe::PipeCreateCommand::new( - source, target, manual, json, deployment, - )), - PipeCommands::List { json, deployment } => { - Box::new(pipe::PipeListCommand::new(json, deployment)) - } + PipeCommands::Scan { app, protocols, json, deployment } => Box::new( + pipe::PipeScanCommand::new(app, protocols, json, deployment), + ), + PipeCommands::Create { source, target, manual, json, deployment } => Box::new( + pipe::PipeCreateCommand::new(source, target, manual, json, deployment), + ), + PipeCommands::List { json, deployment } => Box::new( + pipe::PipeListCommand::new(json, deployment), + ), } - } + }, StackerCommands::Agent { command: agent_cmd } => { use stacker::console::commands::cli::agent; match agent_cmd { - AgentCommands::Health { - app, - system, - json, - deployment, - } => Box::new(agent::AgentHealthCommand::new( - app, json, deployment, system, - )), - AgentCommands::Logs { - app, - limit, - json, - deployment, - } => Box::new(agent::AgentLogsCommand::new( - app, - Some(limit), - json, - deployment, - )), - AgentCommands::Restart { - app, - force, - json, - deployment, - } => Box::new(agent::AgentRestartCommand::new( - app, force, json, deployment, - )), - AgentCommands::DeployApp { - app, - image, - force, - json, - deployment, - } => Box::new(agent::AgentDeployAppCommand::new( - app, image, force, json, deployment, - )), - AgentCommands::RemoveApp { - app, - volumes, - remove_image, - force, - json, - deployment, - } => Box::new(agent::AgentRemoveAppCommand::new( - app, - volumes, - remove_image, - force, - json, - deployment, - )), - AgentCommands::ConfigureFirewall { - action, - list, - app, - public_ports, - private_ports, - persist, - force, - json, - deployment, - } => { + AgentCommands::Health { app, system, json, deployment } => Box::new( + agent::AgentHealthCommand::new(app, json, deployment, system), + ), + AgentCommands::Logs { app, limit, json, deployment } => Box::new( + agent::AgentLogsCommand::new(app, Some(limit), json, deployment), + ), + AgentCommands::Restart { app, force, json, deployment } => Box::new( + agent::AgentRestartCommand::new(app, force, json, deployment), + ), + AgentCommands::DeployApp { app, image, force, json, deployment } => Box::new( + agent::AgentDeployAppCommand::new(app, image, force, json, deployment), + ), + AgentCommands::RemoveApp { app, volumes, remove_image, force, json, deployment } => Box::new( + agent::AgentRemoveAppCommand::new(app, volumes, remove_image, force, json, deployment), + ), + AgentCommands::ConfigureFirewall { action, list, app, public_ports, private_ports, persist, force, json, deployment } => { let effective_action = if list { "list".to_string() } else { action }; Box::new(agent::AgentConfigureFirewallCommand::new( effective_action, @@ -1203,50 +1138,31 @@ fn get_command( deployment, )) } - AgentCommands::ConfigureProxy { - app, - domain, - port, - ssl, - action, - force, - json, - deployment, - } => Box::new(agent::AgentConfigureProxyCommand::new( - app, domain, port, ssl, action, force, json, deployment, - )), + AgentCommands::ConfigureProxy { app, domain, port, ssl, action, force, json, deployment } => Box::new( + agent::AgentConfigureProxyCommand::new(app, domain, port, ssl, action, force, json, deployment), + ), AgentCommands::List { command: list_cmd } => match list_cmd { - AgentListCommands::Apps { json, deployment } => { - Box::new(agent::AgentListAppsCommand::new(json, deployment)) - } - AgentListCommands::Containers { json, deployment } => { - Box::new(agent::AgentListContainersCommand::new(json, deployment)) - } + AgentListCommands::Apps { json, deployment } => Box::new( + agent::AgentListAppsCommand::new(json, deployment), + ), + AgentListCommands::Containers { json, deployment } => Box::new( + agent::AgentListContainersCommand::new(json, deployment), + ), }, - AgentCommands::Status { json, deployment } => { - Box::new(agent::AgentStatusCommand::new(json, deployment)) - } - AgentCommands::History { json, deployment } => { - Box::new(agent::AgentHistoryCommand::new(json, deployment)) - } - AgentCommands::Exec { - command_type, - params, - timeout, - json, - deployment, - } => Box::new(agent::AgentExecCommand::new( - command_type, - params, - timeout, - json, - deployment, - )), - AgentCommands::Install { file, json } => { - Box::new(agent::AgentInstallCommand::new(file, json)) - } + AgentCommands::Status { json, deployment } => Box::new( + agent::AgentStatusCommand::new(json, deployment), + ), + AgentCommands::History { json, deployment } => Box::new( + agent::AgentHistoryCommand::new(json, deployment), + ), + AgentCommands::Exec { command_type, params, timeout, json, deployment } => Box::new( + agent::AgentExecCommand::new(command_type, params, timeout, json, deployment), + ), + AgentCommands::Install { file, json } => Box::new( + agent::AgentInstallCommand::new(file, json), + ), } - } + }, StackerCommands::Submit { file, version, @@ -1254,14 +1170,11 @@ fn get_command( category, plan_type, price, - } => Box::new(stacker::console::commands::cli::submit::SubmitCommand::new( - file, - version, - description, - category, - plan_type, - price, - )), + } => Box::new( + stacker::console::commands::cli::submit::SubmitCommand::new( + file, version, description, category, plan_type, price, + ), + ), StackerCommands::Marketplace { command: mkt_cmd } => match mkt_cmd { MarketplaceCommands::Status { name, json } => Box::new( stacker::console::commands::cli::marketplace::MarketplaceStatusCommand::new( diff --git a/src/cli/ai_client.rs b/src/cli/ai_client.rs index 206f0e70..2139477c 100644 --- a/src/cli/ai_client.rs +++ b/src/cli/ai_client.rs @@ -169,28 +169,13 @@ pub struct ChatMessage { impl ChatMessage { pub fn system(content: impl Into) -> Self { - Self { - role: "system".to_string(), - content: content.into(), - tool_calls: None, - tool_call_id: None, - } + Self { role: "system".to_string(), content: content.into(), tool_calls: None, tool_call_id: None } } pub fn user(content: impl Into) -> Self { - Self { - role: "user".to_string(), - content: content.into(), - tool_calls: None, - tool_call_id: None, - } + Self { role: "user".to_string(), content: content.into(), tool_calls: None, tool_call_id: None } } pub fn tool_result(id: Option, content: impl Into) -> Self { - Self { - role: "tool".to_string(), - content: content.into(), - tool_calls: None, - tool_call_id: id, - } + Self { role: "tool".to_string(), content: content.into(), tool_calls: None, tool_call_id: id } } } @@ -224,8 +209,7 @@ pub enum AiResponse { pub fn write_file_tool() -> ToolDef { ToolDef { name: "write_file".to_string(), - description: "Write content to a file on disk. Creates parent directories as needed." - .to_string(), + description: "Write content to a file on disk. Creates parent directories as needed.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -255,8 +239,7 @@ pub fn list_directory_tool() -> ToolDef { ToolDef { name: "list_directory".to_string(), description: "List files and folders in a directory within the project. \ - Use '.' for the project root." - .to_string(), + Use '.' for the project root.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -273,8 +256,7 @@ pub fn list_directory_tool() -> ToolDef { pub fn config_validate_tool() -> ToolDef { ToolDef { name: "config_validate".to_string(), - description: "Validate the stacker.yml configuration file and report any errors." - .to_string(), + description: "Validate the stacker.yml configuration file and report any errors.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": {}, @@ -286,8 +268,7 @@ pub fn config_validate_tool() -> ToolDef { pub fn config_show_tool() -> ToolDef { ToolDef { name: "config_show".to_string(), - description: "Show the fully-resolved stacker.yml configuration (with env vars expanded)." - .to_string(), + description: "Show the fully-resolved stacker.yml configuration (with env vars expanded).".to_string(), parameters: serde_json::json!({ "type": "object", "properties": {}, @@ -311,9 +292,7 @@ pub fn stacker_status_tool() -> ToolDef { pub fn stacker_logs_tool() -> ToolDef { ToolDef { name: "stacker_logs".to_string(), - description: - "Retrieve container logs. Optionally filter by service name and limit line count." - .to_string(), + description: "Retrieve container logs. Optionally filter by service name and limit line count.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -335,8 +314,7 @@ pub fn stacker_deploy_tool() -> ToolDef { ToolDef { name: "stacker_deploy".to_string(), description: "Build and deploy the stack. Use dry_run=true to preview what would happen \ - without making changes." - .to_string(), + without making changes.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -362,8 +340,7 @@ pub fn stacker_deploy_tool() -> ToolDef { pub fn proxy_add_tool() -> ToolDef { ToolDef { name: "proxy_add".to_string(), - description: "Add a reverse-proxy entry mapping a domain to an upstream service." - .to_string(), + description: "Add a reverse-proxy entry mapping a domain to an upstream service.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -389,8 +366,7 @@ pub fn proxy_add_tool() -> ToolDef { pub fn proxy_detect_tool() -> ToolDef { ToolDef { name: "proxy_detect".to_string(), - description: "Detect running reverse-proxy containers (nginx, Traefik, etc.) on the host." - .to_string(), + description: "Detect running reverse-proxy containers (nginx, Traefik, etc.) on the host.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": {}, @@ -405,8 +381,7 @@ pub fn agent_health_tool() -> ToolDef { ToolDef { name: "agent_health".to_string(), description: "Check container health on the remote deployment via the Status Panel agent. \ - Returns container states, resource usage, and health metrics." - .to_string(), + Returns container states, resource usage, and health metrics.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -428,8 +403,7 @@ pub fn agent_status_tool() -> ToolDef { ToolDef { name: "agent_status".to_string(), description: "Get the Status Panel agent status, including agent version, \ - last heartbeat, container states, and recent command history." - .to_string(), + last heartbeat, container states, and recent command history.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -447,8 +421,7 @@ pub fn agent_logs_tool() -> ToolDef { ToolDef { name: "agent_logs".to_string(), description: "Fetch container logs from the remote deployment via the Status Panel agent. \ - Logs are automatically redacted for safety." - .to_string(), + Logs are automatically redacted for safety.".to_string(), parameters: serde_json::json!({ "type": "object", "properties": { @@ -685,10 +658,11 @@ impl AiProvider for OpenAiProvider { }); } - let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { - provider: "openai".to_string(), - message: format!("Failed to parse response: {}", e), - })?; + let json: serde_json::Value = + response.json().map_err(|e| CliError::AiProviderError { + provider: "openai".to_string(), + message: format!("Failed to parse response: {}", e), + })?; let msg = &json["choices"][0]["message"]; let content = msg["content"].as_str().unwrap_or("").to_string(); @@ -703,13 +677,9 @@ impl AiProvider for OpenAiProvider { let name = func["name"].as_str()?.to_string(); // OpenAI encodes arguments as a JSON string let arguments: serde_json::Value = - serde_json::from_str(func["arguments"].as_str().unwrap_or("{}")) - .unwrap_or(serde_json::json!({})); - Some(ToolCall { - id, - name, - arguments, - }) + serde_json::from_str(func["arguments"].as_str().unwrap_or("{}") + ).unwrap_or(serde_json::json!({})); + Some(ToolCall { id, name, arguments }) }) .collect(); return Ok(AiResponse::ToolCalls(content, calls)); @@ -927,11 +897,7 @@ impl OllamaProvider { let timeout_secs = resolve_timeout(config.timeout); - Self { - endpoint, - model, - timeout_secs, - } + Self { endpoint, model, timeout_secs } } } @@ -1020,10 +986,11 @@ impl AiProvider for OllamaProvider { }); } - let json: serde_json::Value = response.json().map_err(|e| CliError::AiProviderError { - provider: "ollama".to_string(), - message: format!("Failed to parse response: {}", e), - })?; + let json: serde_json::Value = + response.json().map_err(|e| CliError::AiProviderError { + provider: "ollama".to_string(), + message: format!("Failed to parse response: {}", e), + })?; let msg = &json["message"]; let content = msg["content"].as_str().unwrap_or("").to_string(); @@ -1046,11 +1013,7 @@ impl AiProvider for OllamaProvider { } else { serde_json::json!({}) }; - Some(ToolCall { - id: None, - name, - arguments, - }) + Some(ToolCall { id: None, name, arguments }) }) .collect(); return Ok(AiResponse::ToolCalls(content, calls)); @@ -1178,11 +1141,12 @@ pub fn ollama_complete_streaming( continue; } - let json: serde_json::Value = - serde_json::from_str(trimmed).map_err(|e| CliError::AiProviderError { + let json: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| { + CliError::AiProviderError { provider: "ollama".to_string(), message: format!("Invalid streaming chunk: {}", e), - })?; + } + })?; if let Some(chunk) = json["message"]["content"].as_str() { eprint!("{}", chunk); @@ -1316,7 +1280,10 @@ pub fn build_compose_prompt(ctx: &PromptContext) -> (String, String) { /// Build a prompt for troubleshooting deployment issues. pub fn build_troubleshoot_prompt(ctx: &PromptContext) -> (String, String) { - let error = ctx.error_log.as_deref().unwrap_or("No error log provided"); + let error = ctx + .error_log + .as_deref() + .unwrap_or("No error log provided"); let prompt = format!( "Diagnose and fix the following deployment issue.\n\ @@ -1426,9 +1393,7 @@ mod tests { #[test] fn test_mock_ai_complete() { let provider = MockAiProvider::with_response("Use FROM node:lts-alpine"); - let result = provider - .complete("optimize dockerfile", "system context") - .unwrap(); + let result = provider.complete("optimize dockerfile", "system context").unwrap(); assert!(result.contains("node:lts-alpine")); } @@ -1549,9 +1514,7 @@ mod tests { project_type: Some(AppType::Python), files: vec![], error_log: None, - current_config: Some( - "version: '3'\nservices:\n web:\n image: python:3.11".to_string(), - ), + current_config: Some("version: '3'\nservices:\n web:\n image: python:3.11".to_string()), }; let (_, prompt) = build_compose_prompt(&ctx); diff --git a/src/cli/ai_scanner.rs b/src/cli/ai_scanner.rs index 61a756bd..deea3e6e 100644 --- a/src/cli/ai_scanner.rs +++ b/src/cli/ai_scanner.rs @@ -304,12 +304,14 @@ pub fn generate_config_with_ai_impl( // Validate that it's parseable YAML (but don't require it to be a valid StackerConfig // yet — the caller will do from_str() and report detailed errors) - serde_yaml::from_str::(&yaml).map_err(|e| CliError::AiProviderError { - provider: provider.name().to_string(), - message: format!( - "AI generated invalid YAML: {}. Raw response:\n{}", - e, raw_response - ), + serde_yaml::from_str::(&yaml).map_err(|e| { + CliError::AiProviderError { + provider: provider.name().to_string(), + message: format!( + "AI generated invalid YAML: {}. Raw response:\n{}", + e, raw_response + ), + } })?; Ok(yaml) @@ -321,11 +323,19 @@ pub fn strip_code_fences(text: &str) -> String { let trimmed = text.trim(); // Check for opening fence - let without_open = if trimmed.starts_with("```yaml") || trimmed.starts_with("```yml") { + let without_open = if trimmed.starts_with("```yaml") + || trimmed.starts_with("```yml") + { // Remove opening fence line - trimmed.splitn(2, '\n').nth(1).unwrap_or(trimmed) + trimmed + .splitn(2, '\n') + .nth(1) + .unwrap_or(trimmed) } else if trimmed.starts_with("```") { - trimmed.splitn(2, '\n').nth(1).unwrap_or(trimmed) + trimmed + .splitn(2, '\n') + .nth(1) + .unwrap_or(trimmed) } else { return trimmed.to_string(); }; @@ -685,10 +695,7 @@ env: assert_eq!(config.services.len(), 2); assert_eq!(config.services[0].name, "postgres"); assert_eq!(config.services[1].name, "redis"); - assert_eq!( - config.proxy.proxy_type, - crate::cli::config_parser::ProxyType::Nginx - ); + assert_eq!(config.proxy.proxy_type, crate::cli::config_parser::ProxyType::Nginx); assert!(config.monitoring.status_panel); } diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 03682af9..bb01f828 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -266,9 +266,8 @@ where match value { serde_yaml::Value::Null => Ok(Vec::new()), - serde_yaml::Value::Sequence(_) => { - serde_yaml::from_value(value).map_err(serde::de::Error::custom) - } + serde_yaml::Value::Sequence(_) => serde_yaml::from_value(value) + .map_err(serde::de::Error::custom), serde_yaml::Value::Mapping(map) => { let mut services = Vec::new(); @@ -640,8 +639,7 @@ impl StackerConfig { issues.push(ValidationIssue { severity: Severity::Error, code: "E001".to_string(), - message: "Cloud provider configuration is required for cloud deployment" - .to_string(), + message: "Cloud provider configuration is required for cloud deployment".to_string(), field: Some("deploy.cloud.provider".to_string()), }); } @@ -783,7 +781,11 @@ fn load_env_file_vars_from_yaml(path: &Path, raw_content: &str) -> HashMap String { - port_str.split(':').next().unwrap_or(port_str).to_string() + port_str + .split(':') + .next() + .unwrap_or(port_str) + .to_string() } /// Resolve `${VAR_NAME}` references in a string using process environment. @@ -835,15 +837,15 @@ fn resolve_env_vars_with_fallback( .collect(); for (full_match, var_name) in captures { - let value = - match std::env::var(&var_name) { - Ok(v) => v, - Err(_) => fallback_vars.get(&var_name).cloned().ok_or_else(|| { - CliError::EnvVarNotFound { - var_name: var_name.clone(), - } + let value = match std::env::var(&var_name) { + Ok(v) => v, + Err(_) => fallback_vars + .get(&var_name) + .cloned() + .ok_or_else(|| CliError::EnvVarNotFound { + var_name: var_name.clone(), })?, - }; + }; result = result.replace(&full_match, &value); } @@ -1192,12 +1194,12 @@ app: assert_eq!(config.app.app_type, AppType::Static); } - #[test] - fn test_from_file_resolves_env_from_env_file() { - let dir = TempDir::new().unwrap(); - fs::write(dir.path().join(".env"), "DOCKER_IMAGE=node:14-alpine\n").unwrap(); + #[test] + fn test_from_file_resolves_env_from_env_file() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join(".env"), "DOCKER_IMAGE=node:14-alpine\n").unwrap(); - let yaml = r#" + let yaml = r#" name: env-file-test env_file: .env app: @@ -1207,12 +1209,12 @@ app: deploy: target: local "#; - let config_path = dir.path().join("stacker.yml"); - fs::write(&config_path, yaml).unwrap(); + let config_path = dir.path().join("stacker.yml"); + fs::write(&config_path, yaml).unwrap(); - let config = StackerConfig::from_file(&config_path).unwrap(); - assert_eq!(config.app.image.as_deref(), Some("node:14-alpine")); - } + let config = StackerConfig::from_file(&config_path).unwrap(); + assert_eq!(config.app.image.as_deref(), Some("node:14-alpine")); + } #[test] fn test_parse_invalid_app_type_returns_error() { @@ -1259,9 +1261,9 @@ services: assert_eq!(config.services[2].ports.len(), 2); } - #[test] - fn test_parse_services_map() { - let yaml = r#" + #[test] + fn test_parse_services_map() { + let yaml = r#" name: svc-map-test services: web: @@ -1273,21 +1275,15 @@ services: image: redis:7-alpine "#; - let config = StackerConfig::from_str(yaml).unwrap(); - assert_eq!(config.services.len(), 2); - assert!(config - .services - .iter() - .any(|s| s.name == "web" && s.image == "nginx:alpine")); - assert!(config - .services - .iter() - .any(|s| s.name == "redis" && s.image == "redis:7-alpine")); - } + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.services.len(), 2); + assert!(config.services.iter().any(|s| s.name == "web" && s.image == "nginx:alpine")); + assert!(config.services.iter().any(|s| s.name == "redis" && s.image == "redis:7-alpine")); + } - #[test] - fn test_parse_services_map_infers_name_from_key() { - let yaml = r#" + #[test] + fn test_parse_services_map_infers_name_from_key() { + let yaml = r#" name: svc-map-key-test services: web: @@ -1295,11 +1291,11 @@ services: ports: ["8080:80"] "#; - let config = StackerConfig::from_str(yaml).unwrap(); - assert_eq!(config.services.len(), 1); - assert_eq!(config.services[0].name, "web"); - assert_eq!(config.services[0].image, "nginx:alpine"); - } + let config = StackerConfig::from_str(yaml).unwrap(); + assert_eq!(config.services.len(), 1); + assert_eq!(config.services[0].name, "web"); + assert_eq!(config.services[0].image, "nginx:alpine"); + } #[test] fn test_parse_proxy_domains() { @@ -1394,14 +1390,9 @@ ai: .iter() .filter(|i| i.severity == Severity::Error) .collect(); + assert!(!errors.is_empty(), "Expected validation error for missing cloud provider"); assert!( - !errors.is_empty(), - "Expected validation error for missing cloud provider" - ); - assert!( - errors - .iter() - .any(|e| e.field.as_deref() == Some("deploy.cloud.provider")), + errors.iter().any(|e| e.field.as_deref() == Some("deploy.cloud.provider")), "Expected field reference to deploy.cloud.provider" ); } @@ -1419,10 +1410,7 @@ ai: .iter() .filter(|i| i.severity == Severity::Error) .collect(); - assert!( - !errors.is_empty(), - "Expected validation error for missing server host" - ); + assert!(!errors.is_empty(), "Expected validation error for missing server host"); assert!( errors.iter().any(|e| e.message.contains("host")), "Expected 'host' mentioned in error" @@ -1450,7 +1438,10 @@ services: .iter() .filter(|i| i.severity == Severity::Warning) .collect(); - assert!(!warnings.is_empty(), "Expected warning about port conflict"); + assert!( + !warnings.is_empty(), + "Expected warning about port conflict" + ); assert!( warnings.iter().any(|w| w.message.contains("8080")), "Expected port 8080 in warning" @@ -1520,10 +1511,7 @@ services: .iter() .filter(|i| i.severity == Severity::Info) .collect(); - assert!( - errors.is_empty(), - "Expected no blocking errors, got: {errors:?}" - ); + assert!(errors.is_empty(), "Expected no blocking errors, got: {errors:?}"); assert!( infos .iter() @@ -1629,7 +1617,10 @@ services: assert_eq!(original.name, parsed.name); assert_eq!(original.app.app_type, parsed.app.app_type); assert_eq!(original.app.path, parsed.app.path); - assert_eq!(original.env.get("PORT"), parsed.env.get("PORT")); + assert_eq!( + original.env.get("PORT"), + parsed.env.get("PORT") + ); } #[test] diff --git a/src/cli/credentials.rs b/src/cli/credentials.rs index a40999fc..a95d0f68 100644 --- a/src/cli/credentials.rs +++ b/src/cli/credentials.rs @@ -122,7 +122,9 @@ impl FileCredentialStore { pub fn default_path() -> PathBuf { let base = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) - .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".config"))) + .or_else(|_| { + std::env::var("HOME").map(|h| PathBuf::from(h).join(".config")) + }) .unwrap_or_else(|_| PathBuf::from(".")); base.join("stacker").join("credentials.json") @@ -218,7 +220,10 @@ impl CredentialsManager { /// Load credentials and ensure they are present and not expired. /// Returns `CliError::LoginRequired` when absent, /// `CliError::TokenExpired` when expired. - pub fn require_valid_token(&self, feature: &str) -> Result { + pub fn require_valid_token( + &self, + feature: &str, + ) -> Result { let creds = self.store.load()?.ok_or_else(|| CliError::LoginRequired { feature: feature.to_string(), })?; @@ -256,9 +261,7 @@ const TOKEN_ENDPOINT: &str = "/auth/login"; fn is_direct_login_endpoint(auth_url: &str) -> bool { let url = auth_url.trim_end_matches('/').to_lowercase(); - url.ends_with("/auth/login") - || url.ends_with("/server/user/auth/login") - || url.ends_with("/login") + url.ends_with("/auth/login") || url.ends_with("/server/user/auth/login") || url.ends_with("/login") } /// Parameters for a login request. @@ -274,12 +277,8 @@ pub struct LoginRequest { /// Abstraction over the HTTP call to the OAuth token endpoint. /// Production uses `HttpOAuthClient`; tests can inject a mock. pub trait OAuthClient: Send + Sync { - fn request_token( - &self, - auth_url: &str, - email: &str, - password: &str, - ) -> Result; + fn request_token(&self, auth_url: &str, email: &str, password: &str) + -> Result; } /// Production OAuth client using `reqwest::blocking`. @@ -310,7 +309,10 @@ impl OAuthClient for HttpOAuthClient { let resp = if direct_login { client .post(&url) - .form(&[("email", email), ("password", password)]) + .form(&[ + ("email", email), + ("password", password), + ]) .send() } else { client @@ -398,12 +400,8 @@ mod tests { #[test] fn test_is_direct_login_endpoint_detection() { - assert!(is_direct_login_endpoint( - "https://dev.try.direct/server/user/auth/login" - )); - assert!(is_direct_login_endpoint( - "https://dev.try.direct/server/user/auth/login/" - )); + assert!(is_direct_login_endpoint("https://dev.try.direct/server/user/auth/login")); + assert!(is_direct_login_endpoint("https://dev.try.direct/server/user/auth/login/")); assert!(!is_direct_login_endpoint("https://api.try.direct")); } @@ -541,9 +539,8 @@ mod tests { }; let creds = StoredCredentials::from(resp); let diff = creds.expires_at - Utc::now(); - assert!( - diff.num_seconds() > (ten_hours as i64) - 100 && diff.num_seconds() <= ten_hours as i64 - ); + assert!(diff.num_seconds() > (ten_hours as i64) - 100 + && diff.num_seconds() <= ten_hours as i64); } #[test] @@ -812,8 +809,7 @@ mod tests { #[test] fn test_login_invalid_credentials_returns_error() { let (manager, _) = make_manager(); - let oauth = - MockOAuthClient::failure("Authentication failed (HTTP 401 Unauthorized): invalid"); + let oauth = MockOAuthClient::failure("Authentication failed (HTTP 401 Unauthorized): invalid"); let request = LoginRequest { email: "bad@example.com".into(), password: "wrong".into(), diff --git a/src/cli/deployment_lock.rs b/src/cli/deployment_lock.rs index 7255e011..159f550f 100644 --- a/src/cli/deployment_lock.rs +++ b/src/cli/deployment_lock.rs @@ -208,7 +208,13 @@ impl DeploymentLock { .server .as_ref() .and_then(|s| s.ssh_key.clone()) - .or_else(|| config.deploy.cloud.as_ref().and_then(|c| c.ssh_key.clone())); + .or_else(|| { + config + .deploy + .cloud + .as_ref() + .and_then(|c| c.ssh_key.clone()) + }); config.deploy.server = Some(ServerConfig { host: ip.clone(), diff --git a/src/cli/detector.rs b/src/cli/detector.rs index 11951d60..335cbf9d 100644 --- a/src/cli/detector.rs +++ b/src/cli/detector.rs @@ -124,7 +124,10 @@ const ENV_FILE_NAMES: &[&str] = &[".env"]; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /// Detect the project type and infrastructure files in a directory. -pub fn detect_project(project_path: &Path, fs: &dyn FileSystem) -> ProjectDetection { +pub fn detect_project( + project_path: &Path, + fs: &dyn FileSystem, +) -> ProjectDetection { let files = match fs.list_dir(project_path) { Ok(f) => f, Err(_) => return ProjectDetection::default(), diff --git a/src/cli/error.rs b/src/cli/error.rs index 6c38b113..8c90dd4c 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -10,46 +10,27 @@ use crate::cli::config_parser::DeployTarget; #[derive(Debug)] pub enum CliError { // Config errors - ConfigNotFound { - path: PathBuf, - }, - ConfigParseFailed { - source: serde_yaml::Error, - }, + ConfigNotFound { path: PathBuf }, + ConfigParseFailed { source: serde_yaml::Error }, ConfigValidation(String), - EnvVarNotFound { - var_name: String, - }, + EnvVarNotFound { var_name: String }, // Detection errors - DetectionFailed { - path: PathBuf, - reason: String, - }, + DetectionFailed { path: PathBuf, reason: String }, // Generator errors GeneratorError(String), - DockerfileExists { - path: PathBuf, - }, + DockerfileExists { path: PathBuf }, // Deployment errors - DeployFailed { - target: DeployTarget, - reason: String, - }, - LoginRequired { - feature: String, - }, + DeployFailed { target: DeployTarget, reason: String }, + LoginRequired { feature: String }, CloudProviderMissing, ServerHostMissing, // Runtime errors ContainerRuntimeUnavailable, - CommandFailed { - command: String, - exit_code: i32, - }, + CommandFailed { command: String, exit_code: i32 }, // Auth errors AuthFailed(String), @@ -57,29 +38,18 @@ pub enum CliError { // AI errors AiNotConfigured, - AiProviderError { - provider: String, - message: String, - }, + AiProviderError { provider: String, message: String }, // Proxy errors ProxyConfigFailed(String), // Secrets/env errors - EnvFileNotFound { - path: std::path::PathBuf, - }, - SecretKeyNotFound { - key: String, - }, + EnvFileNotFound { path: std::path::PathBuf }, + SecretKeyNotFound { key: String }, // Agent errors - AgentNotFound { - deployment_hash: String, - }, - AgentOffline { - deployment_hash: String, - }, + AgentNotFound { deployment_hash: String }, + AgentOffline { deployment_hash: String }, AgentCommandTimeout { command_id: String, /// Human-readable label for the command (e.g. "Fetching containers") @@ -88,10 +58,7 @@ pub enum CliError { last_status: String, deployment_hash: String, }, - AgentCommandFailed { - command_id: String, - error: String, - }, + AgentCommandFailed { command_id: String, error: String }, // IO errors Io(std::io::Error), @@ -268,11 +235,7 @@ pub struct ValidationIssue { impl fmt::Display for ValidationIssue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.field { - Some(field) => write!( - f, - "[{}] {}: {} ({})", - self.severity, self.code, self.message, field - ), + Some(field) => write!(f, "[{}] {}: {} ({})", self.severity, self.code, self.message, field), None => write!(f, "[{}] {}: {}", self.severity, self.code, self.message), } } @@ -323,7 +286,10 @@ mod tests { msg.contains("Configuration file not found"), "Expected 'Configuration file not found' in: {msg}" ); - assert!(msg.contains("/tmp/stacker.yml"), "Expected path in: {msg}"); + assert!( + msg.contains("/tmp/stacker.yml"), + "Expected path in: {msg}" + ); } #[test] @@ -332,7 +298,10 @@ mod tests { var_name: "DB_PASSWORD".to_string(), }; let msg = format!("{err}"); - assert!(msg.contains("DB_PASSWORD"), "Expected var name in: {msg}"); + assert!( + msg.contains("DB_PASSWORD"), + "Expected var name in: {msg}" + ); } #[test] @@ -425,10 +394,7 @@ mod tests { assert!(msg.contains("[error]"), "Expected severity in: {msg}"); assert!(msg.contains("E001"), "Expected code in: {msg}"); assert!(msg.contains("port conflict"), "Expected message in: {msg}"); - assert!( - msg.contains("services[0].ports"), - "Expected field in: {msg}" - ); + assert!(msg.contains("services[0].ports"), "Expected field in: {msg}"); } #[test] diff --git a/src/cli/generator/compose.rs b/src/cli/generator/compose.rs index 330b9467..6d0600c1 100644 --- a/src/cli/generator/compose.rs +++ b/src/cli/generator/compose.rs @@ -3,7 +3,9 @@ use std::convert::TryFrom; use std::fmt; use std::path::Path; -use crate::cli::config_parser::{AppType, ProxyType, ServiceDefinition, StackerConfig}; +use crate::cli::config_parser::{ + AppType, ProxyType, ServiceDefinition, StackerConfig, +}; use crate::cli::error::CliError; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -348,7 +350,9 @@ impl fmt::Display for ComposeDefinition { #[cfg(test)] mod tests { use super::*; - use crate::cli::config_parser::{AppSource, ConfigBuilder, DeployConfig, ProxyConfig, SslMode}; + use crate::cli::config_parser::{ + AppSource, ConfigBuilder, DeployConfig, ProxyConfig, SslMode, + }; use std::collections::HashMap; fn minimal_config(app_type: AppType) -> StackerConfig { @@ -477,7 +481,10 @@ mod tests { let compose = ComposeDefinition::try_from(&config).unwrap(); let traefik = compose.services.iter().find(|s| s.name == "traefik"); assert!(traefik.is_some()); - assert_eq!(traefik.unwrap().image.as_deref(), Some("traefik:v2.10")); + assert_eq!( + traefik.unwrap().image.as_deref(), + Some("traefik:v2.10") + ); } #[test] @@ -541,14 +548,8 @@ mod tests { let compose = ComposeDefinition::try_from(&config).unwrap(); let app = &compose.services[0]; - assert_eq!( - app.environment.get("NODE_ENV").map(|s| s.as_str()), - Some("production") - ); - assert_eq!( - app.environment.get("LOG_LEVEL").map(|s| s.as_str()), - Some("debug") - ); + assert_eq!(app.environment.get("NODE_ENV").map(|s| s.as_str()), Some("production")); + assert_eq!(app.environment.get("LOG_LEVEL").map(|s| s.as_str()), Some("debug")); } #[test] @@ -600,10 +601,7 @@ mod tests { assert_eq!(compose_svc.image.as_deref(), Some("mysql:8")); assert!(compose_svc.ports.contains(&"3306:3306".to_string())); assert_eq!( - compose_svc - .environment - .get("MYSQL_ROOT_PASSWORD") - .map(|s| s.as_str()), + compose_svc.environment.get("MYSQL_ROOT_PASSWORD").map(|s| s.as_str()), Some("pass") ); } @@ -637,7 +635,10 @@ mod tests { .unwrap(); let compose = ComposeDefinition::try_from(&config).unwrap(); - let npm = compose.services.iter().find(|s| s.name == "proxy-manager"); + let npm = compose + .services + .iter() + .find(|s| s.name == "proxy-manager"); assert!(npm.is_some()); let npm = npm.unwrap(); assert!(npm.ports.contains(&"81:81".to_string())); // NPM admin port diff --git a/src/cli/generator/dockerfile.rs b/src/cli/generator/dockerfile.rs index a043aef2..2ec6f42b 100644 --- a/src/cli/generator/dockerfile.rs +++ b/src/cli/generator/dockerfile.rs @@ -248,7 +248,11 @@ impl DockerfileBuilder { } /// Write Dockerfile to a path. Returns error if file already exists. - pub fn write_to(&self, path: &std::path::Path, overwrite: bool) -> Result<(), CliError> { + pub fn write_to( + &self, + path: &std::path::Path, + overwrite: bool, + ) -> Result<(), CliError> { if !overwrite && path.exists() { return Err(CliError::DockerfileExists { path: path.to_path_buf(), @@ -425,7 +429,10 @@ mod tests { #[test] fn test_multiple_expose_ports() { - let content = DockerfileBuilder::new().expose(80).expose(443).build(); + let content = DockerfileBuilder::new() + .expose(80) + .expose(443) + .build(); assert!(content.contains("EXPOSE 80")); assert!(content.contains("EXPOSE 443")); } diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index e857afd3..7d684d3c 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -207,11 +207,7 @@ impl DeployStrategy for LocalDeploy { }); } - let action = if context.dry_run { - "validated" - } else { - "started" - }; + let action = if context.dry_run { "validated" } else { "started" }; Ok(DeployResult { target: DeployTarget::Local, message: format!("Local deployment {} successfully", action), @@ -430,14 +426,12 @@ impl DeployStrategy for CloudDeploy { if let Some(cloud_cfg) = &config.deploy.cloud { if cloud_cfg.orchestrator == CloudOrchestrator::Remote { let cred_manager = CredentialsManager::with_default_store(); - let creds = - cred_manager.require_valid_token("remote cloud orchestrator deployment")?; + let creds = cred_manager.require_valid_token("remote cloud orchestrator deployment")?; if context.dry_run { return Ok(DeployResult { target: DeployTarget::Cloud, - message: "Remote cloud deploy dry-run validated payload and credentials" - .to_string(), + message: "Remote cloud deploy dry-run validated payload and credentials".to_string(), server_ip: None, deployment_id: None, project_id: None, @@ -464,7 +458,9 @@ impl DeployStrategy for CloudDeploy { .clone() .or_else(|| cloud_cfg.server.clone()); - let base_url = normalize_stacker_server_url(stacker_client::DEFAULT_STACKER_URL); + let base_url = normalize_stacker_server_url( + stacker_client::DEFAULT_STACKER_URL, + ); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -752,11 +748,7 @@ impl DeployStrategy for CloudDeploy { }); } - let action_str = if context.dry_run { - "plan completed" - } else { - "deployed" - }; + let action_str = if context.dry_run { "plan completed" } else { "deployed" }; Ok(DeployResult { target: DeployTarget::Cloud, message: format!("Cloud deployment {}", action_str), @@ -833,13 +825,7 @@ fn normalize_user_service_base_url(raw: &str) -> String { pub fn normalize_stacker_server_url(raw: &str) -> String { let mut url = raw.trim_end_matches('/').to_string(); // Strip known auth endpoints that might be stored as server_url - for suffix in [ - "/oauth_server/token", - "/auth/login", - "/server/user/auth/login", - "/login", - "/api", - ] { + for suffix in ["/oauth_server/token", "/auth/login", "/server/user/auth/login", "/login", "/api"] { if url.ends_with(suffix) { let len = url.len() - suffix.len(); url = url[..len].to_string(); @@ -935,10 +921,7 @@ fn resolve_remote_cloud_credentials(provider: &str) -> serde_json::Map {} @@ -958,16 +941,25 @@ pub(crate) fn resolve_docker_registry_credentials( let registry = config.deploy.registry.as_ref(); // Username: env var > config - let username = first_non_empty_env(&["STACKER_DOCKER_USERNAME", "DOCKER_USERNAME"]) - .or_else(|| registry.and_then(|r| r.username.clone())); + let username = first_non_empty_env(&[ + "STACKER_DOCKER_USERNAME", + "DOCKER_USERNAME", + ]) + .or_else(|| registry.and_then(|r| r.username.clone())); // Password: env var > config - let password = first_non_empty_env(&["STACKER_DOCKER_PASSWORD", "DOCKER_PASSWORD"]) - .or_else(|| registry.and_then(|r| r.password.clone())); + let password = first_non_empty_env(&[ + "STACKER_DOCKER_PASSWORD", + "DOCKER_PASSWORD", + ]) + .or_else(|| registry.and_then(|r| r.password.clone())); // Registry server: env var > config > default "docker.io" - let server = first_non_empty_env(&["STACKER_DOCKER_REGISTRY", "DOCKER_REGISTRY"]) - .or_else(|| registry.and_then(|r| r.server.clone())); + let server = first_non_empty_env(&[ + "STACKER_DOCKER_REGISTRY", + "DOCKER_REGISTRY", + ]) + .or_else(|| registry.and_then(|r| r.server.clone())); if let Some(u) = username { creds.insert("docker_username".to_string(), serde_json::Value::String(u)); @@ -988,12 +980,8 @@ fn build_remote_deploy_payload(config: &StackerConfig) -> serde_json::Value { let provider = cloud .map(|c| provider_code_for_remote(&c.provider.to_string()).to_string()) .unwrap_or_else(|| "htz".to_string()); - let region = cloud - .and_then(|c| c.region.clone()) - .unwrap_or_else(|| "nbg1".to_string()); - let server = cloud - .and_then(|c| c.size.clone()) - .unwrap_or_else(|| "cpx11".to_string()); + let region = cloud.and_then(|c| c.region.clone()).unwrap_or_else(|| "nbg1".to_string()); + let server = cloud.and_then(|c| c.size.clone()).unwrap_or_else(|| "cpx11".to_string()); let stack_code = config .project .identity @@ -1061,7 +1049,12 @@ fn validate_remote_deploy_payload(payload: &serde_json::Value) -> Result<(), Cli if key == "subscriptions" && !v.is_array() { missing.push("subscriptions(array)"); } - if key == "stack_code" && v.as_str().map(|s| s.trim().is_empty()).unwrap_or(true) { + if key == "stack_code" + && v + .as_str() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { missing.push("stack_code(non-empty)"); } } @@ -1070,7 +1063,10 @@ fn validate_remote_deploy_payload(payload: &serde_json::Value) -> Result<(), Cli } if !missing.is_empty() { - let identity_hint = if missing.iter().any(|item| item.contains("stack_code")) { + let identity_hint = if missing + .iter() + .any(|item| item.contains("stack_code")) + { " stack_code defaults to 'custom-stack'. Optionally set project.identity in stacker.yml to a registered catalog stack code." } else { "" @@ -1146,10 +1142,7 @@ fn validate_remote_deploy_payload(payload: &serde_json::Value) -> Result<(), Cli } #[allow(dead_code)] -fn persist_remote_payload_snapshot( - project_dir: &Path, - payload: &serde_json::Value, -) -> Option { +fn persist_remote_payload_snapshot(project_dir: &Path, payload: &serde_json::Value) -> Option { let stacker_dir = project_dir.join(".stacker"); let snapshot_path = stacker_dir.join("remote-payload.last.json"); @@ -1165,10 +1158,7 @@ fn persist_remote_payload_snapshot( let payload_str = match serde_json::to_string_pretty(payload) { Ok(s) => s, Err(err) => { - eprintln!( - "Warning: failed to serialize remote payload snapshot: {}", - err - ); + eprintln!("Warning: failed to serialize remote payload snapshot: {}", err); return None; } }; @@ -1225,13 +1215,13 @@ impl DeployStrategy for ServerDeploy { }); } - let server_host = config.deploy.server.as_ref().map(|s| s.host.clone()); + let server_host = config + .deploy + .server + .as_ref() + .map(|s| s.host.clone()); - let action_str = if context.dry_run { - "plan completed" - } else { - "deployed" - }; + let action_str = if context.dry_run { "plan completed" } else { "deployed" }; Ok(DeployResult { target: DeployTarget::Server, message: format!("Server deployment {}", action_str), @@ -1293,10 +1283,8 @@ fn extract_server_ip(stdout: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::cli::config_parser::{ - CloudConfig, CloudOrchestrator, CloudProvider, ConfigBuilder, ServerConfig, - }; use std::sync::Mutex; + use crate::cli::config_parser::{CloudConfig, CloudOrchestrator, CloudProvider, ConfigBuilder, ServerConfig}; // ── Mock executor ─────────────────────────────── @@ -1366,8 +1354,7 @@ mod tests { } fn sample_cloud_config() -> StackerConfig { - ConfigBuilder::new() - .name("test-cloud-app") + ConfigBuilder::new().name("test-cloud-app") .deploy_target(DeployTarget::Cloud) .cloud(CloudConfig { provider: CloudProvider::Hetzner, @@ -1522,8 +1509,7 @@ mod tests { } fn sample_server_config() -> StackerConfig { - ConfigBuilder::new() - .name("test-server-app") + ConfigBuilder::new().name("test-server-app") .deploy_target(DeployTarget::Server) .server(ServerConfig { host: "192.168.1.100".to_string(), diff --git a/src/cli/progress.rs b/src/cli/progress.rs index b94ede46..e27a1e2c 100644 --- a/src/cli/progress.rs +++ b/src/cli/progress.rs @@ -86,7 +86,10 @@ pub fn health_spinner(total_services: usize) -> ProgressBar { /// Update health check progress. pub fn update_health(pb: &ProgressBar, running: usize, total: usize) { - pb.set_message(format!("Container health: {}/{} running", running, total)); + pb.set_message(format!( + "Container health: {}/{} running", + running, total + )); } #[cfg(test)] diff --git a/src/cli/proxy_manager.rs b/src/cli/proxy_manager.rs index e700900c..c9afaa9e 100644 --- a/src/cli/proxy_manager.rs +++ b/src/cli/proxy_manager.rs @@ -53,11 +53,7 @@ impl ContainerRuntime for DockerCliRuntime { fn list_containers(&self) -> Result, CliError> { let output = std::process::Command::new("docker") - .args([ - "ps", - "--format", - "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}|{{.Status}}", - ]) + .args(["ps", "--format", "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}|{{.Status}}"]) .output() .map_err(|_| CliError::ContainerRuntimeUnavailable)?; @@ -298,8 +294,7 @@ pub fn generate_nginx_server_block(domain: &DomainConfig) -> String { block.push_str(&format!(" proxy_pass http://{};\n", domain.upstream)); block.push_str(" proxy_set_header Host $host;\n"); block.push_str(" proxy_set_header X-Real-IP $remote_addr;\n"); - block - .push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); + block.push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); block.push_str(" proxy_set_header X-Forwarded-Proto $scheme;\n"); block.push_str(" }\n"); block.push_str("}\n"); @@ -312,8 +307,7 @@ pub fn generate_nginx_server_block(domain: &DomainConfig) -> String { block.push_str(&format!(" proxy_pass http://{};\n", domain.upstream)); block.push_str(" proxy_set_header Host $host;\n"); block.push_str(" proxy_set_header X-Real-IP $remote_addr;\n"); - block - .push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); + block.push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); block.push_str(" proxy_set_header X-Forwarded-Proto $scheme;\n"); block.push_str(" }\n"); block.push_str("}\n"); @@ -325,7 +319,9 @@ pub fn generate_nginx_server_block(domain: &DomainConfig) -> String { /// Generate nginx configs for all domains in a proxy config. /// Returns a map of `filename → config content` for writing to `./nginx/conf.d/`. -pub fn generate_nginx_configs(domains: &[DomainConfig]) -> HashMap { +pub fn generate_nginx_configs( + domains: &[DomainConfig], +) -> HashMap { let mut configs = HashMap::new(); for domain in domains { @@ -422,8 +418,10 @@ mod tests { #[test] fn test_detect_proxy_nginx_from_containers() { - let runtime = - MockContainerRuntime::available_with(vec![app_container(), nginx_container()]); + let runtime = MockContainerRuntime::available_with(vec![ + app_container(), + nginx_container(), + ]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::Nginx); assert_eq!(detection.container_name.as_deref(), Some("nginx-proxy")); @@ -433,7 +431,10 @@ mod tests { #[test] fn test_detect_proxy_npm_from_containers() { - let runtime = MockContainerRuntime::available_with(vec![app_container(), npm_container()]); + let runtime = MockContainerRuntime::available_with(vec![ + app_container(), + npm_container(), + ]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); assert!(detection.ports.contains(&81)); @@ -441,7 +442,9 @@ mod tests { #[test] fn test_detect_proxy_traefik_from_containers() { - let runtime = MockContainerRuntime::available_with(vec![traefik_container()]); + let runtime = MockContainerRuntime::available_with(vec![ + traefik_container(), + ]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::Traefik); assert_eq!(detection.container_name.as_deref(), Some("traefik")); @@ -459,8 +462,10 @@ mod tests { fn test_detect_npm_takes_priority_over_nginx() { // NPM containers contain "nginx" in their image. NPM must be detected // first because its signature is checked before plain "nginx". - let runtime = - MockContainerRuntime::available_with(vec![npm_container(), nginx_container()]); + let runtime = MockContainerRuntime::available_with(vec![ + npm_container(), + nginx_container(), + ]); let detection = detect_proxy(&runtime).unwrap(); assert_eq!(detection.proxy_type, ProxyType::NginxProxyManager); } @@ -567,8 +572,7 @@ mod tests { #[test] fn test_parse_docker_ps_line() { - let line = - "abc123|my-nginx|nginx:alpine|0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp|Up 2 hours"; + let line = "abc123|my-nginx|nginx:alpine|0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp|Up 2 hours"; let info = parse_docker_ps_line(line); assert_eq!(info.id, "abc123"); assert_eq!(info.name, "my-nginx"); diff --git a/src/cli/service_catalog.rs b/src/cli/service_catalog.rs index bedad030..dea421ef 100644 --- a/src/cli/service_catalog.rs +++ b/src/cli/service_catalog.rs @@ -60,12 +60,11 @@ impl ServiceCatalog { } // Hardcoded catalog lookup - self.lookup_hardcoded(&canonical).ok_or_else(|| { - CliError::ConfigValidation(format!( + self.lookup_hardcoded(&canonical) + .ok_or_else(|| CliError::ConfigValidation(format!( "Unknown service '{}'. Run `stacker service list` to see available services.", service_name - )) - }) + ))) } /// List all available services from the hardcoded catalog. @@ -86,33 +85,24 @@ impl ServiceCatalog { if let Some(services) = stack_def.get("services") { if let Some(first_svc) = services.as_array().and_then(|arr| arr.first()) { let service = ServiceDefinition { - name: first_svc["name"].as_str().unwrap_or(slug).to_string(), - image: first_svc["image"].as_str().unwrap_or("").to_string(), - ports: first_svc["ports"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) + name: first_svc["name"].as_str() + .unwrap_or(slug).to_string(), + image: first_svc["image"].as_str() + .unwrap_or("").to_string(), + ports: first_svc["ports"].as_array() + .map(|arr| arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()) .unwrap_or_default(), - environment: first_svc["environment"] - .as_object() - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| { - v.as_str().map(|s| (k.clone(), s.to_string())) - }) - .collect() - }) + environment: first_svc["environment"].as_object() + .map(|obj| obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect()) .unwrap_or_default(), - volumes: first_svc["volumes"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) + volumes: first_svc["volumes"].as_array() + .map(|arr| arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()) .unwrap_or_default(), depends_on: Vec::new(), }; @@ -120,9 +110,7 @@ impl ServiceCatalog { return Ok(Some(CatalogEntry { code: slug.to_string(), name: template.name, - category: template - .category_code - .unwrap_or_else(|| "service".to_string()), + category: template.category_code.unwrap_or_else(|| "service".to_string()), description: template.description.unwrap_or_default(), service, related: vec![], @@ -255,6 +243,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── Cache ──────────────────────────────────────── CatalogEntry { code: "redis".into(), @@ -286,6 +275,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── Message Queues ─────────────────────────────── CatalogEntry { code: "rabbitmq".into(), @@ -305,6 +295,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── Proxies ────────────────────────────────────── CatalogEntry { code: "traefik".into(), @@ -357,6 +348,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── Web Applications ───────────────────────────── CatalogEntry { code: "wordpress".into(), @@ -378,6 +370,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec!["mysql".into(), "redis".into(), "traefik".into()], }, + // ── Search ─────────────────────────────────────── CatalogEntry { code: "elasticsearch".into(), @@ -407,15 +400,15 @@ fn build_hardcoded_catalog() -> Vec { name: "kibana".into(), image: "kibana:8.12.0".into(), ports: vec!["5601:5601".into()], - environment: HashMap::from([( - "ELASTICSEARCH_HOSTS".into(), - "http://elasticsearch:9200".into(), - )]), + environment: HashMap::from([ + ("ELASTICSEARCH_HOSTS".into(), "http://elasticsearch:9200".into()), + ]), volumes: vec![], depends_on: vec!["elasticsearch".into()], }, related: vec!["elasticsearch".into()], }, + // ── Vector Databases ───────────────────────────── CatalogEntry { code: "qdrant".into(), @@ -432,6 +425,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── Monitoring ─────────────────────────────────── CatalogEntry { code: "telegraf".into(), @@ -443,11 +437,14 @@ fn build_hardcoded_catalog() -> Vec { image: "telegraf:1.30-alpine".into(), ports: vec![], environment: HashMap::new(), - volumes: vec!["/var/run/docker.sock:/var/run/docker.sock:ro".into()], + volumes: vec![ + "/var/run/docker.sock:/var/run/docker.sock:ro".into(), + ], depends_on: vec![], }, related: vec![], }, + // ── Dev Tools ──────────────────────────────────── CatalogEntry { code: "phpmyadmin".into(), @@ -482,6 +479,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── Storage ────────────────────────────────────── CatalogEntry { code: "minio".into(), @@ -501,6 +499,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── Container Management ───────────────────────── CatalogEntry { code: "portainer".into(), @@ -520,6 +519,7 @@ fn build_hardcoded_catalog() -> Vec { }, related: vec![], }, + // ── AI Assistants ───────────────────────────── CatalogEntry { code: "openclaw".into(), @@ -530,7 +530,9 @@ fn build_hardcoded_catalog() -> Vec { name: "openclaw".into(), image: "ghcr.io/openclaw/openclaw:latest".into(), ports: vec!["18789:18789".into()], - environment: HashMap::from([("OPENCLAW_GATEWAY_BIND".into(), "lan".into())]), + environment: HashMap::from([ + ("OPENCLAW_GATEWAY_BIND".into(), "lan".into()), + ]), volumes: vec![ "openclaw_config:/home/node/.openclaw".into(), "openclaw_workspace:/home/node/.openclaw/workspace".into(), @@ -586,19 +588,13 @@ mod tests { #[test] fn test_resolve_alias_hyphen_to_underscore() { - assert_eq!( - ServiceCatalog::resolve_alias("nginx-proxy-manager"), - "nginx_proxy_manager" - ); + assert_eq!(ServiceCatalog::resolve_alias("nginx-proxy-manager"), "nginx_proxy_manager"); } #[test] fn test_hardcoded_catalog_not_empty() { let catalog = build_hardcoded_catalog(); - assert!( - catalog.len() > 10, - "Expected at least 10 services in catalog" - ); + assert!(catalog.len() > 10, "Expected at least 10 services in catalog"); } #[test] diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 437699e1..bad88d55 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -234,11 +234,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.list.unwrap_or_default()) } @@ -287,11 +288,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.list.unwrap_or_default()) } @@ -358,15 +360,19 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server POST /project failed ({}): {}", status, body), + reason: format!( + "Stacker server POST /project failed ({}): {}", + status, body + ), }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -406,11 +412,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -443,11 +450,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.list.unwrap_or_default()) } @@ -459,13 +467,14 @@ impl StackerClient { ) -> Result, CliError> { let clouds = self.list_clouds().await?; let lower = provider.to_lowercase(); - Ok(clouds - .into_iter() - .find(|c| c.provider.to_lowercase() == lower)) + Ok(clouds.into_iter().find(|c| c.provider.to_lowercase() == lower)) } /// Find saved cloud credentials by name (e.g. "my-hetzner", "htz-4"). - pub async fn find_cloud_by_name(&self, name: &str) -> Result, CliError> { + pub async fn find_cloud_by_name( + &self, + name: &str, + ) -> Result, CliError> { let clouds = self.list_clouds().await?; let lower = name.to_lowercase(); Ok(clouds.into_iter().find(|c| c.name.to_lowercase() == lower)) @@ -501,11 +510,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.item) } @@ -518,8 +528,7 @@ impl StackerClient { cloud_key: Option<&str>, cloud_secret: Option<&str>, ) -> Result { - self.save_cloud_with_name(provider, None, cloud_token, cloud_key, cloud_secret) - .await + self.save_cloud_with_name(provider, None, cloud_token, cloud_key, cloud_secret).await } /// Save cloud credentials with an optional name. @@ -540,7 +549,10 @@ impl StackerClient { if let Some(obj) = payload.as_object_mut() { if let Some(n) = name { - obj.insert("name".to_string(), serde_json::Value::String(n.to_string())); + obj.insert( + "name".to_string(), + serde_json::Value::String(n.to_string()), + ); } if let Some(t) = cloud_token { obj.insert( @@ -579,15 +591,19 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server POST /cloud failed ({}): {}", status, body), + reason: format!( + "Stacker server POST /cloud failed ({}): {}", + status, body + ), }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -620,11 +636,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.list.unwrap_or_default()) } @@ -669,11 +686,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -707,11 +725,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -756,11 +775,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -808,11 +828,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.list.unwrap_or_default()) } @@ -843,15 +864,19 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Marketplace template fetch failed ({}): {}", status, body), + reason: format!( + "Marketplace template fetch failed ({}): {}", + status, body + ), }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.item) } @@ -887,16 +912,19 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Stacker server deploy failed ({}): {}", status, body), + reason: format!( + "Stacker server deploy failed ({}): {}", + status, body + ), }); } - resp.json::() - .await - .map_err(|e| CliError::DeployFailed { + resp.json::().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid deploy response from Stacker server: {}", e), - }) + } + }) } // ── Deployment status ──────────────────────────── @@ -998,7 +1026,10 @@ impl StackerClient { &self, hash: &str, ) -> Result, CliError> { - let url = format!("{}/api/v1/deployments/hash/{}", self.base_url, hash); + let url = format!( + "{}/api/v1/deployments/hash/{}", + self.base_url, hash + ); let resp = self .http .get(&url) @@ -1067,7 +1098,10 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!("Force-complete failed ({}): {}", status, body), + reason: format!( + "Force-complete failed ({}): {}", + status, body + ), }); } @@ -1115,12 +1149,10 @@ impl StackerClient { } let api: ApiResponse = - resp.json() - .await - .map_err(|e| CliError::AgentCommandFailed { - command_id: String::new(), - error: format!("Invalid enqueue response: {}", e), - })?; + resp.json().await.map_err(|e| CliError::AgentCommandFailed { + command_id: String::new(), + error: format!("Invalid enqueue response: {}", e), + })?; api.item.ok_or_else(|| CliError::AgentCommandFailed { command_id: String::new(), @@ -1168,12 +1200,10 @@ impl StackerClient { } let api: ApiResponse = - resp.json() - .await - .map_err(|e| CliError::AgentCommandFailed { - command_id: command_id.to_string(), - error: format!("Invalid status response: {}", e), - })?; + resp.json().await.map_err(|e| CliError::AgentCommandFailed { + command_id: command_id.to_string(), + error: format!("Invalid status response: {}", e), + })?; api.item.ok_or_else(|| CliError::AgentCommandFailed { command_id: command_id.to_string(), @@ -1195,7 +1225,8 @@ impl StackerClient { let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); let interval = std::time::Duration::from_secs(poll_interval_secs); let mut last_status = "pending".to_string(); @@ -1293,20 +1324,14 @@ impl StackerClient { let body = resp.text().await.unwrap_or_default(); return Err(CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, - reason: format!( - "GET /api/v1/agent/project/{} failed ({}): {}", - project_id, status, body - ), + reason: format!("GET /api/v1/agent/project/{} failed ({}): {}", project_id, status, body), }); } - let json: serde_json::Value = - resp.json() - .await - .map_err(|e| CliError::AgentCommandFailed { - command_id: String::new(), - error: format!("Invalid project snapshot response: {}", e), - })?; + let json: serde_json::Value = resp.json().await.map_err(|e| CliError::AgentCommandFailed { + command_id: String::new(), + error: format!("Invalid project snapshot response: {}", e), + })?; // Extract deployment_hash from the nested agent object let hash = json @@ -1316,13 +1341,11 @@ impl StackerClient { .and_then(|a| a.get("deployment_hash")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .ok_or_else(|| { - CliError::ConfigValidation( - "No active agent found for this project. \ + .ok_or_else(|| CliError::ConfigValidation( + "No active agent found for this project. \ The agent may be offline or not yet deployed." - .to_string(), - ) - })?; + .to_string(), + ))?; Ok((json, hash)) } @@ -1352,11 +1375,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; Ok(api.list.unwrap_or_default()) } @@ -1390,11 +1414,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("Invalid response from Stacker server: {}", e), - })?; + } + })?; let item = api.item.unwrap_or(serde_json::json!({})); let reviews: Vec = serde_json::from_value( @@ -1434,11 +1459,12 @@ impl StackerClient { }); } - let api: ApiResponse = - resp.json().await.map_err(|e| CliError::DeployFailed { + let api: ApiResponse = resp.json().await.map_err(|e| { + CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, reason: format!("create template response: {}", e), - })?; + } + })?; api.item.ok_or_else(|| CliError::DeployFailed { target: crate::cli::config_parser::DeployTarget::Cloud, @@ -1623,7 +1649,11 @@ fn parse_volume_mapping(vol_str: &str) -> (String, String, bool) { let parts: Vec<&str> = vol_str.split(':').collect(); match parts.len() { // "source:target:mode" (e.g. "/host:/container:ro") - 3 => (parts[0].to_string(), parts[1].to_string(), parts[2] == "ro"), + 3 => ( + parts[0].to_string(), + parts[1].to_string(), + parts[2] == "ro", + ), // "source:target" 2 => (parts[0].to_string(), parts[1].to_string(), false), // bare path @@ -1920,13 +1950,7 @@ fn generate_server_name(project_name: &str) -> String { let sanitised: String = project_name .to_lowercase() .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' { - c - } else { - '-' - } - }) + .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }) .collect::() .split('-') .filter(|s| !s.is_empty()) @@ -1963,16 +1987,10 @@ fn generate_server_name(project_name: &str) -> String { pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { let cloud = config.deploy.cloud.as_ref(); let provider = cloud - .map(|c| { - super::install_runner::provider_code_for_remote(&c.provider.to_string()).to_string() - }) + .map(|c| super::install_runner::provider_code_for_remote(&c.provider.to_string()).to_string()) .unwrap_or_else(|| "htz".to_string()); - let region = cloud - .and_then(|c| c.region.clone()) - .unwrap_or_else(|| "nbg1".to_string()); - let server_size = cloud - .and_then(|c| c.size.clone()) - .unwrap_or_else(|| "cpx11".to_string()); + let region = cloud.and_then(|c| c.region.clone()).unwrap_or_else(|| "nbg1".to_string()); + let server_size = cloud.and_then(|c| c.size.clone()).unwrap_or_else(|| "cpx11".to_string()); let os = match provider.as_str() { "do" => "docker-20-04", _ => "ubuntu-22.04", @@ -1980,11 +1998,7 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { // Auto-generate a server name from the project name so every // provisioned server gets a recognisable label in `stacker list servers`. - let project_name = config - .project - .identity - .clone() - .unwrap_or_else(|| config.name.clone()); + let project_name = config.project.identity.clone().unwrap_or_else(|| config.name.clone()); let server_name = generate_server_name(&project_name); let mut form = serde_json::json!({ @@ -2029,7 +2043,7 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { let features = stack_obj .entry("extended_features") - .or_insert_with(|| serde_json::json!([])); + .or_insert_with(|| serde_json::json!([])); if let Some(arr) = features.as_array_mut() { let npm = serde_json::Value::String("nginx_proxy_manager".to_string()); if !arr.contains(&npm) { @@ -2048,8 +2062,8 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { // status panel agent with the public Vault address (not the local Docker IP). if config.monitoring.status_panel { // Resolve public Vault URL: env override → default constant. - let vault_url = - std::env::var("STACKER_VAULT_URL").unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); + let vault_url = std::env::var("STACKER_VAULT_URL") + .unwrap_or_else(|_| DEFAULT_VAULT_URL.to_string()); if let Some(stack_obj) = form.get_mut("stack").and_then(|v| v.as_object_mut()) { let features = stack_obj @@ -2119,16 +2133,8 @@ mod tests { assert_eq!(form["stack"]["stack_code"], "myproject"); // Auto-generated server name should start with the project name let name = form["server"]["name"].as_str().unwrap(); - assert!( - name.starts_with("myproject-"), - "server name should start with project name, got: {}", - name - ); - assert_eq!( - name.len(), - "myproject-".len() + 4, - "suffix should be 4 hex chars" - ); + assert!(name.starts_with("myproject-"), "server name should start with project name, got: {}", name); + assert_eq!(name.len(), "myproject-".len() + 4, "suffix should be 4 hex chars"); } #[test] @@ -2329,10 +2335,7 @@ mod tests { let body = build_project_body(&config); let features = body["custom"]["feature"].as_array().unwrap(); - assert!( - features.is_empty(), - "feature array should be empty when no proxy configured" - ); + assert!(features.is_empty(), "feature array should be empty when no proxy configured"); } #[test] @@ -2342,11 +2345,7 @@ mod tests { // 4 hex chars suffix let suffix = &name["website-".len()..]; assert_eq!(suffix.len(), 4); - assert!( - suffix.chars().all(|c| c.is_ascii_hexdigit()), - "suffix should be hex, got: {}", - suffix - ); + assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "suffix should be hex, got: {}", suffix); } #[test] @@ -2358,50 +2357,29 @@ mod tests { #[test] fn test_generate_server_name_empty() { let name = generate_server_name(""); - assert!( - name.starts_with("srv-"), - "empty input should fallback to 'srv', got: {}", - name - ); + assert!(name.starts_with("srv-"), "empty input should fallback to 'srv', got: {}", name); } #[test] fn test_generate_server_name_special_chars() { let name = generate_server_name("app___v2..beta"); - assert!( - name.starts_with("app-v2-beta-"), - "consecutive separators collapsed, got: {}", - name - ); + assert!(name.starts_with("app-v2-beta-"), "consecutive separators collapsed, got: {}", name); } #[test] fn test_generate_server_name_numeric_start() { // Hetzner requires name to start with a letter let name = generate_server_name("123app"); - assert!( - name.starts_with("srv-123app-"), - "numeric start should get 'srv-' prefix, got: {}", - name - ); + assert!(name.starts_with("srv-123app-"), "numeric start should get 'srv-' prefix, got: {}", name); } #[test] fn test_generate_server_name_max_length() { let long = "a".repeat(100); let name = generate_server_name(&long); - assert!( - name.len() <= 63, - "name must be ≤63 chars (Hetzner), got {} chars: {}", - name.len(), - name - ); + assert!(name.len() <= 63, "name must be ≤63 chars (Hetzner), got {} chars: {}", name.len(), name); assert!(name.starts_with("aaa"), "got: {}", name); // Must not end with hyphen - assert!( - !name.ends_with('-'), - "must not end with hyphen, got: {}", - name - ); + assert!(!name.ends_with('-'), "must not end with hyphen, got: {}", name); } } diff --git a/src/connectors/user_service/app.rs b/src/connectors/user_service/app.rs index fb8be88c..ae83ed51 100644 --- a/src/connectors/user_service/app.rs +++ b/src/connectors/user_service/app.rs @@ -137,9 +137,7 @@ impl UserServiceClient { let body = response.text().await.unwrap_or_default(); tracing::warn!( "Catalog endpoint error ({}) for code={}: {}, falling back to search_applications", - status, - code, - body + status, code, body ); return self.fallback_search_by_code(bearer_token, code).await; } diff --git a/src/connectors/user_service/install.rs b/src/connectors/user_service/install.rs index 52a25aa7..588ba80b 100644 --- a/src/connectors/user_service/install.rs +++ b/src/connectors/user_service/install.rs @@ -89,10 +89,7 @@ impl UserServiceClient { bearer_token: &str, installation_id: i64, ) -> Result { - let url = format!( - "{}/api/1.0/installations/{}", - self.base_url, installation_id - ); + let url = format!("{}/api/1.0/installations/{}", self.base_url, installation_id); let response = self .http_client diff --git a/src/connectors/user_service/mod.rs b/src/connectors/user_service/mod.rs index 03376358..f86f0a2e 100644 --- a/src/connectors/user_service/mod.rs +++ b/src/connectors/user_service/mod.rs @@ -6,8 +6,8 @@ pub mod deployment_resolver; pub mod deployment_validator; pub mod init; pub mod install; -pub mod marketplace_search; pub mod marketplace_webhook; +pub mod marketplace_search; pub mod mock; pub mod notifications; pub mod plan; diff --git a/src/console/commands/cli/agent.rs b/src/console/commands/cli/agent.rs index b7e3db5b..be531682 100644 --- a/src/console/commands/cli/agent.rs +++ b/src/console/commands/cli/agent.rs @@ -52,8 +52,7 @@ fn resolve_deployment_hash( if config_path.exists() { if let Ok(config) = crate::cli::config_parser::StackerConfig::from_file(&config_path) { if let Some(ref project_name) = config.project.identity { - if let Ok(Some(proj)) = ctx.block_on(ctx.client.find_project_by_name(project_name)) - { + if let Ok(Some(proj)) = ctx.block_on(ctx.client.find_project_by_name(project_name)) { match ctx.block_on(ctx.client.agent_snapshot_by_project(proj.id)) { Ok((_, hash)) => { eprintln!( @@ -110,7 +109,8 @@ fn run_agent_command( let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); let interval = std::time::Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS); let mut last_status = "pending".to_string(); @@ -132,7 +132,10 @@ fn run_agent_command( .await?; last_status = status.status.clone(); - progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); + progress::update_message( + &pb, + &format!("{} [{}]", spinner_msg, status.status), + ); match status.status.as_str() { "completed" | "failed" => return Ok(status), @@ -172,11 +175,7 @@ fn print_command_result(info: &AgentCommandInfo, json: bool) { println!("Command: {}", info.command_id); println!("Type: {}", info.command_type); - println!( - "Status: {} {}", - progress::status_icon(&info.status), - info.status - ); + println!("Status: {} {}", progress::status_icon(&info.status), info.status); if let Some(ref result) = info.result { println!("\n{}", fmt::pretty_json(result)); @@ -195,7 +194,11 @@ fn print_command_result(info: &AgentCommandInfo, json: bool) { /// /// Returns `Ok(())` when it's safe to proceed, or a `CliError` when the user /// aborts or the prompt cannot be answered. -fn check_active_connections(ctx: &CliRuntime, hash: &str, force: bool) -> Result<(), CliError> { +fn check_active_connections( + ctx: &CliRuntime, + hash: &str, + force: bool, +) -> Result<(), CliError> { let params = crate::forms::status_panel::CheckConnectionsCommandRequest { ports: None }; let request = AgentEnqueueRequest::new(hash, "check_connections") .with_parameters(¶ms) @@ -205,7 +208,9 @@ fn check_active_connections(ctx: &CliRuntime, hash: &str, force: bool) -> Result Ok(info) => info, Err(_) => { // Non-fatal: if the check times out or fails we warn but proceed. - eprintln!("\x1b[33m⚠ Connection check skipped (agent did not respond in time)\x1b[0m"); + eprintln!( + "\x1b[33m⚠ Connection check skipped (agent did not respond in time)\x1b[0m" + ); return Ok(()); } }; @@ -229,17 +234,11 @@ fn check_active_connections(ctx: &CliRuntime, hash: &str, force: bool) -> Result } // Print a per-port table. - eprintln!( - "\n\x1b[33m⚠ {} active HTTP connection(s) detected:\x1b[0m", - active - ); + eprintln!("\n\x1b[33m⚠ {} active HTTP connection(s) detected:\x1b[0m", active); if let Some(ports) = result.get("ports").and_then(|v| v.as_array()) { for entry in ports { let port = entry.get("port").and_then(|v| v.as_u64()).unwrap_or(0); - let conns = entry - .get("connections") - .and_then(|v| v.as_u64()) - .unwrap_or(0); + let conns = entry.get("connections").and_then(|v| v.as_u64()).unwrap_or(0); if conns > 0 { eprintln!(" port {:5} — {} connection(s)", port, conns); } @@ -289,12 +288,7 @@ impl AgentHealthCommand { deployment: Option, include_system: bool, ) -> Self { - Self { - app_code, - json, - deployment, - include_system, - } + Self { app_code, json, deployment, include_system } } } @@ -337,12 +331,7 @@ impl AgentLogsCommand { json: bool, deployment: Option, ) -> Self { - Self { - app_code, - limit, - json, - deployment, - } + Self { app_code, limit, json, deployment } } } @@ -387,13 +376,13 @@ pub struct AgentRestartCommand { } impl AgentRestartCommand { - pub fn new(app_code: String, force: bool, json: bool, deployment: Option) -> Self { - Self { - app_code, - force, - json, - deployment, - } + pub fn new( + app_code: String, + force: bool, + json: bool, + deployment: Option, + ) -> Self { + Self { app_code, force, json, deployment } } } @@ -444,13 +433,7 @@ impl AgentDeployAppCommand { json: bool, deployment: Option, ) -> Self { - Self { - app_code, - image, - force_recreate, - json, - deployment, - } + Self { app_code, image, force_recreate, json, deployment } } } @@ -475,7 +458,12 @@ impl CallableTrait for AgentDeployAppCommand { .map_err(|e| CliError::ConfigValidation(format!("Invalid parameters: {}", e)))? .with_timeout(300); - let info = run_agent_command(&ctx, &request, &format!("Deploying {}", self.app_code), 300)?; + let info = run_agent_command( + &ctx, + &request, + &format!("Deploying {}", self.app_code), + 300, + )?; print_command_result(&info, self.json); Ok(()) } @@ -502,14 +490,7 @@ impl AgentRemoveAppCommand { json: bool, deployment: Option, ) -> Self { - Self { - app_code, - remove_volumes, - remove_image, - force, - json, - deployment, - } + Self { app_code, remove_volumes, remove_image, force, json, deployment } } } @@ -567,16 +548,7 @@ impl AgentConfigureFirewallCommand { json: bool, deployment: Option, ) -> Self { - Self { - action, - app_code, - public_ports, - private_ports, - persist, - force, - json, - deployment, - } + Self { action, app_code, public_ports, private_ports, persist, force, json, deployment } } /// Parse "80/tcp" or "443" into a FirewallPortRule (source defaults to 0.0.0.0/0). @@ -612,14 +584,10 @@ impl AgentConfigureFirewallCommand { fn parse_port_proto(s: &str) -> Result<(u16, String), String> { if let Some((port_s, proto)) = s.split_once('/') { - let port: u16 = port_s - .parse() - .map_err(|_| format!("Invalid port number: {}", port_s))?; + let port: u16 = port_s.parse().map_err(|_| format!("Invalid port number: {}", port_s))?; Ok((port, proto.to_string())) } else { - let port: u16 = s - .parse() - .map_err(|_| format!("Invalid port number: {}", s))?; + let port: u16 = s.parse().map_err(|_| format!("Invalid port number: {}", s))?; Ok((port, "tcp".to_string())) } } @@ -694,16 +662,7 @@ impl AgentConfigureProxyCommand { json: bool, deployment: Option, ) -> Self { - Self { - app_code, - domain, - port, - ssl, - action, - force, - json, - deployment, - } + Self { app_code, domain, port, ssl, action, force, json, deployment } } } @@ -808,10 +767,7 @@ impl CallableTrait for AgentStatusCommand { let mut output = item.clone(); if let Some(list) = &live_containers { if let Some(obj) = output.as_object_mut() { - obj.insert( - "containers_live".to_string(), - serde_json::Value::Array(list.clone()), - ); + obj.insert("containers_live".to_string(), serde_json::Value::Array(list.clone())); } else { output = serde_json::json!({ "snapshot": output, @@ -898,7 +854,10 @@ fn print_snapshot_summary( .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - let version = agent.get("version").and_then(|v| v.as_str()).unwrap_or("-"); + let version = agent + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("-"); let heartbeat = agent .get("last_heartbeat") .and_then(|v| v.as_str()) @@ -980,11 +939,7 @@ impl CallableTrait for AgentListAppsCommand { let snapshot = ctx.block_on(ctx.client.agent_snapshot(&hash))?; let item = snapshot_item(&snapshot); - let apps = item - .get("apps") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); + let apps = item.get("apps").and_then(|v| v.as_array()).cloned().unwrap_or_default(); if self.json { let value = serde_json::Value::Array(apps); @@ -1013,7 +968,8 @@ impl CallableTrait for AgentListContainersCommand { let ctx = CliRuntime::new("agent list containers")?; let hash = resolve_deployment_hash(&self.deployment, &ctx)?; - let containers = fetch_live_containers(&ctx, &hash)?.unwrap_or_default(); + let containers = fetch_live_containers(&ctx, &hash)? + .unwrap_or_default(); if self.json { let value = serde_json::Value::Array(containers); @@ -1099,13 +1055,7 @@ impl AgentExecCommand { json: bool, deployment: Option, ) -> Self { - Self { - command_type, - params, - timeout, - json, - deployment, - } + Self { command_type, params, timeout, json, deployment } } } @@ -1254,13 +1204,11 @@ impl CallableTrait for AgentInstallCommand { .client .find_project_by_name(&project_name) .await? - .ok_or_else(|| { - CliError::ConfigValidation(format!( - "Project '{}' not found on the Stacker server.\n\ + .ok_or_else(|| CliError::ConfigValidation(format!( + "Project '{}' not found on the Stacker server.\n\ Deploy the project first with: stacker deploy --target cloud", - project_name - )) - })?; + project_name + )))?; // 2. Find the server for this project progress::update_message(&pb, "Finding server..."); @@ -1268,21 +1216,17 @@ impl CallableTrait for AgentInstallCommand { let server = servers .into_iter() .find(|s| s.project_id == project.id) - .ok_or_else(|| { - CliError::ConfigValidation(format!( - "No server found for project '{}' (id={}).\n\ + .ok_or_else(|| CliError::ConfigValidation(format!( + "No server found for project '{}' (id={}).\n\ Deploy the project first with: stacker deploy --target cloud", - project_name, project.id - )) - })?; + project_name, project.id + )))?; - let cloud_id = server.cloud_id.ok_or_else(|| { - CliError::ConfigValidation( - "Server has no associated cloud credentials.\n\ + let cloud_id = server.cloud_id.ok_or_else(|| CliError::ConfigValidation( + "Server has no associated cloud credentials.\n\ Cannot install Status Panel without cloud credentials." - .to_string(), - ) - })?; + .to_string(), + ))?; // 3. Build a minimal deploy form with only the statuspanel feature progress::update_message(&pb, "Preparing deploy payload..."); @@ -1320,10 +1264,7 @@ impl CallableTrait for AgentInstallCommand { // 4. Trigger the deploy progress::update_message(&pb, "Deploying Status Panel..."); - let resp = ctx - .client - .deploy(project.id, Some(cloud_id), deploy_form) - .await?; + let resp = ctx.client.deploy(project.id, Some(cloud_id), deploy_form).await?; Ok(resp) }); @@ -1332,10 +1273,7 @@ impl CallableTrait for AgentInstallCommand { progress::finish_success(&pb, "Status Panel agent installation triggered"); if self.json { - println!( - "{}", - serde_json::to_string_pretty(&resp).unwrap_or_default() - ); + println!("{}", serde_json::to_string_pretty(&resp).unwrap_or_default()); } else { println!("Status Panel deploy queued for project '{}'", project_name); if let Some(id) = resp.id { diff --git a/src/console/commands/cli/ai.rs b/src/console/commands/cli/ai.rs index 8d01311f..7081d4a6 100644 --- a/src/console/commands/cli/ai.rs +++ b/src/console/commands/cli/ai.rs @@ -1,12 +1,13 @@ -use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::io::{self, Write}; use crate::cli::ai_client::{ - all_write_mode_tools, create_provider, AiProvider, AiResponse, ChatMessage, ToolCall, ToolDef, + AiProvider, AiResponse, ChatMessage, ToolCall, ToolDef, + all_write_mode_tools, create_provider, }; use crate::cli::config_parser::{AiConfig, AiProviderType, StackerConfig}; use crate::cli::error::CliError; -use crate::cli::service_catalog::{catalog_summary_for_ai, ServiceCatalog}; +use crate::cli::service_catalog::{ServiceCatalog, catalog_summary_for_ai}; use crate::console::commands::CallableTrait; const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; @@ -212,10 +213,7 @@ fn configure_ai_interactive(config_path: &str) -> Result { Some(api_key_input) }; - let endpoint_default = current - .endpoint - .as_deref() - .unwrap_or("http://localhost:11434"); + let endpoint_default = current.endpoint.as_deref().unwrap_or("http://localhost:11434"); let endpoint_input = prompt_with_default("Endpoint", endpoint_default)?; let endpoint = if endpoint_input.trim().is_empty() { None @@ -358,12 +356,8 @@ fn try_extract_tool_calls_from_text(text: &str) -> Vec { // Try full string first, then scan for the first '{' / '[' let candidates: Vec<&str> = { let mut v = vec![stripped.as_str()]; - if let Some(idx) = stripped.find('{') { - v.push(&stripped[idx..]); - } - if let Some(idx) = stripped.find('[') { - v.push(&stripped[idx..]); - } + if let Some(idx) = stripped.find('{') { v.push(&stripped[idx..]); } + if let Some(idx) = stripped.find('[') { v.push(&stripped[idx..]); } v }; @@ -382,17 +376,17 @@ fn try_extract_tool_calls_from_text(text: &str) -> Vec { fn parse_tool_calls_from_json(json: &serde_json::Value) -> Vec { // Array of calls if let Some(arr) = json.as_array() { - let calls: Vec = arr - .iter() + let calls: Vec = arr.iter() .flat_map(|v| parse_tool_calls_from_json(v)) .collect(); - if !calls.is_empty() { - return calls; - } + if !calls.is_empty() { return calls; } } // {"name": ..., "arguments": {...}} - if let (Some(name), Some(args)) = (json["name"].as_str(), json.get("arguments")) { + if let (Some(name), Some(args)) = ( + json["name"].as_str(), + json.get("arguments"), + ) { let arguments = if args.is_object() { args.clone() } else if let Some(s) = args.as_str() { @@ -400,23 +394,18 @@ fn parse_tool_calls_from_json(json: &serde_json::Value) -> Vec { } else { serde_json::json!({}) }; - return vec![ToolCall { - id: None, - name: name.to_string(), - arguments, - }]; + return vec![ToolCall { id: None, name: name.to_string(), arguments }]; } // {"tool": ..., "parameters": {...}} - if let (Some(name), Some(args)) = (json["tool"].as_str(), json.get("parameters")) { + if let (Some(name), Some(args)) = ( + json["tool"].as_str(), + json.get("parameters"), + ) { return vec![ToolCall { id: None, name: name.to_string(), - arguments: if args.is_object() { - args.clone() - } else { - serde_json::json!({}) - }, + arguments: if args.is_object() { args.clone() } else { serde_json::json!({}) }, }]; } @@ -440,7 +429,7 @@ fn is_write_allowed(path_str: &str) -> bool { .trim_start_matches('/') .trim_start_matches('\\'); // Reject any path that tries to escape with "../" - if p.contains("../") || p.contains("..\\") || p == ".." { + if p.contains("../") || p.contains("..\\" ) || p == ".." { return false; } p == "stacker.yml" || p.starts_with(".stacker/") || p.starts_with(".stacker\\") @@ -454,17 +443,16 @@ fn run_subprocess(args: &[&str]) -> String { Err(e) => return format!("Error: could not resolve binary path: {}", e), }; - match std::process::Command::new(&exe).args(args).output() { + match std::process::Command::new(&exe) + .args(args) + .output() + { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr).trim().to_string(); if out.status.success() { - if combined.is_empty() { - "OK (no output)".to_string() - } else { - combined - } + if combined.is_empty() { "OK (no output)".to_string() } else { combined } } else { format!("Exit {}: {}", out.status.code().unwrap_or(-1), combined) } @@ -583,11 +571,7 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { // ── agent CLI tools ──────────────────────────────────────────────── "agent_health" => { - let mut args: Vec = vec![ - "agent".to_string(), - "health".to_string(), - "--json".to_string(), - ]; + let mut args: Vec = vec!["agent".to_string(), "health".to_string(), "--json".to_string()]; if let Some(app) = call.arguments["app"].as_str() { args.push("--app".to_string()); args.push(app.to_string()); @@ -600,11 +584,7 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) } "agent_status" => { - let mut args: Vec = vec![ - "agent".to_string(), - "status".to_string(), - "--json".to_string(), - ]; + let mut args: Vec = vec!["agent".to_string(), "status".to_string(), "--json".to_string()]; if let Some(dep) = call.arguments["deployment"].as_str() { args.push("--deployment".to_string()); args.push(dep.to_string()); @@ -617,12 +597,7 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { Some(a) => a, None => return "Error: missing 'app' argument".to_string(), }; - let mut args: Vec = vec![ - "agent".to_string(), - "logs".to_string(), - app.to_string(), - "--json".to_string(), - ]; + let mut args: Vec = vec!["agent".to_string(), "logs".to_string(), app.to_string(), "--json".to_string()]; if let Some(limit) = call.arguments["limit"].as_u64() { args.push("--limit".to_string()); args.push(limit.to_string()); @@ -649,11 +624,7 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { if call.arguments["force_rebuild"].as_bool().unwrap_or(false) { args.push("--force-rebuild".to_string()); } - let label = if dry_run { - "stacker deploy --dry-run" - } else { - "stacker deploy" - }; + let label = if dry_run { "stacker deploy --dry-run" } else { "stacker deploy" }; eprintln!(" ⚙ running: {}", label); run_subprocess(&args.iter().map(|s| s.as_str()).collect::>()) } @@ -662,8 +633,7 @@ fn execute_tool(call: &ToolCall, cwd: &Path) -> String { Some(d) => d, None => return "Error: missing 'domain' argument".to_string(), }; - let mut args: Vec = - vec!["proxy".to_string(), "add".to_string(), domain.to_string()]; + let mut args: Vec = vec!["proxy".to_string(), "add".to_string(), domain.to_string()]; if let Some(upstream) = call.arguments["upstream"].as_str() { args.push("--upstream".to_string()); args.push(upstream.to_string()); @@ -846,10 +816,7 @@ fn run_chat_turn( if iteration + 1 == MAX_TOOL_ITERATIONS { return Err(CliError::AiProviderError { provider: provider.name().to_string(), - message: format!( - "Reached maximum tool iterations ({})", - MAX_TOOL_ITERATIONS - ), + message: format!("Reached maximum tool iterations ({})", MAX_TOOL_ITERATIONS), }); } continue; @@ -879,10 +846,7 @@ fn run_chat_turn( if iteration + 1 == MAX_TOOL_ITERATIONS { return Err(CliError::AiProviderError { provider: provider.name().to_string(), - message: format!( - "Reached maximum tool iterations ({})", - MAX_TOOL_ITERATIONS - ), + message: format!("Reached maximum tool iterations ({})", MAX_TOOL_ITERATIONS), }); } } @@ -946,11 +910,7 @@ pub fn run_ai_ask_agentic( // strip the JSON before showing narration to user let narration = text .lines() - .filter(|l| { - !l.trim().starts_with('{') - && !l.trim().starts_with('[') - && !l.trim().starts_with('`') - }) + .filter(|l| !l.trim().starts_with('{') && !l.trim().starts_with('[') && !l.trim().starts_with('`')) .collect::>() .join("\n"); if !narration.trim().is_empty() { @@ -970,10 +930,7 @@ pub fn run_ai_ask_agentic( if iteration + 1 == MAX_TOOL_ITERATIONS { return Err(CliError::AiProviderError { provider: provider.name().to_string(), - message: format!( - "Reached maximum tool iterations ({})", - MAX_TOOL_ITERATIONS - ), + message: format!("Reached maximum tool iterations ({})", MAX_TOOL_ITERATIONS), }); } continue; @@ -995,7 +952,10 @@ pub fn run_ai_ask_agentic( // Execute each tool and append results for call in &calls { let result = execute_tool(call, &cwd); - messages.push(ChatMessage::tool_result(call.id.clone(), result)); + messages.push(ChatMessage::tool_result( + call.id.clone(), + result, + )); } if iteration + 1 == MAX_TOOL_ITERATIONS { @@ -1079,7 +1039,11 @@ impl CallableTrait for AiAskCommand { println!("{}", response); } } else { - let response = run_ai_ask(&self.question, self.context.as_deref(), provider.as_ref())?; + let response = run_ai_ask( + &self.question, + self.context.as_deref(), + provider.as_ref(), + )?; println!("{}", response); } Ok(()) @@ -1133,11 +1097,7 @@ impl CallableTrait for AiChatCommand { "Stacker AI ({provider} · {model}){tools}", provider = provider_name, model = model_name, - tools = if write_active { - " [write mode — .stacker/ + stacker.yml]" - } else { - "" - } + tools = if write_active { " [write mode — .stacker/ + stacker.yml]" } else { "" } ); eprintln!("Type your question and press Enter. `help` for tips, `exit` to quit."); eprintln!(); @@ -1151,7 +1111,10 @@ impl CallableTrait for AiChatCommand { "{}\n\n{}\n\n## Current project files\n{}", STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx, ctx ), - None => format!("{}\n\n{}", STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx), + None => format!( + "{}\n\n{}", + STACKER_SCHEMA_SYSTEM_PROMPT, catalog_ctx + ), }; let mut messages: Vec = vec![ChatMessage::system(&system)]; @@ -1217,16 +1180,12 @@ mod tests { impl MockProvider { fn new(response: &str) -> Self { - Self { - response: response.to_string(), - } + Self { response: response.to_string() } } } impl AiProvider for MockProvider { - fn name(&self) -> &str { - "mock" - } + fn name(&self) -> &str { "mock" } fn complete(&self, _prompt: &str, _context: &str) -> Result { Ok(self.response.clone()) } @@ -1270,8 +1229,11 @@ mod tests { std::fs::write(&ctx_path, "FROM rust:1.75\nCOPY . .").unwrap(); let provider = MockProvider::new("Looks good!"); - let result = - run_ai_ask("Review this", Some(ctx_path.to_str().unwrap()), &provider).unwrap(); + let result = run_ai_ask( + "Review this", + Some(ctx_path.to_str().unwrap()), + &provider, + ).unwrap(); assert_eq!(result, "Looks good!"); } diff --git a/src/console/commands/cli/ci.rs b/src/console/commands/cli/ci.rs index 028e3fa2..aff8d14f 100644 --- a/src/console/commands/cli/ci.rs +++ b/src/console/commands/cli/ci.rs @@ -96,10 +96,7 @@ impl CallableTrait for CiExportCommand { println!(" 1. Add STACKER_TOKEN to your GitHub repository secrets"); println!(" (Settings → Secrets and variables → Actions)"); println!(" 2. Commit and push the workflow file:"); - println!( - " git add {} && git commit -m 'ci: add stacker deploy workflow'", - output_path.display() - ); + println!(" git add {} && git commit -m 'ci: add stacker deploy workflow'", output_path.display()); } "gitlab" | "gitlab-ci" => { println!(); @@ -107,10 +104,7 @@ impl CallableTrait for CiExportCommand { println!(" 1. Add STACKER_TOKEN to your GitLab CI/CD variables"); println!(" (Settings → CI/CD → Variables)"); println!(" 2. Commit and push the pipeline file:"); - println!( - " git add {} && git commit -m 'ci: add stacker deploy pipeline'", - output_path.display() - ); + println!(" git add {} && git commit -m 'ci: add stacker deploy pipeline'", output_path.display()); } _ => {} } @@ -154,7 +148,10 @@ impl CallableTrait for CiValidateCommand { }; if !pipeline_path.exists() { - eprintln!("✗ Pipeline file not found: {}", pipeline_path.display()); + eprintln!( + "✗ Pipeline file not found: {}", + pipeline_path.display() + ); eprintln!(" Run: stacker ci export --platform {}", self.platform); return Err(Box::new(CliError::ConfigValidation(format!( "Pipeline file not found: {}", @@ -174,10 +171,7 @@ impl CallableTrait for CiValidateCommand { pipeline_path.display(), config.name ); - eprintln!( - " Re-generate with: stacker ci export --platform {}", - self.platform - ); + eprintln!(" Re-generate with: stacker ci export --platform {}", self.platform); Err(Box::new(CliError::ConfigValidation( "Pipeline may be out of sync with stacker.yml".to_string(), ))) diff --git a/src/console/commands/cli/config.rs b/src/console/commands/cli/config.rs index 54f25c73..4c1158d5 100644 --- a/src/console/commands/cli/config.rs +++ b/src/console/commands/cli/config.rs @@ -1,5 +1,5 @@ -use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::io::{self, Write}; use crate::cli::config_parser::{ CloudConfig, CloudOrchestrator, CloudProvider, DeployTarget, ServerConfig, StackerConfig, @@ -13,7 +13,9 @@ const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; /// Resolve config path from optional override. fn resolve_config_path(file: &Option) -> String { - file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE).to_string() + file.as_deref() + .unwrap_or(DEFAULT_CONFIG_FILE) + .to_string() } fn prompt_line(prompt: &str) -> Result { @@ -37,7 +39,8 @@ fn parse_cloud_provider(s: &str) -> Result { let json = format!("\"{}\"", s.trim().to_lowercase()); serde_json::from_str::(&json).map_err(|_| { CliError::ConfigValidation( - "Invalid cloud provider. Use: hetzner, digitalocean, aws, linode, vultr".to_string(), + "Invalid cloud provider. Use: hetzner, digitalocean, aws, linode, vultr" + .to_string(), ) }) } @@ -104,9 +107,7 @@ fn first_non_empty_env(keys: &[&str]) -> Option { }) } -fn resolve_remote_cloud_credentials( - provider_code: &str, -) -> serde_json::Map { +fn resolve_remote_cloud_credentials(provider_code: &str) -> serde_json::Map { let mut creds = serde_json::Map::new(); match provider_code { @@ -156,10 +157,7 @@ fn resolve_remote_cloud_credentials( if let Some(secret) = first_non_empty_env(&["STACKER_CLOUD_SECRET", "AWS_SECRET_ACCESS_KEY"]) { - creds.insert( - "cloud_secret".to_string(), - serde_json::Value::String(secret), - ); + creds.insert("cloud_secret".to_string(), serde_json::Value::String(secret)); } } _ => {} @@ -298,7 +296,8 @@ pub fn run_generate_remote_payload( "Generated remote payload (advanced/debug): {}", output_path.display() ), - "Set deploy.target=cloud and deploy.cloud.orchestrator=remote (advanced mode)".to_string(), + "Set deploy.target=cloud and deploy.cloud.orchestrator=remote (advanced mode)" + .to_string(), "Tip: regular users can skip this and run `stacker deploy --target cloud` directly" .to_string(), format!("Backup written to {}", backup_path), @@ -393,8 +392,10 @@ pub fn run_setup_cloud_interactive(config_path: &str) -> Result, Cli .and_then(|c| c.ssh_key.clone()) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "~/.ssh/id_rsa".to_string()); - let ssh_key_input = - prompt_with_default("SSH key path (leave empty to skip)", &ssh_key_default)?; + let ssh_key_input = prompt_with_default( + "SSH key path (leave empty to skip)", + &ssh_key_default, + )?; let region_opt = if region.trim().is_empty() { None @@ -481,7 +482,11 @@ pub fn run_fix_interactive(config_path: &str) -> Result, CliError> { .unwrap_or_else(|| "cpx11".to_string()); let size = prompt_with_default("Cloud size", &size_default)?; - let ssh_key = config.deploy.cloud.as_ref().and_then(|c| c.ssh_key.clone()); + let ssh_key = config + .deploy + .cloud + .as_ref() + .and_then(|c| c.ssh_key.clone()); let orchestrator = config .deploy @@ -616,8 +621,9 @@ pub fn run_show(config_path: &str) -> Result { } let config = StackerConfig::from_file(path)?; - let yaml = serde_yaml::to_string(&config) - .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; + let yaml = serde_yaml::to_string(&config).map_err(|e| { + CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) + })?; Ok(yaml) } @@ -829,9 +835,7 @@ impl CallableTrait for ConfigLockCommand { eprintln!("Deployment lock exists but has no remote server details."); if lock.target == "cloud" { eprintln!("The cloud deployment may still be provisioning."); - eprintln!( - "Wait for it to complete, then run `stacker deploy --lock` to retry." - ); + eprintln!("Wait for it to complete, then run `stacker deploy --lock` to retry."); } return Ok(()); } @@ -840,7 +844,9 @@ impl CallableTrait for ConfigLockCommand { // 3. Load stacker.yml, apply lock, write back if !config_path.exists() { - return Err(Box::new(CliError::ConfigNotFound { path: config_path })); + return Err(Box::new(CliError::ConfigNotFound { + path: config_path, + })); } let mut config = StackerConfig::from_file_raw(&config_path)?; @@ -885,7 +891,9 @@ impl CallableTrait for ConfigUnlockCommand { let config_path = project_dir.join(&config_path_str); if !config_path.exists() { - return Err(Box::new(CliError::ConfigNotFound { path: config_path })); + return Err(Box::new(CliError::ConfigNotFound { + path: config_path, + })); } let mut config = StackerConfig::from_file_raw(&config_path)?; @@ -975,10 +983,7 @@ mod tests { #[test] fn test_parse_cloud_provider_valid() { - assert_eq!( - parse_cloud_provider("hetzner").unwrap(), - CloudProvider::Hetzner - ); + assert_eq!(parse_cloud_provider("hetzner").unwrap(), CloudProvider::Hetzner); assert_eq!(parse_cloud_provider("AWS").unwrap(), CloudProvider::Aws); } @@ -1017,8 +1022,7 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); let config_path = write_config(dir.path(), minimal_config_yaml()); - let applied = - run_generate_remote_payload(&config_path, Some("stacker.remote.deploy.json")).unwrap(); + let applied = run_generate_remote_payload(&config_path, Some("stacker.remote.deploy.json")).unwrap(); assert!(!applied.is_empty()); let payload_path = dir.path().join("stacker.remote.deploy.json"); diff --git a/src/console/commands/cli/deploy.rs b/src/console/commands/cli/deploy.rs index 4f73a994..7f31ad23 100644 --- a/src/console/commands/cli/deploy.rs +++ b/src/console/commands/cli/deploy.rs @@ -19,8 +19,8 @@ use crate::cli::install_runner::{ }; use crate::cli::progress; use crate::cli::stacker_client::{self, StackerClient}; -use crate::console::commands::CallableTrait; use crate::helpers::ssh_client; +use crate::console::commands::CallableTrait; /// Default config filename. const DEFAULT_CONFIG_FILE: &str = "stacker.yml"; @@ -37,10 +37,7 @@ fn parse_ai_provider(s: &str) -> Result { }) } -fn resolve_ai_from_env_or_config( - project_dir: &Path, - config_file: Option<&str>, -) -> Result { +fn resolve_ai_from_env_or_config(project_dir: &Path, config_file: Option<&str>) -> Result { let config_path = match config_file { Some(f) => project_dir.join(f), None => project_dir.join(DEFAULT_CONFIG_FILE), @@ -115,18 +112,10 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { let mut hints = Vec::new(); if lower.contains("npm ci") { - hints.push( - "npm ci failed: ensure package-lock.json exists and is in sync with package.json" - .to_string(), - ); - hints.push( - "Try locally: npm ci --production (or npm ci) to see the full dependency error" - .to_string(), - ); + hints.push("npm ci failed: ensure package-lock.json exists and is in sync with package.json".to_string()); + hints.push("Try locally: npm ci --production (or npm ci) to see the full dependency error".to_string()); } - if lower.contains("the attribute `version` is obsolete") - || lower.contains("attribute `version` is obsolete") - { + if lower.contains("the attribute `version` is obsolete") || lower.contains("attribute `version` is obsolete") { hints.push("docker-compose version warning: remove top-level 'version:' from .stacker/docker-compose.yml".to_string()); } if lower.contains("failed to solve") { @@ -136,14 +125,10 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { hints.push("Permission issue detected: verify file ownership and executable bits for scripts copied into the image".to_string()); } if lower.contains("no such file") || lower.contains("not found") { - hints.push( - "Missing file in build context: confirm COPY paths and .dockerignore rules".to_string(), - ); + hints.push("Missing file in build context: confirm COPY paths and .dockerignore rules".to_string()); } if lower.contains("network") || lower.contains("timed out") { - hints.push( - "Network/timeout issue: retry build and verify registry connectivity".to_string(), - ); + hints.push("Network/timeout issue: retry build and verify registry connectivity".to_string()); } if lower.contains("port is already allocated") || lower.contains("bind for 0.0.0.0") @@ -166,20 +151,11 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { hints.push("Orphan containers detected: run docker compose -f .stacker/docker-compose.yml down --remove-orphans".to_string()); } if lower.contains("manifest unknown") || lower.contains("pull access denied") { - hints.push( - "Image pull failed: the configured image tag is not available in the registry" - .to_string(), - ); + hints.push("Image pull failed: the configured image tag is not available in the registry".to_string()); if let Some(image) = extract_missing_image(reason) { hints.push(format!("Missing image detected: {}", image)); - hints.push(format!( - "Build and tag locally: docker build -t {} .", - image - )); - hints.push(format!( - "If using a remote registry, push it first: docker push {}", - image - )); + hints.push(format!("Build and tag locally: docker build -t {} .", image)); + hints.push(format!("If using a remote registry, push it first: docker push {}", image)); } else { hints.push("Build locally first (docker build -t .) or use an existing published tag".to_string()); } @@ -189,10 +165,7 @@ fn fallback_troubleshooting_hints(reason: &str) -> Vec { if hints.is_empty() { hints.push("Run docker compose -f .stacker/docker-compose.yml build --no-cache for detailed build logs".to_string()); hints.push("Inspect .stacker/Dockerfile and .stacker/docker-compose.yml for invalid paths and commands".to_string()); - hints.push( - "If the issue is dependency-related, run the failing install command locally first" - .to_string(), - ); + hints.push("If the issue is dependency-related, run the failing install command locally first".to_string()); } hints @@ -280,7 +253,10 @@ fn try_ssh_server_check(server: &ServerConfig) -> Option p.clone(), None => { @@ -335,18 +311,9 @@ fn print_server_unreachable_hint(server: &ServerConfig, check: &ssh_client::Syst eprintln!(" │ To deploy to this server, fix the connection issue and retry: │"); eprintln!(" │ │"); if let Some(ref key) = server.ssh_key { - eprintln!( - " │ ssh -i {} -p {} {}@{}", - key.display(), - server.port, - server.user, - server.host - ); + eprintln!(" │ ssh -i {} -p {} {}@{}", key.display(), server.port, server.user, server.host); } else { - eprintln!( - " │ ssh -p {} {}@{}", - server.port, server.user, server.host - ); + eprintln!(" │ ssh -p {} {}@{}", server.port, server.user, server.host); } eprintln!(" │ │"); eprintln!(" │ Or, to provision a new cloud server instead, remove the │"); @@ -374,10 +341,7 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError if let serde_yaml::Value::Mapping(ref mut root) = doc { // Remove obsolete compose version key. - if root - .remove(serde_yaml::Value::String("version".to_string())) - .is_some() - { + if root.remove(serde_yaml::Value::String("version".to_string())).is_some() { changed = true; } @@ -420,9 +384,7 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError .map(|d| d.starts_with(".stacker/")) .unwrap_or(false); - if dockerfile_points_to_stacker - && (current_context == "." || current_context == "./") - { + if dockerfile_points_to_stacker && (current_context == "." || current_context == "./") { build_map.insert( context_key.clone(), serde_yaml::Value::String("..".to_string()), @@ -431,7 +393,10 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError } if service_name == "app" && (current_context == "." || current_context == "./") { - build_map.insert(context_key, serde_yaml::Value::String("..".to_string())); + build_map.insert( + context_key, + serde_yaml::Value::String("..".to_string()), + ); let dockerfile_needs_rewrite = match dockerfile.as_deref() { None => true, @@ -453,9 +418,8 @@ fn normalize_generated_compose_paths(compose_path: &Path) -> Result<(), CliError } if changed { - let updated = serde_yaml::to_string(&doc).map_err(|e| { - CliError::ConfigValidation(format!("Failed to serialize compose file: {e}")) - })?; + let updated = serde_yaml::to_string(&doc) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize compose file: {e}")))?; std::fs::write(compose_path, updated)?; eprintln!(" Normalized {}/docker-compose.yml paths", OUTPUT_DIR); } @@ -575,16 +539,11 @@ fn print_ai_deploy_help(project_dir: &Path, config_file: Option<&str>, err: &Cli let ai_config = match resolve_ai_from_env_or_config(project_dir, config_file) { Ok(cfg) => cfg, Err(load_err) => { - eprintln!( - " Could not load AI config for troubleshooting: {}", - load_err - ); + eprintln!(" Could not load AI config for troubleshooting: {}", load_err); for hint in fallback_troubleshooting_hints(reason) { eprintln!(" - {}", hint); } - eprintln!( - " Tip: enable AI with stacker init --with-ai or set STACKER_AI_PROVIDER=ollama" - ); + eprintln!(" Tip: enable AI with stacker init --with-ai or set STACKER_AI_PROVIDER=ollama"); return; } }; @@ -601,10 +560,7 @@ fn print_ai_deploy_help(project_dir: &Path, config_file: Option<&str>, err: &Cli let error_log = build_troubleshoot_error_log(project_dir, reason); let ctx = PromptContext { project_type: None, - files: vec![ - ".stacker/Dockerfile".to_string(), - ".stacker/docker-compose.yml".to_string(), - ], + files: vec![".stacker/Dockerfile".to_string(), ".stacker/docker-compose.yml".to_string()], error_log: Some(error_log), current_config: None, }; @@ -673,7 +629,9 @@ fn cloud_provider_from_code(code: &str) -> Option { /// - `Ok(Some(cloud_info))` when the user picks an existing credential. /// - `Ok(None)` when the user picks "Connect a new cloud provider". /// - `Err(...)` on I/O or network errors. -fn prompt_select_cloud(access_token: &str) -> Result, CliError> { +fn prompt_select_cloud( + access_token: &str, +) -> Result, CliError> { let base_url = crate::cli::install_runner::normalize_stacker_server_url( stacker_client::DEFAULT_STACKER_URL, ); @@ -681,9 +639,7 @@ fn prompt_select_cloud(access_token: &str) -> Result Result = clouds .iter() - .map(|c| { - format!( - "{: Result = None; if deploy_target == DeployTarget::Cloud && !force_new { if let Some(ref server_cfg) = config.deploy.server { - eprintln!( - " Found deploy.server section (host={}). Checking SSH connectivity...", - server_cfg.host - ); + eprintln!(" Found deploy.server section (host={}). Checking SSH connectivity...", server_cfg.host); match try_ssh_server_check(server_cfg) { Some(check) if check.connected && check.authenticated => { - eprintln!( - " ✓ Server {} is reachable ({})", - server_cfg.host, - check.summary() - ); + eprintln!(" ✓ Server {} is reachable ({})", server_cfg.host, check.summary()); if !check.docker_installed { eprintln!(" ⚠ Docker is NOT installed on the server."); @@ -930,9 +874,7 @@ pub fn run_deploy( }); } - eprintln!( - " Switching deploy target from 'cloud' → 'server' (using existing server)" - ); + eprintln!(" Switching deploy target from 'cloud' → 'server' (using existing server)"); deploy_target = DeployTarget::Server; } Some(check) => { @@ -968,24 +910,14 @@ pub fn run_deploy( // Auto-inject the server name so the cloud deploy API reuses the same server. if let Ok(Some(lock)) = DeploymentLock::load(project_dir) { if let Some(ref name) = lock.server_name { - eprintln!( - " ℹ Found previous deployment (server='{}') — reusing server", - name - ); + eprintln!(" ℹ Found previous deployment (server='{}') — reusing server", name); eprintln!(" To provision a new server instead: stacker deploy --force-new"); lock_server_name = Some(name.clone()); } else if let Some(ref ip) = lock.server_ip { if ip != "127.0.0.1" { - eprintln!( - " ℹ Found previous deployment to {} (from .stacker/deployment.lock)", - ip - ); - eprintln!( - " Server name unknown — cannot auto-reuse. Run: stacker config lock" - ); - eprintln!( - " To provision a new server instead: stacker deploy --force-new" - ); + eprintln!(" ℹ Found previous deployment to {} (from .stacker/deployment.lock)", ip); + eprintln!(" Server name unknown — cannot auto-reuse. Run: stacker config lock"); + eprintln!(" To provision a new server instead: stacker deploy --force-new"); } } } @@ -1087,10 +1019,7 @@ pub fn run_deploy( let builder = DockerfileBuilder::from(config.app.app_type); builder.write_to(&dockerfile_path, force_rebuild)?; } else { - eprintln!( - " Using existing {}/Dockerfile (use --force-rebuild to regenerate)", - OUTPUT_DIR - ); + eprintln!(" Using existing {}/Dockerfile (use --force-rebuild to regenerate)", OUTPUT_DIR); } } @@ -1121,10 +1050,7 @@ pub fn run_deploy( let compose = ComposeDefinition::try_from(&config)?; compose.write_to(&compose_out, force_rebuild)?; } else { - eprintln!( - " Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", - OUTPUT_DIR - ); + eprintln!(" Using existing {}/docker-compose.yml (use --force-rebuild to regenerate)", OUTPUT_DIR); } compose_out }; @@ -1133,10 +1059,7 @@ pub fn run_deploy( // 5b.1 Surface build source paths to avoid confusion. if let Some(image) = &config.app.image { - eprintln!( - " App image source: image={} (no local Dockerfile build)", - image - ); + eprintln!(" App image source: image={} (no local Dockerfile build)", image); } else if let Some(build_src) = compose_app_build_source(&compose_path) { eprintln!(" App build source: {}", build_src); } else if let Some(dockerfile) = &config.app.dockerfile { @@ -1147,10 +1070,7 @@ pub fn run_deploy( }; eprintln!(" App build source: Dockerfile={}", dockerfile_display); } else { - eprintln!( - " App build source: Dockerfile={}", - dockerfile_path.display() - ); + eprintln!(" App build source: Dockerfile={}", dockerfile_path.display()); } eprintln!(" Compose file: {}", compose_path.display()); @@ -1175,7 +1095,10 @@ pub fn run_deploy( project_name_override: remote_overrides.project_name.clone(), key_name_override: remote_overrides.key_name.clone(), key_id_override: remote_overrides.key_id, - server_name_override: remote_overrides.server_name.clone().or(lock_server_name), + server_name_override: remote_overrides + .server_name + .clone() + .or(lock_server_name), }; let result = strategy.deploy(&config, &context, executor)?; @@ -1326,8 +1249,7 @@ impl DeployCommand { info.cloud_id, ); if let Some(ref ip) = info.srv_ip { - eprintln!( - " Server details: {} ({}@{}:{})", + eprintln!(" Server details: {} ({}@{}:{})", info.name.as_deref().unwrap_or("unnamed"), info.ssh_user.as_deref().unwrap_or("root"), ip, @@ -1336,9 +1258,7 @@ impl DeployCommand { } } Ok(None) => { - eprintln!( - " ℹ Server details not yet available (may still be provisioning)." - ); + eprintln!(" ℹ Server details not yet available (may still be provisioning)."); } Err(e) => { eprintln!(" ⚠ Could not fetch server details: {}", e); @@ -1381,7 +1301,9 @@ impl DeployCommand { None => project_dir.join(DEFAULT_CONFIG_FILE), }; - if lock.server_ip.is_some() && lock.server_ip.as_deref() != Some("127.0.0.1") { + if lock.server_ip.is_some() + && lock.server_ip.as_deref() != Some("127.0.0.1") + { match StackerConfig::from_file(&config_path) { Ok(mut config) => { lock.apply_to_config(&mut config); @@ -1543,11 +1465,7 @@ fn watch_local_containers( if let Ok(config) = StackerConfig::from_file(&config_path) { if let Some(ref existing) = config.deploy.compose_file { let p = project_dir.join(existing); - if p.exists() { - p - } else { - output_dir.join("docker-compose.yml") - } + if p.exists() { p } else { output_dir.join("docker-compose.yml") } } else { output_dir.join("docker-compose.yml") } @@ -1569,7 +1487,10 @@ fn watch_local_containers( let spin = progress::spinner("Checking container health..."); loop { - let args = vec!["compose", "-f", &compose_str, "ps", "--format", "json"]; + let args = vec![ + "compose", "-f", &compose_str, "ps", + "--format", "json", + ]; if let Ok(output) = executor.execute("docker", &args) { if output.success() { let stdout = output.stdout.trim(); @@ -1594,10 +1515,7 @@ fn watch_local_containers( } if start.elapsed() > timeout { - progress::finish_error( - &spin, - "Timeout waiting for containers — check `stacker status`", - ); + progress::finish_error(&spin, "Timeout waiting for containers — check `stacker status`"); return Ok(()); } @@ -1646,7 +1564,13 @@ fn print_container_summary(compose_str: &str, executor: &dyn CommandExecutor) { // ── Cloud deployment status polling after remote deploy ────── /// Terminal statuses — once reached, watching stops. -const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "cancelled", "error", "paused"]; +const TERMINAL_STATUSES: &[&str] = &[ + "completed", + "failed", + "cancelled", + "error", + "paused", +]; fn is_terminal(status: &str) -> bool { TERMINAL_STATUSES.iter().any(|s| *s == status) @@ -1725,7 +1649,10 @@ fn watch_cloud_deployment(result: &DeployResult) -> Result<(), Box Result<(), Box { if last_status.is_empty() { - progress::update_message(&spin, "Waiting for deployment to appear..."); + progress::update_message( + &spin, + "Waiting for deployment to appear...", + ); last_status = "".to_string(); } } Err(e) => { - progress::finish_error(&spin, &format!("Error polling status: {}", e)); + progress::finish_error( + &spin, + &format!("Error polling status: {}", e), + ); eprintln!(" Run `stacker status --watch` to retry."); return Ok(()); } } if start.elapsed() > timeout { - progress::finish_error(&spin, "Watch timeout (10m) — deployment still in progress"); + progress::finish_error( + &spin, + "Watch timeout (10m) — deployment still in progress", + ); eprintln!(" Run `stacker status --watch` to continue watching."); return Ok(()); } @@ -1837,16 +1773,7 @@ mod tests { ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); // Generated files should exist @@ -1864,16 +1791,7 @@ mod tests { ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); // Custom Dockerfile should not be overwritten @@ -1889,24 +1807,12 @@ mod tests { let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n compose_file: docker-compose.yml\n"; let dir = setup_local_project(&[ ("index.html", "

hello

"), - ( - "docker-compose.yml", - "version: '3.8'\nservices:\n web:\n image: nginx\n", - ), + ("docker-compose.yml", "version: '3.8'\nservices:\n web:\n image: nginx\n"), ("stacker.yml", config), ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); // .stacker/docker-compose.yml should NOT be generated @@ -1919,42 +1825,23 @@ mod tests { let dir = setup_local_project(&[ ("index.html", "

hello

"), ("stacker.yml", config), - ( - ".stacker/docker-compose.yml", - "services:\n app:\n image: nginx\n", - ), + (".stacker/docker-compose.yml", "services:\n app:\n image: nginx\n"), ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); } #[test] fn test_deploy_local_with_image_skips_build() { let config = "name: test-app\napp:\n type: static\n path: .\n image: nginx:latest\n"; - let dir = setup_local_project(&[("stacker.yml", config)]); + let dir = setup_local_project(&[ + ("stacker.yml", config), + ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); // No Dockerfile should be generated (using image) @@ -1963,19 +1850,12 @@ mod tests { #[test] fn test_deploy_cloud_requires_login() { - let dir = setup_local_project(&[("stacker.yml", &cloud_config_yaml())]); + let dir = setup_local_project(&[ + ("stacker.yml", &cloud_config_yaml()), + ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - None, - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); @@ -1990,47 +1870,30 @@ mod tests { fn test_deploy_cloud_requires_provider() { // Cloud target but no cloud config let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: cloud\n"; - let dir = setup_local_project(&[("stacker.yml", config)]); + let dir = setup_local_project(&[ + ("stacker.yml", config), + ]); let executor = MockExecutor::success(); // This should fail at validation since no credentials exist - let result = run_deploy( - dir.path(), - None, - None, - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_err()); } #[test] fn test_deploy_server_requires_host() { let config = "name: test-app\napp:\n type: static\n path: .\ndeploy:\n target: server\n"; - let dir = setup_local_project(&[("stacker.yml", config)]); + let dir = setup_local_project(&[ + ("stacker.yml", config), + ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - None, - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); - assert!( - err.contains("host") || err.contains("Host") || err.contains("server"), - "Expected server host error, got: {}", - err - ); + assert!(err.contains("host") || err.contains("Host") || err.contains("server"), + "Expected server host error, got: {}", err); } #[test] @@ -2038,24 +1901,12 @@ mod tests { let dir = TempDir::new().unwrap(); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - None, - None, - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, None, true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); - assert!( - err.contains("not found") || err.contains("Configuration"), - "Expected config not found error, got: {}", - err - ); + assert!(err.contains("not found") || err.contains("Configuration"), + "Expected config not found error, got: {}", err); } #[test] @@ -2066,16 +1917,7 @@ mod tests { ]); let executor = MockExecutor::success(); - let result = run_deploy( - dir.path(), - Some("custom.yml"), - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), Some("custom.yml"), Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); } @@ -2088,42 +1930,15 @@ mod tests { let executor = MockExecutor::success(); // First deploy creates files - let result = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); // Second deploy without force_rebuild should succeed (reuses existing files) - let result2 = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result2 = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result2.is_ok()); // With force_rebuild should also succeed (regenerates files) - let result3 = run_deploy( - dir.path(), - None, - Some("local"), - true, - true, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result3 = run_deploy(dir.path(), None, Some("local"), true, true, false, &executor, &RemoteDeployOverrides::default()); assert!(result3.is_ok()); } @@ -2148,29 +1963,21 @@ mod tests { #[test] fn test_deploy_runs_pre_build_hook_noted() { - let config = - "name: test-app\napp:\n type: static\n path: .\nhooks:\n pre_build: ./build.sh\n"; - let dir = setup_local_project(&[("index.html", "

hello

"), ("stacker.yml", config)]); + let config = "name: test-app\napp:\n type: static\n path: .\nhooks:\n pre_build: ./build.sh\n"; + let dir = setup_local_project(&[ + ("index.html", "

hello

"), + ("stacker.yml", config), + ]); let executor = MockExecutor::success(); // Dry-run should succeed (hooks are just noted, not executed in dry-run) - let result = run_deploy( - dir.path(), - None, - Some("local"), - true, - false, - false, - &executor, - &RemoteDeployOverrides::default(), - ); + let result = run_deploy(dir.path(), None, Some("local"), true, false, false, &executor, &RemoteDeployOverrides::default()); assert!(result.is_ok()); } #[test] fn test_fallback_hints_for_npm_ci_error() { - let hints = - fallback_troubleshooting_hints("failed to solve: /bin/sh -c npm ci --production"); + let hints = fallback_troubleshooting_hints("failed to solve: /bin/sh -c npm ci --production"); assert!(hints.iter().any(|h| h.contains("npm ci failed"))); } @@ -2199,14 +2006,14 @@ mod tests { assert!(log.contains("(not found)")); } - #[test] - fn test_normalize_generated_compose_paths_fixes_stacker_context_and_version() { - let dir = TempDir::new().unwrap(); - let stacker_dir = dir.path().join(".stacker"); - std::fs::create_dir_all(&stacker_dir).unwrap(); + #[test] + fn test_normalize_generated_compose_paths_fixes_stacker_context_and_version() { + let dir = TempDir::new().unwrap(); + let stacker_dir = dir.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); - let compose_path = stacker_dir.join("docker-compose.yml"); - let compose = r#" + let compose_path = stacker_dir.join("docker-compose.yml"); + let compose = r#" version: "3.9" services: app: @@ -2214,37 +2021,37 @@ services: context: . dockerfile: .stacker/Dockerfile "#; - std::fs::write(&compose_path, compose).unwrap(); + std::fs::write(&compose_path, compose).unwrap(); - normalize_generated_compose_paths(&compose_path).unwrap(); + normalize_generated_compose_paths(&compose_path).unwrap(); - let normalized = std::fs::read_to_string(&compose_path).unwrap(); - assert!(!normalized.contains("version:")); - assert!(normalized.contains("context: ..")); - assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); - } + let normalized = std::fs::read_to_string(&compose_path).unwrap(); + assert!(!normalized.contains("version:")); + assert!(normalized.contains("context: ..")); + assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); + } - #[test] - fn test_normalize_generated_compose_paths_adds_stacker_dockerfile_for_app_when_missing() { - let dir = TempDir::new().unwrap(); - let stacker_dir = dir.path().join(".stacker"); - std::fs::create_dir_all(&stacker_dir).unwrap(); + #[test] + fn test_normalize_generated_compose_paths_adds_stacker_dockerfile_for_app_when_missing() { + let dir = TempDir::new().unwrap(); + let stacker_dir = dir.path().join(".stacker"); + std::fs::create_dir_all(&stacker_dir).unwrap(); - let compose_path = stacker_dir.join("docker-compose.yml"); - let compose = r#" + let compose_path = stacker_dir.join("docker-compose.yml"); + let compose = r#" services: app: build: context: . "#; - std::fs::write(&compose_path, compose).unwrap(); + std::fs::write(&compose_path, compose).unwrap(); - normalize_generated_compose_paths(&compose_path).unwrap(); + normalize_generated_compose_paths(&compose_path).unwrap(); - let normalized = std::fs::read_to_string(&compose_path).unwrap(); - assert!(normalized.contains("context: ..")); - assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); - } + let normalized = std::fs::read_to_string(&compose_path).unwrap(); + assert!(normalized.contains("context: ..")); + assert!(normalized.contains("dockerfile: .stacker/Dockerfile")); + } #[test] fn test_parse_deploy_target_valid() { @@ -2275,9 +2082,7 @@ services: "docker compose failed: manifest for optimum/optimumcode:latest not found: manifest unknown" ); assert!(hints.iter().any(|h| h.contains("Image pull failed"))); - assert!(hints - .iter() - .any(|h| h.contains("docker build -t optimum/optimumcode:latest ."))); + assert!(hints.iter().any(|h| h.contains("docker build -t optimum/optimumcode:latest ."))); } #[test] @@ -2292,7 +2097,7 @@ services: #[test] fn test_fallback_hints_for_orphan_containers() { let hints = fallback_troubleshooting_hints( - "Found orphan containers ([stackerdb]) for this project", + "Found orphan containers ([stackerdb]) for this project" ); assert!(hints.iter().any(|h| h.contains("--remove-orphans"))); } @@ -2310,7 +2115,7 @@ services: fn test_ensure_env_file_is_created_when_missing() { let dir = TempDir::new().unwrap(); let config = StackerConfig::from_str( - "name: env-app\napp:\n type: static\nenv_file: .env\nenv:\n APP_ENV: production\n", + "name: env-app\napp:\n type: static\nenv_file: .env\nenv:\n APP_ENV: production\n" ) .unwrap(); @@ -2380,39 +2185,18 @@ services: #[test] fn test_cloud_provider_from_code() { // Short codes - assert_eq!( - cloud_provider_from_code("htz"), - Some(CloudProvider::Hetzner) - ); - assert_eq!( - cloud_provider_from_code("do"), - Some(CloudProvider::Digitalocean) - ); + assert_eq!(cloud_provider_from_code("htz"), Some(CloudProvider::Hetzner)); + assert_eq!(cloud_provider_from_code("do"), Some(CloudProvider::Digitalocean)); assert_eq!(cloud_provider_from_code("aws"), Some(CloudProvider::Aws)); assert_eq!(cloud_provider_from_code("lo"), Some(CloudProvider::Linode)); assert_eq!(cloud_provider_from_code("vu"), Some(CloudProvider::Vultr)); // Full names - assert_eq!( - cloud_provider_from_code("hetzner"), - Some(CloudProvider::Hetzner) - ); - assert_eq!( - cloud_provider_from_code("digitalocean"), - Some(CloudProvider::Digitalocean) - ); - assert_eq!( - cloud_provider_from_code("linode"), - Some(CloudProvider::Linode) - ); - assert_eq!( - cloud_provider_from_code("vultr"), - Some(CloudProvider::Vultr) - ); + assert_eq!(cloud_provider_from_code("hetzner"), Some(CloudProvider::Hetzner)); + assert_eq!(cloud_provider_from_code("digitalocean"), Some(CloudProvider::Digitalocean)); + assert_eq!(cloud_provider_from_code("linode"), Some(CloudProvider::Linode)); + assert_eq!(cloud_provider_from_code("vultr"), Some(CloudProvider::Vultr)); // Case insensitive - assert_eq!( - cloud_provider_from_code("HTZ"), - Some(CloudProvider::Hetzner) - ); + assert_eq!(cloud_provider_from_code("HTZ"), Some(CloudProvider::Hetzner)); assert_eq!(cloud_provider_from_code("AWS"), Some(CloudProvider::Aws)); // Unknown assert_eq!(cloud_provider_from_code("unknown"), None); @@ -2421,17 +2205,21 @@ services: #[test] fn test_with_watch_flags() { - let cmd = DeployCommand::new(None, None, false, false).with_watch(false, false); + let cmd = DeployCommand::new(None, None, false, false) + .with_watch(false, false); assert_eq!(cmd.watch, None); // auto - let cmd = DeployCommand::new(None, None, false, false).with_watch(true, false); + let cmd = DeployCommand::new(None, None, false, false) + .with_watch(true, false); assert_eq!(cmd.watch, Some(true)); - let cmd = DeployCommand::new(None, None, false, false).with_watch(false, true); + let cmd = DeployCommand::new(None, None, false, false) + .with_watch(false, true); assert_eq!(cmd.watch, Some(false)); // --no-watch wins over --watch - let cmd = DeployCommand::new(None, None, false, false).with_watch(true, true); + let cmd = DeployCommand::new(None, None, false, false) + .with_watch(true, true); assert_eq!(cmd.watch, Some(false)); } } diff --git a/src/console/commands/cli/destroy.rs b/src/console/commands/cli/destroy.rs index 3dc4b58e..ebfe354c 100644 --- a/src/console/commands/cli/destroy.rs +++ b/src/console/commands/cli/destroy.rs @@ -2,7 +2,9 @@ use std::path::Path; use crate::cli::config_parser::DeployTarget; use crate::cli::error::CliError; -use crate::cli::install_runner::{CommandExecutor, ShellExecutor}; +use crate::cli::install_runner::{ + CommandExecutor, ShellExecutor, +}; use crate::console::commands::CallableTrait; /// Output directory for generated artifacts. @@ -104,9 +106,7 @@ mod tests { impl MockExecutor { fn new() -> Self { - Self { - calls: Mutex::new(Vec::new()), - } + Self { calls: Mutex::new(Vec::new()) } } fn recorded_calls(&self) -> Vec<(String, Vec)> { @@ -120,11 +120,7 @@ mod tests { program.to_string(), args.iter().map(|s| s.to_string()).collect(), )); - Ok(CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - }) + Ok(CommandOutput { exit_code: 0, stdout: String::new(), stderr: String::new() }) } } diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index 184b9144..d3a8bb9b 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -1,7 +1,7 @@ use std::convert::TryFrom; use std::path::{Path, PathBuf}; -use crate::cli::ai_client::{create_provider, ollama_complete_streaming, AiProvider}; +use crate::cli::ai_client::{AiProvider, create_provider, ollama_complete_streaming}; use crate::cli::ai_scanner::{ build_generation_request, generate_config_with_ai, strip_code_fences, }; @@ -147,12 +147,7 @@ pub struct InitCommand { } impl InitCommand { - pub fn new( - app_type: Option, - with_proxy: bool, - with_ai: bool, - with_cloud: bool, - ) -> Self { + pub fn new(app_type: Option, with_proxy: bool, with_ai: bool, with_cloud: bool) -> Self { Self { app_type, with_proxy, @@ -398,7 +393,10 @@ fn generate_config_ai_path( \n\ {}\n", ai_config.provider, - ai_config.model.as_deref().unwrap_or("default"), + ai_config + .model + .as_deref() + .unwrap_or("default"), full_config_reference_example(), ); @@ -568,22 +566,14 @@ fn collect_generation_context(project_dir: &Path, config: &StackerConfig) -> Vec } )); - let lockfiles = [ - "package-lock.json", - "pnpm-lock.yaml", - "yarn.lock", - "Cargo.lock", - ]; + let lockfiles = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.lock"]; let found_lockfiles: Vec<&str> = lockfiles .iter() .copied() .filter(|file| project_dir.join(file).exists()) .collect(); if !found_lockfiles.is_empty() { - context.push(format!( - "Detected lockfiles: {}", - found_lockfiles.join(", ") - )); + context.push(format!("Detected lockfiles: {}", found_lockfiles.join(", "))); } context @@ -645,8 +635,7 @@ fn generate_compose_with_ai( context.join("\n\n"), dockerfile_rel ); - let system = - "You are an expert Docker Compose engineer. Return only valid docker compose YAML."; + let system = "You are an expert Docker Compose engineer. Return only valid docker compose YAML."; let raw = if ai_config.provider == AiProviderType::Ollama { eprintln!("🧠 AI reasoning for compose (streaming)..."); let response = ollama_complete_streaming(ai_config, &prompt, system)?; @@ -657,8 +646,9 @@ fn generate_compose_with_ai( }; let compose = strip_code_fences(&raw); - let parsed: serde_yaml::Value = serde_yaml::from_str(&compose) - .map_err(|e| CliError::GeneratorError(format!("AI generated invalid compose YAML: {e}")))?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&compose).map_err(|e| { + CliError::GeneratorError(format!("AI generated invalid compose YAML: {e}")) + })?; if parsed.get("services").is_none() { return Err(CliError::GeneratorError( @@ -688,8 +678,7 @@ impl CallableTrait for InitCommand { if self.with_cloud { eprintln!("☁ Running cloud setup wizard..."); let path_str = config_path.to_string_lossy().to_string(); - let applied = - crate::console::commands::cli::config::run_setup_cloud_interactive(&path_str)?; + let applied = crate::console::commands::cli::config::run_setup_cloud_interactive(&path_str)?; for item in applied { eprintln!(" - {}", item); } @@ -706,15 +695,7 @@ impl CallableTrait for InitCommand { eprintln!(" AI: enabled ({})", config.ai.provider); } if !config.services.is_empty() { - eprintln!( - " Services: {}", - config - .services - .iter() - .map(|s| s.name.as_str()) - .collect::>() - .join(", ") - ); + eprintln!(" Services: {}", config.services.iter().map(|s| s.name.as_str()).collect::>().join(", ")); } // Generate .stacker/ directory with Dockerfile and docker-compose.yml @@ -751,8 +732,7 @@ impl CallableTrait for InitCommand { let mut generated = false; if let Some((ref ai_cfg, ref provider)) = ai_runtime { - match generate_dockerfile_with_ai(&project_dir, &config, ai_cfg, provider.as_ref()) - { + match generate_dockerfile_with_ai(&project_dir, &config, ai_cfg, provider.as_ref()) { Ok(dockerfile) => { std::fs::write(&dockerfile_path, dockerfile)?; eprintln!("✓ Generated {}/Dockerfile (AI)", OUTPUT_DIR); @@ -841,7 +821,10 @@ mod tests { fn new(responses: Vec<&str>) -> Self { Self { responses: std::sync::Mutex::new( - responses.into_iter().map(|s| s.to_string()).collect(), + responses + .into_iter() + .map(|s| s.to_string()) + .collect(), ), } } @@ -989,12 +972,7 @@ mod tests { #[test] fn test_resolve_ai_config_explicit_provider() { - let config = resolve_ai_config( - Some("anthropic"), - Some("claude-sonnet-4-20250514"), - Some("sk-ant-test"), - ) - .unwrap(); + let config = resolve_ai_config(Some("anthropic"), Some("claude-sonnet-4-20250514"), Some("sk-ant-test")).unwrap(); assert_eq!(config.provider, AiProviderType::Anthropic); assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-20250514")); assert_eq!(config.api_key.as_deref(), Some("sk-ant-test")); @@ -1060,18 +1038,12 @@ mod tests { #[test] fn test_parse_ai_provider_all_valid() { assert_eq!(parse_ai_provider("openai").unwrap(), AiProviderType::Openai); - assert_eq!( - parse_ai_provider("anthropic").unwrap(), - AiProviderType::Anthropic - ); + assert_eq!(parse_ai_provider("anthropic").unwrap(), AiProviderType::Anthropic); assert_eq!(parse_ai_provider("ollama").unwrap(), AiProviderType::Ollama); assert_eq!(parse_ai_provider("custom").unwrap(), AiProviderType::Custom); // Case insensitive assert_eq!(parse_ai_provider("OpenAI").unwrap(), AiProviderType::Openai); - assert_eq!( - parse_ai_provider("ANTHROPIC").unwrap(), - AiProviderType::Anthropic - ); + assert_eq!(parse_ai_provider("ANTHROPIC").unwrap(), AiProviderType::Anthropic); } #[test] @@ -1081,13 +1053,8 @@ mod tests { // Use an explicit provider that will fail connection (port 1 is unreachable) // This avoids hitting a real running Ollama instance let result = generate_config_full( - dir.path(), - None, - false, - true, - Some("custom"), - None, - Some("fake-key"), + dir.path(), None, false, true, + Some("custom"), None, Some("fake-key"), ); assert!(result.is_ok()); @@ -1119,8 +1086,7 @@ mod tests { ..AiConfig::default() }; - let dockerfile = - generate_dockerfile_with_ai(dir.path(), &config, &ai_cfg, &provider).unwrap(); + let dockerfile = generate_dockerfile_with_ai(dir.path(), &config, &ai_cfg, &provider).unwrap(); assert!(dockerfile.contains("FROM node:20-alpine")); assert!(!dockerfile.contains("```")); } diff --git a/src/console/commands/cli/list.rs b/src/console/commands/cli/list.rs index bb06e70c..e60cbbec 100644 --- a/src/console/commands/cli/list.rs +++ b/src/console/commands/cli/list.rs @@ -32,9 +32,7 @@ impl CallableTrait for ListProjectsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -101,9 +99,7 @@ impl CallableTrait for ListServersCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -160,11 +156,7 @@ pub struct ListDeploymentsCommand { impl ListDeploymentsCommand { pub fn new(json: bool, project_id: Option, limit: Option) -> Self { - Self { - json, - project_id, - limit, - } + Self { json, project_id, limit } } } @@ -179,9 +171,7 @@ impl CallableTrait for ListDeploymentsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; let project_id = self.project_id; let limit = self.limit; @@ -253,9 +243,7 @@ impl CallableTrait for ListSshKeysCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -293,10 +281,7 @@ impl CallableTrait for ListSshKeysCommand { let mut active_count = 0; for s in &servers { let status_icon = match s.key_status.as_str() { - "active" => { - active_count += 1; - "✓ active" - } + "active" => { active_count += 1; "✓ active" }, "pending" => "◷ pending", "failed" => "✗ failed", _ => " none", @@ -306,9 +291,7 @@ impl CallableTrait for ListSshKeysCommand { s.id, truncate(&s.name.clone().unwrap_or_else(|| "-".to_string()), 18), s.srv_ip.clone().unwrap_or_else(|| "-".to_string()), - s.ssh_port - .map(|p| p.to_string()) - .unwrap_or_else(|| "22".to_string()), + s.ssh_port.map(|p| p.to_string()).unwrap_or_else(|| "22".to_string()), s.ssh_user.clone().unwrap_or_else(|| "root".to_string()), status_icon, &s.connection_mode, @@ -368,9 +351,7 @@ impl CallableTrait for ListCloudsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -378,12 +359,8 @@ impl CallableTrait for ListCloudsCommand { if clouds.is_empty() { eprintln!("No saved cloud credentials found."); - eprintln!( - "Cloud credentials are saved automatically when you deploy with env vars," - ); - eprintln!( - "or via: stacker deploy --target cloud (with HCLOUD_TOKEN, etc. exported)." - ); + eprintln!("Cloud credentials are saved automatically when you deploy with env vars,"); + eprintln!("or via: stacker deploy --target cloud (with HCLOUD_TOKEN, etc. exported)."); return Ok(()); } diff --git a/src/console/commands/cli/login.rs b/src/console/commands/cli/login.rs index 56f23eb1..fce156c4 100644 --- a/src/console/commands/cli/login.rs +++ b/src/console/commands/cli/login.rs @@ -1,7 +1,9 @@ use std::io::{self, IsTerminal}; -use crate::cli::credentials::{login, CredentialsManager, HttpOAuthClient, LoginRequest}; use crate::console::commands::CallableTrait; +use crate::cli::credentials::{ + CredentialsManager, HttpOAuthClient, LoginRequest, login, +}; use dialoguer::Password; /// `stacker login [--org ] [--domain ] [--auth-url ]` diff --git a/src/console/commands/cli/logs.rs b/src/console/commands/cli/logs.rs index 252c9800..edf067c3 100644 --- a/src/console/commands/cli/logs.rs +++ b/src/console/commands/cli/logs.rs @@ -125,7 +125,10 @@ impl CallableTrait for LogsCommand { // No local compose — try remote agent logs if is_remote_deployment(&project_dir) { - return run_remote_logs(self.service.as_deref(), self.tail); + return run_remote_logs( + self.service.as_deref(), + self.tail, + ); } // Neither local nor remote @@ -266,10 +269,7 @@ fn run_remote_logs( }; if app_codes.is_empty() { - eprintln!( - "No containers found for deployment {}.", - &hash[..8.min(hash.len())] - ); + eprintln!("No containers found for deployment {}.", &hash[..8.min(hash.len())]); eprintln!( "Tip: use 'stacker agent status --deployment {}' to check the deployment.", &hash[..8.min(hash.len())] @@ -315,7 +315,8 @@ fn run_remote_agent_command( let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); let interval = std::time::Duration::from_secs(REMOTE_POLL_INTERVAL_SECS); let mut last_status = "pending".to_string(); @@ -338,7 +339,10 @@ fn run_remote_agent_command( .await?; last_status = status.status.clone(); - progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); + progress::update_message( + &pb, + &format!("{} [{}]", spinner_msg, status.status), + ); match status.status.as_str() { "completed" | "failed" => return Ok(status), @@ -376,11 +380,7 @@ fn print_logs_result(app_code: &str, info: &AgentCommandInfo, multi: bool) { if info.status == "failed" { if let Some(ref error) = info.error { - eprintln!( - "Error fetching logs for {}: {}", - app_code, - fmt::pretty_json(error) - ); + eprintln!("Error fetching logs for {}: {}", app_code, fmt::pretty_json(error)); } return; } @@ -453,11 +453,7 @@ mod tests { struct MockExec; impl CommandExecutor for MockExec { fn execute(&self, _p: &str, _a: &[&str]) -> Result { - Ok(CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - }) + Ok(CommandOutput { exit_code: 0, stdout: String::new(), stderr: String::new() }) } } diff --git a/src/console/commands/cli/marketplace.rs b/src/console/commands/cli/marketplace.rs index 5e0c8460..55bcfb81 100644 --- a/src/console/commands/cli/marketplace.rs +++ b/src/console/commands/cli/marketplace.rs @@ -35,9 +35,7 @@ impl CallableTrait for MarketplaceStatusCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; let name = self.name.clone(); @@ -137,9 +135,7 @@ impl CallableTrait for MarketplaceLogsCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; let name = self.name.clone(); @@ -148,7 +144,9 @@ impl CallableTrait for MarketplaceLogsCommand { // First, find the template by name to get its ID let templates = client.marketplace_list_mine().await?; - let template = templates.iter().find(|t| t.name == name || t.slug == name); + let template = templates + .iter() + .find(|t| t.name == name || t.slug == name); let template = match template { Some(t) => t, diff --git a/src/console/commands/cli/pipe.rs b/src/console/commands/cli/pipe.rs index efde9765..ba63ccc9 100644 --- a/src/console/commands/cli/pipe.rs +++ b/src/console/commands/cli/pipe.rs @@ -99,7 +99,8 @@ fn run_agent_command( let command_id = info.command_id.clone(); let deployment_hash = request.deployment_hash.clone(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(timeout); let interval = std::time::Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS); let mut last_status = "pending".to_string(); @@ -121,7 +122,10 @@ fn run_agent_command( .await?; last_status = status.status.clone(); - progress::update_message(&pb, &format!("{} [{}]", spinner_msg, status.status)); + progress::update_message( + &pb, + &format!("{} [{}]", spinner_msg, status.status), + ); match status.status.as_str() { "completed" | "failed" => return Ok(status), @@ -155,11 +159,7 @@ fn print_command_result(info: &AgentCommandInfo, json_output: bool) { println!("Command: {}", info.command_id); println!("Type: {}", info.command_type); - println!( - "Status: {} {}", - progress::status_icon(&info.status), - info.status - ); + println!("Status: {} {}", progress::status_icon(&info.status), info.status); if let Some(ref result) = info.result { println!("\n{}", fmt::pretty_json(result)); diff --git a/src/console/commands/cli/proxy.rs b/src/console/commands/cli/proxy.rs index 3ff67295..2ddb5979 100644 --- a/src/console/commands/cli/proxy.rs +++ b/src/console/commands/cli/proxy.rs @@ -4,8 +4,8 @@ use crate::cli::config_parser::{ use crate::cli::deployment_lock::DeploymentLock; use crate::cli::error::CliError; use crate::cli::proxy_manager::{ - detect_proxy, detect_proxy_from_snapshot, generate_nginx_server_block, ContainerRuntime, - DockerCliRuntime, ProxyDetection, + ContainerRuntime, DockerCliRuntime, ProxyDetection, detect_proxy, + detect_proxy_from_snapshot, generate_nginx_server_block, }; use crate::cli::runtime::CliRuntime; use crate::console::commands::CallableTrait; @@ -20,11 +20,7 @@ pub fn parse_ssl_mode(s: Option<&str>) -> SslMode { } /// Build a `DomainConfig` from CLI arguments. -pub fn build_domain_config( - domain: &str, - upstream: Option<&str>, - ssl: Option<&str>, -) -> DomainConfig { +pub fn build_domain_config(domain: &str, upstream: Option<&str>, ssl: Option<&str>) -> DomainConfig { DomainConfig { domain: domain.to_string(), ssl: parse_ssl_mode(ssl), @@ -58,8 +54,11 @@ impl ProxyAddCommand { impl CallableTrait for ProxyAddCommand { fn call(&self) -> Result<(), Box> { - let config = - build_domain_config(&self.domain, self.upstream.as_deref(), self.ssl.as_deref()); + let config = build_domain_config( + &self.domain, + self.upstream.as_deref(), + self.ssl.as_deref(), + ); let block = generate_nginx_server_block(&config); println!("{}", block); eprintln!("✓ Proxy entry generated for {}", self.domain); diff --git a/src/console/commands/cli/resolve.rs b/src/console/commands/cli/resolve.rs index 5fd968d5..6e3c93a6 100644 --- a/src/console/commands/cli/resolve.rs +++ b/src/console/commands/cli/resolve.rs @@ -19,11 +19,7 @@ pub struct ResolveCommand { impl ResolveCommand { pub fn new(confirm: bool, force: bool, deployment: Option) -> Self { - Self { - confirm, - force, - deployment, - } + Self { confirm, force, deployment } } } @@ -47,8 +43,9 @@ impl CallableTrait for ResolveCommand { } let config_str = std::fs::read_to_string(&config_path)?; - let config: StackerConfig = serde_yaml::from_str(&config_str) - .map_err(|e| CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)))?; + let config: StackerConfig = serde_yaml::from_str(&config_str).map_err(|e| { + CliError::ConfigValidation(format!("Invalid stacker.yml: {}", e)) + })?; let project_name = config .project diff --git a/src/console/commands/cli/secrets.rs b/src/console/commands/cli/secrets.rs index 5f2dcf85..5c3e8af6 100644 --- a/src/console/commands/cli/secrets.rs +++ b/src/console/commands/cli/secrets.rs @@ -79,10 +79,7 @@ fn resolve_env_path(explicit: Option<&str>) -> PathBuf { for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("env_file:") { - let val = trimmed["env_file:".len()..] - .trim() - .trim_matches('"') - .trim_matches('\''); + let val = trimmed["env_file:".len()..].trim().trim_matches('"').trim_matches('\''); if !val.is_empty() { return PathBuf::from(val); } @@ -171,7 +168,9 @@ impl CallableTrait for SecretsGetCommand { let env_path = resolve_env_path(self.file.as_deref()); if !env_path.exists() { - return Err(Box::new(CliError::EnvFileNotFound { path: env_path })); + return Err(Box::new(CliError::EnvFileNotFound { + path: env_path, + })); } let lines = read_env_lines(&env_path)?; @@ -269,7 +268,9 @@ impl CallableTrait for SecretsDeleteCommand { let env_path = resolve_env_path(self.file.as_deref()); if !env_path.exists() { - return Err(Box::new(CliError::EnvFileNotFound { path: env_path })); + return Err(Box::new(CliError::EnvFileNotFound { + path: env_path, + })); } let lines = read_env_lines(&env_path)?; diff --git a/src/console/commands/cli/service.rs b/src/console/commands/cli/service.rs index c9507820..36679c9d 100644 --- a/src/console/commands/cli/service.rs +++ b/src/console/commands/cli/service.rs @@ -8,7 +8,7 @@ use std::path::Path; -use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; +use crate::cli::config_parser::{StackerConfig, ServiceDefinition}; use crate::cli::credentials::CredentialsManager; use crate::cli::error::CliError; use crate::cli::service_catalog::ServiceCatalog; @@ -93,9 +93,7 @@ impl CallableTrait for ServiceAddCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; let entry = rt.block_on(catalog.resolve(&canonical))?; @@ -121,9 +119,8 @@ impl CallableTrait for ServiceAddCommand { config.services.push(entry.service.clone()); // Serialize back to YAML - let yaml = serde_yaml::to_string(&config).map_err(|e| { - CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) - })?; + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; // Backup and write let backup_path = format!("{}.bak", config_path); @@ -139,21 +136,11 @@ impl CallableTrait for ServiceAddCommand { println!(" Volumes: {}", entry.service.volumes.join(", ")); } if !entry.service.environment.is_empty() { - println!( - " Env vars: {}", - entry - .service - .environment - .keys() - .cloned() - .collect::>() - .join(", ") - ); + println!(" Env vars: {}", entry.service.environment.keys() + .cloned().collect::>().join(", ")); } if !entry.related.is_empty() { - let missing_related: Vec<&str> = entry - .related - .iter() + let missing_related: Vec<&str> = entry.related.iter() .filter(|r| !config.services.iter().any(|s| &s.name == *r)) .map(|r| r.as_str()) .collect(); @@ -196,8 +183,7 @@ impl CallableTrait for ServiceListCommand { let entries = catalog.list_available(); // Group by category - let mut by_category: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); + let mut by_category: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for entry in &entries { by_category .entry(entry.category.clone()) @@ -230,12 +216,7 @@ impl CallableTrait for ServiceListCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!( - "Failed to create async runtime: {}", - e - )) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; match rt.block_on(client.list_marketplace_templates(None, None)) { Ok(templates) if templates.is_empty() => { @@ -304,7 +285,10 @@ impl CallableTrait for ServiceRemoveCommand { } let confirmed = Confirm::new() - .with_prompt(format!("Remove '{}' from {}?", canonical, config_path)) + .with_prompt(format!( + "Remove '{}' from {}?", + canonical, config_path + )) .default(false) .interact() .map_err(|e| CliError::ConfigValidation(format!("Prompt error: {}", e)))?; @@ -316,9 +300,8 @@ impl CallableTrait for ServiceRemoveCommand { config.services.retain(|s| s.name != canonical); - let yaml = serde_yaml::to_string(&config).map_err(|e| { - CliError::ConfigValidation(format!("Failed to serialize config: {}", e)) - })?; + let yaml = serde_yaml::to_string(&config) + .map_err(|e| CliError::ConfigValidation(format!("Failed to serialize config: {}", e)))?; let backup_path = format!("{}.bak", config_path); std::fs::copy(config_path, &backup_path)?; diff --git a/src/console/commands/cli/ssh_key.rs b/src/console/commands/cli/ssh_key.rs index 09ea2387..2c2c6b25 100644 --- a/src/console/commands/cli/ssh_key.rs +++ b/src/console/commands/cli/ssh_key.rs @@ -42,9 +42,7 @@ impl CallableTrait for SshKeyGenerateCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -111,9 +109,7 @@ impl CallableTrait for SshKeyShowCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -166,18 +162,16 @@ impl CallableTrait for SshKeyUploadCommand { let priv_path = self.private_key.clone(); // Read key files - let public_key = std::fs::read_to_string(&pub_path).map_err(|e| { - CliError::Io(std::io::Error::new( + let public_key = std::fs::read_to_string(&pub_path) + .map_err(|e| CliError::Io(std::io::Error::new( e.kind(), format!("Failed to read public key {}: {}", pub_path.display(), e), - )) - })?; - let private_key = std::fs::read_to_string(&priv_path).map_err(|e| { - CliError::Io(std::io::Error::new( + )))?; + let private_key = std::fs::read_to_string(&priv_path) + .map_err(|e| CliError::Io(std::io::Error::new( e.kind(), format!("Failed to read private key {}: {}", priv_path.display(), e), - )) - })?; + )))?; let cred_manager = CredentialsManager::with_default_store(); let creds = cred_manager.require_valid_token("ssh-key upload")?; @@ -186,9 +180,7 @@ impl CallableTrait for SshKeyUploadCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -232,13 +224,13 @@ pub struct SshKeyInjectCommand { } impl SshKeyInjectCommand { - pub fn new(server_id: i32, with_key: PathBuf, user: Option, port: Option) -> Self { - Self { - server_id, - with_key, - user, - port, - } + pub fn new( + server_id: i32, + with_key: PathBuf, + user: Option, + port: Option, + ) -> Self { + Self { server_id, with_key, user, port } } } @@ -250,12 +242,11 @@ impl CallableTrait for SshKeyInjectCommand { let override_port = self.port; // Read the local working private key - let local_private_key = std::fs::read_to_string(&key_path).map_err(|e| { - CliError::Io(std::io::Error::new( + let local_private_key = std::fs::read_to_string(&key_path) + .map_err(|e| CliError::Io(std::io::Error::new( e.kind(), format!("Failed to read key file {}: {}", key_path.display(), e), - )) - })?; + )))?; let cred_manager = CredentialsManager::with_default_store(); let creds = cred_manager.require_valid_token("ssh-key inject")?; @@ -264,9 +255,7 @@ impl CallableTrait for SshKeyInjectCommand { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| { - CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)) - })?; + .map_err(|e| CliError::ConfigValidation(format!("Failed to create async runtime: {}", e)))?; rt.block_on(async { let client = StackerClient::new(&base_url, &creds.access_token); @@ -276,23 +265,21 @@ impl CallableTrait for SshKeyInjectCommand { let server_info = servers .into_iter() .find(|s| s.id == server_id) - .ok_or_else(|| { - CliError::ConfigValidation(format!("Server {} not found", server_id)) - })?; + .ok_or_else(|| CliError::ConfigValidation( + format!("Server {} not found", server_id) + ))?; let host = server_info .srv_ip .as_deref() .filter(|ip| !ip.is_empty()) - .ok_or_else(|| { - CliError::ConfigValidation(format!( - "Server {} has no IP address — deploy it first", - server_id - )) - })? + .ok_or_else(|| CliError::ConfigValidation( + format!("Server {} has no IP address — deploy it first", server_id) + ))? .to_string(); - let port = override_port.unwrap_or_else(|| server_info.ssh_port.unwrap_or(22) as u16); + let port = override_port + .unwrap_or_else(|| server_info.ssh_port.unwrap_or(22) as u16); let user = override_user .or_else(|| server_info.ssh_user.clone()) .unwrap_or_else(|| "root".to_string()); @@ -303,21 +290,11 @@ impl CallableTrait for SshKeyInjectCommand { println!("Server: {} (ID {})", host, server_id); println!("SSH user: {} port: {}", user, port); - println!( - "Vault key: {}", - &vault_public_key[..vault_public_key.len().min(60)] - ); + println!("Vault key: {}", &vault_public_key[..vault_public_key.len().min(60)]); println!(); println!("Connecting to inject key into authorized_keys…"); - inject_key_via_ssh( - &host, - port, - &user, - local_private_key.trim(), - &vault_public_key, - ) - .await + inject_key_via_ssh(&host, port, &user, local_private_key.trim(), &vault_public_key).await }) } } @@ -331,9 +308,9 @@ async fn inject_key_via_ssh( local_private_key: &str, vault_public_key: &str, ) -> Result<(), Box> { - use russh::client::{Config, Handle}; use std::sync::Arc; use std::time::Duration; + use russh::client::{Config, Handle}; struct AcceptAllKeys; @@ -355,25 +332,19 @@ async fn inject_key_via_ssh( }); let addr = format!("{}:{}", host, port); - let mut handle: Handle = tokio::time::timeout( - Duration::from_secs(4), - russh::client::connect(config, addr, AcceptAllKeys), - ) - .await - .map_err(|_| CliError::ConfigValidation(format!("Connection to {}:{} timed out", host, port)))? - .map_err(|e| CliError::ConfigValidation(format!("Connection failed: {}", e)))?; + let mut handle: Handle = + tokio::time::timeout(Duration::from_secs(4), russh::client::connect(config, addr, AcceptAllKeys)) + .await + .map_err(|_| CliError::ConfigValidation(format!("Connection to {}:{} timed out", host, port)))? + .map_err(|e| CliError::ConfigValidation(format!("Connection failed: {}", e)))?; let auth_res = handle .authenticate_publickey( username, russh::keys::key::PrivateKeyWithHashAlg::new( Arc::new(key), - handle - .best_supported_rsa_hash() - .await - .map_err(|e| { - CliError::ConfigValidation(format!("RSA hash negotiation failed: {}", e)) - })? + handle.best_supported_rsa_hash().await + .map_err(|e| CliError::ConfigValidation(format!("RSA hash negotiation failed: {}", e)))? .flatten(), ), ) @@ -394,13 +365,9 @@ async fn inject_key_via_ssh( safe_key, safe_key ); - let mut channel = handle - .channel_open_session() - .await + let mut channel = handle.channel_open_session().await .map_err(|e| CliError::ConfigValidation(format!("Failed to open SSH channel: {}", e)))?; - channel - .exec(true, cmd) - .await + channel.exec(true, cmd).await .map_err(|e| CliError::ConfigValidation(format!("Failed to exec command: {}", e)))?; // Drain channel output @@ -409,10 +376,9 @@ async fn inject_key_via_ssh( Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, Some(russh::ChannelMsg::ExitStatus { exit_status }) => { if exit_status != 0 { - return Err(Box::new(CliError::ConfigValidation(format!( - "Remote command exited with status {}", - exit_status - )))); + return Err(Box::new(CliError::ConfigValidation( + format!("Remote command exited with status {}", exit_status), + ))); } break; } @@ -421,14 +387,9 @@ async fn inject_key_via_ssh( } let _ = channel.eof().await; - let _ = handle - .disconnect(russh::Disconnect::ByApplication, "", "English") - .await; + let _ = handle.disconnect(russh::Disconnect::ByApplication, "", "English").await; - println!( - "✓ Vault public key injected into {}@{}:{} authorized_keys", - username, host, port - ); + println!("✓ Vault public key injected into {}@{}:{} authorized_keys", username, host, port); println!(); println!("You can now run: stacker deploy"); diff --git a/src/console/commands/cli/status.rs b/src/console/commands/cli/status.rs index 9c725bcc..915bf49c 100644 --- a/src/console/commands/cli/status.rs +++ b/src/console/commands/cli/status.rs @@ -72,7 +72,13 @@ pub fn run_status( // ── Cloud deployment status ───────────────────────── /// Terminal statuses — once reached, `--watch` stops polling. -const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "cancelled", "error", "paused"]; +const TERMINAL_STATUSES: &[&str] = &[ + "completed", + "failed", + "cancelled", + "error", + "paused", +]; /// Check if a status is terminal (deployment finished or failed). fn is_terminal(status: &str) -> bool { @@ -86,7 +92,11 @@ struct StatusContext<'a> { } /// Pretty-print a deployment status with optional server/config context. -fn print_deployment_status_rich(info: &DeploymentStatusInfo, json: bool, ctx: &StatusContext<'_>) { +fn print_deployment_status_rich( + info: &DeploymentStatusInfo, + json: bool, + ctx: &StatusContext<'_>, +) { if json { if let Ok(j) = serde_json::to_string_pretty(info) { println!("{}", j); @@ -231,8 +241,9 @@ fn run_cloud_status(json: bool, watch: bool) -> Result<(), Box Result<(), Box Result { - Ok(CommandOutput { - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - }) + Ok(CommandOutput { exit_code: 0, stdout: String::new(), stderr: String::new() }) } } diff --git a/src/console/commands/cli/submit.rs b/src/console/commands/cli/submit.rs index e9f78a04..0d94e5ee 100644 --- a/src/console/commands/cli/submit.rs +++ b/src/console/commands/cli/submit.rs @@ -76,13 +76,7 @@ impl CallableTrait for SubmitCommand { let slug = name .to_lowercase() .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' { - c - } else { - '-' - } - }) + .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }) .collect::() .split('-') .filter(|s| !s.is_empty()) @@ -132,7 +126,10 @@ impl CallableTrait for SubmitCommand { // Success message println!(); - println!("Submitted '{}' v{} for marketplace review.", name, version); + println!( + "Submitted '{}' v{} for marketplace review.", + name, version + ); println!( "Your stack will be published automatically once accepted by the review team." ); diff --git a/src/console/commands/mq/listener.rs b/src/console/commands/mq/listener.rs index e056f78f..d77c225b 100644 --- a/src/console/commands/mq/listener.rs +++ b/src/console/commands/mq/listener.rs @@ -271,10 +271,10 @@ impl ListenCommand { async fn connect_with_retry(connection_string: &str) -> Result { let max_retries = 10; let mut retry_delay = Duration::from_secs(1); - + for attempt in 1..=max_retries { println!("RabbitMQ connection attempt {}/{}", attempt, max_retries); - + match MqManager::try_new(connection_string.to_string()) { Ok(manager) => { println!("Connected to RabbitMQ"); @@ -289,7 +289,7 @@ impl ListenCommand { } } } - + Err(format!("Failed to connect after {} attempts", max_retries)) } } diff --git a/src/console/main.rs b/src/console/main.rs index a0c117ae..ce2147f6 100644 --- a/src/console/main.rs +++ b/src/console/main.rs @@ -294,9 +294,7 @@ fn main() -> Result<(), Box> { get_command(command)?.call() } -fn get_command( - command: Commands, -) -> Result, String> { +fn get_command(command: Commands) -> Result, String> { match command { Commands::AppClient { command } => match command { AppClientCommands::New { user_id } => Ok(Box::new( diff --git a/src/db/agent_audit_log.rs b/src/db/agent_audit_log.rs index 7c4a81e4..1eac7c04 100644 --- a/src/db/agent_audit_log.rs +++ b/src/db/agent_audit_log.rs @@ -19,10 +19,7 @@ pub async fn insert_batch( let span = tracing::info_span!("Inserting audit events into database"); for event in events { - let created_at = Utc - .timestamp_opt(event.created_at, 0) - .single() - .unwrap_or_else(Utc::now); + let created_at = Utc.timestamp_opt(event.created_at, 0).single().unwrap_or_else(Utc::now); sqlx::query_as::<_, AgentAuditLog>( r#" diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index 5b03010a..0b85346f 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -709,8 +709,7 @@ pub async fn admin_unapprove( reviewer_user_id: &str, reason: Option<&str>, ) -> Result { - let _query_span = - tracing::info_span!("marketplace_admin_unapprove", template_id = %template_id); + let _query_span = tracing::info_span!("marketplace_admin_unapprove", template_id = %template_id); let mut tx = pool.begin().await.map_err(|e| { tracing::error!("tx begin error: {:?}", e); diff --git a/src/db/project_app.rs b/src/db/project_app.rs index 59d16bfc..e17e535e 100644 --- a/src/db/project_app.rs +++ b/src/db/project_app.rs @@ -255,16 +255,18 @@ pub async fn delete_by_project_and_code( code: &str, ) -> Result { let query_span = tracing::info_span!("Deleting app by project and code"); - let result = sqlx::query("DELETE FROM project_app WHERE project_id = $1 AND code = $2") - .bind(project_id) - .bind(code) - .execute(pool) - .instrument(query_span) - .await - .map_err(|e| { - tracing::error!("Failed to delete app by project and code: {:?}", e); - format!("Failed to delete app: {}", e) - })?; + let result = sqlx::query( + "DELETE FROM project_app WHERE project_id = $1 AND code = $2", + ) + .bind(project_id) + .bind(code) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("Failed to delete app by project and code: {:?}", e); + format!("Failed to delete app: {}", e) + })?; Ok(result.rows_affected() > 0) } diff --git a/src/forms/project/volume.rs b/src/forms/project/volume.rs index 28990304..cc684efe 100644 --- a/src/forms/project/volume.rs +++ b/src/forms/project/volume.rs @@ -88,10 +88,7 @@ impl Volume { }; } - tracing::debug!( - "Bind mount volume '{}' — adding driver_opts with base dir", - host_path - ); + tracing::debug!("Bind mount volume '{}' — adding driver_opts with base dir", host_path); let default_base = std::env::var("DEFAULT_DEPLOY_DIR").unwrap_or_else(|_| "/home/trydirect".to_string()); @@ -109,7 +106,9 @@ impl Volume { ); // Normalize to avoid duplicate slashes in bind-mount device paths. - let normalized_host = host_path.trim_start_matches("./").trim_start_matches('/'); + let normalized_host = host_path + .trim_start_matches("./") + .trim_start_matches('/'); let path = format!("{}/{}", base.trim_end_matches('/'), normalized_host); driver_opts.insert( String::from("device"), @@ -153,16 +152,14 @@ mod tests { }; let compose = volume.to_compose_volume(Some("/srv/trydirect")); - let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); + let device = compose + .driver_opts + .get("device") + .and_then(|v| v.as_ref()); assert_eq!(compose.driver.as_deref(), Some("local")); assert_eq!(compose.name.as_deref(), Some("projects/app")); - assert_eq!( - device, - Some(&SingleValue::String( - "/srv/trydirect/projects/app".to_string() - )) - ); + assert_eq!(device, Some(&SingleValue::String("/srv/trydirect/projects/app".to_string()))); } #[test] @@ -173,14 +170,14 @@ mod tests { }; let compose = volume.to_compose_volume(Some("/srv/trydirect")); - let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); + let device = compose + .driver_opts + .get("device") + .and_then(|v| v.as_ref()); assert!(!volume.is_named_docker_volume()); assert_eq!(compose.driver.as_deref(), Some("local")); - assert_eq!( - device, - Some(&SingleValue::String("/srv/trydirect/data".to_string())) - ); + assert_eq!(device, Some(&SingleValue::String("/srv/trydirect/data".to_string()))); } #[test] @@ -191,14 +188,14 @@ mod tests { }; let compose = volume.to_compose_volume(Some("/srv/trydirect")); - let device = compose.driver_opts.get("device").and_then(|v| v.as_ref()); + let device = compose + .driver_opts + .get("device") + .and_then(|v| v.as_ref()); assert!(!volume.is_named_docker_volume()); assert_eq!(compose.driver.as_deref(), Some("local")); - assert_eq!( - device, - Some(&SingleValue::String("/srv/trydirect/data".to_string())) - ); + assert_eq!(device, Some(&SingleValue::String("/srv/trydirect/data".to_string()))); } #[test] diff --git a/src/forms/status_panel.rs b/src/forms/status_panel.rs index 64aaa178..c0ac7235 100644 --- a/src/forms/status_panel.rs +++ b/src/forms/status_panel.rs @@ -488,16 +488,14 @@ pub fn validate_command_parameters( } // Validate port rules - for rule in params - .public_ports - .iter() - .chain(params.private_ports.iter()) - { + for rule in params.public_ports.iter().chain(params.private_ports.iter()) { if rule.port == 0 { return Err("configure_firewall: port must be > 0".to_string()); } if !["tcp", "udp"].contains(&rule.protocol.as_str()) { - return Err("configure_firewall: protocol must be one of: tcp, udp".to_string()); + return Err( + "configure_firewall: protocol must be one of: tcp, udp".to_string(), + ); } } @@ -629,9 +627,7 @@ pub fn validate_command_result( .map_err(|err| format!("Invalid configure_firewall result: {}", err))?; if report.command_type != "configure_firewall" { - return Err( - "configure_firewall result must include type='configure_firewall'".to_string(), - ); + return Err("configure_firewall result must include type='configure_firewall'".to_string()); } if report.deployment_hash != deployment_hash { return Err("configure_firewall result deployment_hash mismatch".to_string()); @@ -649,9 +645,7 @@ pub fn validate_command_result( .map_err(|err| format!("Invalid probe_endpoints result: {}", err))?; if report.command_type != "probe_endpoints" { - return Err( - "probe_endpoints result must include type='probe_endpoints'".to_string() - ); + return Err("probe_endpoints result must include type='probe_endpoints'".to_string()); } if report.deployment_hash != deployment_hash { return Err("probe_endpoints result deployment_hash mismatch".to_string()); @@ -1108,9 +1102,11 @@ mod tests { #[test] fn check_connections_accepts_null_ports() { - let result = - validate_command_parameters("check_connections", &Some(json!({ "ports": null }))) - .expect("check_connections with null ports should validate"); + let result = validate_command_parameters( + "check_connections", + &Some(json!({ "ports": null })), + ) + .expect("check_connections with null ports should validate"); assert!(result.is_some()); } } diff --git a/src/helpers/security_validator.rs b/src/helpers/security_validator.rs index 0ca27e15..dcaef6a7 100644 --- a/src/helpers/security_validator.rs +++ b/src/helpers/security_validator.rs @@ -37,147 +37,53 @@ impl SecurityReport { /// Patterns that indicate hardcoded secrets in environment variables or configs const SECRET_PATTERNS: &[(&str, &str)] = &[ - ( - r"(?i)(aws_secret_access_key|aws_access_key_id)\s*[:=]\s*[A-Za-z0-9/+=]{20,}", - "AWS credentials", - ), - ( - r"(?i)(api[_-]?key|apikey)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", - "API key", - ), - ( - r"(?i)(secret[_-]?key|secret_token)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", - "Secret key/token", - ), + (r"(?i)(aws_secret_access_key|aws_access_key_id)\s*[:=]\s*[A-Za-z0-9/+=]{20,}", "AWS credentials"), + (r"(?i)(api[_-]?key|apikey)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", "API key"), + (r"(?i)(secret[_-]?key|secret_token)\s*[:=]\s*[A-Za-z0-9_\-]{16,}", "Secret key/token"), (r"(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}", "Bearer token"), - ( - r"(?i)(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}", - "GitHub token", - ), + (r"(?i)(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}", "GitHub token"), (r"(?i)sk-[A-Za-z0-9]{20,}", "OpenAI/Stripe secret key"), - ( - r"(?i)(-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----)", - "Private key", - ), + (r"(?i)(-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----)", "Private key"), (r"(?i)AKIA[0-9A-Z]{16}", "AWS Access Key ID"), (r"(?i)(slack[_-]?token|xox[bpas]-)", "Slack token"), - ( - r"(?i)(database_url|db_url)\s*[:=]\s*\S*:[^${\s]{8,}", - "Database URL with credentials", - ), + (r"(?i)(database_url|db_url)\s*[:=]\s*\S*:[^${\s]{8,}", "Database URL with credentials"), ]; /// Patterns for hardcoded credentials (passwords, default creds) const CRED_PATTERNS: &[(&str, &str)] = &[ - ( - r#"(?i)(password|passwd|pwd)\s*[:=]\s*['"]?(?!(\$\{|\$\(|changeme|CHANGE_ME|your_password|example))[A-Za-z0-9!@#$%^&*]{6,}['"]?"#, - "Hardcoded password", - ), - ( - r#"(?i)(mysql_root_password|postgres_password|mongo_initdb_root_password)\s*[:=]\s*['"]?(?!(\$\{|\$\())[^\s'"$]{4,}"#, - "Hardcoded database password", - ), - ( - r"(?i)root:(?!(\$\{|\$\())[^\s:$]{4,}", - "Root password in plain text", - ), + (r#"(?i)(password|passwd|pwd)\s*[:=]\s*['"]?(?!(\$\{|\$\(|changeme|CHANGE_ME|your_password|example))[A-Za-z0-9!@#$%^&*]{6,}['"]?"#, "Hardcoded password"), + (r#"(?i)(mysql_root_password|postgres_password|mongo_initdb_root_password)\s*[:=]\s*['"]?(?!(\$\{|\$\())[^\s'"$]{4,}"#, "Hardcoded database password"), + (r"(?i)root:(?!(\$\{|\$\())[^\s:$]{4,}", "Root password in plain text"), ]; /// Patterns indicating potentially malicious or dangerous configurations const MALICIOUS_PATTERNS: &[(&str, &str, &str)] = &[ - ( - r"(?i)privileged\s*:\s*true", - "critical", - "Container running in privileged mode", - ), - ( - r#"(?i)network_mode\s*:\s*['"]?host"#, - "warning", - "Container using host network", - ), - ( - r#"(?i)pid\s*:\s*['"]?host"#, - "critical", - "Container sharing host PID namespace", - ), - ( - r#"(?i)ipc\s*:\s*['"]?host"#, - "critical", - "Container sharing host IPC namespace", - ), - ( - r"(?i)cap_add\s*:.*SYS_ADMIN", - "critical", - "Container with SYS_ADMIN capability", - ), - ( - r"(?i)cap_add\s*:.*SYS_PTRACE", - "warning", - "Container with SYS_PTRACE capability", - ), - ( - r"(?i)cap_add\s*:.*ALL", - "critical", - "Container with ALL capabilities", - ), - ( - r"(?i)/var/run/docker\.sock", - "critical", - "Docker socket mounted (container escape risk)", - ), - ( - r"(?i)volumes\s*:.*:/host", - "warning", - "Suspicious host filesystem mount", - ), - ( - r"(?i)volumes\s*:.*:/etc(/|\s|$)", - "warning", - "Host /etc directory mounted", - ), - ( - r"(?i)volumes\s*:.*:/root", - "critical", - "Host /root directory mounted", - ), - ( - r"(?i)volumes\s*:.*:/proc", - "critical", - "Host /proc directory mounted", - ), - ( - r"(?i)volumes\s*:.*:/sys", - "critical", - "Host /sys directory mounted", - ), - ( - r"(?i)curl\s+.*\|\s*(sh|bash)", - "warning", - "Remote script execution via curl pipe", - ), - ( - r"(?i)wget\s+.*\|\s*(sh|bash)", - "warning", - "Remote script execution via wget pipe", - ), + (r"(?i)privileged\s*:\s*true", "critical", "Container running in privileged mode"), + (r#"(?i)network_mode\s*:\s*['"]?host"#, "warning", "Container using host network"), + (r#"(?i)pid\s*:\s*['"]?host"#, "critical", "Container sharing host PID namespace"), + (r#"(?i)ipc\s*:\s*['"]?host"#, "critical", "Container sharing host IPC namespace"), + (r"(?i)cap_add\s*:.*SYS_ADMIN", "critical", "Container with SYS_ADMIN capability"), + (r"(?i)cap_add\s*:.*SYS_PTRACE", "warning", "Container with SYS_PTRACE capability"), + (r"(?i)cap_add\s*:.*ALL", "critical", "Container with ALL capabilities"), + (r"(?i)/var/run/docker\.sock", "critical", "Docker socket mounted (container escape risk)"), + (r"(?i)volumes\s*:.*:/host", "warning", "Suspicious host filesystem mount"), + (r"(?i)volumes\s*:.*:/etc(/|\s|$)", "warning", "Host /etc directory mounted"), + (r"(?i)volumes\s*:.*:/root", "critical", "Host /root directory mounted"), + (r"(?i)volumes\s*:.*:/proc", "critical", "Host /proc directory mounted"), + (r"(?i)volumes\s*:.*:/sys", "critical", "Host /sys directory mounted"), + (r"(?i)curl\s+.*\|\s*(sh|bash)", "warning", "Remote script execution via curl pipe"), + (r"(?i)wget\s+.*\|\s*(sh|bash)", "warning", "Remote script execution via wget pipe"), ]; /// Known suspicious Docker images #[allow(dead_code)] const SUSPICIOUS_IMAGES: &[&str] = &[ - "alpine:latest", // not suspicious per se, but discouraged for reproducibility + "alpine:latest", // not suspicious per se, but discouraged for reproducibility ]; const KNOWN_CRYPTO_MINER_PATTERNS: &[&str] = &[ - "xmrig", - "cpuminer", - "cryptonight", - "stratum+tcp", - "minerd", - "hashrate", - "monero", - "coinhive", - "coin-hive", + "xmrig", "cpuminer", "cryptonight", "stratum+tcp", "minerd", "hashrate", + "monero", "coinhive", "coin-hive", ]; /// Normalize a JSON-pretty-printed string into a YAML-like format so that @@ -244,29 +150,19 @@ pub fn validate_stack_security(stack_definition: &Value) -> SecurityReport { let mut recommendations = Vec::new(); if !no_secrets.passed { - recommendations.push( - "Replace hardcoded secrets with environment variable references (e.g., ${SECRET_KEY})" - .to_string(), - ); + recommendations.push("Replace hardcoded secrets with environment variable references (e.g., ${SECRET_KEY})".to_string()); } if !no_hardcoded_creds.passed { - recommendations.push( - "Use Docker secrets or environment variable references for passwords".to_string(), - ); + recommendations.push("Use Docker secrets or environment variable references for passwords".to_string()); } if !valid_docker_syntax.passed { - recommendations - .push("Fix Docker Compose syntax issues to ensure deployability".to_string()); + recommendations.push("Fix Docker Compose syntax issues to ensure deployability".to_string()); } if !no_malicious_code.passed { - recommendations.push( - "Review and remove dangerous container configurations (privileged mode, host mounts)" - .to_string(), - ); + recommendations.push("Review and remove dangerous container configurations (privileged mode, host mounts)".to_string()); } if risk_score == 0 { - recommendations - .push("Automated scan passed. AI review recommended for deeper analysis.".to_string()); + recommendations.push("Automated scan passed. AI review recommended for deeper analysis.".to_string()); } SecurityReport { @@ -308,10 +204,7 @@ fn check_no_secrets(content: &str) -> SecurityCheckResult { message: if findings.is_empty() { "No exposed secrets detected".to_string() } else { - format!( - "Found {} potential secret(s) in stack definition", - findings.len() - ) + format!("Found {} potential secret(s) in stack definition", findings.len()) }, details: findings, } @@ -323,7 +216,10 @@ fn check_no_hardcoded_creds(content: &str) -> SecurityCheckResult { for (pattern, description) in CRED_PATTERNS { if let Ok(re) = Regex::new(pattern) { for mat in re.find_iter(content) { - let line = content[..mat.start()].lines().count() + 1; + let line = content[..mat.start()] + .lines() + .count() + + 1; findings.push(format!("[WARNING] {} near line {}", description, line)); } } @@ -353,7 +249,10 @@ fn check_no_hardcoded_creds(content: &str) -> SecurityCheckResult { message: if findings.is_empty() { "No hardcoded credentials detected".to_string() } else { - format!("Found {} potential hardcoded credential(s)", findings.len()) + format!( + "Found {} potential hardcoded credential(s)", + findings.len() + ) }, details: findings, } @@ -363,16 +262,16 @@ fn check_valid_docker_syntax(stack_definition: &Value, raw_content: &str) -> Sec let mut findings = Vec::new(); // Check if it looks like valid docker-compose structure - let has_services = - stack_definition.get("services").is_some() || raw_content.contains("services:"); + let has_services = stack_definition.get("services").is_some() + || raw_content.contains("services:"); if !has_services { - findings - .push("[WARNING] Missing 'services' key — may not be valid Docker Compose".to_string()); + findings.push("[WARNING] Missing 'services' key — may not be valid Docker Compose".to_string()); } // Check for 'version' key (optional in modern compose but common) - let has_version = stack_definition.get("version").is_some() || raw_content.contains("version:"); + let has_version = stack_definition.get("version").is_some() + || raw_content.contains("version:"); // Check that services have images or build contexts if let Some(services) = stack_definition.get("services") { @@ -404,10 +303,7 @@ fn check_valid_docker_syntax(stack_definition: &Value, raw_content: &str) -> Sec } } - let errors_only: Vec<&String> = findings - .iter() - .filter(|f| f.contains("[WARNING]")) - .collect(); + let errors_only: Vec<&String> = findings.iter().filter(|f| f.contains("[WARNING]")).collect(); SecurityCheckResult { passed: errors_only.is_empty(), @@ -455,20 +351,14 @@ fn check_no_malicious_code(content: &str) -> SecurityCheckResult { // Check for suspicious base64 encoded content (long base64 strings could hide payloads) if let Ok(re) = Regex::new(r"[A-Za-z0-9+/]{100,}={0,2}") { if re.is_match(content) { - findings.push( - "[WARNING] Long base64-encoded content detected — may contain hidden payload" - .to_string(), - ); + findings.push("[WARNING] Long base64-encoded content detected — may contain hidden payload".to_string()); } } // Check for outbound network calls in entrypoints/commands if let Ok(re) = Regex::new(r"(?i)(curl|wget|nc|ncat)\s+.*(http|ftp|tcp)") { if re.is_match(content) { - findings.push( - "[INFO] Outbound network call detected in command/entrypoint — review if expected" - .to_string(), - ); + findings.push("[INFO] Outbound network call detected in command/entrypoint — review if expected".to_string()); } } diff --git a/src/helpers/ssh_client.rs b/src/helpers/ssh_client.rs index d07b6fe3..a582a672 100644 --- a/src/helpers/ssh_client.rs +++ b/src/helpers/ssh_client.rs @@ -159,11 +159,8 @@ pub async fn check_server( let addr = format!("{}:{}", host, port); tracing::info!("Connecting to {} as {}", addr, username); - let connection_result = timeout( - connection_timeout, - connect_and_auth(config, &addr, username, key), - ) - .await; + let connection_result = + timeout(connection_timeout, connect_and_auth(config, &addr, username, key)).await; match connection_result { Ok(Ok(handle)) => { @@ -177,10 +174,7 @@ pub async fn check_server( Ok(Err(e)) => { tracing::warn!("SSH connection/auth failed: {}", e); let error_str = e.to_string().to_lowercase(); - if error_str.contains("auth") - || error_str.contains("key") - || error_str.contains("permission") - { + if error_str.contains("auth") || error_str.contains("key") || error_str.contains("permission") { result.connected = true; result.error = Some(format!("Authentication failed: {}", e)); } else { @@ -323,25 +317,19 @@ fn parse_disk_info(result: &mut SystemCheckResult, output: &str) { let parts: Vec<&str> = output.split_whitespace().collect(); if parts.len() >= 4 { // Parse total (index 1) - if let Some(total) = parts - .get(1) - .and_then(|s| s.trim_end_matches('G').parse::().ok()) + if let Some(total) = parts.get(1).and_then(|s| s.trim_end_matches('G').parse::().ok()) { result.disk_total_gb = Some(total); } // Parse available (index 3) - if let Some(avail) = parts - .get(3) - .and_then(|s| s.trim_end_matches('G').parse::().ok()) + if let Some(avail) = parts.get(3).and_then(|s| s.trim_end_matches('G').parse::().ok()) { result.disk_available_gb = Some(avail); } // Parse usage percentage (index 4) - if let Some(usage) = parts - .get(4) - .and_then(|s| s.trim_end_matches('%').parse::().ok()) + if let Some(usage) = parts.get(4).and_then(|s| s.trim_end_matches('%').parse::().ok()) { result.disk_usage_percent = Some(usage); } diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index fd6fdb3a..895cf011 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -9,24 +9,23 @@ use std::sync::Arc; use super::protocol::{Tool, ToolContent}; use crate::mcp::tools::{ - AddAppToDeploymentTool, AddCloudTool, AdminApproveTemplateTool, AdminGetTemplateDetailTool, AdminListSubmittedTemplatesTool, AdminListTemplateReviewsTool, AdminListTemplateVersionsTool, - AdminRejectTemplateTool, AdminValidateTemplateSecurityTool, ApplyVaultConfigTool, + AddAppToDeploymentTool, CancelDeploymentTool, CloneProjectTool, - ConfigureFirewallFromRoleTool, - // Firewall tools - ConfigureFirewallTool, + ConfigureProxyTool, // Agent Control tools ConfigureProxyAgentTool, - ConfigureProxyTool, + // Firewall tools + ConfigureFirewallTool, + ConfigureFirewallFromRoleTool, CreateProjectAppTool, CreateProjectTool, DeleteAppEnvVarTool, @@ -36,13 +35,15 @@ use crate::mcp::tools::{ // Ansible Roles tools DeployAppTool, DeployRoleTool, + // Stack Recommendations + RecommendStackServicesTool, + RemoveAppTool, DiagnoseDeploymentTool, DiscoverStackServicesTool, EscalateToSupportTool, + GetAppConfigTool, // Agent Control tools GetAgentStatusTool, - GetAnsibleRoleDefaultsTool, - GetAppConfigTool, // Phase 5: App Configuration tools GetAppEnvVarsTool, GetCloudTool, @@ -64,40 +65,39 @@ use crate::mcp::tools::{ GetUserProfileTool, // Phase 5: Vault Configuration tools GetVaultConfigTool, - InitiateDeploymentTool, ListAvailableRolesTool, + ListCloudsTool, ListCloudImagesTool, ListCloudRegionsTool, ListCloudServerSizesTool, - ListCloudsTool, ListContainersTool, - ListFirewallRulesTool, ListInstallationsTool, + ListFirewallRulesTool, + InitiateDeploymentTool, ListProjectAppsTool, ListProjectsTool, ListProxiesTool, ListTemplatesTool, ListVaultConfigsTool, - MarkAllNotificationsReadTool, - MarkNotificationReadTool, - PreviewInstallConfigTool, - // Stack Recommendations - RecommendStackServicesTool, - RemoveAppTool, - RenderAnsibleTemplateTool, RestartContainerTool, + RenderAnsibleTemplateTool, SearchApplicationsTool, SearchMarketplaceTemplatesTool, SetAppEnvVarTool, SetVaultConfigTool, + MarkAllNotificationsReadTool, + MarkNotificationReadTool, StartContainerTool, StartDeploymentTool, // Phase 5: Container Operations tools StopContainerTool, - SuggestResourcesTool, TriggerRedeployTool, + AdminRejectTemplateTool, + SuggestResourcesTool, UpdateAppDomainTool, UpdateAppPortsTool, + GetAnsibleRoleDefaultsTool, + PreviewInstallConfigTool, ValidateDomainTool, ValidateRoleVarsTool, // Phase 5: Stack Validation tool @@ -179,7 +179,10 @@ impl ToolRegistry { Box::new(SearchMarketplaceTemplatesTool), ); registry.register("get_notifications", Box::new(GetNotificationsTool)); - registry.register("mark_notification_read", Box::new(MarkNotificationReadTool)); + registry.register( + "mark_notification_read", + Box::new(MarkNotificationReadTool), + ); registry.register( "mark_all_notifications_read", Box::new(MarkAllNotificationsReadTool), @@ -265,8 +268,14 @@ impl ToolRegistry { "admin_get_template_detail", Box::new(AdminGetTemplateDetailTool), ); - registry.register("admin_approve_template", Box::new(AdminApproveTemplateTool)); - registry.register("admin_reject_template", Box::new(AdminRejectTemplateTool)); + registry.register( + "admin_approve_template", + Box::new(AdminApproveTemplateTool), + ); + registry.register( + "admin_reject_template", + Box::new(AdminRejectTemplateTool), + ); registry.register( "admin_list_template_versions", Box::new(AdminListTemplateVersionsTool), @@ -283,7 +292,10 @@ impl ToolRegistry { // Ansible Roles tools (SSH deployment method) registry.register("list_available_roles", Box::new(ListAvailableRolesTool)); registry.register("get_role_details", Box::new(GetRoleDetailsTool)); - registry.register("get_role_requirements", Box::new(GetRoleRequirementsTool)); + registry.register( + "get_role_requirements", + Box::new(GetRoleRequirementsTool), + ); registry.register("validate_role_vars", Box::new(ValidateRoleVarsTool)); registry.register("deploy_role", Box::new(DeployRoleTool)); diff --git a/src/mcp/tools/agent_control.rs b/src/mcp/tools/agent_control.rs index ab9dfbc3..33a2da60 100644 --- a/src/mcp/tools/agent_control.rs +++ b/src/mcp/tools/agent_control.rs @@ -37,11 +37,7 @@ async fn wait_for_command_result( if let Some(cmd) = fetched { let status = cmd.status.to_lowercase(); - if status == "completed" - || status == "failed" - || cmd.result.is_some() - || cmd.error.is_some() - { + if status == "completed" || status == "failed" || cmd.result.is_some() || cmd.error.is_some() { return Ok(Some(cmd)); } } @@ -89,9 +85,7 @@ async fn enqueue_and_wait( .await .map_err(|e| format!("Failed to queue command: {}", e))?; - if let Some(cmd) = - wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? - { + if let Some(cmd) = wait_for_command_result(&context.pg_pool, &command.command_id, timeout_secs).await? { let status = cmd.status.to_lowercase(); Ok(json!({ "status": status, @@ -318,12 +312,8 @@ impl ToolHandler for ConfigureProxyAgentTool { action: String, } - fn default_true() -> bool { - true - } - fn default_create() -> String { - "create".to_string() - } + fn default_true() -> bool { true } + fn default_create() -> String { "create".to_string() } let params: Args = serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; diff --git a/src/mcp/tools/ansible_roles.rs b/src/mcp/tools/ansible_roles.rs index 959dadb8..c3ab987a 100644 --- a/src/mcp/tools/ansible_roles.rs +++ b/src/mcp/tools/ansible_roles.rs @@ -49,17 +49,11 @@ pub struct RoleVariable { async fn fetch_roles_from_db(context: &ToolContext) -> Result, String> { let user_service_url = &context.settings.user_service_url; let endpoint = format!("{}{}", user_service_url, POSTGREST_ROLE_ENDPOINT); - + let client = reqwest::Client::new(); let response = client .get(&endpoint) - .header( - "Authorization", - format!( - "Bearer {}", - context.user.access_token.as_deref().unwrap_or("") - ), - ) + .header("Authorization", format!("Bearer {}", context.user.access_token.as_deref().unwrap_or(""))) .send() .await .map_err(|e| format!("Failed to fetch roles from database: {}", e))?; @@ -99,13 +93,13 @@ async fn fetch_roles_from_db(context: &ToolContext) -> Result, /// Scan filesystem for available roles fn scan_roles_from_filesystem() -> Result, String> { let roles_path = Path::new(ROLES_BASE_PATH); - + if !roles_path.exists() { return Err(format!("Roles directory not found: {}", ROLES_BASE_PATH)); } let mut roles = vec![]; - + if let Ok(entries) = std::fs::read_dir(roles_path) { for entry in entries.flatten() { if let Ok(file_type) = entry.file_type() { @@ -120,7 +114,7 @@ fn scan_roles_from_filesystem() -> Result, String> { } } } - + roles.sort(); Ok(roles) } @@ -128,7 +122,7 @@ fn scan_roles_from_filesystem() -> Result, String> { /// Get detailed information about a specific role from filesystem fn get_role_details_from_fs(role_name: &str) -> Result { let role_path = PathBuf::from(ROLES_BASE_PATH).join(role_name); - + if !role_path.exists() { return Err(format!("Role '{}' not found in filesystem", role_name)); } @@ -140,10 +134,7 @@ fn get_role_details_from_fs(role_name: &str) -> Result { private_ports: vec![], variables: HashMap::new(), dependencies: vec![], - supported_os: vec!["ubuntu", "debian"] - .into_iter() - .map(|s| s.to_string()) - .collect(), // default + supported_os: vec!["ubuntu", "debian"].into_iter().map(|s| s.to_string()).collect(), // default }; // Parse README.md for description @@ -153,12 +144,11 @@ fn get_role_details_from_fs(role_name: &str) -> Result { // Extract first non-empty line after "Role Name" or "Description" for line in content.lines() { let trimmed = line.trim(); - if !trimmed.is_empty() + if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with('=') && !trimmed.starts_with('-') - && trimmed.len() > 10 - { + && trimmed.len() > 10 { role.description = Some(trimmed.to_string()); break; } @@ -197,16 +187,16 @@ fn parse_yaml_variable(line: &str) -> Option<(String, String)> { if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.is_empty() { return None; } - + if let Some(colon_pos) = trimmed.find(':') { let key = trimmed[..colon_pos].trim(); let value = trimmed[colon_pos + 1..].trim(); - + if !key.is_empty() && !value.is_empty() { return Some((key.to_string(), value.to_string())); } } - + None } @@ -223,15 +213,12 @@ impl ToolHandler for ListAvailableRolesTool { db_roles } Err(db_err) => { - tracing::warn!( - "Database fetch failed ({}), falling back to filesystem", - db_err - ); - + tracing::warn!("Database fetch failed ({}), falling back to filesystem", db_err); + // Fallback to filesystem scan let role_names = scan_roles_from_filesystem()?; tracing::info!("Scanned {} roles from filesystem", role_names.len()); - + role_names .into_iter() .map(|name| AnsibleRole { @@ -519,11 +506,9 @@ impl ToolHandler for DeployRoleTool { // TODO: Implement actual Ansible playbook execution // This would interface with the Install Service or execute ansible-playbook directly // For now, return a placeholder response - + let ssh_user = params.ssh_user.unwrap_or_else(|| "root".to_string()); - let ssh_key = params - .ssh_key_path - .unwrap_or_else(|| "/root/.ssh/id_rsa".to_string()); + let ssh_key = params.ssh_key_path.unwrap_or_else(|| "/root/.ssh/id_rsa".to_string()); let result = json!({ "status": "queued", diff --git a/src/mcp/tools/cloud.rs b/src/mcp/tools/cloud.rs index b78f8c72..32c12673 100644 --- a/src/mcp/tools/cloud.rs +++ b/src/mcp/tools/cloud.rs @@ -342,7 +342,8 @@ impl ToolHandler for ListCloudRegionsTool { fn schema(&self) -> Tool { Tool { name: "list_cloud_regions".to_string(), - description: "List available regions from App Service for a cloud provider".to_string(), + description: "List available regions from App Service for a cloud provider" + .to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/src/mcp/tools/firewall.rs b/src/mcp/tools/firewall.rs index 4a731384..fea172c5 100644 --- a/src/mcp/tools/firewall.rs +++ b/src/mcp/tools/firewall.rs @@ -17,13 +17,13 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use crate::connectors::user_service::UserServiceDeploymentResolver; use crate::db; use crate::forms::status_panel::{ConfigureFirewallCommandRequest, FirewallPortRule}; use crate::mcp::protocol::{Tool, ToolContent}; use crate::mcp::registry::{ToolContext, ToolHandler}; use crate::models::{Command, CommandPriority}; use crate::services::{DeploymentIdentifier, DeploymentResolver}; +use crate::connectors::user_service::UserServiceDeploymentResolver; /// Execution method for firewall commands #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -153,7 +153,7 @@ impl ToolHandler for ConfigureFirewallTool { // For SSH method, we would need to execute via Ansible // This requires the deploy_role infrastructure // For now, return a placeholder indicating SSH method - + let result = json!({ "status": "pending", "execution_method": "ssh", diff --git a/src/mcp/tools/install_preview.rs b/src/mcp/tools/install_preview.rs index d7366d34..5e6fb23c 100644 --- a/src/mcp/tools/install_preview.rs +++ b/src/mcp/tools/install_preview.rs @@ -148,12 +148,9 @@ impl ToolHandler for RenderAnsibleTemplateTool { let params: Args = serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; - let response = call_install_service( - reqwest::Method::POST, - "/api/render-templates", - Some(params.payload), - ) - .await?; + let response = + call_install_service(reqwest::Method::POST, "/api/render-templates", Some(params.payload)) + .await?; Ok(ToolContent::Text { text: response.to_string(), @@ -163,9 +160,8 @@ impl ToolHandler for RenderAnsibleTemplateTool { fn schema(&self) -> Tool { Tool { name: "render_ansible_template".to_string(), - description: - "Render Ansible templates by calling Install Service /api/render-templates" - .to_string(), + description: "Render Ansible templates by calling Install Service /api/render-templates" + .to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/src/mcp/tools/marketplace_admin.rs b/src/mcp/tools/marketplace_admin.rs index dd342c6f..64a63617 100644 --- a/src/mcp/tools/marketplace_admin.rs +++ b/src/mcp/tools/marketplace_admin.rs @@ -352,7 +352,11 @@ impl ToolHandler for AdminListTemplateReviewsTool { "reviews": reviews, }); - tracing::info!("Admin listed {} reviews for template {}", reviews.len(), id); + tracing::info!( + "Admin listed {} reviews for template {}", + reviews.len(), + id + ); Ok(ToolContent::Text { text: serde_json::to_string(&result).unwrap(), diff --git a/src/mcp/tools/project.rs b/src/mcp/tools/project.rs index 913104fb..d77af5f8 100644 --- a/src/mcp/tools/project.rs +++ b/src/mcp/tools/project.rs @@ -389,11 +389,7 @@ impl ToolHandler for CreateProjectAppTool { let catalog_app = match client.fetch_app_catalog(token, code).await { Ok(app) => app, Err(e) => { - tracing::warn!( - "Could not fetch app catalog for code={}: {}, proceeding with defaults", - code, - e - ); + tracing::warn!("Could not fetch app catalog for code={}: {}, proceeding with defaults", code, e); None } }; diff --git a/src/mcp/tools/user_service/mcp.rs b/src/mcp/tools/user_service/mcp.rs index df628684..15ba88af 100644 --- a/src/mcp/tools/user_service/mcp.rs +++ b/src/mcp/tools/user_service/mcp.rs @@ -280,8 +280,7 @@ impl ToolHandler for GetNotificationsTool { fn schema(&self) -> Tool { Tool { name: "get_notifications".to_string(), - description: "List user notifications with optional pagination and unread filter" - .to_string(), + description: "List user notifications with optional pagination and unread filter".to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/src/models/pipe.rs b/src/models/pipe.rs index b59e553a..57bff71b 100644 --- a/src/models/pipe.rs +++ b/src/models/pipe.rs @@ -128,7 +128,11 @@ pub struct PipeInstance { } impl PipeInstance { - pub fn new(deployment_hash: String, source_container: String, created_by: String) -> Self { + pub fn new( + deployment_hash: String, + source_container: String, + created_by: String, + ) -> Self { Self { id: Uuid::new_v4(), template_id: None, diff --git a/src/project_app/hydration.rs b/src/project_app/hydration.rs index d7c98655..960e9474 100644 --- a/src/project_app/hydration.rs +++ b/src/project_app/hydration.rs @@ -152,9 +152,8 @@ mod hydrate { env_config = Some(config); } - if let Some(config_bundle) = - fetch_optional_config(&vault, &hash, &format!("{}_configs", app.code)) - .await? + if let Some(config_bundle) = fetch_optional_config(&vault, &hash, &format!("{}_configs", app.code)) + .await? { hydrated.config_files = parse_config_bundle(&config_bundle.content); } diff --git a/src/project_app/upsert.rs b/src/project_app/upsert.rs index 06a06635..8d77aa1f 100644 --- a/src/project_app/upsert.rs +++ b/src/project_app/upsert.rs @@ -29,32 +29,36 @@ pub(crate) async fn upsert_app_config_for_deploy( // Resolve the actual deployment record ID from deployment_hash // (deployment_id parameter is actually project_id in the current code) - let actual_deployment_id = - match crate::db::deployment::fetch_by_deployment_hash(pg_pool, deployment_hash).await { - Ok(Some(dep)) => { - tracing::info!( - "[UPSERT_APP_CONFIG] Resolved deployment.id={} from hash={}", - dep.id, - deployment_hash - ); - Some(dep.id) - } - Ok(None) => { - tracing::warn!( + let actual_deployment_id = match crate::db::deployment::fetch_by_deployment_hash( + pg_pool, + deployment_hash, + ) + .await + { + Ok(Some(dep)) => { + tracing::info!( + "[UPSERT_APP_CONFIG] Resolved deployment.id={} from hash={}", + dep.id, + deployment_hash + ); + Some(dep.id) + } + Ok(None) => { + tracing::warn!( "[UPSERT_APP_CONFIG] No deployment found for hash={}, deployment_id will be NULL", deployment_hash ); - None - } - Err(e) => { - tracing::warn!( - "[UPSERT_APP_CONFIG] Failed to resolve deployment for hash={}: {}", - deployment_hash, - e - ); - None - } - }; + None + } + Err(e) => { + tracing::warn!( + "[UPSERT_APP_CONFIG] Failed to resolve deployment for hash={}: {}", + deployment_hash, + e + ); + None + } + }; // Fetch project from DB let project = match crate::db::project::fetch(pg_pool, deployment_id).await { diff --git a/src/routes/agent/link.rs b/src/routes/agent/link.rs index 23c4425b..7f42322e 100644 --- a/src/routes/agent/link.rs +++ b/src/routes/agent/link.rs @@ -38,10 +38,7 @@ fn generate_agent_token() -> String { /// The session_token proves the user authenticated via /api/v1/agent/login. /// Stacker validates token ownership, checks the user owns the deployment, /// then creates or returns an agent with credentials. -#[tracing::instrument( - name = "Link agent to deployment", - skip(agent_pool, vault_client, user_service, req) -)] +#[tracing::instrument(name = "Link agent to deployment", skip(agent_pool, vault_client, user_service, req))] #[post("/link")] pub async fn link_handler( payload: web::Json, @@ -62,16 +59,19 @@ pub async fn link_handler( })?; // 2. Verify user owns the requested deployment - let deployment = - db::deployment::fetch_by_deployment_hash(api_pool.get_ref(), &payload.deployment_id) - .await - .map_err(|e| { - helpers::JsonResponse::::build() - .internal_server_error(format!("Database error: {}", e)) - })?; + let deployment = db::deployment::fetch_by_deployment_hash( + api_pool.get_ref(), + &payload.deployment_id, + ) + .await + .map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("Database error: {}", e)) + })?; let deployment = deployment.ok_or_else(|| { - helpers::JsonResponse::::build().not_found("Deployment not found") + helpers::JsonResponse::::build() + .not_found("Deployment not found") })?; // Check ownership: deployment.user_id must match the authenticated user @@ -91,7 +91,8 @@ pub async fn link_handler( db::agent::fetch_by_deployment_hash(agent_pool.as_ref(), &deployment.deployment_hash) .await .map_err(|e| { - helpers::JsonResponse::::build().internal_server_error(e) + helpers::JsonResponse::::build() + .internal_server_error(e) })?; let (agent, agent_token) = if let Some(mut existing) = existing_agent { @@ -105,7 +106,8 @@ pub async fn link_handler( let existing = db::agent::update(agent_pool.as_ref(), existing) .await .map_err(|e| { - helpers::JsonResponse::::build().internal_server_error(e) + helpers::JsonResponse::::build() + .internal_server_error(e) })?; // Fetch existing token from Vault or regenerate @@ -138,7 +140,8 @@ pub async fn link_handler( let saved_agent = db::agent::insert(agent_pool.as_ref(), agent) .await .map_err(|e| { - helpers::JsonResponse::::build().internal_server_error(e) + helpers::JsonResponse::::build() + .internal_server_error(e) })?; // Store token in Vault diff --git a/src/routes/agent/login.rs b/src/routes/agent/login.rs index 0218494b..4805fc01 100644 --- a/src/routes/agent/login.rs +++ b/src/routes/agent/login.rs @@ -32,10 +32,7 @@ pub struct AgentLoginResponse { /// Proxy login for Status Panel agents. Authenticates the user against /// the TryDirect OAuth server, then returns a session token and the /// user's deployments so the agent can pick one to link to. -#[tracing::instrument( - name = "Agent proxy login", - skip(settings, api_pool, user_service, _req) -)] +#[tracing::instrument(name = "Agent proxy login", skip(settings, api_pool, user_service, _req))] #[post("/login")] pub async fn login_handler( payload: web::Json, @@ -109,12 +106,13 @@ pub async fn login_handler( })?; // 3. Fetch user's deployments from Stacker DB - let deployments = db::deployment::fetch_by_user(api_pool.get_ref(), &profile.email, 50) - .await - .map_err(|e| { - helpers::JsonResponse::::build() - .internal_server_error(format!("Failed to fetch deployments: {}", e)) - })?; + let deployments = + db::deployment::fetch_by_user(api_pool.get_ref(), &profile.email, 50) + .await + .map_err(|e| { + helpers::JsonResponse::::build() + .internal_server_error(format!("Failed to fetch deployments: {}", e)) + })?; let deployment_infos: Vec = deployments .into_iter() diff --git a/src/routes/agent/snapshot.rs b/src/routes/agent/snapshot.rs index 5ce607f9..e1cf74bb 100644 --- a/src/routes/agent/snapshot.rs +++ b/src/routes/agent/snapshot.rs @@ -89,13 +89,9 @@ pub async fn snapshot_handler( tracing::debug!("[SNAPSHOT HANDLER] Deployment : {:?}", deployment); // Fetch apps scoped to this specific deployment (falls back to project-level if no deployment-scoped apps) let apps = if let Some(deployment) = &deployment { - db::project_app::fetch_by_deployment( - agent_pool.get_ref(), - deployment.project_id, - deployment.id, - ) - .await - .unwrap_or_default() + db::project_app::fetch_by_deployment(agent_pool.get_ref(), deployment.project_id, deployment.id) + .await + .unwrap_or_default() } else { vec![] }; @@ -241,10 +237,14 @@ pub async fn project_snapshot_handler( let (agent_snap, deployment_hash) = agent_snapshot; - let commands = - db::command::fetch_recent_by_deployment(agent_pool.get_ref(), &deployment_hash, 50, true) - .await - .unwrap_or_default(); + let commands = db::command::fetch_recent_by_deployment( + agent_pool.get_ref(), + &deployment_hash, + 50, + true, + ) + .await + .unwrap_or_default(); let deployment = db::deployment::fetch_by_deployment_hash(agent_pool.get_ref(), &deployment_hash) @@ -260,10 +260,14 @@ pub async fn project_snapshot_handler( vec![] }; - let health_commands = - db::command::fetch_recent_by_deployment(agent_pool.get_ref(), &deployment_hash, 10, false) - .await - .unwrap_or_default(); + let health_commands = db::command::fetch_recent_by_deployment( + agent_pool.get_ref(), + &deployment_hash, + 10, + false, + ) + .await + .unwrap_or_default(); let mut container_map: std::collections::HashMap = std::collections::HashMap::new(); diff --git a/src/routes/cloud/add.rs b/src/routes/cloud/add.rs index 70e64c6a..f6d34c7c 100644 --- a/src/routes/cloud/add.rs +++ b/src/routes/cloud/add.rs @@ -37,8 +37,9 @@ pub async fn add( Check that SECURITY_KEY is set and is exactly 32 bytes.", cloud.provider ); - return Err(JsonResponse::::build() - .bad_request("Failed to encrypt cloud credentials. Please contact support.")); + return Err(JsonResponse::::build().bad_request( + "Failed to encrypt cloud credentials. Please contact support.", + )); } } diff --git a/src/routes/cloud/update.rs b/src/routes/cloud/update.rs index b284585e..42d4c26a 100644 --- a/src/routes/cloud/update.rs +++ b/src/routes/cloud/update.rs @@ -46,8 +46,9 @@ pub async fn item( Check that SECURITY_KEY is set and is exactly 32 bytes.", cloud.provider ); - return Err(JsonResponse::::build() - .bad_request("Failed to encrypt cloud credentials. Please contact support.")); + return Err(JsonResponse::::build().bad_request( + "Failed to encrypt cloud credentials. Please contact support.", + )); } } diff --git a/src/routes/command/create.rs b/src/routes/command/create.rs index 719536a6..259c2986 100644 --- a/src/routes/command/create.rs +++ b/src/routes/command/create.rs @@ -560,11 +560,15 @@ pub async fn discover_and_register_child_services( deployment_hash: &str, ) -> usize { // Resolve actual deployment ID from hash for scoping apps per deployment - let actual_deployment_id = - match crate::db::deployment::fetch_by_deployment_hash(pg_pool, deployment_hash).await { - Ok(Some(dep)) => Some(dep.id), - _ => None, - }; + let actual_deployment_id = match crate::db::deployment::fetch_by_deployment_hash( + pg_pool, + deployment_hash, + ) + .await + { + Ok(Some(dep)) => Some(dep.id), + _ => None, + }; // Parse the compose file to extract services let services = match parse_compose_services(compose_content) { diff --git a/src/routes/deployment/force_complete.rs b/src/routes/deployment/force_complete.rs index dd39fbc8..13a96c17 100644 --- a/src/routes/deployment/force_complete.rs +++ b/src/routes/deployment/force_complete.rs @@ -41,19 +41,23 @@ pub async fn force_complete_handler( let mut deployment = match deployment { Some(d) => { if d.user_id.as_deref() != Some(&user.id) { - return Err(JsonResponse::::build() - .not_found("Deployment not found")); + return Err( + JsonResponse::::build() + .not_found("Deployment not found"), + ); } d } None => { return Err( - JsonResponse::::build().not_found("Deployment not found") + JsonResponse::::build() + .not_found("Deployment not found"), ); } }; - let status_ok = query.force || FORCE_COMPLETE_ALLOWED.contains(&deployment.status.as_str()); + let status_ok = query.force + || FORCE_COMPLETE_ALLOWED.contains(&deployment.status.as_str()); if !status_ok { return Err(JsonResponse::::build().bad_request(format!( diff --git a/src/routes/deployment/status.rs b/src/routes/deployment/status.rs index df92ff50..2ad00ef2 100644 --- a/src/routes/deployment/status.rs +++ b/src/routes/deployment/status.rs @@ -60,9 +60,7 @@ pub async fn status_by_hash_handler( let deployment = db::deployment::fetch_by_deployment_hash(pg_pool.get_ref(), &hash) .await - .map_err(|err| { - JsonResponse::::build().internal_server_error(err) - })?; + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; match deployment { Some(d) => { @@ -75,9 +73,8 @@ pub async fn status_by_hash_handler( .set_item(resp) .ok("Deployment status fetched")) } - None => { - Err(JsonResponse::::build().not_found("Deployment not found")) - } + None => Err(JsonResponse::::build() + .not_found("Deployment not found")), } } @@ -96,9 +93,7 @@ pub async fn status_handler( let deployment = db::deployment::fetch(pg_pool.get_ref(), deployment_id) .await - .map_err(|err| { - JsonResponse::::build().internal_server_error(err) - })?; + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; match deployment { Some(d) => { @@ -112,9 +107,8 @@ pub async fn status_handler( .set_item(resp) .ok("Deployment status fetched")) } - None => { - Err(JsonResponse::::build().not_found("Deployment not found")) - } + None => Err(JsonResponse::::build() + .not_found("Deployment not found")), } } @@ -132,21 +126,15 @@ pub async fn list_handler( let deployments = if let Some(project_id) = query.project_id { db::deployment::fetch_by_user_and_project(pg_pool.get_ref(), &user.id, project_id, limit) .await - .map_err(|err| { - JsonResponse::::build().internal_server_error(err) - })? + .map_err(|err| JsonResponse::::build().internal_server_error(err))? } else { db::deployment::fetch_by_user(pg_pool.get_ref(), &user.id, limit) .await - .map_err(|err| { - JsonResponse::::build().internal_server_error(err) - })? + .map_err(|err| JsonResponse::::build().internal_server_error(err))? }; - let list: Vec = deployments - .into_iter() - .map(DeploymentStatusResponse::from) - .collect(); + let list: Vec = + deployments.into_iter().map(DeploymentStatusResponse::from).collect(); Ok(JsonResponse::build() .set_list(list) @@ -168,9 +156,7 @@ pub async fn status_by_project_handler( let deployment = db::deployment::fetch_by_project_id(pg_pool.get_ref(), project_id) .await - .map_err(|err| { - JsonResponse::::build().internal_server_error(err) - })?; + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; match deployment { Some(d) => { diff --git a/src/routes/marketplace/admin.rs b/src/routes/marketplace/admin.rs index 3563cc7c..9d6cf20c 100644 --- a/src/routes/marketplace/admin.rs +++ b/src/routes/marketplace/admin.rs @@ -208,10 +208,14 @@ pub async fn unapprove_handler( .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; let req = body.into_inner(); - let updated = - db::marketplace::admin_unapprove(pg_pool.get_ref(), &id, &admin.id, req.reason.as_deref()) - .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + let updated = db::marketplace::admin_unapprove( + pg_pool.get_ref(), + &id, + &admin.id, + req.reason.as_deref(), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; if !updated { return Err(JsonResponse::::build() @@ -241,8 +245,7 @@ pub async fn unapprove_handler( } }); - Ok(JsonResponse::::build() - .ok("Template unapproved and hidden from marketplace")) + Ok(JsonResponse::::build().ok("Template unapproved and hidden from marketplace")) } #[tracing::instrument(name = "Security scan template (admin)")] diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index ea811133..3fcfad23 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -42,11 +42,7 @@ pub async fn create_handler( // Normalize pricing: plan_type "free" forces price to 0 let billing_cycle = req.plan_type.unwrap_or_else(|| "free".to_string()); - let price = if billing_cycle == "free" { - 0.0 - } else { - req.price.unwrap_or(0.0) - }; + let price = if billing_cycle == "free" { 0.0 } else { req.price.unwrap_or(0.0) }; let currency = req.currency.unwrap_or_else(|| "USD".to_string()); // Check if template with this slug already exists for this user diff --git a/src/routes/marketplace/mod.rs b/src/routes/marketplace/mod.rs index b1898a26..1ed063d9 100644 --- a/src/routes/marketplace/mod.rs +++ b/src/routes/marketplace/mod.rs @@ -5,11 +5,11 @@ pub mod creator; pub mod public; pub use admin::{ - approve_handler, list_plans_handler, list_submitted_handler, reject_handler, - security_scan_handler, unapprove_handler, AdminDecisionRequest, UnapproveRequest, + AdminDecisionRequest, UnapproveRequest, approve_handler, list_plans_handler, + list_submitted_handler, reject_handler, security_scan_handler, unapprove_handler, }; pub use creator::{ - create_handler, mine_handler, resubmit_handler, submit_handler, update_handler, - CreateTemplateRequest, ResubmitRequest, UpdateTemplateRequest, + CreateTemplateRequest, ResubmitRequest, UpdateTemplateRequest, create_handler, mine_handler, + resubmit_handler, submit_handler, update_handler, }; pub use public::TemplateListQuery; diff --git a/src/routes/marketplace/public.rs b/src/routes/marketplace/public.rs index 878c3e8a..0a6bdf98 100644 --- a/src/routes/marketplace/public.rs +++ b/src/routes/marketplace/public.rs @@ -123,7 +123,10 @@ pub async fn download_stack_handler( .content_type("application/gzip") .insert_header(( "Content-Disposition", - format!("attachment; filename=\"stack-{}.tar.gz\"", purchase_token), + format!( + "attachment; filename=\"stack-{}.tar.gz\"", + purchase_token + ), )) .body("stack archive placeholder")) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9e57268f..9afe0852 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -22,12 +22,12 @@ pub(crate) mod pipe; pub use agreement::*; pub use deployment::{ - capabilities_handler, force_complete_handler, list_handler, status_by_project_handler, - status_handler, DeploymentListQuery, DeploymentStatusResponse, + DeploymentListQuery, DeploymentStatusResponse, capabilities_handler, force_complete_handler, + list_handler, status_by_project_handler, status_handler, }; pub use marketplace::{ - approve_handler, create_handler, list_plans_handler, list_submitted_handler, mine_handler, - reject_handler, resubmit_handler, security_scan_handler, submit_handler, unapprove_handler, - update_handler, AdminDecisionRequest, CreateTemplateRequest, ResubmitRequest, - TemplateListQuery, UnapproveRequest, UpdateTemplateRequest, + AdminDecisionRequest, CreateTemplateRequest, ResubmitRequest, TemplateListQuery, + UpdateTemplateRequest, UnapproveRequest, approve_handler, create_handler, list_plans_handler, + list_submitted_handler, mine_handler, reject_handler, resubmit_handler, + security_scan_handler, submit_handler, unapprove_handler, update_handler, }; diff --git a/src/routes/project/app.rs b/src/routes/project/app.rs index c922e457..4207995a 100644 --- a/src/routes/project/app.rs +++ b/src/routes/project/app.rs @@ -15,7 +15,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models::{self, Project}; -use crate::services::ProjectAppService; +use crate::services::{ProjectAppService}; use actix_web::{delete, get, post, put, web, Responder, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index 0e6e9683..f42fe346 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -13,10 +13,7 @@ use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; -#[tracing::instrument( - name = "Deploy for every user", - skip(user_service, install_service, vault_client) -)] +#[tracing::instrument(name = "Deploy for every user", skip(user_service, install_service, vault_client))] #[post("/{id}/deploy")] pub async fn item( user: web::ReqData>, @@ -100,7 +97,11 @@ pub async fn item( .cloud_token .as_ref() .map_or(true, |t| t.is_empty()); - let key_empty = form.cloud.cloud_key.as_ref().map_or(true, |k| k.is_empty()); + let key_empty = form + .cloud + .cloud_key + .as_ref() + .map_or(true, |k| k.is_empty()); let secret_empty = form .cloud .cloud_secret @@ -139,10 +140,11 @@ pub async fn item( let existing = db::server::fetch(pg_pool.get_ref(), server_id) .await .map_err(|_| { - JsonResponse::::build() - .internal_server_error("Failed to fetch server") + JsonResponse::::build().internal_server_error("Failed to fetch server") })? - .ok_or_else(|| JsonResponse::::build().not_found("Server not found"))?; + .ok_or_else(|| { + JsonResponse::::build().not_found("Server not found") + })?; // Verify ownership if existing.user_id != user.id { @@ -168,8 +170,7 @@ pub async fn item( db::server::update(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build() - .internal_server_error("Failed to update server") + JsonResponse::::build().internal_server_error("Failed to update server") })? } else { // Create new server @@ -184,8 +185,7 @@ pub async fn item( db::server::insert(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build() - .internal_server_error("Internal Server Error") + JsonResponse::::build().internal_server_error("Internal Server Error") })? }; @@ -319,17 +319,13 @@ pub async fn item( .await { Ok(pk) => { - tracing::info!( - "Fetched SSH private key from Vault for server {}", - server.id - ); + tracing::info!("Fetched SSH private key from Vault for server {}", server.id); Some(pk) } Err(e) => { tracing::warn!( "Failed to fetch SSH private key from Vault for server {}: {}", - server.id, - e + server.id, e ); None } @@ -366,10 +362,7 @@ pub async fn item( }) .map_err(|err| JsonResponse::::build().internal_server_error(err)) } -#[tracing::instrument( - name = "Deploy, when cloud token is saved", - skip(user_service, install_service, vault_client) -)] +#[tracing::instrument(name = "Deploy, when cloud token is saved", skip(user_service, install_service, vault_client))] #[post("/{id}/deploy/{cloud_id}")] pub async fn saved_item( user: web::ReqData>, @@ -472,7 +465,10 @@ pub async fn saved_item( .cloud_token .as_ref() .map_or(true, |t| t.is_empty()); - let key_empty = test_cloud.cloud_key.as_ref().map_or(true, |k| k.is_empty()); + let key_empty = test_cloud + .cloud_key + .as_ref() + .map_or(true, |k| k.is_empty()); let secret_empty = test_cloud .cloud_secret .as_ref() @@ -502,10 +498,11 @@ pub async fn saved_item( let existing = db::server::fetch(pg_pool.get_ref(), server_id) .await .map_err(|_| { - JsonResponse::::build() - .internal_server_error("Failed to fetch server") + JsonResponse::::build().internal_server_error("Failed to fetch server") })? - .ok_or_else(|| JsonResponse::::build().not_found("Server not found"))?; + .ok_or_else(|| { + JsonResponse::::build().not_found("Server not found") + })?; // Verify ownership if existing.user_id != user.id { @@ -532,8 +529,7 @@ pub async fn saved_item( db::server::update(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build() - .internal_server_error("Failed to update server") + JsonResponse::::build().internal_server_error("Failed to update server") })? } else { // Create new server @@ -545,8 +541,7 @@ pub async fn saved_item( db::server::insert(pg_pool.get_ref(), server) .await .map_err(|_| { - JsonResponse::::build() - .internal_server_error("Failed to create server") + JsonResponse::::build().internal_server_error("Failed to create server") })? }; @@ -682,17 +677,13 @@ pub async fn saved_item( .await { Ok(pk) => { - tracing::info!( - "Fetched SSH private key from Vault for server {}", - server.id - ); + tracing::info!("Fetched SSH private key from Vault for server {}", server.id); Some(pk) } Err(e) => { tracing::warn!( "Failed to fetch SSH private key from Vault for server {}: {}", - server.id, - e + server.id, e ); None } diff --git a/src/routes/project/discover.rs b/src/routes/project/discover.rs index db216dec..9dbc3ef8 100644 --- a/src/routes/project/discover.rs +++ b/src/routes/project/discover.rs @@ -354,11 +354,7 @@ pub async fn import_containers( let mut errors = Vec::new(); for container in &body.containers { - if is_blocked_system_container( - &container.container_name, - &container.image, - Some(&container.app_code), - ) { + if is_blocked_system_container(&container.container_name, &container.image, Some(&container.app_code)) { errors.push(format!( "Container '{}' is a system container and cannot be imported", container.container_name diff --git a/src/routes/server/delete.rs b/src/routes/server/delete.rs index 275589cf..ebc9d87e 100644 --- a/src/routes/server/delete.rs +++ b/src/routes/server/delete.rs @@ -34,9 +34,10 @@ pub async fn delete_preview( .await .unwrap_or_default(); - user_servers - .iter() - .any(|s| s.id != server.id && s.vault_key_path.as_deref() == Some(vault_path.as_str())) + user_servers.iter().any(|s| { + s.id != server.id + && s.vault_key_path.as_deref() == Some(vault_path.as_str()) + }) } else { false }; @@ -62,13 +63,11 @@ pub async fn delete_preview( } } - Ok(JsonResponse::::build() - .set_item(serde_json::json!({ - "ssh_key_shared": ssh_key_shared, - "affected_deployments": affected_deployments, - "agent_count": agent_count, - })) - .ok("Delete preview")) + Ok(JsonResponse::::build().set_item(serde_json::json!({ + "ssh_key_shared": ssh_key_shared, + "affected_deployments": affected_deployments, + "agent_count": agent_count, + })).ok("Delete preview")) } #[tracing::instrument(name = "Delete user's server with cleanup.")] @@ -98,16 +97,20 @@ pub async fn item( .await .unwrap_or_default(); - user_servers - .iter() - .any(|s| s.id != server.id && s.vault_key_path.as_deref() == Some(vault_path.as_str())) + user_servers.iter().any(|s| { + s.id != server.id + && s.vault_key_path.as_deref() == Some(vault_path.as_str()) + }) } else { false }; // 2. Delete SSH key from Vault if not shared and key exists if !ssh_key_shared && server.vault_key_path.is_some() { - if let Err(e) = vault_client.delete_ssh_key(&user.id, server.id).await { + if let Err(e) = vault_client + .delete_ssh_key(&user.id, server.id) + .await + { tracing::warn!( "Failed to delete SSH key from Vault for server {}: {}. Continuing with server deletion.", server.id, diff --git a/src/routes/server/get.rs b/src/routes/server/get.rs index f96b2b43..9d3ef9dd 100644 --- a/src/routes/server/get.rs +++ b/src/routes/server/get.rs @@ -41,9 +41,7 @@ pub async fn list( db::server::fetch_by_user_with_provider(pg_pool.get_ref(), user.id.as_ref()) .await .map(|servers| JsonResponse::build().set_list(servers).ok("OK")) - .map_err(|_err| { - JsonResponse::::build().internal_server_error("") - }) + .map_err(|_err| JsonResponse::::build().internal_server_error("")) } #[tracing::instrument(name = "Get servers by project.")] diff --git a/src/routes/server/ssh_key.rs b/src/routes/server/ssh_key.rs index 9e3926e5..fb1fca0a 100644 --- a/src/routes/server/ssh_key.rs +++ b/src/routes/server/ssh_key.rs @@ -107,10 +107,7 @@ pub async fn generate_key( (Some(path), "active", "SSH key generated and stored in Vault successfully. Copy the public key to your server's authorized_keys.".to_string(), false) } Err(e) => { - tracing::warn!( - "Failed to store SSH key in Vault (continuing without Vault): {}", - e - ); + tracing::warn!("Failed to store SSH key in Vault (continuing without Vault): {}", e); (None, "active", format!("SSH key generated successfully, but could not be stored in Vault ({}). Please save the private key shown below - it will not be shown again!", e), true) } }; @@ -122,11 +119,7 @@ pub async fn generate_key( let response = GenerateKeyResponseWithPrivate { public_key: public_key.clone(), - private_key: if include_private_key { - Some(private_key) - } else { - None - }, + private_key: if include_private_key { Some(private_key) } else { None }, fingerprint: None, // TODO: Calculate fingerprint message, }; @@ -293,7 +286,7 @@ pub struct ValidateResponse { /// Validate SSH connection for a server /// POST /server/{id}/ssh-key/validate -/// +/// /// This endpoint: /// 1. Verifies the server exists and belongs to the user /// 2. Checks the SSH key is active and retrieves it from Vault @@ -366,10 +359,7 @@ pub async fn validate_key( { Ok(key) => key, Err(e) => { - tracing::warn!( - "Failed to fetch SSH key from Vault during validation: {}", - e - ); + tracing::warn!("Failed to fetch SSH key from Vault during validation: {}", e); let response = ValidateResponse { valid: false, server_id, @@ -392,10 +382,7 @@ pub async fn validate_key( // Get SSH connection parameters let ssh_port = server.ssh_port.unwrap_or(22) as u16; - let ssh_user = server - .ssh_user - .clone() - .unwrap_or_else(|| "root".to_string()); + let ssh_user = server.ssh_user.clone().unwrap_or_else(|| "root".to_string()); // Perform SSH connection and system check let check_result = ssh_client::check_server( @@ -412,9 +399,7 @@ pub async fn validate_key( let message = if valid { check_result.summary() } else { - check_result - .error - .unwrap_or_else(|| "SSH validation failed".to_string()) + check_result.error.unwrap_or_else(|| "SSH validation failed".to_string()) }; let response = ValidateResponse { @@ -425,11 +410,7 @@ pub async fn validate_key( connected: check_result.connected, authenticated: check_result.authenticated, // Include vault public key in response when auth fails (helps debug key mismatch) - vault_public_key: if !check_result.authenticated { - vault_public_key - } else { - None - }, + vault_public_key: if !check_result.authenticated { vault_public_key } else { None }, username: check_result.username, disk_total_gb: check_result.disk_total_gb, disk_available_gb: check_result.disk_available_gb, diff --git a/src/startup.rs b/src/startup.rs index e4a60b84..d9f59399 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -251,9 +251,7 @@ pub async fn run( .service(crate::routes::marketplace::admin::approve_handler) .service(crate::routes::marketplace::admin::reject_handler) .service(crate::routes::marketplace::admin::unapprove_handler) - .service( - crate::routes::marketplace::admin::security_scan_handler, - ), + .service(crate::routes::marketplace::admin::security_scan_handler), ) .service( web::scope("/marketplace") @@ -265,9 +263,10 @@ pub async fn run( web::scope("/api/v1/marketplace") .service(crate::routes::marketplace::public::install_script_handler) .service(crate::routes::marketplace::public::download_stack_handler) - .service(web::scope("/agents").service( - crate::routes::marketplace::agent::register_marketplace_agent_handler, - )), + .service( + web::scope("/agents") + .service(crate::routes::marketplace::agent::register_marketplace_agent_handler), + ), ) .service( web::scope("/cloud") diff --git a/tests/agent_login_link.rs b/tests/agent_login_link.rs index fd05e3c7..5d452d73 100644 --- a/tests/agent_login_link.rs +++ b/tests/agent_login_link.rs @@ -128,11 +128,7 @@ async fn test_agent_link_rejects_invalid_token() { let status = resp.status(); println!("Link with invalid token response status: {}", status); // Should be 403 (invalid session token) — not 404 (route not found) - assert_ne!( - status.as_u16(), - 404, - "Route /api/v1/agent/link should exist" - ); + assert_ne!(status.as_u16(), 404, "Route /api/v1/agent/link should exist"); assert!( status.is_client_error(), "Expected client error for invalid token, got {}", diff --git a/tests/cli_config.rs b/tests/cli_config.rs index f8b6ec6b..2f29ffd2 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -96,11 +96,11 @@ fn test_config_show_missing_file_returns_error() { .failure(); } -#[test] -fn test_config_example_prints_full_reference() { - let dir = TempDir::new().unwrap(); + #[test] + fn test_config_example_prints_full_reference() { + let dir = TempDir::new().unwrap(); - stacker_cmd() + stacker_cmd() .current_dir(dir.path()) .args(["config", "example"]) .assert() @@ -109,4 +109,4 @@ fn test_config_example_prints_full_reference() { .stdout(predicate::str::contains("monitoring:")) .stdout(predicate::str::contains("hooks:")) .stdout(predicate::str::contains("deploy:")); -} + } diff --git a/tests/cli_deploy.rs b/tests/cli_deploy.rs index e0e9e132..322edf0a 100644 --- a/tests/cli_deploy.rs +++ b/tests/cli_deploy.rs @@ -98,14 +98,7 @@ deploy: stacker_cmd() .current_dir(dir.path()) - .args([ - "deploy", - "--target", - "local", - "--file", - "custom.yml", - "--dry-run", - ]) + .args(["deploy", "--target", "local", "--file", "custom.yml", "--dry-run"]) .assert() .success(); } @@ -134,10 +127,7 @@ deploy: .args(["deploy", "--target", "cloud"]) .assert() .failure() - .stderr( - predicate::str::contains("login") - .or(predicate::str::contains("credential").or(predicate::str::contains("Login"))), - ); + .stderr(predicate::str::contains("login").or(predicate::str::contains("credential").or(predicate::str::contains("Login")))); } #[test] diff --git a/tests/cli_destroy.rs b/tests/cli_destroy.rs index 095cde35..b2d82f0f 100644 --- a/tests/cli_destroy.rs +++ b/tests/cli_destroy.rs @@ -33,10 +33,7 @@ fn test_destroy_no_deployment_returns_error() { .args(["destroy", "--confirm"]) .assert() .failure() - .stderr( - predicate::str::contains("No deployment") - .or(predicate::str::contains("Nothing to destroy")), - ); + .stderr(predicate::str::contains("No deployment").or(predicate::str::contains("Nothing to destroy"))); } #[test] diff --git a/tests/cli_init.rs b/tests/cli_init.rs index a97aad39..6d4898f6 100644 --- a/tests/cli_init.rs +++ b/tests/cli_init.rs @@ -65,14 +65,7 @@ fn test_init_with_ai_flag() { // a real running Ollama which would take minutes to generate). stacker_cmd() .current_dir(dir.path()) - .args([ - "init", - "--with-ai", - "--ai-provider", - "custom", - "--ai-api-key", - "fake", - ]) + .args(["init", "--with-ai", "--ai-provider", "custom", "--ai-api-key", "fake"]) .assert() .success(); @@ -164,14 +157,7 @@ fn test_init_with_ai_and_provider_flags() { // then falls back to template-based generation. stacker_cmd() .current_dir(dir.path()) - .args([ - "init", - "--with-ai", - "--ai-provider", - "custom", - "--ai-api-key", - "fake", - ]) + .args(["init", "--with-ai", "--ai-provider", "custom", "--ai-api-key", "fake"]) .assert() .success() .stderr(predicate::str::contains("AI").or(predicate::str::contains("Created"))); diff --git a/tests/cli_logs.rs b/tests/cli_logs.rs index 2a7db39a..56b4244c 100644 --- a/tests/cli_logs.rs +++ b/tests/cli_logs.rs @@ -18,10 +18,7 @@ fn test_logs_no_deployment_returns_error() { .arg("logs") .assert() .failure() - .stderr( - predicate::str::contains("No deployment found") - .or(predicate::str::contains("docker-compose")), - ); + .stderr(predicate::str::contains("No deployment found").or(predicate::str::contains("docker-compose"))); } #[test] diff --git a/tests/cli_proxy.rs b/tests/cli_proxy.rs index fea73852..27cd03ee 100644 --- a/tests/cli_proxy.rs +++ b/tests/cli_proxy.rs @@ -10,31 +10,19 @@ fn stacker_cmd() -> Command { #[test] fn test_proxy_add_generates_nginx_block() { stacker_cmd() - .args([ - "proxy", - "add", - "example.com", - "--upstream", - "http://app:3000", - ]) + .args(["proxy", "add", "example.com", "--upstream", "http://app:3000"]) .assert() .success() - .stdout( - predicate::str::contains("server_name").or(predicate::str::contains("example.com")), - ); + .stdout(predicate::str::contains("server_name").or(predicate::str::contains("example.com"))); } #[test] fn test_proxy_add_with_ssl() { stacker_cmd() .args([ - "proxy", - "add", - "secure.example.com", - "--upstream", - "http://app:3000", - "--ssl", - "auto", + "proxy", "add", "secure.example.com", + "--upstream", "http://app:3000", + "--ssl", "auto", ]) .assert() .success(); diff --git a/tests/cli_status.rs b/tests/cli_status.rs index 9c48b889..2883eec3 100644 --- a/tests/cli_status.rs +++ b/tests/cli_status.rs @@ -17,10 +17,7 @@ fn test_status_no_deployment_returns_error() { .arg("status") .assert() .failure() - .stderr( - predicate::str::contains("No deployment") - .or(predicate::str::contains("docker-compose")), - ); + .stderr(predicate::str::contains("No deployment").or(predicate::str::contains("docker-compose"))); } #[test] diff --git a/tests/cli_update.rs b/tests/cli_update.rs index db8ecb49..59c9ff4f 100644 --- a/tests/cli_update.rs +++ b/tests/cli_update.rs @@ -31,9 +31,7 @@ fn test_update_invalid_channel_fails() { .args(["update", "--channel", "nightly"]) .assert() .failure() - .stderr( - predicate::str::contains("Unknown channel").or(predicate::str::contains("nightly")), - ); + .stderr(predicate::str::contains("Unknown channel").or(predicate::str::contains("nightly"))); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2f9cb6d4..3006212c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -130,14 +130,9 @@ pub async fn spawn_app_with_vault() -> Option { let address = format!("http://127.0.0.1:{}", port); let agent_pool = AgentPgPool::new(connection_pool.clone()); - let server = stacker::startup::run( - app_listener, - connection_pool.clone(), - agent_pool, - configuration, - ) - .await - .expect("Failed to bind address."); + let server = stacker::startup::run(app_listener, connection_pool.clone(), agent_pool, configuration) + .await + .expect("Failed to bind address."); let _ = tokio::spawn(server); Some(TestAppWithVault { diff --git a/tests/marketplace_mine.rs b/tests/marketplace_mine.rs index a843fb23..2e415ca5 100644 --- a/tests/marketplace_mine.rs +++ b/tests/marketplace_mine.rs @@ -39,13 +39,8 @@ async fn mine_returns_empty_list_for_new_user() { assert_eq!(StatusCode::OK, response.status()); - let body: serde_json::Value = response - .json() - .await - .expect("Response should be valid JSON"); - let list = body - .get("list") - .expect("Response body should contain 'list' field"); + let body: serde_json::Value = response.json().await.expect("Response should be valid JSON"); + let list = body.get("list").expect("Response body should contain 'list' field"); assert!(list.is_array(), "'list' should be a JSON array"); assert_eq!( 0, @@ -90,19 +85,10 @@ async fn mine_returns_only_the_authenticated_users_templates() { assert_eq!(StatusCode::OK, response.status()); - let body: serde_json::Value = response - .json() - .await - .expect("Response should be valid JSON"); - let list = body["list"] - .as_array() - .expect("'list' should be a JSON array"); + let body: serde_json::Value = response.json().await.expect("Response should be valid JSON"); + let list = body["list"].as_array().expect("'list' should be a JSON array"); - assert_eq!( - 1, - list.len(), - "Should return exactly the authenticated user's template" - ); + assert_eq!(1, list.len(), "Should return exactly the authenticated user's template"); assert_eq!( "my-test-stack", list[0]["slug"].as_str().unwrap_or_default(), diff --git a/tests/server_ssh.rs b/tests/server_ssh.rs index 3dafef38..f0986cb7 100644 --- a/tests/server_ssh.rs +++ b/tests/server_ssh.rs @@ -10,7 +10,10 @@ use wiremock::{Mock, ResponseTemplate}; /// Vault path pattern for SSH keys: /v1/secret/users/{user_id}/ssh_keys/{server_id} fn vault_ssh_path_regex(user_id: &str, server_id: i32) -> String { - format!(r"/v1/secret/users/{}/ssh_keys/{}", user_id, server_id) + format!( + r"/v1/secret/users/{}/ssh_keys/{}", + user_id, server_id + ) } /// Successful Vault GET response body for a KV v1 SSH key read. @@ -47,10 +50,7 @@ async fn test_get_public_key_vault_path_null_returns_400() { let client = reqwest::Client::new(); let resp = client - .get(&format!( - "{}/server/{}/ssh-key/public", - &app.address, server_id - )) + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await @@ -60,11 +60,8 @@ async fn test_get_public_key_vault_path_null_returns_400() { let body: Value = resp.json().await.unwrap(); let msg = body["message"].as_str().unwrap_or(""); assert!( - msg.to_lowercase().contains("vault") - || msg.to_lowercase().contains("regenerate") - || msg.to_lowercase().contains("delete"), - "Error message should mention Vault or remediation: {}", - msg + msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate") || msg.to_lowercase().contains("delete"), + "Error message should mention Vault or remediation: {}", msg ); // Vault server must NOT have been called (no vault_key_path to use) assert_eq!(app.vault_server.received_requests().await.unwrap().len(), 0); @@ -97,26 +94,18 @@ async fn test_get_public_key_vault_returns_404_propagates_as_404() { let client = reqwest::Client::new(); let resp = client - .get(&format!( - "{}/server/{}/ssh-key/public", - &app.address, server_id - )) + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await .expect("request failed"); - assert_eq!( - resp.status().as_u16(), - 404, - "Should be 404 when Vault returns 404" - ); + assert_eq!(resp.status().as_u16(), 404, "Should be 404 when Vault returns 404"); let body: Value = resp.json().await.unwrap(); let msg = body["message"].as_str().unwrap_or(""); assert!( msg.to_lowercase().contains("vault") || msg.to_lowercase().contains("regenerate"), - "Error message should mention Vault: {}", - msg + "Error message should mention Vault: {}", msg ); } @@ -129,15 +118,18 @@ async fn test_get_public_key_no_active_key_returns_404() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = - common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; let client = reqwest::Client::new(); let resp = client - .get(&format!( - "{}/server/{}/ssh-key/public", - &app.address, server_id - )) + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await @@ -167,19 +159,18 @@ async fn test_get_public_key_success() { Mock::given(method("GET")) .and(path_regex(vault_ssh_path_regex("test_user_id", server_id))) - .respond_with(ResponseTemplate::new(200).set_body_json(vault_key_response( - expected_pub_key, - "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", - ))) + .respond_with( + ResponseTemplate::new(200).set_body_json(vault_key_response( + expected_pub_key, + "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + )), + ) .mount(&app.vault_server) .await; let client = reqwest::Client::new(); let resp = client - .get(&format!( - "{}/server/{}/ssh-key/public", - &app.address, server_id - )) + .get(&format!("{}/server/{}/ssh-key/public", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await @@ -207,8 +198,14 @@ async fn test_generate_key_vault_down_returns_private_key_inline() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = - common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; // Vault is down — POST returns 500 Mock::given(method("POST")) @@ -219,20 +216,13 @@ async fn test_generate_key_vault_down_returns_private_key_inline() { let client = reqwest::Client::new(); let resp = client - .post(&format!( - "{}/server/{}/ssh-key/generate", - &app.address, server_id - )) + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await .expect("request failed"); - assert_eq!( - resp.status().as_u16(), - 200, - "Generate should succeed even when Vault is down" - ); + assert_eq!(resp.status().as_u16(), 200, "Generate should succeed even when Vault is down"); let body: Value = resp.json().await.unwrap(); // Private key must be returned inline so user can save it @@ -269,8 +259,14 @@ async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = - common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; // Vault is up — POST returns 204 Mock::given(method("POST")) @@ -281,10 +277,7 @@ async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { let client = reqwest::Client::new(); let resp = client - .post(&format!( - "{}/server/{}/ssh-key/generate", - &app.address, server_id - )) + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await @@ -298,10 +291,7 @@ async fn test_generate_key_success_stores_in_vault_no_private_key_exposed() { body["item"]["private_key"].is_null() || !body["item"]["private_key"].is_string(), "Private key must NOT be returned when Vault stored it successfully" ); - assert!( - body["item"]["public_key"].is_string(), - "Public key must be present" - ); + assert!(body["item"]["public_key"].is_string(), "Public key must be present"); // DB: vault_key_path must be set let row = sqlx::query("SELECT key_status, vault_key_path FROM server WHERE id = $1") @@ -338,10 +328,7 @@ async fn test_generate_key_already_active_returns_400() { let client = reqwest::Client::new(); let resp = client - .post(&format!( - "{}/server/{}/ssh-key/generate", - &app.address, server_id - )) + .post(&format!("{}/server/{}/ssh-key/generate", &app.address, server_id)) .header("Authorization", "Bearer test-token") .send() .await @@ -410,8 +397,14 @@ async fn test_delete_key_none_returns_400() { None => return, }; let project_id = common::create_test_project(&app.db_pool, "test_user_id").await; - let server_id = - common::create_test_server(&app.db_pool, "test_user_id", project_id, "none", None).await; + let server_id = common::create_test_server( + &app.db_pool, + "test_user_id", + project_id, + "none", + None, + ) + .await; let client = reqwest::Client::new(); let resp = client @@ -438,26 +431,24 @@ async fn test_ssh_key_endpoints_require_auth() { let client = reqwest::Client::new(); let endpoints: &[(&str, &str)] = &[ - ("GET", "/server/1/ssh-key/public"), - ("POST", "/server/1/ssh-key/generate"), + ("GET", "/server/1/ssh-key/public"), + ("POST", "/server/1/ssh-key/generate"), ("DELETE", "/server/1/ssh-key"), ]; for (verb, path) in endpoints { let req = match *verb { - "GET" => client.get(&format!("{}{}", &app.address, path)), - "POST" => client.post(&format!("{}{}", &app.address, path)), + "GET" => client.get(&format!("{}{}", &app.address, path)), + "POST" => client.post(&format!("{}{}", &app.address, path)), "DELETE" => client.delete(&format!("{}{}", &app.address, path)), - _ => unreachable!(), + _ => unreachable!(), }; let resp = req.send().await.expect("request failed"); let status = resp.status().as_u16(); assert!( status == 400 || status == 401 || status == 403 || status == 404, "{} {} without auth should return 400/401/403, got {}", - verb, - path, - status + verb, path, status ); } } From 32ba7c61b13174fc11f5b3cc56d7e249c937d7d4 Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 15:44:06 +0200 Subject: [PATCH 11/13] Fix CI: use valid action versions and include access_control.conf.dist in artifact - Replace actions/checkout@v6 with @v4 (v6 does not exist) - Replace actions/upload-artifact@v6 with @v4 (v6 does not exist) - Replace actions/cache@v5 with @v4 (v5 does not exist) - Also copy access_control.conf.dist into the app artifact zip so alternative build paths that use the artifact also have the file The invalid action versions caused checkout to silently fail, leaving access_control.conf.dist absent from the Docker build context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docker.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ae6a719b..e0843587 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -23,7 +23,7 @@ jobs: SQLX_OFFLINE: true steps: - name: Checkout sources - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} @@ -47,7 +47,7 @@ jobs: components: rustfmt, clippy - name: Cache cargo registry - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: ~/.cargo/registry key: docker-registry-${{ hashFiles('**/Cargo.lock') }} @@ -56,7 +56,7 @@ jobs: docker- - name: Cache cargo index - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: ~/.cargo/git key: docker-index-${{ hashFiles('**/Cargo.lock') }} @@ -69,7 +69,7 @@ jobs: head -c16 /dev/urandom > src/secret.key - name: Cache cargo build - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: target key: docker-build-${{ hashFiles('**/Cargo.lock') }} @@ -124,7 +124,7 @@ jobs: - name: Archive production artifacts if: ${{ hashFiles('web/package.json') != '' }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: name: dist-without-markdown path: | @@ -141,13 +141,14 @@ jobs: cp target/release/server app/stacker/server if [ -d web/dist ]; then cp -a web/dist/. app/stacker; fi cp Dockerfile app/Dockerfile + cp access_control.conf.dist app/access_control.conf.dist cd app touch .env tar -czvf ../app.tar.gz . cd .. - name: Upload app archive for Docker job - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: name: artifact-linux-docker path: app.tar.gz @@ -158,7 +159,7 @@ jobs: needs: cicd-docker steps: - name: Checkout sources - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} @@ -196,7 +197,7 @@ jobs: needs: cicd-docker steps: - name: Checkout sources - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} - @@ -226,3 +227,4 @@ jobs: file: ./stackerdb/Dockerfile push: true tags: ${{ steps.stackerdb_tags.outputs.tags }} + From 6fe7b2f6c6a42d8a5d3d871c9abb6eb2cf986f5a Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 18:06:12 +0200 Subject: [PATCH 12/13] fix: serialize SECURITY_KEY env tests with a mutex to prevent races Tests that set/remove SECURITY_KEY were racing when cargo test ran with multiple threads (default in CI). Added a static Mutex<()> and a shared TEST_KEY constant so all env-mutating tests acquire the lock before touching the environment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/helpers/cloud/security.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/helpers/cloud/security.rs b/src/helpers/cloud/security.rs index 027817a3..6a9846dd 100644 --- a/src/helpers/cloud/security.rs +++ b/src/helpers/cloud/security.rs @@ -124,6 +124,11 @@ impl Secret { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + const TEST_KEY: &str = "01234567890123456789012345678901"; #[test] fn test_secret_new() { @@ -177,7 +182,7 @@ mod tests { #[test] fn test_encrypt_requires_security_key() { - // Remove SECURITY_KEY if it exists + let _lock = ENV_MUTEX.lock().unwrap(); std::env::remove_var("SECURITY_KEY"); let secret = Secret { user_id: "u1".to_string(), @@ -191,6 +196,7 @@ mod tests { #[test] fn test_encrypt_invalid_key_length() { + let _lock = ENV_MUTEX.lock().unwrap(); std::env::set_var("SECURITY_KEY", "short-key"); let secret = Secret { user_id: "u1".to_string(), @@ -205,8 +211,8 @@ mod tests { #[test] fn test_encrypt_decrypt_roundtrip() { - let key = "01234567890123456789012345678901"; // exactly 32 bytes - std::env::set_var("SECURITY_KEY", key); + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", TEST_KEY); let mut secret = Secret { user_id: "u1".to_string(), @@ -227,7 +233,8 @@ mod tests { #[test] fn test_decrypt_too_short_data() { - std::env::set_var("SECURITY_KEY", "01234567890123456789012345678901"); + let _lock = ENV_MUTEX.lock().unwrap(); + std::env::set_var("SECURITY_KEY", TEST_KEY); let mut secret = Secret::new(); let result = secret.decrypt(vec![1, 2, 3]); // less than 12 bytes assert!(result.is_err()); From cf41e78dc26ef5d557efe433a3303c748ac763cb Mon Sep 17 00:00:00 2001 From: vsilent Date: Wed, 25 Mar 2026 19:17:02 +0200 Subject: [PATCH 13/13] fix: grant group_admin access to /admin/project/:id/compose The compose endpoint only had a Casbin policy for admin_service (JWT), but OAuth-based access from User Service authenticates as 'root' which inherits group_admin. This caused 403 when fetching marketplace template compose snapshots, leaving stack_definition NULL and blocking deployments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...260325140000_casbin_admin_compose_group_admin.down.sql | 5 +++++ ...20260325140000_casbin_admin_compose_group_admin.up.sql | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 migrations/20260325140000_casbin_admin_compose_group_admin.down.sql create mode 100644 migrations/20260325140000_casbin_admin_compose_group_admin.up.sql diff --git a/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql b/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql new file mode 100644 index 00000000..0d0574e0 --- /dev/null +++ b/migrations/20260325140000_casbin_admin_compose_group_admin.down.sql @@ -0,0 +1,5 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' + AND v0 = 'group_admin' + AND v1 = '/admin/project/:id/compose' + AND v2 = 'GET'; diff --git a/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql b/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql new file mode 100644 index 00000000..a19690ad --- /dev/null +++ b/migrations/20260325140000_casbin_admin_compose_group_admin.up.sql @@ -0,0 +1,8 @@ +-- Allow group_admin (and roles inheriting it, like root) to access the admin +-- project compose endpoint. The existing rule only grants access to the +-- admin_service JWT role, but OAuth-based access (User Service client +-- credentials) authenticates as the client owner whose role is "root". +-- root inherits group_admin, so adding the policy here covers both paths. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/admin/project/:id/compose', 'GET', '', '', '') +ON CONFLICT DO NOTHING;