mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-10-01 05:55:05 +02:00
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:
@@ -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..."
|
||||
|
||||
@@ -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) | ❌ |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
+246
-685
File diff suppressed because it is too large
Load Diff
+301
-312
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(), ", ")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+429
-33
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
Vendored
+8
-8
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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}
|
||||
|
||||
Vendored
+6
-6
@@ -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}
|
||||
|
||||
Vendored
+2
-2
@@ -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}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
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
+2
-2
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
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+13
-13
@@ -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
Reference in New Issue
Block a user