cloudinit: merge cloudconfig info from user-data and meta-data

This attempts to retrieve cloudconfigs from two sources: the meta-data
service, and the user-data service. If only one cloudconfig is found,
that is applied to the system. If both services return a cloudconfig,
the two are merged into a single cloudconfig which is then applied to
the system.

Only a subset of parameters are merged (because the meta-data service
currently only partially populates a cloudconfig). In the event of any
conflicts, parameters in the user-data cloudconfig take precedence over
those in the meta-data cloudconfig.
This commit is contained in:
Jonathan Boulle 2014-06-26 17:17:58 -07:00
parent 6a2927d701
commit 1aabacc769
6 changed files with 200 additions and 62 deletions

View File

@ -102,6 +102,7 @@ func main() {
die() die()
} }
// Extract IPv4 addresses from metadata if possible
var subs map[string]string var subs map[string]string
if len(metadataBytes) > 0 { if len(metadataBytes) > 0 {
subs, err = initialize.ExtractIPsFromMetadata(metadataBytes) subs, err = initialize.ExtractIPsFromMetadata(metadataBytes)
@ -110,29 +111,86 @@ func main() {
die() die()
} }
} }
env := initialize.NewEnvironment("/", ds.ConfigRoot(), workspace, convertNetconf, sshKeyName, subs) env := initialize.NewEnvironment("/", ds.ConfigRoot(), workspace, convertNetconf, sshKeyName, subs)
if len(metadataBytes) > 0 { var ccm, ccu *initialize.CloudConfig
if err := processMetadata(string(metadataBytes), env); err != nil { var script system.Script
fmt.Printf("Failed to process meta-data: %v\n", err) if ccm, err = initialize.ParseMetaData(string(metadataBytes)); err != nil {
die() fmt.Printf("Failed to parse meta-data: %v\n", err)
} die()
}
if ud, err := initialize.ParseUserData(string(userdataBytes)); err != nil {
fmt.Printf("Failed to parse user-data: %v\n", err)
die()
} else { } else {
fmt.Println("No meta-data to handle.") switch t := ud.(type) {
case *initialize.CloudConfig:
ccu = t
case system.Script:
script = t
}
} }
if len(userdataBytes) > 0 { var cc *initialize.CloudConfig
if err := processUserdata(string(userdataBytes), env); err != nil { if ccm != nil && ccu != nil {
fmt.Printf("Failed to process user-data: %v\n", err) fmt.Println("Merging cloud-config from meta-data and user-data")
if !ignoreFailure { merged := mergeCloudConfig(*ccu, *ccm)
die() cc = &merged
} } else if ccm != nil && ccu == nil {
} fmt.Println("Processing cloud-config from meta-data")
cc = ccm
} else if ccm == nil && ccu != nil {
fmt.Println("Processing cloud-config from user-data")
cc = ccu
} else { } else {
fmt.Println("No user-data to handle.") fmt.Println("No cloud-config data to handle.")
}
if cc != nil {
if err = initialize.Apply(*cc, env); err != nil {
fmt.Printf("Failed to apply cloud-config: %v\n", err)
die()
}
}
if script != nil {
if err = runScript(script, env); err != nil {
fmt.Printf("Failed to run script: %v\n", err)
die()
}
} }
} }
// mergeCloudConfig merges certain options from mdcc (a CloudConfig derived from
// meta-data) onto udcc (a CloudConfig derived from user-data), if they are
// not already set on udcc (i.e. user-data always takes precedence)
// NB: This needs to be kept in sync with ParseMetadata so that it tracks all
// elements of a CloudConfig which that function can populate.
func mergeCloudConfig(mdcc, udcc initialize.CloudConfig) (cc initialize.CloudConfig) {
if mdcc.Hostname != "" {
if udcc.Hostname != "" {
fmt.Printf("Warning: user-data hostname (%s) overrides metadata hostname (%s)", udcc.Hostname, mdcc.Hostname)
} else {
udcc.Hostname = mdcc.Hostname
}
}
for _, key := range mdcc.SSHAuthorizedKeys {
udcc.SSHAuthorizedKeys = append(udcc.SSHAuthorizedKeys, key)
}
if mdcc.NetworkConfigPath != "" {
if udcc.NetworkConfigPath != "" {
fmt.Printf("Warning: user-data NetworkConfigPath %s overrides metadata NetworkConfigPath %s", udcc.NetworkConfigPath, mdcc.NetworkConfigPath)
} else {
udcc.NetworkConfigPath = mdcc.NetworkConfigPath
}
}
return udcc
}
// getDatasources creates a slice of possible Datasources for cloudinit based
// on the different source command-line flags.
func getDatasources() []datasource.Datasource { func getDatasources() []datasource.Datasource {
dss := make([]datasource.Datasource, 0, 5) dss := make([]datasource.Datasource, 0, 5)
if sources.file != "" { if sources.file != "" {
@ -153,6 +211,11 @@ func getDatasources() []datasource.Datasource {
return dss return dss
} }
// selectDatasource attempts to choose a valid Datasource to use based on its
// current availability. The first Datasource to report to be available is
// returned. Datasources will be retried if possible if they are not
// immediately available. If all Datasources are permanently unavailable or
// datasourceTimeout is reached before one becomes available, nil is returned.
func selectDatasource(sources []datasource.Datasource) datasource.Datasource { func selectDatasource(sources []datasource.Datasource) datasource.Datasource {
ds := make(chan datasource.Datasource) ds := make(chan datasource.Datasource)
stop := make(chan struct{}) stop := make(chan struct{})
@ -199,48 +262,18 @@ func selectDatasource(sources []datasource.Datasource) datasource.Datasource {
return s return s
} }
func processUserdata(userdata string, env *initialize.Environment) error { // TODO(jonboulle): this should probably be refactored and moved into a different module
userdata = env.Apply(userdata) func runScript(script system.Script, env *initialize.Environment) error {
err := initialize.PrepWorkspace(env.Workspace())
parsed, err := initialize.ParseUserData(userdata)
if err != nil {
fmt.Printf("Failed parsing user-data: %v\n", err)
return err
}
err = initialize.PrepWorkspace(env.Workspace())
if err != nil { if err != nil {
fmt.Printf("Failed preparing workspace: %v\n", err) fmt.Printf("Failed preparing workspace: %v\n", err)
return err return err
} }
path, err := initialize.PersistScriptInWorkspace(script, env.Workspace())
switch t := parsed.(type) { if err == nil {
case initialize.CloudConfig: var name string
err = initialize.Apply(t, env) name, err = system.ExecuteScript(path)
case system.Script: initialize.PersistUnitNameInWorkspace(name, env.Workspace())
var path string
path, err = initialize.PersistScriptInWorkspace(t, env.Workspace())
if err == nil {
var name string
name, err = system.ExecuteScript(path)
initialize.PersistUnitNameInWorkspace(name, env.Workspace())
}
} }
return err return err
} }
func processMetadata(metadata string, env *initialize.Environment) error {
parsed, err := initialize.ParseMetaData(metadata)
if err != nil {
fmt.Printf("Failed parsing meta-data: %v\n", err)
return err
}
err = initialize.PrepWorkspace(env.Workspace())
if err != nil {
fmt.Printf("Failed preparing workspace: %v\n", err)
return err
}
return initialize.Apply(parsed, env)
}

110
coreos-cloudinit_test.go Normal file
View File

@ -0,0 +1,110 @@
package main
import (
"reflect"
"testing"
"github.com/coreos/coreos-cloudinit/initialize"
)
func TestMergeCloudConfig(t *testing.T) {
simplecc := initialize.CloudConfig{
SSHAuthorizedKeys: []string{"abc", "def"},
Hostname: "foobar",
NetworkConfigPath: "/path/somewhere",
}
for i, tt := range []struct {
udcc initialize.CloudConfig
mdcc initialize.CloudConfig
want initialize.CloudConfig
}{
{
// If mdcc is empty, udcc should be returned unchanged
simplecc,
initialize.CloudConfig{},
simplecc,
},
{
// If udcc is empty, mdcc should be returned unchanged(overridden)
initialize.CloudConfig{},
simplecc,
simplecc,
},
{
// user-data should override completely in the case of conflicts
simplecc,
initialize.CloudConfig{
Hostname: "meta-hostname",
NetworkConfigPath: "/path/meta",
},
simplecc,
},
{
// Mixed merge should succeed
initialize.CloudConfig{
SSHAuthorizedKeys: []string{"abc", "def"},
Hostname: "user-hostname",
NetworkConfigPath: "/path/somewhere",
},
initialize.CloudConfig{
SSHAuthorizedKeys: []string{"woof", "qux"},
Hostname: "meta-hostname",
},
initialize.CloudConfig{
SSHAuthorizedKeys: []string{"abc", "def", "woof", "qux"},
Hostname: "user-hostname",
NetworkConfigPath: "/path/somewhere",
},
},
{
// Completely non-conflicting merge should be fine
initialize.CloudConfig{
Hostname: "supercool",
},
initialize.CloudConfig{
SSHAuthorizedKeys: []string{"zaphod", "beeblebrox"},
NetworkConfigPath: "/dev/fun",
},
initialize.CloudConfig{
Hostname: "supercool",
SSHAuthorizedKeys: []string{"zaphod", "beeblebrox"},
NetworkConfigPath: "/dev/fun",
},
},
{
// Non-mergeable settings in user-data should not be affected
initialize.CloudConfig{
Hostname: "mememe",
ManageEtcHosts: initialize.EtcHosts("lolz"),
},
initialize.CloudConfig{
Hostname: "youyouyou",
NetworkConfigPath: "meta-meta-yo",
},
initialize.CloudConfig{
Hostname: "mememe",
ManageEtcHosts: initialize.EtcHosts("lolz"),
NetworkConfigPath: "meta-meta-yo",
},
},
{
// Non-mergeable (unexpected) settings in meta-data are ignored
initialize.CloudConfig{
Hostname: "mememe",
},
initialize.CloudConfig{
ManageEtcHosts: initialize.EtcHosts("lolz"),
NetworkConfigPath: "meta-meta-yo",
},
initialize.CloudConfig{
Hostname: "mememe",
NetworkConfigPath: "meta-meta-yo",
},
},
} {
got := mergeCloudConfig(tt.mdcc, tt.udcc)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("case #%d: mergeCloudConfig mutated CloudConfig unexpectedly:\ngot:\n%s\nwant:\n%s", i, got, tt.want)
}
}
}

View File

@ -3,8 +3,8 @@ package initialize
import "encoding/json" import "encoding/json"
// ParseMetaData parses a JSON blob in the OpenStack metadata service format, and // ParseMetaData parses a JSON blob in the OpenStack metadata service format, and
// converts it to a CloudConfig // converts it to a partially hydrated CloudConfig
func ParseMetaData(contents string) (cfg CloudConfig, err error) { func ParseMetaData(contents string) (cfg *CloudConfig, err error) {
var metadata struct { var metadata struct {
SSHAuthorizedKeyMap map[string]string `json:"public_keys"` SSHAuthorizedKeyMap map[string]string `json:"public_keys"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`

View File

@ -19,14 +19,9 @@ func ParseUserData(contents string) (interface{}, error) {
if strings.HasPrefix(header, "#!") { if strings.HasPrefix(header, "#!") {
log.Printf("Parsing user-data as script") log.Printf("Parsing user-data as script")
return system.Script(contents), nil return system.Script(contents), nil
} else if header == "#cloud-config" { } else if header == "#cloud-config" {
log.Printf("Parsing user-data as cloud-config") log.Printf("Parsing user-data as cloud-config")
cfg, err := NewCloudConfig(contents) return NewCloudConfig(contents)
if err != nil {
log.Fatal(err.Error())
}
return *cfg, nil
} else { } else {
return nil, fmt.Errorf("Unrecognized user-data header: %s", header) return nil, fmt.Errorf("Unrecognized user-data header: %s", header)
} }

View File

@ -37,7 +37,7 @@ func TestParseConfigCRLF(t *testing.T) {
t.Fatalf("Failed parsing config: %v", err) t.Fatalf("Failed parsing config: %v", err)
} }
cfg := ud.(CloudConfig) cfg := ud.(*CloudConfig)
if cfg.Hostname != "foo" { if cfg.Hostname != "foo" {
t.Error("Failed parsing hostname from config") t.Error("Failed parsing hostname from config")

2
test
View File

@ -18,7 +18,7 @@ declare -a TESTPKGS=(initialize system datasource pkg network)
if [ -z "$PKG" ]; then if [ -z "$PKG" ]; then
GOFMTPATH="$TESTPKGS coreos-cloudinit.go" GOFMTPATH="$TESTPKGS coreos-cloudinit.go"
# prepend repo path to each package # prepend repo path to each package
TESTPKGS=${TESTPKGS[@]/#/${REPO_PATH}/} TESTPKGS="${TESTPKGS[@]/#/${REPO_PATH}/} ./"
else else
GOFMTPATH="$TESTPKGS" GOFMTPATH="$TESTPKGS"
# strip out slashes and dots from PKG=./foo/ # strip out slashes and dots from PKG=./foo/