diff --git a/builder/digitalocean/api.go b/builder/digitalocean/api.go new file mode 100644 index 000000000..85c3c63a9 --- /dev/null +++ b/builder/digitalocean/api.go @@ -0,0 +1,179 @@ +// All of the methods used to communicate with the digital_ocean API +// are here. Their API is on a path to V2, so just plain JSON is used +// in place of a proper client library for now. + +package digitalocean + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" +) + +const DIGITALOCEAN_API_URL = "https://api.digitalocean.com" + +type DigitalOceanClient struct { + // The http client for communicating + client *http.Client + + // The base URL of the API + BaseURL string + + // Credentials + ClientID string + APIKey string +} + +// Creates a new client for communicating with DO +func (d DigitalOceanClient) New(client string, key string) *DigitalOceanClient { + c := &DigitalOceanClient{ + client: http.DefaultClient, + BaseURL: DIGITALOCEAN_API_URL, + ClientID: client, + APIKey: key, + } + return c +} + +// Creates an SSH Key and returns it's id +func (d DigitalOceanClient) CreateKey(name string, pub string) (uint, error) { + // Escape the public key + pub = url.QueryEscape(pub) + + params := fmt.Sprintf("name=%v&ssh_pub_key=%v", name, pub) + + body, err := NewRequest(d, "ssh_keys/new", params) + if err != nil { + return 0, err + } + + // Read the SSH key's ID we just created + key := body["ssh_key"].(map[string]interface{}) + keyId := key["id"].(float64) + return uint(keyId), nil +} + +// Destroys an SSH key +func (d DigitalOceanClient) DestroyKey(id uint) error { + path := fmt.Sprintf("ssh_keys/%v/destroy", id) + _, err := NewRequest(d, path, "") + return err +} + +// Creates a droplet and returns it's id +func (d DigitalOceanClient) CreateDroplet(name string, size uint, image uint, region uint, keyId uint) (uint, error) { + params := fmt.Sprintf( + "name=%v&image_id=%v&size_id=%v®ion_id=%v&ssh_key_ids=%v", + name, image, size, region, keyId) + + body, err := NewRequest(d, "droplets/new", params) + if err != nil { + return 0, err + } + + // Read the Droplets ID + droplet := body["droplet"].(map[string]interface{}) + dropletId := droplet["id"].(float64) + return uint(dropletId), err +} + +// Destroys a droplet +func (d DigitalOceanClient) DestroyDroplet(id uint) error { + path := fmt.Sprintf("droplets/%v/destroy", id) + _, err := NewRequest(d, path, "") + return err +} + +// Powers off a droplet +func (d DigitalOceanClient) PowerOffDroplet(id uint) error { + path := fmt.Sprintf("droplets/%v/power_off", id) + + _, err := NewRequest(d, path, "") + + return err +} + +// Creates a snaphot of a droplet by it's ID +func (d DigitalOceanClient) CreateSnapshot(id uint, name string) error { + path := fmt.Sprintf("droplets/%v/snapshot", id) + params := fmt.Sprintf("name=%v", name) + + _, err := NewRequest(d, path, params) + + return err +} + +// Returns DO's string representation of status "off" "new" "active" etc. +func (d DigitalOceanClient) DropletStatus(id uint) (string, string, error) { + path := fmt.Sprintf("droplets/%v", id) + + body, err := NewRequest(d, path, "") + if err != nil { + return "", "", err + } + + var ip string + + // Read the droplet's "status" + droplet := body["droplet"].(map[string]interface{}) + status := droplet["status"].(string) + + if droplet["ip_address"] != nil { + ip = droplet["ip_address"].(string) + } + + return ip, status, err +} + +// Sends an api request and returns a generic map[string]interface of +// the response. +func NewRequest(d DigitalOceanClient, path string, params string) (map[string]interface{}, error) { + client := d.client + url := fmt.Sprintf("%v/%v?%v&client_id=%v&api_key=%v", + DIGITALOCEAN_API_URL, path, params, d.ClientID, d.APIKey) + + var decodedResponse map[string]interface{} + + log.Printf("sending new request to digitalocean: %v", url) + + resp, err := client.Get(url) + if err != nil { + return decodedResponse, err + } + + body, err := ioutil.ReadAll(resp.Body) + + resp.Body.Close() + if err != nil { + return decodedResponse, err + } + + err = json.Unmarshal(body, &decodedResponse) + + // Catch all non-200 status and return an error + if resp.StatusCode != 200 { + err = errors.New(fmt.Sprintf("recieved non-200 status from digitalocean: %d", resp.StatusCode)) + log.Printf("response from digital ocean: %v", decodedResponse) + return decodedResponse, err + } + + log.Printf("response from digital ocean: %v", decodedResponse) + + if err != nil { + return decodedResponse, err + } + + // Catch all non-OK statuses from DO and return an error + status := decodedResponse["status"] + if status != "OK" { + err = errors.New(fmt.Sprintf("recieved non-OK status from digitalocean: %d", status)) + log.Printf("response from digital ocean: %v", decodedResponse) + return decodedResponse, err + } + + return decodedResponse, nil +} diff --git a/builder/digitalocean/builder.go b/builder/digitalocean/builder.go new file mode 100644 index 000000000..49dfe2422 --- /dev/null +++ b/builder/digitalocean/builder.go @@ -0,0 +1,148 @@ +// The digitalocean package contains a packer.Builder implementation +// that builds DigitalOcean images (snapshots). + +package digitalocean + +import ( + "errors" + "fmt" + "github.com/mitchellh/mapstructure" + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" + "log" + "time" +) + +// The unique id for the builder +const BuilderId = "pearkes.digitalocean" + +// Configuration tells the builder the credentials +// to use while communicating with DO and describes the image +// you are creating +type config struct { + // Credentials + ClientID string `mapstructure:"client_id"` + APIKey string `mapstructure:"api_key"` + + RegionID uint `mapstructure:"region_id"` + SizeID uint `mapstructure:"size_id"` + ImageID uint `mapstructure:"image_id"` + SSHUsername string `mapstructure:"ssh_username"` + SSHPort uint `mapstructure:"ssh_port"` + + // Configuration for the image being built + SnapshotName string `mapstructure:"snapshot_name"` + + RawSSHTimeout string `mapstructure:"ssh_timeout"` + SSHTimeout time.Duration +} + +type Builder struct { + config config + runner multistep.Runner +} + +func (b *Builder) Prepare(raw interface{}) error { + if err := mapstructure.Decode(raw, &b.config); err != nil { + return err + } + + // Optional configuration with defaults + // + if b.config.RegionID == 0 { + // Default to Region "New York" + b.config.RegionID = 1 + } + + if b.config.SizeID == 0 { + // Default to 512mb, the smallest droplet size + b.config.SizeID = 66 + } + + if b.config.ImageID == 0 { + // Default to base image "Ubuntu 12.04 x64 Server (id: 284203)" + b.config.ImageID = 284203 + } + + if b.config.SSHUsername == "" { + // Default to "root". You can override this if your + // SourceImage has a different user account then the DO default + b.config.SSHUsername = "root" + } + + if b.config.SSHPort == 0 { + // Default to port 22 per DO default + b.config.SSHPort = 22 + } + + if b.config.SnapshotName == "" { + // Default to packer-{{ unix timestamp (utc) }} + b.config.SnapshotName = "packer-{{.CreateTime}}" + } + + if b.config.RawSSHTimeout == "" { + // Default to 1 minute timeouts + b.config.RawSSHTimeout = "1m" + } + + // A list of errors on the configuration + errs := make([]error, 0) + + // Required configurations that will display errors if not set + // + if b.config.ClientID == "" { + errs = append(errs, errors.New("a client_id must be specified")) + } + + if b.config.APIKey == "" { + errs = append(errs, errors.New("an api_key must be specified")) + } + timeout, err := time.ParseDuration(b.config.RawSSHTimeout) + if err != nil { + errs = append(errs, fmt.Errorf("Failed parsing ssh_timeout: %s", err)) + } + b.config.SSHTimeout = timeout + + if len(errs) > 0 { + return &packer.MultiError{errs} + } + + log.Printf("Config: %+v", b.config) + return nil +} + +func (b *Builder) Run(ui packer.Ui, hook packer.Hook, cache packer.Cache) (packer.Artifact, error) { + // Initialize the DO API client + client := DigitalOceanClient{}.New(b.config.ClientID, b.config.APIKey) + + // Set up the state + state := make(map[string]interface{}) + state["config"] = b.config + state["client"] = client + state["hook"] = hook + state["ui"] = ui + + // Build the steps + steps := []multistep.Step{ + new(stepCreateSSHKey), + new(stepCreateDroplet), + new(stepDropletInfo), + new(stepConnectSSH), + new(stepProvision), + new(stepPowerOff), + new(stepSnapshot), + } + + // Run the steps + b.runner = &multistep.BasicRunner{Steps: steps} + b.runner.Run(state) + + return nil, nil +} + +func (b *Builder) Cancel() { + if b.runner != nil { + log.Println("Cancelling the step runner...") + b.runner.Cancel() + } +} diff --git a/builder/digitalocean/builder_test.go b/builder/digitalocean/builder_test.go new file mode 100644 index 000000000..1559833a8 --- /dev/null +++ b/builder/digitalocean/builder_test.go @@ -0,0 +1,237 @@ +package digitalocean + +import ( + "github.com/mitchellh/packer/packer" + "testing" +) + +func testConfig() map[string]interface{} { + return map[string]interface{}{ + "client_id": "foo", + "api_key": "bar", + } +} + +func TestBuilder_ImplementsBuilder(t *testing.T) { + var raw interface{} + raw = &Builder{} + if _, ok := raw.(packer.Builder); !ok { + t.Fatalf("Builder should be a builder") + } +} + +func TestBuilder_Prepare_BadType(t *testing.T) { + b := &Builder{} + c := map[string]interface{}{ + "api_key": []string{}, + } + + err := b.Prepare(c) + if err == nil { + t.Fatalf("prepare should fail") + } +} + +func TestBuilderPrepare_APIKey(t *testing.T) { + var b Builder + config := testConfig() + + // Test good + config["api_key"] = "foo" + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.APIKey != "foo" { + t.Errorf("access key invalid: %s", b.config.APIKey) + } + + // Test bad + delete(config, "api_key") + b = Builder{} + err = b.Prepare(config) + if err == nil { + t.Fatal("should have error") + } +} + +func TestBuilderPrepare_ClientID(t *testing.T) { + var b Builder + config := testConfig() + + // Test good + config["client_id"] = "foo" + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.ClientID != "foo" { + t.Errorf("invalid: %s", b.config.ClientID) + } + + // Test bad + delete(config, "client_id") + b = Builder{} + err = b.Prepare(config) + if err == nil { + t.Fatal("should have error") + } +} + +func TestBuilderPrepare_RegionID(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.RegionID != 1 { + t.Errorf("invalid: %d", b.config.RegionID) + } + + // Test set + config["region_id"] = 2 + b = Builder{} + err = b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.RegionID != 2 { + t.Errorf("invalid: %d", b.config.RegionID) + } +} + +func TestBuilderPrepare_SizeID(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.SizeID != 66 { + t.Errorf("invalid: %d", b.config.SizeID) + } + + // Test set + config["size_id"] = 67 + b = Builder{} + err = b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.SizeID != 67 { + t.Errorf("invalid: %d", b.config.SizeID) + } +} + +func TestBuilderPrepare_ImageID(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.SizeID != 2676 { + t.Errorf("invalid: %d", b.config.SizeID) + } + + // Test set + config["size_id"] = 2 + b = Builder{} + err = b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.SizeID != 2 { + t.Errorf("invalid: %d", b.config.SizeID) + } +} + +func TestBuilderPrepare_SSHUsername(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.SSHUsername != "root" { + t.Errorf("invalid: %d", b.config.SSHUsername) + } + + // Test set + config["ssh_username"] = "" + b = Builder{} + err = b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.SSHPort != 35 { + t.Errorf("invalid: %d", b.config.SSHPort) + } +} + +func TestBuilderPrepare_SSHTimeout(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.RawSSHTimeout != "1m" { + t.Errorf("invalid: %d", b.config.RawSSHTimeout) + } + + // Test set + config["ssh_timeout"] = "30s" + b = Builder{} + err = b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + // Test bad + config["ssh_timeout"] = "tubes" + b = Builder{} + err = b.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + +} + +func TestBuilderPrepare_SnapshotName(t *testing.T) { + var b Builder + config := testConfig() + + // Test set + config["snapshot_name"] = "foo" + err := b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.SnapshotName != "foo" { + t.Errorf("invalid: %s", b.config.SnapshotName) + } +} diff --git a/builder/digitalocean/step_connect_ssh.go b/builder/digitalocean/step_connect_ssh.go new file mode 100644 index 000000000..7e0bcd9ed --- /dev/null +++ b/builder/digitalocean/step_connect_ssh.go @@ -0,0 +1,117 @@ +package digitalocean + +import ( + gossh "code.google.com/p/go.crypto/ssh" + "fmt" + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/communicator/ssh" + "github.com/mitchellh/packer/packer" + "log" + "net" + "time" +) + +type stepConnectSSH struct { + conn net.Conn +} + +func (s *stepConnectSSH) Run(state map[string]interface{}) multistep.StepAction { + config := state["config"].(config) + privateKey := state["privateKey"].(string) + ui := state["ui"].(packer.Ui) + ipAddress := state["droplet_ip"] + + // Build the keyring for authentication. This stores the private key + // we'll use to authenticate. + keyring := &ssh.SimpleKeychain{} + err := keyring.AddPEMKey(privateKey) + if err != nil { + ui.Say(fmt.Sprintf("Error setting up SSH config: %s", err)) + return multistep.ActionHalt + } + + // Build the actual SSH client configuration + sshConfig := &gossh.ClientConfig{ + User: config.SSHUsername, + Auth: []gossh.ClientAuth{ + gossh.ClientAuthKeyring(keyring), + }, + } + + // Start trying to connect to SSH + connected := make(chan bool, 1) + connectQuit := make(chan bool, 1) + defer func() { + connectQuit <- true + }() + + go func() { + var err error + + ui.Say("Connecting to the droplet via SSH...") + attempts := 0 + for { + select { + case <-connectQuit: + return + default: + } + + attempts += 1 + log.Printf( + "Opening TCP conn for SSH to %s:%d (attempt %d)", + ipAddress, config.SSHPort, attempts) + s.conn, err = net.Dial("tcp", fmt.Sprintf("%s:%d", ipAddress, config.SSHPort)) + if err == nil { + break + } + + // A brief sleep so we're not being overly zealous attempting + // to connect to the instance. + time.Sleep(500 * time.Millisecond) + } + + connected <- true + }() + + log.Printf("Waiting up to %s for SSH connection", config.SSHTimeout) + timeout := time.After(config.SSHTimeout) + +ConnectWaitLoop: + for { + select { + case <-connected: + // We connected. Just break the loop. + break ConnectWaitLoop + case <-timeout: + ui.Error("Timeout while waiting to connect to SSH.") + return multistep.ActionHalt + case <-time.After(1 * time.Second): + if _, ok := state[multistep.StateCancelled]; ok { + log.Println("Interrupt detected, quitting waiting for SSH.") + return multistep.ActionHalt + } + } + } + + var comm packer.Communicator + if err == nil { + comm, err = ssh.New(s.conn, sshConfig) + } + + if err != nil { + ui.Error(fmt.Sprintf("Error connecting to SSH: %s", err)) + return multistep.ActionHalt + } + + // Set the communicator on the state bag so it can be used later + state["communicator"] = comm + + return multistep.ActionContinue +} + +func (s *stepConnectSSH) Cleanup(map[string]interface{}) { + if s.conn != nil { + s.conn.Close() + } +} diff --git a/builder/digitalocean/step_create_droplet.go b/builder/digitalocean/step_create_droplet.go new file mode 100644 index 000000000..36a745de5 --- /dev/null +++ b/builder/digitalocean/step_create_droplet.go @@ -0,0 +1,67 @@ +package digitalocean + +import ( + "cgl.tideland.biz/identifier" + "encoding/hex" + "fmt" + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" + "time" +) + +type stepCreateDroplet struct { + dropletId uint +} + +func (s *stepCreateDroplet) Run(state map[string]interface{}) multistep.StepAction { + client := state["client"].(*DigitalOceanClient) + ui := state["ui"].(packer.Ui) + c := state["config"].(config) + sshKeyId := state["ssh_key_id"].(uint) + + ui.Say("Creating droplet...") + + // Some random droplet name as it's temporary + name := fmt.Sprintf("packer-%s", hex.EncodeToString(identifier.NewUUID().Raw())) + + // Create the droplet based on configuration + dropletId, err := client.CreateDroplet(name, c.SizeID, c.ImageID, c.RegionID, sshKeyId) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + // We use this in cleanup + s.dropletId = dropletId + + // Store the droplet id for later + state["droplet_id"] = dropletId + + return multistep.ActionContinue +} + +func (s *stepCreateDroplet) Cleanup(state map[string]interface{}) { + // If the dropletid isn't there, we probably never created it + if s.dropletId == 0 { + return + } + + client := state["client"].(*DigitalOceanClient) + ui := state["ui"].(packer.Ui) + + // Destroy the droplet we just created + ui.Say("Destroying droplet...") + + // Sleep arbitrarily before sending destroy request + // Otherwise we get "pending event" errors, even though there isn't + // one. + time.Sleep(5 * time.Second) + + err := client.DestroyDroplet(s.dropletId) + + if err != nil { + ui.Error(fmt.Sprintf( + "Error destroying droplet. Please destroy it manually: %v", s.dropletId)) + } +} diff --git a/builder/digitalocean/step_create_ssh_key.go b/builder/digitalocean/step_create_ssh_key.go new file mode 100644 index 000000000..1be83d706 --- /dev/null +++ b/builder/digitalocean/step_create_ssh_key.go @@ -0,0 +1,128 @@ +package digitalocean + +import ( + "cgl.tideland.biz/identifier" + "encoding/hex" + "fmt" + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" + "log" +) + +type stepCreateSSHKey struct { + keyId uint +} + +func (s *stepCreateSSHKey) Run(state map[string]interface{}) multistep.StepAction { + client := state["client"].(*DigitalOceanClient) + ui := state["ui"].(packer.Ui) + + ui.Say("Creating temporary ssh key for droplet...") + // priv, err := rsa.GenerateKey(rand.Reader, 2014) + // if err != nil { + // ui.Error(err.Error()) + // return multistep.ActionHalt + // } + + // priv_der := x509.MarshalPKCS1PrivateKey(priv) + // priv_blk := pem.Block{ + // Type: "RSA PRIVATE KEY", + // Headers: nil, + // Bytes: priv_der, + // } + + // Set the pem formatted private key on the state for later + // state["privateKey"] = string(pem.EncodeToMemory(&priv_blk)) + // log.Printf("PRIVATE KEY:\n\n%v\n\n", state["privateKey"]) + + // Create the public key for uploading to DO + // pub := priv.PublicKey + + // pub_bytes, err := x509.MarshalPKIXPublicKey(&pub) + + // pub_blk := pem.Block{ + // Type: "RSA PUBLIC KEY", + // Headers: nil, + // Bytes: pub_bytes, + // } + + // if err != nil { + // ui.Error(err.Error()) + // return multistep.ActionHalt + // } + + // // Encode the public key to base64 + // pub_str := base64.StdEncoding.EncodeToString(pub_bytes) + // pub_str = "ssh-rsa " + pub_str + + // log.Printf("PUBLIC KEY:\n\n%v\n\n", string(pem.EncodeToMemory(&pub_blk))) + // log.Printf("PUBLIC KEY BASE64:\n\n%v\n\n", pub_str) + + pub_str := `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD29LZNMe0f7nOmdOIXDrF6eAmLZEk1yrnnsPI+xjLsnKxggMjdD3HvkBPXMdhakOj3pEF6DNtXbK43A7Pilezvu7y2awz+dxCavgUNtwaJkiTJw3C2qleNDDgrq7ZYLJ/wKmfhgPO4jZBej/8ONA0VjxemCNBPTTBeZ8FaeOpeUqopdhk78KGeGmUJ8Bvl8ACuYNdtJ5Y0BQCZkJT+g1ntTwHvuq/Vy/E2uCwJ2xV3vCDkLlqXVyksuVIcLJxTPtd5LdasD4WMQwoOPNdNMBLBG6ZBhXC/6kCVbMgzy5poSZ7r6BK0EA6b2EdAanaojYs3i52j6JeCIIrYtu9Ub173 jack@jose.local` + state["privateKey"] = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA9vS2TTHtH+5zpnTiFw6xengJi2RJNcq557DyPsYy7JysYIDI +3Q9x75AT1zHYWpDo96RBegzbV2yuNwOz4pXs77u8tmsM/ncQmr4FDbcGiZIkycNw +tqpXjQw4K6u2WCyf8Cpn4YDzuI2QXo//DjQNFY8XpgjQT00wXmfBWnjqXlKqKXYZ +O/ChnhplCfAb5fAArmDXbSeWNAUAmZCU/oNZ7U8B77qv1cvxNrgsCdsVd7wg5C5a +l1cpLLlSHCycUz7XeS3WrA+FjEMKDjzXTTASwRumQYVwv+pAlWzIM8uaaEme6+gS +tBAOm9hHQGp2qI2LN4udo+iXgiCK2LbvVG9e9wIDAQABAoIBABuBB6izTciHoyO/ +0spknYmZQt7ebXTrPic6wtAQ/OzzShN5ZGWSacsXjc4ixAjaKMgj6BLyyZ8EAKcp +52ft8LSGgS8D3y+cDSJe1WtAnh7GQwihlrURZazU1pCukCFj3vA9mNI5rWs5gQG3 +Id3wGCD1jdm1E5Yxb5ikD5nG67tTW5Pn4+tidsavTNsDLsks/pW/0EcPcKAS+TJ8 +Zy15MsGGfHVVkxf+ldULIxxidAeplQhWuED6wkbuD3LQi6Kt4yElHS+UCATca8Fe +CvXNcQWrEHiYUvpyrvU3ybw7WEUUWFa/dctSZwmHvkvRD/bwJPf5M8sIIl8zlyuy +3YCIlSkCgYEA/ZqGOnYIK/bA/QVuyFkFkP3aJjOKJtH0RV9V5XVKSBlU1/Lm3DUZ +XVmp7JuWZHVhPxZa8tswj4x15dX+TwTvGdoUuqPC7K/UMOt6Qzk11o0+o2VRYU97 +GzYyEDxGEnRqoZsc1922I6nBv8YqsW4WkMRhkFN4JNzLJBVXMTXcDCMCgYEA+Uob +VQfVF+7BfCOCNdSu9dqZoYRCyBm5JNEp5bqF1kiEbGw4FhJYp95Ix5ogD3Ug4aqe +8ylwUK86U2BhfkKmGQ5yf+6VNoTx3EPFaGrODIi82BUraYPyYEN10ZrR8Czy5X9g +1WC+WuboRgvTZs+grwnDVJwqQIOqIB2L0p+SdR0CgYEAokHavc7E/bP72CdAsSjb ++d+hUq3JJ3tPiY8suwnnQ+gJM72y3ZOPrf1vTfZiK9Y6KQ4ZlKaPFFkvGaVn95DV +ljnE54FddugsoDwZVqdk/egS+qIZhmQ/BLMRJvgZcTdQ/iLrOmYdYgX788JLkIg6 +Ide0AI6XISavRl/tEIxARPcCgYEAlgh+6K8dFhlRA7iPPnyxjDAzdF0YoDuzDTCB +icy3jh747BQ5sTb7epSyssbU8tiooIjCv1A6U6UScmm4Y3gTZVMnoE1kKnra4Zk8 +LzrQpgSJu3cKOKf78OnI+Ay4u1ciHPOLwQBHsIf2VWn6oo7lg1NZ5wtR9qAHfOqr +Y2k8iRUCgYBKQCtY4SNDuFb6+r5YSEFVfelCn6DJzNgTxO2mkUzzM7RcgejHbd+i +oqgnYXsFLJgm+NpN1eFpbs2RgAe8Zd4pKQNwJFJf0EbEP57sW3kujgFFEsPYJPOp +n8wFU32yrKgrVCftmCk1iI+WPfr1r9LKgKhb0sRX1+DsdWqfN6J7Sw== +-----END RSA PRIVATE KEY-----` + + // The name of the public key on DO + name := fmt.Sprintf("packer-%s", hex.EncodeToString(identifier.NewUUID().Raw())) + + // Create the key! + keyId, err := client.CreateKey(name, pub_str) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + // We use this to check cleanup + s.keyId = keyId + + log.Printf("temporary ssh key name: %s", name) + + // Remember some state for the future + state["ssh_key_id"] = keyId + + return multistep.ActionContinue +} + +func (s *stepCreateSSHKey) Cleanup(state map[string]interface{}) { + // If no key name is set, then we never created it, so just return + if s.keyId == 0 { + return + } + + client := state["client"].(*DigitalOceanClient) + ui := state["ui"].(packer.Ui) + + ui.Say("Deleting temporary ssh key...") + err := client.DestroyKey(s.keyId) + if err != nil { + log.Printf("Error cleaning up ssh key: %v", err.Error()) + ui.Error(fmt.Sprintf( + "Error cleaning up ssh key. Please delete the key manually: %v", s.keyId)) + } +} diff --git a/builder/digitalocean/step_droplet_info.go b/builder/digitalocean/step_droplet_info.go new file mode 100644 index 000000000..ba3fb6987 --- /dev/null +++ b/builder/digitalocean/step_droplet_info.go @@ -0,0 +1,39 @@ +package digitalocean + +import ( + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" +) + +type stepDropletInfo struct{} + +func (s *stepDropletInfo) Run(state map[string]interface{}) multistep.StepAction { + client := state["client"].(*DigitalOceanClient) + ui := state["ui"].(packer.Ui) + dropletId := state["droplet_id"].(uint) + + ui.Say("Waiting for droplet to become active...") + + err := waitForDropletState("active", dropletId, client) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + // Set the IP on the state for later + ip, _, err := client.DropletStatus(dropletId) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + state["droplet_ip"] = ip + + return multistep.ActionContinue +} + +func (s *stepDropletInfo) Cleanup(state map[string]interface{}) { + // no cleanup +} diff --git a/builder/digitalocean/step_power_off.go b/builder/digitalocean/step_power_off.go new file mode 100644 index 000000000..b487f7779 --- /dev/null +++ b/builder/digitalocean/step_power_off.go @@ -0,0 +1,43 @@ +package digitalocean + +import ( + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" + "time" +) + +type stepPowerOff struct{} + +func (s *stepPowerOff) Run(state map[string]interface{}) multistep.StepAction { + client := state["client"].(*DigitalOceanClient) + ui := state["ui"].(packer.Ui) + dropletId := state["droplet_id"].(uint) + + // Sleep arbitrarily before sending power off request + // Otherwise we get "pending event" errors, even though there isn't + // one. + time.Sleep(3 * time.Second) + + // Poweroff the droplet so it can be snapshot + err := client.PowerOffDroplet(dropletId) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + ui.Say("Waiting for droplet to power off...") + + err = waitForDropletState("off", dropletId, client) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + return multistep.ActionContinue +} + +func (s *stepPowerOff) Cleanup(state map[string]interface{}) { + // no cleanup +} diff --git a/builder/digitalocean/step_provision.go b/builder/digitalocean/step_provision.go new file mode 100644 index 000000000..f1e6c8f49 --- /dev/null +++ b/builder/digitalocean/step_provision.go @@ -0,0 +1,22 @@ +package digitalocean + +import ( + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" + "log" +) + +type stepProvision struct{} + +func (*stepProvision) Run(state map[string]interface{}) multistep.StepAction { + comm := state["communicator"].(packer.Communicator) + hook := state["hook"].(packer.Hook) + ui := state["ui"].(packer.Ui) + + log.Println("Running the provision hook") + hook.Run(packer.HookProvision, ui, comm, nil) + + return multistep.ActionContinue +} + +func (*stepProvision) Cleanup(map[string]interface{}) {} diff --git a/builder/digitalocean/step_snapshot.go b/builder/digitalocean/step_snapshot.go new file mode 100644 index 000000000..93e450cf6 --- /dev/null +++ b/builder/digitalocean/step_snapshot.go @@ -0,0 +1,39 @@ +package digitalocean + +import ( + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" +) + +type stepSnapshot struct{} + +func (s *stepSnapshot) Run(state map[string]interface{}) multistep.StepAction { + client := state["client"].(*DigitalOceanClient) + ui := state["ui"].(packer.Ui) + c := state["config"].(config) + dropletId := state["droplet_id"].(uint) + + ui.Say("Creating snapshot...") + + err := client.CreateSnapshot(dropletId, c.SnapshotName) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + ui.Say("Waiting for snapshot to complete...") + + err = waitForDropletState("active", dropletId, client) + + if err != nil { + ui.Error(err.Error()) + return multistep.ActionHalt + } + + return multistep.ActionContinue +} + +func (s *stepSnapshot) Cleanup(state map[string]interface{}) { + // no cleanup +} diff --git a/builder/digitalocean/wait.go b/builder/digitalocean/wait.go new file mode 100644 index 000000000..c712c16ad --- /dev/null +++ b/builder/digitalocean/wait.go @@ -0,0 +1,57 @@ +package digitalocean + +import ( + "errors" + "log" + "time" +) + +// waitForState simply blocks until the droplet is in +// a state we expect, while eventually timing out. +func waitForDropletState(desiredState string, dropletId uint, client *DigitalOceanClient) error { + active := make(chan bool, 1) + + go func() { + attempts := 0 + for { + attempts += 1 + + log.Printf("Checking droplet status... (attempt: %d)", attempts) + + _, status, err := client.DropletStatus(dropletId) + + if err != nil { + log.Println(err) + break + } + + if status == desiredState { + break + } + + // Wait 3 seconds in between + time.Sleep(3 * time.Second) + } + + active <- true + }() + + log.Printf("Waiting for up to 3 minutes for droplet to become %s", desiredState) + duration, _ := time.ParseDuration("3m") + timeout := time.After(duration) + +ActiveWaitLoop: + for { + select { + case <-active: + // We connected. Just break the loop. + break ActiveWaitLoop + case <-timeout: + err := errors.New("Timeout while waiting to for droplet to become active") + return err + } + } + + // If we got this far, there were no errors + return nil +} diff --git a/plugin/builder-digital-ocean/main.go b/plugin/builder-digital-ocean/main.go new file mode 100644 index 000000000..28dcb9726 --- /dev/null +++ b/plugin/builder-digital-ocean/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/mitchellh/packer/packer/plugin" + "github.com/pearkes/packer/builder/digitalocean" +) + +func main() { + plugin.ServeBuilder(new(digitalocean.Builder)) +}