diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c16c249..6fadd2b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,16 @@ "Read(//c/msys64/mingw64/bin/**)", "Read(//c/msys64/clang64/bin/**)", "Bash(cargo doc:*)", - "Bash(grep -r impl.*Read.*for.*Response c:/LocalGitRepos/stackql/stackql-deploy-rs/target/doc/reqwest/blocking/struct.Response.html)" + "Bash(grep -r impl.*Read.*for.*Response c:/LocalGitRepos/stackql/stackql-deploy-rs/target/doc/reqwest/blocking/struct.Response.html)", + "Bash(grep -r \"multiarch\\\\|darwin\\\\|amd64\\\\|architecture\" --include=*.toml --include=*.rs --include=*.md)", + "WebFetch(domain:releases.stackql.io)", + "Bash(curl -sI -L \"https://storage.googleapis.com/stackql-public-releases/latest/stackql_darwin_multiarch.pkg\")", + "Bash(curl -sI -L \"https://releases.stackql.io/stackql/latest/stackql_linux_arm64.zip\")", + "Bash(curl -sI -L \"https://releases.stackql.io/stackql/latest/stackql_darwin_multiarch.pkg\")", + "Bash(python -c \"import yaml; yaml.safe_load\\(open\\(''.github/workflows/ci.yml''\\)\\); print\\(''ci.yml OK''\\)\")", + "Bash(python -c \"import yaml; yaml.safe_load\\(open\\(''.github/workflows/release.yml''\\)\\); print\\(''release.yml OK''\\)\")", + "Bash(cargo fmt:*)", + "Bash(rustfmt --check src/utils/download.rs src/utils/server.rs)" ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24d09c4..a20c16b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ['**'] + branches: [main] paths: - 'src/**' - 'build.rs' @@ -23,10 +23,10 @@ env: jobs: - # Runs on: push to any non-main branch, PR to main + # Runs on: PR to main lint: name: Lint - if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref != 'refs/heads/main') + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -37,10 +37,10 @@ jobs: - name: Run lint run: bash ci-scripts/lint.sh - # Runs on: push to any non-main branch, PR to main + # Runs on: PR to main test: name: Tests - if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref != 'refs/heads/main') + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -49,7 +49,7 @@ jobs: - name: Run tests run: bash ci-scripts/test.sh - # Runs on: PR to main only — single-platform release build to catch compile errors pre-merge + # Runs on: PR to main only — fast compile check (no codegen) to catch errors pre-merge build-check: name: Build Check if: github.event_name == 'pull_request' @@ -61,8 +61,8 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Create placeholder contributors file run: touch contributors.csv - - name: Build release - run: bash ci-scripts/build.sh + - name: Check compilation + run: cargo check --release # Runs on: push to main — fetch contributors for build-time injection prepare: @@ -170,3 +170,59 @@ jobs: name: ${{ matrix.artifact-name }} path: ${{ matrix.artifact-name }}.* if-no-files-found: error + + # Runs on: push to main — runtime smoke test on each platform after build + runtime-test: + name: Runtime Test (${{ matrix.os }}) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact-name: stackql-deploy-linux-x86_64 + archive: tar.gz + - os: windows-latest + artifact-name: stackql-deploy-windows-x86_64 + archive: zip + - os: macos-latest + artifact-name: stackql-deploy-macos-arm64 + archive: tar.gz + - os: macos-13 + artifact-name: stackql-deploy-macos-x86_64 + archive: tar.gz + runs-on: ${{ matrix.os }} + steps: + - uses: actions/download-artifact@v8 + with: + name: ${{ matrix.artifact-name }} + - name: Extract binary (tar.gz) + if: matrix.archive == 'tar.gz' + run: tar -xzf ${{ matrix.artifact-name }}.tar.gz + - name: Extract binary (zip) + if: matrix.archive == 'zip' + shell: pwsh + run: Expand-Archive -Path ${{ matrix.artifact-name }}.zip -DestinationPath . + - name: Run smoke test + shell: bash + run: | + set -e + + echo "=== Test 1: stackql-deploy info (forces stackql download) ===" + ./stackql-deploy info + + echo "" + echo "=== Test 2: stackql-deploy start-server ===" + ./stackql-deploy start-server + + echo "" + echo "=== Test 3: stackql-deploy info (verify server running) ===" + ./stackql-deploy info + + echo "" + echo "=== Test 4: stackql-deploy stop-server ===" + ./stackql-deploy stop-server + + echo "" + echo "=== All smoke tests passed ===" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a4702a..0b10bb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,9 +132,64 @@ jobs: path: ${{ matrix.artifact-name }}.* if-no-files-found: error + # Runtime smoke test on each platform before releasing + runtime-test: + name: Runtime Test (${{ matrix.os }}) + needs: build + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact-name: stackql-deploy-linux-x86_64 + archive: tar.gz + - os: windows-latest + artifact-name: stackql-deploy-windows-x86_64 + archive: zip + - os: macos-latest + artifact-name: stackql-deploy-macos-arm64 + archive: tar.gz + - os: macos-13 + artifact-name: stackql-deploy-macos-x86_64 + archive: tar.gz + runs-on: ${{ matrix.os }} + steps: + - uses: actions/download-artifact@v8 + with: + name: ${{ matrix.artifact-name }} + - name: Extract binary (tar.gz) + if: matrix.archive == 'tar.gz' + run: tar -xzf ${{ matrix.artifact-name }}.tar.gz + - name: Extract binary (zip) + if: matrix.archive == 'zip' + shell: pwsh + run: Expand-Archive -Path ${{ matrix.artifact-name }}.zip -DestinationPath . + - name: Run smoke test + shell: bash + run: | + set -e + + echo "=== Test 1: stackql-deploy info (forces stackql download) ===" + ./stackql-deploy info + + echo "" + echo "=== Test 2: stackql-deploy start-server ===" + ./stackql-deploy start-server + + echo "" + echo "=== Test 3: stackql-deploy info (verify server running) ===" + ./stackql-deploy info + + echo "" + echo "=== Test 4: stackql-deploy stop-server ===" + ./stackql-deploy stop-server + + echo "" + echo "=== All smoke tests passed ===" + release: name: Create GitHub Release - needs: build + needs: [build, runtime-test] runs-on: ubuntu-latest permissions: contents: write diff --git a/Cargo.lock b/Cargo.lock index a4de353..843f930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1809,7 +1809,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stackql-deploy" -version = "2.0.2" +version = "2.0.3" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a84c283..ab257de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "stackql-deploy" -version = "2.0.2" +version = "2.0.3" edition = "2021" rust-version = "1.75" description = "Infrastructure-as-code framework for declarative cloud resource management using StackQL" authors = ["StackQL Studios "] license = "MIT" homepage = "https://stackql.io" -repository = "https://github.com/stackql/stackql-deploy" +repository = "https://github.com/stackql/stackql-deploy-rs" keywords = ["stackql", "infrastructure", "iac", "cloud", "devops"] categories = ["command-line-utilities", "development-tools"] readme = "README.md" diff --git a/src/app.rs b/src/app.rs index 61ffecd..331e139 100644 --- a/src/app.rs +++ b/src/app.rs @@ -71,24 +71,8 @@ pub const STACKQL_BINARY_NAME: &str = "stackql.exe"; #[cfg(not(target_os = "windows"))] pub const STACKQL_BINARY_NAME: &str = "stackql"; -/// StackQL download URLs by platform -#[cfg_attr( - target_os = "windows", - doc = "StackQL download URL (platform dependent)" -)] -#[cfg(target_os = "windows")] -pub const STACKQL_DOWNLOAD_URL: &str = - "https://releases.stackql.io/stackql/latest/stackql_windows_amd64.zip"; - -#[cfg_attr(target_os = "linux", doc = "StackQL download URL (platform dependent)")] -#[cfg(target_os = "linux")] -pub const STACKQL_DOWNLOAD_URL: &str = - "https://releases.stackql.io/stackql/latest/stackql_linux_amd64.zip"; - -#[cfg_attr(target_os = "macos", doc = "StackQL download URL (platform dependent)")] -#[cfg(target_os = "macos")] -pub const STACKQL_DOWNLOAD_URL: &str = - "https://storage.googleapis.com/stackql-public-releases/latest/stackql_darwin_multiarch.pkg"; +/// Base URL for StackQL releases +pub const STACKQL_RELEASE_BASE_URL: &str = "https://releases.stackql.io/stackql/latest"; /// Commands exempt from binary check pub const EXEMPT_COMMANDS: [&str; 1] = ["init"]; diff --git a/src/utils/download.rs b/src/utils/download.rs index 3322e72..64f7807 100644 --- a/src/utils/download.rs +++ b/src/utils/download.rs @@ -32,13 +32,37 @@ use log::debug; use reqwest::blocking::Client; use zip::ZipArchive; -use crate::app::STACKQL_DOWNLOAD_URL; +use crate::app::STACKQL_RELEASE_BASE_URL; use crate::error::AppError; use crate::utils::platform::{get_platform, Platform}; -/// Retrieves the URL for downloading the StackQL binary. +/// Retrieves the URL for downloading the StackQL binary based on OS and architecture. pub fn get_download_url() -> Result { - Ok(STACKQL_DOWNLOAD_URL.to_string()) + let platform = get_platform(); + match platform { + Platform::MacOS => Ok(format!( + "{}/stackql_darwin_multiarch.pkg", + STACKQL_RELEASE_BASE_URL + )), + Platform::Windows => Ok(format!( + "{}/stackql_windows_amd64.zip", + STACKQL_RELEASE_BASE_URL + )), + Platform::Linux => { + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "amd64" + }; + Ok(format!( + "{}/stackql_linux_{}.zip", + STACKQL_RELEASE_BASE_URL, arch + )) + } + Platform::Unknown => Err(AppError::CommandFailed( + "Unsupported platform for stackql download".to_string(), + )), + } } /// Downloads the StackQL binary and extracts it to the current directory. @@ -134,20 +158,32 @@ fn extract_binary( } fs::create_dir_all(&unpacked_dir).map_err(AppError::IoError)?; - Command::new("pkgutil") + let output = Command::new("pkgutil") .arg("--expand-full") .arg(archive_path) .arg(&unpacked_dir) .output() .map_err(|e| AppError::CommandFailed(format!("Failed to extract pkg: {}", e)))?; - let extracted_binary = unpacked_dir - .join("payload") - .join("usr") - .join("local") - .join("bin") - .join("stackql"); - fs::copy(extracted_binary, &binary_path).map_err(AppError::IoError)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppError::CommandFailed(format!( + "pkgutil failed: {}", + stderr + ))); + } + + // Search for the stackql binary in the expanded pkg + // The structure can vary: payload/usr/local/bin/stackql or + // .pkg/payload/usr/local/bin/stackql + let extracted_binary = + find_file_recursive(&unpacked_dir, "stackql").ok_or_else(|| { + AppError::CommandFailed( + "Could not find stackql binary in extracted pkg".to_string(), + ) + })?; + + fs::copy(&extracted_binary, &binary_path).map_err(AppError::IoError)?; // Clean up fs::remove_dir_all(unpacked_dir).ok(); @@ -196,3 +232,20 @@ fn extract_binary( Ok(binary_path) } + +/// Recursively search for a file by name in a directory tree. +/// Returns the first match that is a regular file (not a directory). +fn find_file_recursive(dir: &Path, target_name: &str) -> Option { + let entries = fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_file_recursive(&path, target_name) { + return Some(found); + } + } else if path.file_name().and_then(|n| n.to_str()) == Some(target_name) { + return Some(path); + } + } + None +} diff --git a/src/utils/server.rs b/src/utils/server.rs index db5d38e..602c7e6 100644 --- a/src/utils/server.rs +++ b/src/utils/server.rs @@ -93,20 +93,48 @@ pub fn find_all_running_servers() -> Vec { let mut running_servers = Vec::new(); if cfg!(target_os = "windows") { - let output = ProcessCommand::new("tasklist") - .output() - .unwrap_or_else(|_| panic!("Failed to execute tasklist")); - - let output_str = String::from_utf8_lossy(&output.stdout); + // Use WMIC to get stackql processes with their command lines and PIDs + let output = ProcessCommand::new("wmic") + .args([ + "process", + "where", + "name='stackql.exe'", + "get", + "ProcessId,CommandLine", + "/format:list", + ]) + .output(); + + if let Ok(output) = output { + let output_str = String::from_utf8_lossy(&output.stdout); + let mut current_cmdline = String::new(); + let mut current_pid: Option = None; + + for line in output_str.lines() { + let line = line.trim(); + if let Some(cmdline) = line.strip_prefix("CommandLine=") { + current_cmdline = cmdline.to_string(); + } else if let Some(pid_str) = line.strip_prefix("ProcessId=") { + current_pid = pid_str.trim().parse().ok(); + } - for line in output_str.lines() { - if line.contains("stackql") { - if let Some(port) = extract_port_from_windows_tasklist(line) { - if let Some(pid) = extract_pid_from_windows_tasklist(line) { - running_servers.push(RunningServer { pid, port }); + // When we have both values, emit a server entry + if let Some(pid) = current_pid { + if !current_cmdline.is_empty() { + if let Some(port) = extract_port_from_cmdline(¤t_cmdline) { + debug!( + "find_all_running_servers (Windows): PID {} -> port {} (cmdline: {})", + pid, port, current_cmdline + ); + running_servers.push(RunningServer { pid, port }); + } + current_cmdline.clear(); + current_pid = None; } } } + } else { + debug!("find_all_running_servers: wmic command failed, falling back to tasklist"); } } else { let output = ProcessCommand::new("pgrep") @@ -196,48 +224,33 @@ fn extract_port_from_ps(pid: &str) -> Option { None } -/// Extract PID from process information on Windows -fn extract_pid_from_windows_tasklist(line: &str) -> Option { - line.split_whitespace() - .filter_map(|s| s.parse::().ok()) - .next() -} - -/// Extract port from process information on Windows -fn extract_port_from_windows_tasklist(line: &str) -> Option { - if let Some(port_str) = line.split_whitespace().find(|&s| s.parse::().is_ok()) { - port_str.parse().ok() - } else { - None +/// Extract port from a command line string by looking for --pgsrv.port argument +fn extract_port_from_cmdline(cmdline: &str) -> Option { + // Try --pgsrv.port=PORT format + if let Some(pos) = cmdline.find("--pgsrv.port=") { + let after = &cmdline[pos + "--pgsrv.port=".len()..]; + let port_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(port) = port_str.parse::() { + return Some(port); + } + } + // Try --pgsrv.port PORT format + if let Some(pos) = cmdline.find("--pgsrv.port") { + let after = &cmdline[pos + "--pgsrv.port".len()..]; + let trimmed = after.trim_start(); + let port_str: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(port) = port_str.parse::() { + return Some(port); + } } + None } /// Get the PID of the running stackql server on a specific port pub fn get_server_pid(port: u16) -> Option { - let patterns = [ - format!("stackql.*--pgsrv.port={}", port), - format!("stackql.*--pgsrv.port {}", port), - format!("stackql.*pgsrv.port={}", port), - format!("stackql.*pgsrv.port {}", port), - ]; - - for pattern in &patterns { - let output = ProcessCommand::new("pgrep") - .arg("-f") - .arg(pattern) - .output() - .ok()?; - - if !output.stdout.is_empty() { - let stdout_content = String::from_utf8_lossy(&output.stdout); - let pid_str = stdout_content.trim(); - if let Ok(pid) = pid_str.parse::() { - return Some(pid); - } - } - } - - None + // Use find_all_running_servers which handles platform differences + let servers = find_all_running_servers(); + servers.iter().find(|s| s.port == port).map(|s| s.pid) } /// Start the stackql server with the given options