diff --git a/builder/linode/artifact.go b/builder/linode/artifact.go new file mode 100644 index 000000000..3e1bea96c --- /dev/null +++ b/builder/linode/artifact.go @@ -0,0 +1,32 @@ +package linode + +import ( + "context" + "fmt" + "log" + + "github.com/linode/linodego" +) + +type Artifact struct { + ImageID string + ImageLabel string + + Driver *linodego.Client +} + +func (a Artifact) BuilderId() string { return BuilderID } +func (a Artifact) Files() []string { return nil } +func (a Artifact) Id() string { return a.ImageID } + +func (a Artifact) String() string { + return fmt.Sprintf("Linode image: %s (%s)", a.ImageLabel, a.ImageID) +} + +func (a Artifact) State(name string) interface{} { return nil } + +func (a Artifact) Destroy() error { + log.Printf("Destroying image: %s (%s)", a.ImageID, a.ImageLabel) + err := a.Driver.DeleteImage(context.TODO(), a.ImageID) + return err +} diff --git a/builder/linode/artifact_test.go b/builder/linode/artifact_test.go new file mode 100644 index 000000000..e049b2310 --- /dev/null +++ b/builder/linode/artifact_test.go @@ -0,0 +1,33 @@ +package linode + +import ( + "testing" + + "github.com/hashicorp/packer/packer" +) + +func TestArtifact_Impl(t *testing.T) { + var raw interface{} + raw = &Artifact{} + if _, ok := raw.(packer.Artifact); !ok { + t.Fatalf("Artifact should be artifact") + } +} + +func TestArtifactId(t *testing.T) { + a := &Artifact{"private/42", "packer-foobar", nil} + expected := "private/42" + + if a.Id() != expected { + t.Fatalf("artifact ID should match: %v", expected) + } +} + +func TestArtifactString(t *testing.T) { + a := &Artifact{"private/42", "packer-foobar", nil} + expected := "Linode image: packer-foobar (private/42)" + + if a.String() != expected { + t.Fatalf("artifact string should match: %v", expected) + } +} diff --git a/builder/linode/builder.go b/builder/linode/builder.go new file mode 100644 index 000000000..2837b1e7b --- /dev/null +++ b/builder/linode/builder.go @@ -0,0 +1,98 @@ +// The linode package contains a packer.Builder implementation +// that builds Linode images. +package linode + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/packer/common" + "github.com/linode/linodego" + + "github.com/hashicorp/packer/helper/communicator" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +// The unique ID for this builder. +const BuilderID = "packer.linode" + +// Builder represents a Packer Builder. +type Builder struct { + config *Config + runner multistep.Runner +} + +func (b *Builder) Prepare(raws ...interface{}) ([]string, error) { + c, warnings, errs := NewConfig(raws...) + if errs != nil { + return warnings, errs + } + b.config = c + return nil, nil +} + +func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (ret packer.Artifact, err error) { + ui.Say("Running builder ...") + + client := newLinodeClient(b.config.PersonalAccessToken) + + if err != nil { + ui.Error(err.Error()) + return nil, err + } + + state := new(multistep.BasicStateBag) + state.Put("config", b.config) + state.Put("hook", hook) + state.Put("ui", ui) + + steps := []multistep.Step{ + &StepCreateSSHKey{ + Debug: b.config.PackerDebug, + DebugKeyPath: fmt.Sprintf("linode_%s.pem", b.config.PackerBuildName), + }, + &stepCreateLinode{client}, + &communicator.StepConnect{ + Config: &b.config.Comm, + Host: commHost, + SSHConfig: b.config.Comm.SSHConfigFunc(), + }, + &common.StepProvision{}, + &common.StepCleanupTempKeys{ + Comm: &b.config.Comm, + }, + &stepShutdownLinode{client}, + &stepCreateImage{client}, + } + + b.runner = common.NewRunner(steps, b.config.PackerConfig, ui) + b.runner.Run(ctx, state) + + if rawErr, ok := state.GetOk("error"); ok { + return nil, rawErr.(error) + } + + // If we were interrupted or cancelled, then just exit. + if _, ok := state.GetOk(multistep.StateCancelled); ok { + return nil, errors.New("Build was cancelled.") + } + + if _, ok := state.GetOk(multistep.StateHalted); ok { + return nil, errors.New("Build was halted.") + } + + if _, ok := state.GetOk("image"); !ok { + return nil, errors.New("Cannot find image in state.") + } + + image := state.Get("image").(*linodego.Image) + artifact := Artifact{ + ImageLabel: image.Label, + ImageID: image.ID, + Driver: &client, + } + + return artifact, nil +} diff --git a/builder/linode/builder_acc_test.go b/builder/linode/builder_acc_test.go new file mode 100644 index 000000000..d5a999d6c --- /dev/null +++ b/builder/linode/builder_acc_test.go @@ -0,0 +1,34 @@ +package linode + +import ( + "os" + "testing" + + builderT "github.com/hashicorp/packer/helper/builder/testing" +) + +func TestBuilderAcc_basic(t *testing.T) { + builderT.Test(t, builderT.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Builder: &Builder{}, + Template: testBuilderAccBasic, + }) +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("LINODE_TOKEN"); v == "" { + t.Fatal("LINODE_TOKEN must be set for acceptance tests") + } +} + +const testBuilderAccBasic = ` +{ + "builders": [{ + "type": "test", + "region": "us-east", + "instance_type": "g6-nanode-1", + "image": "linode/alpine3.9", + "ssh_username": "root" + }] +} +` diff --git a/builder/linode/builder_test.go b/builder/linode/builder_test.go new file mode 100644 index 000000000..918be6b2c --- /dev/null +++ b/builder/linode/builder_test.go @@ -0,0 +1,287 @@ +package linode + +import ( + "strconv" + "testing" + + "github.com/hashicorp/packer/packer" +) + +func testConfig() map[string]interface{} { + return map[string]interface{}{ + "linode_token": "bar", + "region": "us-east", + "instance_type": "g6-nanode-1", + "ssh_username": "root", + "image": "linode/alpine3.9", + } +} + +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{}{ + "linode_token": []string{}, + } + + warnings, err := b.Prepare(c) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err == nil { + t.Fatalf("prepare should fail") + } +} + +func TestBuilderPrepare_InvalidKey(t *testing.T) { + var b Builder + config := testConfig() + + // Add a random key + config["i_should_not_be_valid"] = true + warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err == nil { + t.Fatal("should have error") + } +} + +func TestBuilderPrepare_Region(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + delete(config, "region") + warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err == nil { + t.Fatalf("should error") + } + + expected := "us-east" + + // Test set + config["region"] = expected + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.Region != expected { + t.Errorf("found %s, expected %s", b.config.Region, expected) + } +} + +func TestBuilderPrepare_Size(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + delete(config, "instance_type") + warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err == nil { + t.Fatalf("should error") + } + + expected := "g6-nanode-1" + + // Test set + config["instance_type"] = expected + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.InstanceType != expected { + t.Errorf("found %s, expected %s", b.config.InstanceType, expected) + } +} + +func TestBuilderPrepare_Image(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + delete(config, "image") + warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err == nil { + t.Fatal("should error") + } + + expected := "linode/alpine3.9" + + // Test set + config["image"] = expected + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.Image != expected { + t.Errorf("found %s, expected %s", b.config.Image, expected) + } +} + +func TestBuilderPrepare_StateTimeout(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + // Test set + config["state_timeout"] = "5m" + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + // Test bad + config["state_timeout"] = "tubes" + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err == nil { + t.Fatal("should have error") + } + +} + +func TestBuilderPrepare_ImageLabel(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.ImageLabel == "" { + t.Errorf("invalid: %s", b.config.ImageLabel) + } + + // Test set + config["image_label"] = "foobarbaz" + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + // Test set with template + config["image_label"] = "{{timestamp}}" + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + _, err = strconv.ParseInt(b.config.ImageLabel, 0, 0) + if err != nil { + t.Fatalf("failed to parse int in template: %s", err) + } + +} + +func TestBuilderPrepare_Label(t *testing.T) { + var b Builder + config := testConfig() + + // Test default + warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.Label == "" { + t.Errorf("invalid: %s", b.config.Label) + } + + // Test normal set + config["instance_label"] = "foobar" + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + // Test with template + config["instance_label"] = "foobar-{{timestamp}}" + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + // Test with bad template + config["instance_label"] = "foobar-{{" + b = Builder{} + warnings, err = b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err == nil { + t.Fatal("should have error") + } + +} diff --git a/builder/linode/config.go b/builder/linode/config.go new file mode 100644 index 000000000..079c44639 --- /dev/null +++ b/builder/linode/config.go @@ -0,0 +1,156 @@ +package linode + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + "regexp" + "time" + + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/helper/communicator" + "github.com/hashicorp/packer/helper/config" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/template/interpolate" +) + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + ctx interpolate.Context + Comm communicator.Config `mapstructure:",squash"` + + PersonalAccessToken string `mapstructure:"linode_token"` + + Region string `mapstructure:"region"` + InstanceType string `mapstructure:"instance_type"` + Label string `mapstructure:"instance_label"` + Tags []string `mapstructure:"instance_tags"` + Image string `mapstructure:"image"` + SwapSize int `mapstructure:"swap_size"` + RootPass string `mapstructure:"root_pass"` + RootSSHKey string `mapstructure:"root_ssh_key"` + ImageLabel string `mapstructure:"image_label"` + Description string `mapstructure:"image_description"` + + RawStateTimeout string `mapstructure:"state_timeout"` + + stateTimeout time.Duration + interCtx interpolate.Context +} + +func createRandomRootPassword() (string, error) { + rawRootPass := make([]byte, 50) + _, err := rand.Read(rawRootPass) + if err != nil { + return "", fmt.Errorf("Failed to generate random password") + } + rootPass := base64.StdEncoding.EncodeToString(rawRootPass) + return rootPass, nil +} + +func NewConfig(raws ...interface{}) (*Config, []string, error) { + c := new(Config) + + if err := config.Decode(c, &config.DecodeOpts{ + Interpolate: true, + InterpolateContext: &c.ctx, + InterpolateFilter: &interpolate.RenderFilter{ + Exclude: []string{ + "run_command", + }, + }, + }, raws...); err != nil { + return nil, nil, err + } + + var errs *packer.MultiError + + // Defaults + + if c.PersonalAccessToken == "" { + // Default to environment variable for linode_token, if it exists + c.PersonalAccessToken = os.Getenv("LINODE_TOKEN") + } + + if c.ImageLabel == "" { + if def, err := interpolate.Render("packer-{{timestamp}}", nil); err == nil { + c.ImageLabel = def + } else { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Unable to render image name: %s", err)) + } + } + + if c.Label == "" { + // Default to packer-[time-ordered-uuid] + if def, err := interpolate.Render("packer-{{timestamp}}", nil); err == nil { + c.Label = def + } else { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Unable to render Linode label: %s", err)) + } + } + + if c.RootPass == "" { + var err error + c.RootPass, err = createRandomRootPassword() + if err != nil { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Unable to generate root_pass: %s", err)) + } + } + + if c.RawStateTimeout == "" { + c.stateTimeout = 5 * time.Minute + } else { + if stateTimeout, err := time.ParseDuration(c.RawStateTimeout); err == nil { + c.stateTimeout = stateTimeout + } else { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Unable to parse state timeout: %s", err)) + } + } + + if es := c.Comm.Prepare(&c.ctx); len(es) > 0 { + errs = packer.MultiErrorAppend(errs, es...) + } + + c.Comm.SSHPassword = c.RootPass + + if c.PersonalAccessToken == "" { + // Required configurations that will display errors if not set + errs = packer.MultiErrorAppend( + errs, errors.New("linode_token is required")) + } + + if c.Region == "" { + errs = packer.MultiErrorAppend( + errs, errors.New("region is required")) + } + + if c.InstanceType == "" { + errs = packer.MultiErrorAppend( + errs, errors.New("instance_type is required")) + } + + if c.Image == "" { + errs = packer.MultiErrorAppend( + errs, errors.New("image is required")) + } + + if c.Tags == nil { + c.Tags = make([]string, 0) + } + tagRe := regexp.MustCompile("^[[:alnum:]:_-]{1,255}$") + + for _, t := range c.Tags { + if !tagRe.MatchString(t) { + errs = packer.MultiErrorAppend(errs, errors.New(fmt.Sprintf("invalid tag: %s", t))) + } + } + + if errs != nil && len(errs.Errors) > 0 { + return nil, nil, errs + } + + packer.LogSecretFilter.Set(c.PersonalAccessToken) + return c, nil, nil +} diff --git a/builder/linode/linode.go b/builder/linode/linode.go new file mode 100644 index 000000000..726a9b468 --- /dev/null +++ b/builder/linode/linode.go @@ -0,0 +1,30 @@ +package linode + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/packer/version" + "github.com/linode/linodego" + "golang.org/x/oauth2" +) + +func newLinodeClient(pat string) linodego.Client { + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: pat}) + + oauthTransport := &oauth2.Transport{ + Source: tokenSource, + } + oauth2Client := &http.Client{ + Transport: oauthTransport, + } + + client := linodego.NewClient(oauth2Client) + + projectURL := "https://www.packer.io" + userAgent := fmt.Sprintf("Packer/%s (+%s) linodego/%s", + version.FormattedVersion(), projectURL, linodego.Version) + + client.SetUserAgent(userAgent) + return client +} diff --git a/builder/linode/ssh.go b/builder/linode/ssh.go new file mode 100644 index 000000000..6380ecb0e --- /dev/null +++ b/builder/linode/ssh.go @@ -0,0 +1,27 @@ +package linode + +import ( + "fmt" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/linode/linodego" + "golang.org/x/crypto/ssh" +) + +func commHost(state multistep.StateBag) (string, error) { + instance := state.Get("instance").(*linodego.Instance) + if len(instance.IPv4) == 0 { + return "", fmt.Errorf("Linode instance %d has no IPv4 addresses!", instance.ID) + } + return instance.IPv4[0].String(), nil +} + +func sshConfig(state multistep.StateBag) (*ssh.ClientConfig, error) { + return &ssh.ClientConfig{ + User: "root", + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Auth: []ssh.AuthMethod{ + ssh.Password(state.Get("root_pass").(string)), + }, + }, nil +} diff --git a/builder/linode/step_create_image.go b/builder/linode/step_create_image.go new file mode 100644 index 000000000..4e5ea5a64 --- /dev/null +++ b/builder/linode/step_create_image.go @@ -0,0 +1,48 @@ +package linode + +import ( + "context" + "errors" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "github.com/linode/linodego" +) + +type stepCreateImage struct { + client linodego.Client +} + +func (s *stepCreateImage) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + c := state.Get("config").(*Config) + ui := state.Get("ui").(packer.Ui) + disk := state.Get("disk").(*linodego.InstanceDisk) + instance := state.Get("instance").(*linodego.Instance) + + ui.Say("Creating image...") + image, err := s.client.CreateImage(ctx, linodego.ImageCreateOptions{ + DiskID: disk.ID, + Label: c.ImageLabel, + Description: c.Description, + }) + + if err == nil { + _, err = s.client.WaitForInstanceDiskStatus(ctx, instance.ID, disk.ID, linodego.DiskReady, 600) + } + + if err == nil { + image, err = s.client.GetImage(ctx, image.ID) + } + + if err != nil { + err = errors.New("Error creating image: " + err.Error()) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + state.Put("image", image) + return multistep.ActionContinue +} + +func (s *stepCreateImage) Cleanup(state multistep.StateBag) {} diff --git a/builder/linode/step_create_linode.go b/builder/linode/step_create_linode.go new file mode 100644 index 000000000..ca15facfd --- /dev/null +++ b/builder/linode/step_create_linode.go @@ -0,0 +1,94 @@ +package linode + +import ( + "context" + "errors" + "time" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "github.com/linode/linodego" +) + +type stepCreateLinode struct { + client linodego.Client +} + +func (s *stepCreateLinode) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + c := state.Get("config").(*Config) + ui := state.Get("ui").(packer.Ui) + + ui.Say("Creating Linode...") + + createOpts := linodego.InstanceCreateOptions{ + RootPass: c.Comm.Password(), + AuthorizedKeys: []string{string(c.Comm.SSHPublicKey)}, + Region: c.Region, + Type: c.InstanceType, + Label: c.Label, + Image: c.Image, + SwapSize: &c.SwapSize, + } + + instance, err := s.client.CreateInstance(ctx, createOpts) + if err != nil { + err = errors.New("Error creating Linode: " + err.Error()) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + state.Put("instance", instance) + + // wait until instance is running + for instance.Status != linodego.InstanceRunning { + time.Sleep(2 * time.Second) + if instance, err = s.client.GetInstance(ctx, instance.ID); err != nil { + err = errors.New("Error creating Linode: " + err.Error()) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + state.Put("instance", instance) + } + + disk, err := s.findDisk(ctx, instance.ID) + if err != nil { + err = errors.New("Error creating Linode: " + err.Error()) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } else if disk == nil { + err := errors.New("Error creating Linode: no suitable disk was found") + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + state.Put("disk", disk) + return multistep.ActionContinue +} + +func (s *stepCreateLinode) findDisk(ctx context.Context, instanceID int) (*linodego.InstanceDisk, error) { + disks, err := s.client.ListInstanceDisks(ctx, instanceID, nil) + if err != nil { + return nil, err + } + for _, disk := range disks { + if disk.Filesystem != linodego.FilesystemSwap { + return &disk, nil + } + } + return nil, nil +} + +func (s *stepCreateLinode) Cleanup(state multistep.StateBag) { + instance, ok := state.GetOk("instance") + if !ok { + return + } + + ui := state.Get("ui").(packer.Ui) + + if err := s.client.DeleteInstance(context.Background(), instance.(*linodego.Instance).ID); err != nil { + ui.Error("Error cleaning up Linode: " + err.Error()) + } +} diff --git a/builder/linode/step_create_ssh_key.go b/builder/linode/step_create_ssh_key.go new file mode 100644 index 000000000..2e0776ade --- /dev/null +++ b/builder/linode/step_create_ssh_key.go @@ -0,0 +1,95 @@ +package linode + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "os" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "golang.org/x/crypto/ssh" +) + +// StepCreateSSHKey represents a Packer build step that generates SSH key pairs. +type StepCreateSSHKey struct { + Debug bool + DebugKeyPath string +} + +// Run executes the Packer build step that generates SSH key pairs. +// The key pairs are added to the ssh config +func (s *StepCreateSSHKey) Run(_ context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + config := state.Get("config").(*Config) + + if config.Comm.SSHPrivateKeyFile != "" { + ui.Say("Using existing SSH private key") + privateKeyBytes, err := ioutil.ReadFile(config.Comm.SSHPrivateKeyFile) + if err != nil { + state.Put("error", fmt.Errorf( + "Error loading configured private key file: %s", err)) + return multistep.ActionHalt + } + + config.Comm.SSHPrivateKey = privateKeyBytes + config.Comm.SSHPublicKey = nil + + return multistep.ActionContinue + } + + ui.Say("Creating temporary SSH key for instance...") + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + err := fmt.Errorf("Error creating temporary ssh key: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + priv_blk := pem.Block{ + Type: "RSA PRIVATE KEY", + Headers: nil, + Bytes: x509.MarshalPKCS1PrivateKey(priv), + } + + pub, err := ssh.NewPublicKey(&priv.PublicKey) + if err != nil { + err := fmt.Errorf("Error creating temporary ssh key: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + config.Comm.SSHPrivateKey = pem.EncodeToMemory(&priv_blk) + config.Comm.SSHPublicKey = ssh.MarshalAuthorizedKey(pub) + + // Linode has a serious issue with the newline that the ssh package appends to the end of the key. + if config.Comm.SSHPublicKey[len(config.Comm.SSHPublicKey)-1] == '\n' { + config.Comm.SSHPublicKey = config.Comm.SSHPublicKey[:len(config.Comm.SSHPublicKey)-1] + } + + if s.Debug { + ui.Message(fmt.Sprintf("Saving key for debug purposes: %s", s.DebugKeyPath)) + f, err := os.Create(s.DebugKeyPath) + if err != nil { + state.Put("error", fmt.Errorf("Error saving debug key: %s", err)) + return multistep.ActionHalt + } + + // Write out the key + err = pem.Encode(f, &priv_blk) + f.Close() + if err != nil { + state.Put("error", fmt.Errorf("Error saving debug key: %s", err)) + return multistep.ActionHalt + } + } + return multistep.ActionContinue +} + +// Nothing to clean up. SSH keys are associated with a single Linode instance. +func (s *StepCreateSSHKey) Cleanup(state multistep.StateBag) {} diff --git a/builder/linode/step_shutdown_linode.go b/builder/linode/step_shutdown_linode.go new file mode 100644 index 000000000..f9c065224 --- /dev/null +++ b/builder/linode/step_shutdown_linode.go @@ -0,0 +1,39 @@ +package linode + +import ( + "context" + "errors" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "github.com/linode/linodego" +) + +type stepShutdownLinode struct { + client linodego.Client +} + +func (s *stepShutdownLinode) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + instance := state.Get("instance").(*linodego.Instance) + + ui.Say("Shutting down Linode...") + if err := s.client.ShutdownInstance(ctx, instance.ID); err != nil { + err = errors.New("Error shutting down Linode: " + err.Error()) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + _, err := s.client.WaitForInstanceStatus(ctx, instance.ID, linodego.InstanceOffline, 120) + if err != nil { + err = errors.New("Error shutting down Linode: " + err.Error()) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + return multistep.ActionContinue +} + +func (s *stepShutdownLinode) Cleanup(state multistep.StateBag) {} diff --git a/command/plugin.go b/command/plugin.go index 62c909168..088c4af70 100644 --- a/command/plugin.go +++ b/command/plugin.go @@ -29,6 +29,7 @@ import ( hyperonebuilder "github.com/hashicorp/packer/builder/hyperone" hypervisobuilder "github.com/hashicorp/packer/builder/hyperv/iso" hypervvmcxbuilder "github.com/hashicorp/packer/builder/hyperv/vmcx" + linodebuilder "github.com/hashicorp/packer/builder/linode" lxcbuilder "github.com/hashicorp/packer/builder/lxc" lxdbuilder "github.com/hashicorp/packer/builder/lxd" ncloudbuilder "github.com/hashicorp/packer/builder/ncloud" @@ -109,6 +110,7 @@ var Builders = map[string]packer.Builder{ "hyperone": new(hyperonebuilder.Builder), "hyperv-iso": new(hypervisobuilder.Builder), "hyperv-vmcx": new(hypervvmcxbuilder.Builder), + "linode": new(linodebuilder.Builder), "lxc": new(lxcbuilder.Builder), "lxd": new(lxdbuilder.Builder), "ncloud": new(ncloudbuilder.Builder), diff --git a/go.mod b/go.mod index 3a36dd80b..6ac5d5901 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/klauspost/pgzip v0.0.0-20151221113845-47f36e165cec github.com/kr/fs v0.0.0-20131111012553-2788f0dbd169 // indirect github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 // indirect + github.com/linode/linodego v0.7.1 github.com/marstr/guid v0.0.0-20170427235115-8bdf7d1a087c // indirect github.com/masterzen/azure-sdk-for-go v0.0.0-20161014135628-ee4f0065d00c // indirect github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect @@ -118,5 +119,6 @@ require ( gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/h2non/gock.v1 v1.0.12 // indirect gopkg.in/jarcoal/httpmock.v1 v1.0.0-20181117152235-275e9df93516 // indirect + gopkg.in/resty.v1 v1.12.0 // indirect gopkg.in/yaml.v2 v2.2.2 // indirect ) diff --git a/go.sum b/go.sum index 23644de2e..21ebcb7d6 100644 --- a/go.sum +++ b/go.sum @@ -214,6 +214,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/linode/linodego v0.7.1 h1:4WZmMpSA2NRwlPZcc0+4Gyn7rr99Evk9bnr0B3gXRKE= +github.com/linode/linodego v0.7.1/go.mod h1:ga11n3ivecUrPCHN0rANxKmfWBJVkOXfLMZinAbj2sY= github.com/marstr/guid v0.0.0-20170427235115-8bdf7d1a087c h1:N7uWGS2fTwH/4BwxbHiJZNAFTSJ5yPU0emHsQWvkxEY= github.com/marstr/guid v0.0.0-20170427235115-8bdf7d1a087c/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= github.com/masterzen/azure-sdk-for-go v0.0.0-20161014135628-ee4f0065d00c h1:FMUOnVGy8nWk1cvlMCAoftRItQGMxI0vzJ3dQjeZTCE= @@ -386,6 +388,7 @@ golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd h1:HuTn7WObtcDo9uEEU7rEqL0jYthdXAmZ6PP+meazmaU= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -450,6 +453,8 @@ gopkg.in/h2non/gock.v1 v1.0.12/go.mod h1:KHI4Z1sxDW6P4N3DfTWSEza07YpkQP7KJBfglRM gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/jarcoal/httpmock.v1 v1.0.0-20181117152235-275e9df93516 h1:H6trpavCIuipdInWrab8l34Mf+GGVfphniHostMdMaQ= gopkg.in/jarcoal/httpmock.v1 v1.0.0-20181117152235-275e9df93516/go.mod h1:d3R+NllX3X5e0zlG1Rful3uLvsGC/Q3OHut5464DEQw= +gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= diff --git a/test/fixtures/builder-linode/minimal.json b/test/fixtures/builder-linode/minimal.json new file mode 100644 index 000000000..400cfa63a --- /dev/null +++ b/test/fixtures/builder-linode/minimal.json @@ -0,0 +1,28 @@ +{ + "variables": { + "linode_token": "{{env `LINODE_TOKEN`}}" + }, + "builders": [ + { + "type": "linode", + "linode_token": "{{user `linode_token`}}", + + "region": "us-central", + "swap_size": 256, + "image": "linode/debian9", + "instance_type": "g6-nanode-1", + "instance_label": "packerbats-minimal-{{timestamp}}", + + "image_label": "packerbats-minimal-image-{{timestamp}}", + "image_description": "packerbats", + + "ssh_username": "root" + } + ], + "provisioners": [ + { + "type": "shell", + "inline": ["echo Hello > /root/message.txt"] + } + ] + } diff --git a/vendor/modules.txt b/vendor/modules.txt index 910efa76e..206dc7eea 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -281,6 +281,8 @@ github.com/klauspost/pgzip github.com/konsorten/go-windows-terminal-sequences # github.com/kr/fs v0.0.0-20131111012553-2788f0dbd169 github.com/kr/fs +# github.com/linode/linodego v0.7.1 +github.com/linode/linodego # github.com/marstr/guid v0.0.0-20170427235115-8bdf7d1a087c github.com/marstr/guid # github.com/masterzen/azure-sdk-for-go v0.0.0-20161014135628-ee4f0065d00c @@ -475,6 +477,7 @@ golang.org/x/net/http/httpguts golang.org/x/net/http2/hpack golang.org/x/net/idna golang.org/x/net/context/ctxhttp +golang.org/x/net/publicsuffix golang.org/x/net/html golang.org/x/net/internal/timeseries golang.org/x/net/html/atom @@ -575,5 +578,7 @@ google.golang.org/grpc/credentials/internal google.golang.org/grpc/balancer/base google.golang.org/grpc/binarylog/grpc_binarylog_v1 google.golang.org/grpc/internal/syscall +# gopkg.in/resty.v1 v1.12.0 +gopkg.in/resty.v1 # gopkg.in/yaml.v2 v2.2.2 gopkg.in/yaml.v2 diff --git a/website/source/docs/builders/linode.html.md b/website/source/docs/builders/linode.html.md new file mode 100644 index 000000000..3e2cd1719 --- /dev/null +++ b/website/source/docs/builders/linode.html.md @@ -0,0 +1,96 @@ +--- +description: | + The linode Packer builder is able to create new images for use with Linode. The + builder takes a source image, runs any provisioning necessary on the image + after launching it, then snapshots it into a reusable image. This reusable + image can then be used as the foundation of new servers that are launched + within all Linode regions. +layout: docs +page_title: 'Linode - Builders' +sidebar_current: 'docs-builders-linode' +--- + +# Linode Builder + +Type: `linode` + +The `linode` Packer builder is able to create [Linode +Images](https://www.linode.com/docs/platform/disk-images/linode-images/) for +use with [Linode](https://www.linode.com). The builder takes a source image, +runs any provisioning necessary on the image after launching it, then snapshots +it into a reusable image. This reusable image can then be used as the +foundation of new servers that are launched within Linode. + +The builder does *not* manage images. Once it creates an image, it is up to you +to use it or delete it. + +## Configuration Reference + +There are many configuration options available for the builder. They are +segmented below into two categories: required and optional parameters. Within +each category, the available configuration keys are alphabetized. + +In addition to the options listed here, a +[communicator](/docs/templates/communicator.html) can be configured for this +builder. + +### Required + +- `linode_token` (string) - The client TOKEN to use to access your account. + +- `image` (string) - An Image ID to deploy the Disk from. Official Linode + Images start with `linode/`, while user Images start with `private/`. See + [images](https://api.linode.com/v4/images) for more information on the + Images available for use. Examples are `linode/debian9`, `linode/fedora28`, + `linode/ubuntu18.04`, `linode/arch`, and `private/12345`. + +- `region` (string) - The id of the region to launch the Linode instance in. + Images are available in all regions, but there will be less delay when + deploying from the region where the image was taken. Examples are + `us-east`, `us-central`, `us-west`, `ap-south`, `ca-east`, `ap-northeast`, + `eu-central`, and `eu-west`. + +- `instance_type` (string) - The Linode type defines the pricing, CPU, disk, + and RAM specs of the instance. Examples are `g6-nanode-1`, `g6-standard-2`, + `g6-highmem-16`, and `g6-dedicated-16`. + +### Optional + +- `instance_label` (string) - The name assigned to the Linode Instance. + +- `instance_tags` (list) - Tags to apply to the instance when it is created. + +- `swap_size` (int) - The disk size (MiB) allocated for swap space. + +- `image_label` (string) - The name of the resulting image that will appear + in your account. Defaults to "packer-{{timestamp}}" (see [configuration + templates](/docs/templates/engine.html) for more info). + +- `image_description` (string) - The description of the resulting image that + will appear in your account. Defaults to "". + +- `state_timeout` (string) - The time to wait, as a duration string, for the + Linode instance to enter a desired state (such as "running") before timing + out. The default state timeout is "5m". + +## Basic Example + +Here is a Linode builder example. The `linode_token` should be replaced with an +actual [Linode Personal Access +Token](https://www.linode.com/docs/platform/api/getting-started-with-the-linode-api/#get-an-access-token). + +``` json +{ + "type": "linode", + "linode_token": "YOUR API TOKEN", + "image": "linode/debian9", + "region": "us-east", + "instance_type": "g6-nanode-1", + "instance_label": "temporary-linode-{{timestamp}}", + + "image_label": "private-image-{{timestamp}}", + "image_description": "My Private Image", + + "ssh_username": "root" +} +``` diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index c14812be6..9f3a84d5a 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -116,6 +116,9 @@ +