From 8594c32edfed13c3014d28d11c0e221a6bfc796a Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Wed, 27 Feb 2019 15:20:47 -0500 Subject: [PATCH] ConfigDrive utilities --- openstack/baremetal/v1/nodes/configdrive.go | 120 ++++++++++++++++++ openstack/baremetal/v1/nodes/doc.go | 35 +++++ .../v1/nodes/testing/configdrive_test.go | 64 ++++++++++ .../baremetal/v1/nodes/testing/fixtures.go | 101 +++++++++++++++ openstack/baremetal/v1/nodes/util.go | 59 +++++++++ 5 files changed, 379 insertions(+) create mode 100644 openstack/baremetal/v1/nodes/configdrive.go create mode 100644 openstack/baremetal/v1/nodes/doc.go create mode 100644 openstack/baremetal/v1/nodes/testing/configdrive_test.go create mode 100644 openstack/baremetal/v1/nodes/testing/fixtures.go create mode 100644 openstack/baremetal/v1/nodes/util.go diff --git a/openstack/baremetal/v1/nodes/configdrive.go b/openstack/baremetal/v1/nodes/configdrive.go new file mode 100644 index 00000000..7cecbe1f --- /dev/null +++ b/openstack/baremetal/v1/nodes/configdrive.go @@ -0,0 +1,120 @@ +package nodes + +import ( + "encoding/base64" + "encoding/json" + "io/ioutil" + "os" + "path/filepath" +) + +// A ConfigDrive struct will be used to create a base64-encoded, gzipped ISO9660 image for use with Ironic. +type ConfigDrive struct { + UserData UserDataBuilder `json:"user_data"` + MetaData map[string]interface{} `json:"meta_data"` + NetworkData map[string]interface{} `json:"network_data"` + Version string `json:"-"` +} + +// Interface to let us specify a raw string, or a map for the user data +type UserDataBuilder interface { + ToUserData() ([]byte, error) +} + +type UserDataMap map[string]interface{} +type UserDataString string + +// Converts a UserDataMap to JSON-string +func (data UserDataMap) ToUserData() ([]byte, error) { + return json.MarshalIndent(data, "", "\t") +} + +func (data UserDataString) ToUserData() ([]byte, error) { + return []byte(data), nil +} + +type ConfigDriveBuilder interface { + ToConfigDrive() (string, error) +} + +// Writes out a ConfigDrive to a temporary directory, and returns the path +func (configDrive ConfigDrive) ToDirectory() (string, error) { + // Create a temporary directory for our config drive + directory, err := ioutil.TempDir("", "gophercloud") + if err != nil { + return "", err + } + + // Build up the paths for OpenStack + var version string + if configDrive.Version == "" { + version = "latest" + } else { + version = configDrive.Version + } + + path := filepath.FromSlash(directory + "/openstack/" + version) + if err := os.MkdirAll(path, 0755); err != nil { + return "", err + } + + // Dump out user data + if configDrive.UserData != nil { + userDataPath := filepath.FromSlash(path + "/user_data") + data, err := configDrive.UserData.ToUserData() + if err != nil { + return "", err + } + + if err := ioutil.WriteFile(userDataPath, data, 0644); err != nil { + return "", err + } + } + + // Dump out meta data + if configDrive.MetaData != nil { + metaDataPath := filepath.FromSlash(path + "/meta_data.json") + data, err := json.Marshal(configDrive.MetaData) + if err != nil { + return "", err + } + + if err := ioutil.WriteFile(metaDataPath, data, 0644); err != nil { + return "", err + } + } + + // Dump out network data + if configDrive.NetworkData != nil { + networkDataPath := filepath.FromSlash(path + "/network_data.json") + data, err := json.Marshal(configDrive.NetworkData) + if err != nil { + return "", err + } + + if err := ioutil.WriteFile(networkDataPath, data, 0644); err != nil { + return "", err + } + } + + return directory, nil +} + +// Writes out the ConfigDrive struct to a directory structure, and then +// packs it as a base64-encoded gzipped ISO9660 image. +func (configDrive ConfigDrive) ToConfigDrive() (string, error) { + directory, err := configDrive.ToDirectory() + if err != nil { + return "", err + } + defer os.RemoveAll(directory) + + // Pack result as gzipped ISO9660 file + result, err := PackDirectoryAsISO(directory) + if err != nil { + return "", err + } + + // Return as base64-encoded data + return base64.StdEncoding.EncodeToString(result), nil +} diff --git a/openstack/baremetal/v1/nodes/doc.go b/openstack/baremetal/v1/nodes/doc.go new file mode 100644 index 00000000..37dbd0aa --- /dev/null +++ b/openstack/baremetal/v1/nodes/doc.go @@ -0,0 +1,35 @@ +package nodes + +/* +Package nodes provides utilities for working with Ironic's baremetal API. + +* Building a config drive + +As part of provisioning a node, you may need a config drive that contains user data, metadata, and network data +stored inside a base64-encoded gzipped ISO9660 file. These utilities can create that for you. + +For example: + + configDrive = nodes.ConfigDrive{ + UserData: nodes.UserDataMap{ + "ignition": map[string]string{ + "version": "2.2.0", + }, + "systemd": map[string]interface{}{ + "units": []map[string]interface{}{{ + "name": "example.service", + "enabled": true, + }, + }, + }, + } + +Then to upload this to Ironic as a using gophercloud: + + err = nodes.ChangeProvisionState(client, uuid, nodes.ProvisionStateOpts{ + Target: "active", + ConfigDrive: configDrive.ToConfigDrive(), + }).ExtractErr() + +*/ + diff --git a/openstack/baremetal/v1/nodes/testing/configdrive_test.go b/openstack/baremetal/v1/nodes/testing/configdrive_test.go new file mode 100644 index 00000000..0acf2369 --- /dev/null +++ b/openstack/baremetal/v1/nodes/testing/configdrive_test.go @@ -0,0 +1,64 @@ +package testing + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + th "github.com/gophercloud/gophercloud/testhelper" + "github.com/gophercloud/utils/openstack/baremetal/v1/nodes" +) + +func TestUserDataFromMap(t *testing.T) { + userData, err := IgnitionUserData.ToUserData() + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, string(userData), IgnitionUserData) +} + +func TestUserDataFromString(t *testing.T) { + cloudInit := nodes.UserDataString(CloudInitString) + userData, err := cloudInit.ToUserData() + th.AssertNoErr(t, err) + th.AssertByteArrayEquals(t, userData, []byte(cloudInit)) +} + +func TestConfigDriveToDirectory(t *testing.T) { + path, err := ConfigDrive.ToDirectory() + th.AssertNoErr(t, err) + defer os.RemoveAll(path) + + basePath := filepath.FromSlash(path + "/openstack/latest") + + userData, err := ioutil.ReadFile(filepath.FromSlash(basePath + "/user_data")) + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, string(userData), IgnitionUserData) + + metaData, err := ioutil.ReadFile(filepath.FromSlash(basePath + "/meta_data.json")) + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, string(metaData), OpenStackMetaData) + + networkData, err := ioutil.ReadFile(filepath.FromSlash(basePath + "/network_data.json")) + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, string(networkData), NetworkData) +} + +func TestConfigDriveVersionToDirectory(t *testing.T) { + path, err := ConfigDriveVersioned.ToDirectory() + th.AssertNoErr(t, err) + defer os.RemoveAll(path) + + basePath := filepath.FromSlash(path + "/openstack/" + ConfigDriveVersioned.Version) + + userData, err := ioutil.ReadFile(filepath.FromSlash(basePath + "/user_data")) + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, string(userData), IgnitionUserData) + + metaData, err := ioutil.ReadFile(filepath.FromSlash(basePath + "/meta_data.json")) + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, string(metaData), OpenStackMetaData) + + networkData, err := ioutil.ReadFile(filepath.FromSlash(basePath + "/network_data.json")) + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, string(networkData), NetworkData) +} diff --git a/openstack/baremetal/v1/nodes/testing/fixtures.go b/openstack/baremetal/v1/nodes/testing/fixtures.go new file mode 100644 index 00000000..810eab69 --- /dev/null +++ b/openstack/baremetal/v1/nodes/testing/fixtures.go @@ -0,0 +1,101 @@ +package testing + +import "github.com/gophercloud/utils/openstack/baremetal/v1/nodes" + +const IgnitionConfig = ` +{ + "ignition": { + "version": "2.2.0" + }, + "systemd": { + "units": [ + { + "enabled": true, + "name": "example.service" + } + ] + } +} +` + +const OpenstackMetaDataJSON = ` +{ + "availability_zone": "nova", + "hostname": "test.novalocal", + "public_keys": { + "mykey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDBqUfVvCSez0/Wfpd8dLLgZXV9GtXQ7hnMN+Z0OWQUyebVEHey1CXuin0uY1cAJMhUq8j98SiW+cU0sU4J3x5l2+xi1bodDm1BtFWVeLIOQINpfV1n8fKjHB+ynPpe1F6tMDvrFGUlJs44t30BrujMXBe8Rq44cCk6wqyjATA3rQ== Generated by Nova\n" + } +} +` + +const NetworkDataJSON = ` +"services": [ + { + "type": "dns", + "address": "8.8.8.8" + }, + { + "type": "dns", + "address": "8.8.4.4" + } +] +` + +const CloudInitString = ` +#cloud-init + +groups: + - ubuntu: [root,sys] + - cloud-users +` + +var ( + IgnitionUserData = nodes.UserDataMap{ + "ignition": map[string]string{ + "version": "2.2.0", + }, + "systemd": map[string]interface{}{ + "units": []map[string]interface{}{{ + "name": "example.service", + "enabled": true, + }, + }, + }, + } + + OpenStackMetaData = map[string]interface{}{ + "availability_zone": "nova", + "hostname": "test.novalocal", + "public_keys": map[string]string{ + "mykey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDBqUfVvCSez0/Wfpd8dLLgZXV9GtXQ7hnMN+Z0OWQUyebVEHey1CXuin0uY1cAJMhUq8j98SiW+cU0sU4J3x5l2+xi1bodDm1BtFWVeLIOQINpfV1n8fKjHB+ynPpe1F6tMDvrFGUlJs44t30BrujMXBe8Rq44cCk6wqyjATA3rQ== Generated by Nova\n", + }, + } + + NetworkData = map[string]interface{}{ + "services": []map[string]string{ + { + "type": "dns", + "address": "8.8.8.8", + }, + { + "type": "dns", + "address": "8.8.4.4", + }, + }, + } + + CloudInitUserData = nodes.UserDataString(CloudInitString) + + ConfigDrive = nodes.ConfigDrive{ + UserData: IgnitionUserData, + MetaData: OpenStackMetaData, + NetworkData: NetworkData, + } + + ConfigDriveVersioned = nodes.ConfigDrive{ + UserData: IgnitionUserData, + MetaData: OpenStackMetaData, + NetworkData: NetworkData, + Version: "2018-10-10", + } +) diff --git a/openstack/baremetal/v1/nodes/util.go b/openstack/baremetal/v1/nodes/util.go new file mode 100644 index 00000000..d5c63acb --- /dev/null +++ b/openstack/baremetal/v1/nodes/util.go @@ -0,0 +1,59 @@ +package nodes + +import ( + "bytes" + "compress/gzip" + "fmt" + "io/ioutil" + "os" + "os/exec" +) + +// Gzips a file +func GzipFile(path string) ([]byte, error) { + var buf bytes.Buffer + + w := gzip.NewWriter(&buf) + contents, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + _, err = w.Write(contents) + if err != nil { + return nil, err + } + + err = w.Close() + if err != nil { + return nil, err + } + + result := buf.Bytes() + return result, nil +} + +// Packs a directory into a gzipped ISO image +func PackDirectoryAsISO(path string) ([]byte, error) { + iso, err := ioutil.TempFile("", "gophercloud-iso") + if err != nil { + return nil, err + } + iso.Close() + defer os.Remove(iso.Name()) + cmd := exec.Command( + "mkisofs", + "-o", iso.Name(), + "-ldots", + "-allow-lowercase", + "-allow-multidot", "-l", + "-publisher", "gophercloud", + "-quiet", "-J", + "-r", "-V", "config-2", + path, + ) + if err = cmd.Run(); err != nil { + return nil, fmt.Errorf("error creating configdrive iso: %s", err.Error()) + } + + return GzipFile(iso.Name()) +}