diff --git a/cmd/cloudexec/configure.go b/cmd/cloudexec/configure.go index d536559..6440693 100644 --- a/cmd/cloudexec/configure.go +++ b/cmd/cloudexec/configure.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "os/user" "strings" "github.com/crytic/cloudexec/pkg/config" @@ -13,24 +14,34 @@ import ( ) func Configure() error { + user, err := user.Current() + if err != nil { + fmt.Printf("Failed to get current user: %v", err) + os.Exit(1) + } + username, err := promptUserInput("Username", user.Username) + if err != nil { + return err + } spacesRegion, err := promptUserInput("Digital Ocean Spaces region", "nyc3") if err != nil { return err } - apiKey, err := promptSecretInput("Digital Ocean API key or reference", "op://private/DigitalOcean/api token") + apiKey, err := promptSecretInput("Digital Ocean API key or reference", "op://Private/DigitalOcean/ApiKey") if err != nil { return err } - spacesAccessKey, err := promptSecretInput("Digital Ocean Spaces access key ID or reference", "op://private/DigitalOcean/spaces access key id") + spacesAccessKey, err := promptSecretInput("Digital Ocean Spaces access key ID or reference", "op://Private/DigitalOcean/SpacesKeyID") if err != nil { return err } - spacesSecretKey, err := promptSecretInput("Digital Ocean Spaces secret access key or reference", "op://private/DigitalOcean/spaces secret access key") + spacesSecretKey, err := promptSecretInput("Digital Ocean Spaces secret access key or reference", "op://Private/DigitalOcean/SpacesSecret") if err != nil { return err } configValues := config.Config{ + Username: username, DigitalOcean: struct { ApiKey string `toml:"apiKey"` SpacesAccessKey string `toml:"spacesAccessKey"` diff --git a/cmd/cloudexec/launch.go b/cmd/cloudexec/launch.go index 5ec4d01..dc28780 100644 --- a/cmd/cloudexec/launch.go +++ b/cmd/cloudexec/launch.go @@ -3,9 +3,7 @@ package main import ( "fmt" "os" - "os/user" "path/filepath" - "strings" "time" "github.com/BurntSushi/toml" @@ -85,8 +83,8 @@ func LoadLaunchConfig(launchConfigPath string) (LaunchConfig, error) { return lc, nil } -func Launch(user *user.User, config config.Config, dropletSize string, dropletRegion string, lc LaunchConfig) error { - username := user.Username +func Launch(config config.Config, dropletSize string, dropletRegion string, lc LaunchConfig) error { + username := config.Username bucketName := fmt.Sprintf("cloudexec-%s", username) // get existing state from bucket @@ -134,7 +132,7 @@ func Launch(user *user.User, config config.Config, dropletSize string, dropletRe // Get or create an SSH key fmt.Println("Getting or creating SSH key pair...") - publicKey, err := ssh.GetOrCreateSSHKeyPair(user) + publicKey, err := ssh.GetOrCreateSSHKeyPair() if err != nil { return fmt.Errorf("Failed to get or creating SSH key pair: %w", err) } @@ -171,23 +169,19 @@ func Launch(user *user.User, config config.Config, dropletSize string, dropletRe // Add the droplet to the SSH config file fmt.Println("Deleting old cloudexec instance from SSH config file...") - err = ssh.DeleteSSHConfig(user, "cloudexec") + err = ssh.DeleteSSHConfig("cloudexec") if err != nil { return fmt.Errorf("Failed to delete old cloudexec entry from SSH config file: %w", err) } fmt.Println("Adding droplet to SSH config file...") - err = ssh.AddSSHConfig(user, droplet.IP) + err = ssh.AddSSHConfig(droplet.IP) if err != nil { return fmt.Errorf("Failed to add droplet to SSH config file: %w", err) } // Ensure we can SSH into the droplet fmt.Println("Ensuring we can SSH into the droplet...") - // sshConfigName := fmt.Sprintf("cloudexec-%v", dropletIp) - sshConfigName := "cloudexec" - sshConfigName = strings.ReplaceAll(sshConfigName, ".", "-") - sshConfigPath := filepath.Join(user.HomeDir, ".ssh", "config.d", sshConfigName) - err = ssh.WaitForSSHConnection(sshConfigPath) + err = ssh.WaitForSSHConnection() if err != nil { return fmt.Errorf("Failed to SSH into the droplet: %w", err) } diff --git a/cmd/cloudexec/main.go b/cmd/cloudexec/main.go index 34ac123..4f6af23 100644 --- a/cmd/cloudexec/main.go +++ b/cmd/cloudexec/main.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "os/user" "strconv" do "github.com/crytic/cloudexec/pkg/digitalocean" @@ -23,16 +22,6 @@ var ( ) func main() { - user, err := user.Current() - if err != nil { - fmt.Printf("Failed to get current user: %v", err) - os.Exit(1) - } - username := user.Username - // TODO: sanitize username usage in bucketname - bucketName := fmt.Sprintf("cloudexec-%s", username) - dropletName := fmt.Sprintf("cloudexec-%v", username) - // Attempt to load the configuration config, configErr := LoadConfig(ConfigFilePath) @@ -40,29 +29,17 @@ func main() { Name: "cloudexec", Usage: "easily run cloud based jobs", Commands: []*cli.Command{ + { - Name: "check", - Usage: "Verifies cloud authentication", - Aliases: []string{"c"}, + Name: "version", + Usage: "Gets the version of the app", + Aliases: []string{"v"}, Action: func(*cli.Context) error { - // Abort on configuration error - if configErr != nil { - return configErr - } - - resp, err := do.CheckAuth(config) - if err != nil { - return err - } - fmt.Println(resp) - snap, err := do.GetLatestSnapshot(config) - if err != nil { - return err - } - fmt.Printf("Using CloudExec image: %s\n", snap.Name) + fmt.Printf("cloudexec %s, commit %s, built at %s", Version, Commit, Date) return nil }, }, + { Name: "configure", Usage: "Configure credentials", @@ -74,6 +51,7 @@ func main() { return nil }, }, + { Name: "init", Usage: "Create a new cloudexec.toml launch configuration in the current directory", @@ -85,6 +63,30 @@ func main() { return nil }, }, + + { + Name: "check", + Usage: "Verifies cloud authentication", + Aliases: []string{"c"}, + Action: func(*cli.Context) error { + // Abort on configuration error + if configErr != nil { + return configErr + } + resp, err := do.CheckAuth(config) + if err != nil { + return err + } + fmt.Println(resp) + snap, err := do.GetLatestSnapshot(config) + if err != nil { + return err + } + fmt.Printf("Using CloudExec image: %s\n", snap.Name) + return nil + }, + }, + { Name: "launch", Usage: "Launch a droplet and start a job", @@ -110,7 +112,7 @@ func main() { if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Check if a local cloudexec.toml exists if _, err := os.Stat(LaunchConfigFilePath); os.IsNotExist(err) { // Check if the path to a launch config is provided @@ -119,7 +121,6 @@ func main() { } LaunchConfigFilePath = c.Args().Get(0) } - // Load the launch configuration lc, err := LoadLaunchConfig(LaunchConfigFilePath) if err != nil { @@ -128,21 +129,101 @@ func main() { // Get the optional droplet size and region dropletSize := c.String("size") dropletRegion := c.String("region") - // Initialize the s3 state - err = Init(config, bucketName) + err = Init(config, slug) if err != nil { return err } - fmt.Printf("Launching a %s droplet in the %s region\n", dropletSize, dropletRegion) - err = Launch(user, config, dropletSize, dropletRegion, lc) + err = Launch(config, dropletSize, dropletRegion, lc) if err != nil { log.Fatal(err) } return nil }, }, + + { + Name: "status", + Usage: "Get status of running jobs", + Aliases: []string{"s"}, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "show all jobs, including failed, cancelled, and completed", + }, + }, + Action: func(c *cli.Context) error { + // Abort on configuration error + if configErr != nil { + return configErr + } + slug := fmt.Sprintf("cloudexec-%s", config.Username) + // Initialize the s3 state + err := Init(config, slug) + if err != nil { + return err + } + showAll := c.Bool("all") + err = PrintStatus(config, slug, showAll) + if err != nil { + return err + } + return nil + }, + }, + + { + Name: "pull", + Usage: "Pulls down the results of the latest successful job", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "job", + Value: 0, + Usage: "Optional job ID to pull results from", + }, + }, + Action: func(c *cli.Context) error { + // Abort on configuration error + if configErr != nil { + return configErr + } + slug := fmt.Sprintf("cloudexec-%s", config.Username) + // Check if the path is provided + if c.Args().Len() < 1 { + return fmt.Errorf("please provide a path to download job outputs to") + } + path := c.Args().Get(0) + // Initialize the s3 state + err := Init(config, slug) + if err != nil { + return err + } + existingState, err := state.GetState(config, slug) + if err != nil { + return err + } + if c.Int("job") != 0 { + err = DownloadJobOutput(config, c.Int("job"), path, slug) + if err != nil { + return err + } + return nil + } else { + latestCompletedJob, err := state.GetLatestCompletedJob(slug, existingState) + if err != nil { + return err + } + err = DownloadJobOutput(config, int(latestCompletedJob.ID), path, slug) + if err != nil { + return err + } + return nil + } + }, + }, + { Name: "logs", Usage: "Stream logs from a running job", @@ -158,21 +239,19 @@ func main() { if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Initialize the s3 state - err = Init(config, bucketName) + err := Init(config, slug) if err != nil { return err } - - existingState, err := state.GetState(config, bucketName) + existingState, err := state.GetState(config, slug) if err != nil { return err } latestJob := existingState.GetLatestJob() jobID := int(latestJob.ID) jobStatus := latestJob.Status - // If there's a running job, stream the logs directly from the droplet if jobStatus == state.Provisioning || jobStatus == state.Running { err = ssh.StreamLogs() @@ -182,63 +261,52 @@ func main() { return nil } else if c.Int("job") != 0 { jobID := c.Int("job") - err := GetLogsFromBucket(config, jobID, bucketName) + err := GetLogsFromBucket(config, jobID, slug) return err } else { - err := GetLogsFromBucket(config, jobID, bucketName) + err := GetLogsFromBucket(config, jobID, slug) return err } }, }, + { - Name: "cancel", - Usage: "Cancels any running cloudexec jobs", + Name: "attach", + Aliases: []string{"a"}, + Usage: "Attach to a running job", Action: func(*cli.Context) error { // Abort on configuration error if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Initialize the s3 state - err = Init(config, bucketName) - if err != nil { - return err - } - - instanceToJobs, err := state.GetJobIdsByInstance(config, bucketName) + err := Init(config, slug) if err != nil { return err } - - // deletes droplets per user feedback & returns a list of job IDs for state updates - confirmedToDelete, err := ConfirmDeleteDroplets(config, dropletName, instanceToJobs) + // First check if there's a running job + existingState, err := state.GetState(config, slug) if err != nil { return err } - if len(confirmedToDelete) == 0 { + targetJob := existingState.GetLatestJob() + jobStatus := targetJob.Status + // Attach to the running job with tmux + if jobStatus == state.Running { + err = ssh.AttachToTmuxSession() + if err != nil { + return err + } + return nil + } else { + fmt.Println("error: Can't attach, no running job found") + fmt.Println("Check the status of the job with cloudexec status") return nil } - - existingState, err := state.GetState(config, bucketName) - if err != nil { - return err - } - - // mark any running jobs as cancelled - err = existingState.CancelRunningJobs(config, bucketName, confirmedToDelete) - if err != nil { - return err - } - - err = ssh.DeleteSSHConfig(user, "cloudexec") - if err != nil { - return err - } - - return nil - }, }, + { Name: "clean", Usage: "Cleans up any running cloudexec droplets and clears the spaces bucket", @@ -247,29 +315,27 @@ func main() { if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Initialize the s3 state - err = Init(config, bucketName) + err := Init(config, slug) if err != nil { return err } - - instanceToJobs, err := state.GetJobIdsByInstance(config, bucketName) + instanceToJobs, err := state.GetJobIdsByInstance(config, slug) if err != nil { return err } - // clean existing files from the bucket - err = ResetBucket(config, bucketName, config.DigitalOcean.SpacesAccessKey, config.DigitalOcean.SpacesSecretKey, config.DigitalOcean.SpacesRegion) + err = ResetBucket(config, slug, config.DigitalOcean.SpacesAccessKey, config.DigitalOcean.SpacesSecretKey, config.DigitalOcean.SpacesRegion) if err != nil { return err } - confirmedToDelete, err := ConfirmDeleteDroplets(config, dropletName, instanceToJobs) + confirmedToDelete, err := ConfirmDeleteDroplets(config, slug, instanceToJobs) if err != nil { return err } if len(confirmedToDelete) > 0 { - err = ssh.DeleteSSHConfig(user, "cloudexec") + err = ssh.DeleteSSHConfig("cloudexec") if err != nil { return err } @@ -277,95 +343,56 @@ func main() { return nil }, }, - { - Name: "pull", - Usage: "Pulls down the results of the latest successful job", - Flags: []cli.Flag{ - &cli.IntFlag{ - Name: "job", - Value: 0, - Usage: "Optional job ID to pull results from", - }, - }, - Action: func(c *cli.Context) error { - // Check if the path is provided - if c.Args().Len() < 1 { - return fmt.Errorf("please provide a path to download job outputs to") - } - path := c.Args().Get(0) + { + Name: "cancel", + Usage: "Cancels any running cloudexec jobs", + Action: func(*cli.Context) error { // Abort on configuration error if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Initialize the s3 state - err = Init(config, bucketName) + err := Init(config, slug) if err != nil { return err } - - existingState, err := state.GetState(config, bucketName) + instanceToJobs, err := state.GetJobIdsByInstance(config, slug) if err != nil { return err } - - if c.Int("job") != 0 { - err = DownloadJobOutput(config, c.Int("job"), path, bucketName) - if err != nil { - return err - } - return nil - } else { - - latestCompletedJob, err := state.GetLatestCompletedJob(bucketName, existingState) - if err != nil { - return err - } - err = DownloadJobOutput(config, int(latestCompletedJob.ID), path, bucketName) - if err != nil { - return err - } + // deletes droplets per feedback & returns a list of job IDs for state updates + confirmedToDelete, err := ConfirmDeleteDroplets(config, slug, instanceToJobs) + if err != nil { + return err + } + if len(confirmedToDelete) == 0 { return nil } - }, - }, - { - Name: "status", - Usage: "Get status of running jobs", - Aliases: []string{"s"}, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "all", - Aliases: []string{"a"}, - Usage: "show all jobs, including failed, cancelled, and completed", - }, - }, - Action: func(c *cli.Context) error { - // Abort on configuration error - if configErr != nil { - return configErr + existingState, err := state.GetState(config, slug) + if err != nil { + return err } - - // Initialize the s3 state - err = Init(config, bucketName) + // mark any running jobs as cancelled + err = existingState.CancelRunningJobs(config, slug, confirmedToDelete) if err != nil { return err } - showAll := c.Bool("all") - - err = PrintStatus(config, bucketName, showAll) + err = ssh.DeleteSSHConfig("cloudexec") if err != nil { return err } - return nil + }, }, + { Name: "state", Usage: "Manage state file", Subcommands: []*cli.Command{ + { Name: "list", Usage: "List jobs in the state file", @@ -374,19 +401,17 @@ func main() { if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Initialize the s3 state - err = Init(config, bucketName) + err := Init(config, slug) if err != nil { return err } - // Retrieve existing state - existingState, err := state.GetState(config, bucketName) + existingState, err := state.GetState(config, slug) if err != nil { return err } - // Print the jobs from the state for _, job := range existingState.Jobs { fmt.Printf("Job ID: %d, Status: %s\n", job.ID, job.Status) @@ -394,6 +419,7 @@ func main() { return nil }, }, + { Name: "rm", Usage: "Remove a job from the state file", @@ -402,39 +428,37 @@ func main() { if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Initialize the s3 state - err = Init(config, bucketName) + err := Init(config, slug) if err != nil { return err } - jobID := c.Args().First() // Get the job ID from the arguments if jobID == "" { fmt.Println("Please provide a job ID to remove") return nil } - // Convert jobID string to int64 id, err := strconv.ParseInt(jobID, 10, 64) if err != nil { fmt.Printf("Invalid job ID: %s\n", jobID) return nil } - newState := &state.State{} deleteJob := state.JobInfo{ ID: id, Delete: true, } newState.CreateJob(deleteJob) - err = state.UpdateState(config, bucketName, newState) + err = state.UpdateState(config, slug, newState) if err != nil { return err } return nil }, }, + { Name: "json", Usage: "Output the raw state file as JSON", @@ -443,15 +467,14 @@ func main() { if configErr != nil { return configErr } - + slug := fmt.Sprintf("cloudexec-%s", config.Username) // Initialize the s3 state - err = Init(config, bucketName) + err := Init(config, slug) if err != nil { return err } - // Retrieve existing state - existingState, err := state.GetState(config, bucketName) + existingState, err := state.GetState(config, slug) if err != nil { return err } @@ -466,55 +489,6 @@ func main() { }, }, }, - { - Name: "attach", - Usage: "Attach to a running job", - Aliases: []string{"a"}, - Action: func(*cli.Context) error { - // Abort on configuration error - if configErr != nil { - return configErr - } - - // Initialize the s3 state - err = Init(config, bucketName) - if err != nil { - return err - } - - // First check if there's a running job - existingState, err := state.GetState(config, bucketName) - if err != nil { - return err - } - // If we got a job Id, get that job's state, else continue - latestJob := existingState.GetLatestJob() - jobStatus := latestJob.Status - - // Attach to the running job with tmux - if jobStatus == state.Running { - err = ssh.AttachToTmuxSession() - if err != nil { - return err - } - return nil - } else { - fmt.Println("error: Can't attach, no running job found") - fmt.Println("Check the status of the job with cloudexec status") - return nil - - } - }, - }, - { - Name: "version", - Usage: "Gets the version of the app", - Aliases: []string{"v"}, - Action: func(*cli.Context) error { - fmt.Printf("cloudexec %s, commit %s, built at %s", Version, Commit, Date) - return nil - }, - }, }, } diff --git a/pkg/config/config.go b/pkg/config/config.go index c1a3139..d4c3d12 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,7 @@ import ( ) type Config struct { + Username string `toml:"username"` DigitalOcean struct { ApiKey string `toml:"apiKey"` SpacesAccessKey string `toml:"spacesAccessKey"` diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go index aebea98..8483889 100644 --- a/pkg/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -39,14 +39,25 @@ type HostConfig struct { IdentityFile string } -func EnsureSSHIncludeConfig(usr *user.User) error { +func getSSHDir() (string, error) { + user, err := user.Current() + if err != nil { + return "", fmt.Errorf("Failed to get current user: %w", err) + } + return filepath.Join(user.HomeDir, ".ssh"), nil +} + +func EnsureSSHIncludeConfig() error { commentString := "# Added by cloudexec\n" includeString := "Include config.d/*\n" - sshDir := filepath.Join(usr.HomeDir, ".ssh") + sshDir, err := getSSHDir() + if err != nil { + return err + } configPath := filepath.Join(sshDir, "config") // Create the SSH directory if it does not exist - err := os.MkdirAll(sshDir, 0700) + err = os.MkdirAll(sshDir, 0700) if err != nil { return fmt.Errorf("Failed to create SSH directory: %w", err) } @@ -85,13 +96,16 @@ func EnsureSSHIncludeConfig(usr *user.User) error { return nil } -func AddSSHConfig(usr *user.User, ipAddress string) error { - err := EnsureSSHIncludeConfig(usr) +func AddSSHConfig(ipAddress string) error { + err := EnsureSSHIncludeConfig() if err != nil { return fmt.Errorf("Failed to validate main SSH config: %w", err) } + sshDir, err := getSSHDir() + if err != nil { + return err + } - sshDir := filepath.Join(usr.HomeDir, ".ssh") configDir := filepath.Join(sshDir, "config.d") // fileIpAddress := strings.Replace(ipAddress, ".", "-", -1) // configName := fmt.Sprintf("cloudexec-%v", fileIpAddress) @@ -131,13 +145,16 @@ func AddSSHConfig(usr *user.User, ipAddress string) error { return nil } -func DeleteSSHConfig(usr *user.User, filename string) error { - err := EnsureSSHIncludeConfig(usr) +func DeleteSSHConfig(filename string) error { + err := EnsureSSHIncludeConfig() if err != nil { return fmt.Errorf("Failed to validate SSH config: %w", err) } + sshDir, err := getSSHDir() + if err != nil { + return err + } - sshDir := filepath.Join(usr.HomeDir, ".ssh") configDir := filepath.Join(sshDir, "config.d") configPath := filepath.Join(configDir, filename) err = os.Remove(configPath) @@ -151,13 +168,16 @@ func DeleteSSHConfig(usr *user.User, filename string) error { return nil } -func GetOrCreateSSHKeyPair(usr *user.User) (string, error) { - err := EnsureSSHIncludeConfig(usr) +func GetOrCreateSSHKeyPair() (string, error) { + err := EnsureSSHIncludeConfig() if err != nil { return "", fmt.Errorf("Failed to validate SSH config: %w", err) } + sshDir, err := getSSHDir() + if err != nil { + return "", err + } - sshDir := filepath.Join(usr.HomeDir, ".ssh") privateKeyPath := filepath.Join(sshDir, "cloudexec-key") publicKeyPath := filepath.Join(sshDir, "cloudexec-key.pub") @@ -204,7 +224,15 @@ func GetOrCreateSSHKeyPair(usr *user.User) (string, error) { return string(publicKeySSHFormat), nil } -func WaitForSSHConnection(sshConfigPath string) error { +func WaitForSSHConnection() error { + sshDir, err := getSSHDir() + if err != nil { + return err + } + sshConfigName := "cloudexec" + sshConfigName = strings.ReplaceAll(sshConfigName, ".", "-") + sshConfigPath := filepath.Join(sshDir, "config.d", sshConfigName) + timeout := 60 * time.Second retryInterval := 10 * time.Second