diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ab4ab2b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +## Project Overview + +**WRPT** is a Rust CLI tool for deploying and managing Docker Compose stacks on Portainer. It supports both manual usage and CI/CD pipeline integration. Published on [crates.io](https://crates.io/crates/wrpt) and [Docker Hub](https://hub.docker.com/r/wahl/wrpt). + +- **Language:** Rust (Edition 2021) +- **Version:** 0.6.3 +- **License:** MIT + +## Quick Reference Commands + +```bash +# Build +cargo build +cargo build --release + +# Test +cargo test --verbose + +# Lint +cargo clippy --verbose + +# Format +cargo fmt --all --verbose + +# Full CI check (matches GitHub Actions) +cargo build --verbose && cargo test --verbose && cargo clippy --verbose && cargo fmt --all --verbose +``` + +## Project Structure + +``` +src/ +├── main.rs # Entry point (minimal) +└── commands/ + ├── mod.rs # Command dispatch/routing, CliContext initialization + ├── wrpt.rs # CLI args struct, logger init, global args + ├── consts.rs # API endpoint path constants + ├── error.rs # CliError enum (Config, Api, Io, Http) + ├── helpers.rs # Shared utilities (CliContext, HTTP client, table formatting, env parsing) + ├── stacks/ # Stack management (deploy, remove, list, start, stop, resource-control) + │ ├── args/ # clap argument definitions + │ ├── handlers/ # Business logic + │ └── models/ # Data structures + ├── endpoints/ # Endpoint listing + │ ├── args/ handlers/ models/ + ├── teams/ # Team listing + │ ├── args/ handlers/ models/ + └── users/ # User listing + ├── args/ handlers/ models/ +``` + +## Architecture + +Each command domain follows **args → handlers → models**: +- **args/**: CLI argument definitions using `clap::Args` and `clap::Subcommand` +- **handlers/**: Business logic and API calls +- **models/**: Data structures for API requests/responses (with Serde) + +Shared utilities live in `helpers.rs` (HTTP client factory, URL construction, table formatting, env file parsing, API response handling). + +## Code Conventions + +- **Naming:** snake_case for modules/functions, PascalCase for structs/enums, UPPER_SNAKE_CASE for constants +- **Error handling:** Custom `CliError` enum (`Config`, `Api`, `Io`, `Http`) with `Result` propagation via `?` operator +- **Shared context:** `CliContext` struct holds the reusable HTTP client (with 30s timeout) and base URL, passed to all handlers +- **HTTP:** Centralized `create_client()` in helpers; custom headers for Portainer auth (`x-api-key`) +- **Constants:** Compile-time string formatting via `const_format` crate for API paths +- **Output:** `prettytable-rs` for ASCII table display; `simplelog` with Paris for colored logging +- **Global args:** URL (`-l`/`PORTAINER_URL`), access token (`-A`/`PORTAINER_ACCESS_TOKEN`), `--insecure`, verbosity (`-v`), quiet (`-q`), color control + +## Key Dependencies + +| Crate | Purpose | +|-------|---------| +| `clap` (4.x) | CLI argument parsing with derive macros | +| `reqwest` | HTTP client for Portainer API | +| `serde` / `serde_json` | JSON serialization | +| `prettytable-rs` | ASCII table output | +| `simplelog` / `log` | Logging | +| `chrono` | Date/time handling | +| `const_format` | Compile-time string formatting | + +## CI/CD + +Three GitHub Actions workflows in `.github/workflows/`: +- **tests.yml**: Build, test, clippy, fmt on push/PR +- **release.yml**: Manual dispatch → Cocogitto SemVer bump → changelog → GitHub release → crates.io publish +- **docker.yml**: Multi-platform Docker build (amd64/arm64) → Docker Hub + +## Release Process + +Uses [Cocogitto](https://docs.cocogitto.io/) (`cog.toml`) with conventional commits. Pre-bump hooks run test, clippy, and fmt. Post-bump hooks push and publish to crates.io. + +## Docker + +Multi-stage Dockerfile in `docker/Dockerfile`: Rust build → Debian 12 slim runtime with OpenSSL and Docker Compose. + +## Important Notes + +- Conventional commits are required (feat:, fix:, docs:, refactor:, etc.) +- Branch whitelist for releases: `main` only +- No `.rustfmt.toml` or `clippy.toml` overrides — uses default Rust toolchain settings +- `.env` files are gitignored; the tool supports parsing them at runtime diff --git a/Cargo.lock b/Cargo.lock index 4b47be6..bf450a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -840,15 +840,6 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" -[[package]] -name = "log_err" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2829e80508da18227e53a791bbe19018306f3f400bba439bc9959e9faa860f7" -dependencies = [ - "log", -] - [[package]] name = "memchr" version = "2.7.4" @@ -1894,13 +1885,13 @@ dependencies = [ "clap", "const_format", "log", - "log_err", "prettytable-rs", "reqwest", "serde", "serde_json", "serde_repr", "simplelog", + "tempfile", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f09ba11..81d2f53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,11 @@ reqwest = { version = "0.12.9" , features = ["blocking", "json"] } anstyle = "1.0.10" log = "0.4.22" simplelog = { version = "^0.12.2", features = ["paris"] } -log_err = "1.1.1" serde_json = "1.0.134" prettytable-rs = "0.10.0" serde = { version = "1.0.216", features = ["derive"] } serde_repr = "0.1.19" chrono = { version = "0.4.39", features = ["serde"] } + +[dev-dependencies] +tempfile = "3" diff --git a/README.md b/README.md index 3f1daaf..aa38d1d 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,11 @@

AboutRoadmap • + Installation • + Quick StartAvailable CommandsDocker • + CI/CD IntegrationChangelogLicense

@@ -37,8 +40,8 @@ It is also my first project written in Rust and is under **active development**, Here are the planned enhancements and features for WRPT: - 🚧 **Access Control Management:** Enable stack deployments with fine-grained access control, allowing assignment to specific users and/or groups. -- 🚧 **Comprehensive Documentation:** Write detailed usage guides, including setup instructions for integration into CI/CD pipelines on GitLab and GitHub. -- ⏳ **Automated Testing:** Write tests to ensure the reliability and stability of the tool. +- ✅ **Comprehensive Documentation:** Write detailed usage guides, including setup instructions for integration into CI/CD pipelines on GitLab and GitHub. +- ✅ **Automated Testing:** Write tests to ensure the reliability and stability of the tool. - 💭 **Kubernetes Compatibility:** Extend the tool to support Portainer deployments on Kubernetes environment. - ✅ **Automated Release Process:** Implement CI pipelines to generate changelogs and releases automatically based on versioning and commit history. - ✅ **Docker Image:** Create a Docker image. @@ -52,6 +55,77 @@ Here are the planned enhancements and features for WRPT: --- +## Installation + +### From crates.io + +```bash +cargo install wrpt +``` + +### Docker + +```bash +docker pull wahl/wrpt:latest +``` + +### From source + +```bash +git clone https://github.com/wahl-dev/wrpt.git +cd wrpt +cargo build --release +# Binary available at ./target/release/wrpt +``` + +--- + +## Quick Start + +### 1. Generate a Portainer access token + +In your Portainer instance, go to **My Account** > **Access tokens** > **Add access token**. + +See the [Portainer documentation](https://docs.portainer.io/api/access#creating-an-access-token) for more details. + +### 2. Set your environment variables + +```bash +export PORTAINER_URL="https://portainer.example.com" +export PORTAINER_ACCESS_TOKEN="your-access-token" +``` + +### 3. List your endpoints + +```bash +wrpt endpoint list +``` + +### 4. List your stacks + +```bash +wrpt stack list +``` + +### 5. Deploy a stack + +```bash +wrpt stack deploy my-stack \ + --endpoint 1 \ + --compose-file docker-compose.yml +``` + +You can also pass environment variables to the stack: + +```bash +wrpt stack deploy my-stack \ + --endpoint 1 \ + --compose-file docker-compose.yml \ + --env-file .env +``` + +--- + ## Available Commands | Name | Description | @@ -296,6 +370,93 @@ docker run -it --rm \ --- +## CI/CD Integration + +WRPT's Docker image makes it easy to integrate stack deployments into your CI/CD pipelines. + +### GitHub Actions + +Add this workflow to `.github/workflows/deploy.yml`: + +```yaml +name: Deploy Stack + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + container: + image: wahl/wrpt:latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy stack + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL }} + PORTAINER_ACCESS_TOKEN: ${{ secrets.PORTAINER_ACCESS_TOKEN }} + run: | + wrpt stack deploy my-stack \ + --endpoint ${{ vars.PORTAINER_ENDPOINT }} \ + --compose-file docker-compose.yml +``` + +**Required secrets** (Settings > Secrets and variables > Actions): + +| Secret | Description | +|--------|-------------| +| `PORTAINER_URL` | URL of your Portainer instance (e.g. `https://portainer.example.com`) | +| `PORTAINER_ACCESS_TOKEN` | Portainer API access token | + +**Required variables** (Settings > Secrets and variables > Actions > Variables): + +| Variable | Description | +|----------|-------------| +| `PORTAINER_ENDPOINT` | ID of the Portainer endpoint to deploy to | + +### GitLab CI + +Add this to your `.gitlab-ci.yml`: + +```yaml +stages: + - deploy + +deploy-stack: + stage: deploy + image: wahl/wrpt:latest + only: + - main + script: + - wrpt stack deploy my-stack + --endpoint $PORTAINER_ENDPOINT + --compose-file docker-compose.yml + variables: + PORTAINER_URL: $PORTAINER_URL + PORTAINER_ACCESS_TOKEN: $PORTAINER_ACCESS_TOKEN +``` + +**Required CI/CD variables** (Settings > CI/CD > Variables): + +| Variable | Protected | Masked | Description | +|----------|-----------|--------|-------------| +| `PORTAINER_URL` | Yes | No | URL of your Portainer instance | +| `PORTAINER_ACCESS_TOKEN` | Yes | Yes | Portainer API access token | +| `PORTAINER_ENDPOINT` | Yes | No | ID of the Portainer endpoint | + +### CI/CD Best Practices + +- **Never hardcode tokens** in your pipeline files. Always use secrets/protected variables. +- **Use `--insecure` only if necessary** (e.g. self-signed certificates in internal environments). Prefer proper SSL certificates. +- **Use `-vv` for debugging** pipeline failures — it enables verbose output to help diagnose issues. +- **Verify your endpoint first** by running `wrpt endpoint list` as a preliminary step to confirm connectivity. +- **Pin the Docker image tag** to a specific version (e.g. `wahl/wrpt:0.6.3`) in production pipelines for reproducible deployments. + +--- + ## Changelog The changelog is available in the [CHANGELOG.md](./CHANGELOG.md) file. diff --git a/docker/Dockerfile b/docker/Dockerfile index d5744f0..63b8645 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,20 +1,20 @@ -FROM rust:1 AS base +FROM rust:1.83-slim AS build WORKDIR /app -RUN apt-get update && apt-get upgrade -y -RUN rustup component add clippy -RUN rustup component add rustfmt - - -FROM base AS build +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* COPY . /app RUN cargo build --release FROM debian:12-slim AS final -RUN apt-get update && apt install -y openssl curl +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 1000 wrpt COPY --from=build /app/target/release/wrpt /usr/bin/ -COPY --from=docker/compose-bin:latest /docker-compose /usr/bin/docker-compose +COPY --from=docker/compose-bin:v2.32.4 /docker-compose /usr/bin/docker-compose + +USER wrpt -ENTRYPOINT ["/bin/wrpt"] +ENTRYPOINT ["/usr/bin/wrpt"] diff --git a/src/commands/endpoints/handlers/list.rs b/src/commands/endpoints/handlers/list.rs index a49451f..c80174b 100644 --- a/src/commands/endpoints/handlers/list.rs +++ b/src/commands/endpoints/handlers/list.rs @@ -1,45 +1,30 @@ use crate::commands::consts; use crate::commands::endpoints::args::list::EndpointListCommand; use crate::commands::endpoints::models::list::EndpointList; -use crate::commands::helpers::{ - build_table, construct_url, create_client, get_access_token, get_base_url, parse_api_response, -}; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; +use crate::commands::error::CliError; +use crate::commands::helpers::{build_table, construct_url, parse_api_response, CliContext}; use simplelog::debug; -pub(crate) fn handler(command: EndpointListCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: EndpointListCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - - let endpoints = fetch_endpoints( - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; + let endpoints = fetch_endpoints(ctx)?; build_table(&endpoints, None).printstd(); Ok(()) } -pub(crate) fn fetch_endpoints( - base_url: &str, - access_token: &str, - insecure: bool, -) -> Result, ()> { - let url = - construct_url(base_url, consts::ENDPOINT_ENDPOINTS).log_expect("failed to construct url"); +pub(crate) fn fetch_endpoints(ctx: &CliContext) -> Result, CliError> { + let url = construct_url(&ctx.base_url, consts::ENDPOINT_ENDPOINTS)?; debug!("request = GET {:?}", url.as_str()); - let response = create_client(access_token, insecure) + let response = ctx + .client .get(url) .query(&[("excludeSnapshots", "true")]) - .send() - .log_expect("invalid response from API"); + .send()?; parse_api_response(response) } diff --git a/src/commands/endpoints/mod.rs b/src/commands/endpoints/mod.rs index 4293d88..82baa83 100644 --- a/src/commands/endpoints/mod.rs +++ b/src/commands/endpoints/mod.rs @@ -3,12 +3,13 @@ pub mod handlers; pub mod models; use crate::commands::endpoints::args::{EndpointCommand, EndpointSubCommand}; -use crate::commands::wrpt::GlobalArgs; +use crate::commands::error::CliError; +use crate::commands::helpers::CliContext; -pub fn handler(endpoint: EndpointCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub fn handler(endpoint: EndpointCommand, ctx: &CliContext) -> Result<(), CliError> { let command = endpoint.command; match command { - EndpointSubCommand::List(command) => handlers::list::handler(command, global_args), + EndpointSubCommand::List(command) => handlers::list::handler(command, ctx), } } diff --git a/src/commands/endpoints/models/list.rs b/src/commands/endpoints/models/list.rs index 3afaa46..295fa11 100644 --- a/src/commands/endpoints/models/list.rs +++ b/src/commands/endpoints/models/list.rs @@ -4,8 +4,21 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub(crate) struct EndpointList { - id: u32, - name: String, - r#type: EndpointType, - status: EndpointStatus, + pub(crate) id: u32, + pub(crate) name: String, + pub(crate) r#type: EndpointType, + pub(crate) status: EndpointStatus, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_list_deserialize() { + let json = r#"{"Id": 1, "Name": "local", "Type": 1, "Status": 1}"#; + let endpoint: EndpointList = serde_json::from_str(json).unwrap(); + assert_eq!(endpoint.id, 1); + assert_eq!(endpoint.name, "local"); + } } diff --git a/src/commands/endpoints/models/mod.rs b/src/commands/endpoints/models/mod.rs index 22da181..1bdc906 100644 --- a/src/commands/endpoints/models/mod.rs +++ b/src/commands/endpoints/models/mod.rs @@ -21,3 +21,30 @@ pub(crate) enum EndpointType { #[serde(other)] Unknown = 0, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_status_deserialize() { + let up: EndpointStatus = serde_json::from_str("1").unwrap(); + assert_eq!(up, EndpointStatus::Up); + let down: EndpointStatus = serde_json::from_str("2").unwrap(); + assert_eq!(down, EndpointStatus::Down); + let unknown: EndpointStatus = serde_json::from_str("99").unwrap(); + assert_eq!(unknown, EndpointStatus::Unknown); + } + + #[test] + fn endpoint_type_deserialize() { + let docker: EndpointType = serde_json::from_str("1").unwrap(); + assert_eq!(docker, EndpointType::Docker); + let agent: EndpointType = serde_json::from_str("2").unwrap(); + assert_eq!(agent, EndpointType::Agent); + let azure: EndpointType = serde_json::from_str("3").unwrap(); + assert_eq!(azure, EndpointType::Azure); + let unknown: EndpointType = serde_json::from_str("50").unwrap(); + assert_eq!(unknown, EndpointType::Unknown); + } +} diff --git a/src/commands/error.rs b/src/commands/error.rs new file mode 100644 index 0000000..ebcf9f3 --- /dev/null +++ b/src/commands/error.rs @@ -0,0 +1,40 @@ +use std::fmt; + +#[derive(Debug)] +pub(crate) enum CliError { + Config(String), + Api(String), + Io(String), + Http(String), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CliError::Config(msg) => write!(f, "configuration error: {}", msg), + CliError::Api(msg) => write!(f, "API error: {}", msg), + CliError::Io(msg) => write!(f, "IO error: {}", msg), + CliError::Http(msg) => write!(f, "HTTP error: {}", msg), + } + } +} + +impl std::error::Error for CliError {} + +impl From for CliError { + fn from(err: std::io::Error) -> Self { + CliError::Io(err.to_string()) + } +} + +impl From for CliError { + fn from(err: reqwest::Error) -> Self { + CliError::Http(err.to_string()) + } +} + +impl From for CliError { + fn from(err: reqwest::header::InvalidHeaderValue) -> Self { + CliError::Config(format!("invalid access token format: {}", err)) + } +} diff --git a/src/commands/helpers.rs b/src/commands/helpers.rs index 9b080eb..de88fa2 100644 --- a/src/commands/helpers.rs +++ b/src/commands/helpers.rs @@ -1,11 +1,11 @@ use crate::commands::consts; +use crate::commands::error::CliError; use crate::commands::stacks::handlers::list::fetch_stacks; use crate::commands::stacks::models::deploy::EnvVar; use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; use prettytable::format::{FormatBuilder, LinePosition, LineSeparator}; use prettytable::{cell, Cell, Row, Table}; -use reqwest::blocking::Response; +use reqwest::blocking::{Client, Response}; use reqwest::header::{HeaderName, HeaderValue}; use reqwest::Url; use serde::de::DeserializeOwned; @@ -15,29 +15,49 @@ use std::env; use std::fs::File; use std::io::{self, BufRead}; use std::path::PathBuf; +use std::time::Duration; + +/// Shared context holding a reusable HTTP client and resolved configuration. +pub(crate) struct CliContext { + pub client: Client, + pub base_url: String, +} + +impl CliContext { + pub fn from_global_args(global_args: &GlobalArgs) -> Result { + let base_url = get_base_url(global_args)?; + let access_token = get_access_token(global_args)?; + + if global_args.insecure { + warn!("SSL certificate verification is disabled"); + } + + let client = create_client(&access_token, global_args.insecure)?; + + Ok(CliContext { client, base_url }) + } +} + +pub(crate) fn create_client(api_key: &str, insecure: bool) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-api-key"), + HeaderValue::from_str(api_key)?, + ); -pub(crate) fn create_client(api_key: &str, insecure: bool) -> reqwest::blocking::Client { reqwest::blocking::Client::builder() .danger_accept_invalid_certs(insecure) - .default_headers({ - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - HeaderName::from_static("x-api-key"), - HeaderValue::from_str(api_key).unwrap(), - ); - headers - }) + .timeout(Duration::from_secs(30)) + .default_headers(headers) .build() - .unwrap() + .map_err(|e| CliError::Http(format!("failed to build HTTP client: {}", e))) } pub(crate) fn get_stack_id_from_name( + ctx: &CliContext, name: &str, - base_url: &str, - access_token: &str, - insecure: bool, -) -> Result, ()> { - let stacks = fetch_stacks(base_url, access_token, insecure)?; +) -> Result, CliError> { + let stacks = fetch_stacks(ctx)?; for stack in stacks { if stack.name.eq(name) { @@ -48,27 +68,29 @@ pub(crate) fn get_stack_id_from_name( Ok(None) } +/// Resolves a stack by name, returning its ID or an error if it doesn't exist. +pub(crate) fn resolve_stack(ctx: &CliContext, stack_name: &str) -> Result { + let stack_id = get_stack_id_from_name(ctx, stack_name)?; + stack_id.ok_or_else(|| { + error!("Stack \"{}\" does not exist", stack_name); + CliError::Api(format!("stack \"{}\" does not exist", stack_name)) + }) +} + pub(crate) fn get_swarm_id_from_endpoint_id( + ctx: &CliContext, endpoint_id: u32, - url: &str, - access_token: &str, - insecure: bool, -) -> Option { - let mut url = url.to_string(); - url.push_str( - consts::ENDPOINT_ENDPOINTS_DOCKER_INFO - .replace("{id}", endpoint_id.to_string().as_str()) - .as_str(), - ); +) -> Result, CliError> { + let url = construct_url( + &ctx.base_url, + &consts::ENDPOINT_ENDPOINTS_DOCKER_INFO.replace("{id}", &endpoint_id.to_string()), + )?; - let response = create_client(access_token, insecure).get(url).send(); + let response = ctx.client.get(url).send()?; - let body = response - .log_expect("invalid response from API") - .text() - .unwrap_or_default(); + let body = response.text().unwrap_or_default(); - let json = serde_json::from_str::(body.as_str()).unwrap_or_default(); + let json = serde_json::from_str::(&body).unwrap_or_default(); let id = json .get("Swarm") @@ -79,45 +101,40 @@ pub(crate) fn get_swarm_id_from_endpoint_id( .unwrap_or(&Null) .as_str(); - if id.is_some() { - return Some(id?.to_string()); - } - - None + Ok(id.map(|s| s.to_string())) } -pub(crate) fn get_base_url(global_args: &GlobalArgs) -> Result { - match global_args +pub(crate) fn get_base_url(global_args: &GlobalArgs) -> Result { + global_args .url .clone() .or_else(|| env::var("PORTAINER_URL").ok()) - { - None => { + .ok_or_else(|| { error!("param `url` or environment variable `PORTAINER_URL` should be set"); - Err(()) - } - Some(base_url) => Ok(base_url), - } + CliError::Config( + "param `url` or environment variable `PORTAINER_URL` should be set".to_string(), + ) + }) } -pub(crate) fn get_access_token(global_args: &GlobalArgs) -> Result { - match global_args +pub(crate) fn get_access_token(global_args: &GlobalArgs) -> Result { + global_args .access_token .clone() .or_else(|| env::var("PORTAINER_ACCESS_TOKEN").ok()) - { - None => { + .ok_or_else(|| { error!("param `access-token` or environment variable `PORTAINER_ACCESS_TOKEN` should be set"); - Err(()) - } - Some(base_url) => Ok(base_url), - } + CliError::Config( + "param `access-token` or environment variable `PORTAINER_ACCESS_TOKEN` should be set".to_string(), + ) + }) } -pub(crate) fn construct_url(base_url: &str, endpoint: &str) -> Result { - let url = Url::parse(base_url).map_err(|_| "invalid base URL".to_string())?; +pub(crate) fn construct_url(base_url: &str, endpoint: &str) -> Result { + let url = Url::parse(base_url) + .map_err(|e| CliError::Config(format!("invalid base URL \"{}\": {}", base_url, e)))?; url.join(endpoint) - .map_err(|_| "invalid endpoint path".to_string()) + .map_err(|e| CliError::Config(format!("invalid endpoint path \"{}\": {}", endpoint, e))) } pub(crate) fn build_table(items: &[T], columns: Option<&[&str]>) -> Table @@ -149,7 +166,7 @@ fn extract_headers(item: &T, columns: Option<&[&str]>) -> Vec where T: serde::Serialize, { - let serialized = serde_json::to_value(item).log_expect("failed to serialize item"); + let serialized = serde_json::to_value(item).unwrap_or_default(); if let serde_json::Value::Object(map) = serialized { match columns { Some(cols) => cols.iter().map(|&col| col.to_string()).collect(), @@ -164,7 +181,7 @@ fn extract_row(item: &T, columns: Option<&[&str]>) -> Vec where T: serde::Serialize, { - let serialized = serde_json::to_value(item).log_expect("failed to serialize item"); + let serialized = serde_json::to_value(item).unwrap_or_default(); if let serde_json::Value::Object(map) = serialized { match columns { Some(cols) => cols @@ -201,7 +218,7 @@ fn process_table_value(value: &serde_json::Value) -> Cell { } } -pub(crate) fn handle_api_response(response: Response) -> Result { +pub(crate) fn handle_api_response(response: Response) -> Result { debug!("response = {:?}", response); if !response.status().is_success() { @@ -211,7 +228,7 @@ pub(crate) fn handle_api_response(response: Response) -> Result { .unwrap_or_else(|_| "".to_string()); // Try to parse the error response as JSON - if let Ok(json) = serde_json::from_str::(&body) { + let error_msg = if let Ok(json) = serde_json::from_str::(&body) { let message = json .get("message") .and_then(|v| v.as_str()) @@ -225,29 +242,29 @@ pub(crate) fn handle_api_response(response: Response) -> Result { "Api error: {}\nmessage: {}\ndetails: {}", status, message, details ); + format!("{}: {} ({})", status, message, details) } else { // If not JSON, log the raw body error!( "Api error: {}\nbody: {}", status, body ); - } + format!("{}: {}", status, body) + }; - return Err(()); + return Err(CliError::Api(error_msg)); } Ok(response) } -pub(crate) fn parse_api_response(response: Response) -> Result, ()> +pub(crate) fn parse_api_response(response: Response) -> Result, CliError> where T: DeserializeOwned, { - let response = handle_api_response(response)?.text().unwrap_or_else(|_| { - warn!("unable to read API response"); - - String::new() - }); + let response = handle_api_response(response)? + .text() + .map_err(|e| CliError::Http(format!("unable to read API response: {}", e)))?; debug!("response_body = {:?}", response); @@ -278,16 +295,19 @@ pub(crate) fn parse_env_file(file_path: Option) -> Result, continue; } - // Remove inline comments - let line = match line.split_once('#') { - Some((code, _)) => code.trim().to_string(), - None => line, - }; - if let Some((name, value)) = line.split_once('=') { + // Strip surrounding quotes from value + let value = value.trim(); + let value = if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + &value[1..value.len() - 1] + } else { + value + }; vars.push(EnvVar { name: name.trim().to_string(), - value: value.trim().to_string(), + value: value.to_string(), }); } } @@ -295,14 +315,219 @@ pub(crate) fn parse_env_file(file_path: Option) -> Result, Ok(vars) } -// fn main() { -// let file_path = ".env"; // Remplacez par le chemin de votre fichier .env -// -// match parse_env_file(file_path) { -// Ok(env_vars) => { -// let json_output = json!(env_vars); -// println!("{}", serde_json::to_string_pretty(&json_output).unwrap()); -// } -// Err(e) => eprintln!("Erreur lors de la lecture du fichier .env : {}", e), -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + // --- parse_env_file tests --- + + fn write_temp_env(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(content.as_bytes()).unwrap(); + file + } + + #[test] + fn parse_env_file_basic() { + let file = write_temp_env("KEY=value\nFOO=bar\n"); + let result = parse_env_file(Some(file.path().to_path_buf())).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name, "KEY"); + assert_eq!(result[0].value, "value"); + assert_eq!(result[1].name, "FOO"); + assert_eq!(result[1].value, "bar"); + } + + #[test] + fn parse_env_file_comments_and_empty_lines() { + let file = write_temp_env("# comment\n\nKEY=value\n # indented comment\n"); + let result = parse_env_file(Some(file.path().to_path_buf())).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "KEY"); + } + + #[test] + fn parse_env_file_hash_in_value_preserved() { + let file = write_temp_env("PASSWORD=abc#123\n"); + let result = parse_env_file(Some(file.path().to_path_buf())).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "PASSWORD"); + assert_eq!(result[0].value, "abc#123"); + } + + #[test] + fn parse_env_file_quoted_value() { + let file = write_temp_env("KEY=\"hello world\"\nKEY2='single'\n"); + let result = parse_env_file(Some(file.path().to_path_buf())).unwrap(); + assert_eq!(result[0].value, "hello world"); + assert_eq!(result[1].value, "single"); + } + + #[test] + fn parse_env_file_nonexistent_file() { + let result = parse_env_file(Some(PathBuf::from("/tmp/nonexistent_env_file_12345"))); + assert!(result.is_err()); + } + + #[test] + fn parse_env_file_line_without_equals() { + let file = write_temp_env("NOEQUALS\nKEY=value\n"); + let result = parse_env_file(Some(file.path().to_path_buf())).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "KEY"); + } + + #[test] + fn parse_env_file_value_with_equals() { + let file = write_temp_env("KEY=val=ue\n"); + let result = parse_env_file(Some(file.path().to_path_buf())).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "KEY"); + assert_eq!(result[0].value, "val=ue"); + } + + #[test] + fn parse_env_file_whitespace_trimming() { + let file = write_temp_env(" KEY = value \n"); + let result = parse_env_file(Some(file.path().to_path_buf())).unwrap(); + assert_eq!(result[0].name, "KEY"); + assert_eq!(result[0].value, "value"); + } + + // --- construct_url tests --- + + #[test] + fn construct_url_valid() { + let url = construct_url("https://portainer.example.com", "/api/stacks").unwrap(); + assert_eq!(url.as_str(), "https://portainer.example.com/api/stacks"); + } + + #[test] + fn construct_url_invalid_base() { + let result = construct_url("not-a-url", "/api/stacks"); + assert!(result.is_err()); + } + + #[test] + fn construct_url_with_trailing_slash() { + let url = construct_url("https://portainer.example.com/", "/api/stacks").unwrap(); + assert_eq!(url.as_str(), "https://portainer.example.com/api/stacks"); + } + + // --- get_base_url tests --- + + #[test] + fn get_base_url_from_arg() { + let args = GlobalArgs { + url: Some("https://example.com".to_string()), + access_token: None, + insecure: false, + verbose: 1, + quiet: false, + color: clap::ColorChoice::Auto, + }; + assert_eq!(get_base_url(&args).unwrap(), "https://example.com"); + } + + #[test] + fn get_base_url_missing() { + env::remove_var("PORTAINER_URL"); + let args = GlobalArgs { + url: None, + access_token: None, + insecure: false, + verbose: 1, + quiet: false, + color: clap::ColorChoice::Auto, + }; + assert!(get_base_url(&args).is_err()); + } + + // --- get_access_token tests --- + + #[test] + fn get_access_token_from_arg() { + let args = GlobalArgs { + url: None, + access_token: Some("my-token".to_string()), + insecure: false, + verbose: 1, + quiet: false, + color: clap::ColorChoice::Auto, + }; + assert_eq!(get_access_token(&args).unwrap(), "my-token"); + } + + #[test] + fn get_access_token_missing() { + env::remove_var("PORTAINER_ACCESS_TOKEN"); + let args = GlobalArgs { + url: None, + access_token: None, + insecure: false, + verbose: 1, + quiet: false, + color: clap::ColorChoice::Auto, + }; + assert!(get_access_token(&args).is_err()); + } + + // --- build_table tests --- + + #[derive(Debug, serde::Serialize)] + struct TestItem { + id: u32, + name: String, + } + + #[test] + fn build_table_empty() { + let items: Vec = vec![]; + let table = build_table(&items, None); + assert_eq!(table.len(), 0); + } + + #[test] + fn build_table_with_items() { + let items = vec![ + TestItem { + id: 1, + name: "foo".to_string(), + }, + TestItem { + id: 2, + name: "bar".to_string(), + }, + ]; + let table = build_table(&items, None); + assert_eq!(table.len(), 2); + } + + #[test] + fn build_table_with_column_filter() { + let items = vec![TestItem { + id: 1, + name: "foo".to_string(), + }]; + let table = build_table(&items, Some(&["name"])); + assert_eq!(table.len(), 1); + } + + // --- create_client tests --- + + #[test] + fn create_client_valid() { + let client = create_client("test-token", false); + assert!(client.is_ok()); + } + + // --- CliError display --- + + #[test] + fn cli_error_display() { + let err = CliError::Config("test".to_string()); + assert!(err.to_string().contains("test")); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 94051b8..d36d7c5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,14 +1,17 @@ mod consts; mod endpoints; +pub(crate) mod error; mod helpers; mod stacks; mod teams; mod users; mod wrpt; +use crate::commands::helpers::CliContext; use crate::commands::wrpt::{init_logger, WrptArgs}; use crate::commands::Command::{Endpoint, Stack, Team, User}; use clap::{Parser, Subcommand}; +use simplelog::error; #[derive(Debug, Subcommand)] pub(crate) enum Command { @@ -30,10 +33,18 @@ pub fn init() -> Result<(), ()> { init_logger(&args); - match args.command { - Endpoint(command) => endpoints::handler(command, args.global_args), - Stack(command) => stacks::handler(command, args.global_args), - Team(command) => teams::handler(command, args.global_args), - User(command) => users::handler(command, args.global_args), - } + let ctx = CliContext::from_global_args(&args.global_args).map_err(|e| { + error!("{}", e); + })?; + + let result = match args.command { + Endpoint(command) => endpoints::handler(command, &ctx), + Stack(command) => stacks::handler(command, &ctx), + Team(command) => teams::handler(command, &ctx), + User(command) => users::handler(command, &ctx), + }; + + result.map_err(|e| { + error!("{}", e); + }) } diff --git a/src/commands/stacks/handlers/deploy.rs b/src/commands/stacks/handlers/deploy.rs index 329c9cf..1d69c4b 100644 --- a/src/commands/stacks/handlers/deploy.rs +++ b/src/commands/stacks/handlers/deploy.rs @@ -1,49 +1,40 @@ use crate::commands::consts; +use crate::commands::error::CliError; use crate::commands::helpers::{ - build_table, construct_url, create_client, get_access_token, get_base_url, - get_stack_id_from_name, get_swarm_id_from_endpoint_id, parse_api_response, parse_env_file, + build_table, construct_url, get_stack_id_from_name, get_swarm_id_from_endpoint_id, + parse_api_response, parse_env_file, CliContext, }; use crate::commands::stacks::args::deploy::StackDeployCommand; use crate::commands::stacks::models::deploy::{ Stack, StackDeployStandaloneCreatePayload, StackDeploySwarmCreatePayload, StackDeployUpdatePayload, }; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; use simplelog::{debug, info}; use std::fs; -pub(crate) fn handler(command: StackDeployCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: StackDeployCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - - let stack_file_content = - fs::read_to_string(command.compose_file).log_expect("Unable to read `compose-file`"); + let stack_file_content = fs::read_to_string(&command.compose_file).map_err(|e| { + CliError::Io(format!( + "unable to read compose file \"{}\": {}", + command.compose_file.display(), + e + )) + })?; debug!("stack_file_content = {:?}", stack_file_content); let env_file = parse_env_file(command.env_file).unwrap_or_default(); debug!("env_file = {:?}", env_file); info!("Getting stack info..."); - let stack_id = get_stack_id_from_name( - command.stack_name.as_str(), - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; + let stack_id = get_stack_id_from_name(ctx, command.stack_name.as_str())?; let stack: Vec = if stack_id.is_none() { info!("Stack \"{}\" does not exist", command.stack_name); info!("Getting Docker info..."); - let swarm_id = get_swarm_id_from_endpoint_id( - command.endpoint, - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - ); + let swarm_id = get_swarm_id_from_endpoint_id(ctx, command.endpoint)?; match swarm_id { Some(swarm_id) => { @@ -61,12 +52,10 @@ pub(crate) fn handler(command: StackDeployCommand, global_args: GlobalArgs) -> R info!("Creating Swarm stack \"{}\"", command.stack_name); create_stack( - base_url.as_str(), - access_token.as_str(), + ctx, stack_create_payload, command.endpoint, consts::ENDPOINT_STACKS_CREATE_SWARM_STRING, - global_args.insecure, )? } None => { @@ -83,12 +72,10 @@ pub(crate) fn handler(command: StackDeployCommand, global_args: GlobalArgs) -> R info!("Creating standalone stack \"{}\"", command.stack_name); create_stack( - base_url.as_str(), - access_token.as_str(), + ctx, stack_create_payload, command.endpoint, consts::ENDPOINT_STACKS_CREATE_STANDALONE_STRING, - global_args.insecure, )? } } @@ -110,12 +97,10 @@ pub(crate) fn handler(command: StackDeployCommand, global_args: GlobalArgs) -> R info!("Updating stack \"{}\"", command.stack_name); update_stack( - base_url.as_str(), - access_token.as_str(), + ctx, stack_update_payload, stack_id.unwrap_or_default(), command.endpoint, - global_args.insecure, )? }; @@ -143,51 +128,44 @@ pub(crate) fn handler(command: StackDeployCommand, global_args: GlobalArgs) -> R } pub(crate) fn create_stack( - base_url: &str, - access_token: &str, + ctx: &CliContext, stack_create_payload: T, - entrypoint_id: u32, + endpoint_id: u32, endpoint: &str, - insecure: bool, -) -> Result, ()> { - let url = construct_url(base_url, endpoint).log_expect("failed to construct url"); +) -> Result, CliError> { + let url = construct_url(&ctx.base_url, endpoint)?; debug!("request = POST {:?}", url.as_str()); - let response = create_client(access_token, insecure) + let response = ctx + .client .post(url) .json(&stack_create_payload) - .query(&[("endpointId", entrypoint_id)]) - .send() - .log_expect("invalid response from API"); + .query(&[("endpointId", endpoint_id)]) + .send()?; parse_api_response(response) } pub(crate) fn update_stack( - base_url: &str, - access_token: &str, + ctx: &CliContext, stack_update_payload: StackDeployUpdatePayload, stack_id: u32, - entrypoint_id: u32, - insecure: bool, -) -> Result, ()> { + endpoint_id: u32, +) -> Result, CliError> { let url = construct_url( - base_url, - consts::ENDPOINT_STACKS_UPDATE - .replace("{id}", stack_id.to_string().as_str()) - .as_str(), - ) - .log_expect("failed to construct url"); + &ctx.base_url, + &consts::ENDPOINT_STACKS_UPDATE.replace("{id}", &stack_id.to_string()), + )?; debug!("request = PUT {:?}", url.as_str()); - let response = create_client(access_token, insecure) + let response = ctx + .client .put(url) .json(&stack_update_payload) - .query(&[("endpointId", entrypoint_id)]) - .send() - .log_expect("invalid response from API"); + .query(&[("endpointId", endpoint_id)]) + .send()?; parse_api_response(response) } diff --git a/src/commands/stacks/handlers/list.rs b/src/commands/stacks/handlers/list.rs index 49dbfab..9894034 100644 --- a/src/commands/stacks/handlers/list.rs +++ b/src/commands/stacks/handlers/list.rs @@ -1,24 +1,14 @@ use crate::commands::consts; -use crate::commands::helpers::{ - build_table, construct_url, create_client, get_access_token, get_base_url, parse_api_response, -}; +use crate::commands::error::CliError; +use crate::commands::helpers::{build_table, construct_url, parse_api_response, CliContext}; use crate::commands::stacks::args::list::StackListCommand; use crate::commands::stacks::models::list::StackList; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; use simplelog::debug; -pub(crate) fn handler(command: StackListCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: StackListCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - - let stacks = fetch_stacks( - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; + let stacks = fetch_stacks(ctx)?; build_table( &stacks, @@ -41,20 +31,12 @@ pub(crate) fn handler(command: StackListCommand, global_args: GlobalArgs) -> Res Ok(()) } -pub(crate) fn fetch_stacks( - base_url: &str, - access_token: &str, - insecure: bool, -) -> Result, ()> { - let url = - construct_url(base_url, consts::ENDPOINT_STACKS).log_expect("failed to construct url"); +pub(crate) fn fetch_stacks(ctx: &CliContext) -> Result, CliError> { + let url = construct_url(&ctx.base_url, consts::ENDPOINT_STACKS)?; debug!("request = GET {:?}", url.as_str()); - let response = create_client(access_token, insecure) - .get(url) - .send() - .log_expect("invalid response from API"); + let response = ctx.client.get(url).send()?; parse_api_response(response) } diff --git a/src/commands/stacks/handlers/remove.rs b/src/commands/stacks/handlers/remove.rs index 3dc7ab8..5546753 100644 --- a/src/commands/stacks/handlers/remove.rs +++ b/src/commands/stacks/handlers/remove.rs @@ -1,75 +1,38 @@ use crate::commands::consts; -use crate::commands::helpers::{ - construct_url, create_client, get_access_token, get_base_url, get_stack_id_from_name, - handle_api_response, -}; +use crate::commands::error::CliError; +use crate::commands::helpers::{construct_url, handle_api_response, resolve_stack, CliContext}; use crate::commands::stacks::args::remove::StackRemoveCommand; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; -use simplelog::{debug, error, info}; +use simplelog::{debug, info}; -pub(crate) fn handler(command: StackRemoveCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: StackRemoveCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - info!("Getting stack info..."); - let stack_id = get_stack_id_from_name( - command.stack_name.as_str(), - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; - - if stack_id.is_none() { - error!("Stack \"{}\" does not exist", command.stack_name); - - return Err(()); - } + let stack_id = resolve_stack(ctx, &command.stack_name)?; info!( "Stack \"{}\" exists (id = {})", - command.stack_name, - stack_id.unwrap_or_default() + command.stack_name, stack_id ); info!("Deleting stack \"{}\"", command.stack_name); - remove_stack( - base_url.as_str(), - access_token.as_str(), - stack_id.unwrap_or_default(), - command.endpoint, - global_args.insecure, - ); - - info!("Done"); - Ok(()) -} - -pub(crate) fn remove_stack( - base_url: &str, - access_token: &str, - stack_id: u32, - entrypoint_id: u32, - insecure: bool, -) { let url = construct_url( - base_url, - consts::ENDPOINT_STACKS_REMOVE - .replace("{id}", stack_id.to_string().as_str()) - .as_str(), - ) - .log_expect("failed to construct url"); + &ctx.base_url, + &consts::ENDPOINT_STACKS_REMOVE.replace("{id}", &stack_id.to_string()), + )?; debug!("request = DELETE {:?}", url.as_str()); - let response = create_client(access_token, insecure) + let response = ctx + .client .delete(url) - .query(&[("endpointId", entrypoint_id)]) - .send() - .log_expect("invalid response from API"); + .query(&[("endpointId", command.endpoint)]) + .send()?; + + handle_api_response(response)?; - let _ = handle_api_response(response); + info!("Done"); + + Ok(()) } diff --git a/src/commands/stacks/handlers/resource_control.rs b/src/commands/stacks/handlers/resource_control.rs index 6c2717b..b8b717e 100644 --- a/src/commands/stacks/handlers/resource_control.rs +++ b/src/commands/stacks/handlers/resource_control.rs @@ -1,56 +1,45 @@ use crate::commands::consts; +use crate::commands::error::CliError; use crate::commands::helpers::{ - build_table, construct_url, create_client, get_access_token, get_base_url, - get_stack_id_from_name, parse_api_response, + build_table, construct_url, parse_api_response, resolve_stack, CliContext, }; use crate::commands::stacks::args::resource_control::StackResourceControlCommand; use crate::commands::stacks::models::deploy::Stack; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; use simplelog::{debug, error, info}; pub(crate) fn handler( command: StackResourceControlCommand, - global_args: GlobalArgs, -) -> Result<(), ()> { + ctx: &CliContext, +) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - info!("Getting stack info..."); - let stack_id = get_stack_id_from_name( - command.stack_name.as_str(), - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; - - if stack_id.is_none() { - error!("Stack \"{}\" does not exist", command.stack_name); - - return Err(()); - } + let stack_id = resolve_stack(ctx, &command.stack_name)?; info!( "Stack \"{}\" exists (id = {})", - command.stack_name, - stack_id.unwrap_or_default() + command.stack_name, stack_id ); info!( "Display the ResourceControl details of stack \"{}\"", command.stack_name ); - let stack = inspect_stack( - base_url.as_str(), - access_token.as_str(), - stack_id.unwrap_or_default(), - command.endpoint, - global_args.insecure, - )?; + let stack = inspect_stack(ctx, stack_id, command.endpoint)?; - let resource_control = &stack.first().ok_or(())?.resource_control; + let stack = stack.first().ok_or_else(|| { + CliError::Api(format!( + "no data returned for stack \"{}\"", + command.stack_name + )) + })?; + let resource_control = stack.resource_control.as_ref().ok_or_else(|| { + error!("Stack \"{}\" has no ResourceControl", command.stack_name); + CliError::Api(format!( + "stack \"{}\" has no ResourceControl", + command.stack_name + )) + })?; build_table(&[resource_control], None).printstd(); @@ -58,27 +47,22 @@ pub(crate) fn handler( } pub(crate) fn inspect_stack( - base_url: &str, - access_token: &str, + ctx: &CliContext, stack_id: u32, - entrypoint_id: u32, - insecure: bool, -) -> Result, ()> { + endpoint_id: u32, +) -> Result, CliError> { let url = construct_url( - base_url, - consts::ENDPOINT_STACKS_INSPECT - .replace("{id}", stack_id.to_string().as_str()) - .as_str(), - ) - .log_expect("failed to construct url"); + &ctx.base_url, + &consts::ENDPOINT_STACKS_INSPECT.replace("{id}", &stack_id.to_string()), + )?; debug!("request = GET {:?}", url.as_str()); - let response = create_client(access_token, insecure) + let response = ctx + .client .get(url) - .query(&[("endpointId", entrypoint_id)]) - .send() - .log_expect("invalid response from API"); + .query(&[("endpointId", endpoint_id)]) + .send()?; parse_api_response(response) } diff --git a/src/commands/stacks/handlers/start.rs b/src/commands/stacks/handlers/start.rs index 17039b6..234359d 100644 --- a/src/commands/stacks/handlers/start.rs +++ b/src/commands/stacks/handlers/start.rs @@ -1,75 +1,38 @@ use crate::commands::consts; -use crate::commands::helpers::{ - construct_url, create_client, get_access_token, get_base_url, get_stack_id_from_name, - handle_api_response, -}; +use crate::commands::error::CliError; +use crate::commands::helpers::{construct_url, handle_api_response, resolve_stack, CliContext}; use crate::commands::stacks::args::start::StackStartCommand; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; -use simplelog::{debug, error, info}; +use simplelog::{debug, info}; -pub(crate) fn handler(command: StackStartCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: StackStartCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - info!("Getting stack info..."); - let stack_id = get_stack_id_from_name( - command.stack_name.as_str(), - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; - - if stack_id.is_none() { - error!("Stack \"{}\" does not exist", command.stack_name); - - return Err(()); - } + let stack_id = resolve_stack(ctx, &command.stack_name)?; info!( "Stack \"{}\" exists (id = {})", - command.stack_name, - stack_id.unwrap_or_default() + command.stack_name, stack_id ); info!("Starting stack \"{}\"", command.stack_name); - start_stack( - base_url.as_str(), - access_token.as_str(), - stack_id.unwrap_or_default(), - command.endpoint, - global_args.insecure, - ); - - info!("Done"); - Ok(()) -} - -pub(crate) fn start_stack( - base_url: &str, - access_token: &str, - stack_id: u32, - entrypoint_id: u32, - insecure: bool, -) { let url = construct_url( - base_url, - consts::ENDPOINT_STACKS_START - .replace("{id}", stack_id.to_string().as_str()) - .as_str(), - ) - .log_expect("failed to construct url"); + &ctx.base_url, + &consts::ENDPOINT_STACKS_START.replace("{id}", &stack_id.to_string()), + )?; debug!("request = POST {:?}", url.as_str()); - let response = create_client(access_token, insecure) + let response = ctx + .client .post(url) - .query(&[("endpointId", entrypoint_id)]) - .send() - .log_expect("invalid response from API"); + .query(&[("endpointId", command.endpoint)]) + .send()?; + + handle_api_response(response)?; - let _ = handle_api_response(response); + info!("Done"); + + Ok(()) } diff --git a/src/commands/stacks/handlers/stop.rs b/src/commands/stacks/handlers/stop.rs index 7b1b32d..9215e25 100644 --- a/src/commands/stacks/handlers/stop.rs +++ b/src/commands/stacks/handlers/stop.rs @@ -1,75 +1,38 @@ use crate::commands::consts; -use crate::commands::helpers::{ - construct_url, create_client, get_access_token, get_base_url, get_stack_id_from_name, - handle_api_response, -}; +use crate::commands::error::CliError; +use crate::commands::helpers::{construct_url, handle_api_response, resolve_stack, CliContext}; use crate::commands::stacks::args::stop::StackStopCommand; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; -use simplelog::{debug, error, info}; +use simplelog::{debug, info}; -pub(crate) fn handler(command: StackStopCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: StackStopCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - info!("Getting stack info..."); - let stack_id = get_stack_id_from_name( - command.stack_name.as_str(), - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; - - if stack_id.is_none() { - error!("Stack \"{}\" does not exist", command.stack_name); - - return Err(()); - } + let stack_id = resolve_stack(ctx, &command.stack_name)?; info!( "Stack \"{}\" exists (id = {})", - command.stack_name, - stack_id.unwrap_or_default() + command.stack_name, stack_id ); info!("Stopping stack \"{}\"", command.stack_name); - stop_stack( - base_url.as_str(), - access_token.as_str(), - stack_id.unwrap_or_default(), - command.endpoint, - global_args.insecure, - ); - - info!("Done"); - Ok(()) -} - -pub(crate) fn stop_stack( - base_url: &str, - access_token: &str, - stack_id: u32, - entrypoint_id: u32, - insecure: bool, -) { let url = construct_url( - base_url, - consts::ENDPOINT_STACKS_STOP - .replace("{id}", stack_id.to_string().as_str()) - .as_str(), - ) - .log_expect("failed to construct url"); + &ctx.base_url, + &consts::ENDPOINT_STACKS_STOP.replace("{id}", &stack_id.to_string()), + )?; debug!("request = POST {:?}", url.as_str()); - let response = create_client(access_token, insecure) + let response = ctx + .client .post(url) - .query(&[("endpointId", entrypoint_id)]) - .send() - .log_expect("invalid response from API"); + .query(&[("endpointId", command.endpoint)]) + .send()?; + + handle_api_response(response)?; - let _ = handle_api_response(response); + info!("Done"); + + Ok(()) } diff --git a/src/commands/stacks/mod.rs b/src/commands/stacks/mod.rs index 5c64f4b..fca1fa5 100644 --- a/src/commands/stacks/mod.rs +++ b/src/commands/stacks/mod.rs @@ -2,20 +2,21 @@ pub mod args; pub mod handlers; pub mod models; +use crate::commands::error::CliError; +use crate::commands::helpers::CliContext; use crate::commands::stacks::args::{StackCommand, StackSubCommand}; -use crate::commands::wrpt::GlobalArgs; -pub fn handler(stack: StackCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub fn handler(stack: StackCommand, ctx: &CliContext) -> Result<(), CliError> { let command = stack.command; match command { - StackSubCommand::List(command) => handlers::list::handler(command, global_args), - StackSubCommand::Deploy(command) => handlers::deploy::handler(command, global_args), - StackSubCommand::Remove(command) => handlers::remove::handler(command, global_args), + StackSubCommand::List(command) => handlers::list::handler(command, ctx), + StackSubCommand::Deploy(command) => handlers::deploy::handler(command, ctx), + StackSubCommand::Remove(command) => handlers::remove::handler(command, ctx), StackSubCommand::ResourceControl(command) => { - handlers::resource_control::handler(command, global_args) + handlers::resource_control::handler(command, ctx) } - StackSubCommand::Start(command) => handlers::start::handler(command, global_args), - StackSubCommand::Stop(command) => handlers::stop::handler(command, global_args), + StackSubCommand::Start(command) => handlers::start::handler(command, ctx), + StackSubCommand::Stop(command) => handlers::stop::handler(command, ctx), } } diff --git a/src/commands/stacks/models/deploy.rs b/src/commands/stacks/models/deploy.rs index 95ce7b6..b491b67 100644 --- a/src/commands/stacks/models/deploy.rs +++ b/src/commands/stacks/models/deploy.rs @@ -59,3 +59,71 @@ pub(crate) struct Stack { pub(crate) updated_by: Option, pub(crate) resource_control: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stack_deserialize_with_resource_control() { + let json = r#"{ + "Id": 1, + "Name": "app", + "Env": [], + "FromAppTemplate": false, + "SwarmId": "", + "Type": 2, + "Status": 1, + "EndpointId": 3, + "CreationDate": 1700000000, + "CreatedBy": "admin", + "UpdateDate": null, + "UpdatedBy": null, + "ResourceControl": { + "Id": 1, + "ResourceId": "1_app", + "Type": 6, + "UserAccesses": [], + "TeamAccesses": [], + "Public": true + } + }"#; + let stack: Stack = serde_json::from_str(json).unwrap(); + assert_eq!(stack.id, 1); + assert_eq!(stack.name, "app"); + assert!(stack.resource_control.is_some()); + } + + #[test] + fn stack_deserialize_without_resource_control() { + let json = r#"{ + "Id": 2, + "Name": "test", + "Env": [{"name": "KEY", "value": "val"}], + "FromAppTemplate": false, + "SwarmId": "swarm-123", + "Type": 1, + "Status": 1, + "EndpointId": 1, + "CreationDate": 1700000000, + "CreatedBy": "user", + "UpdateDate": 1700001000, + "UpdatedBy": "user", + "ResourceControl": null + }"#; + let stack: Stack = serde_json::from_str(json).unwrap(); + assert_eq!(stack.id, 2); + assert!(stack.resource_control.is_none()); + } + + #[test] + fn env_var_serialize() { + let env = EnvVar { + name: "KEY".to_string(), + value: "value".to_string(), + }; + let json = serde_json::to_string(&env).unwrap(); + assert!(json.contains("\"name\":\"KEY\"")); + assert!(json.contains("\"value\":\"value\"")); + } +} diff --git a/src/commands/stacks/models/list.rs b/src/commands/stacks/models/list.rs index 26fa2af..9c5b259 100644 --- a/src/commands/stacks/models/list.rs +++ b/src/commands/stacks/models/list.rs @@ -10,15 +10,73 @@ use serde::{Deserialize, Serialize}; pub(crate) struct StackList { pub(crate) id: u32, pub(crate) name: String, - r#type: StackType, - status: StackStatus, - swarm_id: String, - endpoint_id: u32, + pub(crate) r#type: StackType, + pub(crate) status: StackStatus, + pub(crate) swarm_id: String, + pub(crate) endpoint_id: u32, #[serde(deserialize_with = "from_ts")] - creation_date: DateTime, - created_by: String, + pub(crate) creation_date: DateTime, + pub(crate) created_by: String, #[serde(deserialize_with = "from_ts_option")] - update_date: Option>, - updated_by: Option, - resource_control: ResourceControl, + pub(crate) update_date: Option>, + pub(crate) updated_by: Option, + pub(crate) resource_control: ResourceControl, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stack_list_deserialize() { + let json = r#"{ + "Id": 1, + "Name": "my-stack", + "Type": 2, + "Status": 1, + "SwarmId": "", + "EndpointId": 5, + "CreationDate": 1700000000, + "CreatedBy": "admin", + "UpdateDate": 1700001000, + "UpdatedBy": "admin", + "ResourceControl": { + "Id": 10, + "ResourceId": "1_my-stack", + "Type": 6, + "UserAccesses": [], + "TeamAccesses": [], + "Public": true + } + }"#; + let stack: StackList = serde_json::from_str(json).unwrap(); + assert_eq!(stack.id, 1); + assert_eq!(stack.name, "my-stack"); + } + + #[test] + fn stack_list_deserialize_null_update_date() { + let json = r#"{ + "Id": 2, + "Name": "test", + "Type": 1, + "Status": 2, + "SwarmId": "abc123", + "EndpointId": 1, + "CreationDate": 1700000000, + "CreatedBy": "user", + "UpdateDate": null, + "UpdatedBy": null, + "ResourceControl": { + "Id": 5, + "ResourceId": "2_test", + "Type": 6, + "UserAccesses": [], + "TeamAccesses": [], + "Public": false + } + }"#; + let stack: StackList = serde_json::from_str(json).unwrap(); + assert_eq!(stack.id, 2); + } } diff --git a/src/commands/stacks/models/mod.rs b/src/commands/stacks/models/mod.rs index 378fa1e..2b40aa9 100644 --- a/src/commands/stacks/models/mod.rs +++ b/src/commands/stacks/models/mod.rs @@ -22,3 +22,36 @@ pub(crate) enum StackType { #[serde(other)] Unknown = 0, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stack_status_deserialize_known() { + let active: StackStatus = serde_json::from_str("1").unwrap(); + assert_eq!(active, StackStatus::Active); + let inactive: StackStatus = serde_json::from_str("2").unwrap(); + assert_eq!(inactive, StackStatus::Inactive); + } + + #[test] + fn stack_status_deserialize_unknown() { + let unknown: StackStatus = serde_json::from_str("99").unwrap(); + assert_eq!(unknown, StackStatus::Unknown); + } + + #[test] + fn stack_type_deserialize_known() { + let swarm: StackType = serde_json::from_str("1").unwrap(); + assert_eq!(swarm, StackType::Swarm); + let compose: StackType = serde_json::from_str("2").unwrap(); + assert_eq!(compose, StackType::Compose); + } + + #[test] + fn stack_type_deserialize_unknown() { + let unknown: StackType = serde_json::from_str("99").unwrap(); + assert_eq!(unknown, StackType::Unknown); + } +} diff --git a/src/commands/teams/handlers/list.rs b/src/commands/teams/handlers/list.rs index 0c75033..360d226 100644 --- a/src/commands/teams/handlers/list.rs +++ b/src/commands/teams/handlers/list.rs @@ -1,43 +1,26 @@ use crate::commands::consts; -use crate::commands::helpers::{ - build_table, construct_url, create_client, get_access_token, get_base_url, parse_api_response, -}; +use crate::commands::error::CliError; +use crate::commands::helpers::{build_table, construct_url, parse_api_response, CliContext}; use crate::commands::teams::args::list::TeamListCommand; use crate::commands::teams::models::list::TeamList; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; use simplelog::debug; -pub(crate) fn handler(command: TeamListCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: TeamListCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - - let teams = fetch_teams( - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; + let teams = fetch_teams(ctx)?; build_table(&teams, None).printstd(); Ok(()) } -pub(crate) fn fetch_teams( - base_url: &str, - access_token: &str, - insecure: bool, -) -> Result, ()> { - let url = construct_url(base_url, consts::ENDPOINT_TEAMS).log_expect("failed to construct url"); +pub(crate) fn fetch_teams(ctx: &CliContext) -> Result, CliError> { + let url = construct_url(&ctx.base_url, consts::ENDPOINT_TEAMS)?; debug!("request = GET {:?}", url.as_str()); - let response = create_client(access_token, insecure) - .get(url) - .send() - .log_expect("invalid response from API"); + let response = ctx.client.get(url).send()?; parse_api_response(response) } diff --git a/src/commands/teams/mod.rs b/src/commands/teams/mod.rs index e4dc244..5aeb42a 100644 --- a/src/commands/teams/mod.rs +++ b/src/commands/teams/mod.rs @@ -2,13 +2,14 @@ pub mod args; pub mod handlers; pub mod models; +use crate::commands::error::CliError; +use crate::commands::helpers::CliContext; use crate::commands::teams::args::{TeamCommand, TeamSubCommand}; -use crate::commands::wrpt::GlobalArgs; -pub fn handler(endpoint: TeamCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub fn handler(endpoint: TeamCommand, ctx: &CliContext) -> Result<(), CliError> { let command = endpoint.command; match command { - TeamSubCommand::List(command) => handlers::list::handler(command, global_args), + TeamSubCommand::List(command) => handlers::list::handler(command, ctx), } } diff --git a/src/commands/teams/models/list.rs b/src/commands/teams/models/list.rs index fe2d696..0ac6b10 100644 --- a/src/commands/teams/models/list.rs +++ b/src/commands/teams/models/list.rs @@ -3,6 +3,19 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub(crate) struct TeamList { - id: u32, - name: String, + pub(crate) id: u32, + pub(crate) name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn team_list_deserialize() { + let json = r#"{"Id": 1, "Name": "developers"}"#; + let team: TeamList = serde_json::from_str(json).unwrap(); + assert_eq!(team.id, 1); + assert_eq!(team.name, "developers"); + } } diff --git a/src/commands/users/handlers/list.rs b/src/commands/users/handlers/list.rs index 7975be3..c77eb35 100644 --- a/src/commands/users/handlers/list.rs +++ b/src/commands/users/handlers/list.rs @@ -1,43 +1,26 @@ use crate::commands::consts; -use crate::commands::helpers::{ - build_table, construct_url, create_client, get_access_token, get_base_url, parse_api_response, -}; +use crate::commands::error::CliError; +use crate::commands::helpers::{build_table, construct_url, parse_api_response, CliContext}; use crate::commands::users::args::list::UserListCommand; use crate::commands::users::models::list::User; -use crate::commands::wrpt::GlobalArgs; -use log_err::LogErrResult; use simplelog::debug; -pub(crate) fn handler(command: UserListCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub(crate) fn handler(command: UserListCommand, ctx: &CliContext) -> Result<(), CliError> { debug!("command = {:?}", command); - let base_url = get_base_url(&global_args)?; - let access_token = get_access_token(&global_args)?; - - let users = fetch_users( - base_url.as_str(), - access_token.as_str(), - global_args.insecure, - )?; + let users = fetch_users(ctx)?; build_table(&users, Some(&["Id", "Username", "Role"])).printstd(); Ok(()) } -pub(crate) fn fetch_users( - base_url: &str, - access_token: &str, - insecure: bool, -) -> Result, ()> { - let url = construct_url(base_url, consts::ENDPOINT_USERS).log_expect("failed to construct url"); +pub(crate) fn fetch_users(ctx: &CliContext) -> Result, CliError> { + let url = construct_url(&ctx.base_url, consts::ENDPOINT_USERS)?; debug!("request = GET {:?}", url.as_str()); - let response = create_client(access_token, insecure) - .get(url) - .send() - .log_expect("invalid response from API"); + let response = ctx.client.get(url).send()?; parse_api_response(response) } diff --git a/src/commands/users/mod.rs b/src/commands/users/mod.rs index 2caacbf..cc2cf70 100644 --- a/src/commands/users/mod.rs +++ b/src/commands/users/mod.rs @@ -2,13 +2,14 @@ pub mod args; pub mod handlers; pub mod models; +use crate::commands::error::CliError; +use crate::commands::helpers::CliContext; use crate::commands::users::args::{UserCommand, UserSubCommand}; -use crate::commands::wrpt::GlobalArgs; -pub fn handler(endpoint: UserCommand, global_args: GlobalArgs) -> Result<(), ()> { +pub fn handler(endpoint: UserCommand, ctx: &CliContext) -> Result<(), CliError> { let command = endpoint.command; match command { - UserSubCommand::List(command) => handlers::list::handler(command, global_args), + UserSubCommand::List(command) => handlers::list::handler(command, ctx), } } diff --git a/src/commands/users/models/list.rs b/src/commands/users/models/list.rs index 15bd7b1..b7868f4 100644 --- a/src/commands/users/models/list.rs +++ b/src/commands/users/models/list.rs @@ -4,9 +4,9 @@ use serde_repr::Deserialize_repr; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub(crate) struct User { - id: u32, - username: String, - role: UserRole, + pub(crate) id: u32, + pub(crate) username: String, + pub(crate) role: UserRole, } #[derive(Debug, Serialize, Deserialize_repr, PartialEq)] @@ -17,3 +17,31 @@ pub(crate) enum UserRole { #[serde(other)] Unknown = 0, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_deserialize() { + let json = r#"{"Id": 1, "Username": "admin", "Role": 1}"#; + let user: User = serde_json::from_str(json).unwrap(); + assert_eq!(user.id, 1); + assert_eq!(user.username, "admin"); + assert_eq!(user.role, UserRole::Administrator); + } + + #[test] + fn user_role_deserialize_unknown() { + let unknown: UserRole = serde_json::from_str("99").unwrap(); + assert_eq!(unknown, UserRole::Unknown); + } + + #[test] + fn user_role_deserialize_known() { + let admin: UserRole = serde_json::from_str("1").unwrap(); + assert_eq!(admin, UserRole::Administrator); + let user: UserRole = serde_json::from_str("2").unwrap(); + assert_eq!(user, UserRole::User); + } +}