Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Release CLI Binaries

permissions:
contents: write

on:
release:
types: [published]

env:
CARGO_TERM_COLOR: always

jobs:
build-and-upload:
name: Build & upload (${{ matrix.target }})
env:
SQLX_OFFLINE: true
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
asset_os: linux
asset_arch: x86_64
- os: macos-latest
target: x86_64-apple-darwin
asset_os: darwin
asset_arch: x86_64
- os: macos-latest
target: aarch64-apple-darwin
asset_os: darwin
asset_arch: aarch64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: ${{ matrix.target }}
override: true

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-

- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-index-

- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: release-${{ runner.os }}-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
release-${{ runner.os }}-target-${{ matrix.target }}-

- name: Build stacker-cli (release)
run: cargo build --release --target ${{ matrix.target }} --bin stacker-cli --verbose

- name: Package binary
run: |
VERSION="${GITHUB_REF_NAME#v}"
ASSET_NAME="stacker-v${VERSION}-${{ matrix.asset_arch }}-${{ matrix.asset_os }}.tar.gz"
mkdir -p staging
cp target/${{ matrix.target }}/release/stacker-cli staging/stacker
tar -czf "${ASSET_NAME}" -C staging .
echo "ASSET_NAME=${ASSET_NAME}" >> "$GITHUB_ENV"

- name: Upload release asset
uses: softprops/action-gh-release@v2
with:
files: ${{ env.ASSET_NAME }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ configuration.yaml.backup
configuration.yaml.orig
.vscode/
.env
docker/local/
docs/*.sql
config-to-validate.yaml
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stacker"
version = "0.2.3"
version = "0.2.4"
edition = "2021"
default-run= "server"

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ Stacker is a platform for turning any project into a deployable Docker stack. Ad

```
┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │
│ │ REST │ │ queue │ (on target server) │
│ stacker.yml │ API │ Stack Builder UI │ pull │ │
│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │
│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │
│ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │
│ │ REST │ │ queue │ (on target server) │
│ stacker.yml │ API │ Stack Builder UI│ pull │ │
│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │
│ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │
└──────────────┘ └──────────────────┘ │ deploy_app / proxy │
└─────────────────────┘
│ └─────────────────────┘
Terraform + Ansible ──► Cloud
(Hetzner, DO, AWS, Linode)
Expand Down
4 changes: 3 additions & 1 deletion docker/dev/.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ POSTGRES_DB=stacker
POSTGRES_PORT=5432

# Vault Configuration
VAULT_ADDRESS=http://127.0.0.1:8200
VAULT_ADDRESS=http://vault2.try.direct:8200
VAULT_TOKEN=your_vault_token_here
VAULT_AGENT_PATH_PREFIX=agent
VAULT_API_PREFIX=v1
VAULT_SSH_KEY_PATH_PREFIX=users

### 10.3 Environment Variables Required
# User Service integration
Expand Down
22 changes: 10 additions & 12 deletions src/helpers/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,13 @@ impl VaultClient {

// ============ SSH Key Management Methods ============

/// Build the Vault path for SSH keys: {base}/v1/secret/users/{user_id}/ssh_keys/{server_id}
/// Build the Vault API URL for SSH keys (KV v1).
/// Path: `{address}/{api_prefix}/secret/{prefix}/{user_id}/ssh_keys/{server_id}`
fn ssh_key_path(&self, user_id: &str, server_id: i32) -> String {
let base = self.address.trim_end_matches('/');
let api_prefix = self.api_prefix.trim_matches('/');
let prefix = self.ssh_key_path_prefix.trim_matches('/');

// Path without 'data' segment (KV v1 or custom mount)
if api_prefix.is_empty() {
format!(
"{}/secret/{}/{}/ssh_keys/{}",
Expand Down Expand Up @@ -219,13 +219,11 @@ impl VaultClient {
let path = self.ssh_key_path(user_id, server_id);

let payload = json!({
"data": {
"public_key": public_key,
"private_key": private_key,
"user_id": user_id,
"server_id": server_id,
"created_at": chrono::Utc::now().to_rfc3339()
}
"public_key": public_key,
"private_key": private_key,
"user_id": user_id,
"server_id": server_id,
"created_at": chrono::Utc::now().to_rfc3339()
});

self.client
Expand All @@ -244,7 +242,7 @@ impl VaultClient {
format!("Vault error: {}", e)
})?;

// Return the vault path for storage in database
// Return the logical vault path for storage in database
let vault_key_path = format!(
"secret/{}/{}/ssh_keys/{}",
self.ssh_key_path_prefix.trim_matches('/'),
Expand Down Expand Up @@ -293,7 +291,7 @@ impl VaultClient {
format!("Vault parse error: {}", e)
})?;

vault_response["data"]["data"]["private_key"]
vault_response["data"]["private_key"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| {
Expand Down Expand Up @@ -339,7 +337,7 @@ impl VaultClient {
format!("Vault parse error: {}", e)
})?;

vault_response["data"]["data"]["public_key"]
vault_response["data"]["public_key"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| {
Expand Down
4 changes: 2 additions & 2 deletions src/routes/project/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub async fn add(

DcBuilder::new(project)
.build()
.map_err(|err| JsonResponse::<models::Project>::build().internal_server_error(err))
.map_err(|err| JsonResponse::<models::Project>::build().bad_request(err))
.map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success"))
}

Expand All @@ -50,6 +50,6 @@ pub async fn admin(

DcBuilder::new(project)
.build()
.map_err(|err| JsonResponse::<models::Project>::build().internal_server_error(err))
.map_err(|err| JsonResponse::<models::Project>::build().bad_request(err))
.map(|fc| JsonResponse::build().set_id(id).set_item(fc).ok("Success"))
}
27 changes: 25 additions & 2 deletions src/routes/server/ssh_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,24 @@ pub async fn get_public_key(
.not_found("No active SSH key found for this server"));
}

if server.vault_key_path.is_none() {
return Err(JsonResponse::<PublicKeyResponse>::build()
.bad_request("SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one."));
}

let public_key = vault_client
.get_ref()
.fetch_ssh_public_key(&user.id, server_id)
.await
.map_err(|e| {
tracing::error!("Failed to fetch public key from Vault: {}", e);
JsonResponse::<PublicKeyResponse>::build()
.internal_server_error("Failed to retrieve public key")
if e.to_lowercase().contains("not found") {
JsonResponse::<PublicKeyResponse>::build()
.not_found("SSH key not found in Vault. The key may have been lost or Vault was restored without its data. Please delete this key and generate a new one.")
} else {
JsonResponse::<PublicKeyResponse>::build()
.bad_request("Failed to retrieve SSH key from Vault. Please try again or regenerate the key.")
}
})?;

let response = PublicKeyResponse {
Expand Down Expand Up @@ -311,6 +321,19 @@ pub async fn validate_key(
.ok("Validation failed"));
}

if server.vault_key_path.is_none() {
let response = ValidateResponse {
valid: false,
server_id,
srv_ip: server.srv_ip.clone(),
message: "SSH key is not stored in Vault (Vault was unavailable when the key was generated). Please delete this key and generate a new one.".to_string(),
..Default::default()
};
return Ok(JsonResponse::build()
.set_item(Some(response))
.ok("Validation failed"));
}

// Verify we have the server IP
let srv_ip = match &server.srv_ip {
Some(ip) if !ip.is_empty() => ip.clone(),
Expand Down
101 changes: 101 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use stacker::configuration::{get_configuration, DatabaseSettings, Settings};
use stacker::forms;
use stacker::helpers::AgentPgPool;
use std::net::TcpListener;
use wiremock::MockServer;

pub async fn spawn_app_with_configuration(mut configuration: Settings) -> Option<TestApp> {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
Expand Down Expand Up @@ -85,6 +86,106 @@ pub struct TestApp {
pub db_pool: PgPool,
}

pub struct TestAppWithVault {
pub address: String,
pub db_pool: PgPool,
pub vault_server: MockServer,
}

/// Spawn the full app with a mock Vault server.
/// The returned `vault_server` is a wiremock MockServer — mount expectations on it
/// before calling API endpoints that touch Vault.
pub async fn spawn_app_with_vault() -> Option<TestAppWithVault> {
let mut configuration = get_configuration().expect("Failed to get configuration");

// Mock auth server
let auth_listener = std::net::TcpListener::bind("127.0.0.1:0")
.expect("Failed to bind port for testing auth server");
configuration.auth_url = format!(
"http://127.0.0.1:{}/me",
auth_listener.local_addr().unwrap().port()
);
let _ = tokio::spawn(mock_auth_server(auth_listener));
tokio::time::sleep(std::time::Duration::from_millis(200)).await;

// Mock Vault server
let vault_server = MockServer::start().await;
configuration.vault.address = vault_server.uri();
configuration.vault.token = "test-vault-token".to_string();
configuration.vault.api_prefix = "v1".to_string();
configuration.vault.ssh_key_path_prefix = Some("users".to_string());

configuration.database.database_name = uuid::Uuid::new_v4().to_string();

let connection_pool = match configure_database(&configuration.database).await {
Ok(pool) => pool,
Err(err) => {
eprintln!("Skipping tests: failed to connect to postgres: {}", err);
return None;
}
};

let app_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind app port");
let port = app_listener.local_addr().unwrap().port();
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 _ = tokio::spawn(server);

Some(TestAppWithVault {
address,
db_pool: connection_pool,
vault_server,
})
}

/// Insert a minimal project into the DB and return its id.
/// Required because server.project_id has a FK constraint to project(id).
pub async fn create_test_project(pool: &PgPool, user_id: &str) -> i32 {
sqlx::query(
r#"INSERT INTO project (stack_id, user_id, name, body, created_at, updated_at)
VALUES (gen_random_uuid(), $1, 'Test Project', '{}', NOW(), NOW())
RETURNING id"#,
)
.bind(user_id)
.fetch_one(pool)
.await
.map(|row| {
use sqlx::Row;
row.get::<i32, _>("id")
})
.expect("Failed to insert test project")
}

/// Insert a test server with specific SSH key state and return its id.
pub async fn create_test_server(
pool: &PgPool,
user_id: &str,
project_id: i32,
key_status: &str,
vault_key_path: Option<&str>,
) -> i32 {
sqlx::query(
r#"INSERT INTO server (user_id, project_id, connection_mode, key_status, vault_key_path, created_at, updated_at)
VALUES ($1, $2, 'ssh', $3, $4, NOW(), NOW())
RETURNING id"#,
)
.bind(user_id)
.bind(project_id)
.bind(key_status)
.bind(vault_key_path)
.fetch_one(pool)
.await
.map(|row| {
use sqlx::Row;
row.get::<i32, _>("id")
})
.expect("Failed to insert test server")
}

#[get("")]
async fn mock_auth() -> actix_web::Result<impl Responder> {
println!("Mock auth endpoint called - returning test user");
Expand Down
Loading
Loading