Skip to content
Open
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=data/users

### 10.3 Environment Variables Required
# User Service integration
Expand Down
3 changes: 2 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ download_and_install() {
need curl
need tar

tmpdir=$(mktemp -d)
# Use $HOME-based temp dir so Snap-sandboxed curl can write to it
tmpdir=$(mktemp -d "${HOME}/.stacker-install.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT

curl -fsSL "$url" -o "${tmpdir}/${archive_name}" \
Expand Down
3 changes: 2 additions & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ download_and_install() {
need curl
need tar

tmpdir=$(mktemp -d)
# Use $HOME-based temp dir so Snap-sandboxed curl can write to it
tmpdir=$(mktemp -d "${HOME}/.stacker-install.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT

curl -fsSL "$url" -o "${tmpdir}/${archive_name}" \
Expand Down
4 changes: 2 additions & 2 deletions src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ impl Default for VaultSettings {
token: "dev-token".to_string(),
agent_path_prefix: "agent".to_string(),
api_prefix: Self::default_api_prefix(),
ssh_key_path_prefix: Some("users".to_string()),
ssh_key_path_prefix: Some("data/users".to_string()),
}
}
}
Expand All @@ -183,7 +183,7 @@ impl VaultSettings {
let api_prefix = std::env::var("VAULT_API_PREFIX").unwrap_or(self.api_prefix);
let ssh_key_path_prefix = std::env::var("VAULT_SSH_KEY_PATH_PREFIX").unwrap_or(
self.ssh_key_path_prefix
.unwrap_or_else(|| "users".to_string()),
.unwrap_or_else(|| "data/users".to_string()),
);

VaultSettings {
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
Loading
Loading