feat: update Next.js build assets and add cloud setup E2E tests

- Update Next.js generated chunk hashes and build IDs reflecting latest dashboard build
- Update CSS stylesheet references in workflow upload page metadata
- Add comprehensive cloud setup E2E test suite (cloud_setup_test.go) with SSH password/key auth, post-command variable expansion, and Ansible integration
- Fix API priority levels to include 'medium' priority in test coverage
- Add agent-sdk test workflows (minimal, config, codex, multi-agent, session variants)
- Update E2E test utilities with runCLIInBase helper for multi-step cloud config tests
- Fix stderr/stdout capture in dependencies_target_types_test assertions
This commit is contained in:
j3ssie
2026-04-04 13:57:34 +08:00
parent 51bb8bfbac
commit 0269cf4e26
401 changed files with 11651 additions and 2918 deletions
+10
View File
@@ -155,6 +155,16 @@ test-e2e-ssh: build install-gotestsum
@echo "$(PREFIX) Cleaning up..."
docker-compose -f build/docker/docker-compose.test.yaml down -v
# Cloud setup E2E tests (SSH password, key, ansible, post-commands)
test-e2e-cloud-setup: build install-gotestsum
@echo "$(PREFIX) Starting SSH server for cloud setup tests..."
docker-compose -f build/docker/docker-compose.test.yaml up -d ssh-server
@sleep 5
@echo "$(PREFIX) Running cloud setup E2E tests..."
$(TESTCMD) $(TESTFLAGS) -run TestCloudSetup ./test/e2e/...
@echo "$(PREFIX) Cleaning up..."
docker-compose -f build/docker/docker-compose.test.yaml down -v
# Distributed scan e2e tests (requires Docker for Redis)
test-distributed: build install-gotestsum
@echo "$(PREFIX) Starting Redis for distributed tests..."
+1 -1
View File
@@ -162,7 +162,7 @@ The high-level ambitious plan for the project, in order:
| 5 | Rewriting the workflow to adapt to new architecture and syntax | ✅ |
| 6 | Testing more utility functions like notifications | ✅ |
| 7 | SAST integration with SARIF parsing (Semgrep, Trivy, etc.) | ✅ |
| 8 | Cloud integration, which supports running the scan on the cloud provider. | 🚧 |
| 8 | Cloud integration, which supports running the scan on the cloud provider. | ✅ |
| 9 | Generate diff reports showing new/removed/unchanged assets between runs. | ❌ |
| 10 | Adding step type from cloud provider that can be run via serverless | ❌ |
| N | Fancy features (to be discussed later) | ❌ |
+99
View File
@@ -347,6 +347,105 @@ docker run --rm \
alpine tar czf /backup/workspaces-backup.tar.gz /data
```
## Ansible Deployment
Deploy Osmedeus on Ubuntu/Debian servers using Ansible. Uses the official install script, SQLite storage, and no Redis — designed for simple single-host setups.
### Prerequisites
- **Control machine**: Ansible 2.12+
- **Target server**: Ubuntu 20.04+ or Debian 11+
- SSH access with root or sudo privileges
### Quick Start
```bash
cd build/infra
# 1. Copy and edit inventory
cp inventory.example.ini inventory.ini
# Edit inventory.ini with your server IP/hostname
# 2. Deploy with secure credentials
ansible-playbook -i inventory.ini deploy.yaml \
-e osm_admin_password=YourSecurePassword \
-e osm_jwt_secret=$(openssl rand -base64 32)
# 3. Dry run (preview changes without applying)
ansible-playbook -i inventory.ini deploy.yaml --check
```
### What It Does
1. Installs system dependencies (curl, tmux, git, chromium, etc.)
2. Installs Osmedeus via `curl -fsSL https://www.osmedeus.org/install.sh | bash`
3. Deploys `osm-settings.yaml` configured with SQLite (no Redis)
4. Runs `osmedeus health` to verify the installation
5. Sets up a systemd service for auto-start on boot
### Playbook Files
```
build/infra/
├── deploy.yaml # Main playbook
├── inventory.example.ini # Example inventory
└── templates/
├── osm-settings.yaml.j2 # Settings config template
└── osmedeus.service.j2 # Systemd unit template
```
### Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `osm_server_port` | `8002` | API server port |
| `osm_admin_user` | `admin` | Admin username |
| `osm_admin_password` | `CHANGE_ME_ADMIN_PASSWORD` | Admin password |
| `osm_jwt_secret` | `CHANGE_ME_JWT_SECRET_MIN_32_CHARS` | JWT signing secret |
| `osm_jwt_expiration_minutes` | `1440` | Token expiry (24h) |
| `osm_threads_aggressive` | `50` | Aggressive scan threads |
| `osm_threads_default` | `20` | Default scan threads |
| `osm_threads_gently` | `5` | Gentle scan threads |
| `osm_enable_service` | `true` | Install systemd service |
| `osm_telegram_enabled` | `false` | Enable Telegram notifications |
| `osm_telegram_bot_token` | `""` | Telegram bot token |
| `osm_telegram_chat_id` | `""` | Telegram chat ID |
| `osm_global_variables` | `[]` | Extra env vars for workflows |
### Customization
Override any variable at deploy time with `-e`:
```bash
# Custom port and thread settings
ansible-playbook -i inventory.ini deploy.yaml \
-e osm_server_port=9090 \
-e osm_threads_default=30
# With Telegram notifications
ansible-playbook -i inventory.ini deploy.yaml \
-e osm_telegram_enabled=true \
-e osm_telegram_bot_token=your_bot_token \
-e osm_telegram_chat_id=your_chat_id
# Skip systemd service setup
ansible-playbook -i inventory.ini deploy.yaml \
-e osm_enable_service=false
```
### Post-Deployment
```bash
# Check service status
ssh root@YOUR_SERVER systemctl status osmedeus
# Run a scan
ssh root@YOUR_SERVER osmedeus run -f general -t example.com
# View logs
ssh root@YOUR_SERVER journalctl -u osmedeus -f
```
## Troubleshooting
### Common Issues
+153
View File
@@ -0,0 +1,153 @@
---
# Osmedeus Single-Host Deployment Playbook
# ==========================================
# Deploys Osmedeus on Ubuntu/Debian using the official install script.
#
# Usage:
# ansible-playbook -i inventory.ini deploy.yaml
#
# # With custom variables:
# ansible-playbook -i inventory.ini deploy.yaml \
# -e osm_admin_password=MySecurePass123 \
# -e osm_server_port=9090
#
# # Dry run:
# ansible-playbook -i inventory.ini deploy.yaml --check
- name: Deploy Osmedeus
hosts: osmedeus
become: true
vars:
# --- Installation ---
osm_user: root
osm_install_dir: /root/.osmedeus/binaries
osm_base_dir: /root/osmedeus-base
osm_workspaces_dir: /root/workspaces-osmedeus
# --- Server ---
osm_server_host: "0.0.0.0"
osm_server_port: 8002
osm_admin_user: admin
osm_admin_password: "CHANGE_ME_ADMIN_PASSWORD"
osm_jwt_secret: "CHANGE_ME_JWT_SECRET_MIN_32_CHARS"
osm_jwt_expiration_minutes: 1440
# --- Scan Threads ---
osm_threads_aggressive: 50
osm_threads_default: 20
osm_threads_gently: 5
# --- Systemd ---
osm_enable_service: true
# --- Notifications (optional) ---
osm_telegram_enabled: false
osm_telegram_bot_token: ""
osm_telegram_chat_id: ""
# --- Global Variables (optional) ---
# Example:
# osm_global_variables:
# - name: GITHUB_API_KEY
# value: "ghp_xxxx"
# as_env: true
osm_global_variables: []
tasks:
# =========================================================================
# 1. System Prerequisites
# =========================================================================
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install system dependencies
ansible.builtin.apt:
name:
- curl
- tmux
- git
- unzip
- jq
- chromium-browser
- python3
state: present
# =========================================================================
# 2. Install Osmedeus
# =========================================================================
- name: Check if osmedeus is already installed
ansible.builtin.stat:
path: "{{ osm_install_dir }}/osmedeus"
register: osm_binary
- name: Install osmedeus via official install script
ansible.builtin.shell: |
curl -fsSL https://www.osmedeus.org/install.sh | bash
args:
creates: "{{ osm_install_dir }}/osmedeus"
when: not osm_binary.stat.exists
- name: Ensure osmedeus binary is in PATH
ansible.builtin.shell: |
osmedeus install env
changed_when: false
# =========================================================================
# 3. Configure
# =========================================================================
- name: Create workspaces directory
ansible.builtin.file:
path: "{{ osm_workspaces_dir }}"
state: directory
owner: "{{ osm_user }}"
mode: "0755"
- name: Deploy osm-settings.yaml
ansible.builtin.template:
src: templates/osm-settings.yaml.j2
dest: "{{ osm_base_dir }}/osm-settings.yaml"
owner: "{{ osm_user }}"
mode: "0600"
notify: restart osmedeus
# =========================================================================
# 4. Verify Installation
# =========================================================================
- name: Run osmedeus health check
ansible.builtin.command: "{{ osm_install_dir }}/osmedeus health"
register: health_result
changed_when: false
- name: Show health check output
ansible.builtin.debug:
var: health_result.stdout_lines
# =========================================================================
# 5. Systemd Service (optional)
# =========================================================================
- name: Deploy systemd service
ansible.builtin.template:
src: templates/osmedeus.service.j2
dest: /etc/systemd/system/osmedeus.service
mode: "0644"
when: osm_enable_service
notify: restart osmedeus
- name: Enable and start osmedeus service
ansible.builtin.systemd:
name: osmedeus
enabled: true
state: started
daemon_reload: true
when: osm_enable_service
handlers:
- name: restart osmedeus
ansible.builtin.systemd:
name: osmedeus
state: restarted
daemon_reload: true
when: osm_enable_service
+22
View File
@@ -0,0 +1,22 @@
# Osmedeus Ansible Inventory
# ===========================
# Copy this file to inventory.ini and update with your server details.
#
# Usage:
# cp inventory.example.ini inventory.ini
# # Edit inventory.ini with your server IP/hostname
[osmedeus]
# Single host deployment
# Replace with your server's IP or hostname
osmedeus-server ansible_host=YOUR_SERVER_IP ansible_user=root
# Multiple hosts example (uncomment to add more)
# osmedeus-server-2 ansible_host=10.0.0.2 ansible_user=root
[osmedeus:vars]
# SSH key path (uncomment if not using default ~/.ssh/id_rsa)
# ansible_ssh_private_key_file=~/.ssh/your_key
# Python interpreter (Ubuntu 22.04+)
ansible_python_interpreter=/usr/bin/python3
@@ -0,0 +1,59 @@
# Osmedeus Configuration (Ansible-managed)
# ==========================================
# WARNING: This file is managed by Ansible. Local changes will be overwritten.
# Environment Paths
environment:
binaries: "{{ '{{base_folder}}' }}/external-binaries"
external_data: "{{ '{{base_folder}}' }}/external-data"
external_configs: "{{ '{{base_folder}}' }}/external-configs"
workspaces: {{ osm_workspaces_dir }}
workflows: "{{ '{{base_folder}}' }}/workflows"
snapshot: "{{ '{{base_folder}}' }}/snapshot"
# Database - SQLite
database:
db_engine: sqlite
db_path: "{{ '{{base_folder}}' }}/database-osm.sqlite"
connection_timeout: 60
# Server
server:
host: "{{ osm_server_host }}"
port: {{ osm_server_port }}
ui_path: "{{ '{{base_folder}}' }}/ui/"
workspace_prefix_key: ""
simple_user_map_key:
{{ osm_admin_user }}: "{{ osm_admin_password }}"
jwt:
secret_signing_key: "{{ osm_jwt_secret }}"
expiration_minutes: {{ osm_jwt_expiration_minutes }}
# Scan Tactic
scan_tactic:
aggressive: {{ osm_threads_aggressive }}
default: {{ osm_threads_default }}
gently: {{ osm_threads_gently }}
{% if osm_telegram_enabled %}
# Notifications
notification:
telegram:
enabled: true
bot_token: "{{ osm_telegram_bot_token }}"
chat_id: "{{ osm_telegram_chat_id }}"
{% endif %}
{% if osm_global_variables | length > 0 %}
# Global Variables
global_variables:
{% for var in osm_global_variables %}
- name: "{{ var.name }}"
value: "{{ var.value }}"
{% if var.as_env is defined %}
as_env: {{ var.as_env | lower }}
{% endif %}
{% endfor %}
{% endif %}
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Osmedeus Security Automation Server
After=network.target
[Service]
Type=simple
User={{ osm_user }}
Group={{ osm_user }}
ExecStart={{ osm_install_dir }}/osmedeus server --port {{ osm_server_port }}
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths={{ osm_base_dir }} {{ osm_workspaces_dir }}
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -12,7 +12,7 @@ var (
)
// @title Osmedeus API
// @version 5.0.1
// @version 5.0.2
// @description Workflow Engine for Offensive Security - REST API for managing security automation workflows, scans, and distributed task execution.
// @termsOfService https://docs.osmedeus.org/terms/
+172
View File
@@ -102,6 +102,14 @@ curl -X POST http://localhost:8002/osm/api/runs \
| `docker_image` | string | No | - | Docker image for docker runner |
| `ssh_host` | string | No | - | SSH host for ssh runner |
| `workspace` | string | No | auto | Custom workspace name |
| `run_mode` | string | No | `local` | Execution mode: `local`, `distributed`, `cloud` |
| `cloud_provider` | string | No | config default | Cloud provider: `aws`, `gcp`, `digitalocean`, `linode`, `azure`, `hetzner` |
| `cloud_instances` | int | No | 1 | Number of cloud instances to provision |
| `cloud_instance_type` | string | No | provider default | Instance size override (e.g., `t3.medium`, `s-2vcpu-4gb`) |
| `cloud_region` | string | No | provider default | Region override |
| `cloud_auto_destroy` | bool | No | `false` | Destroy cloud infrastructure when scan completes |
| `cloud_reuse_infra` | string | No | - | Existing infrastructure ID to reuse instead of provisioning |
| `cloud_use_spot` | bool | No | `false` | Use spot/preemptible instances for cost savings |
\* One of `flow` or `module` is required.
\** One of `target`, `targets`, or `target_file` is required.
@@ -115,6 +123,7 @@ curl -X POST http://localhost:8002/osm/api/runs \
"target": "example.com",
"target_count": 1,
"priority": "normal",
"run_mode": "local",
"job_id": "a1b2c3d4",
"run_uuid": "550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
@@ -178,6 +187,169 @@ This is similar to CLI's `-T` flag: `osmedeus run -m port-scan -T targets.txt`
---
## Distributed Mode
Submit scans to the distributed worker pool. Requires the server to be started with `--master` flag.
**Single target distributed scan:**
```bash
curl -X POST http://localhost:8002/osm/api/runs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"flow": "subdomain-enum",
"target": "example.com",
"run_mode": "distributed"
}'
```
**Multiple targets distributed across workers:**
```bash
curl -X POST http://localhost:8002/osm/api/runs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"flow": "subdomain-enum",
"targets": ["example.com", "test.com", "demo.com"],
"run_mode": "distributed",
"priority": "high"
}'
```
Each target is submitted as a separate task to the distributed worker queue. Workers pick up tasks and execute them independently.
**Response:**
```json
{
"message": "Run started",
"workflow": "subdomain-enum",
"kind": "flow",
"target_count": 3,
"targets": ["example.com", "test.com", "demo.com"],
"priority": "high",
"run_mode": "distributed",
"job_id": "c3d4e5f6",
"status": "queued",
"poll_url": "/osm/api/jobs/c3d4e5f6"
}
```
**Error when server is not in master mode:**
```json
{
"error": true,
"message": "Distributed mode requires the server to be started with --master flag"
}
```
---
## Cloud Mode
Provision cloud infrastructure and execute scans on remote instances. Requires cloud features to be enabled in the configuration (`cloud.enabled: true` in `osm-settings.yaml`).
**Basic cloud scan (uses default provider from config):**
```bash
curl -X POST http://localhost:8002/osm/api/runs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"flow": "subdomain-enum",
"target": "example.com",
"run_mode": "cloud"
}'
```
**Cloud scan with multiple instances:**
```bash
curl -X POST http://localhost:8002/osm/api/runs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"flow": "subdomain-enum",
"targets": ["example.com", "test.com", "demo.com"],
"run_mode": "cloud",
"cloud_provider": "digitalocean",
"cloud_instances": 3,
"cloud_auto_destroy": true
}'
```
Targets are distributed round-robin across the provisioned instances. When `cloud_auto_destroy` is `true`, infrastructure is torn down after all scans complete.
**Cloud scan with instance customization:**
```bash
curl -X POST http://localhost:8002/osm/api/runs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"flow": "general",
"target": "example.com",
"run_mode": "cloud",
"cloud_provider": "aws",
"cloud_instances": 2,
"cloud_instance_type": "t3.large",
"cloud_region": "ap-southeast-1",
"cloud_use_spot": true,
"cloud_auto_destroy": true,
"priority": "high"
}'
```
**Cloud scan reusing existing infrastructure:**
```bash
curl -X POST http://localhost:8002/osm/api/runs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"flow": "subdomain-enum",
"target": "example.com",
"run_mode": "cloud",
"cloud_reuse_infra": "a1b2c3d4"
}'
```
Skip provisioning and run on existing infrastructure by passing the `cloud_reuse_infra` ID (from a previous `POST /cloud/instances` or cloud run response).
**Response:**
```json
{
"message": "Run started",
"workflow": "subdomain-enum",
"kind": "flow",
"target": "example.com",
"target_count": 1,
"priority": "high",
"run_mode": "cloud",
"job_id": "d4e5f6a7",
"run_uuid": "770e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"poll_url": "/osm/api/jobs/d4e5f6a7",
"infra_id": "d4e5f6a7",
"infra_status_url": "/osm/api/cloud/instances/d4e5f6a7/status",
"cloud_provider": "aws",
"cloud_instances": 2
}
```
**Error when cloud is not enabled:**
```json
{
"error": true,
"message": "Cloud mode requires cloud features to be enabled in configuration"
}
```
**Error when provider credentials are invalid:**
```json
{
"error": true,
"message": "Cloud provider validation failed: invalid API token"
}
```
---
## List Runs
Get a paginated list of all runs.
+156 -216
View File
@@ -1,221 +1,161 @@
# Osmedeus Cloud Cheatsheet
# Cloud Cheatsheet
## First-Time Setup
```bash
# 0. Enable cloud feature
osmedeus config set cloud.enabled true
# 1. Credentials (pick one provider)
osmedeus cloud config set providers.aws.access_key_id <key>
osmedeus cloud config set providers.aws.secret_access_key <secret>
osmedeus cloud config set providers.aws.region ap-southeast-1
osmedeus cloud config set defaults.provider aws
# 2. SSH
osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa
osmedeus cloud config set ssh.public_key_path ~/.ssh/id_rsa.pub
# 3. Clean the setup scripts first, then add worker setup
osmedeus cloud config set setup.commands.clear ""
osmedeus cloud config set setup.commands.add "curl -fsSL https://www.osmedeus.org/install.sh | bash"
osmedeus cloud config set setup.commands.add "osmedeus install base --preset"
# 4. Cost limits (recommended)
osmedeus cloud config set limits.max_hourly_spend 1.00
osmedeus cloud config set limits.max_total_spend 10.00
```
╔════════════════════════════════════════════════════════════════════════════╗
║ OSMEDEUS CLOUD CHEATSHEET ║
║ Distributed Security Scanning ║
╚════════════════════════════════════════════════════════════════════════════╝
┌─ QUICK START ──────────────────────────────────────────────────────────────┐
│ │
│ # Configure │
│ osmedeus cloud config set providers.digitalocean.token "YOUR_TOKEN" │
│ osmedeus cloud config set defaults.provider "digitalocean" │
│ │
│ # Run │
│ osmedeus cloud run -f general -t example.com --instances 3 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
## Workflow Mode
┌─ CONFIGURATION ────────────────────────────────────────────────────────────┐
│ │
│ osmedeus cloud config show # View configuration │
│ osmedeus cloud config set <key> <value> # Set value │
│ │
│ # Provider Credentials │
│ providers.digitalocean.token "dop_v1_..." │
│ providers.aws.access_key_id "AKIA..." │
│ providers.aws.secret_access_key "..." │
│ providers.gcp.project_id "my-project" │
│ │
│ # Defaults │
│ defaults.provider "digitalocean|aws|gcp|linode|azure" │
│ defaults.max_instances 10 │
│ │
│ # Cost Limits │
│ limits.max_hourly_spend 5.00 │
│ limits.max_total_spend 50.00 │
│ │
│ # Instance Types │
│ providers.digitalocean.size "s-2vcpu-4gb" │
│ providers.aws.instance_type "t3.medium" │
│ providers.gcp.machine_type "n1-standard-2" │
│ │
│ # Cost Savings (70-80% off) │
│ providers.aws.use_spot true │
│ providers.gcp.use_preemptible true │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ INFRASTRUCTURE MANAGEMENT ────────────────────────────────────────────────┐
│ │
│ osmedeus cloud create --instances 5 # Create │
│ osmedeus cloud create --provider aws --instances 10 │
│ osmedeus cloud create --instances 3 --force │
│ │
│ osmedeus cloud list # List │
│ │
│ osmedeus cloud destroy <id> # Destroy │
│ osmedeus cloud destroy --all │
│ osmedeus cloud destroy <id> --force │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ RUNNING WORKFLOWS ────────────────────────────────────────────────────────┐
│ │
│ # Single target │
│ osmedeus cloud run -f general -t example.com --instances 3 │
│ osmedeus cloud run -m subdomain-enum -t example.com --instances 5 │
│ │
│ # Multiple targets │
│ osmedeus cloud run -f general -T targets.txt --instances 10 │
│ osmedeus cloud run -f general -T targets.txt -c 10 --instances 5 │
│ │
│ # With provider │
│ osmedeus cloud run -f general -t example.com --provider aws --instances 5 │
│ │
│ # With timeout │
│ osmedeus cloud run -f general -t example.com --instances 3 --timeout 2h │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ COST ESTIMATES ───────────────────────────────────────────────────────────┐
│ │
│ ╔══════════════════╦═════════════╦══════════════╦═══════════════════════╗ │
│ ║ Provider ║ Type ║ Hourly ║ Daily ║ │
│ ╠══════════════════╬═════════════╬══════════════╬═══════════════════════╣ │
│ ║ DigitalOcean ║ s-1vcpu-1gb ║ $0.00744/hr ║ $0.18/day ║ │
│ ║ DigitalOcean ⭐ ║ s-2vcpu-4gb ║ $0.02232/hr ║ $0.54/day ║ │
│ ║ DigitalOcean ║ s-4vcpu-8gb ║ $0.04464/hr ║ $1.07/day ║ │
│ ║ AWS ║ t3.micro ║ $0.0104/hr ║ $0.25/day ║ │
│ ║ AWS ⭐ ║ t3.medium ║ $0.0416/hr ║ $1.00/day ║ │
│ ║ AWS (spot) ║ t3.medium ║ ~$0.0125/hr ║ ~$0.30/day (-70%) ║ │
│ ║ GCP ║ n1-std-1 ║ $0.0475/hr ║ $1.14/day ║ │
│ ║ GCP ⭐ ║ n1-std-2 ║ $0.0950/hr ║ $2.28/day ║ │
│ ║ GCP (preempt) ║ n1-std-2 ║ ~$0.0190/hr ║ ~$0.46/day (-80%) ║ │
│ ╚══════════════════╩═════════════╩══════════════╩═══════════════════════╝ │
│ │
│ Formula: Total = (Hourly Rate × Instances × Hours) │
│ Example: 5 × s-2vcpu-4gb × 2h = 5 × $0.02232 × 2 = $0.22 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ COMMON WORKFLOWS ─────────────────────────────────────────────────────────┐
│ │
│ # Bug Bounty Recon │
│ osmedeus cloud run -m subdomain-enum -T targets.txt --instances 10 \ │
│ --timeout 2h │
│ │
│ # Repository Audit (100 repos) │
│ osmedeus cloud run -f repo -T repos.txt -c 20 --instances 20 --timeout 6h │
│ │
│ # IP Range Scanning │
│ osmedeus cloud run -f ip-scanning -T cidr.txt --instances 15 --timeout 4h │
│ │
│ # Large Campaign (Persistent) │
│ osmedeus cloud create --instances 20 │
│ osmedeus run -f general -T batch-1.txt -c 10 │
│ osmedeus run -f general -T batch-2.txt -c 10 │
│ osmedeus cloud destroy --all │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ PROVIDER REGIONS ─────────────────────────────────────────────────────────┐
│ │
│ DigitalOcean: nyc1, nyc3, sfo1, sfo3, ams3, sgp1, lon1, fra1, tor1, blr1 │
│ AWS: us-east-1, us-west-2, eu-west-1, ap-southeast-1 │
│ GCP: us-central1, us-east1, europe-west1, asia-southeast1 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ ENVIRONMENT VARIABLES ────────────────────────────────────────────────────┐
│ │
│ export DIGITALOCEAN_TOKEN="dop_v1_..." │
│ export AWS_ACCESS_KEY_ID="AKIA..." │
│ export AWS_SECRET_ACCESS_KEY="..." │
│ export GCP_PROJECT_ID="my-project" │
│ │
│ # Reference in config │
│ osmedeus cloud config set providers.digitalocean.token \ │
│ '${DIGITALOCEAN_TOKEN}' │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ TROUBLESHOOTING ──────────────────────────────────────────────────────────┐
│ │
│ osmedeus --debug cloud run ... # Debug mode │
│ tail -f ~/osmedeus-base/logs/osmedeus-*.log # View logs │
│ osmedeus cloud list # List infrastructure │
│ osmedeus cloud destroy --all --force # Emergency cleanup │
│ │
│ # Check states │
│ ls -la ~/osmedeus-base/cloud-state/infrastructure/ │
│ │
│ # Manual cleanup │
│ doctl compute droplet list | grep osmedeus │
│ aws ec2 describe-instances --filters "Name=tag:osmedeus,Values=*" │
│ gcloud compute instances list | grep osmedeus │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ BEST PRACTICES ───────────────────────────────────────────────────────────┐
│ │
│ ✓ Always set cost limits (max_hourly_spend, max_total_spend) │
│ ✓ Use spot/preemptible instances for 70-80% cost savings │
│ ✓ Start small (1-2 instances) then scale up │
│ ✓ Clean up infrastructure after use (cloud destroy --all) │
│ ✓ Use custom snapshots for faster boot (30s vs 5min) │
│ ✓ Monitor real-time cost during execution │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ ADVANCED FEATURES ────────────────────────────────────────────────────────┐
│ │
│ # Custom Snapshots (Fast Boot) │
│ osmedeus cloud config set providers.digitalocean.snapshot_id "123456789" │
│ │
│ # Custom Worker Setup │
│ osmedeus cloud config set setup.commands[0] "apt-get update" │
│ osmedeus cloud config set setup.commands[1] "pip3 install custom-tool" │
│ │
│ # SSH Configuration │
│ osmedeus cloud config set ssh.private_key_path "~/.ssh/cloud_rsa" │
│ osmedeus cloud config set ssh.user "root" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ FLAGS REFERENCE ──────────────────────────────────────────────────────────┐
│ │
│ --provider <name> Cloud provider (digitalocean|aws|gcp|linode...) │
│ --mode <mode> Execution mode (vm|serverless) │
│ --instances <n> Number of instances to provision │
│ --timeout <duration> Timeout for cloud run (e.g., 2h, 30m) │
│ --force Skip confirmation prompts │
│ -c <n> Concurrent target scanning │
│ -f <flow> Flow name (general, repo, etc.) │
│ -m <module> Module name (subdomain-enum, etc.) │
│ -t <target> Single target │
│ -T <file> Multiple targets from file │
│ --debug Enable debug output │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ HELP & DOCUMENTATION ─────────────────────────────────────────────────────┐
│ │
│ osmedeus cloud --help # Cloud help │
│ osmedeus --usage-example # Full examples │
│ │
│ Documentation: │
│ • docs/cloud-usage-examples.md # Detailed examples │
│ • docs/cloud-quick-reference.md # Quick reference │
│ • docs/cloud-usage-guide.md # Architecture guide │
│ • test/e2e/CLOUD_TESTS_README.md # Test documentation │
│ │
│ GitHub: https://github.com/j3ssie/osmedeus/issues │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
╔════════════════════════════════════════════════════════════════════════════╗
║ Version: v5.0+ | License: MIT | Author: @j3ssie ║
╚════════════════════════════════════════════════════════════════════════════╝
```bash
osmedeus cloud run -f fast -t example.com # Single target
osmedeus cloud run -f fast -T targets.txt --instances 5 # Distributed
osmedeus cloud run -f fast -t example.com --sync-back # Sync results
osmedeus cloud run -f fast -t example.com --auto-destroy # Auto cleanup
osmedeus cloud run -f fast -t example.com --sync-back --auto-destroy # Full lifecycle
osmedeus cloud run -f fast -t example.com --reuse # Reuse infra
osmedeus cloud run -m enum-subdomain -t example.com --timeout 30m # Module + timeout
```
## Custom Command Mode
```bash
# Run anything on cloud instances
osmedeus cloud run --custom-cmd "nmap -sV {{Target}}" -t example.com
# Pipeline: multiple commands, sync results
osmedeus cloud run \
--custom-cmd "subfinder -d {{Target}} -o /tmp/osm-custom/subs.txt" \
--custom-cmd "cat /tmp/osm-custom/subs.txt | httpx -o /tmp/osm-custom/live.txt" \
--custom-post-cmd "wc -l /tmp/osm-custom/live.txt" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
# Distribute targets, sync to custom dir
osmedeus cloud run \
--custom-cmd "cat {{Target}} | nuclei -o /tmp/osm-custom/nuclei.txt" \
--sync-path "/tmp/osm-custom/nuclei.txt" \
--sync-dest "./nuclei-results" \
-T targets.txt --instances 5
```
### Variables: `{{Target}}` `{{public_ip}}` `{{private_ip}}` `{{worker_name}}` `{{worker_id}}` `{{infra_id}}` `{{provider}}` `{{ssh_user}}` `{{index}}`
### Rules
- Commands run in `/tmp/osm-custom/` on remote
- Sequential per worker, parallel across workers
- First failure skips remaining cmds + post-cmds
- Sync destination: `<sync-dest>/<worker_name>-<ip>/<path>`
## Infrastructure
```bash
osmedeus cloud create --provider aws -n 3 # Create
osmedeus cloud list # List
osmedeus cloud destroy <id> # Destroy one
osmedeus cloud destroy all --force # Destroy all
osmedeus cloud setup --reuse-with "1.2.3.4" # Setup existing
```
## Config
```bash
osmedeus cloud config list # View
osmedeus cloud config set <key> <value> # Set
osmedeus cloud config set <key>.add <value> # Append to list
osmedeus cloud config clean # Reset
```
## Provider Quick Config
**AWS:**
```bash
osmedeus cloud config set providers.aws.access_key_id ${AWS_ACCESS_KEY_ID}
osmedeus cloud config set providers.aws.secret_access_key ${AWS_SECRET_ACCESS_KEY}
osmedeus cloud config set providers.aws.region ap-southeast-1
osmedeus cloud config set providers.aws.instance_type t3.medium
osmedeus cloud config set providers.aws.use_spot true # 70% cheaper
```
**Hetzner:**
```bash
osmedeus cloud config set providers.hetzner.token ${HETZNER_API_TOKEN}
osmedeus cloud config set providers.hetzner.location fsn1
osmedeus cloud config set providers.hetzner.server_type cx22
```
**DigitalOcean:**
```bash
osmedeus cloud config set providers.digitalocean.token ${DO_TOKEN}
osmedeus cloud config set providers.digitalocean.region sgp1
osmedeus cloud config set providers.digitalocean.size s-2vcpu-4gb
```
**GCP:**
```bash
osmedeus cloud config set providers.gcp.project_id ${GCP_PROJECT}
osmedeus cloud config set providers.gcp.credentials_file /path/to/sa-key.json
osmedeus cloud config set providers.gcp.region us-central1
osmedeus cloud config set providers.gcp.zone us-central1-a
osmedeus cloud config set providers.gcp.machine_type n1-standard-2
```
**Linode:**
```bash
osmedeus cloud config set providers.linode.token ${LINODE_TOKEN}
osmedeus cloud config set providers.linode.region ap-south
osmedeus cloud config set providers.linode.type g6-standard-2
```
**Azure:**
```bash
osmedeus cloud config set providers.azure.subscription_id ${AZURE_SUB_ID}
osmedeus cloud config set providers.azure.tenant_id ${AZURE_TENANT_ID}
osmedeus cloud config set providers.azure.client_id ${AZURE_CLIENT_ID}
osmedeus cloud config set providers.azure.client_secret ${AZURE_CLIENT_SECRET}
osmedeus cloud config set providers.azure.location southeastasia
osmedeus cloud config set providers.azure.vm_size Standard_B2s
```
## Cost Reference
| Provider | Instance | vCPU | RAM | $/hr |
|----------|----------|------|-----|------|
| Hetzner | cx22 | 2 | 4 GB | 0.007 |
| Linode | g6-standard-2 | 2 | 4 GB | 0.018 |
| DigitalOcean | s-2vcpu-4gb | 2 | 4 GB | 0.022 |
| AWS | t3.medium | 2 | 4 GB | 0.042 |
| Azure | Standard_B2s | 2 | 4 GB | 0.042 |
| GCP | n1-standard-2 | 2 | 7.5 GB | 0.095 |
5 x Hetzner cx22 x 2 hours = **$0.07** | 5 x DO s-2vcpu-4gb x 2 hours = **$0.22**
## Troubleshooting
```bash
osmedeus cloud run -f fast -t example.com --verbose-setup # See setup output
osmedeus cloud run -f fast -t example.com --debug # Full debug logs
osmedeus cloud list # Check for orphans
osmedeus cloud destroy all --force # Emergency cleanup
```
+276
View File
@@ -0,0 +1,276 @@
# AWS Provider Guide
Step-by-step guide for running osmedeus cloud on AWS EC2 instances.
## Prerequisites
- An AWS account
- An IAM user or role with EC2 permissions
- An SSH key pair (local `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub`)
### Required IAM Permissions
The IAM user needs these permissions (or use the `AmazonEC2FullAccess` managed policy):
```
ec2:RunInstances
ec2:TerminateInstances
ec2:DescribeInstances
ec2:DescribeImages
ec2:CreateSecurityGroup
ec2:AuthorizeSecurityGroupIngress
ec2:DeleteSecurityGroup
ec2:DescribeSecurityGroups
ec2:ImportKeyPair
ec2:DeleteKeyPair
ec2:DescribeKeyPairs
ec2:CreateTags
```
### Get Your Credentials
1. Go to **IAM Console** > **Users** > select your user
2. **Security credentials** tab > **Create access key**
3. Save the **Access key ID** and **Secret access key**
Or use environment variables if already configured for AWS CLI:
```bash
export AWS_ACCESS_KEY_ID=<YOUR_AWS_ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<YOUR_AWS_SECRET_ACCESS_KEY>
```
## Configuration
### Minimal Setup
```bash
# Enable cloud feature
osmedeus config set cloud.enabled true
# Credentials
osmedeus cloud config set providers.aws.access_key_id ${AWS_ACCESS_KEY_ID}
osmedeus cloud config set providers.aws.secret_access_key ${AWS_SECRET_ACCESS_KEY}
osmedeus cloud config set providers.aws.region ap-southeast-1
osmedeus cloud config set defaults.provider aws
# SSH
osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa
osmedeus cloud config set ssh.public_key_path ~/.ssh/id_rsa.pub
osmedeus cloud config set ssh.user ubuntu
# Clean the setup scripts first
osmedeus cloud config set setup.commands.clear ""
# Worker setup
osmedeus cloud config set setup.commands.add "sudo apt-get update"
osmedeus cloud config set setup.commands.add "sudo apt-get install -y -qq curl git tmux unzip jq rsync"
osmedeus cloud config set setup.commands.add "curl -fsSL https://www.osmedeus.org/install.sh | bash"
osmedeus cloud config set setup.commands.add "osmedeus health"
```
### Instance Types
| Instance | vCPU | RAM | $/hr (on-demand) | $/hr (spot, ~70% off) | Best For |
|----------|------|-----|------|------|----------|
| t3.medium | 2 | 4 GB | $0.0416 | ~$0.012 | Light scans, single targets |
| t3.large | 2 | 8 GB | $0.0832 | ~$0.025 | General scanning |
| t3.xlarge | 4 | 16 GB | $0.1664 | ~$0.050 | Heavy scans, large target lists |
| t3.2xlarge | 8 | 32 GB | $0.3328 | ~$0.100 | Parallel pipelines |
```bash
# Set instance type
osmedeus cloud config set providers.aws.instance_type t3.large
```
### Spot Instances
Spot instances cost 60-80% less than on-demand. They can be interrupted but are fine for security scanning (stateless, can retry).
```bash
osmedeus cloud config set providers.aws.use_spot true
```
### Regions
Pick a region close to your targets or with the lowest pricing:
| Region | Location | Code |
|--------|----------|------|
| US East (N. Virginia) | US | `us-east-1` |
| US West (Oregon) | US | `us-west-2` |
| EU (Frankfurt) | Europe | `eu-central-1` |
| EU (Ireland) | Europe | `eu-west-1` |
| Asia Pacific (Singapore) | Asia | `ap-southeast-1` |
| Asia Pacific (Tokyo) | Asia | `ap-northeast-1` |
| Asia Pacific (Mumbai) | Asia | `ap-south-1` |
| Asia Pacific (Sydney) | Australia | `ap-southeast-2` |
```bash
osmedeus cloud config set providers.aws.region us-east-1
```
### Custom AMI
Use a custom AMI with tools pre-installed for faster startup:
```bash
# Find the default Ubuntu AMI for your region
# aws ec2 describe-images --owners 099720109477 --filters "Name=name,Values=ubuntu/images/hvm-ssd/ubuntu-*-amd64-*" --query 'sort_by(Images, &CreationDate)[-1].ImageId'
# Or use your own pre-built AMI
osmedeus cloud config set providers.aws.ami ami-0123456789abcdef0
```
### Cost Limits
```bash
osmedeus cloud config set limits.max_hourly_spend 1.00
osmedeus cloud config set limits.max_total_spend 10.00
osmedeus cloud config set limits.max_instances 10
```
## Examples
### Quick Domain Recon
```bash
osmedeus cloud run -f fast -t example.com --auto-destroy
```
Cost: ~$0.04 (1 x t3.medium x 1 hour)
### Large-Scale Subdomain Enumeration
```bash
# targets.txt: one domain per line
osmedeus cloud run -f general -T targets.txt --instances 5 --sync-back --auto-destroy
```
Cost: ~$0.42 (5 x t3.medium x 2 hours)
### Custom Nmap Scan
```bash
osmedeus cloud run \
--custom-cmd "nmap -sV -sC {{Target}} -oA /tmp/osm-custom/nmap" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
```
### Distributed Nuclei Scanning
```bash
osmedeus cloud run \
--custom-cmd "cat {{Target}} | nuclei -o /tmp/osm-custom/results.txt" \
--sync-path "/tmp/osm-custom/results.txt" \
--sync-dest "./nuclei-aws" \
-T urls.txt --instances 10 --auto-destroy
```
Cost: ~$0.42 (10 x t3.medium x 1 hour)
### Spot Instance Pipeline
```bash
# Configure spot
osmedeus cloud config set providers.aws.use_spot true
osmedeus cloud config set providers.aws.instance_type t3.large
# Run a heavy scan for cheap
osmedeus cloud run \
--custom-cmd "subfinder -d {{Target}} -all -o /tmp/osm-custom/subs.txt" \
--custom-cmd "cat /tmp/osm-custom/subs.txt | httpx -td -o /tmp/osm-custom/live.txt" \
--custom-cmd "cat /tmp/osm-custom/live.txt | nuclei -o /tmp/osm-custom/nuclei.txt" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
```
Cost: ~$0.025 (1 x t3.large spot x 1 hour)
### Persistent Recon Campaign
```bash
# Create instances once (saves setup time on subsequent runs)
osmedeus cloud create --provider aws -n 3
# Run scans throughout the day
osmedeus cloud run -f fast -t target1.com --reuse
osmedeus cloud run -f fast -t target2.com --reuse
osmedeus cloud run --custom-cmd "nuclei -u target3.com -o /tmp/osm-custom/nuclei.txt" \
--sync-path "/tmp/osm-custom/" -t target3.com --reuse
# Destroy at end of day
osmedeus cloud destroy all --force
```
### Multi-Region Scanning
```bash
# Scan US targets from US region
osmedeus cloud config set providers.aws.region us-east-1
osmedeus cloud run -f fast -t us-company.com --auto-destroy
# Scan APAC targets from Singapore
osmedeus cloud config set providers.aws.region ap-southeast-1
osmedeus cloud run -f fast -t apac-company.com --auto-destroy
```
## Troubleshooting
### "UnauthorizedOperation" Error
Your IAM user lacks required permissions. Attach `AmazonEC2FullAccess` policy or the minimal permissions listed above.
### Instances Not Starting
```bash
# Check with debug output
osmedeus cloud run -f fast -t example.com --debug
# Common causes:
# - Region doesn't have the instance type available
# - vCPU limit reached (request limit increase in AWS console)
# - Spot capacity unavailable (try on-demand or different region)
```
### SSH Connection Timeout
```bash
# Verify security group allows SSH (port 22)
# Check with verbose setup
osmedeus cloud run -f fast -t example.com --verbose-setup
```
### Spot Instance Interrupted
Spot instances can be reclaimed by AWS. The scan will fail for that worker. Mitigation:
- Use `--auto-destroy` to clean up
- Re-run the failed targets
- Use on-demand instances for critical scans
### Cleaning Up
```bash
# List all infrastructure
osmedeus cloud list
# Destroy specific
osmedeus cloud destroy <infra-id>
# Nuclear option
osmedeus cloud destroy all --force
# If osmedeus state is out of sync, check AWS console directly:
# EC2 Console > Instances > filter by tag "osmedeus"
```
## Cost Optimization
1. **Use spot instances** for all non-critical scans (`use_spot: true`)
2. **Right-size instances**: t3.medium is enough for most single-target scans
3. **Always use `--auto-destroy`** to prevent forgotten instances
4. **Set cost limits** to catch runaway spending
5. **Use custom AMIs** to reduce setup time (less instance-hours)
6. **Pick the cheapest region** if target geo-location doesn't matter (us-east-1 is usually cheapest)
+339
View File
@@ -0,0 +1,339 @@
# GCP Provider Guide
Step-by-step guide for running osmedeus cloud on Google Cloud Platform Compute Engine instances.
## Prerequisites
- A GCP account with a project
- A service account with Compute Engine permissions
- A service account key file (JSON)
- An SSH key pair (local `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub`)
### Required IAM Permissions
The service account needs these roles (or use the `Compute Admin` role):
```
compute.instances.create
compute.instances.delete
compute.instances.get
compute.instances.list
compute.instances.setMetadata
compute.firewalls.create
compute.firewalls.delete
compute.firewalls.get
compute.networks.get
compute.subnetworks.use
compute.disks.create
compute.images.get
compute.images.useReadOnly
```
The simplest approach is to assign the **Compute Admin** (`roles/compute.admin`) role to your service account.
### Create a Service Account and Key
1. Go to **IAM & Admin** > **Service Accounts** > **Create Service Account**
2. Name it `osmedeus-cloud` (or similar)
3. Grant it the **Compute Admin** role
4. Go to the service account > **Keys** > **Add Key** > **Create new key** > **JSON**
5. Save the JSON file (e.g., `~/.gcp/osmedeus-sa.json`)
Or via `gcloud` CLI:
```bash
# Create service account
gcloud iam service-accounts create osmedeus-cloud \
--display-name="Osmedeus Cloud Scanner"
# Grant Compute Admin role
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:osmedeus-cloud@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/compute.admin"
# Create and download key file
gcloud iam service-accounts keys create ~/.gcp/osmedeus-sa.json \
--iam-account=osmedeus-cloud@YOUR_PROJECT_ID.iam.gserviceaccount.com
```
You can also export the credentials file path as an environment variable:
```bash
export GCP_PROJECT_ID=your-project-id
export GCP_CREDENTIALS_FILE=~/.gcp/osmedeus-sa.json
```
## Configuration
### Minimal Setup
```bash
# Enable cloud feature
osmedeus config set cloud.enabled true
# Credentials
osmedeus cloud config set providers.gcp.project_id ${GCP_PROJECT_ID}
osmedeus cloud config set providers.gcp.credentials_file ${GCP_CREDENTIALS_FILE}
osmedeus cloud config set providers.gcp.region us-central1
osmedeus cloud config set providers.gcp.zone us-central1-a
osmedeus cloud config set defaults.provider gcp
# SSH
osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa
osmedeus cloud config set ssh.public_key_path ~/.ssh/id_rsa.pub
osmedeus cloud config set ssh.user root
# Clean the setup scripts first
osmedeus cloud config set setup.commands.clear ""
# Worker setup
osmedeus cloud config set setup.commands.add "sudo apt-get update"
osmedeus cloud config set setup.commands.add "sudo apt-get install -y -qq curl git tmux unzip jq rsync"
osmedeus cloud config set setup.commands.add "curl -fsSL https://www.osmedeus.org/install.sh | bash"
osmedeus cloud config set setup.commands.add "osmedeus install base --preset"
```
### Machine Types
| Machine Type | vCPU | RAM | $/hr (on-demand) | $/hr (preemptible, ~80% off) | Best For |
|-------------|------|-----|------|------|----------|
| e2-medium | 2 | 4 GB | $0.0335 | ~$0.010 | Light scans, single targets |
| n1-standard-2 | 2 | 7.5 GB | $0.0950 | ~$0.019 | General scanning (default) |
| n1-standard-4 | 4 | 15 GB | $0.1900 | ~$0.038 | Heavy scans, large target lists |
| n2-standard-2 | 2 | 8 GB | $0.0971 | ~$0.019 | General scanning (newer gen) |
| n2-standard-4 | 4 | 16 GB | $0.1942 | ~$0.039 | Parallel pipelines |
| c2-standard-4 | 4 | 16 GB | $0.2088 | ~$0.042 | CPU-intensive scans |
```bash
# Set machine type
osmedeus cloud config set providers.gcp.machine_type n1-standard-2
```
### Preemptible Instances
Preemptible VMs cost up to 80% less than on-demand. They last at most 24 hours and can be reclaimed, but are ideal for security scanning workloads.
```bash
osmedeus cloud config set providers.gcp.use_preemptible true
```
### Regions and Zones
Pick a region close to your targets or with the lowest pricing:
| Region | Location | Code | Zone Example |
|--------|----------|------|-------------|
| Iowa | US | `us-central1` | `us-central1-a` |
| South Carolina | US | `us-east1` | `us-east1-b` |
| Oregon | US | `us-west1` | `us-west1-b` |
| Frankfurt | Europe | `europe-west3` | `europe-west3-a` |
| London | Europe | `europe-west2` | `europe-west2-a` |
| Singapore | Asia | `asia-southeast1` | `asia-southeast1-a` |
| Tokyo | Asia | `asia-northeast1` | `asia-northeast1-a` |
| Mumbai | Asia | `asia-south1` | `asia-south1-a` |
| Sydney | Australia | `australia-southeast1` | `australia-southeast1-a` |
```bash
osmedeus cloud config set providers.gcp.region us-central1
osmedeus cloud config set providers.gcp.zone us-central1-a
```
> **Note:** The zone must be within the selected region.
### Custom Image Family
Use a custom image family with tools pre-installed for faster startup:
```bash
# Default is ubuntu-2204-lts from the ubuntu-os-cloud project
# Use your own custom image family if you have one
osmedeus cloud config set providers.gcp.image_family my-osmedeus-image
```
### Cost Limits
```bash
osmedeus cloud config set limits.max_hourly_spend 1.00
osmedeus cloud config set limits.max_total_spend 10.00
osmedeus cloud config set limits.max_instances 10
```
## Examples
### Quick Domain Recon
```bash
osmedeus cloud run -f fast -t example.com --auto-destroy
```
Cost: ~$0.03 (1 x e2-medium x 1 hour)
### Large-Scale Subdomain Enumeration
```bash
# targets.txt: one domain per line
osmedeus cloud run -f general -T targets.txt --instances 5 --sync-back --auto-destroy
```
Cost: ~$0.48 (5 x n1-standard-2 x 1 hour)
### Custom Nmap Scan
```bash
osmedeus cloud run \
--custom-cmd "nmap -sV -sC {{Target}} -oA /tmp/osm-custom/nmap" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
```
### Distributed Nuclei Scanning
```bash
osmedeus cloud run \
--custom-cmd "cat {{Target}} | nuclei -o /tmp/osm-custom/results.txt" \
--sync-path "/tmp/osm-custom/results.txt" \
--sync-dest "./nuclei-gcp" \
-T urls.txt --instances 10 --auto-destroy
```
Cost: ~$0.34 (10 x e2-medium x 1 hour)
### Preemptible Instance Pipeline
```bash
# Configure preemptible
osmedeus cloud config set providers.gcp.use_preemptible true
osmedeus cloud config set providers.gcp.machine_type n1-standard-2
# Run a heavy scan for cheap
osmedeus cloud run \
--custom-cmd "subfinder -d {{Target}} -all -o /tmp/osm-custom/subs.txt" \
--custom-cmd "cat /tmp/osm-custom/subs.txt | httpx -td -o /tmp/osm-custom/live.txt" \
--custom-cmd "cat /tmp/osm-custom/live.txt | nuclei -o /tmp/osm-custom/nuclei.txt" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
```
Cost: ~$0.019 (1 x n1-standard-2 preemptible x 1 hour)
### Persistent Recon Campaign
```bash
# Create instances once (saves setup time on subsequent runs)
osmedeus cloud create --provider gcp -n 3
# Run scans throughout the day
osmedeus cloud run -f fast -t target1.com --reuse
osmedeus cloud run -f fast -t target2.com --reuse
osmedeus cloud run --custom-cmd "nuclei -u target3.com -o /tmp/osm-custom/nuclei.txt" \
--sync-path "/tmp/osm-custom/" -t target3.com --reuse
# Destroy at end of day
osmedeus cloud destroy all --force
```
### Multi-Region Scanning
```bash
# Scan US targets from Iowa
osmedeus cloud config set providers.gcp.region us-central1
osmedeus cloud config set providers.gcp.zone us-central1-a
osmedeus cloud run -f fast -t us-company.com --auto-destroy
# Scan APAC targets from Singapore
osmedeus cloud config set providers.gcp.region asia-southeast1
osmedeus cloud config set providers.gcp.zone asia-southeast1-a
osmedeus cloud run -f fast -t apac-company.com --auto-destroy
```
## Troubleshooting
### "Permission denied" or "403 Forbidden"
Your service account lacks required permissions. Assign the **Compute Admin** role:
```bash
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:YOUR_SA@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/compute.admin"
```
### "Credentials file not found"
Make sure the JSON key file path is correct and the file exists:
```bash
# Check the file exists
ls -la ~/.gcp/osmedeus-sa.json
# Or set via environment variable
export GCP_CREDENTIALS_FILE=/absolute/path/to/key.json
osmedeus cloud config set providers.gcp.credentials_file ${GCP_CREDENTIALS_FILE}
```
### Instances Not Starting
```bash
# Check with debug output
osmedeus cloud run -f fast -t example.com --debug
# Common causes:
# - Quota exceeded (check Quotas page in Cloud Console)
# - Zone doesn't have the machine type available
# - Compute Engine API not enabled (enable it in APIs & Services)
# - Preemptible capacity unavailable (try a different zone or on-demand)
```
### "Compute Engine API has not been used" Error
Enable the Compute Engine API for your project:
```bash
gcloud services enable compute.googleapis.com --project=YOUR_PROJECT_ID
```
### SSH Connection Timeout
```bash
# Verify firewall rule allows SSH (port 22)
gcloud compute firewall-rules list --filter="name~osmedeus"
# Check with verbose setup
osmedeus cloud run -f fast -t example.com --verbose-setup
```
### Preemptible Instance Terminated
Preemptible VMs are reclaimed after 24 hours or when GCP needs capacity. The scan will fail for that worker. Mitigation:
- Use `--auto-destroy` to clean up
- Re-run the failed targets
- Use on-demand instances for critical or long-running scans
### Cleaning Up
```bash
# List all infrastructure
osmedeus cloud list
# Destroy specific
osmedeus cloud destroy <infra-id>
# Nuclear option
osmedeus cloud destroy all --force
# If osmedeus state is out of sync, check GCP console directly:
# Compute Engine > VM Instances > filter by label "osmedeus"
# Or via gcloud:
gcloud compute instances list --filter="labels.osmedeus:*"
```
## Cost Optimization
1. **Use preemptible instances** for all non-critical scans (`use_preemptible: true`) — up to 80% savings
2. **Right-size machines**: e2-medium is enough for most single-target scans
3. **Always use `--auto-destroy`** to prevent forgotten instances
4. **Set cost limits** to catch runaway spending
5. **Use custom images** to reduce setup time (less instance-hours)
6. **Pick the cheapest region** if target geo-location doesn't matter (us-central1 is usually cheapest)
7. **GCP sustained-use discounts** apply automatically for on-demand VMs running more than 25% of the month
+296
View File
@@ -0,0 +1,296 @@
# Hetzner Provider Guide
Step-by-step guide for running osmedeus cloud on Hetzner Cloud servers. Hetzner offers the lowest cost per instance among all supported providers, making it ideal for high-volume scanning.
## Prerequisites
- A Hetzner Cloud account (https://console.hetzner.cloud)
- An API token
- An SSH key pair (local `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub`)
### Get Your API Token
1. Go to **Hetzner Cloud Console** > select your project (or create one)
2. **Security** > **API Tokens** > **Generate API Token**
3. Set permissions to **Read & Write**
4. Copy the token (shown only once)
You can also store it as an environment variable:
```bash
export HETZNER_API_TOKEN="your-token-here"
```
## Configuration
### Minimal Setup
```bash
# Enable cloud feature
osmedeus config set cloud.enabled true
# Credentials
osmedeus cloud config set providers.hetzner.token ${HETZNER_API_TOKEN}
osmedeus cloud config set providers.hetzner.location hel1
osmedeus cloud config set providers.hetzner.server_type "cx23" # 2 vCPU, 4GB RAM (current generation)
osmedeus cloud config set defaults.provider hetzner
# SSH
osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa
osmedeus cloud config set ssh.public_key_path ~/.ssh/id_rsa.pub
osmedeus cloud config set ssh.user root
# Clean the setup scripts first
osmedeus cloud config set setup.commands.clear ""
# Worker setup
osmedeus cloud config set setup.commands.add "sudo apt-get update"
osmedeus cloud config set setup.commands.add "sudo apt-get install -y -qq curl git tmux unzip jq rsync"
osmedeus cloud config set setup.commands.add "curl -fsSL https://www.osmedeus.org/install.sh | bash"
osmedeus cloud config set setup.commands.add "osmedeus install base --preset"
```
### Server Types
Hetzner's pricing is significantly cheaper than other providers:
| Server Type | vCPU | RAM | Disk | $/hr | $/month | Best For |
|-------------|------|-----|------|------|---------|----------|
| cx22 | 2 | 4 GB | 40 GB | ~$0.007 | ~$4.50 | Light scans, single targets |
| cx32 | 4 | 8 GB | 80 GB | ~$0.013 | ~$8.50 | General scanning |
| cx42 | 8 | 16 GB | 160 GB | ~$0.025 | ~$16.50 | Heavy scans, parallel tools |
| cx52 | 16 | 32 GB | 320 GB | ~$0.050 | ~$33.00 | Large-scale operations |
| cpx21 | 3 | 4 GB | 80 GB | ~$0.008 | ~$5.50 | CPU-optimized scanning |
| cpx31 | 4 | 8 GB | 160 GB | ~$0.015 | ~$10.00 | CPU-optimized, more RAM |
```bash
osmedeus cloud config set providers.hetzner.server_type cx32
```
### Locations
| Location | Code | Region |
|----------|------|--------|
| Falkenstein | `fsn1` | Germany |
| Nuremberg | `nbg1` | Germany |
| Helsinki | `hel1` | Finland |
| Ashburn | `ash` | US East |
| Hillsboro | `hil` | US West |
| Singapore | `sin` | Asia |
```bash
osmedeus cloud config set providers.hetzner.location fsn1
```
### Custom Image
Use a pre-built snapshot for faster boot:
```bash
# After setting up a server manually with all tools:
# Hetzner Console > Servers > your-server > Snapshots > Create Snapshot
# Note the snapshot ID
osmedeus cloud config set providers.hetzner.image 12345678
```
### SSH Key (optional)
If you have an SSH key registered in Hetzner Cloud:
```bash
# Hetzner Console > Security > SSH Keys > note the key name
osmedeus cloud config set providers.hetzner.ssh_key_name my-key-name
```
### Cost Limits
```bash
osmedeus cloud config set limits.max_hourly_spend 0.50
osmedeus cloud config set limits.max_total_spend 5.00
osmedeus cloud config set limits.max_instances 20
```
## Examples
### Quick Domain Recon
```bash
osmedeus cloud run -f fast -t example.com --auto-destroy
```
Cost: ~$0.007 (1 x cx22 x 1 hour) -- less than a penny.
### Budget Bulk Scanning
Hetzner's low prices make it perfect for scanning many targets:
```bash
# 20 workers scanning 200 targets for ~$0.28
osmedeus cloud run \
-f fast -T targets.txt --instances 20 \
--sync-back --auto-destroy
```
Cost: 20 x $0.007 x 2 hours = **$0.28**
### Custom Command Pipeline
```bash
osmedeus cloud run \
--custom-cmd "subfinder -d {{Target}} -o /tmp/osm-custom/subs.txt" \
--custom-cmd "cat /tmp/osm-custom/subs.txt | httpx -o /tmp/osm-custom/live.txt" \
--custom-cmd "cat /tmp/osm-custom/live.txt | nuclei -o /tmp/osm-custom/nuclei.txt" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
```
Cost: ~$0.007
### Distributed Nuclei at Scale
```bash
# Split 10,000 URLs across 10 Hetzner workers
osmedeus cloud run \
--custom-cmd "cat {{Target}} | nuclei -o /tmp/osm-custom/results.txt" \
--sync-path "/tmp/osm-custom/results.txt" \
--sync-dest "./nuclei-hetzner" \
-T urls.txt --instances 10 --auto-destroy
```
Cost: 10 x $0.007 x 1 hour = **$0.07**
### Port Scanning
```bash
# Use a bigger instance for masscan (needs more resources)
osmedeus cloud config set providers.hetzner.server_type cx32
osmedeus cloud run \
--custom-cmd "masscan {{Target}} -p1-65535 --rate 10000 -oG /tmp/osm-custom/masscan.txt" \
--custom-cmd "cat /tmp/osm-custom/masscan.txt | grep 'Host:' | awk '{print \$2\":\" \$5}' | sed 's|/.*||' > /tmp/osm-custom/open-ports.txt" \
--sync-path "/tmp/osm-custom/" \
-t 203.0.113.0/24 --auto-destroy
```
### Persistent Low-Cost Lab
```bash
# Create 5 workers and keep them running all day
osmedeus cloud create --provider hetzner -n 5
# Run multiple scans throughout the day
osmedeus cloud run -f fast -t target1.com --reuse
osmedeus cloud run --custom-cmd "nmap -sV {{Target}}" -t target2.com --reuse
osmedeus cloud run -f general -T targets.txt --reuse
# Destroy at end of day
osmedeus cloud destroy all --force
```
Cost: 5 x $0.007 x 8 hours = **$0.28** for a full day of scanning on 5 machines.
### EU-Based Scanning
Hetzner's European locations are useful when you need scans originating from EU IP space:
```bash
# Use German datacenter
osmedeus cloud config set providers.hetzner.location fsn1
osmedeus cloud run -f fast -t eu-target.com --auto-destroy
# Use Finnish datacenter
osmedeus cloud config set providers.hetzner.location hel1
osmedeus cloud run -f fast -t nordic-target.com --auto-destroy
```
### High-Performance with cx42
```bash
# Use 8 vCPU / 16 GB RAM instance for heavy parallel scanning
osmedeus cloud config set providers.hetzner.server_type cx42
osmedeus cloud run \
--custom-cmd "subfinder -d {{Target}} -all -o /tmp/osm-custom/subs.txt" \
--custom-cmd "cat /tmp/osm-custom/subs.txt | httpx -td -threads 200 -o /tmp/osm-custom/live.txt" \
--custom-cmd "cat /tmp/osm-custom/live.txt | nuclei -c 100 -o /tmp/osm-custom/nuclei.txt" \
--custom-cmd "cat /tmp/osm-custom/live.txt | katana -d 3 -jc -o /tmp/osm-custom/crawl.txt" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
```
Cost: ~$0.025 per hour
## Cost Comparison
Why Hetzner is the cheapest option for bulk scanning:
| Scenario | Hetzner (cx22) | DigitalOcean (s-2vcpu-4gb) | AWS (t3.medium) |
|----------|---------------|---------------------------|-----------------|
| 1 instance x 1 hour | $0.007 | $0.022 | $0.042 |
| 5 instances x 2 hours | $0.07 | $0.22 | $0.42 |
| 10 instances x 4 hours | $0.28 | $0.89 | $1.66 |
| 20 instances x 8 hours | $1.12 | $3.57 | $6.66 |
Hetzner is ~3x cheaper than DigitalOcean and ~6x cheaper than AWS for equivalent specs.
## Troubleshooting
### "Unauthorized" Error
Your API token is invalid or expired. Generate a new one in the Hetzner Cloud Console.
```bash
osmedeus cloud config set providers.hetzner.token <new-token>
```
### Server Type Not Available
Some server types may not be available in all locations. Try a different location:
```bash
osmedeus cloud config set providers.hetzner.location nbg1
```
### SSH Connection Issues
Hetzner servers default to `root` user:
```bash
osmedeus cloud config set ssh.user root
```
Verify your SSH key is correctly configured:
```bash
osmedeus cloud run --custom-cmd "whoami" -t test --verbose-setup
```
### Rate Limiting
Hetzner's API has rate limits. If creating many instances at once, you may hit them. Space out creation or contact Hetzner support to increase limits.
### Cleaning Up
```bash
# List all infrastructure
osmedeus cloud list
# Destroy specific
osmedeus cloud destroy <infra-id>
# Destroy everything
osmedeus cloud destroy all --force
# If out of sync, check Hetzner Console directly:
# Console > Servers > look for osmedeus-prefixed servers
```
## Best Practices
1. **Use cx22 as default** -- 2 vCPU / 4 GB is enough for most scans at $0.007/hr
2. **Scale horizontally** -- 10 x cx22 is cheaper and faster than 1 x cx52 for parallelizable workloads
3. **Always `--auto-destroy`** -- even at $0.007/hr, forgotten instances add up
4. **Use European locations** (fsn1, nbg1) for lowest latency to Hetzner's network
5. **Pre-build snapshots** for frequently-used tool configurations to skip setup time
6. **Set modest cost limits** -- even $5.00 max_total_spend goes a long way at Hetzner pricing
+131 -262
View File
@@ -1,302 +1,171 @@
# Osmedeus Cloud - Quick Reference Card
# Cloud Quick Reference
Quick reference for common `osmedeus cloud` commands. For detailed examples, see [cloud-usage-examples.md](./cloud-usage-examples.md).
---
## 🚀 Quick Start (30 seconds)
## Setup (30 seconds)
```bash
# 1. Set credentials
osmedeus cloud config set providers.digitalocean.token "YOUR_TOKEN"
# Enable cloud feature
osmedeus config set cloud.enabled true
# 2. Set default provider
osmedeus cloud config set defaults.provider "digitalocean"
# Set credentials (pick your provider)
osmedeus cloud config set providers.aws.access_key_id ${AWS_ACCESS_KEY_ID}
osmedeus cloud config set providers.aws.secret_access_key ${AWS_SECRET_ACCESS_KEY}
osmedeus cloud config set providers.aws.region ap-southeast-1
osmedeus cloud config set defaults.provider aws
# 3. Run first cloud scan
osmedeus cloud run -f general -t example.com --instances 3
# SSH keys
osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa
osmedeus cloud config set ssh.public_key_path ~/.ssh/id_rsa.pub
# Clean the setup scripts first
osmedeus cloud config set setup.commands.clear ""
# Worker setup commands
osmedeus cloud config set setup.commands.add "curl -fsSL https://www.osmedeus.org/install.sh | bash"
osmedeus cloud config set setup.commands.add "osmedeus install base --preset"
```
---
## 📝 Configuration Commands
## Configuration
```bash
# View configuration
osmedeus cloud config show
osmedeus cloud config list # View all settings
osmedeus cloud config set <key> <value> # Set a value
osmedeus cloud config set <key>.add <value> # Append to list
osmedeus cloud config clean # Reset to defaults
# Set provider credentials
osmedeus cloud config set providers.digitalocean.token "dop_v1_..."
osmedeus cloud config set providers.aws.access_key_id "AKIA..."
osmedeus cloud config set providers.gcp.project_id "my-project"
# Provider credentials
osmedeus cloud config set providers.<provider>.<key> <value>
# Set defaults
osmedeus cloud config set defaults.provider "digitalocean"
osmedeus cloud config set defaults.max_instances 10
# Instance type
osmedeus cloud config set providers.aws.instance_type t3.large
# Set cost limits
osmedeus cloud config set limits.max_hourly_spend 5.00
osmedeus cloud config set limits.max_total_spend 50.00
# Set instance type
osmedeus cloud config set providers.digitalocean.size "s-2vcpu-4gb"
osmedeus cloud config set providers.aws.instance_type "t3.medium"
# Enable spot/preemptible instances (cost savings)
# Spot instances (70-80% cheaper)
osmedeus cloud config set providers.aws.use_spot true
osmedeus cloud config set providers.gcp.use_preemptible true
# Cost limits
osmedeus cloud config set limits.max_hourly_spend 1.00
osmedeus cloud config set limits.max_total_spend 10.00
osmedeus cloud config set limits.max_instances 10
```
---
## 🏗️ Infrastructure Management
## Infrastructure
```bash
# Create infrastructure
osmedeus cloud create --instances 5
osmedeus cloud create --provider aws --instances 10
osmedeus cloud create --instances 3 --force
# List active infrastructure
osmedeus cloud list
# Destroy infrastructure
osmedeus cloud destroy <infrastructure-id>
osmedeus cloud destroy --all
osmedeus cloud destroy <id> --force
osmedeus cloud create --provider aws -n 3 # Create instances
osmedeus cloud list # List active infra
osmedeus cloud destroy <infra-id> # Destroy by ID
osmedeus cloud destroy all --force # Destroy everything
osmedeus cloud setup --reuse-with "1.2.3.4,5.6.7.8" # Setup existing machines
```
---
## ▶️ Running Workflows
## Workflow Mode
```bash
# Run flow on single target
osmedeus cloud run -f general -t example.com --instances 3
# Basic
osmedeus cloud run -f fast -t example.com
osmedeus cloud run -m enum-subdomain -t example.com --timeout 30m
# Run module on single target
osmedeus cloud run -m subdomain-enumeration -t example.com --instances 5
# Multiple instances
osmedeus cloud run -f general -t example.com --instances 3 --provider aws
# Run on multiple targets from file
osmedeus cloud run -f general -T targets.txt --instances 10
# Multiple targets distributed across workers
osmedeus cloud run -f fast -T targets.txt --instances 5
osmedeus cloud run -f fast -T targets.txt --chunk-size 10 # 10 targets per worker
osmedeus cloud run -f fast -T targets.txt --chunk-count 3 # Split into 3 chunks
# Run with specific provider
osmedeus cloud run -f general -t example.com --provider aws --instances 5
# Reuse existing infrastructure
osmedeus cloud run -f fast -t example.com --reuse
osmedeus cloud run -f fast -t example.com --reuse-with "1.2.3.4,5.6.7.8"
# Run with concurrent targets
osmedeus cloud run -f general -T targets.txt -c 10 --instances 5
# Run with timeout
osmedeus cloud run -f general -t example.com --instances 3 --timeout 2h
# Sync results back + auto-destroy
osmedeus cloud run -f fast -t example.com --sync-back --auto-destroy
```
---
## Custom Command Mode
## 💰 Cost Estimates (per hour)
### DigitalOcean
```
s-1vcpu-1gb: $0.00744/hr ($0.18/day) [1 vCPU, 1GB RAM]
s-2vcpu-4gb: $0.02232/hr ($0.54/day) [2 vCPU, 4GB RAM] ⭐ Default
s-4vcpu-8gb: $0.04464/hr ($1.07/day) [4 vCPU, 8GB RAM]
s-8vcpu-16gb: $0.08928/hr ($2.14/day) [8 vCPU, 16GB RAM]
```
### AWS (On-Demand)
```
t3.micro: $0.0104/hr ($0.25/day) [2 vCPU, 1GB RAM]
t3.medium: $0.0416/hr ($1.00/day) [2 vCPU, 4GB RAM] ⭐ Default
t3.large: $0.0832/hr ($2.00/day) [2 vCPU, 8GB RAM]
t3.xlarge: $0.1664/hr ($4.00/day) [4 vCPU, 16GB RAM]
Spot instances: ~70% savings
```
### GCP
```
f1-micro: $0.0076/hr ($0.18/day) [0.6 vCPU, 0.6GB RAM]
n1-standard-1: $0.0475/hr ($1.14/day) [1 vCPU, 3.75GB RAM]
n1-standard-2: $0.0950/hr ($2.28/day) [2 vCPU, 7.5GB RAM] ⭐ Default
n1-standard-4: $0.1900/hr ($4.56/day) [4 vCPU, 15GB RAM]
Preemptible: ~80% savings
```
**Cost Calculator:**
```
Total Cost = (Hourly Rate × Number of Instances × Runtime Hours)
Example:
5 × s-2vcpu-4gb × 2 hours = 5 × $0.02232 × 2 = $0.22
```
---
## 🎯 Common Workflows
### Bug Bounty Recon
```bash
osmedeus cloud run -m subdomain-enumeration -T targets.txt --instances 10 --timeout 2h
```
### Repository Audit
```bash
osmedeus cloud run -f repo -T repos.txt -c 20 --instances 20 --timeout 6h
```
### IP Range Scanning
```bash
osmedeus cloud run -f ip-scanning -T cidr.txt --instances 15 --timeout 4h
```
### Large Campaign (Persistent Infrastructure)
```bash
# Step 1: Create once
osmedeus cloud create --instances 20
# Step 2: Run multiple scans
osmedeus run -f general -T batch-1.txt -c 10
osmedeus run -f general -T batch-2.txt -c 10
# Step 3: Destroy when done
osmedeus cloud destroy --all
```
---
## 🌍 Provider Regions
### DigitalOcean
`nyc1, nyc2, nyc3, sfo1, sfo2, sfo3, ams2, ams3, sgp1, lon1, fra1, tor1, blr1`
### AWS
`us-east-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-1, ap-northeast-1`
### GCP
`us-central1, us-east1, europe-west1, asia-southeast1`
---
## ⚙️ Environment Variables
Run arbitrary commands on cloud instances (mutually exclusive with `-f`/`-m`):
```bash
# Set credentials via environment
export DIGITALOCEAN_TOKEN="dop_v1_..."
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export GCP_PROJECT_ID="my-project"
# Single command
osmedeus cloud run --custom-cmd "nmap -sV {{Target}}" -t example.com
# Reference in config
osmedeus cloud config set providers.digitalocean.token '${DIGITALOCEAN_TOKEN}'
osmedeus cloud config set providers.aws.access_key_id '${AWS_ACCESS_KEY_ID}'
# Multiple sequential commands
osmedeus cloud run \
--custom-cmd "subfinder -d {{Target}} -o /tmp/osm-custom/subs.txt" \
--custom-cmd "cat /tmp/osm-custom/subs.txt | httpx -o /tmp/osm-custom/live.txt" \
-t example.com
# Post-commands (run only if all custom-cmds succeed)
osmedeus cloud run \
--custom-cmd "nuclei -u {{Target}} -o /tmp/osm-custom/results.txt" \
--custom-post-cmd "cat /tmp/osm-custom/results.txt | notify" \
-t example.com
# Sync results back
osmedeus cloud run \
--custom-cmd "nmap -sV {{Target}} -oA /tmp/osm-custom/scan" \
--sync-path "/tmp/osm-custom/" \
--sync-dest "./my-results" \
-t example.com
# Distribute targets across workers
osmedeus cloud run \
--custom-cmd "cat {{Target}} | httpx -o /tmp/osm-custom/live.txt" \
--sync-path "/tmp/osm-custom/live.txt" \
-T targets.txt --instances 5 --auto-destroy
```
---
### Template Variables
## 🔧 Troubleshooting
| Variable | Description |
|----------|-------------|
| `{{Target}}` | Target string or chunk file path (with `-T`) |
| `{{public_ip}}` | Worker's public IP |
| `{{private_ip}}` | Worker's private IP |
| `{{worker_name}}` | Resource name |
| `{{worker_id}}` | Cloud resource ID |
| `{{infra_id}}` | Infrastructure ID |
| `{{provider}}` | Provider name |
| `{{ssh_user}}` | SSH username |
| `{{index}}` | Worker index (0, 1, 2, ...) |
```bash
# Debug mode
osmedeus --debug cloud run -f general -t example.com --instances 3
### Behavior
# View logs
tail -f ~/osmedeus-base/logs/osmedeus-*.log
- Commands run in `/tmp/osm-custom/` on the remote
- Custom-cmds run sequentially per worker, in parallel across workers
- First failure stops remaining commands and skips post-cmds for that worker
- Sync downloads to: `<sync-dest>/<worker_name>-<ip>/<remote_path>`
# List infrastructure states
ls -la ~/osmedeus-base/cloud-state/infrastructure/
## Flags Reference
# Emergency cleanup
osmedeus cloud destroy --all --force
| Flag | Short | Description |
|------|-------|-------------|
| `--flow` | `-f` | Flow workflow name |
| `--module` | `-m` | Module workflow name |
| `--target` | `-t` | Single target |
| `--target-file` | `-T` | File containing targets |
| `--provider` | `-p` | Cloud provider |
| `--instances` | `-n` | Number of instances |
| `--timeout` | | Scan timeout (e.g., `2h`, `30m`) |
| `--auto-destroy` | | Destroy infrastructure after completion |
| `--reuse` | | Auto-discover existing infrastructure |
| `--reuse-with` | | Reuse specific IPs (comma-separated) |
| `--sync-back` | | Download workflow results (workflow mode) |
| `--verbose-setup` | | Show full setup command output |
| `--ansible` | | Use Ansible playbook for setup |
| `--chunk-size` | | Targets per worker chunk |
| `--chunk-count` | | Split targets into N chunks |
| `--custom-cmd` | | Custom command (repeatable) |
| `--custom-post-cmd` | | Post-command (repeatable) |
| `--sync-path` | | Remote path to download (repeatable) |
| `--sync-dest` | | Local sync directory (default: `./osm-sync-back`) |
# Manual provider cleanup
doctl compute droplet list | grep osmedeus
aws ec2 describe-instances --filters "Name=tag:osmedeus,Values=*"
gcloud compute instances list | grep osmedeus
```
## Cost Reference
---
## 🎨 Advanced Features
### Custom Snapshots
```bash
# Use pre-baked VM image (boot in 30s vs 5min)
osmedeus cloud config set providers.digitalocean.snapshot_id "123456789"
```
### Custom Worker Setup
```bash
# Run custom commands on worker boot
osmedeus cloud config set setup.commands[0] "apt-get update"
osmedeus cloud config set setup.commands[1] "pip3 install custom-tool"
```
### SSH Configuration
```bash
osmedeus cloud config set ssh.private_key_path "~/.ssh/cloud_rsa"
osmedeus cloud config set ssh.user "root"
```
---
## 📊 Cost Management Best Practices
1. **Always set limits:**
```bash
osmedeus cloud config set limits.max_hourly_spend 5.00
osmedeus cloud config set limits.max_total_spend 50.00
```
2. **Use spot/preemptible instances:**
```bash
osmedeus cloud config set providers.aws.use_spot true # 70% savings
osmedeus cloud config set providers.gcp.use_preemptible true # 80% savings
```
3. **Start small, scale up:**
```bash
# Test with 1-2 instances first
osmedeus cloud run -f general -t example.com --instances 2
```
4. **Clean up after use:**
```bash
osmedeus cloud destroy --all
```
5. **Monitor costs:**
- Check cost estimates before creating infrastructure
- Cloud run shows real-time cost tracking during execution
---
## 📚 More Information
- **Detailed Examples:** [cloud-usage-examples.md](./cloud-usage-examples.md)
- **Architecture:** [cloud-usage-guide.md](./cloud-usage-guide.md)
- **Tests:** [../test/e2e/CLOUD_TESTS_README.md](../test/e2e/CLOUD_TESTS_README.md)
- **Config Template:** [../public/presets/cloud-settings.example.yaml](../public/presets/cloud-settings.example.yaml)
---
## 🆘 Getting Help
```bash
# Command help
osmedeus cloud --help
osmedeus cloud config --help
osmedeus cloud create --help
osmedeus cloud run --help
# Full usage examples
osmedeus --usage-example
# GitHub Issues
https://github.com/j3ssie/osmedeus/issues
```
---
**Version:** v5.0+
**License:** MIT
**Author:** @j3ssie
| Provider | Instance | vCPU | RAM | Hourly |
|----------|----------|------|-----|--------|
| Hetzner | cx22 | 2 | 4 GB | ~$0.007 |
| Linode | g6-standard-2 | 2 | 4 GB | $0.018 |
| DigitalOcean | s-2vcpu-4gb | 2 | 4 GB | $0.02232 |
| AWS | t3.medium | 2 | 4 GB | $0.0416 |
| GCP | n1-standard-2 | 2 | 7.5 GB | $0.095 |
| Azure | Standard_B2s | 2 | 4 GB | $0.042 |
File diff suppressed because it is too large Load Diff
+301 -312
View File
@@ -1,394 +1,383 @@
# Cloud Infrastructure Usage Guide
# Cloud Usage Guide
> 📚 **For detailed examples and advanced usage, see [Cloud Usage Examples](./cloud-usage-examples.md)**
Osmedeus Cloud provisions virtual machines across cloud providers and runs security workflows or arbitrary commands on them. This guide covers the architecture, configuration, and operational patterns.
This guide provides an overview of the cloud infrastructure feature. For comprehensive examples with copy-paste commands, detailed provider configurations, cost calculations, and troubleshooting, refer to the [Cloud Usage Examples](./cloud-usage-examples.md) documentation.
## How It Works
## Quick Start
### 1. Enable Cloud Features
Edit `~/osmedeus-base/osm-settings.yaml`:
```yaml
cloud:
cloud_path: "{{base_folder}}/cloud"
cloud_settings: "{{base_folder}}/cloud/cloud-settings.yaml"
enabled: true # Set to true
```
Local Machine Cloud Provider
┌──────────────┐ ┌──────────────────┐
│ osmedeus │ 1. Provision │ Worker VM 1 │
│ cloud run │ ──────────────────► │ ┌────────────┐ │
│ │ 2. SSH setup │ │ osmedeus │ │
│ │ ──────────────────► │ │ + tools │ │
│ │ 3. Stream output │ └────────────┘ │
│ │ ◄────────────────── │ │
│ │ ├──────────────────┤
│ │ (same for each) │ Worker VM 2 │
│ │ ◄──────────────────► │ ... │
│ │ ├──────────────────┤
│ │ 4. Sync results │ Worker VM N │
│ │ ◄────────────────── │ ... │
│ │ 5. Destroy └──────────────────┘
└──────────────┘
```
### 2. Configure Cloud Provider
**Lifecycle:**
1. **Provision** -- Create VMs via Pulumi (or reuse existing ones)
2. **Setup** -- SSH into each worker, run setup commands (install osmedeus, tools, etc.)
3. **Execute** -- Run workflow or custom commands, stream output back in real time
4. **Sync** -- Download results to local machine (optional)
5. **Destroy** -- Tear down infrastructure (optional, can be automatic)
## Supported Providers
| Provider | Config Key | Instance Types |
|----------|-----------|----------------|
| AWS | `aws` | t3.medium, t3.large, t3.xlarge |
| DigitalOcean | `digitalocean` | s-2vcpu-4gb, s-4vcpu-8gb, s-8vcpu-16gb |
| GCP | `gcp` | n1-standard-2, n1-standard-4 |
| Hetzner | `hetzner` | cx22, cx32, cx42 |
| Linode | `linode` | g6-standard-2, g6-standard-4 |
| Azure | `azure` | Standard_B2s, Standard_D2s_v3 |
## Configuration
Cloud config lives in `~/.osmedeus/cloud/cloud-settings.yaml`. Manage it with:
```bash
# Set default provider
osmedeus cloud config set defaults.provider digitalocean
# Set a value
osmedeus cloud config set <key> <value>
# Set DigitalOcean credentials
osmedeus cloud config set providers.digitalocean.token ${DIGITALOCEAN_TOKEN}
osmedeus cloud config set providers.digitalocean.region nyc1
osmedeus cloud config set providers.digitalocean.size s-2vcpu-4gb
# View current config
osmedeus cloud config list
# Set cost limits
osmedeus cloud config set limits.max_hourly_spend 10.0
osmedeus cloud config set limits.max_total_spend 100.0
# Reset to defaults
osmedeus cloud config clean
```
Alternatively, manually create `~/osmedeus-base/cloud/cloud-settings.yaml` using the example in `docs/cloud-settings.example.yaml`.
### Required Configuration
### 3. Verify Configuration
Every provider needs four things: **cloud enabled**, **credentials**, **SSH keys**, and **setup commands**.
```bash
# Show current config
osmedeus cloud config show
# 0. Enable cloud feature
osmedeus config set cloud.enabled true
# Check estimated cost for 5 instances
# (will be implemented in cloud create command)
# 1. Provider credentials (example: AWS)
osmedeus cloud config set providers.aws.access_key_id ${AWS_ACCESS_KEY_ID}
osmedeus cloud config set providers.aws.secret_access_key ${AWS_SECRET_ACCESS_KEY}
osmedeus cloud config set providers.aws.region ap-southeast-1
# 2. SSH keys (used to connect to workers)
osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa
osmedeus cloud config set ssh.public_key_path ~/.ssh/id_rsa.pub
# 3. Clean the setup scripts first, then add setup commands (run on each worker before scanning)
osmedeus cloud config set setup.commands.clear ""
osmedeus cloud config set setup.commands.add "curl -fsSL https://www.osmedeus.org/install.sh | bash"
osmedeus cloud config set setup.commands.add "osmedeus install base --preset"
# 4. Set default provider
osmedeus cloud config set defaults.provider aws
```
## Usage Scenarios
### Scenario 1: One-Off Distributed Scan
Provision infrastructure, run scan, collect results, and destroy in one command:
### Optional Configuration
```bash
# Run general reconnaissance on example.com using 5 cloud workers
osmedeus cloud run -f general -t example.com --instances 5
# Instance type
osmedeus cloud config set providers.aws.instance_type t3.large
# Or use multiple targets
osmedeus cloud run -f general -T targets.txt --instances 10
# Use spot/preemptible instances (70-80% cheaper)
osmedeus cloud config set providers.aws.use_spot true
# Cost limits
osmedeus cloud config set limits.max_hourly_spend 1.00
osmedeus cloud config set limits.max_total_spend 10.00
osmedeus cloud config set limits.max_instances 10
# Default timeout
osmedeus cloud config set defaults.timeout 2h
# SSH user (default: root for most providers, ubuntu for AWS)
osmedeus cloud config set ssh.user root
```
**What happens:**
1. Validates cost limits
2. Provisions 5 VMs on DigitalOcean
3. Waits for workers to register (auto-join via cloud-init)
4. Distributes workflow tasks across workers
5. Monitors progress
6. Collects results via SSH to master workspace
7. Destroys infrastructure
8. Shows final cost summary
### Post-Setup Commands
### Scenario 2: Manual Infrastructure Management
For more control, manage infrastructure lifecycle manually:
Post-setup commands run per-worker after the main setup, with template variables expanded:
```bash
# 1. Create infrastructure
osmedeus cloud create --instances 5
osmedeus cloud config set setup.post_commands.add "echo 'Worker {{index}} ready at {{public_ip}}'"
```
# 2. Verify workers joined
osmedeus worker status
# Should show 5 workers with wosm-<ip> IDs
Available variables: `{{public_ip}}`, `{{private_ip}}`, `{{worker_name}}`, `{{worker_id}}`, `{{infra_id}}`, `{{provider}}`, `{{ssh_user}}`, `{{index}}`
# 3. Run workflows (uses existing workers)
osmedeus run -f general -t example.com
osmedeus run -m recon/httprobe -T targets.txt
## Two Execution Modes
# 4. List cloud infrastructure
### Workflow Mode (default)
Runs an osmedeus flow or module on remote workers:
```bash
# Run a flow
osmedeus cloud run -f fast -t example.com
# Run a module
osmedeus cloud run -m enum-subdomain -t example.com
```
### Custom Command Mode
Runs arbitrary shell commands on remote workers -- no osmedeus workflow required:
```bash
osmedeus cloud run --custom-cmd "nmap -sV {{Target}} -oA /tmp/osm-custom/nmap" -t example.com
```
`--custom-cmd` is mutually exclusive with `-f`/`-m`. See [Custom Command Mode](#custom-command-mode-details) below.
## Infrastructure Management
### Provisioning
```bash
# Provision with cloud run (creates + runs + optional destroy)
osmedeus cloud run -f fast -t example.com --instances 3
# Provision separately (no scan)
osmedeus cloud create --provider aws -n 3
```
### Listing
```bash
osmedeus cloud list
# 5. When done, destroy infrastructure
osmedeus cloud destroy
```
### Scenario 3: Multi-Target Campaign
Run large-scale reconnaissance across many targets:
### Reusing Existing Infrastructure
```bash
# Prepare target list
echo "hackerone.com" > targets.txt
echo "bugcrowd.com" >> targets.txt
echo "synack.com" >> targets.txt
# Auto-discover from saved state
osmedeus cloud run -f fast -t example.com --reuse
# Create infrastructure with max instances
osmedeus cloud create --instances 20
# Run parallel scans (each target gets distributed to workers)
osmedeus run -f general -T targets.txt -c 5
# Monitor progress
osmedeus worker status
# Results are in ~/workspaces-osmedeus/
ls -lh ~/workspaces-osmedeus/
# Cleanup
osmedeus cloud destroy
# Specify IPs directly
osmedeus cloud run -f fast -t example.com --reuse-with "1.2.3.4,5.6.7.8"
```
### Scenario 4: Provider Override
Use a different provider for specific tasks:
### Destroying
```bash
# Create AWS infrastructure instead of default
osmedeus cloud create --provider aws --instances 3
# Destroy specific infrastructure
osmedeus cloud destroy <infra-id>
# Or override in run command
osmedeus cloud run -f general -t example.com --provider gcp --instances 10
# Destroy all
osmedeus cloud destroy all --force
```
## Target Distribution
When scanning multiple targets across multiple workers, osmedeus splits the target list into chunks:
```bash
# 100 targets across 5 workers = 20 targets each
osmedeus cloud run -f fast -T targets.txt --instances 5
# Control chunk size: 10 targets per worker
osmedeus cloud run -f fast -T targets.txt --instances 10 --chunk-size 10
# Control chunk count: split into exactly 3 chunks
osmedeus cloud run -f fast -T targets.txt --instances 5 --chunk-count 3
```
Each worker receives its chunk as a file at `/tmp/osm-targets-{i}.txt` on the remote machine.
## Custom Command Mode Details
Run any commands on cloud instances without using osmedeus workflows. Commands run in `/tmp/osm-custom/` on the remote.
### Flags
| Flag | Description |
|------|-------------|
| `--custom-cmd` | Command to run (repeatable, sequential per worker) |
| `--custom-post-cmd` | Runs after all custom-cmds succeed (repeatable) |
| `--sync-path` | Remote path to download after execution (repeatable) |
| `--sync-dest` | Local base directory for downloads (default: `./osm-sync-back`) |
### Template Variables
All commands and sync paths support these variables:
| Variable | Description | Example |
|----------|-------------|---------|
| `{{Target}}` | Target string, or chunk file path with `-T` | `example.com` or `/tmp/osm-targets-0.txt` |
| `{{public_ip}}` | Worker's public IP | `203.0.113.10` |
| `{{private_ip}}` | Worker's private IP | `10.0.0.5` |
| `{{worker_name}}` | Resource name | `osmw-1775159841-0` |
| `{{worker_id}}` | Cloud resource ID | `i-0437adf5...` |
| `{{infra_id}}` | Infrastructure ID | `cloud-aws-1775159841` |
| `{{provider}}` | Provider name | `aws` |
| `{{ssh_user}}` | SSH username | `ubuntu` |
| `{{index}}` | Worker index | `0`, `1`, `2` |
### Execution Rules
- Custom-cmds run **sequentially** on each worker, but **in parallel** across workers
- If any `--custom-cmd` fails (non-zero exit), remaining commands and all `--custom-post-cmd` are skipped for that worker
- Post-cmd failures are logged but do not affect other workers
### Sync-Back
Downloaded files are placed at: `<sync-dest>/<worker_name>-<ip>/<remote_path>`
For example, `--sync-path /tmp/osm-custom/results.txt` from worker `osmw-0` at `1.2.3.4`:
```
./osm-sync-back/osmw-0-1.2.3.4/tmp/osm-custom/results.txt
```
### Examples
```bash
# Simple: run nmap on a cloud instance
osmedeus cloud run \
--custom-cmd "nmap -sV {{Target}} -oA /tmp/osm-custom/nmap-result" \
--sync-path "/tmp/osm-custom/" \
-t example.com --auto-destroy
# Multi-step pipeline with post-processing
osmedeus cloud run \
--custom-cmd "subfinder -d {{Target}} -o /tmp/osm-custom/subs.txt" \
--custom-cmd "cat /tmp/osm-custom/subs.txt | httpx -o /tmp/osm-custom/live.txt" \
--custom-post-cmd "wc -l /tmp/osm-custom/live.txt" \
--sync-path "/tmp/osm-custom/subs.txt" \
--sync-path "/tmp/osm-custom/live.txt" \
-t example.com
# Distribute target list across 5 workers
osmedeus cloud run \
--custom-cmd "cat {{Target}} | nuclei -o /tmp/osm-custom/nuclei.txt" \
--sync-path "/tmp/osm-custom/nuclei.txt" \
--sync-dest "./nuclei-results" \
-T targets.txt --instances 5 --auto-destroy
```
## Syncing Results
### Workflow Mode: `--sync-back`
Exports osmedeus workspaces (including database state) from remote workers and imports them locally:
```bash
osmedeus cloud run -f fast -t example.com --sync-back
```
### Custom Mode: `--sync-path`
Downloads specific files or directories via SFTP:
```bash
osmedeus cloud run --custom-cmd "..." --sync-path "/tmp/osm-custom/" -t example.com
```
## Cost Management
### Understanding Costs
### Pre-Provisioning Estimates
Costs are estimated before provisioning. Set limits to prevent overspending:
```bash
# DigitalOcean pricing examples:
# s-1vcpu-1gb: $5/month = $0.00744/hour
# s-2vcpu-4gb: $15/month = $0.02232/hour
# s-4vcpu-8gb: $30/month = $0.04464/hour
# For 5 x s-2vcpu-4gb instances:
# Hourly: 5 × $0.02232 = $0.1116/hour
# Daily: $0.1116 × 24 = $2.6784/day
osmedeus cloud config set limits.max_hourly_spend 1.00
osmedeus cloud config set limits.max_total_spend 10.00
osmedeus cloud config set limits.max_instances 10
```
### Cost Limits
### Spot/Preemptible Instances
Limits are enforced at two stages:
1. **Pre-provisioning**: Checks `max_hourly_spend` before creating infrastructure
2. **During execution**: Checks `max_total_spend` every 30 seconds
Save 70-80% on instance costs:
```bash
# Set conservative limits for testing
osmedeus cloud config set limits.max_hourly_spend 0.5
osmedeus cloud config set limits.max_total_spend 5.0
# AWS spot instances
osmedeus cloud config set providers.aws.use_spot true
# This will fail if it would exceed limits
osmedeus cloud create --instances 50
# Error: estimated hourly cost ($1.116) exceeds limit ($0.50)
# GCP preemptible instances
osmedeus cloud config set providers.gcp.use_preemptible true
```
### Cost Monitoring
### Cost Reference
During execution, you'll see cost updates:
```
[INFO] Creating Cloud Infrastructure
[INFO] Provider: digitalocean, Mode: vm, Instances: 5
[INFO] Estimated cost: $0.11/hour ($2.68/day)
[INFO] Provisioning 5 droplets...
[INFO] Waiting for workers... (3/5 registered)
[INFO] All workers ready!
[INFO] Running workflow...
[INFO] Cost: Elapsed: 0h 15m | Current: $0.03 | Rate: $0.11/hr
[SUCCESS] Workflow complete!
[INFO] Final cost: $0.05 (18 minutes)
```
| Provider | Instance | vCPU | RAM | Hourly |
|----------|----------|------|-----|--------|
| Hetzner | cx22 | 2 | 4 GB | ~$0.007 |
| Linode | g6-standard-2 | 2 | 4 GB | $0.018 |
| DigitalOcean | s-2vcpu-4gb | 2 | 4 GB | $0.02232 |
| AWS | t3.medium | 2 | 4 GB | $0.0416 |
| GCP | n1-standard-2 | 2 | 7.5 GB | $0.095 |
| Azure | Standard_B2s | 2 | 4 GB | $0.042 |
## Worker Management
**Example:** 5 DigitalOcean s-2vcpu-4gb instances for 2 hours = 5 x $0.02232 x 2 = **$0.22**
### Worker Auto-Registration
## Worker Setup
Cloud VMs automatically register as workers via cloud-init script:
Workers are set up via SSH after provisioning. The setup flow:
1. **Cloud-init** (automatic): Installs SSH keys, basic packages
2. **Setup commands** (`setup.commands`): Install osmedeus, tools, base data
3. **Post-setup commands** (`setup.post_commands`): Per-worker configuration with template variables
### Ansible Alternative
For complex setups, use Ansible instead of SSH commands:
```bash
#!/bin/bash
# Installed on boot by cloud provider
# Install osmedeus
curl -fsSL https://www.osmedeus.org/install.sh | bash
# Join master as worker
osmedeus worker join --redis-url redis://master:6379 --get-public-ip
# Worker ID: wosm-203.0.113.42
osmedeus cloud config set setup.ansible.enabled true
osmedeus cloud config set setup.ansible.playbook_path /path/to/playbook.yaml
osmedeus cloud run -f fast -t example.com --ansible
```
### Monitoring Workers
### Setup on Existing Machines
```bash
# List all workers
osmedeus worker status
# Example output:
# ID STATUS TASKS IP JOINED
# wosm-203.0.113.1 idle 5/0 203.0.113.1 2m ago
# wosm-203.0.113.2 busy 3/0 203.0.113.2 2m ago
# wosm-203.0.113.3 idle 7/0 203.0.113.3 2m ago
```
### Debugging Worker Issues
```bash
# If workers don't register:
# 1. Check cloud infrastructure status
osmedeus cloud list
# 2. SSH into a VM manually
ssh root@<vm-ip>
# 3. Check osmedeus worker logs
journalctl -u osmedeus-worker -f
# 4. Check Redis connectivity
redis-cli -h <master-redis-ip> ping
```
## Advanced Configuration
### Custom Worker Setup
Add custom commands to run on worker boot:
```yaml
# In cloud-settings.yaml
setup:
commands:
- "apt-get update && apt-get install -y custom-tool"
- "echo 'export CUSTOM_VAR=value' >> ~/.bashrc"
- "cp /path/to/config /etc/config"
```
### Using Custom Snapshots
Pre-bake VMs with all tools installed for faster boot:
```bash
# 1. Create a VM manually
# 2. Install osmedeus and all tools
# 3. Create snapshot via provider console
# 4. Get snapshot ID
# Configure to use snapshot
osmedeus cloud config set providers.digitalocean.snapshot_id <snapshot-id>
# Now VMs boot with everything pre-installed
osmedeus cloud create --instances 5
# Boot time: ~30s instead of ~5min
```
### SSH Key Management
```bash
# Option 1: Use existing SSH key
osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa
# Option 2: Generate new key for cloud workers
ssh-keygen -t rsa -b 4096 -f ~/.ssh/osmedeus-cloud -N ""
osmedeus cloud config set ssh.private_key_path ~/.ssh/osmedeus-cloud
osmedeus cloud config set ssh.public_key_path ~/.ssh/osmedeus-cloud.pub
osmedeus cloud setup --reuse-with "1.2.3.4,5.6.7.8"
```
## Troubleshooting
### Issue: Workers don't register
### Workers Not Connecting
**Possible causes:**
1. Redis URL not reachable from worker VMs
2. Cloud-init script failed
3. Network/firewall blocking connection
**Solution:**
```bash
# Check infrastructure status
osmedeus cloud list
# Verbose setup to see SSH output
osmedeus cloud run -f fast -t example.com --verbose-setup
# SSH into a VM
ssh root@<vm-ip>
# Check cloud-init logs
tail -f /var/log/cloud-init-output.log
# Check worker process
ps aux | grep osmedeus
# Debug mode for full logging
osmedeus cloud run -f fast -t example.com --debug
```
### Issue: Cost exceeded during execution
### Infrastructure Stuck
**What happens:**
- Infrastructure is immediately destroyed
- Partial results are collected
- Error message shows final cost
**Prevention:**
```bash
# Set realistic limits
osmedeus cloud config set limits.max_total_spend 50.0
# Estimate before running
# (5 instances × $0.02/hr × 2 hours = $0.20)
```
### Issue: Infrastructure not destroyed
**Recovery:**
```bash
# List all infrastructure
osmedeus cloud list
# Destroy by ID
osmedeus cloud destroy <infrastructure-id>
# Force destroy everything
osmedeus cloud destroy all --force
```
# Or destroy directly via provider console
# (check cloud-state/*.json for resource IDs)
### Cost Exceeded
If cost limits are hit, provisioning is blocked. Adjust limits:
```bash
osmedeus cloud config set limits.max_hourly_spend 5.00
```
## Best Practices
1. **Start small**: Test with 1-2 instances before scaling up
2. **Set cost limits**: Always configure max_hourly_spend and max_total_spend
3. **Use snapshots**: Pre-bake images for faster provisioning
4. **Clean up**: Always destroy infrastructure when done
5. **Monitor costs**: Check cloud provider billing dashboard
6. **Secure Redis**: Use password authentication for Redis in production
7. **SSH keys**: Use dedicated keys for cloud workers, not personal keys
## Examples
### Example 1: Quick Domain Recon
```bash
osmedeus cloud run -m recon/httprobe -t example.com --instances 3
```
### Example 2: Large-Scale Asset Discovery
```bash
# Prepare target list
cat targets.txt
# example1.com
# example2.com
# ...
# example100.com
# Run distributed scan
osmedeus cloud run -f general -T targets.txt --instances 20
```
### Example 3: Custom Workflow with Specific Provider
```bash
osmedeus cloud run \
-f custom-workflow \
-t target.com \
--provider aws \
--mode vm \
--instances 10
```
## Current Limitations
⚠️ **Note**: As of this implementation, the following features are **foundational** and require completion:
1. **DigitalOcean droplet creation**: Pulumi program needs completion
2. **Cloud run workflow**: Task distribution and monitoring needs implementation
3. **Result collection**: SSH sync needs integration
4. **Other providers**: AWS, GCP, Linode, Azure need implementation
The CLI commands and infrastructure are in place, but will show "not yet fully implemented" errors until the above are completed.
## Getting Help
```bash
# Show cloud command help
osmedeus cloud --help
# Show specific subcommand help
osmedeus cloud config --help
osmedeus cloud create --help
osmedeus cloud run --help
# Check configuration
osmedeus cloud config show
# List available providers
# (Currently: digitalocean, aws, gcp, linode, azure)
```
1. **Always set cost limits** before running large-scale scans
2. **Use `--auto-destroy`** to avoid forgotten instances accruing charges
3. **Use spot instances** for non-critical scans (70-80% savings)
4. **Use `--reuse`** to avoid re-provisioning for iterative work
5. **Start small** -- test with 1 instance before scaling up
6. **Use custom snapshots** with tools pre-installed to cut setup time from 5min to 30s
7. **Check `cloud list`** regularly to verify no orphaned infrastructure
+49 -14
View File
@@ -5,6 +5,10 @@ go 1.26.0
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/alecthomas/chroma/v2 v2.21.1
github.com/aws/aws-sdk-go-v2/config v1.32.13
github.com/aws/aws-sdk-go-v2/credentials v1.19.13
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
@@ -26,8 +30,11 @@ require (
github.com/google/uuid v1.6.0
github.com/hashicorp/go-getter/v2 v2.2.3
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/hetznercloud/hcloud-go/v2 v2.37.0
github.com/itchyny/gojq v0.12.18
github.com/j3ssie/go-agent-agnostic v0.0.0-20260331165354-91ae454341b6
github.com/json-iterator/go v1.1.12
github.com/linode/linodego v1.67.0
github.com/mattn/go-runewidth v0.0.19
github.com/mattn/go-sqlite3 v1.14.32
github.com/minio/minio-go/v7 v7.0.97
@@ -35,7 +42,14 @@ require (
github.com/orivej/go-nix v0.0.0-20180830055821-dae45d921a44
github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2
github.com/pulumi/pulumi-aws/sdk/v6 v6.83.2
github.com/pulumi/pulumi-azure-native-sdk/compute/v2 v2.92.0
github.com/pulumi/pulumi-azure-native-sdk/network/v2 v2.92.0
github.com/pulumi/pulumi-azure-native-sdk/resources/v2 v2.92.0
github.com/pulumi/pulumi-digitalocean/sdk/v4 v4.57.0
github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1
github.com/pulumi/pulumi-hcloud/sdk v1.32.1
github.com/pulumi/pulumi-linode/sdk/v4 v4.39.0
github.com/pulumi/pulumi/sdk/v3 v3.220.0
github.com/redis/rueidis v1.0.70
github.com/robfig/cron/v3 v3.0.1
@@ -49,11 +63,11 @@ require (
github.com/uptrace/bun/driver/sqliteshim v1.2.16
github.com/valyala/fastjson v1.6.7
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.39.0
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -72,6 +86,17 @@ require (
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -107,12 +132,13 @@ require (
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.4 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-github/v74 v74.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect
@@ -172,6 +198,7 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect
github.com/pulumi/esc v0.17.0 // indirect
github.com/pulumi/pulumi-azure-native-sdk/v2 v2.92.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
@@ -182,8 +209,13 @@ require (
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/sst/opencode-sdk-go v0.19.2 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/texttheater/golang-levenshtein v1.0.1 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
@@ -200,18 +232,21 @@ require (
github.com/yuin/goldmark-emoji v1.0.5 // indirect
github.com/zclconf/go-cty v1.13.2 // indirect
gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
google.golang.org/grpc v1.71.1 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+118 -45
View File
@@ -51,6 +51,36 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2 h1:Ytu50ChAxCiDsOlBcBq8jbczXy6+QLb07T65DBJASRs=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2/go.mod h1:R+2BNtUfTfhPY0RH18oL02q116bakeBWjanrbnVBqkM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -154,8 +184,8 @@ github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -168,6 +198,8 @@ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
@@ -185,20 +217,19 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM=
github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@@ -229,6 +260,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M=
github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
github.com/hetznercloud/hcloud-go/v2 v2.37.0 h1:PMnuOA8pL8aHLLPp6nnnCTo2Xk2tqu4dAfYsC3bWdT0=
github.com/hetznercloud/hcloud-go/v2 v2.37.0/go.mod h1:zaDOCKmpnI86ftoCpUpaiYaw9Wew1ib1AcXTh96deYI=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -239,6 +272,10 @@ github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4G
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/iwdgo/sigintwindows v0.2.2 h1:P6oWzpvV7MrEAmhUgs+zmarrWkyL77ycZz4v7+1gYAE=
github.com/iwdgo/sigintwindows v0.2.2/go.mod h1:70wPb8oz8OnxPvsj2QMUjgIVhb8hMu5TUgX8KfFl7QY=
github.com/j3ssie/go-agent-agnostic v0.0.0-20260331165354-91ae454341b6 h1:djsMuIM1yqGC8fqn8Jng80RuBNvy9Bc7lxO9q1hDVfw=
github.com/j3ssie/go-agent-agnostic v0.0.0-20260331165354-91ae454341b6/go.mod h1:gSyge3KbjcKOPL++R8q+WwwfBCKj3gqg2VpAK+dadd4=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -272,6 +309,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/linode/linodego v1.67.0 h1:pomhFuuCCJI4N6emtB9027h1yXHY2/MIT0hwHEFwvq4=
github.com/linode/linodego v1.67.0/go.mod h1:+9mbdu0P3WMRCl0QbVfiFavR+Iel7TCRDJk3nInyx14=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -378,8 +417,24 @@ github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435
github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE=
github.com/pulumi/esc v0.17.0 h1:oaVOIyFTENlYDuqc3pW75lQT9jb2cd6ie/4/Twxn66w=
github.com/pulumi/esc v0.17.0/go.mod h1:XnSxlt5NkmuAj304l/gK4pRErFbtqq6XpfX1tYT9Jbc=
github.com/pulumi/pulumi-aws/sdk/v6 v6.83.2 h1:KrV04/k8+PRfkX/90pLm/jug6tfc9YeQH1JOjJmz4GE=
github.com/pulumi/pulumi-aws/sdk/v6 v6.83.2/go.mod h1:520DDoW2zBYVWwwAT8qt/9VhNoBcDIslDljzE8/O080=
github.com/pulumi/pulumi-azure-native-sdk/compute/v2 v2.92.0 h1:f1BQvsZynnv5Ui8CSL1vhkQ2lytwlXDo1TB1LIvbcaQ=
github.com/pulumi/pulumi-azure-native-sdk/compute/v2 v2.92.0/go.mod h1:LHcwYXvoKdAqq5sefxFc2pwHaeu0YXk6o7BZfWyPSjM=
github.com/pulumi/pulumi-azure-native-sdk/network/v2 v2.92.0 h1:RguVjgGxzhJDSWudhR4GlMPtkl2PQ5MdUlUnj7McHlc=
github.com/pulumi/pulumi-azure-native-sdk/network/v2 v2.92.0/go.mod h1:UhZbccgN1BnDpEHhqqVc8WkCzi/uPXCBVpGsDrfOyVY=
github.com/pulumi/pulumi-azure-native-sdk/resources/v2 v2.92.0 h1:kP8zlCfV7V+t4C1AVySiDHCTFhazq9Iz6Gp1JQ8DbSI=
github.com/pulumi/pulumi-azure-native-sdk/resources/v2 v2.92.0/go.mod h1:7mj+DiyzVR+dY9gtlN2zJTpm8ynNMxoRDILHQDIcpkY=
github.com/pulumi/pulumi-azure-native-sdk/v2 v2.92.0 h1:epa2m2QLh28bDf70KYs0egJejbz18J6FC1LELF5s8ks=
github.com/pulumi/pulumi-azure-native-sdk/v2 v2.92.0/go.mod h1:L5UBctoZQg3tq8XqOfT1pUgq1ai/uJ2FJIZfovhRUaI=
github.com/pulumi/pulumi-digitalocean/sdk/v4 v4.57.0 h1:fJtnYk53vpXKpq6jeR+h2pH7Rs7BkN0h7vaI9oFtOW8=
github.com/pulumi/pulumi-digitalocean/sdk/v4 v4.57.0/go.mod h1:zLQppHVr/0pKtqlI6mK5lxOSeVFilNJLB6Q90V2/Kug=
github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1 h1:w6OnO3d4j5yVf2vpm8OzXFC/xHOEGqt+9FjWCUBCq6U=
github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1/go.mod h1:UyZyv7hz4knpFx6/Sh+SkZe6hT6sJHtDvw9A0TbvEsk=
github.com/pulumi/pulumi-hcloud/sdk v1.32.1 h1:erzucxHkUCuFRHzHfT6L17gu7W4JgHlJl1eMBbis6g0=
github.com/pulumi/pulumi-hcloud/sdk v1.32.1/go.mod h1:7N5twLXy3cqonunLBa9PZ07IFKuLCfvTMIu+baSHxUg=
github.com/pulumi/pulumi-linode/sdk/v4 v4.39.0 h1:AoIy/hOuIzlrW322bygCwMB36/lwu4jqgK839TjJ6m4=
github.com/pulumi/pulumi-linode/sdk/v4 v4.39.0/go.mod h1:EisQjPTvQ6VJAqlMGWyUwZXftGHHPf2gFGa7Xtzmrcc=
github.com/pulumi/pulumi/sdk/v3 v3.220.0 h1:TtdlW2VfvBWhFZSvaDN9lSUlSS4gGSdNWdca3RGPsBQ=
github.com/pulumi/pulumi/sdk/v3 v3.220.0/go.mod h1:UGWJOz25OiFIN0QH79UFij8mffH94TYebKUgy9Wvug0=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
@@ -416,8 +471,11 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sst/opencode-sdk-go v0.19.2 h1:ffgQpE+ms4F0Wop/tT4tqTvFAbocyWYM8iy543b3Ous=
github.com/sst/opencode-sdk-go v0.19.2/go.mod h1:rrpo5n0Be43y6tJ29TeMxH1/zeoDcB0D43nJh6gnL34=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -427,6 +485,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
@@ -435,6 +494,16 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
@@ -483,18 +552,18 @@ github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0
github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8=
gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -517,8 +586,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -530,8 +599,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -546,10 +615,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -559,8 +628,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180828065106-d99a578cf41b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -587,8 +656,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -598,8 +667,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -610,10 +679,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -624,16 +693,18 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -642,6 +713,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
@@ -685,5 +758,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v0.5.5 h1:jkgx1TjbQPD/feRoK+S/mXw9e1uj6WilpHrXJowi6oA=
pgregory.net/rapid v0.5.5/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
pgregory.net/rapid v0.6.1 h1:4eyrDxyht86tT4Ztm+kvlyNBLIk071gR+ZQdhphc9dQ=
pgregory.net/rapid v0.6.1/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+384
View File
@@ -0,0 +1,384 @@
package cloud
import (
"context"
"fmt"
"strconv"
"time"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/ec2"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/pulumi/pulumi-aws/sdk/v6/go/aws"
awsec2 "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ec2"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// AWSProvider implements the Provider interface for AWS
type AWSProvider struct {
accessKeyID string
secretAccessKey string
region string
instanceType string
ami string
amiFilter string
useSpot bool
}
// NewAWSProvider creates a new AWS provider
func NewAWSProvider(accessKeyID, secretAccessKey, region, instanceType, ami, amiFilter string, useSpot bool) (*AWSProvider, error) {
if accessKeyID == "" {
return nil, fmt.Errorf("AWS access key ID is required")
}
if secretAccessKey == "" {
return nil, fmt.Errorf("AWS secret access key is required")
}
if region == "" {
region = "us-east-1"
}
if instanceType == "" {
instanceType = "t3.medium"
}
if amiFilter == "" {
amiFilter = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"
}
return &AWSProvider{
accessKeyID: accessKeyID,
secretAccessKey: secretAccessKey,
region: region,
instanceType: instanceType,
ami: ami,
amiFilter: amiFilter,
useSpot: useSpot,
}, nil
}
// Validate checks if the provider configuration is valid
func (p *AWSProvider) Validate(ctx context.Context) error {
cfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(p.region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(p.accessKeyID, p.secretAccessKey, "")),
)
if err != nil {
return fmt.Errorf("failed to load AWS config: %w", err)
}
_, err = sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
if err != nil {
return fmt.Errorf("failed to validate AWS credentials: %w", err)
}
return nil
}
// EstimateCost estimates the cost for the given configuration
func (p *AWSProvider) EstimateCost(mode ExecutionMode, instanceCount int) (*CostEstimate, error) {
if mode != ModeVM {
return nil, fmt.Errorf("only VM mode is supported for AWS")
}
// Default pricing for common instance types (USD per hour)
pricing := map[string]float64{
"t3.micro": 0.0104,
"t3.small": 0.0208,
"t3.medium": 0.0416,
"t3.large": 0.0832,
"t3.xlarge": 0.1664,
"m5.large": 0.0960,
"m5.xlarge": 0.1920,
"c5.large": 0.0850,
"c5.xlarge": 0.1700,
}
hourlyRate, ok := pricing[p.instanceType]
if !ok {
// Default to t3.medium pricing if unknown
hourlyRate = 0.0416
}
notes := []string{
fmt.Sprintf("%d x %s instances @ $%.4f/hr each", instanceCount, p.instanceType, hourlyRate),
}
if p.useSpot {
hourlyRate *= 0.4 // 60% discount for spot instances
notes = append(notes, "Using spot instances (estimated 60% discount)")
}
totalHourlyRate := hourlyRate * float64(instanceCount)
return &CostEstimate{
HourlyCost: totalHourlyRate,
DailyCost: totalHourlyRate * 24,
Currency: "USD",
Breakdown: map[string]float64{
"compute": totalHourlyRate,
},
Notes: notes,
}, nil
}
// CreateInfrastructure provisions AWS EC2 instances
func (p *AWSProvider) CreateInfrastructure(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
infraID := fmt.Sprintf("cloud-aws-%d", time.Now().Unix())
statePath := opts.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infraID, statePath)
if err != nil {
return nil, fmt.Errorf("failed to create Pulumi manager: %w", err)
}
// Set provider credentials
if err := pm.SetConfig(ctx, "aws:region", p.region, false); err != nil {
return nil, fmt.Errorf("failed to set AWS region: %w", err)
}
if err := pm.SetConfig(ctx, "aws:accessKey", p.accessKeyID, true); err != nil {
return nil, fmt.Errorf("failed to set AWS access key: %w", err)
}
if err := pm.SetConfig(ctx, "aws:secretKey", p.secretAccessKey, true); err != nil {
return nil, fmt.Errorf("failed to set AWS secret key: %w", err)
}
// Run Pulumi program
if err := pm.Up(ctx, p.createInstanceProgram(infraID, opts)); err != nil {
return nil, fmt.Errorf("failed to provision EC2 instances: %w", err)
}
// Extract outputs
outputs, err := pm.GetOutputs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get outputs: %w", err)
}
infra := &Infrastructure{
ID: infraID,
Provider: ProviderAWS,
Mode: opts.Mode,
CreatedAt: time.Now(),
PulumiStackID: pm.GetStackName(),
StatePath: statePath,
Resources: buildResourcesFromOutputs(infraID, opts.InstanceCount, outputs),
Metadata: map[string]interface{}{
"region": p.region,
"instance_type": p.instanceType,
"ssh_user": "ubuntu",
},
}
return infra, nil
}
// DestroyInfrastructure tears down AWS resources
func (p *AWSProvider) DestroyInfrastructure(ctx context.Context, infra *Infrastructure) error {
statePath := infra.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infra.PulumiStackID, statePath)
if err != nil {
return fmt.Errorf("failed to create Pulumi manager: %w", err)
}
if err := pm.SetConfig(ctx, "aws:region", p.region, false); err != nil {
return fmt.Errorf("failed to set AWS region: %w", err)
}
if err := pm.SetConfig(ctx, "aws:accessKey", p.accessKeyID, true); err != nil {
return fmt.Errorf("failed to set AWS access key: %w", err)
}
if err := pm.SetConfig(ctx, "aws:secretKey", p.secretAccessKey, true); err != nil {
return fmt.Errorf("failed to set AWS secret key: %w", err)
}
return pm.Destroy(ctx)
}
// GetStatus retrieves the current status of infrastructure
func (p *AWSProvider) GetStatus(ctx context.Context, infra *Infrastructure) (*InfraStatus, error) {
cfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(p.region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(p.accessKeyID, p.secretAccessKey, "")),
)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
ec2Client := ec2.NewFromConfig(cfg)
status := &InfraStatus{
Status: "running",
TotalCount: len(infra.Resources),
Details: make([]ResourceStatus, 0, len(infra.Resources)),
}
// Collect instance IDs
instanceIDs := make([]string, 0, len(infra.Resources))
for _, res := range infra.Resources {
if res.ID != "" {
instanceIDs = append(instanceIDs, res.ID)
}
}
if len(instanceIDs) == 0 {
return status, nil
}
// Describe instances
describeOutput, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{
InstanceIds: instanceIDs,
})
if err != nil {
return nil, fmt.Errorf("failed to describe instances: %w", err)
}
// Build a map of instance ID -> state
instanceStates := make(map[string]ec2types.InstanceStateName)
for _, reservation := range describeOutput.Reservations {
for _, instance := range reservation.Instances {
if instance.InstanceId != nil && instance.State != nil {
instanceStates[*instance.InstanceId] = instance.State.Name
}
}
}
for _, res := range infra.Resources {
rs := ResourceStatus{
ResourceID: res.ID,
WorkerRegistered: res.WorkerID != "",
}
if res.WorkerID != "" {
status.WorkersRegistered++
}
if state, ok := instanceStates[res.ID]; ok {
rs.Status = string(state)
if state == ec2types.InstanceStateNameRunning {
status.ReadyCount++
}
} else {
rs.Status = "unknown"
rs.Message = "instance not found"
}
status.Details = append(status.Details, rs)
}
return status, nil
}
// Type returns the provider type
func (p *AWSProvider) Type() ProviderType {
return ProviderAWS
}
// createInstanceProgram creates a Pulumi program for AWS EC2 instances
func (p *AWSProvider) createInstanceProgram(infraID string, opts *CreateOptions) pulumi.RunFunc {
suffix := infraSuffix(infraID)
return func(ctx *pulumi.Context) error {
userData := GenerateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
// Determine AMI
ami := p.ami
if opts.ImageID != "" {
ami = opts.ImageID
}
if ami == "" {
// Look up AMI using the configured filter
mostRecent := true
amiLookup, err := awsec2.LookupAmi(ctx, &awsec2.LookupAmiArgs{
Filters: []awsec2.GetAmiFilter{
{
Name: "name",
Values: []string{p.amiFilter},
},
{
Name: "virtualization-type",
Values: []string{"hvm"},
},
},
Owners: []string{"099720109477"}, // Canonical
MostRecent: &mostRecent,
})
if err != nil {
return fmt.Errorf("failed to look up Ubuntu AMI: %w", err)
}
ami = amiLookup.Id
}
// Use aws.GetRegion to reference the aws provider package
regionResult, err := aws.GetRegion(ctx, &aws.GetRegionArgs{})
if err != nil {
return fmt.Errorf("failed to get AWS region: %w", err)
}
// Create security group allowing SSH inbound and all outbound
sg, err := awsec2.NewSecurityGroup(ctx, "osmedeus-sg", &awsec2.SecurityGroupArgs{
Description: pulumi.String("Osmedeus worker security group"),
Ingress: awsec2.SecurityGroupIngressArray{
&awsec2.SecurityGroupIngressArgs{
Protocol: pulumi.String("tcp"),
FromPort: pulumi.Int(22),
ToPort: pulumi.Int(22),
CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
},
},
Egress: awsec2.SecurityGroupEgressArray{
&awsec2.SecurityGroupEgressArgs{
Protocol: pulumi.String("-1"),
FromPort: pulumi.Int(0),
ToPort: pulumi.Int(0),
CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
},
},
Tags: pulumi.StringMap{
"Name": pulumi.String(fmt.Sprintf("osmedeus-workers-%s", suffix)),
"Region": pulumi.String(regionResult.Name),
},
})
if err != nil {
return fmt.Errorf("failed to create security group: %w", err)
}
// Create key pair
keyPair, err := awsec2.NewKeyPair(ctx, "osmedeus-key", &awsec2.KeyPairArgs{
KeyName: pulumi.String(fmt.Sprintf("osmedeus-key-%s", suffix)),
PublicKey: pulumi.String(opts.SSHPublicKey),
})
if err != nil {
return fmt.Errorf("failed to create key pair: %w", err)
}
// Create instances
for i := 0; i < opts.InstanceCount; i++ {
instanceName := fmt.Sprintf("osmw-%s-%d", suffix, i)
instance, err := awsec2.NewInstance(ctx, instanceName, &awsec2.InstanceArgs{
Ami: pulumi.String(ami),
InstanceType: pulumi.String(p.instanceType),
KeyName: keyPair.KeyName,
VpcSecurityGroupIds: pulumi.StringArray{sg.ID()},
UserData: pulumi.String(userData),
AssociatePublicIpAddress: pulumi.Bool(true),
Tags: pulumi.StringMap{
"Name": pulumi.String(instanceName),
"Project": pulumi.String("osmedeus"),
"Role": pulumi.String("worker"),
"Index": pulumi.String(strconv.Itoa(i)),
},
})
if err != nil {
return fmt.Errorf("failed to create instance %s: %w", instanceName, err)
}
ctx.Export(fmt.Sprintf("worker-%d-ip", i), instance.PublicIp)
ctx.Export(fmt.Sprintf("worker-%d-id", i), instance.ID())
}
return nil
}
}
+417
View File
@@ -0,0 +1,417 @@
package cloud
import (
"context"
"encoding/base64"
"fmt"
"os"
"strings"
"time"
"github.com/pulumi/pulumi-azure-native-sdk/compute/v2"
"github.com/pulumi/pulumi-azure-native-sdk/network/v2"
"github.com/pulumi/pulumi-azure-native-sdk/resources/v2"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// AzureProvider implements the Provider interface for Azure
type AzureProvider struct {
subscriptionID string
tenantID string
clientID string
clientSecret string
location string
vmSize string
imageReference string // "Publisher:Offer:SKU:Version"
}
// NewAzureProvider creates a new Azure provider
func NewAzureProvider(subscriptionID, tenantID, clientID, clientSecret, location, vmSize, imageReference string) (*AzureProvider, error) {
if subscriptionID == "" {
return nil, fmt.Errorf("azure subscription ID is required")
}
if clientID == "" {
return nil, fmt.Errorf("azure client ID is required")
}
if clientSecret == "" {
return nil, fmt.Errorf("azure client secret is required")
}
if location == "" {
location = "eastus"
}
if vmSize == "" {
vmSize = "Standard_B2s"
}
if imageReference == "" {
imageReference = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts:latest"
}
return &AzureProvider{
subscriptionID: subscriptionID,
tenantID: tenantID,
clientID: clientID,
clientSecret: clientSecret,
location: location,
vmSize: vmSize,
imageReference: imageReference,
}, nil
}
// Validate checks if the provider configuration is valid
func (p *AzureProvider) Validate(ctx context.Context) error {
if p.subscriptionID == "" {
return fmt.Errorf("azure subscription ID is required")
}
if p.clientID == "" {
return fmt.Errorf("azure client ID is required")
}
if p.clientSecret == "" {
return fmt.Errorf("azure client secret is required")
}
// Set ARM environment variables for Pulumi azure-native provider
_ = os.Setenv("ARM_SUBSCRIPTION_ID", p.subscriptionID)
_ = os.Setenv("ARM_TENANT_ID", p.tenantID)
_ = os.Setenv("ARM_CLIENT_ID", p.clientID)
_ = os.Setenv("ARM_CLIENT_SECRET", p.clientSecret)
// Pulumi will validate credentials when running Up
return nil
}
// EstimateCost estimates the cost for the given configuration
func (p *AzureProvider) EstimateCost(mode ExecutionMode, instanceCount int) (*CostEstimate, error) {
if mode != ModeVM {
return nil, fmt.Errorf("only VM mode is supported for Azure")
}
// Default pricing for common VM sizes (USD per hour, East US region)
pricing := map[string]float64{
"Standard_B1s": 0.0104,
"Standard_B2s": 0.0416,
"Standard_B2ms": 0.0832,
"Standard_D2s_v3": 0.0960,
"Standard_D4s_v3": 0.1920,
"Standard_F2s_v2": 0.0850,
"Standard_F4s_v2": 0.1700,
}
hourlyRate, ok := pricing[p.vmSize]
if !ok {
// Default to Standard_B2s pricing if unknown
hourlyRate = 0.0416
}
totalHourlyRate := hourlyRate * float64(instanceCount)
return &CostEstimate{
HourlyCost: totalHourlyRate,
DailyCost: totalHourlyRate * 24,
Currency: "USD",
Breakdown: map[string]float64{
"compute": totalHourlyRate,
},
Notes: []string{
fmt.Sprintf("%d x %s VMs @ $%.4f/hr each", instanceCount, p.vmSize, hourlyRate),
},
}, nil
}
// CreateInfrastructure provisions Azure VMs
func (p *AzureProvider) CreateInfrastructure(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
infraID := fmt.Sprintf("cloud-azure-%d", time.Now().Unix())
statePath := opts.StatePath
// Set ARM environment variables for Pulumi azure-native provider
_ = os.Setenv("ARM_SUBSCRIPTION_ID", p.subscriptionID)
_ = os.Setenv("ARM_TENANT_ID", p.tenantID)
_ = os.Setenv("ARM_CLIENT_ID", p.clientID)
_ = os.Setenv("ARM_CLIENT_SECRET", p.clientSecret)
pm, err := NewPulumiManager("osmedeus-cloud", infraID, statePath)
if err != nil {
return nil, fmt.Errorf("failed to create Pulumi manager: %w", err)
}
// Set provider location
if err := pm.SetConfig(ctx, "azure-native:location", p.location, false); err != nil {
return nil, fmt.Errorf("failed to set Azure location: %w", err)
}
// Run Pulumi program
if err := pm.Up(ctx, p.createVMProgram(infraID, opts)); err != nil {
return nil, fmt.Errorf("failed to provision Azure VMs: %w", err)
}
// Extract outputs
outputs, err := pm.GetOutputs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get outputs: %w", err)
}
infra := &Infrastructure{
ID: infraID,
Provider: ProviderAzure,
Mode: opts.Mode,
CreatedAt: time.Now(),
PulumiStackID: pm.GetStackName(),
StatePath: statePath,
Resources: buildResourcesFromOutputs(infraID, opts.InstanceCount, outputs),
Metadata: map[string]interface{}{
"location": p.location,
"vm_size": p.vmSize,
"ssh_user": "azureuser",
},
}
return infra, nil
}
// DestroyInfrastructure tears down Azure resources
func (p *AzureProvider) DestroyInfrastructure(ctx context.Context, infra *Infrastructure) error {
statePath := infra.StatePath
// Set ARM environment variables for Pulumi azure-native provider
_ = os.Setenv("ARM_SUBSCRIPTION_ID", p.subscriptionID)
_ = os.Setenv("ARM_TENANT_ID", p.tenantID)
_ = os.Setenv("ARM_CLIENT_ID", p.clientID)
_ = os.Setenv("ARM_CLIENT_SECRET", p.clientSecret)
pm, err := NewPulumiManager("osmedeus-cloud", infra.PulumiStackID, statePath)
if err != nil {
return fmt.Errorf("failed to create Pulumi manager: %w", err)
}
if err := pm.SetConfig(ctx, "azure-native:location", p.location, false); err != nil {
return fmt.Errorf("failed to set Azure location: %w", err)
}
return pm.Destroy(ctx)
}
// GetStatus retrieves the current status of infrastructure
func (p *AzureProvider) GetStatus(ctx context.Context, infra *Infrastructure) (*InfraStatus, error) {
status := &InfraStatus{
Status: "running",
TotalCount: len(infra.Resources),
Details: make([]ResourceStatus, 0, len(infra.Resources)),
}
for _, res := range infra.Resources {
rs := ResourceStatus{
ResourceID: res.ID,
WorkerRegistered: res.WorkerID != "",
}
if res.WorkerID != "" {
status.WorkersRegistered++
}
// Use stored status from infrastructure resources
if res.Status != "" {
rs.Status = res.Status
} else {
rs.Status = "unknown"
}
if rs.Status == "running" || rs.Status == "active" {
status.ReadyCount++
}
status.Details = append(status.Details, rs)
}
return status, nil
}
// Type returns the provider type
func (p *AzureProvider) Type() ProviderType {
return ProviderAzure
}
// parseImageReference splits an image reference string "Publisher:Offer:SKU:Version" into its components
func parseImageReference(ref string) (publisher, offer, sku, version string) {
parts := strings.SplitN(ref, ":", 4)
if len(parts) != 4 {
// Return defaults for Ubuntu 22.04 LTS
return "Canonical", "0001-com-ubuntu-server-jammy", "22_04-lts", "latest"
}
return parts[0], parts[1], parts[2], parts[3]
}
// createVMProgram creates a Pulumi program for Azure VMs
func (p *AzureProvider) createVMProgram(infraID string, opts *CreateOptions) pulumi.RunFunc {
suffix := infraSuffix(infraID)
return func(ctx *pulumi.Context) error {
userData := GenerateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
customData := base64.StdEncoding.EncodeToString([]byte(userData))
publisher, offer, sku, version := parseImageReference(p.imageReference)
// Create resource group
rg, err := resources.NewResourceGroup(ctx, "osmedeus-rg", &resources.ResourceGroupArgs{
ResourceGroupName: pulumi.Sprintf("osmedeus-rg-%d", time.Now().Unix()),
Location: pulumi.String(p.location),
})
if err != nil {
return fmt.Errorf("failed to create resource group: %w", err)
}
// Create virtual network
vnet, err := network.NewVirtualNetwork(ctx, "osmedeus-vnet", &network.VirtualNetworkArgs{
ResourceGroupName: rg.Name,
VirtualNetworkName: pulumi.String("osmedeus-vnet"),
Location: pulumi.String(p.location),
AddressSpace: &network.AddressSpaceArgs{
AddressPrefixes: pulumi.StringArray{
pulumi.String("10.0.0.0/16"),
},
},
})
if err != nil {
return fmt.Errorf("failed to create virtual network: %w", err)
}
// Create subnet
subnet, err := network.NewSubnet(ctx, "osmedeus-subnet", &network.SubnetArgs{
ResourceGroupName: rg.Name,
VirtualNetworkName: vnet.Name,
SubnetName: pulumi.String("osmedeus-subnet"),
AddressPrefix: pulumi.String("10.0.1.0/24"),
})
if err != nil {
return fmt.Errorf("failed to create subnet: %w", err)
}
// Create network security group allowing SSH inbound
nsg, err := network.NewNetworkSecurityGroup(ctx, "osmedeus-nsg", &network.NetworkSecurityGroupArgs{
ResourceGroupName: rg.Name,
NetworkSecurityGroupName: pulumi.String("osmedeus-nsg"),
Location: pulumi.String(p.location),
SecurityRules: network.SecurityRuleTypeArray{
&network.SecurityRuleTypeArgs{
Name: pulumi.String("allow-ssh"),
Priority: pulumi.Int(1000),
Direction: pulumi.String("Inbound"),
Access: pulumi.String("Allow"),
Protocol: pulumi.String("Tcp"),
SourcePortRange: pulumi.String("*"),
DestinationPortRange: pulumi.String("22"),
SourceAddressPrefix: pulumi.String("*"),
DestinationAddressPrefix: pulumi.String("*"),
},
},
})
if err != nil {
return fmt.Errorf("failed to create network security group: %w", err)
}
// Create VMs
for i := 0; i < opts.InstanceCount; i++ {
workerName := fmt.Sprintf("osmw-%s-%d", suffix, i)
// Create public IP address
publicIP, err := network.NewPublicIPAddress(ctx, fmt.Sprintf("osmedeus-pip-%d", i), &network.PublicIPAddressArgs{
ResourceGroupName: rg.Name,
PublicIpAddressName: pulumi.Sprintf("osmedeus-pip-%d", i),
Location: pulumi.String(p.location),
PublicIPAllocationMethod: pulumi.String("Dynamic"),
})
if err != nil {
return fmt.Errorf("failed to create public IP for %s: %w", workerName, err)
}
// Create network interface
nic, err := network.NewNetworkInterface(ctx, fmt.Sprintf("osmedeus-nic-%d", i), &network.NetworkInterfaceArgs{
ResourceGroupName: rg.Name,
NetworkInterfaceName: pulumi.Sprintf("osmedeus-nic-%d", i),
Location: pulumi.String(p.location),
IpConfigurations: network.NetworkInterfaceIPConfigurationArray{
&network.NetworkInterfaceIPConfigurationArgs{
Name: pulumi.String("ipconfig"),
PrivateIPAllocationMethod: pulumi.String("Dynamic"),
Subnet: &network.SubnetTypeArgs{
Id: subnet.ID(),
},
PublicIPAddress: &network.PublicIPAddressTypeArgs{
Id: publicIP.ID(),
},
},
},
NetworkSecurityGroup: &network.NetworkSecurityGroupTypeArgs{
Id: nsg.ID(),
},
})
if err != nil {
return fmt.Errorf("failed to create network interface for %s: %w", workerName, err)
}
// Create virtual machine
vm, err := compute.NewVirtualMachine(ctx, workerName, &compute.VirtualMachineArgs{
ResourceGroupName: rg.Name,
VmName: pulumi.String(workerName),
Location: pulumi.String(p.location),
HardwareProfile: &compute.HardwareProfileArgs{
VmSize: pulumi.String(p.vmSize),
},
OsProfile: &compute.OSProfileArgs{
ComputerName: pulumi.String(workerName),
AdminUsername: pulumi.String("azureuser"),
CustomData: pulumi.String(customData),
LinuxConfiguration: &compute.LinuxConfigurationArgs{
DisablePasswordAuthentication: pulumi.Bool(true),
Ssh: &compute.SshConfigurationArgs{
PublicKeys: compute.SshPublicKeyTypeArray{
&compute.SshPublicKeyTypeArgs{
Path: pulumi.String("/home/azureuser/.ssh/authorized_keys"),
KeyData: pulumi.String(opts.SSHPublicKey),
},
},
},
},
},
StorageProfile: &compute.StorageProfileArgs{
ImageReference: &compute.ImageReferenceArgs{
Publisher: pulumi.String(publisher),
Offer: pulumi.String(offer),
Sku: pulumi.String(sku),
Version: pulumi.String(version),
},
OsDisk: &compute.OSDiskArgs{
CreateOption: pulumi.String("FromImage"),
ManagedDisk: &compute.ManagedDiskParametersArgs{
StorageAccountType: pulumi.String("Standard_LRS"),
},
},
},
NetworkProfile: &compute.NetworkProfileArgs{
NetworkInterfaces: compute.NetworkInterfaceReferenceArray{
&compute.NetworkInterfaceReferenceArgs{
Id: nic.ID(),
},
},
},
})
if err != nil {
return fmt.Errorf("failed to create virtual machine %s: %w", workerName, err)
}
// Export the public IP address and VM ID
// For Dynamic allocation, the IP is only assigned after the VM is created,
// so we depend on the VM resource to ensure the IP is available
ctx.Export(fmt.Sprintf("worker-%d-ip", i), pulumi.All(vm.ID(), publicIP.IpAddress).ApplyT(
func(args []interface{}) string {
if ip, ok := args[1].(*string); ok && ip != nil {
return *ip
}
return ""
},
).(pulumi.StringOutput))
ctx.Export(fmt.Sprintf("worker-%d-id", i), vm.ID())
}
return nil
}
}
+41
View File
@@ -0,0 +1,41 @@
package cloud
import "strings"
// GenerateCloudInit generates the cloud-init user data script for worker VMs.
// The script installs osmedeus, sets up SSH access, and joins the worker to the master.
func GenerateCloudInit(redisURL, sshPublicKey string, setupCommands []string) string {
var sb strings.Builder
sb.WriteString(`#!/bin/bash
set -e
# Install osmedeus
curl -fsSL https://www.osmedeus.org/install.sh | bash
# Setup SSH keys
mkdir -p ~/.ssh
`)
sb.WriteString(`echo "`)
sb.WriteString(sshPublicKey)
sb.WriteString(`" >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
`)
if redisURL != "" {
sb.WriteString("\n# Join as worker\nosmedeus worker join --redis-url ")
sb.WriteString(redisURL)
sb.WriteString(" --get-public-ip\n")
}
if len(setupCommands) > 0 {
sb.WriteString("\n# Custom setup commands\n")
for _, cmd := range setupCommands {
sb.WriteString(cmd)
sb.WriteString("\n")
}
}
return sb.String()
}
+17
View File
@@ -3,6 +3,7 @@ package cloud
import (
"fmt"
"os"
"strings"
"path/filepath"
"github.com/j3ssie/osmedeus/v5/internal/config"
@@ -98,9 +99,13 @@ func resolveEnvVars(cfg *config.CloudConfigs) error {
cfg.Providers.Azure.ClientSecret = os.ExpandEnv(cfg.Providers.Azure.ClientSecret)
cfg.Providers.Azure.Location = os.ExpandEnv(cfg.Providers.Azure.Location)
// Hetzner
cfg.Providers.Hetzner.Token = os.ExpandEnv(cfg.Providers.Hetzner.Token)
// SSH
cfg.SSH.PrivateKeyPath = os.ExpandEnv(cfg.SSH.PrivateKeyPath)
cfg.SSH.PrivateKeyContent = os.ExpandEnv(cfg.SSH.PrivateKeyContent)
cfg.SSH.Password = os.ExpandEnv(cfg.SSH.Password)
// State
cfg.State.Path = os.ExpandEnv(cfg.State.Path)
@@ -132,6 +137,10 @@ func ValidateCloudConfig(cfg *config.CloudConfigs) error {
if cfg.Providers.Azure.SubscriptionID == "" || cfg.Providers.Azure.ClientID == "" {
return fmt.Errorf("azure credentials not configured")
}
case "hetzner":
if cfg.Providers.Hetzner.Token == "" {
return fmt.Errorf("hetzner token not configured")
}
default:
return fmt.Errorf("invalid provider: %s", cfg.Defaults.Provider)
}
@@ -154,3 +163,11 @@ func ValidateCloudConfig(cfg *config.CloudConfigs) error {
return nil
}
// ResolveTemplatePaths expands {{base_folder}} in path fields of the cloud config.
// Call this after LoadCloudConfig with the actual base folder path.
func ResolveTemplatePaths(cfg *config.CloudConfigs, baseFolder string) {
cfg.State.Path = strings.ReplaceAll(cfg.State.Path, "{{base_folder}}", baseFolder)
cfg.Setup.Ansible.PlaybookPath = strings.ReplaceAll(cfg.Setup.Ansible.PlaybookPath, "{{base_folder}}", baseFolder)
cfg.Setup.Ansible.InventoryPath = strings.ReplaceAll(cfg.Setup.Ansible.InventoryPath, "{{base_folder}}", baseFolder)
}
+179 -59
View File
@@ -3,6 +3,7 @@ package cloud
import (
"context"
"fmt"
"strconv"
"time"
"github.com/digitalocean/godo"
@@ -92,40 +93,104 @@ func (p *DigitalOceanProvider) EstimateCost(mode ExecutionMode, instanceCount in
// CreateInfrastructure provisions DigitalOcean droplets
func (p *DigitalOceanProvider) CreateInfrastructure(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
// Generate unique infrastructure ID
infraID := fmt.Sprintf("cloud-do-%d", time.Now().Unix())
statePath := opts.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infraID, statePath)
if err != nil {
return nil, fmt.Errorf("failed to create Pulumi manager: %w", err)
}
// Set provider credentials
if err := pm.SetConfig(ctx, "digitalocean:token", p.token, true); err != nil {
return nil, fmt.Errorf("failed to set DigitalOcean token: %w", err)
}
// Run Pulumi program
if err := pm.Up(ctx, p.createDropletProgram(ctx, infraID, opts)); err != nil {
return nil, fmt.Errorf("failed to provision droplets: %w", err)
}
// Extract outputs
outputs, err := pm.GetOutputs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get outputs: %w", err)
}
// Placeholder implementation - actual Pulumi logic will be added
// This demonstrates the structure
infra := &Infrastructure{
ID: infraID,
Provider: ProviderDigitalOcean,
Mode: opts.Mode,
CreatedAt: time.Now(),
PulumiStackID: fmt.Sprintf("osmedeus-cloud/%s", infraID),
Resources: []Resource{},
Metadata: map[string]interface{}{},
PulumiStackID: pm.GetStackName(),
StatePath: statePath,
Resources: buildResourcesFromOutputs(infraID, opts.InstanceCount, outputs),
Metadata: map[string]interface{}{
"region": p.region,
"size": p.size,
"ssh_user": "root",
},
}
return infra, fmt.Errorf("DigitalOcean infrastructure creation not yet fully implemented")
return infra, nil
}
// DestroyInfrastructure tears down DigitalOcean resources
func (p *DigitalOceanProvider) DestroyInfrastructure(ctx context.Context, infra *Infrastructure) error {
// Placeholder implementation
return fmt.Errorf("DigitalOcean infrastructure destruction not yet fully implemented")
statePath := infra.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infra.PulumiStackID, statePath)
if err != nil {
return fmt.Errorf("failed to create Pulumi manager: %w", err)
}
if err := pm.SetConfig(ctx, "digitalocean:token", p.token, true); err != nil {
return fmt.Errorf("failed to set DigitalOcean token: %w", err)
}
return pm.Destroy(ctx)
}
// GetStatus retrieves the current status of infrastructure
func (p *DigitalOceanProvider) GetStatus(ctx context.Context, infra *Infrastructure) (*InfraStatus, error) {
// Placeholder implementation
return &InfraStatus{
Status: "unknown",
ReadyCount: 0,
TotalCount: len(infra.Resources),
WorkersRegistered: 0,
Details: []ResourceStatus{},
}, nil
status := &InfraStatus{
Status: "running",
TotalCount: len(infra.Resources),
Details: make([]ResourceStatus, 0, len(infra.Resources)),
}
for _, res := range infra.Resources {
rs := ResourceStatus{
ResourceID: res.ID,
WorkerRegistered: res.WorkerID != "",
}
if res.WorkerID != "" {
status.WorkersRegistered++
}
// Query droplet status via API
dropletID, err := strconv.Atoi(res.ID)
if err == nil {
droplet, _, apiErr := p.client.Droplets.Get(ctx, dropletID)
if apiErr == nil {
rs.Status = droplet.Status
if droplet.Status == "active" {
status.ReadyCount++
}
} else {
rs.Status = "unknown"
rs.Message = apiErr.Error()
}
} else {
rs.Status = "unknown"
rs.Message = "invalid droplet ID"
}
status.Details = append(status.Details, rs)
}
return status, nil
}
// Type returns the provider type
@@ -133,60 +198,115 @@ func (p *DigitalOceanProvider) Type() ProviderType {
return ProviderDigitalOcean
}
// generateCloudInit generates the cloud-init user data script
// TODO: This method will be used when the Create() method is fully implemented
//
//nolint:unused // Reserved for future implementation
func (p *DigitalOceanProvider) generateCloudInit(redisURL, sshPublicKey string, setupCommands []string) string {
script := `#!/bin/bash
set -e
# Install osmedeus
curl -fsSL https://www.osmedeus.org/install.sh | bash
# Setup SSH keys
mkdir -p ~/.ssh
echo "` + sshPublicKey + `" >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
# Join as worker
osmedeus worker join --redis-url ` + redisURL + ` --get-public-ip
`
// Add custom setup commands
for _, cmd := range setupCommands {
script += cmd + "\n"
// findExistingSSHKey checks if an SSH key with the same fingerprint already exists in DigitalOcean
func (p *DigitalOceanProvider) findExistingSSHKey(ctx context.Context, publicKey string) (string, bool) {
fp, err := sshPublicKeyFingerprint(publicKey)
if err != nil {
return "", false
}
return script
key, _, err := p.client.Keys.GetByFingerprint(ctx, fp)
if err != nil || key == nil {
return "", false
}
return key.Fingerprint, true
}
// createDropletProgram creates a Pulumi program for DigitalOcean droplets
// TODO: This method will be used when the Create() method is fully implemented
//
//nolint:unused // Reserved for future implementation
func (p *DigitalOceanProvider) createDropletProgram(opts *CreateOptions) pulumi.RunFunc {
func (p *DigitalOceanProvider) createDropletProgram(parentCtx context.Context, infraID string, opts *CreateOptions) pulumi.RunFunc {
suffix := infraSuffix(infraID)
existingFingerprint, keyExists := p.findExistingSSHKey(parentCtx, opts.SSHPublicKey)
return func(ctx *pulumi.Context) error {
// This will be implemented with actual Pulumi DigitalOcean resource creation
// For now, just a placeholder to demonstrate the pattern
userData := p.generateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
userData := GenerateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
for i := 0; i < opts.InstanceCount; i++ {
dropletName := fmt.Sprintf("osmedeus-worker-%d", i)
_, err := digitalocean.NewDroplet(ctx, dropletName, &digitalocean.DropletArgs{
Image: pulumi.String(opts.ImageID),
Region: pulumi.String(p.region),
Size: pulumi.String(p.size),
UserData: pulumi.String(userData),
// Use existing SSH key if found, otherwise create a new one
var sshKeyFingerprint pulumi.StringInput
if keyExists {
sshKeyFingerprint = pulumi.String(existingFingerprint)
} else {
sshKey, err := digitalocean.NewSshKey(ctx, "osmedeus-ssh-key", &digitalocean.SshKeyArgs{
Name: pulumi.String(fmt.Sprintf("osmedeus-key-%s", suffix)),
PublicKey: pulumi.String(opts.SSHPublicKey),
})
if err != nil {
return err
return fmt.Errorf("failed to create SSH key: %w", err)
}
sshKeyFingerprint = sshKey.Fingerprint
}
// Create firewall allowing SSH
fw, err := digitalocean.NewFirewall(ctx, "osmedeus-firewall", &digitalocean.FirewallArgs{
Name: pulumi.String(fmt.Sprintf("osmedeus-fw-%s", suffix)),
InboundRules: digitalocean.FirewallInboundRuleArray{
&digitalocean.FirewallInboundRuleArgs{
Protocol: pulumi.String("tcp"),
PortRange: pulumi.String("22"),
SourceAddresses: pulumi.StringArray{
pulumi.String("0.0.0.0/0"),
pulumi.String("::/0"),
},
},
},
OutboundRules: digitalocean.FirewallOutboundRuleArray{
&digitalocean.FirewallOutboundRuleArgs{
Protocol: pulumi.String("tcp"),
PortRange: pulumi.String("1-65535"),
DestinationAddresses: pulumi.StringArray{
pulumi.String("0.0.0.0/0"),
pulumi.String("::/0"),
},
},
&digitalocean.FirewallOutboundRuleArgs{
Protocol: pulumi.String("udp"),
PortRange: pulumi.String("1-65535"),
DestinationAddresses: pulumi.StringArray{
pulumi.String("0.0.0.0/0"),
pulumi.String("::/0"),
},
},
},
})
if err != nil {
return fmt.Errorf("failed to create firewall: %w", err)
}
// Determine image
image := p.snapshotID
if image == "" {
image = "ubuntu-22-04-x64"
}
if opts.ImageID != "" {
image = opts.ImageID
}
// Create droplets
for i := 0; i < opts.InstanceCount; i++ {
dropletName := fmt.Sprintf("osmw-%s-%d", suffix, i)
droplet, err := digitalocean.NewDroplet(ctx, dropletName, &digitalocean.DropletArgs{
Image: pulumi.String(image),
Region: digitalocean.Region(p.region),
Size: digitalocean.DropletSlug(p.size),
UserData: pulumi.String(userData),
SshKeys: pulumi.StringArray{
sshKeyFingerprint,
},
Tags: pulumi.StringArray{
pulumi.String("osmedeus"),
pulumi.String("worker"),
},
})
if err != nil {
return fmt.Errorf("failed to create droplet %s: %w", dropletName, err)
}
ctx.Export(fmt.Sprintf("worker-%d-ip", i), droplet.Ipv4Address)
ctx.Export(fmt.Sprintf("worker-%d-id", i), droplet.ID())
}
_ = fw
return nil
}
}
+326
View File
@@ -0,0 +1,326 @@
package cloud
import (
"context"
"fmt"
"os"
"time"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/compute"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// GCPProvider implements the Provider interface for Google Cloud Platform
type GCPProvider struct {
projectID string
credentialsFile string
region string
zone string
machineType string
imageFamily string
usePreemptible bool
}
// NewGCPProvider creates a new GCP provider
func NewGCPProvider(projectID, credentialsFile, region, zone, machineType, imageFamily string, usePreemptible bool) (*GCPProvider, error) {
if projectID == "" {
return nil, fmt.Errorf("GCP project ID is required")
}
if region == "" {
region = "us-central1"
}
if zone == "" {
zone = "us-central1-a"
}
if machineType == "" {
machineType = "n1-standard-2"
}
if imageFamily == "" {
imageFamily = "ubuntu-2204-lts"
}
return &GCPProvider{
projectID: projectID,
credentialsFile: credentialsFile,
region: region,
zone: zone,
machineType: machineType,
imageFamily: imageFamily,
usePreemptible: usePreemptible,
}, nil
}
// Validate checks if the provider configuration is valid
func (p *GCPProvider) Validate(ctx context.Context) error {
if p.credentialsFile == "" {
return fmt.Errorf("GCP credentials file path is required; set it via cloud config or GOOGLE_APPLICATION_CREDENTIALS")
}
if _, err := os.Stat(p.credentialsFile); os.IsNotExist(err) {
return fmt.Errorf("GCP credentials file not found at %s", p.credentialsFile)
}
return nil
}
// EstimateCost estimates the cost for the given configuration
func (p *GCPProvider) EstimateCost(mode ExecutionMode, instanceCount int) (*CostEstimate, error) {
if mode != ModeVM {
return nil, fmt.Errorf("only VM mode is supported for GCP")
}
// Default pricing for common machine types in us-central1 (USD per hour)
pricing := map[string]float64{
"e2-micro": 0.0084,
"e2-small": 0.0168,
"e2-medium": 0.0335,
"n1-standard-1": 0.0475,
"n1-standard-2": 0.0950,
"n1-standard-4": 0.1900,
"n2-standard-2": 0.0971,
"n2-standard-4": 0.1942,
"c2-standard-4": 0.2088,
}
hourlyRate, ok := pricing[p.machineType]
if !ok {
// Default to n1-standard-2 pricing if unknown
hourlyRate = 0.0950
}
notes := []string{
fmt.Sprintf("%d x %s instances @ $%.4f/hr each", instanceCount, p.machineType, hourlyRate),
}
if p.usePreemptible {
hourlyRate *= 0.20 // 80% discount for preemptible instances
notes = append(notes, "preemptible instances: 80% discount applied")
}
totalHourlyRate := hourlyRate * float64(instanceCount)
return &CostEstimate{
HourlyCost: totalHourlyRate,
DailyCost: totalHourlyRate * 24,
Currency: "USD",
Breakdown: map[string]float64{
"compute": totalHourlyRate,
},
Notes: notes,
}, nil
}
// CreateInfrastructure provisions GCP compute instances
func (p *GCPProvider) CreateInfrastructure(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
infraID := fmt.Sprintf("cloud-gcp-%d", time.Now().Unix())
statePath := opts.StatePath
// Set GOOGLE_CREDENTIALS env var from credentials file content
credContent, err := os.ReadFile(p.credentialsFile)
if err != nil {
return nil, fmt.Errorf("failed to read GCP credentials file: %w", err)
}
if err := os.Setenv("GOOGLE_CREDENTIALS", string(credContent)); err != nil {
return nil, fmt.Errorf("failed to set GOOGLE_CREDENTIALS env var: %w", err)
}
pm, err := NewPulumiManager("osmedeus-cloud", infraID, statePath)
if err != nil {
return nil, fmt.Errorf("failed to create Pulumi manager: %w", err)
}
// Set GCP provider configuration
if err := pm.SetConfig(ctx, "gcp:project", p.projectID, false); err != nil {
return nil, fmt.Errorf("failed to set GCP project: %w", err)
}
if err := pm.SetConfig(ctx, "gcp:region", p.region, false); err != nil {
return nil, fmt.Errorf("failed to set GCP region: %w", err)
}
if err := pm.SetConfig(ctx, "gcp:zone", p.zone, false); err != nil {
return nil, fmt.Errorf("failed to set GCP zone: %w", err)
}
// Run Pulumi program
if err := pm.Up(ctx, p.createInstanceProgram(infraID, opts)); err != nil {
return nil, fmt.Errorf("failed to provision GCP instances: %w", err)
}
// Extract outputs
outputs, err := pm.GetOutputs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get outputs: %w", err)
}
infra := &Infrastructure{
ID: infraID,
Provider: ProviderGCP,
Mode: opts.Mode,
CreatedAt: time.Now(),
PulumiStackID: pm.GetStackName(),
StatePath: statePath,
Resources: buildResourcesFromOutputs(infraID, opts.InstanceCount, outputs),
Metadata: map[string]interface{}{
"project": p.projectID,
"region": p.region,
"zone": p.zone,
"ssh_user": "root",
},
}
return infra, nil
}
// DestroyInfrastructure tears down GCP resources
func (p *GCPProvider) DestroyInfrastructure(ctx context.Context, infra *Infrastructure) error {
// Set GOOGLE_CREDENTIALS env var from credentials file content
credContent, err := os.ReadFile(p.credentialsFile)
if err != nil {
return fmt.Errorf("failed to read GCP credentials file: %w", err)
}
if err := os.Setenv("GOOGLE_CREDENTIALS", string(credContent)); err != nil {
return fmt.Errorf("failed to set GOOGLE_CREDENTIALS env var: %w", err)
}
statePath := infra.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infra.PulumiStackID, statePath)
if err != nil {
return fmt.Errorf("failed to create Pulumi manager: %w", err)
}
if err := pm.SetConfig(ctx, "gcp:project", p.projectID, false); err != nil {
return fmt.Errorf("failed to set GCP project: %w", err)
}
if err := pm.SetConfig(ctx, "gcp:region", p.region, false); err != nil {
return fmt.Errorf("failed to set GCP region: %w", err)
}
if err := pm.SetConfig(ctx, "gcp:zone", p.zone, false); err != nil {
return fmt.Errorf("failed to set GCP zone: %w", err)
}
return pm.Destroy(ctx)
}
// GetStatus retrieves the current status of infrastructure
func (p *GCPProvider) GetStatus(ctx context.Context, infra *Infrastructure) (*InfraStatus, error) {
status := &InfraStatus{
Status: "running",
TotalCount: len(infra.Resources),
Details: make([]ResourceStatus, 0, len(infra.Resources)),
}
for _, res := range infra.Resources {
rs := ResourceStatus{
ResourceID: res.ID,
Status: res.Status,
WorkerRegistered: res.WorkerID != "",
}
if res.WorkerID != "" {
status.WorkersRegistered++
}
if res.Status == "running" || res.Status == "RUNNING" {
status.ReadyCount++
}
status.Details = append(status.Details, rs)
}
return status, nil
}
// Type returns the provider type
func (p *GCPProvider) Type() ProviderType {
return ProviderGCP
}
// createInstanceProgram creates a Pulumi program for GCP compute instances
func (p *GCPProvider) createInstanceProgram(infraID string, opts *CreateOptions) pulumi.RunFunc {
suffix := infraSuffix(infraID)
return func(ctx *pulumi.Context) error {
userData := GenerateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
// Create firewall rule allowing SSH access
_, err := compute.NewFirewall(ctx, "osmedeus-allow-ssh", &compute.FirewallArgs{
Network: pulumi.String("default"),
Allows: compute.FirewallAllowArray{
&compute.FirewallAllowArgs{
Protocol: pulumi.String("tcp"),
Ports: pulumi.StringArray{
pulumi.String("22"),
},
},
},
SourceRanges: pulumi.StringArray{
pulumi.String("0.0.0.0/0"),
},
TargetTags: pulumi.StringArray{
pulumi.String("osmedeus-worker"),
},
})
if err != nil {
return fmt.Errorf("failed to create firewall rule: %w", err)
}
// Determine boot disk image
image := fmt.Sprintf("ubuntu-os-cloud/%s", p.imageFamily)
if opts.ImageID != "" {
image = opts.ImageID
}
// Create compute instances
for i := 0; i < opts.InstanceCount; i++ {
instanceName := fmt.Sprintf("osmw-%s-%d", suffix, i)
instanceArgs := &compute.InstanceArgs{
MachineType: pulumi.String(p.machineType),
Zone: pulumi.String(p.zone),
BootDisk: &compute.InstanceBootDiskArgs{
InitializeParams: &compute.InstanceBootDiskInitializeParamsArgs{
Image: pulumi.String(image),
},
},
NetworkInterfaces: compute.InstanceNetworkInterfaceArray{
&compute.InstanceNetworkInterfaceArgs{
Network: pulumi.String("default"),
AccessConfigs: compute.InstanceNetworkInterfaceAccessConfigArray{
&compute.InstanceNetworkInterfaceAccessConfigArgs{},
},
},
},
Metadata: pulumi.StringMap{
"startup-script": pulumi.String(userData),
"ssh-keys": pulumi.Sprintf("root:%s", opts.SSHPublicKey),
},
Tags: pulumi.StringArray{
pulumi.String("osmedeus-worker"),
},
}
// Enable preemptible scheduling if requested
if p.usePreemptible {
instanceArgs.Scheduling = &compute.InstanceSchedulingArgs{
Preemptible: pulumi.Bool(true),
AutomaticRestart: pulumi.Bool(false),
}
}
instance, err := compute.NewInstance(ctx, instanceName, instanceArgs)
if err != nil {
return fmt.Errorf("failed to create instance %s: %w", instanceName, err)
}
// Export instance ID
ctx.Export(fmt.Sprintf("worker-%d-id", i), instance.InstanceId)
// Export public IP from the first network interface's first access config
publicIP := instance.NetworkInterfaces.Index(pulumi.Int(0)).AccessConfigs().Index(pulumi.Int(0)).NatIp()
ctx.Export(fmt.Sprintf("worker-%d-ip", i), publicIP)
}
return nil
}
}
+321
View File
@@ -0,0 +1,321 @@
package cloud
import (
"context"
"fmt"
"strconv"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
pulumiHcloud "github.com/pulumi/pulumi-hcloud/sdk/go/hcloud"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// HetznerProvider implements the Provider interface for Hetzner Cloud
type HetznerProvider struct {
token string
location string
serverType string
image string
sshKeyName string
client *hcloud.Client
}
// NewHetznerProvider creates a new Hetzner Cloud provider
func NewHetznerProvider(token, location, serverType, image, sshKeyName string) (*HetznerProvider, error) {
if token == "" {
return nil, fmt.Errorf("hetzner cloud token is required")
}
// Apply defaults
if location == "" {
location = "hel1"
}
if serverType == "" {
serverType = "cx23"
}
if image == "" {
image = "ubuntu-22.04"
}
client := hcloud.NewClient(hcloud.WithToken(token))
return &HetznerProvider{
token: token,
location: location,
serverType: serverType,
image: image,
sshKeyName: sshKeyName,
client: client,
}, nil
}
// Validate checks if the provider configuration is valid
func (p *HetznerProvider) Validate(ctx context.Context) error {
_, err := p.client.ServerType.All(ctx)
if err != nil {
return fmt.Errorf("failed to validate Hetzner Cloud credentials: %w", err)
}
return nil
}
// EstimateCost estimates the cost for the given configuration
func (p *HetznerProvider) EstimateCost(mode ExecutionMode, instanceCount int) (*CostEstimate, error) {
if mode != ModeVM {
return nil, fmt.Errorf("only VM mode is supported for Hetzner Cloud")
}
// Approximate USD/hr pricing (Hetzner bills in EUR; these are approximate USD conversions)
pricing := map[string]float64{
// Current generation (cost-optimized)
"cx23": 0.0050,
"cx33": 0.0094,
"cx43": 0.0169,
"cx53": 0.0319,
"cax11": 0.0044,
"cax21": 0.0069,
"cax31": 0.0119,
"cax41": 0.0219,
// Previous generation (may still work)
"cx11": 0.0050,
"cx21": 0.0094,
"cx22": 0.0094,
"cx31": 0.0169,
"cx32": 0.0169,
"cx41": 0.0319,
"cx42": 0.0319,
"cx51": 0.0609,
"cx52": 0.0609,
"cpx11": 0.0064,
"cpx21": 0.0109,
"cpx31": 0.0199,
"cpx41": 0.0359,
"cpx51": 0.0679,
}
hourlyRate, ok := pricing[p.serverType]
if !ok {
// Default to cx23 pricing if unknown
hourlyRate = 0.0050
}
totalHourlyRate := hourlyRate * float64(instanceCount)
return &CostEstimate{
HourlyCost: totalHourlyRate,
DailyCost: totalHourlyRate * 24,
Currency: "USD",
Breakdown: map[string]float64{
"compute": totalHourlyRate,
},
Notes: []string{
fmt.Sprintf("%d x %s servers @ $%.4f/hr each", instanceCount, p.serverType, hourlyRate),
"Hetzner bills in EUR; prices shown are approximate USD conversions",
},
}, nil
}
// CreateInfrastructure provisions Hetzner Cloud servers
func (p *HetznerProvider) CreateInfrastructure(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
infraID := fmt.Sprintf("cloud-hetzner-%d", time.Now().Unix())
statePath := opts.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infraID, statePath)
if err != nil {
return nil, fmt.Errorf("failed to create Pulumi manager: %w", err)
}
// Set provider credentials
if err := pm.SetConfig(ctx, "hcloud:token", p.token, true); err != nil {
return nil, fmt.Errorf("failed to set Hetzner Cloud token: %w", err)
}
// Run Pulumi program
if err := pm.Up(ctx, p.createServerProgram(ctx, infraID, opts)); err != nil {
return nil, fmt.Errorf("failed to provision servers: %w", err)
}
// Extract outputs
outputs, err := pm.GetOutputs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get outputs: %w", err)
}
infra := &Infrastructure{
ID: infraID,
Provider: ProviderHetzner,
Mode: opts.Mode,
CreatedAt: time.Now(),
PulumiStackID: pm.GetStackName(),
StatePath: statePath,
Resources: buildResourcesFromOutputs(infraID, opts.InstanceCount, outputs),
Metadata: map[string]interface{}{
"location": p.location,
"server_type": p.serverType,
"ssh_user": "root",
},
}
return infra, nil
}
// DestroyInfrastructure tears down Hetzner Cloud resources
func (p *HetznerProvider) DestroyInfrastructure(ctx context.Context, infra *Infrastructure) error {
statePath := infra.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infra.PulumiStackID, statePath)
if err != nil {
return fmt.Errorf("failed to create Pulumi manager: %w", err)
}
if err := pm.SetConfig(ctx, "hcloud:token", p.token, true); err != nil {
return fmt.Errorf("failed to set Hetzner Cloud token: %w", err)
}
return pm.Destroy(ctx)
}
// GetStatus retrieves the current status of infrastructure
func (p *HetznerProvider) GetStatus(ctx context.Context, infra *Infrastructure) (*InfraStatus, error) {
status := &InfraStatus{
Status: "running",
TotalCount: len(infra.Resources),
Details: make([]ResourceStatus, 0, len(infra.Resources)),
}
for _, res := range infra.Resources {
rs := ResourceStatus{
ResourceID: res.ID,
WorkerRegistered: res.WorkerID != "",
}
if res.WorkerID != "" {
status.WorkersRegistered++
}
// Query server status via API
serverID, err := strconv.ParseInt(res.ID, 10, 64)
if err == nil {
server, _, apiErr := p.client.Server.GetByID(ctx, serverID)
if apiErr == nil && server != nil {
rs.Status = string(server.Status)
if server.Status == hcloud.ServerStatusRunning {
status.ReadyCount++
}
} else if apiErr != nil {
rs.Status = "unknown"
rs.Message = apiErr.Error()
} else {
rs.Status = "not_found"
rs.Message = "server not found"
}
} else {
rs.Status = "unknown"
rs.Message = "invalid server ID"
}
status.Details = append(status.Details, rs)
}
return status, nil
}
// Type returns the provider type
func (p *HetznerProvider) Type() ProviderType {
return ProviderHetzner
}
func (p *HetznerProvider) findExistingSSHKey(ctx context.Context, publicKey string) (string, bool) {
fp, err := sshPublicKeyFingerprint(publicKey)
if err != nil {
return "", false
}
key, _, err := p.client.SSHKey.GetByFingerprint(ctx, fp)
if err != nil || key == nil {
return "", false
}
return key.Name, true
}
// createServerProgram creates a Pulumi program for Hetzner Cloud servers
func (p *HetznerProvider) createServerProgram(parentCtx context.Context, infraID string, opts *CreateOptions) pulumi.RunFunc {
suffix := infraSuffix(infraID)
existingKeyName, keyExists := p.findExistingSSHKey(parentCtx, opts.SSHPublicKey)
return func(ctx *pulumi.Context) error {
userData := GenerateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
// Use existing SSH key if found, otherwise create a new one
var sshKeyName pulumi.StringInput
if keyExists {
sshKeyName = pulumi.String(existingKeyName)
} else {
sshKey, err := pulumiHcloud.NewSshKey(ctx, "osmedeus-ssh-key", &pulumiHcloud.SshKeyArgs{
Name: pulumi.String(fmt.Sprintf("osmedeus-key-%s", suffix)),
PublicKey: pulumi.String(opts.SSHPublicKey),
})
if err != nil {
return fmt.Errorf("failed to create SSH key: %w", err)
}
sshKeyName = sshKey.Name
}
// Create firewall allowing SSH
fw, err := pulumiHcloud.NewFirewall(ctx, "osmedeus-firewall", &pulumiHcloud.FirewallArgs{
Name: pulumi.String(fmt.Sprintf("osmedeus-fw-%s", suffix)),
Rules: pulumiHcloud.FirewallRuleArray{
&pulumiHcloud.FirewallRuleArgs{
Direction: pulumi.String("in"),
Protocol: pulumi.String("tcp"),
Port: pulumi.String("22"),
SourceIps: pulumi.StringArray{
pulumi.String("0.0.0.0/0"),
pulumi.String("::/0"),
},
},
},
})
if err != nil {
return fmt.Errorf("failed to create firewall: %w", err)
}
// Determine image
image := p.image
if opts.ImageID != "" {
image = opts.ImageID
}
// Create servers
for i := 0; i < opts.InstanceCount; i++ {
serverName := fmt.Sprintf("osmw-%s-%d", suffix, i)
server, err := pulumiHcloud.NewServer(ctx, serverName, &pulumiHcloud.ServerArgs{
Name: pulumi.String(serverName),
ServerType: pulumi.String(p.serverType),
Image: pulumi.String(image),
Location: pulumi.String(p.location),
SshKeys: pulumi.StringArray{
sshKeyName,
},
UserData: pulumi.String(userData),
FirewallIds: pulumi.IntArray{
fw.ID().ToStringOutput().ApplyT(func(s string) (int, error) {
return strconv.Atoi(s)
}).(pulumi.IntOutput),
},
Labels: pulumi.StringMap{
"osmedeus": pulumi.String("true"),
},
})
if err != nil {
return fmt.Errorf("failed to create server %s: %w", serverName, err)
}
ctx.Export(fmt.Sprintf("worker-%d-ip", i), server.Ipv4Address)
ctx.Export(fmt.Sprintf("worker-%d-id", i), server.ID())
}
return nil
}
}
+24 -11
View File
@@ -30,10 +30,12 @@ func NewLifecycleManager(cfg *config.CloudConfigs, provider Provider, client *di
func (lm *LifecycleManager) CreateAndRun(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
var infra *Infrastructure
var err error
var infraCreated bool
// Setup cleanup on failure if configured
// Setup cleanup on failure if configured.
// Only destroy if infrastructure creation itself failed, not if worker registration failed.
defer func() {
if err != nil && lm.cfg.Defaults.CleanupOnFailure && infra != nil {
if err != nil && !infraCreated && lm.cfg.Defaults.CleanupOnFailure && infra != nil {
_ = lm.provider.DestroyInfrastructure(context.Background(), infra)
}
}()
@@ -51,11 +53,17 @@ func (lm *LifecycleManager) CreateAndRun(ctx context.Context, opts *CreateOption
// Initialize cost tracker
lm.tracker = NewCostTracker(estimate.HourlyCost, lm.cfg.Limits.MaxTotalSpend)
// Ensure providers use the resolved state path from config
if opts.StatePath == "" && lm.cfg.State.Path != "" {
opts.StatePath = lm.cfg.State.Path
}
// Step 2: Create infrastructure
infra, err = lm.provider.CreateInfrastructure(ctx, opts)
if err != nil {
return nil, fmt.Errorf("failed to create infrastructure: %w", err)
}
infraCreated = true
// Save state for recovery
if err := SaveInfrastructureState(infra, lm.cfg.State.Path); err != nil {
@@ -63,16 +71,21 @@ func (lm *LifecycleManager) CreateAndRun(ctx context.Context, opts *CreateOption
fmt.Printf("Warning: failed to save infrastructure state: %v\n", err)
}
// Step 3: Wait for workers to register
workerIDs, err := WaitForWorkers(ctx, lm.client, opts.InstanceCount, 5*time.Minute)
if err != nil {
return infra, fmt.Errorf("failed to wait for workers: %w", err)
}
// Step 3: Wait for workers to register (only if distributed client is available)
if lm.client != nil {
workerIDs, waitErr := WaitForWorkers(ctx, lm.client, opts.InstanceCount, 5*time.Minute)
if waitErr != nil {
// Worker registration failed but infrastructure was created successfully.
// Update state and return the infra without failing.
_ = SaveInfrastructureState(infra, lm.cfg.State.Path)
return infra, fmt.Errorf("infrastructure created but worker registration failed: %w", waitErr)
}
// Update infrastructure with worker IDs
for i, workerID := range workerIDs {
if i < len(infra.Resources) {
infra.Resources[i].WorkerID = workerID
// Update infrastructure with worker IDs
for i, workerID := range workerIDs {
if i < len(infra.Resources) {
infra.Resources[i].WorkerID = workerID
}
}
}
+299
View File
@@ -0,0 +1,299 @@
package cloud
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"time"
"github.com/linode/linodego"
"github.com/pulumi/pulumi-linode/sdk/v4/go/linode"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"golang.org/x/oauth2"
)
// LinodeProvider implements the Provider interface for Linode
type LinodeProvider struct {
token string
region string
linodeType string
image string
client linodego.Client
}
// NewLinodeProvider creates a new Linode provider
func NewLinodeProvider(token, region, linodeType, image string) (*LinodeProvider, error) {
if token == "" {
return nil, fmt.Errorf("linode token is required")
}
if region == "" {
region = "us-east"
}
if linodeType == "" {
linodeType = "g6-standard-2"
}
if image == "" {
image = "linode/ubuntu22.04"
}
// Create linodego client
tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
oauthClient := &http.Client{Transport: &oauth2.Transport{Source: tokenSource}}
client := linodego.NewClient(oauthClient)
return &LinodeProvider{
token: token,
region: region,
linodeType: linodeType,
image: image,
client: client,
}, nil
}
// Validate checks if the provider configuration is valid
func (p *LinodeProvider) Validate(ctx context.Context) error {
_, err := p.client.GetAccount(ctx)
if err != nil {
return fmt.Errorf("failed to validate Linode credentials: %w", err)
}
return nil
}
// EstimateCost estimates the cost for the given configuration
func (p *LinodeProvider) EstimateCost(mode ExecutionMode, instanceCount int) (*CostEstimate, error) {
if mode != ModeVM {
return nil, fmt.Errorf("only VM mode is supported for Linode")
}
// Default pricing for common types (USD per hour)
pricing := map[string]float64{
"g6-nanode-1": 0.0075,
"g6-standard-1": 0.0075,
"g6-standard-2": 0.0150,
"g6-standard-4": 0.0300,
"g6-standard-6": 0.0600,
"g6-standard-8": 0.0900,
"g6-dedicated-2": 0.0450,
"g6-dedicated-4": 0.0900,
}
hourlyRate, ok := pricing[p.linodeType]
if !ok {
// Default to g6-standard-2 pricing if unknown
hourlyRate = 0.0150
}
totalHourlyRate := hourlyRate * float64(instanceCount)
return &CostEstimate{
HourlyCost: totalHourlyRate,
DailyCost: totalHourlyRate * 24,
Currency: "USD",
Breakdown: map[string]float64{
"compute": totalHourlyRate,
},
Notes: []string{
fmt.Sprintf("%d x %s instances @ $%.4f/hr each", instanceCount, p.linodeType, hourlyRate),
},
}, nil
}
// CreateInfrastructure provisions Linode instances
func (p *LinodeProvider) CreateInfrastructure(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
infraID := fmt.Sprintf("cloud-linode-%d", time.Now().Unix())
statePath := opts.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infraID, statePath)
if err != nil {
return nil, fmt.Errorf("failed to create Pulumi manager: %w", err)
}
// Set provider credentials
if err := pm.SetConfig(ctx, "linode:token", p.token, true); err != nil {
return nil, fmt.Errorf("failed to set Linode token: %w", err)
}
// Run Pulumi program
if err := pm.Up(ctx, p.createInstanceProgram(infraID, opts)); err != nil {
return nil, fmt.Errorf("failed to provision instances: %w", err)
}
// Extract outputs
outputs, err := pm.GetOutputs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get outputs: %w", err)
}
infra := &Infrastructure{
ID: infraID,
Provider: ProviderLinode,
Mode: opts.Mode,
CreatedAt: time.Now(),
PulumiStackID: pm.GetStackName(),
StatePath: statePath,
Resources: buildResourcesFromOutputs(infraID, opts.InstanceCount, outputs),
Metadata: map[string]interface{}{
"region": p.region,
"type": p.linodeType,
"ssh_user": "root",
},
}
return infra, nil
}
// DestroyInfrastructure tears down Linode resources
func (p *LinodeProvider) DestroyInfrastructure(ctx context.Context, infra *Infrastructure) error {
statePath := infra.StatePath
pm, err := NewPulumiManager("osmedeus-cloud", infra.PulumiStackID, statePath)
if err != nil {
return fmt.Errorf("failed to create Pulumi manager: %w", err)
}
if err := pm.SetConfig(ctx, "linode:token", p.token, true); err != nil {
return fmt.Errorf("failed to set Linode token: %w", err)
}
return pm.Destroy(ctx)
}
// GetStatus retrieves the current status of infrastructure
func (p *LinodeProvider) GetStatus(ctx context.Context, infra *Infrastructure) (*InfraStatus, error) {
status := &InfraStatus{
Status: "running",
TotalCount: len(infra.Resources),
Details: make([]ResourceStatus, 0, len(infra.Resources)),
}
for _, res := range infra.Resources {
rs := ResourceStatus{
ResourceID: res.ID,
WorkerRegistered: res.WorkerID != "",
}
if res.WorkerID != "" {
status.WorkersRegistered++
}
// Query instance status via API
instanceID, err := strconv.Atoi(res.ID)
if err == nil {
instance, apiErr := p.client.GetInstance(ctx, instanceID)
if apiErr == nil {
rs.Status = string(instance.Status)
if instance.Status == linodego.InstanceRunning {
status.ReadyCount++
}
} else {
rs.Status = "unknown"
rs.Message = apiErr.Error()
}
} else {
rs.Status = "unknown"
rs.Message = "invalid instance ID"
}
status.Details = append(status.Details, rs)
}
return status, nil
}
// Type returns the provider type
func (p *LinodeProvider) Type() ProviderType {
return ProviderLinode
}
// createInstanceProgram creates a Pulumi program for Linode instances
func (p *LinodeProvider) createInstanceProgram(infraID string, opts *CreateOptions) pulumi.RunFunc {
suffix := infraSuffix(infraID)
return func(ctx *pulumi.Context) error {
userData := GenerateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
userDataB64 := base64.StdEncoding.EncodeToString([]byte(userData))
// Upload SSH key
sshKey, err := linode.NewSshKey(ctx, "osmedeus-ssh-key", &linode.SshKeyArgs{
Label: pulumi.String(fmt.Sprintf("osmedeus-key-%s", suffix)),
SshKey: pulumi.String(opts.SSHPublicKey),
})
if err != nil {
return fmt.Errorf("failed to create SSH key: %w", err)
}
// Create firewall allowing SSH inbound and all outbound
fw, err := linode.NewFirewall(ctx, "osmedeus-firewall", &linode.FirewallArgs{
Label: pulumi.String(fmt.Sprintf("osmedeus-fw-%s", suffix)),
InboundPolicy: pulumi.String("DROP"),
OutboundPolicy: pulumi.String("ACCEPT"),
Inbounds: linode.FirewallInboundArray{
&linode.FirewallInboundArgs{
Action: pulumi.String("ACCEPT"),
Label: pulumi.String("allow-ssh"),
Protocol: pulumi.String("TCP"),
Ports: pulumi.String("22"),
Ipv4s: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
Ipv6s: pulumi.StringArray{pulumi.String("::/0")},
},
},
Outbounds: linode.FirewallOutboundArray{
&linode.FirewallOutboundArgs{
Action: pulumi.String("ACCEPT"),
Label: pulumi.String("allow-all-tcp"),
Protocol: pulumi.String("TCP"),
Ports: pulumi.String("1-65535"),
Ipv4s: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
Ipv6s: pulumi.StringArray{pulumi.String("::/0")},
},
},
})
if err != nil {
return fmt.Errorf("failed to create firewall: %w", err)
}
// Determine image
image := p.image
if opts.ImageID != "" {
image = opts.ImageID
}
// Create instances
for i := 0; i < opts.InstanceCount; i++ {
instanceName := fmt.Sprintf("osmw-%s-%d", suffix, i)
instance, err := linode.NewInstance(ctx, instanceName, &linode.InstanceArgs{
Label: pulumi.String(instanceName),
Type: pulumi.String(p.linodeType),
Region: pulumi.String(p.region),
Image: pulumi.String(image),
AuthorizedKeys: pulumi.StringArray{pulumi.String(opts.SSHPublicKey)},
Metadatas: linode.InstanceMetadataArray{
&linode.InstanceMetadataArgs{
UserData: pulumi.String(userDataB64),
},
},
Tags: pulumi.StringArray{
pulumi.String("osmedeus"),
pulumi.String("worker"),
},
})
if err != nil {
return fmt.Errorf("failed to create instance %s: %w", instanceName, err)
}
ctx.Export(fmt.Sprintf("worker-%d-ip", i), instance.IpAddress) //nolint:staticcheck // IpAddress is deprecated but no replacement in SDK v4
ctx.Export(fmt.Sprintf("worker-%d-id", i), instance.ID())
}
// Reference sshKey and fw to avoid unused variable errors
_ = sshKey
_ = fw
return nil
}
}
+66
View File
@@ -2,7 +2,13 @@ package cloud
import (
"context"
"crypto/md5"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/pulumi/pulumi/sdk/v3/go/auto"
)
// ProviderType represents the cloud provider type
@@ -14,6 +20,7 @@ const (
ProviderDigitalOcean ProviderType = "digitalocean"
ProviderLinode ProviderType = "linode"
ProviderAzure ProviderType = "azure"
ProviderHetzner ProviderType = "hetzner"
)
// ExecutionMode represents the execution mode (VM or serverless)
@@ -82,6 +89,10 @@ type CreateOptions struct {
// Timeout is the maximum time to wait for infrastructure creation
Timeout time.Duration
// StatePath is the resolved path for Pulumi state storage.
// Set by LifecycleManager from the cloud config's State.Path.
StatePath string
}
// Infrastructure represents created cloud infrastructure
@@ -101,6 +112,10 @@ type Infrastructure struct {
// PulumiStackID is the Pulumi stack identifier
PulumiStackID string
// StatePath is the resolved Pulumi state directory used during creation.
// Persisted so that Destroy can locate the correct state.
StatePath string `json:"state_path,omitempty"`
// Resources are the created resources (VMs, functions, etc.)
Resources []Resource
@@ -188,3 +203,54 @@ type CostEstimate struct {
// Notes contains additional cost information
Notes []string
}
// infraSuffix extracts the timestamp suffix from an infrastructure ID
// (e.g., "cloud-hetzner-1775231420" -> "1775231420").
func infraSuffix(infraID string) string {
if idx := strings.LastIndex(infraID, "-"); idx >= 0 {
return infraID[idx+1:]
}
return infraID
}
// sshPublicKeyFingerprint computes the MD5 fingerprint of an SSH public key
// in the colon-separated hex format used by cloud providers.
func sshPublicKeyFingerprint(publicKey string) (string, error) {
parts := strings.Fields(strings.TrimSpace(publicKey))
if len(parts) < 2 {
return "", fmt.Errorf("invalid SSH public key format")
}
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("failed to decode SSH public key: %w", err)
}
hash := md5.Sum(decoded)
fp := make([]string, len(hash))
for i, b := range hash {
fp[i] = fmt.Sprintf("%02x", b)
}
return strings.Join(fp, ":"), nil
}
// buildResourcesFromOutputs constructs a Resource slice from Pulumi stack outputs.
func buildResourcesFromOutputs(infraID string, count int, outputs map[string]auto.OutputValue) []Resource {
suffix := infraSuffix(infraID)
resources := make([]Resource, 0, count)
for i := 0; i < count; i++ {
res := Resource{
Type: "vm",
Name: fmt.Sprintf("osmw-%s-%d", suffix, i),
SSHEnabled: true,
Status: "active",
Metadata: map[string]interface{}{},
}
if ipOut, ok := outputs[fmt.Sprintf("worker-%d-ip", i)]; ok {
res.PublicIP = fmt.Sprintf("%v", ipOut.Value)
}
if idOut, ok := outputs[fmt.Sprintf("worker-%d-id", i)]; ok {
res.ID = fmt.Sprintf("%v", idOut.Value)
}
resources = append(resources, res)
}
return resources
}
+56 -22
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"github.com/pulumi/pulumi/sdk/v3/go/auto"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
@@ -16,6 +17,7 @@ import (
type PulumiManager struct {
projectName string
stackName string
statePath string
workspace auto.Workspace
stack auto.Stack
}
@@ -32,34 +34,33 @@ func NewPulumiManager(projectName, stackName, statePath string) (*PulumiManager,
return nil, fmt.Errorf("failed to create state directory: %w", err)
}
ctx := context.Background()
// Create workspace with local backend
ws, err := auto.NewLocalWorkspace(ctx,
auto.WorkDir(statePath),
)
if err != nil {
return nil, fmt.Errorf("failed to create Pulumi workspace: %w", err)
// Set passphrase for local secrets encryption if not already set.
// Pulumi requires this for its local backend to encrypt config secrets.
if os.Getenv("PULUMI_CONFIG_PASSPHRASE") == "" && os.Getenv("PULUMI_CONFIG_PASSPHRASE_FILE") == "" {
_ = os.Setenv("PULUMI_CONFIG_PASSPHRASE", "")
}
// Initialize stack with inline program
ctx := context.Background()
// Use file:// backend pointing at the state directory
backendURL := fmt.Sprintf("file://%s", statePath)
// Initialize stack with inline program and explicit local backend
stack, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, func(ctx *pulumi.Context) error {
// Placeholder program - will be replaced by provider-specific logic
return nil
})
}, auto.EnvVars(map[string]string{
"PULUMI_BACKEND_URL": backendURL,
"PULUMI_CONFIG_PASSPHRASE": os.Getenv("PULUMI_CONFIG_PASSPHRASE"),
}))
if err != nil {
return nil, fmt.Errorf("failed to create stack: %w", err)
}
// Set workspace for stack
stack.Workspace().SetProgram(func(ctx *pulumi.Context) error {
return nil
})
return &PulumiManager{
projectName: projectName,
stackName: stackName,
workspace: ws,
statePath: statePath,
workspace: stack.Workspace(),
stack: stack,
}, nil
}
@@ -72,8 +73,10 @@ func (pm *PulumiManager) Up(ctx context.Context, program pulumi.RunFunc) error {
// Set stack configuration if needed
// This can be extended to set provider-specific config
// Run pulumi up with progress streaming
_, err := pm.stack.Up(ctx, optup.ProgressStreams(os.Stdout))
// Run pulumi up with colorized progress streaming
pw := NewPulumiWriter()
_, err := pm.stack.Up(ctx, optup.ProgressStreams(pw))
pw.Flush()
if err != nil {
return fmt.Errorf("failed to provision infrastructure: %w", err)
}
@@ -81,21 +84,44 @@ func (pm *PulumiManager) Up(ctx context.Context, program pulumi.RunFunc) error {
return nil
}
// Destroy tears down the infrastructure
// Destroy tears down the infrastructure and removes the stack and its state
func (pm *PulumiManager) Destroy(ctx context.Context) error {
_, err := pm.stack.Destroy(ctx)
pw := NewPulumiWriter()
_, err := pm.stack.Destroy(ctx, optdestroy.ProgressStreams(pw))
pw.Flush()
if err != nil {
return fmt.Errorf("failed to destroy infrastructure: %w", err)
}
// Remove stack after successful destroy
// Remove stack history and configuration after successful destroy
if err := pm.stack.Workspace().RemoveStack(ctx, pm.stackName); err != nil {
return fmt.Errorf("failed to remove stack: %w", err)
}
// Clean up leftover Pulumi state directory for this stack
pm.cleanupStackState()
return nil
}
// cleanupStackState removes leftover Pulumi local backend files for the stack
func (pm *PulumiManager) cleanupStackState() {
statePath := pm.statePath
// Remove stack-specific state files from the local backend
stackDir := filepath.Join(statePath, ".pulumi", "stacks", pm.projectName)
stackFile := filepath.Join(stackDir, pm.stackName+".json")
_ = os.Remove(stackFile)
stackFileBak := filepath.Join(stackDir, pm.stackName+".json.bak")
_ = os.Remove(stackFileBak)
// Remove the project directory if empty
entries, err := os.ReadDir(stackDir)
if err == nil && len(entries) == 0 {
_ = os.Remove(stackDir)
}
}
// GetOutputs retrieves the stack outputs (IPs, IDs, etc.)
func (pm *PulumiManager) GetOutputs(ctx context.Context) (map[string]auto.OutputValue, error) {
outputs, err := pm.stack.Outputs(ctx)
@@ -105,6 +131,14 @@ func (pm *PulumiManager) GetOutputs(ctx context.Context) (map[string]auto.Output
return outputs, nil
}
// SetConfig sets a Pulumi stack configuration value
func (pm *PulumiManager) SetConfig(ctx context.Context, key, value string, secret bool) error {
return pm.stack.SetConfig(ctx, key, auto.ConfigValue{
Value: value,
Secret: secret,
})
}
// GetStackName returns the stack name
func (pm *PulumiManager) GetStackName() string {
return pm.stackName
+132
View File
@@ -0,0 +1,132 @@
package cloud
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
)
const pulumiPrefix = " │ "
// PulumiWriter is an io.Writer that colorizes Pulumi progress output
type PulumiWriter struct {
out io.Writer
buf []byte // buffer for incomplete lines
}
// NewPulumiWriter creates a writer that colorizes Pulumi output lines
func NewPulumiWriter() *PulumiWriter {
return &PulumiWriter{out: os.Stdout}
}
func (w *PulumiWriter) Write(p []byte) (int, error) {
n := len(p)
w.buf = append(w.buf, p...)
for {
idx := bytes.IndexByte(w.buf, '\n')
if idx < 0 {
break
}
line := string(w.buf[:idx])
w.buf = w.buf[idx+1:]
colored := colorizePulumiLine(line)
if _, err := fmt.Fprintf(w.out, "%s%s\n", pulumiPrefix, colored); err != nil {
return n, err
}
}
return n, nil
}
// Flush writes any remaining buffered content
func (w *PulumiWriter) Flush() {
if len(w.buf) > 0 {
colored := colorizePulumiLine(string(w.buf))
_, _ = fmt.Fprintf(w.out, "%s%s\n", pulumiPrefix, colored)
w.buf = nil
}
}
func colorizePulumiLine(line string) string {
trimmed := strings.TrimSpace(line)
// Empty lines pass through
if trimmed == "" {
return ""
}
// Resource operations: " + type name action"
if strings.HasPrefix(trimmed, "+") {
return terminal.Green(line)
}
if strings.HasPrefix(trimmed, "~") {
return terminal.Yellow(line)
}
if strings.HasPrefix(trimmed, "-") {
return terminal.Red(line)
}
// Replace operations show as "+-"
if strings.HasPrefix(trimmed, "++") || strings.HasPrefix(trimmed, "+-") {
return terminal.Yellow(line)
}
// Progress spinner lines: "@ updating...."
if strings.HasPrefix(trimmed, "@") {
return terminal.Gray(line)
}
// Section headers
if strings.HasPrefix(trimmed, "Updating") ||
strings.HasPrefix(trimmed, "Destroying") ||
strings.HasPrefix(trimmed, "Previewing") ||
strings.HasPrefix(trimmed, "Refreshing") {
return terminal.BoldCyan(line)
}
// Output/Resource summary headers
if strings.HasPrefix(trimmed, "Outputs:") || strings.HasPrefix(trimmed, "Resources:") {
return terminal.Bold(line)
}
// Duration line
if strings.HasPrefix(trimmed, "Duration:") {
return terminal.Cyan(line)
}
// Resource count summary lines like "+ 4 created", "~ 1 updated", "- 2 deleted"
if (strings.HasPrefix(trimmed, "+ ") || strings.HasPrefix(trimmed, "~ ") || strings.HasPrefix(trimmed, "- ")) &&
(strings.Contains(trimmed, "created") || strings.Contains(trimmed, "updated") || strings.Contains(trimmed, "deleted") || strings.Contains(trimmed, "unchanged")) {
switch trimmed[0] {
case '+':
return terminal.Green(line)
case '~':
return terminal.Yellow(line)
case '-':
return terminal.Red(line)
}
}
// Suppress "run pulumi stack rm" hint — we call RemoveStack automatically
if strings.Contains(trimmed, "pulumi stack rm") ||
strings.Contains(trimmed, "the history and configuration associated with the stack are still maintained") {
return ""
}
// Diagnostics / errors
if strings.HasPrefix(trimmed, "error:") || strings.HasPrefix(trimmed, "Error:") {
return terminal.Red(line)
}
if strings.HasPrefix(trimmed, "warning:") || strings.HasPrefix(trimmed, "Warning:") {
return terminal.Yellow(line)
}
// Output key-value pairs (indented under Outputs:)
// Default: pass through unchanged
return line
}
+41 -4
View File
@@ -53,13 +53,50 @@ func CreateProvider(cfg *config.CloudConfigs, providerType ProviderType) (Provid
cfg.Providers.DigitalOcean.SSHKeyFingerprint,
)
case ProviderAWS:
return nil, fmt.Errorf("AWS provider not yet implemented")
return NewAWSProvider(
cfg.Providers.AWS.AccessKeyID,
cfg.Providers.AWS.SecretAccessKey,
cfg.Providers.AWS.Region,
cfg.Providers.AWS.InstanceType,
cfg.Providers.AWS.AMI,
cfg.Providers.AWS.AMIFilter,
cfg.Providers.AWS.UseSpot,
)
case ProviderGCP:
return nil, fmt.Errorf("GCP provider not yet implemented")
return NewGCPProvider(
cfg.Providers.GCP.ProjectID,
cfg.Providers.GCP.CredentialsFile,
cfg.Providers.GCP.Region,
cfg.Providers.GCP.Zone,
cfg.Providers.GCP.MachineType,
cfg.Providers.GCP.ImageFamily,
cfg.Providers.GCP.UsePreemptible,
)
case ProviderLinode:
return nil, fmt.Errorf("linode provider not yet implemented")
return NewLinodeProvider(
cfg.Providers.Linode.Token,
cfg.Providers.Linode.Region,
cfg.Providers.Linode.Type,
cfg.Providers.Linode.Image,
)
case ProviderAzure:
return nil, fmt.Errorf("azure provider not yet implemented")
return NewAzureProvider(
cfg.Providers.Azure.SubscriptionID,
cfg.Providers.Azure.TenantID,
cfg.Providers.Azure.ClientID,
cfg.Providers.Azure.ClientSecret,
cfg.Providers.Azure.Location,
cfg.Providers.Azure.VMSize,
cfg.Providers.Azure.ImageReference,
)
case ProviderHetzner:
return NewHetznerProvider(
cfg.Providers.Hetzner.Token,
cfg.Providers.Hetzner.Location,
cfg.Providers.Hetzner.ServerType,
cfg.Providers.Hetzner.Image,
cfg.Providers.Hetzner.SSHKeyName,
)
default:
return nil, fmt.Errorf("unknown provider type: %s", providerType)
}
+320
View File
@@ -0,0 +1,320 @@
package cloud
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/runner"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// ansiControlRegex strips non-visual ANSI sequences (cursor, screen, OSC, mode)
// while preserving SGR sequences (colors/bold/reset ending in 'm').
// OSC sequences can end with BEL (\x07) or ST (\x1b\\).
var ansiControlRegex = regexp.MustCompile(`\x1b\[[0-9;]*[A-HJ-Zadfhijklnqrstu]|\x1b\][^\x1b\x07]*(?:\x07|\x1b\\)|\x1b\(B|\x1b\[\?[0-9;]*[hl]`)
// ansiAllRegex strips all ANSI escape sequences including colors (used when color is disabled).
var ansiAllRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x1b\x07]*(?:\x07|\x1b\\)|\x1b\(B|\x1b\[[\?]?[0-9;]*[hlm]`)
// SSHConfig holds the parameters needed to connect to a remote host
type SSHConfig struct {
Host string
Port int
User string
KeyFile string
Password string
}
// CloudSSHClient wraps a pooled SSH connection for cloud operations.
// It provides command execution (blocking and streaming) plus SFTP file transfers.
type CloudSSHClient struct {
client *ssh.Client
poolKey runner.SSHPoolKey
config *core.RunnerConfig
}
// NewCloudSSHClient creates a new SSH client using the global connection pool.
func NewCloudSSHClient(ctx context.Context, cfg SSHConfig) (*CloudSSHClient, error) {
if cfg.Port == 0 {
cfg.Port = 22
}
if cfg.User == "" {
cfg.User = "root"
}
runnerCfg := &core.RunnerConfig{
Host: cfg.Host,
Port: cfg.Port,
User: cfg.User,
KeyFile: cfg.KeyFile,
Password: cfg.Password,
}
pool := runner.GetSSHPool()
client, poolKey, err := pool.Get(ctx, runnerCfg)
if err != nil {
return nil, fmt.Errorf("SSH connection to %s:%d failed: %w", cfg.Host, cfg.Port, err)
}
return &CloudSSHClient{
client: client,
poolKey: poolKey,
config: runnerCfg,
}, nil
}
// Close releases the connection back to the pool.
func (c *CloudSSHClient) Close() {
runner.GetSSHPool().Release(c.poolKey)
}
// RunCommand executes a command on the remote host and returns the output.
func (c *CloudSSHClient) RunCommand(ctx context.Context, command string) (string, int, error) {
session, err := c.client.NewSession()
if err != nil {
return "", -1, fmt.Errorf("failed to create session: %w", err)
}
defer func() { _ = session.Close() }()
// Request a PTY so sudo works (non-fatal if unavailable)
_ = session.RequestPty("xterm", 80, 200, ssh.TerminalModes{})
output, err := session.CombinedOutput(command)
exitCode := 0
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
exitCode = exitErr.ExitStatus()
} else {
return string(output), -1, err
}
}
return string(output), exitCode, nil
}
// LineCallback is called for each ANSI-stripped output line during streaming.
type LineCallback func(line string)
// RunCommandStreaming executes a command, streaming stdout/stderr line-by-line with a prefix.
// Blocks until the command completes.
func (c *CloudSSHClient) RunCommandStreaming(ctx context.Context, command string, prefix string) error {
return c.RunCommandStreamingWithCallback(ctx, command, prefix, nil)
}
// RunCommandStreamingWithCallback is like RunCommandStreaming but calls onLine for each
// ANSI-stripped output line, allowing the caller to observe progress without parsing stdout.
func (c *CloudSSHClient) RunCommandStreamingWithCallback(ctx context.Context, command string, prefix string, onLine LineCallback) error {
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
defer func() { _ = session.Close() }()
// Request a PTY so sudo works and output is interleaved (non-fatal if unavailable)
_ = session.RequestPty("xterm", 80, 200, ssh.TerminalModes{})
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderr, err := session.StderrPipe()
if err != nil {
return fmt.Errorf("failed to get stderr pipe: %w", err)
}
if err := session.Start(command); err != nil {
return fmt.Errorf("failed to start command: %w", err)
}
prefixStr := terminal.Gray(fmt.Sprintf("[%s] ", prefix))
// oscFragmentRegex catches leftover OSC fragments when the ESC opener was on a previous line
// e.g. "11;rgb:1818/1919/2020" from a split OSC 11 background-color response
oscFragmentRegex := regexp.MustCompile(`^[0-9]+;[^\x1b]*(?:\x07|\x1b\\)?`)
// Stream both pipes with prefix in parallel
done := make(chan struct{}, 2)
streamLines := func(r io.Reader) {
defer func() { done <- struct{}{} }()
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
pendingOSC := false
for scanner.Scan() {
raw := scanner.Text()
// If previous line had an unterminated OSC, this line is the continuation
if pendingOSC {
pendingOSC = false
raw = oscFragmentRegex.ReplaceAllString(raw, "")
}
// Detect unterminated OSC at end of line (ESC ] without matching terminator)
if idx := strings.LastIndex(raw, "\x1b]"); idx >= 0 {
tail := raw[idx:]
if !strings.Contains(tail, "\x07") && !strings.Contains(tail, "\x1b\\") {
raw = raw[:idx]
pendingOSC = true
}
}
var line string
if terminal.IsColorEnabled() {
// Preserve SGR (color) codes; strip only control sequences
line = ansiControlRegex.ReplaceAllString(raw, "")
} else {
line = ansiAllRegex.ReplaceAllString(raw, "")
}
if line == "" {
continue
}
_, _ = fmt.Fprintf(os.Stdout, "%s%s\n", prefixStr, line)
if onLine != nil {
onLine(line)
}
}
}
go streamLines(stdout)
go streamLines(stderr)
<-done
<-done
return session.Wait()
}
// UploadFile copies a local file to the remote host via SFTP.
func (c *CloudSSHClient) UploadFile(localPath, remotePath string) error {
sftpClient, err := sftp.NewClient(c.client)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %w", err)
}
defer func() { _ = sftpClient.Close() }()
// Ensure remote directory exists
remoteDir := filepath.Dir(remotePath)
_ = sftpClient.MkdirAll(remoteDir)
localFile, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("failed to open local file: %w", err)
}
defer func() { _ = localFile.Close() }()
remoteFile, err := sftpClient.Create(remotePath)
if err != nil {
return fmt.Errorf("failed to create remote file: %w", err)
}
defer func() { _ = remoteFile.Close() }()
if _, err := io.Copy(remoteFile, localFile); err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
return nil
}
// DownloadFile copies a remote file to the local filesystem via SFTP.
func (c *CloudSSHClient) DownloadFile(remotePath, localPath string) error {
sftpClient, err := sftp.NewClient(c.client)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %w", err)
}
defer func() { _ = sftpClient.Close() }()
remoteFile, err := sftpClient.Open(remotePath)
if err != nil {
return fmt.Errorf("failed to open remote file: %w", err)
}
defer func() { _ = remoteFile.Close() }()
// Ensure local directory exists
localDir := filepath.Dir(localPath)
if err := os.MkdirAll(localDir, 0755); err != nil {
return fmt.Errorf("failed to create local directory: %w", err)
}
localFile, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer func() { _ = localFile.Close() }()
if _, err := io.Copy(localFile, remoteFile); err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
return nil
}
// DownloadDir recursively downloads a remote directory to local via SFTP.
func (c *CloudSSHClient) DownloadDir(remotePath, localPath string) error {
sftpClient, err := sftp.NewClient(c.client)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %w", err)
}
defer func() { _ = sftpClient.Close() }()
return downloadDirRecursive(sftpClient, remotePath, localPath)
}
func downloadDirRecursive(client *sftp.Client, remotePath, localPath string) error {
entries, err := client.ReadDir(remotePath)
if err != nil {
return fmt.Errorf("failed to list remote dir %s: %w", remotePath, err)
}
if err := os.MkdirAll(localPath, 0755); err != nil {
return fmt.Errorf("failed to create local dir: %w", err)
}
for _, entry := range entries {
remoteEntry := remotePath + "/" + entry.Name()
localEntry := filepath.Join(localPath, entry.Name())
if entry.IsDir() {
if err := downloadDirRecursive(client, remoteEntry, localEntry); err != nil {
return err
}
} else {
remoteFile, err := client.Open(remoteEntry)
if err != nil {
return fmt.Errorf("failed to open %s: %w", remoteEntry, err)
}
localFile, err := os.Create(localEntry)
if err != nil {
_ = remoteFile.Close()
return fmt.Errorf("failed to create %s: %w", localEntry, err)
}
_, copyErr := io.Copy(localFile, remoteFile)
_ = remoteFile.Close()
_ = localFile.Close()
if copyErr != nil {
return fmt.Errorf("failed to copy %s: %w", remoteEntry, copyErr)
}
}
}
return nil
}
// ExpandPath expands ~ to the user's home directory
func ExpandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
+37 -1
View File
@@ -17,6 +17,7 @@ type Providers struct {
DigitalOcean DigitalOceanConfig `yaml:"digitalocean"`
Linode LinodeConfig `yaml:"linode"`
Azure AzureConfig `yaml:"azure"`
Hetzner HetznerConfig `yaml:"hetzner"`
}
// AWSConfig contains AWS credentials and configuration
@@ -26,6 +27,7 @@ type AWSConfig struct {
Region string `yaml:"region"`
InstanceType string `yaml:"instance_type"`
AMI string `yaml:"ami"`
AMIFilter string `yaml:"ami_filter"`
UseSpot bool `yaml:"use_spot"`
}
@@ -71,6 +73,15 @@ type AzureConfig struct {
ImageReference string `yaml:"image_reference"`
}
// HetznerConfig contains Hetzner Cloud credentials and configuration
type HetznerConfig struct {
Token string `yaml:"token"`
Location string `yaml:"location"`
ServerType string `yaml:"server_type"`
Image string `yaml:"image"`
SSHKeyName string `yaml:"ssh_key_name"`
}
// Defaults contains default cloud configuration values
type Defaults struct {
Provider string `yaml:"provider"`
@@ -101,11 +112,24 @@ type SSH struct {
PublicKeyPath string `yaml:"public_key_path"`
PublicKeyContent string `yaml:"public_key_content"`
User string `yaml:"user"`
Password string `yaml:"password"`
Port string `yaml:"port"`
}
// Setup contains worker setup configuration
type Setup struct {
Commands []string `yaml:"commands"`
Commands []string `yaml:"commands"`
PostCommands []string `yaml:"post_commands"`
Ansible AnsibleSetup `yaml:"ansible"`
}
// AnsibleSetup contains ansible playbook configuration for worker setup
type AnsibleSetup struct {
Enabled bool `yaml:"enabled"`
PlaybookPath string `yaml:"playbook_path"`
InventoryPath string `yaml:"inventory_path"`
ExtraVars map[string]string `yaml:"extra_vars"`
ExtraArgs string `yaml:"extra_args"`
}
// DefaultCloudConfigs returns a default cloud configuration
@@ -117,6 +141,7 @@ func DefaultCloudConfigs() *CloudConfigs {
SecretAccessKey: "${AWS_SECRET_ACCESS_KEY}",
Region: "us-east-1",
InstanceType: "t3.medium",
AMIFilter: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*",
UseSpot: false,
},
GCP: GCPConfig{
@@ -147,6 +172,12 @@ func DefaultCloudConfigs() *CloudConfigs {
Location: "eastus",
VMSize: "Standard_B2s",
},
Hetzner: HetznerConfig{
Token: "${HETZNER_TOKEN}",
Location: "nbg1",
ServerType: "cx22",
Image: "ubuntu-22.04",
},
},
Defaults: Defaults{
Provider: "digitalocean",
@@ -173,6 +204,11 @@ func DefaultCloudConfigs() *CloudConfigs {
Commands: []string{
"# Add custom setup commands here",
},
Ansible: AnsibleSetup{
Enabled: false,
PlaybookPath: "{{base_folder}}/cloud-infra/setup-playbook.yaml",
InventoryPath: "{{base_folder}}/cloud-infra/inventory.ini",
},
},
}
}
+8
View File
@@ -852,6 +852,10 @@ func (c *Config) ResolvePaths() {
// Resolve external scripts path
c.ExternalScriptsPath = c.resolvePath(c.Environments.ExternalScripts, baseFolder)
// Resolve cloud paths
c.Cloud.CloudPath = c.resolvePath(c.Cloud.CloudPath, baseFolder)
c.Cloud.CloudSettings = c.resolvePath(c.Cloud.CloudSettings, baseFolder)
}
// resolvePath resolves a single path with variable substitution
@@ -1236,6 +1240,10 @@ func DefaultConfig() *Config {
SystemPrompt: "",
CustomHeaders: "",
},
Cloud: CloudConfig{
CloudPath: "{{base_folder}}/cloud",
CloudSettings: "{{base_folder}}/cloud/cloud-settings.yaml",
},
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package core
// Project metadata constants
const (
// VERSION of this project
VERSION = "v5.0.1"
VERSION = "v5.0.2"
// DESC description of the tool
DESC = "A Modern Orchestration Engine for Security"
// BINARY name of osmedeus
+36
View File
@@ -268,6 +268,9 @@ type Step struct {
AllowedPaths []string `yaml:"allowed_paths,omitempty"` // Paths the agent is allowed to read
ACPConfig *ACPStepConfig `yaml:"acp_config,omitempty"` // ACP-specific configuration
// Agent-SDK step fields (uses go-agent-agnostic library)
SDKConfig *SDKStepConfig `yaml:"sdk_config,omitempty"` // SDK-specific configuration
// Streaming (applies to both llm and agent steps)
Stream *bool `yaml:"stream,omitempty"` // Enable streaming output (overrides llm_config.stream and global config)
@@ -360,6 +363,18 @@ type ACPStepConfig struct {
WriteEnabled bool `yaml:"write_enabled,omitempty"` // Allow the agent to write files (default: false)
}
// SDKStepConfig holds configuration specific to agent-sdk steps
type SDKStepConfig struct {
Model string `yaml:"model,omitempty"` // Model name (agent-specific, e.g., "sonnet", "o3")
Env map[string]string `yaml:"env,omitempty"` // Environment variables for the agent process
MaxTurns int `yaml:"max_turns,omitempty"` // Max agentic turns (claude-code only, 0 = default)
PermissionMode string `yaml:"permission_mode,omitempty"` // Permission mode (claude-code only, e.g., "bypassPermissions")
Sandbox string `yaml:"sandbox,omitempty"` // Sandbox mode (codex only, e.g., "danger-full-access")
Strategy string `yaml:"strategy,omitempty"` // Multi-agent strategy: "first" or "all" (requires agents list)
Agents []string `yaml:"agents,omitempty"` // Multiple agents for multi-agent strategies
SessionResume string `yaml:"session_resume,omitempty"` // Resume a previous session by ID (claude-code only)
}
// IsBashStep returns true if this is a bash step
func (s *Step) IsBashStep() bool {
return s.Type == StepTypeBash
@@ -405,6 +420,11 @@ func (s *Step) IsAgentACPStep() bool {
return s.Type == StepTypeAgentACP
}
// IsAgentSDKStep returns true if this is an agent-sdk step
func (s *Step) IsAgentSDKStep() bool {
return s.Type == StepTypeAgentSDK
}
// GetStepRunner returns the step runner type, defaulting to host/local
func (s *Step) GetStepRunner() RunnerType {
if s.StepRunner == "" {
@@ -574,6 +594,22 @@ func (s *Step) Clone() *Step {
cloned.ACPConfig = &cfg
}
// Deep copy Agent-SDK fields
if s.SDKConfig != nil {
cfg := *s.SDKConfig
if len(s.SDKConfig.Env) > 0 {
cfg.Env = make(map[string]string, len(s.SDKConfig.Env))
for k, v := range s.SDKConfig.Env {
cfg.Env[k] = v
}
}
if len(s.SDKConfig.Agents) > 0 {
cfg.Agents = make([]string, len(s.SDKConfig.Agents))
copy(cfg.Agents, s.SDKConfig.Agents)
}
cloned.SDKConfig = &cfg
}
// Deep copy SubAgents
if len(s.SubAgents) > 0 {
cloned.SubAgents = make([]SubAgentDef, len(s.SubAgents))
+4
View File
@@ -27,6 +27,7 @@ const (
StepTypeLLM StepType = "llm"
StepTypeAgent StepType = "agent"
StepTypeAgentACP StepType = "agent-acp"
StepTypeAgentSDK StepType = "agent-sdk"
)
// TriggerType represents trigger types
@@ -119,6 +120,9 @@ const (
// DefaultACPAgent is the default ACP agent used when none is specified.
const DefaultACPAgent = "claude-code"
// DefaultSDKAgent is the default agent-sdk agent used when none is specified.
const DefaultSDKAgent = "claude-code"
// KillProcessAndChildren kills a process and all its children using SIGKILL.
// It first attempts to kill the entire process group (negative PID), falling
// back to killing just the process. Returns true if the signal was sent.
+7 -1
View File
@@ -3984,7 +3984,7 @@ type RunResult struct {
}
// ListRuns returns paginated runs with optional filters
func ListRuns(ctx context.Context, offset, limit int, status, workflow, target, workspace string) (*RunResult, error) {
func ListRuns(ctx context.Context, offset, limit int, status, workflow, target, workspace, runMode string) (*RunResult, error) {
if db == nil {
return nil, fmt.Errorf("database not connected")
}
@@ -4008,6 +4008,9 @@ func ListRuns(ctx context.Context, offset, limit int, status, workflow, target,
if workspace != "" {
query = query.Where("workspace = ?", workspace)
}
if runMode != "" {
query = query.Where("run_mode = ?", runMode)
}
totalCount, err := query.Count(ctx)
if err != nil {
@@ -4031,6 +4034,9 @@ func ListRuns(ctx context.Context, offset, limit int, status, workflow, target,
if workspace != "" {
q = q.Where("workspace = ?", workspace)
}
if runMode != "" {
q = q.Where("run_mode = ?", runMode)
}
return q
}).
Order("created_at DESC").
+49
View File
@@ -122,6 +122,7 @@ func NewStepDispatcherWithConfig(cfg StepDispatcherConfig) *StepDispatcher {
d.registry.Register(d.llmExecutor)
d.registry.Register(d.agentExecutor)
d.registry.Register(NewACPExecutor(engine))
d.registry.Register(NewSDKExecutor(engine))
return d
}
@@ -335,6 +336,21 @@ func collectRenderRequests(step *core.Step) []template.RenderRequest {
}
}
// Agent-SDK step fields
if step.SDKConfig != nil {
add("SDKConfig.Model", step.SDKConfig.Model)
add("SDKConfig.PermissionMode", step.SDKConfig.PermissionMode)
add("SDKConfig.Sandbox", step.SDKConfig.Sandbox)
add("SDKConfig.Strategy", step.SDKConfig.Strategy)
add("SDKConfig.SessionResume", step.SDKConfig.SessionResume)
for k, v := range step.SDKConfig.Env {
add(fmt.Sprintf("SDKConfig.Env[%s]", k), v)
}
for i, a := range step.SDKConfig.Agents {
add(fmt.Sprintf("SDKConfig.Agents[%d]", i), a)
}
}
// RunnerConfig fields
if step.StepRunnerConfig != nil && step.StepRunnerConfig.RunnerConfig != nil {
cfg := step.StepRunnerConfig.RunnerConfig
@@ -564,6 +580,39 @@ func (d *StepDispatcher) renderStepBatch(step *core.Step, vars map[string]any) (
rendered.ACPConfig = &cfg
}
// Agent-SDK step fields
if step.SDKConfig != nil {
cfg := *step.SDKConfig
if v := get("SDKConfig.Model"); v != "" {
cfg.Model = v
}
if v := get("SDKConfig.PermissionMode"); v != "" {
cfg.PermissionMode = v
}
if v := get("SDKConfig.Sandbox"); v != "" {
cfg.Sandbox = v
}
if v := get("SDKConfig.Strategy"); v != "" {
cfg.Strategy = v
}
if v := get("SDKConfig.SessionResume"); v != "" {
cfg.SessionResume = v
}
if len(step.SDKConfig.Env) > 0 {
cfg.Env = make(map[string]string, len(step.SDKConfig.Env))
for k := range step.SDKConfig.Env {
cfg.Env[k] = get(fmt.Sprintf("SDKConfig.Env[%s]", k))
}
}
if len(step.SDKConfig.Agents) > 0 {
cfg.Agents = make([]string, len(step.SDKConfig.Agents))
for i := range step.SDKConfig.Agents {
cfg.Agents[i] = get(fmt.Sprintf("SDKConfig.Agents[%d]", i))
}
}
rendered.SDKConfig = &cfg
}
// Apply results to slice fields
if len(step.Commands) > 0 {
rendered.Commands = make([]string, len(step.Commands))
+42
View File
@@ -603,6 +603,36 @@ func formatDuration(d time.Duration) string {
}
// printDryRunHeader prints a formatted header for dry-run mode
// specialToggleParams maps param names to their descriptions for the tip message.
// These are params that are disabled by default but can significantly increase scan results.
var specialToggleParams = map[string]string{
"enableDnsBruteFocing": "DNS brute forcing",
"enablePermutation": "domain permutation",
"enableSYNScan": "SYN scanning (may alert cloud providers)",
}
// collectDisabledToggles returns the subset of special toggle params that exist
// in the workflow and are resolved to false.
func collectDisabledToggles(params []core.Param, execCtx *core.ExecutionContext) map[string]string {
disabled := make(map[string]string)
for _, p := range params {
desc, isSpecial := specialToggleParams[p.Name]
if !isSpecial {
continue
}
val, ok := execCtx.GetParam(p.Name)
if !ok {
// param exists in workflow but wasn't resolved — treat as disabled
disabled[p.Name] = desc
continue
}
if boolVal, isBool := val.(bool); isBool && !boolVal {
disabled[p.Name] = desc
}
}
return disabled
}
func printDryRunHeader(workflowName, workflowKind, target, tactic string, stepCount int, execCtx *core.ExecutionContext) {
separator := strings.Repeat("═", 52)
@@ -1202,6 +1232,10 @@ func (e *Executor) ExecuteModule(ctx context.Context, module *core.Workflow, par
} else if e.progressBar == nil {
toggle, speed, _, _ := core.CategorizeParams(module.Params)
e.printer.WorkflowInfo(module.Name, module.Description, module.Tags, string(module.Runner), len(module.Steps), len(toggle), len(speed))
// Show tip for disabled special toggle params
disabledToggles := collectDisabledToggles(module.Params, execCtx)
e.printer.DisabledTogglesTip(disabledToggles)
}
// Show target space folder location
@@ -2585,6 +2619,14 @@ func (e *Executor) executeStep(ctx context.Context, step *core.Step, execCtx *co
result.NextStep = gotoStep
}
}
// Print inline results output to terminal
if e.progressBar == nil && !e.silent {
for _, ir := range result.InlineResults {
if ir != nil && ir.Output != "" {
e.printer.VerboseOutput(ir.Output)
}
}
}
}
// Log step execution details to state execution log file
+30 -11
View File
@@ -45,35 +45,54 @@ func DetectKubernetes() bool {
return false
}
// DetectCloudProvider detects AWS, GCP, Azure, or returns "local"
// DetectCloudProvider detects cloud providers via DMI information.
// Returns the provider name (e.g. "aws", "gcp"), "on-prem" if no provider
// is detected, or "unknown" if DMI information cannot be read at all.
func DetectCloudProvider() string {
// Only works on Linux - check DMI information
// DMI detection only works on Linux
if runtime.GOOS != "linux" {
return "local"
return "on-prem"
}
// Check sys_vendor
vendorPaths := []string{
dmiPaths := []string{
"/sys/class/dmi/id/sys_vendor",
"/sys/devices/virtual/dmi/id/bios_vendor",
"/sys/class/dmi/id/product_name",
"/sys/class/dmi/id/chassis_asset_tag",
}
for _, path := range vendorPaths {
anyReadable := false
for _, path := range dmiPaths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
vendor := strings.ToLower(strings.TrimSpace(string(data)))
anyReadable = true
value := strings.ToLower(strings.TrimSpace(string(data)))
switch {
case strings.Contains(vendor, "amazon"):
case strings.Contains(value, "amazon"):
return "aws"
case strings.Contains(vendor, "google"):
case strings.Contains(value, "google"):
return "gcp"
case strings.Contains(vendor, "microsoft"):
case strings.Contains(value, "microsoft"):
return "azure"
case strings.Contains(value, "digitalocean"):
return "digitalocean"
case strings.Contains(value, "akamai") || strings.Contains(value, "linode"):
return "linode"
case strings.Contains(value, "vultr"):
return "vultr"
case strings.Contains(value, "hetzner"):
return "hetzner"
case strings.Contains(value, "oraclecloud"):
return "oracle"
}
}
return "local"
if !anyReadable {
return "unknown"
}
return "on-prem"
}
+2 -2
View File
@@ -26,7 +26,7 @@ func TestDetectKubernetes(t *testing.T) {
func TestDetectCloudProvider(t *testing.T) {
result := DetectCloudProvider()
t.Logf("DetectCloudProvider() = %s", result)
// On a local machine, this should be "local"
// On a local machine, this should be "on-prem"
// This is primarily a smoke test - we just verify it doesn't panic
}
@@ -137,7 +137,7 @@ func TestPlatformVariablesTemplateRendering(t *testing.T) {
{"echo {{PlatformArch}}", "echo " + runtime.GOARCH},
{"echo {{PlatformInDocker}}", "echo false"},
{"echo {{PlatformInKubernetes}}", "echo false"},
{"echo {{PlatformCloudProvider}}", "echo local"},
{"echo {{PlatformCloudProvider}}", "echo " + DetectCloudProvider()},
}
for _, tt := range tests {
+263
View File
@@ -0,0 +1,263 @@
package executor
import (
"context"
"fmt"
"strings"
"time"
agnostic "github.com/j3ssie/go-agent-agnostic"
"github.com/j3ssie/go-agent-agnostic/sdk/claude"
"github.com/j3ssie/go-agent-agnostic/sdk/codex"
"github.com/j3ssie/go-agent-agnostic/sdk/opencode"
"github.com/j3ssie/osmedeus/v5/internal/core"
oslogger "github.com/j3ssie/osmedeus/v5/internal/logger"
"github.com/j3ssie/osmedeus/v5/internal/template"
"go.uber.org/zap"
)
// supportedSDKAgents lists agent names supported by the go-agent-agnostic SDK.
var supportedSDKAgents = []string{"claude-code", "codex", "opencode"}
// SDKExecutor implements StepExecutorPlugin for agent-sdk steps.
// It uses the go-agent-agnostic library to run coding agents.
type SDKExecutor struct {
templateEngine template.TemplateEngine
}
// NewSDKExecutor creates a new SDK executor.
func NewSDKExecutor(engine template.TemplateEngine) *SDKExecutor {
return &SDKExecutor{
templateEngine: engine,
}
}
// Name returns the executor name for logging/debugging.
func (e *SDKExecutor) Name() string {
return "agent-sdk"
}
// StepTypes returns the step types this executor handles.
func (e *SDKExecutor) StepTypes() []core.StepType {
return []core.StepType{core.StepTypeAgentSDK}
}
// Execute runs an agent-sdk step.
func (e *SDKExecutor) Execute(ctx context.Context, step *core.Step, execCtx *core.ExecutionContext) (*core.StepResult, error) {
result := &core.StepResult{
StepName: step.Name,
Status: core.StepStatusRunning,
StartTime: time.Now(),
Exports: make(map[string]interface{}),
}
prompt := BuildPrompt(step)
if prompt == "" {
err := fmt.Errorf("agent-sdk step has no prompt (messages with content required)")
e.fillResult(result, "", "", err)
return result, err
}
agentName := step.Agent
if agentName == "" {
agentName = core.DefaultSDKAgent
}
opts := e.buildOptions(step, execCtx)
strategy := ""
var agentNames []string
if step.SDKConfig != nil && step.SDKConfig.Strategy != "" && len(step.SDKConfig.Agents) > 0 {
strategy = step.SDKConfig.Strategy
agentNames = step.SDKConfig.Agents
}
log := oslogger.Get()
if strategy != "" {
// Multi-agent mode
log.Debug("running agent-sdk in multi-agent mode",
zap.String("strategy", strategy),
zap.Strings("agents", agentNames))
output, agentUsed, err := e.runMultiAgent(ctx, prompt, strategy, agentNames, opts)
e.fillResult(result, output, agentUsed, err)
return result, err
}
log.Debug("running agent-sdk",
zap.String("agent", agentName),
zap.Int("promptLength", len(prompt)))
agent, err := e.createAgent(agentName, opts)
if err != nil {
e.fillResult(result, "", "", err)
return result, err
}
defer func() { _ = agent.Close() }()
output, err := agent.Run(ctx, prompt, nil)
// Capture session ID if available
sessionID := ""
if sp, ok := agent.(agnostic.SessionProvider); ok {
sessionID = sp.LastSessionID()
}
e.fillResult(result, output, agentName, err)
result.Exports["sdk_session_id"] = sessionID
return result, err
}
// buildOptions constructs agnostic.Options from step configuration.
func (e *SDKExecutor) buildOptions(step *core.Step, execCtx *core.ExecutionContext) *agnostic.Options {
opts := &agnostic.Options{
Cwd: step.Cwd,
}
if opts.Cwd == "" {
opts.Cwd = execCtx.WorkspacePath
}
// Extract system prompt from messages
for _, msg := range step.Messages {
if msg.Role == "system" {
if content, ok := msg.Content.(string); ok {
opts.SystemPrompt = content
break
}
}
}
cfg := step.SDKConfig
if cfg == nil {
return opts
}
opts.Model = cfg.Model
opts.Env = cfg.Env
// Apply agent-specific options
agentName := step.Agent
if agentName == "" {
agentName = core.DefaultSDKAgent
}
switch agentName {
case "claude-code":
cc := &agnostic.ClaudeCodeOptions{}
if cfg.MaxTurns > 0 {
cc.MaxTurns = cfg.MaxTurns
}
if cfg.PermissionMode != "" {
cc.PermissionMode = cfg.PermissionMode
}
if cfg.SessionResume != "" {
cc.Resume = cfg.SessionResume
}
if len(step.AllowedPaths) > 0 {
cc.AdditionalDirs = step.AllowedPaths
}
opts.ClaudeCode = cc
case "codex":
cx := &agnostic.CodexOptions{}
if cfg.Sandbox != "" {
cx.Sandbox = cfg.Sandbox
}
opts.Codex = cx
case "opencode":
opts.OpenCode = &agnostic.OpenCodeOptions{}
}
return opts
}
// createAgent creates an agnostic.Agent for the given agent name.
func (e *SDKExecutor) createAgent(name string, opts *agnostic.Options) (agnostic.Agent, error) {
switch name {
case "claude-code":
return claude.New(opts), nil
case "codex":
return codex.New(opts), nil
case "opencode":
return opencode.New(opts), nil
default:
return nil, fmt.Errorf("unknown agent-sdk agent: %q (available: %s)", name, availableSDKAgentNames())
}
}
// runMultiAgent runs multiple agents using the specified strategy.
func (e *SDKExecutor) runMultiAgent(ctx context.Context, prompt, strategy string, agentNames []string, baseOpts *agnostic.Options) (string, string, error) {
agents := make([]agnostic.Agent, 0, len(agentNames))
for _, name := range agentNames {
a, err := e.createAgent(name, baseOpts)
if err != nil {
// Close already-created agents
for _, created := range agents {
_ = created.Close()
}
return "", "", fmt.Errorf("failed to create agent %q: %w", name, err)
}
agents = append(agents, a)
}
defer func() {
for _, a := range agents {
_ = a.Close()
}
}()
switch strategy {
case "first":
result, err := agnostic.RunFirst(ctx, prompt, agents...)
if err != nil {
return "", "", err
}
return result.Output, string(result.Agent), nil
case "all":
results := agnostic.RunAll(ctx, prompt, agents...)
var outputs []string
var agentsUsed []string
for _, r := range results {
if r.Err == nil {
outputs = append(outputs, r.Output)
agentsUsed = append(agentsUsed, string(r.Agent))
}
}
if len(outputs) == 0 {
return "", "", fmt.Errorf("all %d agents failed", len(results))
}
return strings.Join(outputs, "\n---\n"), strings.Join(agentsUsed, ","), nil
default:
return "", "", fmt.Errorf("unknown multi-agent strategy: %q (use \"first\" or \"all\")", strategy)
}
}
// fillResult populates a StepResult with agent output.
func (e *SDKExecutor) fillResult(result *core.StepResult, output, agentName string, err error) {
result.EndTime = time.Now()
result.Duration = result.EndTime.Sub(result.StartTime)
result.Output = output
result.Exports["sdk_output"] = output
result.Exports["sdk_agent"] = agentName
if err != nil {
result.Status = core.StepStatusFailed
result.Error = err
} else {
result.Status = core.StepStatusSuccess
}
}
// ListSDKAgentNames returns the names of all supported SDK agents.
func ListSDKAgentNames() []string {
return supportedSDKAgents
}
// availableSDKAgentNames returns a comma-separated list of supported SDK agent names.
func availableSDKAgentNames() string {
return strings.Join(ListSDKAgentNames(), ", ")
}
+24
View File
@@ -415,8 +415,32 @@ func InstallBinary(name string, registry BinaryRegistry, binariesFolder string,
return downloadAndExtractBinary(name, url, binariesFolder, customHeaders)
}
// maybePrependSudo prepends "sudo" to package manager commands when not running as root.
// This ensures commands like "apt install coreutils" work on cloud VMs where osmedeus
// runs as an unprivileged user (e.g., ubuntu on AWS).
func maybePrependSudo(command string) string {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return command
}
if os.Geteuid() == 0 {
return command
}
if strings.HasPrefix(command, "sudo ") {
return command
}
pkgManagers := []string{"apt ", "apt-get ", "dnf ", "yum ", "pacman ", "zypper ", "apk "}
for _, prefix := range pkgManagers {
if strings.HasPrefix(command, prefix) {
return "sudo " + command
}
}
return command
}
// executeCommand runs a shell command for installing a binary
func executeCommand(command string) error {
command = maybePrependSudo(command)
var cmd *exec.Cmd
// @NOTE: This is intentional - installation commands come from the binary registry
// configuration which is a trusted source for binary installation procedures.
+34
View File
@@ -1,6 +1,7 @@
package installer
import (
"runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -70,3 +71,36 @@ func TestIsSubPath(t *testing.T) {
})
}
}
func TestMaybePrependSudo(t *testing.T) {
// On darwin/windows, maybePrependSudo is a no-op
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
assert.Equal(t, "apt install coreutils", maybePrependSudo("apt install coreutils"),
"should be no-op on darwin/windows")
return
}
tests := []struct {
name string
input string
expect string
}{
{"apt install", "apt install coreutils", "sudo apt install coreutils"},
{"apt-get install", "apt-get install -y curl", "sudo apt-get install -y curl"},
{"dnf install", "dnf install nmap", "sudo dnf install nmap"},
{"yum install", "yum install git", "sudo yum install git"},
{"pacman install", "pacman -S nmap", "sudo pacman -S nmap"},
{"already has sudo", "sudo apt install coreutils", "sudo apt install coreutils"},
{"go install unchanged", "go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest", "go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest"},
{"pip unchanged", "pip install semgrep", "pip install semgrep"},
{"git clone unchanged", "git clone https://github.com/example/repo", "git clone https://github.com/example/repo"},
{"curl unchanged", "curl -fsSL https://example.com | bash", "curl -fsSL https://example.com | bash"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maybePrependSudo(tt.input)
assert.Equal(t, tt.expect, result)
})
}
}
+7 -5
View File
@@ -233,6 +233,13 @@ func (r *UndefinedVariableRule) Check(wast *WorkflowAST) []LintIssue {
for i, step := range w.Steps {
stepPrefix := fmt.Sprintf("steps[%d]", i)
// Add this step's exports to defined BEFORE checking fields,
// because decision conditions can reference the step's own exports
// (exports are merged into context before decision evaluation at runtime).
for exportName := range step.Exports {
defined[exportName] = true
}
// Check all fields of this step
checkStepFieldsForUndefinedVars(&step, stepPrefix, defined, triggerVars, hasEventTrigger, wast, r, &issues)
@@ -252,11 +259,6 @@ func (r *UndefinedVariableRule) Check(wast *WorkflowAST) []LintIssue {
checkStepFieldsForUndefinedVars(&step.ParallelSteps[j], fmt.Sprintf("%s.parallel_steps[%d]", stepPrefix, j), defined, triggerVars, hasEventTrigger, wast, r, &issues)
}
// After processing this step, add its exports to defined
for exportName := range step.Exports {
defined[exportName] = true
}
// Add foreach variable to defined for subsequent steps
if step.Variable != "" {
defined[step.Variable] = true
+17
View File
@@ -464,6 +464,23 @@ func (p *Parser) validateStep(step *core.Step, index int) error {
}
}
}
case core.StepTypeAgentSDK:
// Validate agent-sdk step has at least one message
if len(step.Messages) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages", index),
Message: "agent-sdk step must have at least one message",
}
}
// Validate messages have content
for i, msg := range step.Messages {
if msg.Content == nil || msg.Content == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages[%d].content", index, i),
Message: "message content must not be empty",
}
}
}
default:
return &ValidationError{
Field: fmt.Sprintf("steps[%d].type", index),
+37
View File
@@ -256,6 +256,43 @@ func (r *SSHRunner) CopyFromRemote(ctx context.Context, remotePath, localPath st
return nil
}
// CopyFromRemoteSFTP copies a file from the SSH host to local using SFTP (no rsync dependency).
func (r *SSHRunner) CopyFromRemoteSFTP(remotePath, localPath string) error {
if r.client == nil {
return fmt.Errorf("SSH client not connected")
}
sftpClient, err := sftp.NewClient(r.client)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %w", err)
}
defer func() { _ = sftpClient.Close() }()
remoteFile, err := sftpClient.Open(remotePath)
if err != nil {
return fmt.Errorf("failed to open remote file %s: %w", remotePath, err)
}
defer func() { _ = remoteFile.Close() }()
if dir := filepath.Dir(localPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create local directory: %w", err)
}
}
localFile, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer func() { _ = localFile.Close() }()
if _, err := io.Copy(localFile, remoteFile); err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
return nil
}
// Type returns the runner type
func (r *SSHRunner) Type() core.RunnerType {
return core.RunnerTypeSSH
+17
View File
@@ -261,6 +261,23 @@ func (p *Printer) WorkflowInfo(name, description string, tags []string, runnerTy
_, _ = fmt.Fprintf(os.Stdout, " %s %s\n\n", Gray("Tip:"), Cyan("osmedeus workflow view "+name))
}
// DisabledTogglesTip prints a tip about disabled toggle params that could generate more results.
// disabledToggles is a map of param name to description for each disabled toggle.
func (p *Printer) DisabledTogglesTip(disabledToggles map[string]string) {
if IsCIMode() || len(disabledToggles) == 0 {
return
}
_, _ = fmt.Fprintf(os.Stdout, " %s %s\n", Yellow(SymbolLightning), Bold("Some scan options are disabled by default:"))
var paramNames []string
for name, desc := range disabledToggles {
_, _ = fmt.Fprintf(os.Stdout, " %s %s — %s\n", Gray("•"), Green(name), Gray(desc))
paramNames = append(paramNames, fmt.Sprintf("-p %s=true", name))
}
_, _ = fmt.Fprintf(os.Stdout, " %s %s\n", Gray("Enable with:"), Cyan(strings.Join(paramNames, " ")))
_, _ = fmt.Fprintf(os.Stdout, " %s\n\n", Gray("These generate more results but slow down the scan significantly."))
}
// Section prints a section header with symbol
func (p *Printer) Section(title string) {
if IsCIMode() {
+2313 -66
View File
File diff suppressed because it is too large Load Diff
+429 -33
View File
@@ -1,8 +1,11 @@
package cli
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
@@ -16,6 +19,35 @@ import (
"github.com/spf13/cobra"
)
// normalizeConfigSetArgs handles "key = value" syntax by stripping the "=" token.
// Accepts: ["key", "value"], ["key", "=", "value"], ["key=", "value"], ["key", "=value"].
func normalizeConfigSetArgs(args []string) (string, string, error) {
// Filter out standalone "=" tokens and trim "=" from edges
var cleaned []string
for _, a := range args {
a = strings.TrimSpace(a)
if a == "=" || a == "" {
continue
}
a = strings.TrimPrefix(a, "=")
a = strings.TrimSuffix(a, "=")
if a != "" {
cleaned = append(cleaned, a)
}
}
if len(cleaned) < 1 {
return "", "", fmt.Errorf("requires a key and value, e.g.: config set <key> <value>")
}
// Keys ending in .clear don't require a value (they clear a list)
if len(cleaned) < 2 {
if strings.HasSuffix(cleaned[0], ".clear") {
return cleaned[0], "", nil
}
return "", "", fmt.Errorf("requires a key and value, e.g.: config set <key> <value>")
}
return cleaned[0], strings.Join(cleaned[1:], " "), nil
}
// configCmd - parent command for config management
var configCmd = &cobra.Command{
Use: "config",
@@ -33,10 +65,9 @@ var configCleanCmd = &cobra.Command{
// configSetCmd - set a config value
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Use: "set [<key> <value>]",
Short: "Set a configuration value",
Long: UsageConfigSet(),
Args: cobra.ExactArgs(2),
RunE: runConfigSet,
}
@@ -44,6 +75,8 @@ var (
configViewRedact bool
configViewForce bool
configListShowSecrets bool
configSetFromFile string
configCleanWS bool
)
var configViewCmd = &cobra.Command{
@@ -59,14 +92,16 @@ var configListCmd = &cobra.Command{
Aliases: []string{"ls"},
Short: "List configuration values",
Long: UsageConfigList(),
Args: cobra.NoArgs,
Args: cobra.MaximumNArgs(1),
RunE: runConfigList,
}
func init() {
configCmd.AddCommand(configCleanCmd)
configCleanCmd.Flags().BoolVar(&configCleanWS, "clean-ws", false, "also remove workspace data directory (e.g. ~/workspaces-osmedeus)")
configCmd.AddCommand(configSetCmd)
configSetCmd.Flags().SetInterspersed(false) // Allow negative numbers as positional args
configSetCmd.Flags().StringVar(&configSetFromFile, "from-file", "", "Read key-value pairs from a file (or use - for stdin)")
configCmd.AddCommand(configViewCmd)
configCmd.AddCommand(configListCmd)
@@ -100,15 +135,50 @@ func runConfigClean(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to write default config: %w", err)
}
// Clean up cloud config and state
cloudSettingsPath := filepath.Join(cfg.BaseFolder, "cloud", "cloud-settings.yaml")
if _, err := os.Stat(cloudSettingsPath); err == nil {
_ = os.Remove(cloudSettingsPath)
printer.Info("Removed cloud config: %s", cloudSettingsPath)
}
cloudStatePath := filepath.Join(cfg.BaseFolder, "cloud-state")
if _, err := os.Stat(cloudStatePath); err == nil {
_ = os.RemoveAll(cloudStatePath)
printer.Info("Removed cloud state: %s", cloudStatePath)
}
printer.Success("Configuration reset to defaults at %s", settingsPath)
// Clean workspace data directory if --clean-ws is set
if configCleanWS {
wsPath := cfg.GetWorkspacesDir()
if wsPath == "" {
printer.Warning("Workspaces path not configured, skipping workspace cleanup")
} else {
printer.Info("Removing workspace data: %s", wsPath)
if err := os.RemoveAll(wsPath); err != nil {
return fmt.Errorf("failed to remove workspaces directory: %w", err)
}
if err := os.MkdirAll(wsPath, 0755); err != nil {
return fmt.Errorf("failed to recreate workspaces directory: %w", err)
}
printer.Success("Workspace data cleaned: %s", wsPath)
}
}
return nil
}
// runConfigSet sets a configuration value using dot notation
func runConfigSet(cmd *cobra.Command, args []string) error {
printer := terminal.NewPrinter()
key := args[0]
value := args[1]
// Determine key-value pairs from args, file, or stdin
pairs, err := resolveConfigSetPairs(args, configSetFromFile)
if err != nil {
return err
}
cfg := config.Get()
if cfg == nil {
@@ -128,30 +198,41 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
currentAuthUsername = "osmedeus"
}
// Set the value using dot notation
if err := setConfigValue(freshCfg, key, value); err != nil {
return fmt.Errorf("failed to set %s: %w", key, err)
}
var setErrors []string
for _, pair := range pairs {
key, value := pair[0], pair[1]
effectiveKey := key
var writeErr error
switch key {
case "server.password":
effectiveKey = fmt.Sprintf("server.simple_user_map_key.%s", currentAuthUsername)
writeErr = updateSettingsYAMLScalarValue(settingsPath, effectiveKey, value)
case "server.username":
writeErr = updateSettingsYAMLMappingKey(settingsPath, []string{"server", "simple_user_map_key"}, currentAuthUsername, value)
default:
writeErr = updateSettingsYAMLScalarValue(settingsPath, effectiveKey, value)
}
if writeErr != nil {
if err := writeSettingsYAMLFromConfig(settingsPath, freshCfg); err != nil {
return fmt.Errorf("failed to write config: %w", writeErr)
// Set the value using dot notation
if err := setConfigValue(freshCfg, key, value); err != nil {
setErrors = append(setErrors, fmt.Sprintf("failed to set %s: %v", key, err))
continue
}
effectiveKey := key
var writeErr error
switch key {
case "server.password":
effectiveKey = fmt.Sprintf("server.simple_user_map_key.%s", currentAuthUsername)
writeErr = updateSettingsYAMLScalarValue(settingsPath, effectiveKey, value)
case "server.username":
writeErr = updateSettingsYAMLMappingKey(settingsPath, []string{"server", "simple_user_map_key"}, currentAuthUsername, value)
default:
writeErr = updateSettingsYAMLScalarValue(settingsPath, effectiveKey, value)
}
if writeErr != nil {
if err := writeSettingsYAMLFromConfig(settingsPath, freshCfg); err != nil {
setErrors = append(setErrors, fmt.Sprintf("failed to write %s: %v", key, writeErr))
continue
}
}
printer.Success("Set %s = %s", terminal.Cyan(key), terminal.Green(redactValueForDisplay(key, value, false)))
}
printer.Success("Set %s = %s", key, redactValueForDisplay(key, value, false))
if len(setErrors) > 0 {
return fmt.Errorf("errors setting config:\n %s", strings.Join(setErrors, "\n "))
}
return nil
}
@@ -179,12 +260,25 @@ func runConfigView(cmd *cobra.Command, args []string) error {
if key == "server.username" {
username, _ := primaryServerAuthUser(fileCfg)
if globalJSON {
result := map[string]interface{}{"key": key, "value": username}
jsonBytes, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonBytes))
return nil
}
fmt.Println(username)
return nil
}
if key == "server.password" {
_, password := primaryServerAuthUser(fileCfg)
fmt.Println(redactValueForDisplay(key, password, !configViewRedact))
val := redactValueForDisplay(key, password, !configViewRedact)
if globalJSON {
result := map[string]interface{}{"key": key, "value": val}
jsonBytes, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonBytes))
return nil
}
fmt.Println(val)
return nil
}
@@ -206,6 +300,47 @@ func runConfigView(cmd *cobra.Command, args []string) error {
return err
}
if globalJSON {
var value interface{}
if strNode, ok := targetNode.(*ast.StringNode); ok {
val := strNode.Value
if configViewRedact {
val = redactValueForDisplay(key, val, false)
}
value = val
} else if intNode, ok := targetNode.(*ast.IntegerNode); ok {
if v, err := strconv.ParseInt(intNode.String(), 10, 64); err == nil {
value = v
} else {
value = intNode.String()
}
} else if floatNode, ok := targetNode.(*ast.FloatNode); ok {
if v, err := strconv.ParseFloat(floatNode.String(), 64); err == nil {
value = v
} else {
value = floatNode.String()
}
} else if boolNode, ok := targetNode.(*ast.BoolNode); ok {
value = boolNode.Value
} else {
output := targetNode.String()
if configViewRedact {
output = redactSensitiveFieldsYAML(output)
}
value = output
}
result := map[string]interface{}{
"key": key,
"value": value,
}
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config value: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
// Check if it's a scalar node
if strNode, ok := targetNode.(*ast.StringNode); ok {
fmt.Println(redactValueForDisplay(key, strNode.Value, !configViewRedact))
@@ -239,6 +374,12 @@ func runConfigList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("configuration not loaded")
}
// Optional fuzzy filter from first argument
var filter string
if len(args) > 0 {
filter = strings.ToLower(args[0])
}
settingsPath := filepath.Join(cfg.BaseFolder, "osm-settings.yaml")
fileCfg, err := config.LoadFromFile(settingsPath)
if err != nil {
@@ -275,12 +416,41 @@ func runConfigList(cmd *cobra.Command, args []string) error {
}
sortStrings(keys)
if globalJSON {
result := make(map[string]string)
for _, k := range keys {
if filter != "" && !strings.Contains(strings.ToLower(k), filter) {
continue
}
v := out[k]
if !configListShowSecrets {
v = redactValueForDisplay(k, v, false)
}
result[k] = v
}
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
matched := 0
for _, k := range keys {
if filter != "" && !strings.Contains(strings.ToLower(k), filter) {
continue
}
v := out[k]
if !configListShowSecrets {
v = redactValueForDisplay(k, v, false)
}
fmt.Printf("%s = %s\n", getCategoryColor(k)(k), v)
matched++
}
if filter != "" && matched == 0 {
printer.Warning("No config keys matching %q", filter)
}
return nil
}
@@ -641,12 +811,32 @@ func setConfigValue(cfg *config.Config, key, value string) error {
return setStorageValue(cfg, parts[1:], value)
case "llm_config":
return setLLMValue(cfg, parts[1:], value)
case "cloud":
return setCloudMainValue(cfg, parts[1:], value)
default:
return fmt.Errorf("unknown config section: %s", parts[0])
}
return nil
}
// setCloudMainValue sets a cloud config field in the main osm-settings
func setCloudMainValue(cfg *config.Config, parts []string, value string) error {
if len(parts) == 0 {
return fmt.Errorf("missing cloud field. Use: cloud.enabled, cloud.cloud_path, or cloud.cloud_settings")
}
switch parts[0] {
case "enabled":
cfg.Cloud.Enabled = (value == "true")
case "cloud_path":
cfg.Cloud.CloudPath = value
case "cloud_settings":
cfg.Cloud.CloudSettings = value
default:
return fmt.Errorf("unknown cloud field: %s. For provider/limits config use: osmedeus cloud config set <key> <value>", parts[0])
}
return nil
}
// setServerValue sets a server config field
func setServerValue(cfg *config.Config, parts []string, value string) error {
if len(parts) == 0 {
@@ -824,14 +1014,32 @@ func getCategoryColor(key string) func(string) string {
return terminal.Teal
case strings.HasPrefix(key, "llm_config."):
return terminal.HiBlue
case strings.HasPrefix(key, "cloud."),
strings.HasPrefix(key, "providers."),
strings.HasPrefix(key, "defaults."),
strings.HasPrefix(key, "limits."),
strings.HasPrefix(key, "state."),
strings.HasPrefix(key, "ssh."),
strings.HasPrefix(key, "setup."):
case strings.HasPrefix(key, "cloud."):
return terminal.HiCyan
case strings.HasPrefix(key, "providers.aws."):
return terminal.Green
case strings.HasPrefix(key, "providers.azure."):
return terminal.Blue
case strings.HasPrefix(key, "providers.digitalocean."):
return terminal.HiCyan
case strings.HasPrefix(key, "providers.gcp."):
return terminal.Magenta
case strings.HasPrefix(key, "providers.hetzner."):
return terminal.Red
case strings.HasPrefix(key, "providers.linode."):
return terminal.Teal
case strings.HasPrefix(key, "providers."):
return terminal.Cyan
case strings.HasPrefix(key, "defaults."):
return terminal.Cyan
case strings.HasPrefix(key, "limits."):
return terminal.Yellow
case strings.HasPrefix(key, "setup."):
return terminal.HiMagenta
case strings.HasPrefix(key, "ssh."):
return terminal.HiBlue
case strings.HasPrefix(key, "state."):
return terminal.Gray
default:
return terminal.White
}
@@ -1379,3 +1587,191 @@ func globToRegex(pattern string) (*regexp.Regexp, error) {
sb.WriteString("$")
return regexp.Compile(sb.String())
}
// resolveConfigSetPairs returns key-value pairs from either positional args, a file, or stdin.
// When fromFile is "-", reads from stdin. When non-empty, reads from the given path.
// Otherwise falls back to positional args.
func resolveConfigSetPairs(args []string, fromFile string) ([][2]string, error) {
if fromFile == "-" {
// Read from stdin
data, err := readStdinData()
if err != nil {
return nil, fmt.Errorf("failed to read stdin: %w", err)
}
return parseConfigSetLines(string(data))
}
if fromFile != "" {
data, err := os.ReadFile(fromFile)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", fromFile, err)
}
return parseConfigSetLines(string(data))
}
// No file/stdin — check if stdin has piped data and no positional args
if len(args) == 0 {
if hasStdinPipe() {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("failed to read stdin: %w", err)
}
return parseConfigSetLines(string(data))
}
return nil, fmt.Errorf("requires a key and value, e.g.: config set <key> <value>\n or use --from-file <path> / pipe via stdin")
}
// Positional args — single key-value pair
key, value, err := normalizeConfigSetArgs(args)
if err != nil {
return nil, err
}
return [][2]string{{key, value}}, nil
}
// parseConfigSetLines parses multi-line input into key-value pairs.
// Supported formats per line:
//
// key value
// key = value
// key="value"
// osmedeus config set key value
// osmedeus cloud config set key value
func parseConfigSetLines(input string) ([][2]string, error) {
var pairs [][2]string
scanner := bufio.NewScanner(strings.NewReader(input))
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Strip leading "osmedeus cloud config set" or "osmedeus config set" prefix
line = stripConfigSetPrefix(line)
// Parse key-value from the remaining content
key, value, err := parseKeyValueLine(line)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNum, err)
}
pairs = append(pairs, [2]string{key, value})
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(pairs) == 0 {
return nil, fmt.Errorf("no key-value pairs found in input")
}
return pairs, nil
}
// stripConfigSetPrefix removes known CLI prefixes from a config set line.
func stripConfigSetPrefix(line string) string {
// Try to strip "osmedeus cloud config set " or "osmedeus config set "
prefixes := []string{
"osmedeus cloud config set ",
"osmedeus config set ",
}
lower := strings.ToLower(line)
for _, prefix := range prefixes {
if strings.HasPrefix(lower, prefix) {
return strings.TrimSpace(line[len(prefix):])
}
}
return line
}
// parseKeyValueLine parses a single line into a key and value.
// Supports: "key value", "key = value", "key=value", with optional quotes around value.
func parseKeyValueLine(line string) (string, string, error) {
// Try key=value (no spaces around =)
if idx := strings.Index(line, "="); idx > 0 {
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
if !strings.Contains(key, " ") && key != "" {
return key, unquoteValue(value), nil
}
}
// Split by whitespace, treating quoted strings as single tokens
parts := splitRespectingQuotes(line)
if len(parts) < 2 {
return "", "", fmt.Errorf("cannot parse key-value from: %s", line)
}
key := parts[0]
// Skip "=" token if present (e.g., "key = value")
rest := parts[1:]
if len(rest) > 1 && rest[0] == "=" {
rest = rest[1:]
}
if len(rest) == 0 {
return "", "", fmt.Errorf("missing value for key: %s", key)
}
value := strings.Join(rest, " ")
return key, unquoteValue(value), nil
}
// splitRespectingQuotes splits a string by whitespace but keeps quoted strings together.
func splitRespectingQuotes(s string) []string {
var parts []string
var current strings.Builder
inQuote := rune(0)
for _, r := range s {
switch {
case inQuote != 0:
if r == inQuote {
inQuote = 0
} else {
current.WriteRune(r)
}
case r == '"' || r == '\'':
inQuote = r
case r == ' ' || r == '\t':
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// unquoteValue strips surrounding quotes from a value string.
func unquoteValue(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
// hasStdinPipe returns true if stdin is a pipe (not a terminal).
func hasStdinPipe() bool {
stat, err := os.Stdin.Stat()
if err != nil {
return false
}
return (stat.Mode() & os.ModeCharDevice) == 0
}
// readStdinData reads all data from stdin.
func readStdinData() ([]byte, error) {
return io.ReadAll(os.Stdin)
}
+91
View File
@@ -0,0 +1,91 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeConfigSetArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantKey string
wantValue string
wantErr bool
}{
{
name: "clear key - no value arg",
args: []string{"setup.commands.clear"},
wantKey: "setup.commands.clear",
},
{
name: "clear key - empty string value",
args: []string{"setup.commands.clear", ""},
wantKey: "setup.commands.clear",
},
{
name: "clear key - equals and empty",
args: []string{"setup.commands.clear", "=", ""},
wantKey: "setup.commands.clear",
},
{
name: "clear key - post_commands variant",
args: []string{"setup.post_commands.clear"},
wantKey: "setup.post_commands.clear",
},
{
name: "normal key-value pair",
args: []string{"defaults.provider", "digitalocean"},
wantKey: "defaults.provider",
wantValue: "digitalocean",
},
{
name: "key-value with equals separator",
args: []string{"defaults.provider", "=", "digitalocean"},
wantKey: "defaults.provider",
wantValue: "digitalocean",
},
{
name: "key with trailing equals",
args: []string{"defaults.provider=", "digitalocean"},
wantKey: "defaults.provider",
wantValue: "digitalocean",
},
{
name: "value with leading equals",
args: []string{"defaults.provider", "=digitalocean"},
wantKey: "defaults.provider",
wantValue: "digitalocean",
},
{
name: "empty args",
args: []string{},
wantErr: true,
},
{
name: "single non-clear key",
args: []string{"defaults.provider"},
wantErr: true,
},
{
name: "only equals and empty strings",
args: []string{"=", "", "="},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, value, err := normalizeConfigSetArgs(tt.args)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantKey, key)
assert.Equal(t, tt.wantValue, value)
})
}
}
+47
View File
@@ -3,6 +3,7 @@ package cli
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
@@ -421,6 +422,10 @@ func runFunctionList(cmd *cobra.Command, args []string) error {
}
if len(rows) == 0 {
if globalJSON {
fmt.Println("[]")
return nil
}
if funcSearchFilter != "" {
printer.Info("No functions matching '%s'", funcSearchFilter)
} else {
@@ -429,6 +434,48 @@ func runFunctionList(cmd *cobra.Command, args []string) error {
return nil
}
if globalJSON {
var jsonFuncs []map[string]string
// Re-iterate to build clean JSON (rows contain color codes)
for _, cat := range categories {
if funcs, ok := registry[cat.Key]; ok {
for _, fn := range funcs {
if funcSearchFilter != "" {
nameLower := strings.ToLower(fn.Name)
descLower := strings.ToLower(fn.Description)
catLower := strings.ToLower(cat.Title)
if !strings.Contains(nameLower, searchLower) &&
!strings.Contains(descLower, searchLower) &&
!strings.Contains(catLower, searchLower) {
continue
}
}
entry := map[string]string{
"category": cat.ShortTitle,
"name": fn.Name,
"signature": fn.Signature,
"description": fn.Description,
"return_type": fn.ReturnType,
}
if fn.Example != "" {
entry["example"] = fn.Example
}
jsonFuncs = append(jsonFuncs, entry)
}
}
}
if jsonFuncs == nil {
fmt.Println("[]")
return nil
}
jsonBytes, err := json.MarshalIndent(jsonFuncs, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal functions: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
if funcSearchFilter != "" {
printer.Info("Found %d function(s) matching '%s':", len(rows), funcSearchFilter)
fmt.Println()
+3 -1
View File
@@ -298,7 +298,9 @@ func init() {
// Set custom help function to show banner before help
defaultHelpFunc := rootCmd.HelpFunc()
rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
fmt.Print(terminal.Banner())
if cmd == rootCmd {
fmt.Print(terminal.Banner())
}
defaultHelpFunc(cmd, args)
})
+17 -13
View File
@@ -326,20 +326,20 @@ func applyWorkflowPreferences(prefs *core.Preferences, printer *terminal.Printer
func handleTargetTypeMismatchError(err error) bool {
var ttmErr *executor.TargetTypeMismatchError
if errors.As(err, &ttmErr) {
fmt.Println()
fmt.Printf("%s %s\n", terminal.Red("✘"), terminal.BoldRed("Target type mismatch"))
fmt.Printf(" Supplied: %s\n", ttmErr.Supplied)
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "%s %s\n", terminal.Red("✘"), terminal.BoldRed("Target type mismatch"))
fmt.Fprintf(os.Stderr, " Supplied: %s\n", ttmErr.Supplied)
if ttmErr.DetectedType != "" {
fmt.Printf(" Detected type: %s\n", ttmErr.DetectedType)
fmt.Fprintf(os.Stderr, " Detected type: %s\n", ttmErr.DetectedType)
}
fmt.Printf(" %s %s\n", terminal.HiBlue("Required Params:"), ttmErr.ExpectedType)
fmt.Println()
fmt.Fprintf(os.Stderr, " %s dependency required types: %s\n", terminal.HiBlue("✘"), ttmErr.ExpectedType)
fmt.Fprintln(os.Stderr)
info := terminal.Cyan(terminal.SymbolInfo)
fmt.Printf(" %s %s\n", info, terminal.HiBlue("\"Target\" in Required Params is supplied via -t flag (e.g., -t example.com) or each line from -T list-of-targets.txt"))
fmt.Printf(" %s %s\n", info, terminal.Yellow("Hint: Use --skip-validation to bypass this check"))
fmt.Printf(" %s %s\n", info, terminal.Yellow("Hint: Use --convert-to-file to write targets into a temp file and use the file path as the target"))
fmt.Printf(" %s %s\n", info, terminal.Yellow("Hint: Use --convert-file-to-line to read a file target and expand each line as a separate target"))
fmt.Println()
fmt.Fprintf(os.Stderr, " %s %s\n", info, terminal.HiBlue("\"Target\" in Required Params is supplied via -t flag (e.g., -t example.com) or each line from -T list-of-targets.txt"))
fmt.Fprintf(os.Stderr, " %s %s\n", info, terminal.Yellow("Hint: Use --skip-validation to bypass this check"))
fmt.Fprintf(os.Stderr, " %s %s\n", info, terminal.Yellow("Hint: Use --convert-to-file to write targets into a temp file and use the file path as the target"))
fmt.Fprintf(os.Stderr, " %s %s\n", info, terminal.Yellow("Hint: Use --convert-file-to-line to read a file target and expand each line as a separate target"))
fmt.Fprintln(os.Stderr)
return true
}
return false
@@ -392,9 +392,13 @@ func runRun(cmd *cobra.Command, args []string) error {
// Validate --run-priority flag
if runPriority != "" {
validPriorities := map[string]bool{"low": true, "normal": true, "high": true, "critical": true}
validPriorities := map[string]bool{"low": true, "normal": true, "medium": true, "high": true, "critical": true}
if !validPriorities[runPriority] {
return fmt.Errorf("invalid --run-priority value '%s'. Must be one of: low, normal, high, critical", runPriority)
return fmt.Errorf("invalid --run-priority value '%s'. Must be one of: low, normal, medium, high, critical", runPriority)
}
// Normalize "medium" to "normal"
if runPriority == "medium" {
runPriority = "normal"
}
if serverURL == "" {
return fmt.Errorf("--run-priority requires --server-url to be specified")
+14
View File
@@ -2,6 +2,7 @@ package cli
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -189,10 +190,23 @@ func runSnapshotList(cmd *cobra.Command, args []string) error {
}
if len(snapshots) == 0 {
if globalJSON {
fmt.Println("[]")
return nil
}
printer.Info("No snapshots found in: %s", cfg.SnapshotPath)
return nil
}
if globalJSON {
jsonBytes, err := json.MarshalIndent(snapshots, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal snapshots: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
printer.Section("Available Snapshots")
fmt.Println()
+16 -1
View File
@@ -111,7 +111,7 @@ func UsageRun() string {
` + terminal.Green("# Split into 4 equal chunks and run chunk 0") + `
osmedeus run ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-T") + ` targets.txt ` + terminal.Yellow("--chunk-count") + ` 4 ` + terminal.Yellow("--chunk-part") + ` 0
` + terminal.Green("# Distributed processing across machines") + `
` + terminal.Green("# Manual target splitting across machines") + `
osmedeus run ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-T") + ` targets.txt ` + terminal.Yellow("--chunk-size") + ` 250 ` + terminal.Yellow("--chunk-part") + ` 0 ` + terminal.Gray("# Machine 1") + `
osmedeus run ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-T") + ` targets.txt ` + terminal.Yellow("--chunk-size") + ` 250 ` + terminal.Yellow("--chunk-part") + ` 1 ` + terminal.Gray("# Machine 2") + `
@@ -554,6 +554,8 @@ func UsageConfigSet() string {
` + terminal.BoldCyan("▷ Syntax") + `
osmedeus config set <key> <value>
osmedeus config set ` + terminal.Yellow("--from-file") + ` <path>
cat config.txt | osmedeus config set ` + terminal.Yellow("--from-file") + ` -
` + terminal.BoldCyan("▷ Examples") + `
` + terminal.Green("osmedeus config set server.port 9000") + `
@@ -564,6 +566,19 @@ func UsageConfigSet() string {
` + terminal.Green("osmedeus config set global_vars.github_token ghp_xxx") + `
` + terminal.Green("osmedeus config set notification.enabled true") + `
` + terminal.Green("# Batch set from a file") + `
osmedeus config set ` + terminal.Yellow("--from-file") + ` my-settings.txt
` + terminal.Green("# Pipe from stdin") + `
cat my-settings.txt | osmedeus config set ` + terminal.Yellow("--from-file") + ` -
` + terminal.BoldCyan("▷ File Format") + `
Lines can use any of these formats:
server.port 9000
server.port = 9000
osmedeus config set server.port 9000
Lines starting with # are ignored.
` + terminal.BoldCyan("▷ Available Keys") + `
` + terminal.Yellow("base_folder") + ` Base directory path
` + terminal.Yellow("server.host") + ` Server bind host
+134
View File
@@ -1,6 +1,7 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -208,6 +209,28 @@ var workflowListCmd = &cobra.Command{
return nil
}
if globalJSON {
var jsonRows []map[string]interface{}
for _, r := range rows {
jsonRows = append(jsonRows, map[string]interface{}{
"name": r.name,
"type": r.wfType,
"description": r.desc,
"required_params": r.reqParams,
"steps": r.steps,
"tags": r.tags,
"target_types": r.targetTypes,
"usage": r.usage,
})
}
jsonBytes, err := json.MarshalIndent(jsonRows, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal workflows: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
// Calculate max widths
nameWidth := len("Name")
typeWidth := len("Type")
@@ -948,6 +971,117 @@ var workflowShowCmd = &cobra.Command{
return nil
}
if globalJSON {
result := map[string]interface{}{
"name": workflow.Name,
"kind": string(workflow.Kind),
"description": workflow.Description,
"file_path": workflow.FilePath,
"tags": workflow.Tags,
}
if workflow.Hidden {
result["hidden"] = true
}
if workflow.Help != nil {
help := map[string]interface{}{}
if workflow.Help.Usage != "" {
help["usage"] = workflow.Help.Usage
}
if len(workflow.Help.ExampleTargets) > 0 {
help["example_targets"] = workflow.Help.ExampleTargets
}
if len(help) > 0 {
result["help"] = help
}
}
if len(workflow.Params) > 0 {
var params []map[string]interface{}
for _, p := range workflow.Params {
pm := map[string]interface{}{
"name": p.Name,
"required": p.Required,
}
if p.DefaultString() != "" {
pm["default"] = p.DefaultString()
}
params = append(params, pm)
}
result["params"] = params
}
if workflow.Dependencies != nil {
deps := map[string]interface{}{}
if len(workflow.Dependencies.Variables) > 0 {
var vars []map[string]interface{}
for _, v := range workflow.Dependencies.Variables {
vm := map[string]interface{}{
"name": v.Name,
"required": v.Required,
}
if v.Type != "" {
vm["type"] = string(v.Type)
}
vars = append(vars, vm)
}
deps["variables"] = vars
}
if len(workflow.Dependencies.TargetTypes) > 0 {
var types []string
for _, t := range workflow.Dependencies.TargetTypes {
types = append(types, string(t))
}
deps["target_types"] = types
}
if len(deps) > 0 {
result["dependencies"] = deps
}
}
if workflow.IsModule() && len(workflow.Steps) > 0 {
var steps []map[string]interface{}
for _, s := range workflow.Steps {
steps = append(steps, map[string]interface{}{
"name": s.Name,
"type": string(s.Type),
})
}
result["steps"] = steps
}
if workflow.IsFlow() && len(workflow.Modules) > 0 {
var modules []map[string]interface{}
for _, m := range workflow.Modules {
mm := map[string]interface{}{
"name": m.Name,
"path": m.Path,
}
if len(m.DependsOn) > 0 {
mm["depends_on"] = m.DependsOn
}
modules = append(modules, mm)
}
result["modules"] = modules
}
if len(workflow.Triggers) > 0 {
var triggers []map[string]interface{}
for _, t := range workflow.Triggers {
triggers = append(triggers, map[string]interface{}{
"name": t.Name,
"type": string(t.On),
"enabled": t.Enabled,
})
}
result["triggers"] = triggers
}
if workflow.Runner != "" {
result["runner"] = string(workflow.Runner)
}
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal workflow: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
// Default: Table format output
// Metadata section
fmt.Println()
+571
View File
@@ -0,0 +1,571 @@
package handlers
import (
"context"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/j3ssie/osmedeus/v5/internal/cloud"
"github.com/j3ssie/osmedeus/v5/internal/config"
"github.com/j3ssie/osmedeus/v5/internal/logger"
"go.uber.org/zap"
)
// loadCloudConfigFromCfg loads and validates cloud configuration
func loadCloudConfigFromCfg(cfg *config.Config) (*config.CloudConfigs, error) {
if !cfg.Cloud.Enabled {
return nil, fmt.Errorf("cloud features are not enabled in configuration")
}
if cfg.Cloud.CloudSettings == "" {
return nil, fmt.Errorf("cloud settings path is not configured")
}
cloudCfg, err := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
if err != nil {
return nil, err
}
cloud.ResolveTemplatePaths(cloudCfg, cfg.BaseFolder)
return cloudCfg, nil
}
// CreateCloudInstancesRequest represents a request to provision cloud infrastructure
type CreateCloudInstancesRequest struct {
Provider string `json:"provider"` // required: aws, gcp, digitalocean, linode, azure, hetzner
InstanceCount int `json:"instance_count"` // required: number of instances (>= 1)
InstanceType string `json:"instance_type,omitempty"` // override default instance size
Region string `json:"region,omitempty"` // override default region
UseSpot bool `json:"use_spot,omitempty"` // use spot/preemptible instances
Tags map[string]string `json:"tags,omitempty"` // resource tags
}
// EstimateCloudCostRequest represents a cost estimation request
type EstimateCloudCostRequest struct {
Provider string `json:"provider"` // required
InstanceCount int `json:"instance_count"` // required
InstanceType string `json:"instance_type,omitempty"` // override
UseSpot bool `json:"use_spot,omitempty"`
}
// ListCloudProviders returns configured cloud providers with their validation status
// @Summary List cloud providers
// @Description Get a list of all configured cloud providers and whether their credentials are valid
// @Tags Cloud
// @Produce json
// @Success 200 {object} map[string]interface{} "List of providers"
// @Failure 400 {object} map[string]interface{} "Cloud not enabled"
// @Security BearerAuth
// @Router /osm/api/cloud/providers [get]
func ListCloudProviders(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
allProviders := []cloud.ProviderType{
cloud.ProviderAWS, cloud.ProviderGCP, cloud.ProviderDigitalOcean,
cloud.ProviderLinode, cloud.ProviderAzure, cloud.ProviderHetzner,
}
providers := make([]fiber.Map, 0, len(allProviders))
for _, pt := range allProviders {
entry := fiber.Map{
"name": string(pt),
"is_default": string(pt) == cloudCfg.Defaults.Provider,
}
provider, createErr := cloud.CreateProvider(cloudCfg, pt)
if createErr != nil {
entry["configured"] = false
entry["status"] = "not_configured"
entry["message"] = createErr.Error()
} else {
entry["configured"] = true
if valErr := provider.Validate(c.Context()); valErr != nil {
entry["status"] = "invalid"
entry["message"] = valErr.Error()
} else {
entry["status"] = "valid"
}
}
providers = append(providers, entry)
}
return c.JSON(fiber.Map{
"providers": providers,
"default_provider": cloudCfg.Defaults.Provider,
})
}
}
// ValidateCloudProvider tests credentials for a specific cloud provider
// @Summary Validate cloud provider
// @Description Test if the credentials for a specific cloud provider are valid
// @Tags Cloud
// @Produce json
// @Param name path string true "Provider name (aws, gcp, digitalocean, linode, azure, hetzner)"
// @Success 200 {object} map[string]interface{} "Validation result"
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Security BearerAuth
// @Router /osm/api/cloud/providers/{name}/validate [post]
func ValidateCloudProvider(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
providerName := c.Params("name")
validProviders := map[string]bool{
"aws": true, "gcp": true, "digitalocean": true,
"linode": true, "azure": true, "hetzner": true,
}
if !validProviders[providerName] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid provider name. Must be one of: aws, gcp, digitalocean, linode, azure, hetzner",
})
}
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
provider, err := cloud.CreateProvider(cloudCfg, cloud.ProviderType(providerName))
if err != nil {
return c.JSON(fiber.Map{
"provider": providerName,
"valid": false,
"message": err.Error(),
})
}
if err := provider.Validate(c.Context()); err != nil {
return c.JSON(fiber.Map{
"provider": providerName,
"valid": false,
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"provider": providerName,
"valid": true,
"message": "Provider credentials are valid",
})
}
}
// CreateCloudInstances provisions new cloud infrastructure
// @Summary Create cloud instances
// @Description Provision new cloud infrastructure. Returns immediately with infra ID; poll status endpoint for progress.
// @Tags Cloud
// @Accept json
// @Produce json
// @Param request body CreateCloudInstancesRequest true "Infrastructure configuration"
// @Success 202 {object} map[string]interface{} "Provisioning started"
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Security BearerAuth
// @Router /osm/api/cloud/instances [post]
func CreateCloudInstances(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req CreateCloudInstancesRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid request body",
})
}
if req.Provider == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "provider is required",
})
}
if req.InstanceCount < 1 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "instance_count must be at least 1",
})
}
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
provider, err := cloud.CreateProvider(cloudCfg, cloud.ProviderType(req.Provider))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to create provider %q: %v", req.Provider, err),
})
}
if err := provider.Validate(c.Context()); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Provider validation failed: %v", err),
})
}
infraID := uuid.New().String()[:8]
tags := req.Tags
if tags == nil {
tags = make(map[string]string)
}
tags["source"] = "api"
tags["infra_id"] = infraID
createOpts := &cloud.CreateOptions{
Mode: cloud.ModeVM,
InstanceCount: req.InstanceCount,
InstanceType: req.InstanceType,
UseSpot: req.UseSpot,
Tags: tags,
}
// Provision in background
go func() {
ctx := context.Background()
lgr := logger.Get()
lm := cloud.NewLifecycleManager(cloudCfg, provider, nil)
infra, createErr := lm.CreateAndRun(ctx, createOpts)
if createErr != nil {
lgr.Error("Failed to provision cloud infrastructure",
zap.String("infra_id", infraID),
zap.Error(createErr))
return
}
lgr.Info("Cloud infrastructure provisioned",
zap.String("infra_id", infra.ID),
zap.Int("resources", len(infra.Resources)))
}()
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
"message": "Infrastructure provisioning started",
"infra_id": infraID,
"status": "provisioning",
"poll_url": fmt.Sprintf("/osm/api/cloud/instances/%s/status", infraID),
})
}
}
// ListCloudInstances returns all saved infrastructure states
// @Summary List cloud instances
// @Description Get a list of all cloud infrastructure (active and saved)
// @Tags Cloud
// @Produce json
// @Success 200 {object} map[string]interface{} "List of infrastructure"
// @Failure 400 {object} map[string]interface{} "Cloud not enabled"
// @Security BearerAuth
// @Router /osm/api/cloud/instances [get]
func ListCloudInstances(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
infrastructures, err := cloud.ListInfrastructures(cloudCfg.State.Path)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to list infrastructure: %v", err),
})
}
data := make([]fiber.Map, 0, len(infrastructures))
for _, infra := range infrastructures {
resources := make([]fiber.Map, 0, len(infra.Resources))
for _, r := range infra.Resources {
resources = append(resources, fiber.Map{
"type": r.Type,
"id": r.ID,
"name": r.Name,
"public_ip": r.PublicIP,
"private_ip": r.PrivateIP,
"status": r.Status,
"worker_id": r.WorkerID,
})
}
data = append(data, fiber.Map{
"id": infra.ID,
"provider": string(infra.Provider),
"mode": string(infra.Mode),
"created_at": infra.CreatedAt,
"resources": resources,
})
}
return c.JSON(fiber.Map{
"data": data,
"count": len(data),
})
}
}
// GetCloudInstance returns details for a specific infrastructure
// @Summary Get cloud instance details
// @Description Get details for a specific cloud infrastructure by ID
// @Tags Cloud
// @Produce json
// @Param id path string true "Infrastructure ID"
// @Success 200 {object} map[string]interface{} "Infrastructure details"
// @Failure 404 {object} map[string]interface{} "Infrastructure not found"
// @Security BearerAuth
// @Router /osm/api/cloud/instances/{id} [get]
func GetCloudInstance(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
infraID := c.Params("id")
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
infra, err := cloud.LoadInfrastructureState(infraID, cloudCfg.State.Path)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Infrastructure not found: %v", err),
})
}
resources := make([]fiber.Map, 0, len(infra.Resources))
for _, r := range infra.Resources {
resources = append(resources, fiber.Map{
"type": r.Type,
"id": r.ID,
"name": r.Name,
"public_ip": r.PublicIP,
"private_ip": r.PrivateIP,
"status": r.Status,
"worker_id": r.WorkerID,
"metadata": r.Metadata,
})
}
return c.JSON(fiber.Map{
"id": infra.ID,
"provider": string(infra.Provider),
"mode": string(infra.Mode),
"created_at": infra.CreatedAt,
"pulumi_stack": infra.PulumiStackID,
"resources": resources,
"resource_count": len(infra.Resources),
"metadata": infra.Metadata,
})
}
}
// GetCloudInstanceStatus returns live status for a specific infrastructure
// @Summary Get cloud instance status
// @Description Get live status from the cloud provider for a specific infrastructure
// @Tags Cloud
// @Produce json
// @Param id path string true "Infrastructure ID"
// @Success 200 {object} map[string]interface{} "Infrastructure status"
// @Failure 404 {object} map[string]interface{} "Infrastructure not found"
// @Security BearerAuth
// @Router /osm/api/cloud/instances/{id}/status [get]
func GetCloudInstanceStatus(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
infraID := c.Params("id")
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
infra, err := cloud.LoadInfrastructureState(infraID, cloudCfg.State.Path)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Infrastructure not found: %v", err),
})
}
provider, err := cloud.CreateProvider(cloudCfg, infra.Provider)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to create provider: %v", err),
})
}
status, err := provider.GetStatus(c.Context(), infra)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to get status: %v", err),
})
}
details := make([]fiber.Map, 0, len(status.Details))
for _, d := range status.Details {
details = append(details, fiber.Map{
"resource_id": d.ResourceID,
"status": d.Status,
"message": d.Message,
"worker_registered": d.WorkerRegistered,
})
}
return c.JSON(fiber.Map{
"infra_id": infraID,
"status": status.Status,
"ready_count": status.ReadyCount,
"total_count": status.TotalCount,
"workers_registered": status.WorkersRegistered,
"details": details,
})
}
}
// DestroyCloudInstance tears down cloud infrastructure
// @Summary Destroy cloud instance
// @Description Destroy a specific cloud infrastructure and remove its state
// @Tags Cloud
// @Produce json
// @Param id path string true "Infrastructure ID"
// @Success 200 {object} map[string]interface{} "Infrastructure destroyed"
// @Failure 404 {object} map[string]interface{} "Infrastructure not found"
// @Failure 500 {object} map[string]interface{} "Destroy failed"
// @Security BearerAuth
// @Router /osm/api/cloud/instances/{id} [delete]
func DestroyCloudInstance(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
infraID := c.Params("id")
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
infra, err := cloud.LoadInfrastructureState(infraID, cloudCfg.State.Path)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Infrastructure not found: %v", err),
})
}
provider, err := cloud.CreateProvider(cloudCfg, infra.Provider)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to create provider: %v", err),
})
}
lm := cloud.NewLifecycleManager(cloudCfg, provider, nil)
if err := lm.Destroy(c.Context(), infra); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to destroy infrastructure: %v", err),
})
}
return c.JSON(fiber.Map{
"message": "Infrastructure destroyed successfully",
"infra_id": infraID,
})
}
}
// EstimateCloudCost returns a cost estimate for the given configuration
// @Summary Estimate cloud cost
// @Description Get a cost estimate for provisioning cloud infrastructure
// @Tags Cloud
// @Accept json
// @Produce json
// @Param request body EstimateCloudCostRequest true "Cost estimation parameters"
// @Success 200 {object} map[string]interface{} "Cost estimate"
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Security BearerAuth
// @Router /osm/api/cloud/estimate [post]
func EstimateCloudCost(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req EstimateCloudCostRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid request body",
})
}
if req.Provider == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "provider is required",
})
}
if req.InstanceCount < 1 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "instance_count must be at least 1",
})
}
cloudCfg, err := loadCloudConfigFromCfg(cfg)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": err.Error(),
})
}
provider, err := cloud.CreateProvider(cloudCfg, cloud.ProviderType(req.Provider))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to create provider %q: %v", req.Provider, err),
})
}
estimate, err := provider.EstimateCost(cloud.ModeVM, req.InstanceCount)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to estimate cost: %v", err),
})
}
return c.JSON(fiber.Map{
"provider": req.Provider,
"instance_count": req.InstanceCount,
"hourly_cost": estimate.HourlyCost,
"daily_cost": estimate.DailyCost,
"currency": estimate.Currency,
"breakdown": estimate.Breakdown,
"notes": estimate.Notes,
})
}
}
+13
View File
@@ -38,6 +38,15 @@ type CreateRunRequest struct {
DockerImage string `json:"docker_image,omitempty"` // Docker image to use when runner_type=docker
SSHHost string `json:"ssh_host,omitempty"` // SSH host when runner_type=ssh
// Cloud-specific options (used when run_mode=cloud)
CloudProvider string `json:"cloud_provider,omitempty"` // aws, gcp, digitalocean, linode, azure, hetzner
CloudInstances int `json:"cloud_instances,omitempty"` // Number of cloud instances to provision
CloudInstanceType string `json:"cloud_instance_type,omitempty"` // Instance size override
CloudRegion string `json:"cloud_region,omitempty"` // Region override
CloudAutoDestroy bool `json:"cloud_auto_destroy,omitempty"` // Destroy infrastructure when scan completes
CloudReuseInfra string `json:"cloud_reuse_infra,omitempty"` // Existing infrastructure ID to reuse
CloudUseSpot bool `json:"cloud_use_spot,omitempty"` // Use spot/preemptible instances
// Scheduling options
Schedule string `json:"schedule,omitempty"` // Cron expression for scheduled scans
ScheduleEnabled bool `json:"schedule_enabled,omitempty"` // Enable scheduled execution
@@ -171,6 +180,10 @@ func validateCreateRunInput(req *CreateRunRequest) error {
{"ssh_host", req.SSHHost},
{"docker_image", req.DockerImage},
{"repeat_wait_time", req.RepeatWaitTime},
{"cloud_provider", req.CloudProvider},
{"cloud_instance_type", req.CloudInstanceType},
{"cloud_region", req.CloudRegion},
{"cloud_reuse_infra", req.CloudReuseInfra},
}
for _, f := range fields {
+357 -45
View File
@@ -10,12 +10,15 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/j3ssie/osmedeus/v5/internal/cloud"
"github.com/j3ssie/osmedeus/v5/internal/config"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/database"
"github.com/j3ssie/osmedeus/v5/internal/distributed"
"github.com/j3ssie/osmedeus/v5/internal/executor"
"github.com/j3ssie/osmedeus/v5/internal/logger"
"github.com/j3ssie/osmedeus/v5/internal/parser"
"github.com/j3ssie/osmedeus/v5/internal/runner"
"go.uber.org/zap"
)
@@ -260,7 +263,7 @@ func executeRunsConcurrently(
// @Failure 404 {object} map[string]interface{} "Workflow not found"
// @Security BearerAuth
// @Router /osm/api/runs [post]
func CreateRun(cfg *config.Config) fiber.Handler {
func CreateRun(cfg *config.Config, master *distributed.Master) fiber.Handler {
return func(c *fiber.Ctx) error {
var req CreateRunRequest
if err := c.BodyParser(&req); err != nil {
@@ -349,13 +352,17 @@ func CreateRun(cfg *config.Config) fiber.Handler {
priority = "normal"
}
// Validate priority
validPriorities := map[string]bool{"low": true, "normal": true, "high": true, "critical": true}
validPriorities := map[string]bool{"low": true, "normal": true, "medium": true, "high": true, "critical": true}
if !validPriorities[priority] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid priority. Must be one of: low, normal, high, critical",
"message": "Invalid priority. Must be one of: low, normal, medium, high, critical",
})
}
// Normalize "medium" to "normal"
if priority == "medium" {
priority = "normal"
}
// Set default run_mode if not specified
runMode := req.RunMode
@@ -371,6 +378,45 @@ func CreateRun(cfg *config.Config) fiber.Handler {
})
}
// Validate distributed mode requirements
if runMode == "distributed" && master == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Distributed mode requires the server to be started with --master flag",
})
}
// Validate cloud mode requirements
if runMode == "cloud" {
if !cfg.Cloud.Enabled {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Cloud mode requires cloud features to be enabled in configuration",
})
}
// Validate cloud provider if specified
if req.CloudProvider != "" {
validProviders := map[string]bool{
"aws": true, "gcp": true, "digitalocean": true,
"linode": true, "azure": true, "hetzner": true,
}
if !validProviders[req.CloudProvider] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid cloud_provider. Must be one of: aws, gcp, digitalocean, linode, azure, hetzner",
})
}
}
if req.CloudInstances < 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "cloud_instances must be a positive number",
})
}
}
// Add runner configuration to params if specified
if req.RunnerType != "" {
params["runner_type"] = req.RunnerType
@@ -410,60 +456,311 @@ func CreateRun(cfg *config.Config) fiber.Handler {
// Generate a job ID for grouping runs from this request
jobID := uuid.New().String()[:8]
// Create run record(s) and execute
// Create run record(s) and execute based on run_mode
var runIDs []string
if len(targets) == 1 {
// Single target - existing behavior
params["target"] = targets[0]
var infraID string
// Create run record in database
ctx := context.Background()
run, _ := createRunRecord(ctx, cfgCopy, workflow, loader, targets[0], params, "api", jobID, priority, runMode)
if run != nil {
runIDs = append(runIDs, run.RunUUID)
switch runMode {
case "distributed":
// Submit tasks to distributed worker queue
for _, target := range targets {
targetParams := make(map[string]string)
for k, v := range params {
targetParams[k] = v
}
targetParams["target"] = target
ctx := context.Background()
run, _ := createRunRecord(ctx, cfgCopy, workflow, loader, target, targetParams, "api", jobID, priority, runMode)
if run != nil {
runIDs = append(runIDs, run.RunUUID)
}
kind := "module"
if isFlow {
kind = "flow"
}
// Convert params to map[string]interface{} for distributed task
taskParams := make(map[string]interface{})
for k, v := range targetParams {
taskParams[k] = v
}
task := &distributed.Task{
WorkflowName: workflow.Name,
WorkflowKind: kind,
Target: target,
Params: taskParams,
}
if err := master.SubmitTask(ctx, task); err != nil {
lgr := logger.Get()
lgr.Warn("Failed to submit distributed task", zap.String("target", target), zap.Error(err))
if run != nil {
_ = database.UpdateRunStatus(ctx, run.RunUUID, "failed", fmt.Sprintf("failed to submit to distributed queue: %v", err))
}
continue
}
// Log the link between run and distributed task
if run != nil && task.ID != "" {
lgr := logger.Get()
lgr.Info("Linked run to distributed task", zap.String("run_uuid", run.RunUUID), zap.String("task_id", task.ID))
}
}
exec := executor.NewExecutor()
exec.SetServerMode(true) // Enable file logging for server mode
exec.SetLoader(loader)
if req.EmptyTarget {
exec.SetSkipWorkspace(true)
case "cloud":
// Provision cloud infrastructure and execute
cloudCfg, err := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to load cloud configuration: %v", err),
})
}
cloud.ResolveTemplatePaths(cloudCfg, cfg.BaseFolder)
// Set up database progress tracking
if run != nil {
exec.SetDBRunUUID(run.RunUUID)
exec.SetDBRunID(run.ID)
exec.SetOnStepCompleted(func(stepCtx context.Context, dbRunUUID string) {
_ = database.IncrementRunCompletedSteps(stepCtx, dbRunUUID)
// Determine provider
providerName := req.CloudProvider
if providerName == "" {
providerName = cloudCfg.Defaults.Provider
}
if providerName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "No cloud provider specified and no default provider configured",
})
}
go func(runID string) {
ctx := context.Background()
var execErr error
if isFlow && workflow.IsFlow() {
_, execErr = exec.ExecuteFlow(ctx, workflow, params, cfgCopy)
} else {
_, execErr = exec.ExecuteModule(ctx, workflow, params, cfgCopy)
provider, err := cloud.CreateProvider(cloudCfg, cloud.ProviderType(providerName))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Failed to create cloud provider %q: %v", providerName, err),
})
}
if err := provider.Validate(context.Background()); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": fmt.Sprintf("Cloud provider validation failed: %v", err),
})
}
// Create run records for all targets
for _, target := range targets {
targetParams := make(map[string]string)
for k, v := range params {
targetParams[k] = v
}
// Update run status in database
if runID != "" {
if execErr != nil {
_ = database.UpdateRunStatus(ctx, runID, "failed", execErr.Error())
} else {
_ = database.UpdateRunStatus(ctx, runID, "completed", "")
targetParams["target"] = target
ctx := context.Background()
run, _ := createRunRecord(ctx, cfgCopy, workflow, loader, target, targetParams, "api", jobID, priority, runMode)
if run != nil {
runIDs = append(runIDs, run.RunUUID)
}
}
// Determine instance count
instanceCount := req.CloudInstances
if instanceCount <= 0 {
instanceCount = 1
}
// Build create options
createOpts := &cloud.CreateOptions{
Mode: cloud.ModeVM,
InstanceCount: instanceCount,
InstanceType: req.CloudInstanceType,
UseSpot: req.CloudUseSpot,
Tags: map[string]string{"job_id": jobID, "source": "api"},
Timeout: 10 * time.Minute,
}
if req.CloudRegion != "" {
createOpts.Tags["region_override"] = req.CloudRegion
}
// Execute cloud run in background
go func(runUUIDs []string, autoDestroy bool, reuseInfraID string) {
ctx := context.Background()
lgr := logger.Get()
var infra *cloud.Infrastructure
var provisionErr error
if reuseInfraID != "" {
// Reuse existing infrastructure
infra, provisionErr = cloud.LoadInfrastructureState(reuseInfraID, cloudCfg.State.Path)
if provisionErr != nil {
lgr.Error("Failed to load existing infrastructure", zap.String("infra_id", reuseInfraID), zap.Error(provisionErr))
for _, id := range runUUIDs {
_ = database.UpdateRunStatus(ctx, id, "failed", fmt.Sprintf("failed to load infrastructure %s: %v", reuseInfraID, provisionErr))
}
return
}
} else {
// Provision new infrastructure
lm := cloud.NewLifecycleManager(cloudCfg, provider, nil)
infra, provisionErr = lm.CreateAndRun(ctx, createOpts)
if provisionErr != nil {
lgr.Error("Failed to provision cloud infrastructure", zap.Error(provisionErr))
for _, id := range runUUIDs {
_ = database.UpdateRunStatus(ctx, id, "failed", fmt.Sprintf("cloud provisioning failed: %v", provisionErr))
}
return
}
}
}(func() string {
if run != nil {
return run.RunUUID
lgr.Info("Cloud infrastructure ready", zap.String("infra_id", infra.ID), zap.Int("resources", len(infra.Resources)))
// Execute workflow on cloud workers via SSH
kind := "module"
if isFlow {
kind = "flow"
}
return ""
}())
} else {
// Multiple targets - concurrent execution
go executeRunsConcurrently(workflow, targets, params, cfgCopy, concurrency, isFlow, jobID, priority, runMode)
var wg sync.WaitGroup
for i, target := range targets {
resource := infra.Resources[i%len(infra.Resources)]
if resource.PublicIP == "" {
continue
}
runUUID := ""
if i < len(runUUIDs) {
runUUID = runUUIDs[i]
}
wg.Add(1)
go func(target, ip, runID, wfName, wfKind string) {
defer wg.Done()
execCtx := context.Background()
// Build remote command
flag := "m"
if wfKind == "flow" {
flag = "f"
}
remoteCmd := fmt.Sprintf("osmedeus run -%s %s -t %s", flag, wfName, target)
// Execute via SSH runner
sshRunnerCfg := &core.RunnerConfig{
Host: ip,
User: cloudCfg.SSH.User,
KeyFile: cloudCfg.SSH.PrivateKeyPath,
Password: cloudCfg.SSH.Password,
Port: 22,
}
sshRunner, sshErr := runner.NewSSHRunner(sshRunnerCfg, "")
if sshErr != nil {
lgr.Error("Failed to create SSH runner", zap.String("ip", ip), zap.Error(sshErr))
if runID != "" {
_ = database.UpdateRunStatus(execCtx, runID, "failed", fmt.Sprintf("SSH runner creation failed: %v", sshErr))
}
return
}
if setupErr := sshRunner.Setup(execCtx); setupErr != nil {
lgr.Error("Failed to setup SSH connection", zap.String("ip", ip), zap.Error(setupErr))
if runID != "" {
_ = database.UpdateRunStatus(execCtx, runID, "failed", fmt.Sprintf("SSH connection failed: %v", setupErr))
}
return
}
defer func() { _ = sshRunner.Cleanup(execCtx) }()
result, execErr := sshRunner.Execute(execCtx, remoteCmd)
if runID != "" {
if execErr != nil || (result != nil && result.ExitCode != 0) {
errMsg := ""
if execErr != nil {
errMsg = execErr.Error()
} else if result != nil {
errMsg = fmt.Sprintf("remote command exited with code %d", result.ExitCode)
}
_ = database.UpdateRunStatus(execCtx, runID, "failed", errMsg)
} else {
_ = database.UpdateRunStatus(execCtx, runID, "completed", "")
}
}
}(target, resource.PublicIP, runUUID, workflow.Name, kind)
}
// Wait for all targets to complete before optional cleanup
wg.Wait()
// Auto-destroy if requested
if autoDestroy && infra != nil {
lm := cloud.NewLifecycleManager(cloudCfg, provider, nil)
if destroyErr := lm.Destroy(ctx, infra); destroyErr != nil {
lgr.Warn("Failed to auto-destroy cloud infrastructure", zap.String("infra_id", infra.ID), zap.Error(destroyErr))
}
}
}(runIDs, req.CloudAutoDestroy, req.CloudReuseInfra)
// Set infraID for response (generate one for tracking if provisioning new)
if req.CloudReuseInfra != "" {
infraID = req.CloudReuseInfra
} else {
infraID = jobID // Will be updated once provisioning completes
}
default:
// Local mode - existing behavior
if len(targets) == 1 {
// Single target
params["target"] = targets[0]
ctx := context.Background()
run, _ := createRunRecord(ctx, cfgCopy, workflow, loader, targets[0], params, "api", jobID, priority, runMode)
if run != nil {
runIDs = append(runIDs, run.RunUUID)
}
exec := executor.NewExecutor()
exec.SetServerMode(true)
exec.SetLoader(loader)
if req.EmptyTarget {
exec.SetSkipWorkspace(true)
}
if run != nil {
exec.SetDBRunUUID(run.RunUUID)
exec.SetDBRunID(run.ID)
exec.SetOnStepCompleted(func(stepCtx context.Context, dbRunUUID string) {
_ = database.IncrementRunCompletedSteps(stepCtx, dbRunUUID)
})
}
go func(runID string) {
ctx := context.Background()
var execErr error
if isFlow && workflow.IsFlow() {
_, execErr = exec.ExecuteFlow(ctx, workflow, params, cfgCopy)
} else {
_, execErr = exec.ExecuteModule(ctx, workflow, params, cfgCopy)
}
if runID != "" {
if execErr != nil {
_ = database.UpdateRunStatus(ctx, runID, "failed", execErr.Error())
} else {
_ = database.UpdateRunStatus(ctx, runID, "completed", "")
}
}
}(func() string {
if run != nil {
return run.RunUUID
}
return ""
}())
} else {
// Multiple targets - concurrent execution
go executeRunsConcurrently(workflow, targets, params, cfgCopy, concurrency, isFlow, jobID, priority, runMode)
}
}
// Build response
@@ -517,6 +814,20 @@ func CreateRun(cfg *config.Config) fiber.Handler {
}
}
// Add cloud-specific response fields
if runMode == "cloud" {
if infraID != "" {
response["infra_id"] = infraID
response["infra_status_url"] = fmt.Sprintf("/osm/api/cloud/instances/%s/status", infraID)
}
if req.CloudProvider != "" {
response["cloud_provider"] = req.CloudProvider
}
if req.CloudInstances > 0 {
response["cloud_instances"] = req.CloudInstances
}
}
return c.Status(fiber.StatusAccepted).JSON(response)
}
}
@@ -543,6 +854,7 @@ func ListRuns(cfg *config.Config) fiber.Handler {
workflow := c.Query("workflow")
target := c.Query("target")
workspace := c.Query("workspace")
runMode := c.Query("run_mode")
if offset < 0 {
offset = 0
@@ -555,7 +867,7 @@ func ListRuns(cfg *config.Config) fiber.Handler {
}
ctx := context.Background()
result, err := database.ListRuns(ctx, offset, limit, status, workflow, target, workspace)
result, err := database.ListRuns(ctx, offset, limit, status, workflow, target, workspace, runMode)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
+11 -1
View File
@@ -285,7 +285,7 @@ func (s *Server) setupRoutes() {
api.Get("/workflows/:name", handlers.GetWorkflowVerbose(s.config))
// Runs
api.Post("/runs", handlers.CreateRun(s.config))
api.Post("/runs", handlers.CreateRun(s.config, s.options.Master))
api.Get("/runs", handlers.ListRuns(s.config))
api.Get("/runs/:id", handlers.GetRun(s.config))
api.Delete("/runs/:id", handlers.CancelRun(s.config))
@@ -386,6 +386,16 @@ func (s *Server) setupRoutes() {
api.Post("/tasks", handlers.SubmitTask(s.options.Master))
}
// Cloud infrastructure endpoints
api.Get("/cloud/providers", handlers.ListCloudProviders(s.config))
api.Post("/cloud/providers/:name/validate", handlers.ValidateCloudProvider(s.config))
api.Post("/cloud/instances", handlers.CreateCloudInstances(s.config))
api.Get("/cloud/instances", handlers.ListCloudInstances(s.config))
api.Get("/cloud/instances/:id", handlers.GetCloudInstance(s.config))
api.Get("/cloud/instances/:id/status", handlers.GetCloudInstanceStatus(s.config))
api.Delete("/cloud/instances/:id", handlers.DestroyCloudInstance(s.config))
api.Post("/cloud/estimate", handlers.EstimateCloudCost(s.config))
// Event receiver endpoints (only available when event receiver is enabled)
if s.eventReceiver != nil {
api.Get("/event-receiver/status", handlers.GetEventReceiverStatus(s.eventReceiver))
@@ -0,0 +1,16 @@
# Osmedeus Cloud Workers Inventory
# This file is auto-generated by `osmedeus cloud run` when ansible is enabled.
# You can also create it manually and point to it via:
# setup.ansible.inventory_path in cloud-settings.yaml
#
# Format:
# [osmedeus_workers]
# <ip> ansible_user=<user> ansible_ssh_private_key_file=<key>
[osmedeus_workers]
# Example entries (uncomment and edit):
# 54.179.136.27 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/deploy_key
# 13.212.21.106 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/deploy_key
[osmedeus_workers:vars]
ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR'
@@ -0,0 +1,133 @@
---
# Osmedeus Worker Setup Playbook
# This playbook installs osmedeus and its dependencies on cloud workers.
# Customize this file to fit your setup needs.
#
# Usage:
# ansible-playbook -i inventory.ini setup-playbook.yaml
#
# This playbook is auto-run by `osmedeus cloud run` when ansible is enabled
# in cloud-settings.yaml:
# setup:
# ansible:
# enabled: true
- name: Setup Osmedeus Workers
hosts: osmedeus_workers
become: yes
gather_facts: yes
vars:
osmedeus_user: "{{ ansible_user }}"
osmedeus_home: "/home/{{ osmedeus_user }}"
osmedeus_base: "{{ osmedeus_home }}/osmedeus-base"
osmedeus_bin: "{{ osmedeus_home }}/.local/bin"
# Override these via cloud-settings.yaml extra_vars
osmedeus_version: "latest"
install_optional_binaries: false
tasks:
# ---------------------------------------------------------------
# System Dependencies
# ---------------------------------------------------------------
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install system dependencies
apt:
name:
- curl
- git
- tmux
- unzip
- jq
- build-essential
- chromium-browser
state: present
# ---------------------------------------------------------------
# Install Osmedeus
# ---------------------------------------------------------------
- name: Check if osmedeus is already installed
stat:
path: "{{ osmedeus_bin }}/osmedeus"
register: osmedeus_binary
- name: Install osmedeus CLI
become_user: "{{ osmedeus_user }}"
shell: |
curl -fsSL https://www.osmedeus.org/install.sh | bash
args:
creates: "{{ osmedeus_bin }}/osmedeus"
environment:
HOME: "{{ osmedeus_home }}"
PATH: "{{ osmedeus_bin }}:{{ osmedeus_home }}/go/bin:/usr/local/go/bin:{{ ansible_env.PATH }}"
# ---------------------------------------------------------------
# Install Base & Workflows
# ---------------------------------------------------------------
- name: Check if osmedeus base exists
stat:
path: "{{ osmedeus_base }}/workflows"
register: osmedeus_base_dir
- name: Install osmedeus base and workflows
become_user: "{{ osmedeus_user }}"
shell: |
export PATH="{{ osmedeus_bin }}:{{ osmedeus_home }}/go/bin:/usr/local/go/bin:$PATH"
osmedeus install base --preset
when: not osmedeus_base_dir.stat.exists
environment:
HOME: "{{ osmedeus_home }}"
# ---------------------------------------------------------------
# Install Security Binaries
# ---------------------------------------------------------------
- name: Install osmedeus binaries
become_user: "{{ osmedeus_user }}"
shell: |
export PATH="{{ osmedeus_bin }}:{{ osmedeus_base }}/external-binaries:{{ osmedeus_home }}/go/bin:/usr/local/go/bin:$PATH"
osmedeus install binary --all {{ '--install-optional' if install_optional_binaries else '' }}
environment:
HOME: "{{ osmedeus_home }}"
- name: Configure PATH in bashrc
become_user: "{{ osmedeus_user }}"
shell: |
export PATH="{{ osmedeus_bin }}:{{ osmedeus_base }}/external-binaries:{{ osmedeus_home }}/go/bin:/usr/local/go/bin:$PATH"
osmedeus install env
environment:
HOME: "{{ osmedeus_home }}"
# ---------------------------------------------------------------
# Verify Installation
# ---------------------------------------------------------------
- name: Verify osmedeus installation
become_user: "{{ osmedeus_user }}"
shell: |
export PATH="{{ osmedeus_bin }}:{{ osmedeus_base }}/external-binaries:{{ osmedeus_home }}/go/bin:/usr/local/go/bin:$PATH"
osmedeus --version
register: osmedeus_version_output
environment:
HOME: "{{ osmedeus_home }}"
- name: Display osmedeus version
debug:
msg: "Osmedeus installed: {{ osmedeus_version_output.stdout }}"
- name: Run osmedeus health check
become_user: "{{ osmedeus_user }}"
shell: |
export PATH="{{ osmedeus_bin }}:{{ osmedeus_base }}/external-binaries:{{ osmedeus_home }}/go/bin:/usr/local/go/bin:$PATH"
osmedeus health
register: health_output
ignore_errors: yes
environment:
HOME: "{{ osmedeus_home }}"
- name: Display health check result
debug:
msg: "{{ health_output.stdout_lines[-5:] }}"
when: health_output.stdout_lines | length > 0
+89 -5
View File
@@ -12,7 +12,20 @@ providers:
secret_access_key: "${AWS_SECRET_ACCESS_KEY}"
region: "us-east-1"
instance_type: "t3.medium"
ami: "" # Leave empty to use default Ubuntu AMI
# AMI ID for the worker instances.
# Leave empty to auto-select using ami_filter below.
# To find AMIs: https://cloud-images.ubuntu.com/locator/ec2/
# Or via AWS CLI: aws ec2 describe-images --owners 099720109477 \
# --filters "Name=name,Values=ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*" \
# --query 'Images | sort_by(@, &CreationDate) | [-1].ImageId' --region <your-region>
ami: ""
# AMI name filter for auto-lookup when ami is empty.
# Default: latest Ubuntu 24.04 LTS. Change to use a different OS:
# Ubuntu 22.04: "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"
# Ubuntu 24.04: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"
# Debian 12: "debian-12-amd64-*"
# Amazon Linux: "al2023-ami-*-x86_64"
ami_filter: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"
use_spot: false
# Google Cloud Platform Configuration
@@ -53,6 +66,29 @@ providers:
vm_size: "Standard_B2s"
image_reference: "Canonical:UbuntuServer:22.04-LTS:latest"
# Hetzner Cloud Configuration
hetzner:
token: "${HETZNER_TOKEN}"
location: "hel1" # Helsinki, Finland (recommended for cx23)
server_type: "cx23" # 2 vCPU, 4GB RAM (current generation)
image: "ubuntu-22.04"
ssh_key_name: "" # Name of SSH key registered in Hetzner
#
# Available locations:
# hel1 (Helsinki) fsn1 (Falkenstein) nbg1 (Nuremberg)
# ash (Ashburn) hil (Hillsboro) sin (Singapore)
# Note: not all server types are available in every location.
#
# Server types (current generation):
# Cost-optimized (shared x86): cx23, cx33, cx43, cx53
# Cost-optimized (shared ARM): cax11, cax21, cax31, cax41
# Shared AMD: cpx11, cpx21, cpx31, cpx41, cpx51
# Dedicated CPU: ccx13, ccx23, ccx33, ccx43, ccx53, ccx63
#
# List available types via API:
# curl -s -H "Authorization: Bearer $HETZNER_TOKEN" \
# https://api.hetzner.cloud/v1/server_types | jq '.server_types[].name'
# =============================================================================
# Default Settings
# =============================================================================
@@ -119,15 +155,63 @@ ssh:
# SSH username (default: root for most cloud VMs)
user: "root"
# SSH password — STRONGLY ADVISED NOT TO USE.
# Key-based authentication is far more secure. Only use this for ad-hoc
# machines that don't have key-based auth configured.
# Uses sshpass under the hood (must be installed: brew install sshpass / apt install sshpass)
password: ""
# SSH port (default: 22)
port: ""
# =============================================================================
# Worker Setup Commands
# =============================================================================
# Additional commands to run on worker boot (after osmedeus installation)
# Commands to run on each worker via SSH before starting scans.
# You have full control — add whatever install/config steps you need.
setup:
commands:
- "# Install additional tools"
- "# apt-get update && apt-get install -y <tool>"
- "# Configure worker-specific settings"
- "# Example: install osmedeus and tools"
- "# sudo apt-get update && sudo apt-get install -y curl git tmux unzip jq"
- "# curl -fsSL https://www.osmedeus.org/install.sh | bash"
- "# export PATH=$HOME/.local/bin:$PATH && osmedeus install base --preset"
- "# export PATH=$HOME/.local/bin:$PATH && osmedeus install binary --all"
# Post-setup commands run after setup.commands complete.
# Template variables are expanded per-worker:
# {{public_ip}} - Worker's public IP address
# {{private_ip}} - Worker's private IP address
# {{worker_name}} - Resource name (e.g., osmw-1775159841-0)
# {{worker_id}} - Cloud resource ID (e.g., i-0437adf5...)
# {{infra_id}} - Infrastructure ID (e.g., cloud-aws-1775159841)
# {{provider}} - Provider name (e.g., aws, digitalocean)
# {{ssh_user}} - SSH username (e.g., ubuntu, root)
# {{index}} - Worker index (0, 1, 2, ...)
post_commands:
- "# Example: register as a distributed worker"
- "# osmedeus worker join --redis-url redis://master:6379 --alias {{worker_name}}"
- "# Example: notify a remote server"
- "# curl -s https://my-server.com/api/register?ip={{public_ip}}&name={{worker_name}}&infra={{infra_id}}"
# ---------------------------------------------------------------------------
# Ansible Setup (alternative to raw SSH commands)
# ---------------------------------------------------------------------------
# When enabled, runs an ansible playbook against all workers instead of
# setup.commands. Falls back to SSH commands if ansible fails.
# The inventory file is auto-generated with all worker IPs.
ansible:
enabled: false
# Path to the ansible playbook. A default example is provided at this path.
# Edit it to customize: ~/osmedeus-base/cloud-infra/setup-playbook.yaml
playbook_path: "{{base_folder}}/cloud-infra/setup-playbook.yaml"
# Path where the dynamic inventory is written (auto-generated per run)
inventory_path: "{{base_folder}}/cloud-infra/inventory.ini"
# Extra variables passed to ansible-playbook as --extra-vars
extra_vars: {}
# osmedeus_version: "v5.0.2"
# redis_url: "redis://master:6379"
# Additional ansible-playbook flags (e.g., "-vvv" for debug, "--tags setup")
extra_args: ""
# =============================================================================
# Usage Examples
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,9 +1,9 @@
1:"$Sreact.fragment"
2:I[47257,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"ClientPageRoot"]
3:I[16883,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/908798be08ffb4a0.js","/_next/static/chunks/320f958c018eaa44.js","/_next/static/chunks/05e83c8918990ca6.js","/_next/static/chunks/e76c06a2463d634e.js","/_next/static/chunks/b5f708c6982c3b94.js","/_next/static/chunks/5ee0626c235da08b.js","/_next/static/chunks/411ba98ac14f065c.js"],"default"]
3:I[16883,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/908798be08ffb4a0.js","/_next/static/chunks/4ee7454817df754a.js","/_next/static/chunks/05e83c8918990ca6.js","/_next/static/chunks/e76c06a2463d634e.js","/_next/static/chunks/b5f708c6982c3b94.js","/_next/static/chunks/5ee0626c235da08b.js","/_next/static/chunks/0ce12536101cfeca.js"],"default"]
6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"OutletBoundary"]
7:"$Sreact.suspense"
0:{"buildId":"CTobtAA1SeQzzrWG56_8H","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/411ba98ac14f065c.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
0:{"buildId":"d1GNi4ZvpEqNTdpJQsavQ","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/0ce12536101cfeca.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
4:{}
5:{}
8:null
+2 -2
View File
@@ -1,7 +1,7 @@
1:"$Sreact.fragment"
2:I[92825,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"ClientSegmentRoot"]
3:I[1701,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/908798be08ffb4a0.js","/_next/static/chunks/320f958c018eaa44.js","/_next/static/chunks/05e83c8918990ca6.js","/_next/static/chunks/e76c06a2463d634e.js","/_next/static/chunks/b5f708c6982c3b94.js","/_next/static/chunks/5ee0626c235da08b.js"],"default"]
3:I[1701,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/908798be08ffb4a0.js","/_next/static/chunks/4ee7454817df754a.js","/_next/static/chunks/05e83c8918990ca6.js","/_next/static/chunks/e76c06a2463d634e.js","/_next/static/chunks/b5f708c6982c3b94.js","/_next/static/chunks/5ee0626c235da08b.js"],"default"]
4:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
5:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
0:{"buildId":"CTobtAA1SeQzzrWG56_8H","rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/908798be08ffb4a0.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/320f958c018eaa44.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/05e83c8918990ca6.js","async":true}],["$","script","script-3",{"src":"/_next/static/chunks/e76c06a2463d634e.js","async":true}],["$","script","script-4",{"src":"/_next/static/chunks/b5f708c6982c3b94.js","async":true}],["$","script","script-5",{"src":"/_next/static/chunks/5ee0626c235da08b.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"loading":null,"isPartial":false}
0:{"buildId":"d1GNi4ZvpEqNTdpJQsavQ","rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/908798be08ffb4a0.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/4ee7454817df754a.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/05e83c8918990ca6.js","async":true}],["$","script","script-3",{"src":"/_next/static/chunks/e76c06a2463d634e.js","async":true}],["$","script","script-4",{"src":"/_next/static/chunks/b5f708c6982c3b94.js","async":true}],["$","script","script-5",{"src":"/_next/static/chunks/5ee0626c235da08b.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"loading":null,"isPartial":false}
6:"$0:rsc:props:children:1:props:serverProvidedParams:params"
+8 -8
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,4 +3,4 @@
3:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"MetadataBoundary"]
4:"$Sreact.suspense"
5:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"IconMark"]
0:{"buildId":"CTobtAA1SeQzzrWG56_8H","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Osmedeus Dashboard"}],["$","meta","1",{"name":"description","content":"Security scan management dashboard for Osmedeus Workflow Engine"}],["$","link","2",{"rel":"icon","href":"/_next/static/media/favicon.c24ea5a7.ico"}],["$","$L5","3",{}]]}]}]}],null]}],"loading":null,"isPartial":false}
0:{"buildId":"d1GNi4ZvpEqNTdpJQsavQ","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Osmedeus Dashboard"}],["$","meta","1",{"name":"description","content":"Security scan management dashboard for Osmedeus Workflow Engine"}],["$","link","2",{"rel":"icon","href":"/_next/static/media/favicon.c24ea5a7.ico"}],["$","$L5","3",{}]]}]}]}],null]}],"loading":null,"isPartial":false}
+6 -6
View File
@@ -1,9 +1,9 @@
1:"$Sreact.fragment"
2:I[72111,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/440d7e9f198c67cb.js"],"ThemeProvider"]
3:I[25184,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/440d7e9f198c67cb.js"],"ColorVarsProvider"]
4:I[91617,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/440d7e9f198c67cb.js"],"AuthProvider"]
2:I[72111,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/440d7e9f198c67cb.js"],"ThemeProvider"]
3:I[25184,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/440d7e9f198c67cb.js"],"ColorVarsProvider"]
4:I[91617,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/440d7e9f198c67cb.js"],"AuthProvider"]
5:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
6:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
7:I[46696,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/440d7e9f198c67cb.js"],"Toaster"]
:HL["/_next/static/chunks/a88faf372a58a004.css","style"]
0:{"buildId":"CTobtAA1SeQzzrWG56_8H","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/a88faf372a58a004.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/9785aa98eecd6922.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/35d59feed15619b8.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/440d7e9f198c67cb.js","async":true}]],["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-screen bg-background antialiased","children":["$","$L2",null,{"attribute":"class","defaultTheme":"system","enableSystem":true,"disableTransitionOnChange":true,"children":[["$","$L3",null,{}],["$","$L4",null,{"children":[["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}],["$","$L7",null,{"position":"bottom-right","richColors":true,"toastOptions":{"className":"border border-border"}}]]}]]}]}]}]]}],"loading":null,"isPartial":false}
7:I[46696,["/_next/static/chunks/9785aa98eecd6922.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/440d7e9f198c67cb.js"],"Toaster"]
:HL["/_next/static/chunks/be16f9c77f941f9d.css","style"]
0:{"buildId":"d1GNi4ZvpEqNTdpJQsavQ","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/be16f9c77f941f9d.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/9785aa98eecd6922.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/2c2b2b4a7c3cabc2.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/440d7e9f198c67cb.js","async":true}]],["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-screen bg-background antialiased","children":["$","$L2",null,{"attribute":"class","defaultTheme":"system","enableSystem":true,"disableTransitionOnChange":true,"children":[["$","$L3",null,{}],["$","$L4",null,{"children":[["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}],["$","$L7",null,{"position":"bottom-right","richColors":true,"toastOptions":{"className":"border border-border"}}]]}]]}]}]}]]}],"loading":null,"isPartial":false}
+2 -2
View File
@@ -1,2 +1,2 @@
:HL["/_next/static/chunks/a88faf372a58a004.css","style"]
0:{"buildId":"CTobtAA1SeQzzrWG56_8H","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"(dashboard)","paramType":null,"paramKey":"(dashboard)","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
:HL["/_next/static/chunks/be16f9c77f941f9d.css","style"]
0:{"buildId":"d1GNi4ZvpEqNTdpJQsavQ","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"(dashboard)","paramType":null,"paramKey":"(dashboard)","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+13 -13
View File
@@ -1,18 +1,18 @@
1:"$Sreact.fragment"
2:I[72111,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"ThemeProvider"]
3:I[25184,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"ColorVarsProvider"]
4:I[91617,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"AuthProvider"]
5:I[39756,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
6:I[37457,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
7:I[46696,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"Toaster"]
8:I[97367,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"OutletBoundary"]
2:I[72111,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"ThemeProvider"]
3:I[25184,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"ColorVarsProvider"]
4:I[91617,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"AuthProvider"]
5:I[39756,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
6:I[37457,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
7:I[46696,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"Toaster"]
8:I[97367,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"OutletBoundary"]
9:"$Sreact.suspense"
b:I[97367,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"ViewportBoundary"]
d:I[97367,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"MetadataBoundary"]
f:I[68027,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
:HL["/_next/static/chunks/a88faf372a58a004.css","style"]
0:{"P":null,"b":"CTobtAA1SeQzzrWG56_8H","c":["","_not-found"],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/a88faf372a58a004.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/5ae60b5ed3a7770a.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/440d7e9f198c67cb.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/35d59feed15619b8.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/_next/static/chunks/fb70bb72a072b317.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-screen bg-background antialiased","children":["$","$L2",null,{"attribute":"class","defaultTheme":"system","enableSystem":true,"disableTransitionOnChange":true,"children":[["$","$L3",null,{}],["$","$L4",null,{"children":[["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L7",null,{"position":"bottom-right","richColors":true,"toastOptions":{"className":"border border-border"}}]]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],null]}],false]],"m":"$undefined","G":["$f","$undefined"],"S":true}
b:I[97367,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"ViewportBoundary"]
d:I[97367,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"MetadataBoundary"]
f:I[68027,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"default"]
:HL["/_next/static/chunks/be16f9c77f941f9d.css","style"]
0:{"P":null,"b":"d1GNi4ZvpEqNTdpJQsavQ","c":["","_not-found"],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/be16f9c77f941f9d.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/5ae60b5ed3a7770a.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/440d7e9f198c67cb.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/2c2b2b4a7c3cabc2.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/_next/static/chunks/fb70bb72a072b317.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-screen bg-background antialiased","children":["$","$L2",null,{"attribute":"class","defaultTheme":"system","enableSystem":true,"disableTransitionOnChange":true,"children":[["$","$L3",null,{}],["$","$L4",null,{"children":[["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L7",null,{"position":"bottom-right","richColors":true,"toastOptions":{"className":"border border-border"}}]]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:0:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$9",null,{"name":"Next.Metadata","children":"$Le"}]}]}],null]}],false]],"m":"$undefined","G":["$f","$undefined"],"S":true}
c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
10:I[27201,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/35d59feed15619b8.js","/_next/static/chunks/fb70bb72a072b317.js"],"IconMark"]
10:I[27201,["/_next/static/chunks/5ae60b5ed3a7770a.js","/_next/static/chunks/440d7e9f198c67cb.js","/_next/static/chunks/2c2b2b4a7c3cabc2.js","/_next/static/chunks/fb70bb72a072b317.js"],"IconMark"]
a:null
e:[["$","title","0",{"children":"Osmedeus Dashboard"}],["$","meta","1",{"name":"description","content":"Security scan management dashboard for Osmedeus Workflow Engine"}],["$","link","2",{"rel":"icon","href":"/_next/static/media/favicon.c24ea5a7.ico"}],["$","$L10","3",{}]]

Some files were not shown because too many files have changed in this diff Show More