mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-20 14:42:31 +02:00
- 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
2691 lines
87 KiB
Go
2691 lines
87 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
execPkg "os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/goccy/go-yaml/parser"
|
|
"github.com/google/uuid"
|
|
"github.com/j3ssie/osmedeus/v5/internal/cloud"
|
|
"github.com/j3ssie/osmedeus/v5/internal/config"
|
|
"github.com/j3ssie/osmedeus/v5/internal/database"
|
|
"github.com/j3ssie/osmedeus/v5/internal/snapshot"
|
|
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
|
"github.com/j3ssie/osmedeus/v5/public"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
// Cloud command flags
|
|
cloudProvider string
|
|
cloudMode string
|
|
cloudInstances int
|
|
cloudForce bool
|
|
|
|
// Cloud run flags
|
|
cloudFlowName string
|
|
cloudModuleName string
|
|
cloudTarget string
|
|
cloudTargetFile string
|
|
cloudTimeout string
|
|
cloudAutoDestroy bool
|
|
cloudReuseInfra bool
|
|
cloudReuseWith string
|
|
cloudVerboseSetup bool
|
|
cloudUseAnsible bool
|
|
cloudSkipSetup bool
|
|
cloudSyncBack bool
|
|
|
|
// Cloud run chunk flags
|
|
cloudChunkSize int
|
|
cloudChunkCount int
|
|
|
|
// Custom command mode flags
|
|
cloudCustomCmds []string // --custom-cmd (repeatable)
|
|
cloudCustomPostCmds []string // --custom-post-cmd (repeatable)
|
|
cloudSyncPaths []string // --sync-path (repeatable)
|
|
cloudSyncDest string // --sync-dest (default "./osm-sync-back")
|
|
|
|
// Cloud config set flags
|
|
cloudConfigSetFromFile string
|
|
)
|
|
|
|
|
|
// cloudCmd represents the cloud command
|
|
var cloudCmd = &cobra.Command{
|
|
Use: "cloud",
|
|
Short: "Cloud infrastructure management commands",
|
|
Long: terminal.BoldCyan("◆ Description") + `
|
|
Provision and manage cloud infrastructure for distributed scanning.
|
|
Supports AWS, GCP, DigitalOcean, Linode, Azure, and Hetzner.
|
|
|
|
` + terminal.BoldCyan("▷ Quick Start") + `
|
|
# 1. Configure provider credentials
|
|
` + terminal.Green("osmedeus cloud config set providers.aws.access_key_id <key>") + `
|
|
` + terminal.Green("osmedeus cloud config set providers.aws.secret_access_key <secret>") + `
|
|
` + terminal.Green("osmedeus cloud config set providers.aws.region ap-southeast-1") + `
|
|
` + terminal.Green("osmedeus cloud config set defaults.provider aws") + `
|
|
|
|
# 2. Configure SSH keys
|
|
` + terminal.Green("osmedeus cloud config set ssh.private_key_path ~/.ssh/id_rsa") + `
|
|
` + terminal.Green("osmedeus cloud config set ssh.public_key_path ~/.ssh/id_rsa.pub") + `
|
|
|
|
# 3. Add setup commands (runs on each worker before scanning)
|
|
` + terminal.Green(`osmedeus cloud config set setup.commands.add "curl -fsSL https://www.osmedeus.org/install.sh | bash"`) + `
|
|
` + terminal.Green(`osmedeus cloud config set setup.commands.add "osmedeus install base --preset"`) + `
|
|
|
|
# 4. Run a scan on cloud infrastructure
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --instances 1") + `
|
|
|
|
` + terminal.BoldCyan("▷ Common Commands") + `
|
|
` + terminal.Green("osmedeus cloud config list") + ` Show cloud configuration
|
|
` + terminal.Green("osmedeus cloud create --provider aws -n 3") + ` Create 3 AWS instances
|
|
` + terminal.Green("osmedeus cloud ls") + ` List active infrastructure
|
|
` + terminal.Green("osmedeus cloud run -f general -t target.com") + ` Run scan on cloud workers
|
|
` + terminal.Green("osmedeus cloud destroy <id>") + ` Destroy specific infrastructure
|
|
` + terminal.Green("osmedeus cloud destroy all --force") + ` Destroy all infrastructure
|
|
`,
|
|
}
|
|
|
|
// cloudConfigCmd manages cloud configuration
|
|
var cloudConfigCmd = &cobra.Command{
|
|
Use: "config",
|
|
Short: "Manage cloud configuration",
|
|
Long: `View and update cloud configuration settings`,
|
|
}
|
|
|
|
// cloudConfigSetCmd sets a cloud config value
|
|
var cloudConfigSetCmd = &cobra.Command{
|
|
Use: "set [<key> <value>]",
|
|
Short: "Set a cloud configuration value",
|
|
Long: terminal.BoldCyan("◆ Description") + `
|
|
Set a cloud configuration value using dot notation.
|
|
|
|
` + terminal.BoldCyan("▷ Examples") + `
|
|
` + terminal.Green("osmedeus cloud config set defaults.provider digitalocean") + `
|
|
` + terminal.Green("osmedeus cloud config set ssh.user ubuntu") + `
|
|
|
|
` + terminal.Green("# Batch set from a file") + `
|
|
osmedeus cloud config set ` + terminal.Yellow("--from-file") + ` cloud-config.txt
|
|
|
|
` + terminal.Green("# Pipe from stdin") + `
|
|
cat cloud-config.txt | osmedeus cloud config set ` + terminal.Yellow("--from-file") + ` -
|
|
|
|
` + terminal.BoldCyan("▷ File Format") + `
|
|
Lines can use any of these formats:
|
|
ssh.user "ubuntu"
|
|
ssh.user = "ubuntu"
|
|
osmedeus cloud config set ssh.user "ubuntu"
|
|
Lines starting with # are ignored.
|
|
`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
configPath := cfg.Cloud.CloudSettings
|
|
if configPath == "" {
|
|
configPath = filepath.Join(cfg.BaseFolder, "cloud", "cloud-settings.yaml")
|
|
}
|
|
|
|
// Auto-create from preset if file doesn't exist
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
if err := ensureCloudConfig(configPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Load existing config
|
|
cloudCfg, err := cloud.LoadCloudConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load cloud config: %w", err)
|
|
}
|
|
|
|
// Resolve key-value pairs from args, file, or stdin
|
|
pairs, err := resolveConfigSetPairs(args, cloudConfigSetFromFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var setErrors []string
|
|
for _, pair := range pairs {
|
|
key, value := pair[0], pair[1]
|
|
|
|
if err := setCloudConfigValue(cloudCfg, key, value); err != nil {
|
|
setErrors = append(setErrors, fmt.Sprintf("failed to set %s: %v", key, err))
|
|
continue
|
|
}
|
|
|
|
// Save after each successful set to keep config consistent
|
|
if err := cloud.SaveCloudConfig(cloudCfg, configPath); err != nil {
|
|
setErrors = append(setErrors, fmt.Sprintf("failed to save after setting %s: %v", key, err))
|
|
continue
|
|
}
|
|
|
|
printer.Success("Cloud config updated: %s = %s", terminal.Cyan(key), terminal.Green(redactValueForDisplay(key, value, false)))
|
|
}
|
|
|
|
if len(setErrors) > 0 {
|
|
return fmt.Errorf("errors setting cloud config:\n %s", strings.Join(setErrors, "\n "))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var cloudConfigListShowSecrets bool
|
|
var cloudConfigCleanForce bool
|
|
|
|
// cloudConfigListCmd lists cloud configuration as flattened key=value pairs
|
|
var cloudConfigListCmd = &cobra.Command{
|
|
Use: "list [filter]",
|
|
Aliases: []string{"ls", "show"},
|
|
Short: "List cloud configuration values",
|
|
Long: `Display cloud configuration as flattened key=value pairs, optionally filtered by a substring match on key or value`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
configPath := cfg.Cloud.CloudSettings
|
|
if configPath == "" {
|
|
configPath = filepath.Join(cfg.BaseFolder, "cloud", "cloud-settings.yaml")
|
|
}
|
|
|
|
// Auto-create from preset if file doesn't exist
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
if err := ensureCloudConfig(configPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Read and parse YAML via AST to flatten
|
|
content, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read cloud config: %w", err)
|
|
}
|
|
|
|
file, err := parser.ParseBytes(content, parser.ParseComments)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse cloud config: %w", err)
|
|
}
|
|
if len(file.Docs) == 0 {
|
|
return fmt.Errorf("empty cloud config file")
|
|
}
|
|
|
|
out := map[string]string{}
|
|
flattenASTScalars(file.Docs[0].Body, "", out)
|
|
|
|
keys := make([]string, 0, len(out))
|
|
for k := range out {
|
|
keys = append(keys, k)
|
|
}
|
|
sortStrings(keys)
|
|
|
|
// Apply fuzzy filter if provided
|
|
filter := ""
|
|
if len(args) > 0 {
|
|
filter = strings.ToLower(args[0])
|
|
}
|
|
|
|
if globalJSON {
|
|
result := make(map[string]string)
|
|
for _, k := range keys {
|
|
v := out[k]
|
|
if !cloudConfigListShowSecrets {
|
|
v = redactValueForDisplay(k, v, false)
|
|
}
|
|
if filter != "" {
|
|
if !strings.Contains(strings.ToLower(k), filter) && !strings.Contains(strings.ToLower(v), filter) {
|
|
continue
|
|
}
|
|
}
|
|
result[k] = v
|
|
}
|
|
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal cloud config: %w", err)
|
|
}
|
|
fmt.Println(string(jsonBytes))
|
|
return nil
|
|
}
|
|
|
|
for _, k := range keys {
|
|
v := out[k]
|
|
if !cloudConfigListShowSecrets {
|
|
v = redactValueForDisplay(k, v, false)
|
|
}
|
|
|
|
// Fuzzy filter: match on key or value (case-insensitive)
|
|
if filter != "" {
|
|
if !strings.Contains(strings.ToLower(k), filter) && !strings.Contains(strings.ToLower(v), filter) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
fmt.Printf("%s = %s\n", getCategoryColor(k)(k), v)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
|
|
// cloudConfigCleanCmd resets cloud config and state to a fresh preset
|
|
var cloudConfigCleanCmd = &cobra.Command{
|
|
Use: "clean",
|
|
Short: "Clean cloud configuration and generate a fresh one from preset",
|
|
Long: terminal.BoldCyan("◆ Description") + `
|
|
Remove the current cloud configuration and state, then generate a
|
|
fresh cloud-settings.yaml from the built-in preset template.
|
|
|
|
By default this command asks for confirmation. Use --force to skip.
|
|
|
|
` + terminal.BoldCyan("▷ Examples") + `
|
|
` + terminal.Green("osmedeus cloud config clean") + `
|
|
` + terminal.Green("osmedeus cloud config clean --force") + `
|
|
`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
configPath := cfg.Cloud.CloudSettings
|
|
if configPath == "" {
|
|
configPath = filepath.Join(cfg.BaseFolder, "cloud", "cloud-settings.yaml")
|
|
}
|
|
|
|
statePath := filepath.Join(cfg.BaseFolder, "cloud-state")
|
|
|
|
// Check if there is anything to clean
|
|
configExists := false
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
configExists = true
|
|
}
|
|
stateExists := false
|
|
if _, err := os.Stat(statePath); err == nil {
|
|
stateExists = true
|
|
}
|
|
|
|
if !configExists && !stateExists {
|
|
printer.Info("No cloud configuration or state found — nothing to clean")
|
|
printer.Info("Generating fresh cloud config from preset")
|
|
return ensureCloudConfig(configPath)
|
|
}
|
|
|
|
// Confirm unless --force
|
|
if !cloudConfigCleanForce {
|
|
printer.Warning("This will remove the following:")
|
|
if configExists {
|
|
printer.Bullet(configPath)
|
|
}
|
|
if stateExists {
|
|
printer.Bullet(statePath + " (all infrastructure state)")
|
|
}
|
|
_, _ = fmt.Fprint(os.Stdout, "\n Type 'yes' to continue: ")
|
|
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
scanner.Scan()
|
|
if strings.TrimSpace(scanner.Text()) != "yes" {
|
|
printer.Info("Aborted")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Backup existing cloud config
|
|
if configExists {
|
|
backupPath := configPath + ".backup"
|
|
if err := copyFile(configPath, backupPath); err != nil {
|
|
printer.Warning("Could not backup config: %v", err)
|
|
} else {
|
|
printer.Info("Backed up existing config to %s", backupPath)
|
|
}
|
|
if err := os.Remove(configPath); err != nil {
|
|
return fmt.Errorf("failed to remove cloud config: %w", err)
|
|
}
|
|
printer.Info("Removed cloud config: %s", configPath)
|
|
}
|
|
|
|
// Remove cloud state directory
|
|
if stateExists {
|
|
if err := os.RemoveAll(statePath); err != nil {
|
|
return fmt.Errorf("failed to remove cloud state: %w", err)
|
|
}
|
|
printer.Info("Removed cloud state: %s", statePath)
|
|
}
|
|
|
|
// Generate fresh config from preset
|
|
if err := ensureCloudConfig(configPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
printer.Success("Cloud configuration has been reset to defaults")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// copyFile copies src to dst, creating or overwriting dst
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = in.Close() }()
|
|
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = out.Close() }()
|
|
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
// ensureCloudConfig creates the cloud config file from the embedded preset
|
|
func ensureCloudConfig(configPath string) error {
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
|
return fmt.Errorf("failed to create cloud config directory: %w", err)
|
|
}
|
|
|
|
data, err := public.GetCloudConfigExample()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read embedded cloud config preset: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write cloud config: %w", err)
|
|
}
|
|
|
|
printer.Success("Created cloud config from preset at %s", configPath)
|
|
|
|
// Also copy ansible playbook and inventory example to cloud-infra/
|
|
baseFolder := filepath.Dir(filepath.Dir(configPath))
|
|
ensureCloudInfraPresets(baseFolder)
|
|
|
|
return nil
|
|
}
|
|
|
|
// cloudCreateCmd provisions cloud infrastructure
|
|
var cloudCreateCmd = &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create cloud infrastructure",
|
|
Long: terminal.BoldCyan("◆ Description") + `
|
|
Provision cloud VMs. Instances stay running until you destroy them.
|
|
|
|
` + terminal.BoldCyan("▷ Examples") + `
|
|
` + terminal.Green("osmedeus cloud create --provider aws --instances 1") + `
|
|
` + terminal.Green("osmedeus cloud create --provider digitalocean -n 3") + `
|
|
` + terminal.Green("osmedeus cloud create --provider hetzner --instances 2") + `
|
|
`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
if !cfg.Cloud.Enabled {
|
|
return fmt.Errorf("cloud features are disabled. Enable in osm-settings.yaml: cloud.enabled = true")
|
|
}
|
|
|
|
// Load cloud config
|
|
cloudCfg, err := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load cloud config: %w", err)
|
|
}
|
|
cloud.ResolveTemplatePaths(cloudCfg, cfg.BaseFolder)
|
|
|
|
// Override provider if specified
|
|
providerType := cloud.ProviderType(cloudCfg.Defaults.Provider)
|
|
if cloudProvider != "" {
|
|
providerType = cloud.ProviderType(cloudProvider)
|
|
}
|
|
|
|
// Override mode if specified
|
|
mode := cloud.ExecutionMode(cloudCfg.Defaults.Mode)
|
|
if cloudMode != "" {
|
|
mode = cloud.ExecutionMode(cloudMode)
|
|
}
|
|
|
|
// Override instance count if specified
|
|
instanceCount := 1 // default to 1 instance unless explicitly specified
|
|
if cloudInstances > 0 {
|
|
instanceCount = cloudInstances
|
|
}
|
|
|
|
// Validate against limits
|
|
if instanceCount > cloudCfg.Limits.MaxInstances {
|
|
return fmt.Errorf("instance count (%d) exceeds limit (%d)", instanceCount, cloudCfg.Limits.MaxInstances)
|
|
}
|
|
|
|
printer.Section("Creating Cloud Infrastructure")
|
|
printer.KeyValueColored("Provider", string(providerType), terminal.BoldCyan)
|
|
printer.KeyValue("Mode", string(mode))
|
|
printer.KeyValue("Instances", fmt.Sprintf("%d", instanceCount))
|
|
|
|
// Create provider
|
|
provider, err := cloud.CreateProvider(cloudCfg, providerType)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create provider: %w", err)
|
|
}
|
|
|
|
// Validate provider credentials
|
|
ctx := context.Background()
|
|
if err := provider.Validate(ctx); err != nil {
|
|
return fmt.Errorf("provider validation failed: %w", err)
|
|
}
|
|
|
|
// Estimate cost
|
|
estimate, err := provider.EstimateCost(mode, instanceCount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to estimate cost: %w", err)
|
|
}
|
|
|
|
printer.KeyValueColored("Est. cost", fmt.Sprintf("$%.2f/hour ($%.2f/day)", estimate.HourlyCost, estimate.DailyCost), terminal.Yellow)
|
|
for _, note := range estimate.Notes {
|
|
printer.Bullet(terminal.Gray(note))
|
|
}
|
|
|
|
// Read SSH public key
|
|
sshPublicKey := ""
|
|
if cloudCfg.SSH.PublicKeyContent != "" {
|
|
sshPublicKey = cloudCfg.SSH.PublicKeyContent
|
|
} else if cloudCfg.SSH.PublicKeyPath != "" {
|
|
pubKeyPath := cloudCfg.SSH.PublicKeyPath
|
|
if len(pubKeyPath) > 1 && pubKeyPath[:2] == "~/" {
|
|
home, _ := os.UserHomeDir()
|
|
pubKeyPath = filepath.Join(home, pubKeyPath[2:])
|
|
}
|
|
data, err := os.ReadFile(pubKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read SSH public key: %w", err)
|
|
}
|
|
sshPublicKey = strings.TrimSpace(string(data))
|
|
}
|
|
|
|
// Create lifecycle manager
|
|
lm := cloud.NewLifecycleManager(cloudCfg, provider, nil)
|
|
|
|
// Create infrastructure
|
|
createOpts := &cloud.CreateOptions{
|
|
Mode: mode,
|
|
InstanceCount: instanceCount,
|
|
SSHPublicKey: sshPublicKey,
|
|
SetupCommands: cloudCfg.Setup.Commands,
|
|
Tags: map[string]string{"managed-by": "osmedeus"},
|
|
}
|
|
|
|
printer.Newline()
|
|
infra, err := lm.CreateAndRun(ctx, createOpts)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create infrastructure: %w", err)
|
|
}
|
|
|
|
printer.Newline()
|
|
printer.Success("Infrastructure created: %s", terminal.BoldGreen(infra.ID))
|
|
printer.Divider()
|
|
for _, res := range infra.Resources {
|
|
statusColor := terminal.Green
|
|
if res.Status != "active" && res.Status != "running" {
|
|
statusColor = terminal.Yellow
|
|
}
|
|
printer.KeyValueColored(res.Name, fmt.Sprintf("%s %s", terminal.Bold(res.PublicIP), statusColor(res.Status)), terminal.Cyan)
|
|
}
|
|
printer.Divider()
|
|
|
|
// Setup workers unless --skip-setup is specified
|
|
if !cloudSkipSetup {
|
|
sshAuth := cloudSSHAuthFromConfig(cloudCfg)
|
|
if sshAuth.User == "" {
|
|
sshAuth.User = "root"
|
|
}
|
|
|
|
// Use provider-specific SSH user from infra metadata
|
|
if u, ok := infra.Metadata["ssh_user"].(string); ok && u != "" {
|
|
sshAuth.User = u
|
|
}
|
|
|
|
// Wait for SSH on all workers
|
|
printer.Section("Preparing Workers")
|
|
var readyWorkers []cloud.Resource
|
|
for _, res := range infra.Resources {
|
|
if res.PublicIP == "" {
|
|
printer.Warning("Skipping %s: no public IP", res.Name)
|
|
continue
|
|
}
|
|
printer.Info("Waiting for SSH on %s (%s)...", res.Name, terminal.Cyan(res.PublicIP))
|
|
if waitErr := waitForSSHPort(res.PublicIP, sshAuth.Port, 3*time.Minute); waitErr != nil {
|
|
printer.Warning("SSH not ready on %s: %v", res.Name, waitErr)
|
|
continue
|
|
}
|
|
printer.Success("SSH ready on %s", terminal.Cyan(res.PublicIP))
|
|
readyWorkers = append(readyWorkers, res)
|
|
}
|
|
|
|
if len(readyWorkers) == 0 {
|
|
printer.Warning("No workers reachable via SSH — skipping setup")
|
|
} else {
|
|
// Run setup commands (ansible or raw SSH)
|
|
if cloudCfg.Setup.Ansible.Enabled || cloudUseAnsible {
|
|
printer.Section("Running Ansible Setup")
|
|
ensureCloudInfraPresets(cfg.BaseFolder)
|
|
if ansibleErr := runAnsibleSetup(&cloudCfg.Setup.Ansible, readyWorkers, sshAuth); ansibleErr != nil {
|
|
printer.Warning("Ansible setup failed: %v", ansibleErr)
|
|
printer.Info("Falling back to SSH-based setup commands...")
|
|
for _, res := range readyWorkers {
|
|
if err := setupWorkerViaSSHAuth(sshAuth, res.PublicIP, cloudCfg.Setup.Commands); err != nil {
|
|
printer.Warning("Setup failed on %s: %v", res.PublicIP, err)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for _, res := range readyWorkers {
|
|
if setupErr := setupWorkerViaSSHAuth(sshAuth, res.PublicIP, cloudCfg.Setup.Commands); setupErr != nil {
|
|
printer.Warning("Setup failed on %s: %v", res.Name, setupErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run post-setup commands per-worker (with template vars)
|
|
if len(cloudCfg.Setup.PostCommands) > 0 {
|
|
for i, res := range readyWorkers {
|
|
postVars := map[string]string{
|
|
"public_ip": res.PublicIP,
|
|
"private_ip": res.PrivateIP,
|
|
"worker_name": res.Name,
|
|
"worker_id": res.ID,
|
|
"infra_id": infra.ID,
|
|
"provider": string(infra.Provider),
|
|
"ssh_user": sshAuth.User,
|
|
"index": fmt.Sprintf("%d", i),
|
|
}
|
|
runPostCommandsAuth(sshAuth, res.PublicIP, cloudCfg.Setup.PostCommands, postVars, cloudVerboseSetup)
|
|
}
|
|
}
|
|
|
|
printer.Success("All workers set up and ready")
|
|
}
|
|
}
|
|
|
|
printer.Info("Destroy when done: %s", terminal.Gray(fmt.Sprintf("osmedeus cloud destroy %s", infra.ID)))
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// cloudListCmd lists cloud infrastructure
|
|
var cloudListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Aliases: []string{"ls"},
|
|
Short: "List cloud infrastructure",
|
|
Long: `List all active cloud infrastructure`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
// Load cloud config to get the state path
|
|
cloudCfg, err := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
|
|
statePath := cfg.Cloud.CloudPath
|
|
if err == nil {
|
|
cloud.ResolveTemplatePaths(cloudCfg, cfg.BaseFolder)
|
|
statePath = cloudCfg.State.Path
|
|
}
|
|
infrastructures, err := cloud.ListInfrastructures(statePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list infrastructures: %w", err)
|
|
}
|
|
|
|
if len(infrastructures) == 0 {
|
|
if globalJSON {
|
|
fmt.Println("[]")
|
|
return nil
|
|
}
|
|
printer.Info("No active cloud infrastructure found")
|
|
return nil
|
|
}
|
|
|
|
// Build flat rows: one row per resource (VM)
|
|
var rows []map[string]interface{}
|
|
for _, infra := range infrastructures {
|
|
if len(infra.Resources) == 0 {
|
|
rows = append(rows, map[string]interface{}{
|
|
"infra_id": infra.ID,
|
|
"provider": string(infra.Provider),
|
|
"status": "no resources",
|
|
"name": "",
|
|
"public_ip": "",
|
|
"worker_id": "",
|
|
"created": infra.CreatedAt.Format("2006-01-02 15:04"),
|
|
})
|
|
continue
|
|
}
|
|
for _, res := range infra.Resources {
|
|
rows = append(rows, map[string]interface{}{
|
|
"infra_id": infra.ID,
|
|
"provider": string(infra.Provider),
|
|
"status": res.Status,
|
|
"name": res.Name,
|
|
"public_ip": res.PublicIP,
|
|
"worker_id": res.WorkerID,
|
|
"created": infra.CreatedAt.Format("2006-01-02 15:04"),
|
|
})
|
|
}
|
|
}
|
|
|
|
if globalJSON {
|
|
if rows == nil {
|
|
fmt.Println("[]")
|
|
return nil
|
|
}
|
|
jsonBytes, err := json.MarshalIndent(rows, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal infrastructure: %w", err)
|
|
}
|
|
fmt.Println(string(jsonBytes))
|
|
return nil
|
|
}
|
|
|
|
columns := []string{"infra_id", "provider", "name", "public_ip", "status", "created"}
|
|
renderTableWithTablewriter("cloud", rows, columns, 0, false, nil)
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// cloudDestroyCmd destroys cloud infrastructure
|
|
var cloudDestroyCmd = &cobra.Command{
|
|
Use: "destroy [infrastructure-id]",
|
|
Short: "Destroy cloud infrastructure",
|
|
Long: terminal.BoldCyan("◆ Description") + `
|
|
Tear down cloud infrastructure and clean up resources.
|
|
|
|
` + terminal.BoldCyan("▷ Examples") + `
|
|
# Destroy a specific infrastructure
|
|
` + terminal.Green("osmedeus cloud destroy cloud-aws-1775159841") + `
|
|
|
|
# Destroy all infrastructure (requires --force)
|
|
` + terminal.Green("osmedeus cloud destroy all --force") + `
|
|
|
|
# List available infrastructure IDs
|
|
` + terminal.Green("osmedeus cloud ls") + `
|
|
`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
// Load cloud config to get the state path
|
|
cloudCfg, loadErr := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
|
|
statePath := cfg.Cloud.CloudPath
|
|
if loadErr == nil {
|
|
cloud.ResolveTemplatePaths(cloudCfg, cfg.BaseFolder)
|
|
statePath = cloudCfg.State.Path
|
|
}
|
|
|
|
// Helper to destroy a single infrastructure
|
|
destroyOne := func(infra *cloud.Infrastructure) error {
|
|
destroyCfg, err := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load cloud config: %w", err)
|
|
}
|
|
cloud.ResolveTemplatePaths(destroyCfg, cfg.BaseFolder)
|
|
|
|
provider, err := cloud.CreateProvider(destroyCfg, infra.Provider)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create provider: %w", err)
|
|
}
|
|
|
|
printer.Info("Tearing down %s (%s)...", terminal.Cyan(infra.ID), terminal.Gray(string(infra.Provider)))
|
|
lm := cloud.NewLifecycleManager(destroyCfg, provider, nil)
|
|
if destroyErr := lm.Destroy(context.Background(), infra); destroyErr != nil {
|
|
return destroyErr
|
|
}
|
|
printer.Success("Destroyed %s", terminal.BoldGreen(infra.ID))
|
|
return nil
|
|
}
|
|
|
|
// "destroy all" — destroy everything
|
|
if len(args) > 0 && args[0] == "all" {
|
|
if !cloudForce {
|
|
return fmt.Errorf("refusing to destroy all infrastructure without --force flag")
|
|
}
|
|
|
|
infrastructures, err := cloud.ListInfrastructures(statePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list infrastructures: %w", err)
|
|
}
|
|
if len(infrastructures) == 0 {
|
|
printer.Info("No active infrastructure found")
|
|
return nil
|
|
}
|
|
|
|
printer.Section(fmt.Sprintf("Destroying All Infrastructure (%d)", len(infrastructures)))
|
|
for _, infra := range infrastructures {
|
|
if err := destroyOne(infra); err != nil {
|
|
printer.Warning("Failed to destroy %s: %v", infra.ID, err)
|
|
}
|
|
}
|
|
printer.Success("All infrastructure destroyed")
|
|
return nil
|
|
}
|
|
|
|
// Destroy specific infrastructure by ID
|
|
if len(args) > 0 {
|
|
infraID := args[0]
|
|
infra, err := cloud.LoadInfrastructureState(infraID, statePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load infrastructure %s: %w", infraID, err)
|
|
}
|
|
|
|
printer.Section(fmt.Sprintf("Destroying Infrastructure: %s", terminal.BoldRed(infraID)))
|
|
if err := destroyOne(infra); err != nil {
|
|
return fmt.Errorf("failed to destroy infrastructure: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// No ID: list available
|
|
infrastructures, err := cloud.ListInfrastructures(statePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list infrastructures: %w", err)
|
|
}
|
|
|
|
if len(infrastructures) == 0 {
|
|
printer.Info("No active infrastructure found")
|
|
return nil
|
|
}
|
|
|
|
printer.Warning("Please specify an infrastructure ID to destroy:")
|
|
for _, infra := range infrastructures {
|
|
printer.Info(" %s (%s, %d resources)", infra.ID, infra.Provider, len(infra.Resources))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// cloudRunCmd runs a workflow on cloud infrastructure
|
|
var cloudRunCmd = &cobra.Command{
|
|
Use: "run",
|
|
Short: "Run workflow on cloud infrastructure",
|
|
Long: terminal.BoldCyan("◆ Description") + `
|
|
Provision cloud VMs, run setup commands, execute an osmedeus workflow,
|
|
and stream output back to your terminal.
|
|
|
|
` + terminal.BoldCyan("▷ Examples") + `
|
|
# Run a flow on a new AWS instance
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --provider aws --instances 1") + `
|
|
|
|
# Run a module with a timeout
|
|
` + terminal.Green("osmedeus cloud run -m enum-subdomain -t example.com --timeout 30m") + `
|
|
|
|
# Run on multiple instances
|
|
` + terminal.Green("osmedeus cloud run -f general -t example.com --provider aws --instances 3") + `
|
|
|
|
# Auto-destroy infrastructure after scan completes
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --auto-destroy") + `
|
|
|
|
# Reuse existing infrastructure (auto-discover from saved state)
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --reuse") + `
|
|
|
|
# Reuse specific instances by IP
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --reuse-with '1.2.3.4,5.6.7.8'") + `
|
|
|
|
# Show full setup command output
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --verbose-setup") + `
|
|
|
|
# Run with targets from a file
|
|
` + terminal.Green("osmedeus cloud run -f domain-list-recon -T targets.txt --instances 2") + `
|
|
|
|
# Sync results back to local machine after scan
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --sync-back") + `
|
|
|
|
# Full lifecycle: provision, scan, sync results, destroy
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --sync-back --auto-destroy") + `
|
|
|
|
# Combine: reuse infra + sync + auto-destroy
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --reuse --sync-back --auto-destroy") + `
|
|
|
|
` + terminal.BoldCyan("▷ Custom Command Mode") + `
|
|
# Run custom commands on cloud workers (no osmedeus workflow)
|
|
` + terminal.Green("osmedeus cloud run --custom-cmd 'nmap -sV {{Target}} -oA /tmp/osm-custom/nmap' -t example.com") + `
|
|
|
|
# Multiple commands with post-processing and sync-back
|
|
` + terminal.Green(`osmedeus cloud run \
|
|
--custom-cmd 'nuclei -u {{Target}} -o /tmp/osm-custom/nuclei.txt' \
|
|
--custom-post-cmd 'cat /tmp/osm-custom/nuclei.txt | notify' \
|
|
--sync-path '/tmp/osm-custom/' \
|
|
-t example.com`) + `
|
|
|
|
# Distribute targets across workers with custom commands
|
|
` + terminal.Green(`osmedeus cloud run \
|
|
--custom-cmd 'cat {{Target}} | httpx -o /tmp/osm-custom/live.txt' \
|
|
--sync-path '/tmp/osm-custom/live.txt' \
|
|
--sync-dest './my-results' \
|
|
-T targets.txt --instances 3`) + `
|
|
|
|
# Run on existing infrastructure
|
|
` + terminal.Green("osmedeus cloud run --custom-cmd 'whoami && id' -t example.com --reuse") + `
|
|
`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
if !cfg.Cloud.Enabled {
|
|
return fmt.Errorf("cloud features are disabled. Enable in osm-settings.yaml: cloud.enabled = true")
|
|
}
|
|
|
|
// Validate workflow flags
|
|
isCustomMode := len(cloudCustomCmds) > 0
|
|
isFlowMode := cloudFlowName != "" || cloudModuleName != ""
|
|
|
|
if isCustomMode && isFlowMode {
|
|
return fmt.Errorf("--custom-cmd is mutually exclusive with --flow (-f) and --module (-m)")
|
|
}
|
|
if !isCustomMode && !isFlowMode {
|
|
return fmt.Errorf("either --flow (-f), --module (-m), or --custom-cmd is required")
|
|
}
|
|
if cloudTarget == "" && cloudTargetFile == "" {
|
|
return fmt.Errorf("either --target (-t) or --target-file (-T) is required")
|
|
}
|
|
if !isCustomMode && (len(cloudCustomPostCmds) > 0 || len(cloudSyncPaths) > 0) {
|
|
return fmt.Errorf("--custom-post-cmd and --sync-path require --custom-cmd")
|
|
}
|
|
|
|
// Load cloud config
|
|
cloudCfg, err := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load cloud config: %w", err)
|
|
}
|
|
cloud.ResolveTemplatePaths(cloudCfg, cfg.BaseFolder)
|
|
|
|
// Override provider if specified
|
|
providerType := cloud.ProviderType(cloudCfg.Defaults.Provider)
|
|
if cloudProvider != "" {
|
|
providerType = cloud.ProviderType(cloudProvider)
|
|
}
|
|
|
|
instanceCount := 1 // default to 1 instance unless explicitly specified
|
|
if cloudInstances > 0 {
|
|
instanceCount = cloudInstances
|
|
}
|
|
|
|
// Create provider
|
|
provider, err := cloud.CreateProvider(cloudCfg, providerType)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create provider: %w", err)
|
|
}
|
|
|
|
// Validate provider credentials
|
|
ctx := context.Background()
|
|
if err := provider.Validate(ctx); err != nil {
|
|
return fmt.Errorf("provider validation failed: %w", err)
|
|
}
|
|
|
|
// Read SSH keys
|
|
sshPublicKey := ""
|
|
if cloudCfg.SSH.PublicKeyContent != "" {
|
|
sshPublicKey = cloudCfg.SSH.PublicKeyContent
|
|
} else if cloudCfg.SSH.PublicKeyPath != "" {
|
|
pubKeyPath := cloudCfg.SSH.PublicKeyPath
|
|
if len(pubKeyPath) > 1 && pubKeyPath[:2] == "~/" {
|
|
home, _ := os.UserHomeDir()
|
|
pubKeyPath = filepath.Join(home, pubKeyPath[2:])
|
|
}
|
|
data, readErr := os.ReadFile(pubKeyPath)
|
|
if readErr != nil {
|
|
return fmt.Errorf("failed to read SSH public key: %w", readErr)
|
|
}
|
|
sshPublicKey = strings.TrimSpace(string(data))
|
|
}
|
|
|
|
sshAuth := cloudSSHAuthFromConfig(cloudCfg)
|
|
sshUser := sshAuth.User
|
|
if sshUser == "" {
|
|
sshUser = "root"
|
|
sshAuth.User = sshUser
|
|
}
|
|
|
|
// Step 1: Provision or reuse infrastructure
|
|
var infra *cloud.Infrastructure
|
|
|
|
statePath := cfg.Cloud.CloudPath
|
|
if cloudCfg.State.Path != "" {
|
|
statePath = cloudCfg.State.Path
|
|
}
|
|
|
|
if cloudReuseInfra && cloudReuseWith != "" {
|
|
return fmt.Errorf("--reuse and --reuse-with are mutually exclusive")
|
|
}
|
|
|
|
if cloudReuseWith != "" {
|
|
// Reuse specific instances by IP or name
|
|
printer.Section("Reusing Specified Instances")
|
|
identifiers := strings.Split(cloudReuseWith, ",")
|
|
for i := range identifiers {
|
|
identifiers[i] = strings.TrimSpace(identifiers[i])
|
|
}
|
|
|
|
infra = resolveReuseWithInstances(identifiers, statePath, sshAuth)
|
|
printer.Success("Reusing %d instance(s)", len(infra.Resources))
|
|
for _, res := range infra.Resources {
|
|
printer.KeyValueColored(res.Name, res.PublicIP, terminal.Cyan)
|
|
}
|
|
} else if cloudReuseInfra {
|
|
// Auto-discover all saved infrastructures
|
|
printer.Section("Discovering Existing Infrastructure")
|
|
discoveredInfra, discoverErr := discoverAndPrioritizeInfra(statePath, sshAuth)
|
|
if discoverErr != nil {
|
|
return discoverErr
|
|
}
|
|
infra = discoveredInfra
|
|
printer.Success("Discovered %d reachable instance(s)", len(infra.Resources))
|
|
for _, res := range infra.Resources {
|
|
printer.KeyValueColored(res.Name, res.PublicIP, terminal.Cyan)
|
|
}
|
|
} else {
|
|
// Provision new infrastructure
|
|
printer.Section("Provisioning Cloud Infrastructure")
|
|
printer.KeyValueColored("Provider", string(providerType), terminal.BoldCyan)
|
|
printer.KeyValue("Instances", fmt.Sprintf("%d", instanceCount))
|
|
|
|
lm := cloud.NewLifecycleManager(cloudCfg, provider, nil)
|
|
createOpts := &cloud.CreateOptions{
|
|
Mode: cloud.ModeVM,
|
|
InstanceCount: instanceCount,
|
|
SSHPublicKey: sshPublicKey,
|
|
SetupCommands: cloudCfg.Setup.Commands,
|
|
Tags: map[string]string{"managed-by": "osmedeus"},
|
|
}
|
|
|
|
newInfra, createErr := lm.CreateAndRun(ctx, createOpts)
|
|
if createErr != nil {
|
|
return fmt.Errorf("failed to create infrastructure: %w", createErr)
|
|
}
|
|
infra = newInfra
|
|
printer.Newline()
|
|
printer.Success("Infrastructure ready: %s", terminal.BoldGreen(infra.ID))
|
|
}
|
|
|
|
// Use provider-specific SSH user from infra metadata
|
|
if u, ok := infra.Metadata["ssh_user"].(string); ok && u != "" {
|
|
sshUser = u
|
|
}
|
|
|
|
// Step 2: Wait for SSH on all workers
|
|
printer.Section("Preparing Workers")
|
|
var readyWorkers []cloud.Resource
|
|
for _, res := range infra.Resources {
|
|
if res.PublicIP == "" {
|
|
printer.Warning("Skipping %s: no public IP", res.Name)
|
|
continue
|
|
}
|
|
printer.Info("Waiting for SSH on %s (%s)...", res.Name, terminal.Cyan(res.PublicIP))
|
|
if waitErr := waitForSSHPort(res.PublicIP, sshAuth.Port, 3*time.Minute); waitErr != nil {
|
|
printer.Warning("SSH not ready on %s: %v", res.Name, waitErr)
|
|
continue
|
|
}
|
|
printer.Success("SSH ready on %s", terminal.Cyan(res.PublicIP))
|
|
readyWorkers = append(readyWorkers, res)
|
|
}
|
|
|
|
if len(readyWorkers) == 0 {
|
|
return fmt.Errorf("no workers are reachable via SSH")
|
|
}
|
|
|
|
// Step 4: Setup workers (ansible or raw commands)
|
|
if cloudCfg.Setup.Ansible.Enabled || cloudUseAnsible {
|
|
// Ansible-based setup: run playbook against all workers at once
|
|
printer.Section("Running Ansible Setup")
|
|
ensureCloudInfraPresets(cfg.BaseFolder)
|
|
if ansibleErr := runAnsibleSetup(&cloudCfg.Setup.Ansible, readyWorkers, sshAuth); ansibleErr != nil {
|
|
printer.Warning("Ansible setup failed: %v", ansibleErr)
|
|
printer.Info("Falling back to SSH-based setup commands...")
|
|
for _, res := range readyWorkers {
|
|
if err := setupWorkerViaSSHAuth(sshAuth, res.PublicIP, cloudCfg.Setup.Commands); err != nil {
|
|
printer.Warning("Setup failed on %s: %v", res.PublicIP, err)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// SSH-based setup: run commands per-worker
|
|
for _, res := range readyWorkers {
|
|
if setupErr := setupWorkerViaSSHAuth(sshAuth, res.PublicIP, cloudCfg.Setup.Commands); setupErr != nil {
|
|
printer.Warning("Setup failed on %s: %v", res.Name, setupErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 5: Run post-setup commands per-worker (with template vars)
|
|
if len(cloudCfg.Setup.PostCommands) > 0 {
|
|
for i, res := range readyWorkers {
|
|
postVars := map[string]string{
|
|
"public_ip": res.PublicIP,
|
|
"private_ip": res.PrivateIP,
|
|
"worker_name": res.Name,
|
|
"worker_id": res.ID,
|
|
"infra_id": infra.ID,
|
|
"provider": string(infra.Provider),
|
|
"ssh_user": sshUser,
|
|
"index": fmt.Sprintf("%d", i),
|
|
}
|
|
runPostCommandsAuth(sshAuth, res.PublicIP, cloudCfg.Setup.PostCommands, postVars, cloudVerboseSetup)
|
|
}
|
|
}
|
|
|
|
// Branch: custom command mode vs flow/module mode
|
|
if isCustomMode {
|
|
// Custom command mode: run arbitrary commands on workers
|
|
tasks, scanErrors := executeCustomCommands(ctx, sshAuth, sshUser, readyWorkers, infra, cloudCfg)
|
|
|
|
// Print summary
|
|
failCount := 0
|
|
for _, e := range scanErrors {
|
|
if e != nil {
|
|
failCount++
|
|
}
|
|
}
|
|
if failCount > 0 {
|
|
printer.Warning("%d of %d workers failed", failCount, len(tasks))
|
|
}
|
|
|
|
// Sync custom paths back if requested
|
|
if len(cloudSyncPaths) > 0 {
|
|
syncCustomPaths(sshAuth, readyWorkers, infra, sshUser, cloudSyncPaths, cloudSyncDest)
|
|
}
|
|
} else {
|
|
// Initialize database for cloud run tracking (best-effort)
|
|
cloudRunGroupID := uuid.New().String()
|
|
cloudDBReady := false
|
|
if _, dbErr := database.Connect(cfg); dbErr == nil {
|
|
if migErr := database.Migrate(ctx); migErr == nil {
|
|
cloudDBReady = true
|
|
}
|
|
}
|
|
|
|
// Step 5.5: Build per-worker commands and distribute target files
|
|
var tasks []workerTask
|
|
|
|
// Build the base command prefix (flow or module)
|
|
var baseCmd string
|
|
if cloudFlowName != "" {
|
|
baseCmd = fmt.Sprintf("osmedeus run -f %s", cloudFlowName)
|
|
} else {
|
|
baseCmd = fmt.Sprintf("osmedeus run -m %s", cloudModuleName)
|
|
}
|
|
if cloudTimeout != "" {
|
|
baseCmd += fmt.Sprintf(" --timeout %s", cloudTimeout)
|
|
}
|
|
|
|
if cloudTargetFile != "" {
|
|
// Read targets locally and distribute across workers
|
|
allTargets, readErr := readTargetsFromFile(cloudTargetFile)
|
|
if readErr != nil {
|
|
return fmt.Errorf("failed to read target file %s: %w", cloudTargetFile, readErr)
|
|
}
|
|
if len(allTargets) == 0 {
|
|
return fmt.Errorf("target file %s is empty", cloudTargetFile)
|
|
}
|
|
|
|
// Validate chunk flags
|
|
if cloudChunkSize > 0 && cloudChunkCount > 0 {
|
|
return fmt.Errorf("--chunk-size and --chunk-count are mutually exclusive")
|
|
}
|
|
|
|
effectiveWorkers := len(readyWorkers)
|
|
|
|
// Apply chunk overrides
|
|
if cloudChunkCount > 0 {
|
|
if cloudChunkCount > len(readyWorkers) {
|
|
return fmt.Errorf("--chunk-count %d exceeds available workers %d", cloudChunkCount, len(readyWorkers))
|
|
}
|
|
effectiveWorkers = cloudChunkCount
|
|
} else if cloudChunkSize > 0 {
|
|
needed := (len(allTargets) + cloudChunkSize - 1) / cloudChunkSize
|
|
if needed > len(readyWorkers) {
|
|
printer.Warning("chunk-size %d requires %d workers but only %d available; using %d workers",
|
|
cloudChunkSize, needed, len(readyWorkers), len(readyWorkers))
|
|
effectiveWorkers = len(readyWorkers)
|
|
} else {
|
|
effectiveWorkers = needed
|
|
}
|
|
}
|
|
|
|
// Cap workers at target count
|
|
if len(allTargets) < effectiveWorkers {
|
|
printer.Warning("Only %d targets for %d workers; using %d workers",
|
|
len(allTargets), effectiveWorkers, len(allTargets))
|
|
effectiveWorkers = len(allTargets)
|
|
}
|
|
|
|
chunks := splitTargetsForWorkers(allTargets, effectiveWorkers)
|
|
|
|
printer.Section("Distributing Targets")
|
|
printer.Info("Total targets: %d, Workers: %d", len(allTargets), effectiveWorkers)
|
|
for i := 0; i < effectiveWorkers; i++ {
|
|
chunk := chunks[i]
|
|
if len(chunk) == 0 {
|
|
continue
|
|
}
|
|
|
|
worker := readyWorkers[i]
|
|
|
|
// Write chunk to local temp file
|
|
uid := uuid.New().String()[:8]
|
|
localTmp := filepath.Join(os.TempDir(), fmt.Sprintf("osm-cloud-targets-%s-%d.txt", uid, i))
|
|
if writeErr := os.WriteFile(localTmp, []byte(strings.Join(chunk, "\n")+"\n"), 0644); writeErr != nil {
|
|
return fmt.Errorf("failed to write temp target file: %w", writeErr)
|
|
}
|
|
|
|
// SCP to remote worker
|
|
remotePath := fmt.Sprintf("/tmp/osm-targets-%d.txt", i)
|
|
printer.Info("Uploading %d targets to %s (%s)", len(chunk), worker.Name, terminal.Cyan(worker.PublicIP))
|
|
if scpErr := scpFileToRemote(sshAuth.KeyPath, sshAuth.User, worker.PublicIP, localTmp, remotePath); scpErr != nil {
|
|
_ = os.Remove(localTmp)
|
|
return fmt.Errorf("failed to SCP targets to %s: %w", worker.Name, scpErr)
|
|
}
|
|
_ = os.Remove(localTmp)
|
|
|
|
// Build per-worker command with the remote target file
|
|
workerCmd := fmt.Sprintf("%s -T %s", baseCmd, remotePath)
|
|
|
|
tasks = append(tasks, workerTask{
|
|
resource: worker,
|
|
osmCmd: workerCmd,
|
|
chunkInfo: fmt.Sprintf("%d targets", len(chunk)),
|
|
targets: chunk,
|
|
})
|
|
}
|
|
} else {
|
|
// Single target (-t): same command for all workers
|
|
singleCmd := fmt.Sprintf("%s -t %s", baseCmd, cloudTarget)
|
|
for _, res := range readyWorkers {
|
|
tasks = append(tasks, workerTask{
|
|
resource: res,
|
|
osmCmd: singleCmd,
|
|
targets: []string{cloudTarget},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Step 6: Run scans in parallel
|
|
printer.Section("Starting Scans")
|
|
pathSetup := "export PATH=$HOME/.local/bin:$HOME/osmedeus-base/external-binaries:$HOME/go/bin:/usr/local/go/bin:$PATH"
|
|
|
|
var wg sync.WaitGroup
|
|
scanErrors := make([]error, len(tasks))
|
|
|
|
for i, task := range tasks {
|
|
wg.Add(1)
|
|
go func(idx int, t workerTask) {
|
|
defer wg.Done()
|
|
label := t.resource.Name
|
|
if t.chunkInfo != "" {
|
|
printer.Info("[%s] %s (%s@%s): %s",
|
|
label, t.chunkInfo, terminal.Cyan(sshUser), terminal.Bold(t.resource.PublicIP), terminal.Gray(t.osmCmd))
|
|
} else {
|
|
printer.Info("[%s] (%s@%s): %s",
|
|
label, terminal.Cyan(sshUser), terminal.Bold(t.resource.PublicIP), terminal.Gray(t.osmCmd))
|
|
}
|
|
|
|
// Create a Run record for this worker (best-effort)
|
|
var workerRunUUID string
|
|
if cloudDBReady {
|
|
now := time.Now()
|
|
workerRunUUID = uuid.New().String()
|
|
|
|
workerTarget := cloudTarget
|
|
if len(t.targets) == 1 {
|
|
workerTarget = t.targets[0]
|
|
} else if len(t.targets) > 1 {
|
|
workerTarget = fmt.Sprintf("%d targets", len(t.targets))
|
|
}
|
|
|
|
workflowName := cloudFlowName
|
|
workflowKind := "flow"
|
|
if cloudModuleName != "" {
|
|
workflowName = cloudModuleName
|
|
workflowKind = "module"
|
|
}
|
|
|
|
run := &database.Run{
|
|
RunUUID: workerRunUUID,
|
|
WorkflowName: workflowName,
|
|
WorkflowKind: workflowKind,
|
|
Target: workerTarget,
|
|
Params: map[string]interface{}{
|
|
"cloud_provider": string(providerType),
|
|
"cloud_infra_id": infra.ID,
|
|
"worker_name": t.resource.Name,
|
|
"worker_ip": t.resource.PublicIP,
|
|
"osm_command": t.osmCmd,
|
|
},
|
|
Status: "running",
|
|
TriggerType: "cli",
|
|
RunGroupID: cloudRunGroupID,
|
|
StartedAt: &now,
|
|
Workspace: computeWorkspace(workerTarget, map[string]string{}),
|
|
RunPriority: "critical",
|
|
RunMode: "cloud",
|
|
}
|
|
|
|
if createErr := database.CreateRun(ctx, run); createErr != nil {
|
|
workerRunUUID = ""
|
|
}
|
|
}
|
|
|
|
// Build progress callback for step tracking
|
|
var onLine cloud.LineCallback
|
|
if cloudDBReady && workerRunUUID != "" {
|
|
onLine = newCloudProgressParser(ctx, workerRunUUID)
|
|
}
|
|
|
|
scanCmd := fmt.Sprintf("%s && %s", pathSetup, t.osmCmd)
|
|
var scanErr error
|
|
if onLine != nil {
|
|
scanErr = runSSHCommandStreamingAuthWithCallback(sshAuth, t.resource.PublicIP, scanCmd, onLine, t.resource.Name)
|
|
} else {
|
|
scanErr = runSSHCommandStreamingAuth(sshAuth, t.resource.PublicIP, scanCmd, t.resource.Name)
|
|
}
|
|
|
|
if scanErr != nil {
|
|
scanErrors[idx] = scanErr
|
|
printer.Warning("Scan failed on %s: %v", t.resource.Name, scanErr)
|
|
} else {
|
|
printer.Success("Scan completed on %s", t.resource.Name)
|
|
}
|
|
|
|
// Update run record status (best-effort)
|
|
if cloudDBReady && workerRunUUID != "" {
|
|
if scanErr != nil {
|
|
_ = database.UpdateRunStatus(ctx, workerRunUUID, "failed", scanErr.Error())
|
|
} else {
|
|
_ = database.UpdateRunStatus(ctx, workerRunUUID, "completed", "")
|
|
}
|
|
}
|
|
}(i, task)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Print summary
|
|
failCount := 0
|
|
for _, e := range scanErrors {
|
|
if e != nil {
|
|
failCount++
|
|
}
|
|
}
|
|
if failCount > 0 {
|
|
printer.Warning("%d of %d workers failed", failCount, len(tasks))
|
|
}
|
|
|
|
// Sync results back from workers if requested
|
|
if cloudSyncBack {
|
|
printer.Section("Syncing Results Back")
|
|
for _, task := range tasks {
|
|
for _, target := range task.targets {
|
|
p := terminal.NewPrinter()
|
|
p.Info("Syncing %s from %s...", terminal.Bold(target), terminal.Cyan(task.resource.PublicIP))
|
|
if syncErr := syncWorkspaceBack(sshAuth, task.resource.PublicIP, target, cfg); syncErr != nil {
|
|
printer.Warning(" Sync failed for %s: %v", target, syncErr)
|
|
}
|
|
}
|
|
}
|
|
printer.Success("All results synced to local workspace")
|
|
}
|
|
}
|
|
|
|
// Auto-destroy infrastructure if requested
|
|
if cloudAutoDestroy {
|
|
printer.Section("Destroying Infrastructure")
|
|
printer.Info("Auto-destroying %s...", terminal.Cyan(infra.ID))
|
|
destroyCfg, loadErr := cloud.LoadCloudConfig(cfg.Cloud.CloudSettings)
|
|
if loadErr == nil {
|
|
cloud.ResolveTemplatePaths(destroyCfg, cfg.BaseFolder)
|
|
destroyProvider, provErr := cloud.CreateProvider(destroyCfg, infra.Provider)
|
|
if provErr == nil {
|
|
lm := cloud.NewLifecycleManager(destroyCfg, destroyProvider, nil)
|
|
if destroyErr := lm.Destroy(ctx, infra); destroyErr != nil {
|
|
printer.Warning("Failed to destroy: %v", destroyErr)
|
|
printer.Warning("Manual cleanup: osmedeus cloud destroy %s", infra.ID)
|
|
} else {
|
|
printer.Success("Infrastructure %s destroyed", terminal.BoldGreen(infra.ID))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
printer.Section("Cloud Run Summary")
|
|
printer.Divider()
|
|
printer.KeyValueColored("Infrastructure", infra.ID, terminal.BoldGreen)
|
|
printer.KeyValue("Workers", fmt.Sprintf("%d", len(infra.Resources)))
|
|
for _, res := range infra.Resources {
|
|
printer.KeyValueColored(res.Name, res.PublicIP, terminal.Cyan)
|
|
}
|
|
printer.Divider()
|
|
printer.Newline()
|
|
printer.Bullet(fmt.Sprintf("Destroy: %s", terminal.Gray(fmt.Sprintf("osmedeus cloud destroy %s", infra.ID))))
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// cloudSSHAuth holds SSH authentication context for cloud commands.
|
|
// It bridges cloud config to the Go-native SSH client in internal/cloud/ssh.go.
|
|
type cloudSSHAuth struct {
|
|
KeyPath string
|
|
Password string
|
|
User string
|
|
Port string
|
|
}
|
|
|
|
func cloudSSHAuthFromConfig(cfg *config.CloudConfigs) cloudSSHAuth {
|
|
return cloudSSHAuth{
|
|
KeyPath: cloud.ExpandPath(cfg.SSH.PrivateKeyPath),
|
|
Password: cfg.SSH.Password,
|
|
User: cfg.SSH.User,
|
|
Port: cfg.SSH.Port,
|
|
}
|
|
}
|
|
|
|
// toSSHConfig converts to cloud.SSHConfig for use with CloudSSHClient
|
|
func (a cloudSSHAuth) toSSHConfig(host string) cloud.SSHConfig {
|
|
port := 22
|
|
if a.Port != "" {
|
|
if p, err := fmt.Sscanf(a.Port, "%d", &port); p == 0 || err != nil {
|
|
port = 22
|
|
}
|
|
}
|
|
return cloud.SSHConfig{
|
|
Host: host,
|
|
Port: port,
|
|
User: a.User,
|
|
KeyFile: a.KeyPath,
|
|
Password: a.Password,
|
|
}
|
|
}
|
|
|
|
// connect creates a Go-native SSH client for the given host
|
|
func (a cloudSSHAuth) connect(ctx context.Context, host string) (*cloud.CloudSSHClient, error) {
|
|
return cloud.NewCloudSSHClient(ctx, a.toSSHConfig(host))
|
|
}
|
|
|
|
// runSSHCommandAuth runs a command via Go-native SSH and returns output
|
|
func runSSHCommandAuth(auth cloudSSHAuth, host, command string) (string, error) {
|
|
ctx := context.Background()
|
|
client, err := auth.connect(ctx, host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer client.Close()
|
|
out, _, runErr := client.RunCommand(ctx, command)
|
|
return out, runErr
|
|
}
|
|
|
|
// runSSHCommandStreamingAuth runs a command via Go-native SSH, streaming output with prefix
|
|
func runSSHCommandStreamingAuth(auth cloudSSHAuth, host, command string, prefixLabel ...string) error {
|
|
ctx := context.Background()
|
|
client, err := auth.connect(ctx, host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
label := "remote"
|
|
if len(prefixLabel) > 0 && prefixLabel[0] != "" {
|
|
label = prefixLabel[0]
|
|
}
|
|
return client.RunCommandStreaming(ctx, command, label)
|
|
}
|
|
|
|
// runSSHCommandStreamingAuthWithCallback runs a command via Go-native SSH, streaming output
|
|
// with prefix and calling onLine for each output line for progress tracking.
|
|
func runSSHCommandStreamingAuthWithCallback(auth cloudSSHAuth, host, command string, onLine cloud.LineCallback, prefixLabel ...string) error {
|
|
ctx := context.Background()
|
|
client, err := auth.connect(ctx, host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
label := "remote"
|
|
if len(prefixLabel) > 0 && prefixLabel[0] != "" {
|
|
label = prefixLabel[0]
|
|
}
|
|
return client.RunCommandStreamingWithCallback(ctx, command, label, onLine)
|
|
}
|
|
|
|
// newCloudProgressParser returns a callback that parses structured log lines from remote
|
|
// osmedeus output and increments the completed_steps counter for the given run.
|
|
func newCloudProgressParser(ctx context.Context, runUUID string) func(string) {
|
|
return func(line string) {
|
|
idx := strings.Index(line, "{")
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
var entry struct {
|
|
Msg string `json:"msg"`
|
|
}
|
|
if json.Unmarshal([]byte(line[idx:]), &entry) != nil {
|
|
return
|
|
}
|
|
if entry.Msg == "Step completed" {
|
|
_ = database.IncrementRunCompletedSteps(ctx, runUUID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// uploadFileAuth copies a local file to remote via Go-native SFTP
|
|
func uploadFileAuth(auth cloudSSHAuth, host, localPath, remotePath string) error {
|
|
ctx := context.Background()
|
|
client, err := auth.connect(ctx, host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
return client.UploadFile(localPath, remotePath)
|
|
}
|
|
|
|
func scpFileToRemote(keyPath, user, host, localPath, remotePath string) error {
|
|
return uploadFileAuth(cloudSSHAuth{KeyPath: keyPath, User: user}, host, localPath, remotePath)
|
|
}
|
|
|
|
// isOsmedeusRunning checks if an osmedeus process is running on a remote host.
|
|
// Returns true if busy, false if idle. Errors are treated as unreachable.
|
|
func isOsmedeusRunning(auth cloudSSHAuth, host string) (bool, error) {
|
|
out, err := runSSHCommandAuth(auth, host, "pgrep -f 'osmedeus run|osmedeus cloud' || true")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(out) != "", nil
|
|
}
|
|
|
|
// discoverAndPrioritizeInfra loads all saved infrastructures, checks SSH reachability,
|
|
// and prioritizes idle instances (no osmedeus process running).
|
|
func discoverAndPrioritizeInfra(statePath string, auth cloudSSHAuth) (*cloud.Infrastructure, error) {
|
|
allInfras, err := cloud.ListInfrastructures(statePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list infrastructures: %w", err)
|
|
}
|
|
if len(allInfras) == 0 {
|
|
return nil, fmt.Errorf("no saved infrastructure found. Provision first with: osmedeus cloud run -f <flow> -t <target>")
|
|
}
|
|
|
|
// Collect all resources with public IPs across all infrastructures
|
|
type resourceInfo struct {
|
|
resource cloud.Resource
|
|
reachable bool
|
|
idle bool
|
|
}
|
|
var candidates []resourceInfo
|
|
|
|
for _, inf := range allInfras {
|
|
for _, res := range inf.Resources {
|
|
if res.PublicIP == "" {
|
|
continue
|
|
}
|
|
candidates = append(candidates, resourceInfo{resource: res})
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil, fmt.Errorf("no instances with public IPs found in saved infrastructure")
|
|
}
|
|
|
|
// Check reachability and busyness in parallel
|
|
type checkResult struct {
|
|
idx int
|
|
reachable bool
|
|
idle bool
|
|
}
|
|
results := make(chan checkResult, len(candidates))
|
|
|
|
for i, c := range candidates {
|
|
go func(idx int, res cloud.Resource) {
|
|
// Quick SSH port check (15s timeout)
|
|
if waitErr := waitForSSHPort(res.PublicIP, auth.Port, 15*time.Second); waitErr != nil {
|
|
printer.Warning("Skipping %s (%s): unreachable", res.Name, res.PublicIP)
|
|
results <- checkResult{idx: idx, reachable: false}
|
|
return
|
|
}
|
|
busy, err := isOsmedeusRunning(auth, res.PublicIP)
|
|
if err != nil {
|
|
printer.Warning("Skipping %s (%s): SSH error: %v", res.Name, res.PublicIP, err)
|
|
results <- checkResult{idx: idx, reachable: false}
|
|
return
|
|
}
|
|
if busy {
|
|
printer.Info("Instance %s (%s) is busy (osmedeus running)", res.Name, terminal.Yellow(res.PublicIP))
|
|
} else {
|
|
printer.Info("Instance %s (%s) is idle", res.Name, terminal.Green(res.PublicIP))
|
|
}
|
|
results <- checkResult{idx: idx, reachable: true, idle: !busy}
|
|
}(i, c.resource)
|
|
}
|
|
|
|
// Collect results
|
|
for range candidates {
|
|
r := <-results
|
|
candidates[r.idx].reachable = r.reachable
|
|
candidates[r.idx].idle = r.idle
|
|
}
|
|
|
|
// Build merged infrastructure: idle instances first, then busy ones
|
|
var idleResources, busyResources []cloud.Resource
|
|
for _, c := range candidates {
|
|
if !c.reachable {
|
|
continue
|
|
}
|
|
if c.idle {
|
|
idleResources = append(idleResources, c.resource)
|
|
} else {
|
|
busyResources = append(busyResources, c.resource)
|
|
}
|
|
}
|
|
|
|
allReady := append(idleResources, busyResources...)
|
|
if len(allReady) == 0 {
|
|
return nil, fmt.Errorf("no reachable instances found in saved infrastructure")
|
|
}
|
|
|
|
return &cloud.Infrastructure{
|
|
ID: "reuse-discovered",
|
|
Resources: allReady,
|
|
Metadata: allInfras[0].Metadata, // inherit metadata from first infra for SSH user etc.
|
|
}, nil
|
|
}
|
|
|
|
// resolveReuseWithInstances resolves comma-separated IPs/names against saved state,
|
|
// falling back to ad-hoc resources for unrecognized identifiers.
|
|
func resolveReuseWithInstances(identifiers []string, statePath string, auth cloudSSHAuth) *cloud.Infrastructure {
|
|
// Load all saved infras to match against
|
|
allInfras, _ := cloud.ListInfrastructures(statePath)
|
|
|
|
// Build lookup maps from saved state
|
|
ipToResource := make(map[string]cloud.Resource)
|
|
nameToResource := make(map[string]cloud.Resource)
|
|
for _, inf := range allInfras {
|
|
for _, res := range inf.Resources {
|
|
if res.PublicIP != "" {
|
|
ipToResource[res.PublicIP] = res
|
|
}
|
|
if res.Name != "" {
|
|
nameToResource[res.Name] = res
|
|
}
|
|
}
|
|
}
|
|
|
|
var resources []cloud.Resource
|
|
seen := make(map[string]bool)
|
|
|
|
for _, id := range identifiers {
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
|
|
// Try matching by IP first, then by name
|
|
if res, ok := ipToResource[id]; ok {
|
|
printer.Info("Matched %s from saved state (%s)", terminal.Cyan(id), res.Name)
|
|
resources = append(resources, res)
|
|
} else if res, ok := nameToResource[id]; ok {
|
|
printer.Info("Matched %s from saved state (%s)", terminal.Cyan(id), res.PublicIP)
|
|
resources = append(resources, res)
|
|
} else {
|
|
// Treat as ad-hoc IP
|
|
printer.Info("Using %s as ad-hoc instance", terminal.Yellow(id))
|
|
resources = append(resources, cloud.Resource{
|
|
Name: fmt.Sprintf("adhoc-%s", id),
|
|
PublicIP: id,
|
|
Type: "vm",
|
|
Status: "running",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Inherit metadata from first saved infra if available
|
|
var metadata map[string]interface{}
|
|
if len(allInfras) > 0 {
|
|
metadata = allInfras[0].Metadata
|
|
}
|
|
|
|
return &cloud.Infrastructure{
|
|
ID: "reuse-specified",
|
|
Resources: resources,
|
|
Metadata: metadata,
|
|
}
|
|
}
|
|
|
|
// splitTargetsForWorkers divides targets into contiguous chunks, one per worker.
|
|
func splitTargetsForWorkers(allTargets []string, workerCount int) [][]string {
|
|
total := len(allTargets)
|
|
if total == 0 || workerCount <= 0 {
|
|
return nil
|
|
}
|
|
chunkSize := (total + workerCount - 1) / workerCount
|
|
chunks := make([][]string, workerCount)
|
|
for i := 0; i < workerCount; i++ {
|
|
start := i * chunkSize
|
|
if start >= total {
|
|
break
|
|
}
|
|
end := start + chunkSize
|
|
if end > total {
|
|
end = total
|
|
}
|
|
chunks[i] = allTargets[start:end]
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
// workerTask holds the per-worker command and metadata for parallel scan execution.
|
|
type workerTask struct {
|
|
resource cloud.Resource
|
|
osmCmd string
|
|
chunkInfo string // e.g. "5 targets"
|
|
targets []string // targets assigned to this worker (for sync-back)
|
|
}
|
|
|
|
func setupWorkerViaSSHAuth(auth cloudSSHAuth, host string, commands []string) error {
|
|
p := terminal.NewPrinter()
|
|
|
|
// Filter out comments and empty lines
|
|
var cmds []string
|
|
for _, cmd := range commands {
|
|
if strings.TrimSpace(cmd) == "" || strings.HasPrefix(strings.TrimSpace(cmd), "#") {
|
|
continue
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
if len(cmds) == 0 {
|
|
p.Info("No setup commands configured — skipping worker setup")
|
|
p.Info("Configure via: %s", terminal.Gray("osmedeus cloud config set setup.commands.add \"<command>\""))
|
|
return nil
|
|
}
|
|
|
|
p.Info("Running %d setup commands on %s...", len(cmds), terminal.Cyan(host))
|
|
|
|
// PATH prefix so osmedeus and tools are found in non-interactive shells
|
|
envPrefix := "export DEBIAN_FRONTEND=noninteractive && export PATH=$HOME/.local/bin:$HOME/osmedeus-base/external-binaries:$HOME/go/bin:/usr/local/go/bin:$PATH"
|
|
|
|
for i, cmd := range cmds {
|
|
p.Info(" [%d/%d] %s %s", i+1, len(cmds), terminal.Gray("$"), terminal.Cyan(cmd))
|
|
fullCmd := fmt.Sprintf("%s && %s", envPrefix, cmd)
|
|
if cloudVerboseSetup {
|
|
if err := runSSHCommandStreamingAuth(auth, host, fullCmd); err != nil {
|
|
p.Warning(" Command failed (%v)", err)
|
|
}
|
|
} else {
|
|
if _, err := runSSHCommandAuth(auth, host, fullCmd); err != nil {
|
|
p.Warning(" Command failed (%v)", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
p.Success("Setup complete on %s", terminal.Cyan(host))
|
|
return nil
|
|
}
|
|
|
|
// expandPostCommandVars replaces template variables in a post-command string
|
|
func expandPostCommandVars(cmd string, vars map[string]string) string {
|
|
for k, v := range vars {
|
|
cmd = strings.ReplaceAll(cmd, "{{"+k+"}}", v)
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func runPostCommandsAuth(auth cloudSSHAuth, host string, commands []string, vars map[string]string, verbose bool) {
|
|
p := terminal.NewPrinter()
|
|
|
|
var cmds []string
|
|
for _, cmd := range commands {
|
|
if strings.TrimSpace(cmd) == "" || strings.HasPrefix(strings.TrimSpace(cmd), "#") {
|
|
continue
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
if len(cmds) == 0 {
|
|
return
|
|
}
|
|
|
|
envPrefix := "export DEBIAN_FRONTEND=noninteractive && export PATH=$HOME/.local/bin:$HOME/osmedeus-base/external-binaries:$HOME/go/bin:/usr/local/go/bin:$PATH"
|
|
|
|
p.Info("Running %d post-setup commands on %s...", len(cmds), terminal.Cyan(host))
|
|
for i, cmd := range cmds {
|
|
expanded := expandPostCommandVars(cmd, vars)
|
|
p.Info(" [%d/%d] %s %s", i+1, len(cmds), terminal.Gray("$"), terminal.Cyan(expanded))
|
|
fullCmd := fmt.Sprintf("%s && %s", envPrefix, expanded)
|
|
if verbose {
|
|
if err := runSSHCommandStreamingAuth(auth, host, fullCmd); err != nil {
|
|
p.Warning(" Post-command failed (%v)", err)
|
|
}
|
|
} else {
|
|
if _, err := runSSHCommandAuth(auth, host, fullCmd); err != nil {
|
|
p.Warning(" Post-command failed (%v)", err)
|
|
}
|
|
}
|
|
}
|
|
p.Success("Post-setup complete on %s", terminal.Cyan(host))
|
|
}
|
|
|
|
// executeCustomCommands runs custom commands on all workers in parallel.
|
|
// Each worker runs --custom-cmd commands sequentially, stopping on first failure.
|
|
// If all custom-cmds succeed, --custom-post-cmd commands run in order.
|
|
func executeCustomCommands(
|
|
ctx context.Context,
|
|
sshAuth cloudSSHAuth,
|
|
sshUser string,
|
|
readyWorkers []cloud.Resource,
|
|
infra *cloud.Infrastructure,
|
|
cloudCfg *config.CloudConfigs,
|
|
) ([]workerTask, []error) {
|
|
printer := terminal.NewPrinter()
|
|
|
|
// --- Target distribution ---
|
|
type customWorkerCtx struct {
|
|
resource cloud.Resource
|
|
targetVal string // value for {{Target}}
|
|
index int
|
|
}
|
|
var workerCtxs []customWorkerCtx
|
|
|
|
if cloudTargetFile != "" {
|
|
allTargets, readErr := readTargetsFromFile(cloudTargetFile)
|
|
if readErr != nil {
|
|
printer.Warning("Failed to read target file: %v", readErr)
|
|
return nil, []error{readErr}
|
|
}
|
|
if len(allTargets) == 0 {
|
|
printer.Warning("Target file %s is empty", cloudTargetFile)
|
|
return nil, []error{fmt.Errorf("target file is empty")}
|
|
}
|
|
|
|
if cloudChunkSize > 0 && cloudChunkCount > 0 {
|
|
return nil, []error{fmt.Errorf("--chunk-size and --chunk-count are mutually exclusive")}
|
|
}
|
|
|
|
effectiveWorkers := len(readyWorkers)
|
|
if cloudChunkCount > 0 {
|
|
if cloudChunkCount > len(readyWorkers) {
|
|
effectiveWorkers = len(readyWorkers)
|
|
} else {
|
|
effectiveWorkers = cloudChunkCount
|
|
}
|
|
} else if cloudChunkSize > 0 {
|
|
needed := (len(allTargets) + cloudChunkSize - 1) / cloudChunkSize
|
|
if needed > len(readyWorkers) {
|
|
effectiveWorkers = len(readyWorkers)
|
|
} else {
|
|
effectiveWorkers = needed
|
|
}
|
|
}
|
|
if len(allTargets) < effectiveWorkers {
|
|
effectiveWorkers = len(allTargets)
|
|
}
|
|
|
|
chunks := splitTargetsForWorkers(allTargets, effectiveWorkers)
|
|
|
|
printer.Section("Distributing Targets")
|
|
printer.Info("Total targets: %d, Workers: %d", len(allTargets), effectiveWorkers)
|
|
for i := 0; i < effectiveWorkers; i++ {
|
|
chunk := chunks[i]
|
|
if len(chunk) == 0 {
|
|
continue
|
|
}
|
|
|
|
worker := readyWorkers[i]
|
|
uid := uuid.New().String()[:8]
|
|
localTmp := filepath.Join(os.TempDir(), fmt.Sprintf("osm-cloud-targets-%s-%d.txt", uid, i))
|
|
if writeErr := os.WriteFile(localTmp, []byte(strings.Join(chunk, "\n")+"\n"), 0644); writeErr != nil {
|
|
printer.Warning("Failed to write temp target file: %v", writeErr)
|
|
continue
|
|
}
|
|
remotePath := fmt.Sprintf("/tmp/osm-targets-%d.txt", i)
|
|
printer.Info("Uploading %d targets to %s (%s)", len(chunk), worker.Name, terminal.Cyan(worker.PublicIP))
|
|
if scpErr := scpFileToRemote(sshAuth.KeyPath, sshAuth.User, worker.PublicIP, localTmp, remotePath); scpErr != nil {
|
|
_ = os.Remove(localTmp)
|
|
printer.Warning("Failed to SCP targets to %s: %v", worker.Name, scpErr)
|
|
continue
|
|
}
|
|
_ = os.Remove(localTmp)
|
|
|
|
workerCtxs = append(workerCtxs, customWorkerCtx{
|
|
resource: worker,
|
|
targetVal: remotePath,
|
|
index: i,
|
|
})
|
|
}
|
|
} else {
|
|
// Single target: same for all workers
|
|
for i, res := range readyWorkers {
|
|
workerCtxs = append(workerCtxs, customWorkerCtx{
|
|
resource: res,
|
|
targetVal: cloudTarget,
|
|
index: i,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(workerCtxs) == 0 {
|
|
printer.Warning("No workers to run custom commands on")
|
|
return nil, nil
|
|
}
|
|
|
|
// --- Run commands in parallel across workers ---
|
|
printer.Section("Running Custom Commands")
|
|
|
|
tasks := make([]workerTask, len(workerCtxs))
|
|
scanErrors := make([]error, len(workerCtxs))
|
|
var wg sync.WaitGroup
|
|
|
|
pathSetup := "export PATH=$HOME/.local/bin:$HOME/osmedeus-base/external-binaries:$HOME/go/bin:/usr/local/go/bin:$PATH"
|
|
workdirSetup := "mkdir -p /tmp/osm-custom && cd /tmp/osm-custom"
|
|
|
|
for i, wCtx := range workerCtxs {
|
|
tasks[i] = workerTask{
|
|
resource: wCtx.resource,
|
|
targets: []string{wCtx.targetVal},
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func(idx int, wc customWorkerCtx) {
|
|
defer wg.Done()
|
|
label := wc.resource.Name
|
|
|
|
// Build template vars
|
|
vars := map[string]string{
|
|
"Target": wc.targetVal,
|
|
"public_ip": wc.resource.PublicIP,
|
|
"private_ip": wc.resource.PrivateIP,
|
|
"worker_name": wc.resource.Name,
|
|
"worker_id": wc.resource.ID,
|
|
"infra_id": infra.ID,
|
|
"provider": string(infra.Provider),
|
|
"ssh_user": sshUser,
|
|
"index": fmt.Sprintf("%d", wc.index),
|
|
}
|
|
|
|
// Run each --custom-cmd in order, stop on first failure
|
|
cmdFailed := false
|
|
for ci, rawCmd := range cloudCustomCmds {
|
|
expanded := expandPostCommandVars(rawCmd, vars)
|
|
printer.Info("[%s] custom-cmd [%d/%d]: %s", label, ci+1, len(cloudCustomCmds), terminal.Cyan(expanded))
|
|
fullCmd := fmt.Sprintf("%s && %s && %s", pathSetup, workdirSetup, expanded)
|
|
if err := runSSHCommandStreamingAuth(sshAuth, wc.resource.PublicIP, fullCmd, label); err != nil {
|
|
scanErrors[idx] = fmt.Errorf("custom-cmd %d failed on %s: %w", ci+1, label, err)
|
|
printer.Warning("[%s] custom-cmd %d failed: %v — skipping remaining commands", label, ci+1, err)
|
|
cmdFailed = true
|
|
break
|
|
}
|
|
printer.Success("[%s] custom-cmd %d completed", label, ci+1)
|
|
}
|
|
|
|
// Run --custom-post-cmd only if all custom-cmds succeeded
|
|
if !cmdFailed && len(cloudCustomPostCmds) > 0 {
|
|
for pi, rawCmd := range cloudCustomPostCmds {
|
|
expanded := expandPostCommandVars(rawCmd, vars)
|
|
printer.Info("[%s] post-cmd [%d/%d]: %s", label, pi+1, len(cloudCustomPostCmds), terminal.Cyan(expanded))
|
|
fullCmd := fmt.Sprintf("%s && %s && %s", pathSetup, workdirSetup, expanded)
|
|
if err := runSSHCommandStreamingAuth(sshAuth, wc.resource.PublicIP, fullCmd, label); err != nil {
|
|
printer.Warning("[%s] post-cmd %d failed: %v", label, pi+1, err)
|
|
} else {
|
|
printer.Success("[%s] post-cmd %d completed", label, pi+1)
|
|
}
|
|
}
|
|
}
|
|
}(i, wCtx)
|
|
}
|
|
|
|
wg.Wait()
|
|
return tasks, scanErrors
|
|
}
|
|
|
|
// syncCustomPaths downloads specified remote paths from each worker to local disk.
|
|
// Local layout: <syncDest>/<workerName>-<ip>/<relativePath>
|
|
func syncCustomPaths(
|
|
sshAuth cloudSSHAuth,
|
|
workers []cloud.Resource,
|
|
infra *cloud.Infrastructure,
|
|
sshUser string,
|
|
syncPaths []string,
|
|
syncDest string,
|
|
) {
|
|
printer := terminal.NewPrinter()
|
|
printer.Section("Syncing Custom Paths")
|
|
|
|
ctx := context.Background()
|
|
for i, res := range workers {
|
|
workerDir := fmt.Sprintf("%s-%s", res.Name, res.PublicIP)
|
|
localBase := filepath.Join(syncDest, workerDir)
|
|
|
|
client, err := sshAuth.connect(ctx, res.PublicIP)
|
|
if err != nil {
|
|
printer.Warning("SSH connect failed for %s: %v", res.Name, err)
|
|
continue
|
|
}
|
|
|
|
// Build template vars for expanding sync paths
|
|
vars := map[string]string{
|
|
"Target": cloudTarget,
|
|
"public_ip": res.PublicIP,
|
|
"private_ip": res.PrivateIP,
|
|
"worker_name": res.Name,
|
|
"worker_id": res.ID,
|
|
"infra_id": infra.ID,
|
|
"provider": string(infra.Provider),
|
|
"ssh_user": sshUser,
|
|
"index": fmt.Sprintf("%d", i),
|
|
}
|
|
|
|
for _, rawPath := range syncPaths {
|
|
remotePath := expandPostCommandVars(rawPath, vars)
|
|
|
|
// Determine local destination preserving remote path structure
|
|
localPath := filepath.Join(localBase, remotePath)
|
|
|
|
// Check if remote path is a file or directory
|
|
checkCmd := fmt.Sprintf("test -d '%s' && echo DIR || (test -f '%s' && echo FILE || echo MISSING)", remotePath, remotePath)
|
|
out, _, _ := client.RunCommand(ctx, checkCmd)
|
|
pathType := strings.TrimSpace(out)
|
|
|
|
switch pathType {
|
|
case "DIR":
|
|
printer.Info("Downloading dir %s from %s...", terminal.Bold(remotePath), terminal.Cyan(res.PublicIP))
|
|
if err := client.DownloadDir(remotePath, localPath); err != nil {
|
|
printer.Warning("Failed to download dir %s from %s: %v", remotePath, res.Name, err)
|
|
} else {
|
|
printer.Success("Downloaded %s → %s", remotePath, terminal.Gray(localPath))
|
|
}
|
|
case "FILE":
|
|
printer.Info("Downloading file %s from %s...", terminal.Bold(remotePath), terminal.Cyan(res.PublicIP))
|
|
if mkErr := os.MkdirAll(filepath.Dir(localPath), 0755); mkErr != nil {
|
|
printer.Warning("Failed to create local dir for %s: %v", localPath, mkErr)
|
|
continue
|
|
}
|
|
if err := client.DownloadFile(remotePath, localPath); err != nil {
|
|
printer.Warning("Failed to download file %s from %s: %v", remotePath, res.Name, err)
|
|
} else {
|
|
printer.Success("Downloaded %s → %s", remotePath, terminal.Gray(localPath))
|
|
}
|
|
default:
|
|
printer.Warning("Path %s not found on %s", remotePath, res.Name)
|
|
}
|
|
}
|
|
|
|
client.Close()
|
|
}
|
|
|
|
printer.Success("Sync complete → %s", terminal.Bold(syncDest))
|
|
}
|
|
|
|
// runAnsibleSetup runs an ansible playbook against all workers.
|
|
// It generates a dynamic inventory file, then runs ansible-playbook locally.
|
|
func runAnsibleSetup(ansibleCfg *config.AnsibleSetup, workers []cloud.Resource, auth cloudSSHAuth) error {
|
|
p := terminal.NewPrinter()
|
|
|
|
// Check ansible-playbook is installed
|
|
if _, err := execPkg.LookPath("ansible-playbook"); err != nil {
|
|
return fmt.Errorf("ansible-playbook not found in PATH — install ansible first")
|
|
}
|
|
|
|
// Check playbook exists
|
|
if _, err := os.Stat(ansibleCfg.PlaybookPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("playbook not found: %s", ansibleCfg.PlaybookPath)
|
|
}
|
|
|
|
// Generate inventory file
|
|
inventoryPath := ansibleCfg.InventoryPath
|
|
if err := os.MkdirAll(filepath.Dir(inventoryPath), 0755); err != nil {
|
|
return fmt.Errorf("failed to create inventory directory: %w", err)
|
|
}
|
|
|
|
sshUser := auth.User
|
|
if sshUser == "" {
|
|
sshUser = "root"
|
|
}
|
|
|
|
var inv strings.Builder
|
|
inv.WriteString("[osmedeus_workers]\n")
|
|
for _, w := range workers {
|
|
if w.PublicIP == "" {
|
|
continue
|
|
}
|
|
hostLine := fmt.Sprintf("%s ansible_user=%s", w.PublicIP, sshUser)
|
|
// Key-based auth
|
|
if auth.KeyPath != "" {
|
|
hostLine += fmt.Sprintf(" ansible_ssh_private_key_file=%s", auth.KeyPath)
|
|
}
|
|
// Password auth
|
|
if auth.Password != "" {
|
|
hostLine += fmt.Sprintf(" ansible_ssh_pass=%s", auth.Password)
|
|
}
|
|
// Custom port
|
|
if auth.Port != "" && auth.Port != "22" {
|
|
hostLine += fmt.Sprintf(" ansible_port=%s", auth.Port)
|
|
}
|
|
inv.WriteString(hostLine + "\n")
|
|
}
|
|
inv.WriteString("\n[osmedeus_workers:vars]\n")
|
|
inv.WriteString("ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR'\n")
|
|
|
|
if err := os.WriteFile(inventoryPath, []byte(inv.String()), 0644); err != nil {
|
|
return fmt.Errorf("failed to write inventory: %w", err)
|
|
}
|
|
p.Info("Inventory written: %s (%d workers)", terminal.Gray(inventoryPath), len(workers))
|
|
|
|
// Build ansible-playbook command
|
|
args := []string{"-i", inventoryPath, ansibleCfg.PlaybookPath}
|
|
|
|
// Add extra vars
|
|
for k, v := range ansibleCfg.ExtraVars {
|
|
args = append(args, "--extra-vars", fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
|
|
// Add extra args
|
|
if ansibleCfg.ExtraArgs != "" {
|
|
args = append(args, strings.Fields(ansibleCfg.ExtraArgs)...)
|
|
}
|
|
|
|
p.Info("Running: %s %s", terminal.Gray("$"), terminal.Cyan("ansible-playbook "+strings.Join(args, " ")))
|
|
p.Divider()
|
|
|
|
cmd := execPkg.Command("ansible-playbook", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Env = os.Environ()
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("ansible-playbook failed: %w", err)
|
|
}
|
|
|
|
p.Divider()
|
|
p.Success("Ansible setup complete")
|
|
return nil
|
|
}
|
|
|
|
// ensureCloudInfraPresets copies the default cloud-infra preset files if they don't exist
|
|
func ensureCloudInfraPresets(baseFolder string) {
|
|
infraDir := filepath.Join(baseFolder, "cloud-infra")
|
|
if err := os.MkdirAll(infraDir, 0755); err != nil {
|
|
return
|
|
}
|
|
|
|
// Copy playbook if missing
|
|
playbookPath := filepath.Join(infraDir, "setup-playbook.yaml")
|
|
if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
|
|
data, readErr := public.EmbedFS.ReadFile("presets/cloud-infra/setup-playbook.yaml")
|
|
if readErr == nil {
|
|
_ = os.WriteFile(playbookPath, data, 0644)
|
|
}
|
|
}
|
|
|
|
// Copy inventory example if missing
|
|
examplePath := filepath.Join(infraDir, "inventory.ini.example")
|
|
if _, err := os.Stat(examplePath); os.IsNotExist(err) {
|
|
data, readErr := public.EmbedFS.ReadFile("presets/cloud-infra/inventory.ini.example")
|
|
if readErr == nil {
|
|
_ = os.WriteFile(examplePath, data, 0644)
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitForSSH waits for SSH (port 22) to become reachable on the given host
|
|
// syncWorkspaceBack downloads a workspace from a remote worker and imports it locally.
|
|
// It runs `osmedeus snapshot export` on the remote, downloads the ZIP via SFTP,
|
|
// then imports using the existing snapshot import (which handles path differences and DB replay).
|
|
func syncWorkspaceBack(auth cloudSSHAuth, host, target string, cfg *config.Config) error {
|
|
p := terminal.NewPrinter()
|
|
|
|
ctx := context.Background()
|
|
client, err := auth.connect(ctx, host)
|
|
if err != nil {
|
|
return fmt.Errorf("SSH connect failed: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
pathSetup := "export PATH=$HOME/.local/bin:$HOME/osmedeus-base/external-binaries:$HOME/go/bin:/usr/local/go/bin:$PATH"
|
|
remoteZip := fmt.Sprintf("/tmp/%s.zip", target)
|
|
|
|
// Step 1: Export workspace on remote
|
|
p.Info(" Exporting workspace on %s for target %s...", terminal.Cyan(host), terminal.Bold(target))
|
|
exportCmd := fmt.Sprintf("%s && osmedeus snapshot export %s -o %s", pathSetup, target, remoteZip)
|
|
out, exitCode, runErr := client.RunCommand(ctx, exportCmd)
|
|
if runErr != nil || exitCode != 0 {
|
|
return fmt.Errorf("remote snapshot export failed (exit %d): %s", exitCode, strings.TrimSpace(out))
|
|
}
|
|
|
|
// Step 2: Download ZIP via SFTP
|
|
// Resolve the actual remote path (~ is expanded by the shell, but SFTP needs absolute)
|
|
resolveCmd := fmt.Sprintf("echo %s", remoteZip)
|
|
resolvedPath, _, _ := client.RunCommand(ctx, resolveCmd)
|
|
resolvedPath = strings.TrimSpace(resolvedPath)
|
|
if resolvedPath == "" {
|
|
resolvedPath = remoteZip
|
|
}
|
|
|
|
localZip := filepath.Join(os.TempDir(), fmt.Sprintf("%s.zip", target))
|
|
p.Info(" Downloading %s from %s...", terminal.Gray(target+".zip"), terminal.Cyan(host))
|
|
if dlErr := client.DownloadFile(resolvedPath, localZip); dlErr != nil {
|
|
return fmt.Errorf("SFTP download failed: %w", dlErr)
|
|
}
|
|
defer func() { _ = os.Remove(localZip) }()
|
|
|
|
// Step 3: Import locally
|
|
p.Info(" Importing workspace %s locally...", terminal.Bold(target))
|
|
importResult, importErr := snapshot.ForceImportWorkspace(localZip, cfg.WorkspacesPath, false, cfg)
|
|
if importErr != nil {
|
|
return fmt.Errorf("local import failed: %w", importErr)
|
|
}
|
|
p.Success(" Imported %s → %s", terminal.Bold(target), terminal.Gray(importResult.LocalPath))
|
|
|
|
// Step 4: Clean up remote ZIP
|
|
_, _, _ = client.RunCommand(ctx, "rm -f "+remoteZip)
|
|
|
|
return nil
|
|
}
|
|
|
|
func waitForSSHPort(host, port string, timeout time.Duration) error {
|
|
if port == "" {
|
|
port = "22"
|
|
}
|
|
addr := host + ":" + port
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
return nil
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return fmt.Errorf("SSH not ready on %s after %s", addr, timeout)
|
|
}
|
|
|
|
// cloudSetupCmd sets up a remote machine without provisioning
|
|
var cloudSetupCmd = &cobra.Command{
|
|
Use: "setup <ip> [ip2] [ip3] ...",
|
|
Short: "Setup osmedeus on existing remote machines",
|
|
Long: terminal.BoldCyan("◆ Description") + `
|
|
Run setup commands on existing remote machines via SSH.
|
|
Uses the same SSH key, user, and setup.commands from cloud-settings.yaml
|
|
but skips cloud provisioning. Useful for VMs you already have.
|
|
|
|
` + terminal.BoldCyan("▷ Examples") + `
|
|
# Setup a single machine
|
|
` + terminal.Green("osmedeus cloud setup 1.2.3.4") + `
|
|
|
|
# Setup multiple machines
|
|
` + terminal.Green("osmedeus cloud setup 1.2.3.4 5.6.7.8 9.10.11.12") + `
|
|
|
|
# Show full setup output
|
|
` + terminal.Green("osmedeus cloud setup 1.2.3.4 --verbose-setup") + `
|
|
|
|
# Use ansible playbook for setup
|
|
` + terminal.Green("osmedeus cloud setup 1.2.3.4 5.6.7.8 --ansible") + `
|
|
|
|
# Then run a scan using the setup machines
|
|
` + terminal.Green("osmedeus cloud run -f fast -t example.com --reuse") + `
|
|
`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return errConfigNotLoaded
|
|
}
|
|
|
|
// Load cloud config
|
|
configPath := cfg.Cloud.CloudSettings
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
if err := ensureCloudConfig(configPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
cloudCfg, err := cloud.LoadCloudConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load cloud config: %w", err)
|
|
}
|
|
cloud.ResolveTemplatePaths(cloudCfg, cfg.BaseFolder)
|
|
|
|
// SSH config
|
|
sshAuth := cloudSSHAuthFromConfig(cloudCfg)
|
|
sshUser := sshAuth.User
|
|
if sshUser == "" {
|
|
sshUser = "root"
|
|
sshAuth.User = sshUser
|
|
}
|
|
|
|
// Build worker list from args
|
|
var workers []cloud.Resource
|
|
for i, ip := range args {
|
|
workers = append(workers, cloud.Resource{
|
|
Type: "vm",
|
|
Name: fmt.Sprintf("remote-%d", i),
|
|
PublicIP: ip,
|
|
Status: "active",
|
|
})
|
|
}
|
|
|
|
printer.Section("Setting Up Remote Machines")
|
|
printer.KeyValue("Machines", fmt.Sprintf("%d", len(workers)))
|
|
printer.KeyValue("SSH User", sshUser)
|
|
for _, w := range workers {
|
|
printer.Bullet(terminal.Cyan(w.PublicIP))
|
|
}
|
|
printer.Newline()
|
|
|
|
// Wait for SSH on all
|
|
var readyWorkers []cloud.Resource
|
|
for _, w := range workers {
|
|
printer.Info("Waiting for SSH on %s...", terminal.Cyan(w.PublicIP))
|
|
if waitErr := waitForSSHPort(w.PublicIP, sshAuth.Port, 2*time.Minute); waitErr != nil {
|
|
printer.Warning("SSH not ready on %s: %v", w.PublicIP, waitErr)
|
|
continue
|
|
}
|
|
printer.Success("SSH ready on %s", terminal.Cyan(w.PublicIP))
|
|
readyWorkers = append(readyWorkers, w)
|
|
}
|
|
|
|
if len(readyWorkers) == 0 {
|
|
return fmt.Errorf("no machines are reachable via SSH")
|
|
}
|
|
|
|
// Run ansible or SSH commands
|
|
if cloudCfg.Setup.Ansible.Enabled || cloudUseAnsible {
|
|
printer.Section("Running Ansible Setup")
|
|
ensureCloudInfraPresets(cfg.BaseFolder)
|
|
if ansibleErr := runAnsibleSetup(&cloudCfg.Setup.Ansible, readyWorkers, sshAuth); ansibleErr != nil {
|
|
printer.Warning("Ansible setup failed: %v", ansibleErr)
|
|
printer.Info("Falling back to SSH-based setup commands...")
|
|
for _, w := range readyWorkers {
|
|
if err := setupWorkerViaSSHAuth(sshAuth, w.PublicIP, cloudCfg.Setup.Commands); err != nil {
|
|
printer.Warning("Setup failed on %s: %v", w.PublicIP, err)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for _, w := range readyWorkers {
|
|
if setupErr := setupWorkerViaSSHAuth(sshAuth, w.PublicIP, cloudCfg.Setup.Commands); setupErr != nil {
|
|
printer.Warning("Setup failed on %s: %v", w.PublicIP, setupErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run post-setup commands
|
|
if len(cloudCfg.Setup.PostCommands) > 0 {
|
|
for i, w := range readyWorkers {
|
|
postVars := map[string]string{
|
|
"public_ip": w.PublicIP,
|
|
"private_ip": w.PrivateIP,
|
|
"worker_name": w.Name,
|
|
"worker_id": w.ID,
|
|
"infra_id": "remote-adhoc",
|
|
"provider": "remote-adhoc",
|
|
"ssh_user": sshUser,
|
|
"index": fmt.Sprintf("%d", i),
|
|
}
|
|
runPostCommandsAuth(sshAuth, w.PublicIP, cloudCfg.Setup.PostCommands, postVars, cloudVerboseSetup)
|
|
}
|
|
}
|
|
|
|
// Save as remote-adhoc infrastructure state so `cloud ls` and `cloud destroy` can see it
|
|
infraID := fmt.Sprintf("remote-adhoc-%d", time.Now().Unix())
|
|
infra := &cloud.Infrastructure{
|
|
ID: infraID,
|
|
Provider: "remote-adhoc",
|
|
Mode: cloud.ModeVM,
|
|
CreatedAt: time.Now(),
|
|
Resources: readyWorkers,
|
|
Metadata: map[string]interface{}{
|
|
"ssh_user": sshUser,
|
|
},
|
|
}
|
|
if err := cloud.SaveInfrastructureState(infra, cloudCfg.State.Path); err != nil {
|
|
printer.Warning("Failed to save state: %v", err)
|
|
}
|
|
|
|
printer.Newline()
|
|
printer.Divider()
|
|
printer.Success("Setup complete")
|
|
printer.KeyValueColored("Infrastructure", infraID, terminal.BoldGreen)
|
|
for _, w := range readyWorkers {
|
|
printer.KeyValueColored(w.Name, w.PublicIP, terminal.Cyan)
|
|
}
|
|
printer.Divider()
|
|
printer.Newline()
|
|
printer.Info("Run a scan: %s", terminal.Gray("osmedeus cloud run -f fast -t example.com --reuse"))
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// setCloudConfigValue sets a nested config value using dot notation
|
|
func setCloudConfigValue(cfg *config.CloudConfigs, key, value string) error {
|
|
parts := strings.Split(key, ".")
|
|
if len(parts) < 2 {
|
|
return fmt.Errorf("invalid key format. Use dot notation (e.g., defaults.provider)")
|
|
}
|
|
|
|
// Simple implementation for common keys
|
|
switch parts[0] {
|
|
case "defaults":
|
|
switch parts[1] {
|
|
case "provider":
|
|
cfg.Defaults.Provider = value
|
|
case "mode":
|
|
cfg.Defaults.Mode = value
|
|
case "max_instances":
|
|
var val int
|
|
if _, err := fmt.Sscanf(value, "%d", &val); err != nil {
|
|
return fmt.Errorf("invalid integer value: %s", value)
|
|
}
|
|
cfg.Defaults.MaxInstances = val
|
|
case "use_spot":
|
|
cfg.Defaults.UseSpot = (value == "true")
|
|
case "timeout":
|
|
cfg.Defaults.Timeout = value
|
|
case "cleanup_on_failure":
|
|
cfg.Defaults.CleanupOnFailure = (value == "true")
|
|
default:
|
|
return fmt.Errorf("unknown key: %s", key)
|
|
}
|
|
|
|
case "providers":
|
|
if len(parts) < 3 {
|
|
return fmt.Errorf("provider key requires 3 parts (e.g., providers.digitalocean.token)")
|
|
}
|
|
switch parts[1] {
|
|
case "digitalocean":
|
|
switch parts[2] {
|
|
case "token":
|
|
cfg.Providers.DigitalOcean.Token = value
|
|
case "region":
|
|
cfg.Providers.DigitalOcean.Region = value
|
|
case "size":
|
|
cfg.Providers.DigitalOcean.Size = value
|
|
case "image":
|
|
cfg.Providers.DigitalOcean.Image = value
|
|
case "snapshot_id":
|
|
cfg.Providers.DigitalOcean.SnapshotID = value
|
|
case "ssh_key_id":
|
|
cfg.Providers.DigitalOcean.SSHKeyID = value
|
|
case "ssh_key_fingerprint":
|
|
cfg.Providers.DigitalOcean.SSHKeyFingerprint = value
|
|
default:
|
|
return fmt.Errorf("unknown DigitalOcean key: %s", parts[2])
|
|
}
|
|
case "aws":
|
|
switch parts[2] {
|
|
case "access_key_id":
|
|
cfg.Providers.AWS.AccessKeyID = value
|
|
case "secret_access_key":
|
|
cfg.Providers.AWS.SecretAccessKey = value
|
|
case "region":
|
|
cfg.Providers.AWS.Region = value
|
|
case "instance_type":
|
|
cfg.Providers.AWS.InstanceType = value
|
|
case "ami":
|
|
cfg.Providers.AWS.AMI = value
|
|
case "ami_filter":
|
|
cfg.Providers.AWS.AMIFilter = value
|
|
case "use_spot":
|
|
cfg.Providers.AWS.UseSpot = (value == "true")
|
|
default:
|
|
return fmt.Errorf("unknown AWS key: %s", parts[2])
|
|
}
|
|
case "gcp":
|
|
switch parts[2] {
|
|
case "project_id":
|
|
cfg.Providers.GCP.ProjectID = value
|
|
case "credentials_file":
|
|
cfg.Providers.GCP.CredentialsFile = value
|
|
case "region":
|
|
cfg.Providers.GCP.Region = value
|
|
case "zone":
|
|
cfg.Providers.GCP.Zone = value
|
|
case "machine_type":
|
|
cfg.Providers.GCP.MachineType = value
|
|
case "image_family":
|
|
cfg.Providers.GCP.ImageFamily = value
|
|
case "use_preemptible":
|
|
cfg.Providers.GCP.UsePreemptible = (value == "true")
|
|
default:
|
|
return fmt.Errorf("unknown GCP key: %s", parts[2])
|
|
}
|
|
case "linode":
|
|
switch parts[2] {
|
|
case "token":
|
|
cfg.Providers.Linode.Token = value
|
|
case "region":
|
|
cfg.Providers.Linode.Region = value
|
|
case "type":
|
|
cfg.Providers.Linode.Type = value
|
|
case "image":
|
|
cfg.Providers.Linode.Image = value
|
|
case "ssh_public_key":
|
|
cfg.Providers.Linode.SSHPublicKey = value
|
|
default:
|
|
return fmt.Errorf("unknown Linode key: %s", parts[2])
|
|
}
|
|
case "azure":
|
|
switch parts[2] {
|
|
case "subscription_id":
|
|
cfg.Providers.Azure.SubscriptionID = value
|
|
case "tenant_id":
|
|
cfg.Providers.Azure.TenantID = value
|
|
case "client_id":
|
|
cfg.Providers.Azure.ClientID = value
|
|
case "client_secret":
|
|
cfg.Providers.Azure.ClientSecret = value
|
|
case "location":
|
|
cfg.Providers.Azure.Location = value
|
|
case "vm_size":
|
|
cfg.Providers.Azure.VMSize = value
|
|
case "image_reference":
|
|
cfg.Providers.Azure.ImageReference = value
|
|
default:
|
|
return fmt.Errorf("unknown Azure key: %s", parts[2])
|
|
}
|
|
case "hetzner":
|
|
switch parts[2] {
|
|
case "token":
|
|
cfg.Providers.Hetzner.Token = value
|
|
case "location":
|
|
cfg.Providers.Hetzner.Location = value
|
|
case "server_type":
|
|
cfg.Providers.Hetzner.ServerType = value
|
|
case "image":
|
|
cfg.Providers.Hetzner.Image = value
|
|
case "ssh_key_name":
|
|
cfg.Providers.Hetzner.SSHKeyName = value
|
|
default:
|
|
return fmt.Errorf("unknown Hetzner key: %s", parts[2])
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown provider: %s", parts[1])
|
|
}
|
|
|
|
case "limits":
|
|
switch parts[1] {
|
|
case "max_hourly_spend":
|
|
var val float64
|
|
if _, err := fmt.Sscanf(value, "%f", &val); err != nil {
|
|
return fmt.Errorf("invalid float value: %s", value)
|
|
}
|
|
cfg.Limits.MaxHourlySpend = val
|
|
case "max_total_spend":
|
|
var val float64
|
|
if _, err := fmt.Sscanf(value, "%f", &val); err != nil {
|
|
return fmt.Errorf("invalid float value: %s", value)
|
|
}
|
|
cfg.Limits.MaxTotalSpend = val
|
|
case "max_instances":
|
|
var val int
|
|
if _, err := fmt.Sscanf(value, "%d", &val); err != nil {
|
|
return fmt.Errorf("invalid integer value: %s", value)
|
|
}
|
|
cfg.Limits.MaxInstances = val
|
|
default:
|
|
return fmt.Errorf("unknown limit key: %s", parts[1])
|
|
}
|
|
|
|
case "ssh":
|
|
switch parts[1] {
|
|
case "private_key_path":
|
|
cfg.SSH.PrivateKeyPath = value
|
|
case "private_key_content":
|
|
cfg.SSH.PrivateKeyContent = value
|
|
case "public_key_path":
|
|
cfg.SSH.PublicKeyPath = value
|
|
case "public_key_content":
|
|
cfg.SSH.PublicKeyContent = value
|
|
case "user":
|
|
cfg.SSH.User = value
|
|
case "password":
|
|
cfg.SSH.Password = value
|
|
case "port":
|
|
cfg.SSH.Port = value
|
|
default:
|
|
return fmt.Errorf("unknown SSH key: %s", parts[1])
|
|
}
|
|
|
|
case "setup":
|
|
switch parts[1] {
|
|
case "commands":
|
|
if len(parts) >= 3 {
|
|
switch parts[2] {
|
|
case "add":
|
|
cfg.Setup.Commands = append(cfg.Setup.Commands, value)
|
|
return nil
|
|
case "clear":
|
|
cfg.Setup.Commands = []string{}
|
|
return nil
|
|
}
|
|
}
|
|
cfg.Setup.Commands = []string{value}
|
|
case "post_commands":
|
|
if len(parts) >= 3 {
|
|
switch parts[2] {
|
|
case "add":
|
|
cfg.Setup.PostCommands = append(cfg.Setup.PostCommands, value)
|
|
return nil
|
|
case "clear":
|
|
cfg.Setup.PostCommands = []string{}
|
|
return nil
|
|
}
|
|
}
|
|
cfg.Setup.PostCommands = []string{value}
|
|
case "ansible":
|
|
if len(parts) < 3 {
|
|
return fmt.Errorf("ansible key requires 3 parts (e.g., setup.ansible.enabled)")
|
|
}
|
|
switch parts[2] {
|
|
case "enabled":
|
|
cfg.Setup.Ansible.Enabled = (value == "true")
|
|
case "playbook_path":
|
|
cfg.Setup.Ansible.PlaybookPath = value
|
|
case "inventory_path":
|
|
cfg.Setup.Ansible.InventoryPath = value
|
|
case "extra_args":
|
|
cfg.Setup.Ansible.ExtraArgs = value
|
|
default:
|
|
// Handle extra_vars with 4-part keys: setup.ansible.extra_vars.<key>
|
|
if len(parts) >= 4 && parts[2] == "extra_vars" {
|
|
if cfg.Setup.Ansible.ExtraVars == nil {
|
|
cfg.Setup.Ansible.ExtraVars = make(map[string]string)
|
|
}
|
|
cfg.Setup.Ansible.ExtraVars[parts[3]] = value
|
|
return nil
|
|
}
|
|
return fmt.Errorf("unknown ansible key: %s", parts[2])
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown setup key: %s. Use: setup.commands, setup.post_commands, setup.ansible", parts[1])
|
|
}
|
|
|
|
case "state":
|
|
switch parts[1] {
|
|
case "backend":
|
|
cfg.State.Backend = value
|
|
case "path":
|
|
cfg.State.Path = value
|
|
default:
|
|
return fmt.Errorf("unknown state key: %s", parts[1])
|
|
}
|
|
|
|
default:
|
|
return fmt.Errorf("unknown config section: %s", parts[0])
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func init() {
|
|
// Add subcommands
|
|
cloudCmd.AddCommand(cloudConfigCmd)
|
|
cloudCmd.AddCommand(cloudCreateCmd)
|
|
cloudCmd.AddCommand(cloudListCmd)
|
|
cloudCmd.AddCommand(cloudDestroyCmd)
|
|
cloudCmd.AddCommand(cloudRunCmd)
|
|
cloudCmd.AddCommand(cloudSetupCmd)
|
|
|
|
cloudSetupCmd.Flags().BoolVar(&cloudVerboseSetup, "verbose-setup", false, "Show full setup output")
|
|
cloudSetupCmd.Flags().BoolVar(&cloudUseAnsible, "ansible", false, "Use ansible playbook for setup (overrides config)")
|
|
|
|
cloudConfigCmd.AddCommand(cloudConfigSetCmd)
|
|
cloudConfigSetCmd.Flags().StringVar(&cloudConfigSetFromFile, "from-file", "", "Read key-value pairs from a file (or use - for stdin)")
|
|
cloudConfigCmd.AddCommand(cloudConfigListCmd)
|
|
cloudConfigCmd.AddCommand(cloudConfigCleanCmd)
|
|
cloudConfigListCmd.Flags().BoolVar(&cloudConfigListShowSecrets, "show-secrets", false, "show sensitive values")
|
|
cloudConfigCleanCmd.Flags().BoolVar(&cloudConfigCleanForce, "force", false, "skip confirmation prompt")
|
|
|
|
// Flags for create command
|
|
cloudCreateCmd.Flags().StringVarP(&cloudProvider, "provider", "p", "", "Cloud provider (aws, gcp, digitalocean, linode, azure, hetzner)")
|
|
cloudCreateCmd.Flags().StringVarP(&cloudMode, "mode", "m", "", "Execution mode (vm, serverless)")
|
|
cloudCreateCmd.Flags().IntVarP(&cloudInstances, "instances", "n", 0, "Number of instances to create")
|
|
cloudCreateCmd.Flags().BoolVarP(&cloudForce, "force", "f", false, "Force recreation of existing infrastructure")
|
|
cloudCreateCmd.Flags().BoolVar(&cloudSkipSetup, "skip-setup", false, "Skip worker setup after provisioning")
|
|
cloudCreateCmd.Flags().BoolVar(&cloudVerboseSetup, "verbose-setup", false, "Show full setup output")
|
|
cloudCreateCmd.Flags().BoolVar(&cloudUseAnsible, "ansible", false, "Use ansible playbook for setup (overrides config)")
|
|
|
|
// Flags for destroy command
|
|
cloudDestroyCmd.Flags().BoolVar(&cloudForce, "force", false, "Force destroy (required for 'destroy all')")
|
|
|
|
// Flags for run command
|
|
cloudRunCmd.Flags().StringVarP(&cloudFlowName, "flow", "f", "", "Flow workflow name to execute")
|
|
cloudRunCmd.Flags().StringVarP(&cloudModuleName, "module", "m", "", "Module workflow name to execute")
|
|
cloudRunCmd.Flags().StringVarP(&cloudTarget, "target", "t", "", "Target to scan")
|
|
cloudRunCmd.Flags().StringVarP(&cloudTargetFile, "target-file", "T", "", "File containing targets")
|
|
cloudRunCmd.Flags().StringVarP(&cloudProvider, "provider", "p", "", "Cloud provider")
|
|
cloudRunCmd.Flags().IntVarP(&cloudInstances, "instances", "n", 0, "Number of instances")
|
|
cloudRunCmd.Flags().StringVar(&cloudTimeout, "timeout", "", "Scan timeout (e.g., 2h, 30m)")
|
|
cloudRunCmd.Flags().BoolVar(&cloudAutoDestroy, "auto-destroy", false, "Destroy infrastructure after scan completes")
|
|
cloudRunCmd.Flags().BoolVar(&cloudReuseInfra, "reuse", false, "Auto-discover and reuse existing infrastructure (skip provisioning)")
|
|
cloudRunCmd.Flags().StringVar(&cloudReuseWith, "reuse-with", "", "Reuse specific instances by public IP or name (comma-separated)")
|
|
cloudRunCmd.Flags().BoolVar(&cloudVerboseSetup, "verbose-setup", false, "Show full setup output (default: quiet)")
|
|
cloudRunCmd.Flags().BoolVar(&cloudUseAnsible, "ansible", false, "Use ansible playbook for setup (overrides config)")
|
|
cloudRunCmd.Flags().IntVar(&cloudChunkSize, "chunk-size", 0, "Number of targets per chunk (mutually exclusive with --chunk-count)")
|
|
cloudRunCmd.Flags().IntVar(&cloudChunkCount, "chunk-count", 0, "Split targets into N equal chunks (mutually exclusive with --chunk-size)")
|
|
cloudRunCmd.Flags().BoolVar(&cloudSyncBack, "sync-back", false, "Download results and import into local database after scan")
|
|
|
|
// Custom command mode flags
|
|
cloudRunCmd.Flags().StringArrayVar(&cloudCustomCmds, "custom-cmd", nil, "Custom command to run on workers (repeatable, mutually exclusive with -f/-m)")
|
|
cloudRunCmd.Flags().StringArrayVar(&cloudCustomPostCmds, "custom-post-cmd", nil, "Post-command to run after custom-cmds succeed (repeatable)")
|
|
cloudRunCmd.Flags().StringArrayVar(&cloudSyncPaths, "sync-path", nil, "Remote file/dir to download after execution (repeatable)")
|
|
cloudRunCmd.Flags().StringVar(&cloudSyncDest, "sync-dest", "./osm-sync-back", "Local base directory for synced files")
|
|
}
|