Add base support for openstack [GH-155]

This change adds base support for an openstack builder.
Thank you to Rackspace for providing cloud assets to complete this work
and @sam-falvo for working with us on the perigee/gophercloud changes.
This commit is contained in:
Mark Peek 2013-08-26 21:57:23 -07:00
parent e7ba508745
commit 4b7da04052
16 changed files with 926 additions and 0 deletions

View File

@ -0,0 +1,73 @@
package openstack
import (
"fmt"
"github.com/mitchellh/packer/packer"
"github.com/rackspace/gophercloud"
"os"
)
// AccessConfig is for common configuration related to openstack access
type AccessConfig struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Provider string `mapstructure:"provider"`
}
// Auth returns a valid Auth object for access to openstack services, or
// an error if the authentication couldn't be resolved.
func (c *AccessConfig) Auth() (gophercloud.AccessProvider, error) {
username := c.Username
password := c.Password
provider := c.Provider
if username == "" {
username = os.Getenv("SDK_USERNAME")
}
if password == "" {
password = os.Getenv("SDK_PASSWORD")
}
if provider == "" {
provider = os.Getenv("SDK_PROVIDER")
}
authoptions := gophercloud.AuthOptions{
Username: username,
Password: password,
AllowReauth: true,
}
return gophercloud.Authenticate(provider, authoptions)
}
func (c *AccessConfig) Prepare(t *packer.ConfigTemplate) []error {
if t == nil {
var err error
t, err = packer.NewConfigTemplate()
if err != nil {
return []error{err}
}
}
templates := map[string]*string{
"username": &c.Username,
"password": &c.Password,
"provider": &c.Provider,
}
errs := make([]error, 0)
for n, ptr := range templates {
var err error
*ptr, err = t.Process(*ptr, nil)
if err != nil {
errs = append(
errs, fmt.Errorf("Error processing %s: %s", n, err))
}
}
if len(errs) > 0 {
return errs
}
return nil
}

View File

@ -0,0 +1,16 @@
package openstack
import (
"testing"
)
func testAccessConfig() *AccessConfig {
return &AccessConfig{}
}
func TestAccessConfigPrepare_Region(t *testing.T) {
c := testAccessConfig()
if err := c.Prepare(nil); err != nil {
t.Fatalf("shouldn't have err: %s", err)
}
}

View File

@ -0,0 +1,122 @@
// The openstack package contains a packer.Builder implementation that
// builds Images for openstack.
package openstack
import (
//"fmt"
"github.com/mitchellh/multistep"
"github.com/mitchellh/packer/common"
"github.com/mitchellh/packer/packer"
"github.com/rackspace/gophercloud"
"log"
)
// The unique ID for this builder
const BuilderId = "mitchellh.openstack"
type config struct {
common.PackerConfig `mapstructure:",squash"`
AccessConfig `mapstructure:",squash"`
ImageConfig `mapstructure:",squash"`
RunConfig `mapstructure:",squash"`
tpl *packer.ConfigTemplate
}
type Builder struct {
config config
runner multistep.Runner
}
func (b *Builder) Prepare(raws ...interface{}) error {
md, err := common.DecodeConfig(&b.config, raws...)
if err != nil {
return err
}
b.config.tpl, err = packer.NewConfigTemplate()
if err != nil {
return err
}
b.config.tpl.UserVars = b.config.PackerUserVars
// Accumulate any errors
errs := common.CheckUnusedConfig(md)
errs = packer.MultiErrorAppend(errs, b.config.AccessConfig.Prepare(b.config.tpl)...)
errs = packer.MultiErrorAppend(errs, b.config.ImageConfig.Prepare(b.config.tpl)...)
errs = packer.MultiErrorAppend(errs, b.config.RunConfig.Prepare(b.config.tpl)...)
if errs != nil && len(errs.Errors) > 0 {
return 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) {
auth, err := b.config.AccessConfig.Auth()
if err != nil {
return nil, err
}
// Setup the state bag and initial state for the steps
state := make(map[string]interface{})
state["config"] = b.config
state["accessor"] = auth
api := &gophercloud.ApiCriteria{
Name: "cloudServersOpenStack",
Region: "DFW",
VersionId: "2",
UrlChoice: gophercloud.PublicURL,
}
state["api"] = api
state["hook"] = hook
state["ui"] = ui
// Build the steps
steps := []multistep.Step{
&StepKeyPair{},
&StepRunSourceServer{
Name: b.config.ImageName,
Flavor: b.config.Flavor,
SourceImage: b.config.SourceImage,
},
&common.StepConnectSSH{
SSHAddress: SSHAddress(&auth, api, b.config.SSHPort),
SSHConfig: SSHConfig(b.config.SSHUsername),
SSHWaitTimeout: b.config.SSHTimeout(),
},
&common.StepProvision{},
&stepCreateImage{},
}
// Run!
if b.config.PackerDebug {
b.runner = &multistep.DebugRunner{
Steps: steps,
PauseFn: common.MultistepDebugFn(ui),
}
} else {
b.runner = &multistep.BasicRunner{Steps: steps}
}
b.runner.Run(state)
// If there was an error, return that
if rawErr, ok := state["error"]; ok {
return nil, rawErr.(error)
}
// XXX - add artifact
return nil, nil
}
func (b *Builder) Cancel() {
if b.runner != nil {
log.Println("Cancelling the step runner...")
b.runner.Cancel()
}
}

View File

@ -0,0 +1,78 @@
package openstack
import (
"github.com/mitchellh/packer/packer"
"testing"
)
func testConfig() map[string]interface{} {
return map[string]interface{}{
"username": "foo",
"password": "bar",
"provider": "foo",
"image_name": "foo",
"source_image": "foo",
"flavor": "foo",
"ssh_username": "root",
}
}
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{}{
"password": []string{},
}
err := b.Prepare(c)
if err == nil {
t.Fatalf("prepare should fail")
}
}
func TestBuilderPrepare_ImageName(t *testing.T) {
var b Builder
config := testConfig()
// Test good
config["image_name"] = "foo"
err := b.Prepare(config)
if err != nil {
t.Fatalf("should not have error: %s", err)
}
// Test bad
config["image_name"] = "foo {{"
b = Builder{}
err = b.Prepare(config)
if err == nil {
t.Fatal("should have error")
}
// Test bad
delete(config, "image_name")
b = Builder{}
err = b.Prepare(config)
if err == nil {
t.Fatal("should have error")
}
}
func TestBuilderPrepare_InvalidKey(t *testing.T) {
var b Builder
config := testConfig()
// Add a random key
config["i_should_not_be_valid"] = true
err := b.Prepare(config)
if err == nil {
t.Fatal("should have error")
}
}

View File

@ -0,0 +1,45 @@
package openstack
import (
"fmt"
"github.com/mitchellh/packer/packer"
)
// ImageConfig is for common configuration related to creating Images.
type ImageConfig struct {
ImageName string `mapstructure:"image_name"`
}
func (c *ImageConfig) Prepare(t *packer.ConfigTemplate) []error {
if t == nil {
var err error
t, err = packer.NewConfigTemplate()
if err != nil {
return []error{err}
}
}
templates := map[string]*string{
"image_name": &c.ImageName,
}
errs := make([]error, 0)
for n, ptr := range templates {
var err error
*ptr, err = t.Process(*ptr, nil)
if err != nil {
errs = append(
errs, fmt.Errorf("Error processing %s: %s", n, err))
}
}
if c.ImageName == "" {
errs = append(errs, fmt.Errorf("An image_name must be specified"))
}
if len(errs) > 0 {
return errs
}
return nil
}

View File

@ -0,0 +1,23 @@
package openstack
import (
"testing"
)
func testImageConfig() *ImageConfig {
return &ImageConfig{
ImageName: "foo",
}
}
func TestImageConfigPrepare_Region(t *testing.T) {
c := testImageConfig()
if err := c.Prepare(nil); err != nil {
t.Fatalf("shouldn't have err: %s", err)
}
c.ImageName = ""
if err := c.Prepare(nil); err == nil {
t.Fatal("should have error")
}
}

View File

@ -0,0 +1,86 @@
package openstack
import (
"errors"
"fmt"
"github.com/mitchellh/packer/packer"
"time"
)
// RunConfig contains configuration for running an instance from a source
// image and details on how to access that launched image.
type RunConfig struct {
SourceImage string `mapstructure:"source_image"`
Flavor string `mapstructure:"flavor"`
RawSSHTimeout string `mapstructure:"ssh_timeout"`
SSHUsername string `mapstructure:"ssh_username"`
SSHPort int `mapstructure:"ssh_port"`
// Unexported fields that are calculated from others
sshTimeout time.Duration
}
func (c *RunConfig) Prepare(t *packer.ConfigTemplate) []error {
if t == nil {
var err error
t, err = packer.NewConfigTemplate()
if err != nil {
return []error{err}
}
}
// Defaults
if c.SSHUsername == "" {
c.SSHUsername = "root"
}
if c.SSHPort == 0 {
c.SSHPort = 22
}
if c.RawSSHTimeout == "" {
c.RawSSHTimeout = "1m"
}
// Validation
var err error
errs := make([]error, 0)
if c.SourceImage == "" {
errs = append(errs, errors.New("A source_image must be specified"))
}
if c.Flavor == "" {
errs = append(errs, errors.New("A flavor must be specified"))
}
if c.SSHUsername == "" {
errs = append(errs, errors.New("An ssh_username must be specified"))
}
templates := map[string]*string{
"flavlor": &c.Flavor,
"ssh_timeout": &c.RawSSHTimeout,
"ssh_username": &c.SSHUsername,
"source_image": &c.SourceImage,
}
for n, ptr := range templates {
var err error
*ptr, err = t.Process(*ptr, nil)
if err != nil {
errs = append(
errs, fmt.Errorf("Error processing %s: %s", n, err))
}
}
c.sshTimeout, err = time.ParseDuration(c.RawSSHTimeout)
if err != nil {
errs = append(errs, fmt.Errorf("Failed parsing ssh_timeout: %s", err))
}
return errs
}
func (c *RunConfig) SSHTimeout() time.Duration {
return c.sshTimeout
}

View File

@ -0,0 +1,88 @@
package openstack
import (
"os"
"testing"
)
func init() {
// Clear out the openstack env vars so they don't
// affect our tests.
os.Setenv("SDK_USERNAME", "")
os.Setenv("SDK_PASSWORD", "")
os.Setenv("SDK_PROVIDER", "")
}
func testRunConfig() *RunConfig {
return &RunConfig{
SourceImage: "abcd",
Flavor: "m1.small",
SSHUsername: "root",
}
}
func TestRunConfigPrepare(t *testing.T) {
c := testRunConfig()
err := c.Prepare(nil)
if len(err) > 0 {
t.Fatalf("err: %s", err)
}
}
func TestRunConfigPrepare_InstanceType(t *testing.T) {
c := testRunConfig()
c.Flavor = ""
if err := c.Prepare(nil); len(err) != 1 {
t.Fatalf("err: %s", err)
}
}
func TestRunConfigPrepare_SourceImage(t *testing.T) {
c := testRunConfig()
c.SourceImage = ""
if err := c.Prepare(nil); len(err) != 1 {
t.Fatalf("err: %s", err)
}
}
func TestRunConfigPrepare_SSHPort(t *testing.T) {
c := testRunConfig()
c.SSHPort = 0
if err := c.Prepare(nil); len(err) != 0 {
t.Fatalf("err: %s", err)
}
if c.SSHPort != 22 {
t.Fatalf("invalid value: %d", c.SSHPort)
}
c.SSHPort = 44
if err := c.Prepare(nil); len(err) != 0 {
t.Fatalf("err: %s", err)
}
if c.SSHPort != 44 {
t.Fatalf("invalid value: %d", c.SSHPort)
}
}
func TestRunConfigPrepare_SSHTimeout(t *testing.T) {
c := testRunConfig()
c.RawSSHTimeout = ""
if err := c.Prepare(nil); len(err) != 0 {
t.Fatalf("err: %s", err)
}
c.RawSSHTimeout = "bad"
if err := c.Prepare(nil); len(err) != 1 {
t.Fatalf("err: %s", err)
}
}
func TestRunConfigPrepare_SSHUsername(t *testing.T) {
c := testRunConfig()
c.SSHUsername = ""
if err := c.Prepare(nil); len(err) != 0 {
t.Fatalf("err: %s", err)
}
}

View File

@ -0,0 +1,90 @@
package openstack
import (
"errors"
"fmt"
"github.com/mitchellh/multistep"
"github.com/rackspace/gophercloud"
"log"
"time"
)
// StateRefreshFunc is a function type used for StateChangeConf that is
// responsible for refreshing the item being watched for a state change.
//
// It returns three results. `result` is any object that will be returned
// as the final object after waiting for state change. This allows you to
// return the final updated object, for example an openstack instance after
// refreshing it.
//
// `state` is the latest state of that object. And `err` is any error that
// may have happened while refreshing the state.
type StateRefreshFunc func() (result interface{}, state string, progress int, err error)
// StateChangeConf is the configuration struct used for `WaitForState`.
type StateChangeConf struct {
Accessor *gophercloud.Access
Api *gophercloud.ApiCriteria
Pending []string
Refresh StateRefreshFunc
StepState map[string]interface{}
Target string
}
// ServerStateRefreshFunc returns a StateRefreshFunc that is used to watch
// an openstacn server.
func ServerStateRefreshFunc(accessor *gophercloud.Access, api *gophercloud.ApiCriteria, s *gophercloud.Server) StateRefreshFunc {
return func() (interface{}, string, int, error) {
csp, err := gophercloud.ServersApi(accessor, *api)
resp, err := csp.ServerById(s.Id)
if err != nil {
log.Printf("Error on ServerStateRefresh: %s", err)
return nil, "", 0, err
}
return resp, resp.Status, resp.Progress, nil
}
}
// WaitForState watches an object and waits for it to achieve a certain
// state.
func WaitForState(conf *StateChangeConf) (i interface{}, err error) {
log.Printf("Waiting for state to become: %s", conf.Target)
for {
var currentProgress int
var currentState string
i, currentState, currentProgress, err = conf.Refresh()
if err != nil {
return
}
if currentState == conf.Target {
return
}
if conf.StepState != nil {
if _, ok := conf.StepState[multistep.StateCancelled]; ok {
return nil, errors.New("interrupted")
}
}
found := false
for _, allowed := range conf.Pending {
if currentState == allowed {
found = true
break
}
}
if !found {
fmt.Errorf("unexpected state '%s', wanted target '%s'", currentState, conf.Target)
return
}
log.Printf("Waiting for state to become: %s currently %s (%d%%)", conf.Target, currentState, currentProgress)
time.Sleep(2 * time.Second)
}
return
}

55
builder/openstack/ssh.go Normal file
View File

@ -0,0 +1,55 @@
package openstack
import (
gossh "code.google.com/p/go.crypto/ssh"
"errors"
"fmt"
"github.com/mitchellh/packer/communicator/ssh"
"github.com/rackspace/gophercloud"
"time"
)
// SSHAddress returns a function that can be given to the SSH communicator
// for determining the SSH address based on the server AccessIPv4 setting..
func SSHAddress(accessor *gophercloud.AccessProvider, api *gophercloud.ApiCriteria, port int) func(map[string]interface{}) (string, error) {
return func(state map[string]interface{}) (string, error) {
for j := 0; j < 2; j++ {
s := state["server"].(*gophercloud.Server)
if s.AccessIPv4 != "" {
return fmt.Sprintf("%s:%d", s.AccessIPv4, port), nil
}
csp, err := gophercloud.ServersApi(*accessor, *api)
serverState, err := csp.ServerById(s.Id)
if err != nil {
return "", err
}
state["server"] = serverState
time.Sleep(1 * time.Second)
}
return "", errors.New("couldn't determine IP address for server")
}
}
// SSHConfig returns a function that can be used for the SSH communicator
// config for connecting to the instance created over SSH using the generated
// private key.
func SSHConfig(username string) func(map[string]interface{}) (*gossh.ClientConfig, error) {
return func(state map[string]interface{}) (*gossh.ClientConfig, error) {
privateKey := state["privateKey"].(string)
keyring := new(ssh.SimpleKeychain)
if err := keyring.AddPEMKey(privateKey); err != nil {
return nil, fmt.Errorf("Error setting up SSH config: %s", err)
}
return &gossh.ClientConfig{
User: username,
Auth: []gossh.ClientAuth{
gossh.ClientAuthKeyring(keyring),
},
}, nil
}
}

View File

@ -0,0 +1,71 @@
package openstack
import (
"fmt"
"github.com/mitchellh/multistep"
"github.com/mitchellh/packer/packer"
"github.com/rackspace/gophercloud"
"log"
"time"
)
type stepCreateImage struct{}
func (s *stepCreateImage) Run(state map[string]interface{}) multistep.StepAction {
accessor := state["accessor"].(*gophercloud.Access)
api := state["api"].(*gophercloud.ApiCriteria)
config := state["config"].(config)
server := state["server"].(*gophercloud.Server)
ui := state["ui"].(packer.Ui)
// Create the image
ui.Say(fmt.Sprintf("Creating the image: %s", config.ImageName))
createOpts := gophercloud.CreateImage{
Name: config.ImageName,
}
csp, err := gophercloud.ServersApi(accessor, *api)
imageId, err := csp.CreateImage(server.Id, createOpts)
if err != nil {
err := fmt.Errorf("Error creating image: %s", err)
state["error"] = err
ui.Error(err.Error())
return multistep.ActionHalt
}
// Set the Image ID in the state
ui.Say(fmt.Sprintf("Image: %s", imageId))
state["image"] = imageId
// Wait for the image to become ready
ui.Say("Waiting for image to become ready...")
if err := WaitForImage(accessor, api, imageId); err != nil {
err := fmt.Errorf("Error waiting for image: %s", err)
state["error"] = err
ui.Error(err.Error())
return multistep.ActionHalt
}
return multistep.ActionContinue
}
func (s *stepCreateImage) Cleanup(map[string]interface{}) {
// No cleanup...
}
// WaitForImage waits for the given Image ID to become ready.
func WaitForImage(accessor *gophercloud.Access, api *gophercloud.ApiCriteria, imageId string) error {
for {
csp, err := gophercloud.ServersApi(accessor, *api)
image, err := csp.ImageById(imageId)
if err != nil {
return err
}
if image.Status == "ACTIVE" {
return nil
}
log.Printf("Waiting for image creation status: %s (%d%%)", image.Status, image.Progress)
time.Sleep(2 * time.Second)
}
}

View File

@ -0,0 +1,59 @@
package openstack
import (
"cgl.tideland.biz/identifier"
"encoding/hex"
"fmt"
"github.com/mitchellh/multistep"
"github.com/mitchellh/packer/packer"
"github.com/rackspace/gophercloud"
"log"
)
type StepKeyPair struct {
keyName string
}
func (s *StepKeyPair) Run(state map[string]interface{}) multistep.StepAction {
accessor := state["accessor"].(*gophercloud.Access)
api := state["api"].(*gophercloud.ApiCriteria)
ui := state["ui"].(packer.Ui)
ui.Say("Creating temporary keypair for this instance...")
keyName := fmt.Sprintf("packer %s", hex.EncodeToString(identifier.NewUUID().Raw()))
log.Printf("temporary keypair name: %s", keyName)
csp, err := gophercloud.ServersApi(accessor, *api)
keyResp, err := csp.CreateKeyPair(gophercloud.NewKeyPair{Name: keyName})
if err != nil {
state["error"] = fmt.Errorf("Error creating temporary keypair: %s", err)
return multistep.ActionHalt
}
// Set the keyname so we know to delete it later
s.keyName = keyName
// Set some state data for use in future steps
state["keyPair"] = keyName
state["privateKey"] = keyResp.PrivateKey
return multistep.ActionContinue
}
func (s *StepKeyPair) Cleanup(state map[string]interface{}) {
// If no key name is set, then we never created it, so just return
if s.keyName == "" {
return
}
accessor := state["accessor"].(*gophercloud.Access)
api := state["api"].(*gophercloud.ApiCriteria)
ui := state["ui"].(packer.Ui)
ui.Say("Deleting temporary keypair...")
csp, err := gophercloud.ServersApi(accessor, *api)
err = csp.DeleteKeyPair(s.keyName)
if err != nil {
ui.Error(fmt.Sprintf(
"Error cleaning up keypair. Please delete the key manually: %s", s.keyName))
}
}

View File

@ -0,0 +1,108 @@
package openstack
import (
"fmt"
"github.com/mitchellh/multistep"
"github.com/mitchellh/packer/packer"
"github.com/rackspace/gophercloud"
"log"
)
type StepRunSourceServer struct {
Flavor string
Name string
SourceImage string
server *gophercloud.Server
}
func (s *StepRunSourceServer) Run(state map[string]interface{}) multistep.StepAction {
accessor := state["accessor"].(*gophercloud.Access)
api := state["api"].(*gophercloud.ApiCriteria)
keyName := state["keyPair"].(string)
ui := state["ui"].(packer.Ui)
csp, err := gophercloud.ServersApi(accessor, *api)
if err != nil {
err := fmt.Errorf("Error connecting to api: %s", err)
state["error"] = err
ui.Error(err.Error())
return multistep.ActionHalt
}
// XXX - validate image and flavor is available
server := gophercloud.NewServer{
Name: s.Name,
ImageRef: s.SourceImage,
FlavorRef: s.Flavor,
KeyPairName: keyName,
}
serverResp, err := csp.CreateServer(server)
if err != nil {
err := fmt.Errorf("Error launching source server: %s", err)
state["error"] = err
ui.Error(err.Error())
return multistep.ActionHalt
}
s.server, err = csp.ServerById(serverResp.Id)
log.Printf("server id: %s", s.server.Id)
ui.Say(fmt.Sprintf("Waiting for server (%s) to become ready...", s.server.Id))
stateChange := StateChangeConf{
Accessor: accessor,
Api: api,
Pending: []string{"BUILD"},
Target: "ACTIVE",
Refresh: ServerStateRefreshFunc(accessor, api, s.server),
StepState: state,
}
latestServer, err := WaitForState(&stateChange)
if err != nil {
err := fmt.Errorf("Error waiting for server (%s) to become ready: %s", s.server.Id, err)
state["error"] = err
ui.Error(err.Error())
return multistep.ActionHalt
}
s.server = latestServer.(*gophercloud.Server)
state["server"] = s.server
return multistep.ActionContinue
}
func (s *StepRunSourceServer) Cleanup(state map[string]interface{}) {
if s.server == nil {
return
}
accessor := state["accessor"].(*gophercloud.Access)
api := state["api"].(*gophercloud.ApiCriteria)
ui := state["ui"].(packer.Ui)
csp, err := gophercloud.ServersApi(accessor, *api)
if err != nil {
err := fmt.Errorf("Error connecting to api: %s", err)
state["error"] = err
ui.Error(err.Error())
return
}
ui.Say("Terminating the source server...")
if err := csp.DeleteServerById(s.server.Id); err != nil {
ui.Error(fmt.Sprintf("Error terminating server, may still be around: %s", err))
return
}
stateChange := StateChangeConf{
Accessor: accessor,
Api: api,
Pending: []string{"ACTIVE", "BUILD", "REBUILD", "SUSPENDED"},
Refresh: ServerStateRefreshFunc(accessor, api, s.server),
Target: "DELETED",
}
WaitForState(&stateChange)
}

View File

@ -23,6 +23,7 @@ const defaultConfig = `
"amazon-chroot": "packer-builder-amazon-chroot",
"amazon-instance": "packer-builder-amazon-instance",
"digitalocean": "packer-builder-digitalocean",
"openstack": "packer-builder-openstack",
"virtualbox": "packer-builder-virtualbox",
"vmware": "packer-builder-vmware"
},

View File

@ -0,0 +1,10 @@
package main
import (
"github.com/mitchellh/packer/builder/openstack"
"github.com/mitchellh/packer/packer/plugin"
)
func main() {
plugin.ServeBuilder(new(openstack.Builder))
}

View File

@ -0,0 +1 @@
package main