diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index 7f21c072..695f966e 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -22,6 +22,11 @@ "Comment": "v1.3.2-6-g405c260", "Rev": "405c2600b19ae77516c967f8ee8ebde5624d3663" }, + { + "ImportPath": "github.com/coreos/coreos-cloudinit/initialize", + "Comment": "v1.3.2-6-g405c260", + "Rev": "405c2600b19ae77516c967f8ee8ebde5624d3663" + }, { "ImportPath": "github.com/coreos/coreos-cloudinit/network", "Comment": "v1.3.2-6-g405c260", diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/config.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/config.go new file mode 100755 index 00000000..3bf7f541 --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/config.go @@ -0,0 +1,293 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "errors" + "fmt" + "log" + "path" + + "github.com/coreos/coreos-cloudinit/config" + "github.com/coreos/coreos-cloudinit/network" + "github.com/coreos/coreos-cloudinit/system" +) + +// CloudConfigFile represents a CoreOS specific configuration option that can generate +// an associated system.File to be written to disk +type CloudConfigFile interface { + // File should either return (*system.File, error), or (nil, nil) if nothing + // needs to be done for this configuration option. + File() (*system.File, error) +} + +// CloudConfigUnit represents a CoreOS specific configuration option that can generate +// associated system.Units to be created/enabled appropriately +type CloudConfigUnit interface { + Units() []system.Unit +} + +// Apply renders a CloudConfig to an Environment. This can involve things like +// configuring the hostname, adding new users, writing various configuration +// files to disk, and manipulating systemd services. +func Apply(cfg config.CloudConfig, ifaces []network.InterfaceGenerator, env *Environment) error { + if cfg.Hostname != "" { + if err := system.SetHostname(cfg.Hostname); err != nil { + return err + } + log.Printf("Set hostname to %s", cfg.Hostname) + } + + for _, user := range cfg.Users { + if user.Name == "" { + log.Printf("User object has no 'name' field, skipping") + continue + } + + if system.UserExists(&user) { + log.Printf("User '%s' exists, ignoring creation-time fields", user.Name) + if user.PasswordHash != "" { + log.Printf("Setting '%s' user's password", user.Name) + if err := system.SetUserPassword(user.Name, user.PasswordHash); err != nil { + log.Printf("Failed setting '%s' user's password: %v", user.Name, err) + return err + } + } + } else { + log.Printf("Creating user '%s'", user.Name) + if err := system.CreateUser(&user); err != nil { + log.Printf("Failed creating user '%s': %v", user.Name, err) + return err + } + } + + if len(user.SSHAuthorizedKeys) > 0 { + log.Printf("Authorizing %d SSH keys for user '%s'", len(user.SSHAuthorizedKeys), user.Name) + if err := system.AuthorizeSSHKeys(user.Name, env.SSHKeyName(), user.SSHAuthorizedKeys); err != nil { + return err + } + } + if user.SSHImportGithubUser != "" { + log.Printf("Authorizing github user %s SSH keys for CoreOS user '%s'", user.SSHImportGithubUser, user.Name) + if err := SSHImportGithubUser(user.Name, user.SSHImportGithubUser); err != nil { + return err + } + } + for _, u := range user.SSHImportGithubUsers { + log.Printf("Authorizing github user %s SSH keys for CoreOS user '%s'", u, user.Name) + if err := SSHImportGithubUser(user.Name, u); err != nil { + return err + } + } + if user.SSHImportURL != "" { + log.Printf("Authorizing SSH keys for CoreOS user '%s' from '%s'", user.Name, user.SSHImportURL) + if err := SSHImportKeysFromURL(user.Name, user.SSHImportURL); err != nil { + return err + } + } + } + + if len(cfg.SSHAuthorizedKeys) > 0 { + err := system.AuthorizeSSHKeys("core", env.SSHKeyName(), cfg.SSHAuthorizedKeys) + if err == nil { + log.Printf("Authorized SSH keys for core user") + } else { + return err + } + } + + var writeFiles []system.File + for _, file := range cfg.WriteFiles { + writeFiles = append(writeFiles, system.File{File: file}) + } + + for _, ccf := range []CloudConfigFile{ + system.OEM{OEM: cfg.CoreOS.OEM}, + system.Update{Update: cfg.CoreOS.Update, ReadConfig: system.DefaultReadConfig}, + system.EtcHosts{EtcHosts: cfg.ManageEtcHosts}, + system.Flannel{Flannel: cfg.CoreOS.Flannel}, + } { + f, err := ccf.File() + if err != nil { + return err + } + if f != nil { + writeFiles = append(writeFiles, *f) + } + } + + var units []system.Unit + for _, u := range cfg.CoreOS.Units { + units = append(units, system.Unit{Unit: u}) + } + + for _, ccu := range []CloudConfigUnit{ + system.Etcd{Etcd: cfg.CoreOS.Etcd}, + system.Fleet{Fleet: cfg.CoreOS.Fleet}, + system.Locksmith{Locksmith: cfg.CoreOS.Locksmith}, + system.Update{Update: cfg.CoreOS.Update, ReadConfig: system.DefaultReadConfig}, + } { + units = append(units, ccu.Units()...) + } + + wroteEnvironment := false + for _, file := range writeFiles { + fullPath, err := system.WriteFile(&file, env.Root()) + if err != nil { + return err + } + if path.Clean(file.Path) == "/etc/environment" { + wroteEnvironment = true + } + log.Printf("Wrote file %s to filesystem", fullPath) + } + + if !wroteEnvironment { + ef := env.DefaultEnvironmentFile() + if ef != nil { + err := system.WriteEnvFile(ef, env.Root()) + if err != nil { + return err + } + log.Printf("Updated /etc/environment") + } + } + + if len(ifaces) > 0 { + units = append(units, createNetworkingUnits(ifaces)...) + if err := system.RestartNetwork(ifaces); err != nil { + return err + } + } + + um := system.NewUnitManager(env.Root()) + return processUnits(units, env.Root(), um) +} + +func createNetworkingUnits(interfaces []network.InterfaceGenerator) (units []system.Unit) { + appendNewUnit := func(units []system.Unit, name, content string) []system.Unit { + if content == "" { + return units + } + return append(units, system.Unit{Unit: config.Unit{ + Name: name, + Runtime: true, + Content: content, + }}) + } + for _, i := range interfaces { + units = appendNewUnit(units, fmt.Sprintf("%s.netdev", i.Filename()), i.Netdev()) + units = appendNewUnit(units, fmt.Sprintf("%s.link", i.Filename()), i.Link()) + units = appendNewUnit(units, fmt.Sprintf("%s.network", i.Filename()), i.Network()) + } + return units +} + +// processUnits takes a set of Units and applies them to the given root using +// the given UnitManager. This can involve things like writing unit files to +// disk, masking/unmasking units, or invoking systemd +// commands against units. It returns any error encountered. +func processUnits(units []system.Unit, root string, um system.UnitManager) error { + type action struct { + unit system.Unit + command string + } + actions := make([]action, 0, len(units)) + reload := false + restartNetworkd := false + for _, unit := range units { + if unit.Name == "" { + log.Printf("Skipping unit without name") + continue + } + + if unit.Content != "" { + log.Printf("Writing unit %q to filesystem", unit.Name) + if err := um.PlaceUnit(unit); err != nil { + return err + } + log.Printf("Wrote unit %q", unit.Name) + reload = true + } + + for _, dropin := range unit.DropIns { + if dropin.Name != "" && dropin.Content != "" { + log.Printf("Writing drop-in unit %q to filesystem", dropin.Name) + if err := um.PlaceUnitDropIn(unit, dropin); err != nil { + return err + } + log.Printf("Wrote drop-in unit %q", dropin.Name) + reload = true + } + } + + if unit.Mask { + log.Printf("Masking unit file %q", unit.Name) + if err := um.MaskUnit(unit); err != nil { + return err + } + } else if unit.Runtime { + log.Printf("Ensuring runtime unit file %q is unmasked", unit.Name) + if err := um.UnmaskUnit(unit); err != nil { + return err + } + } + + if unit.Enable { + if unit.Group() != "network" { + log.Printf("Enabling unit file %q", unit.Name) + if err := um.EnableUnitFile(unit); err != nil { + return err + } + log.Printf("Enabled unit %q", unit.Name) + } else { + log.Printf("Skipping enable for network-like unit %q", unit.Name) + } + } + + if unit.Group() == "network" { + restartNetworkd = true + } else if unit.Command != "" { + actions = append(actions, action{unit, unit.Command}) + } + } + + if reload { + if err := um.DaemonReload(); err != nil { + return errors.New(fmt.Sprintf("failed systemd daemon-reload: %s", err)) + } + } + + if restartNetworkd { + log.Printf("Restarting systemd-networkd") + networkd := system.Unit{Unit: config.Unit{Name: "systemd-networkd.service"}} + res, err := um.RunUnitCommand(networkd, "restart") + if err != nil { + return err + } + log.Printf("Restarted systemd-networkd (%s)", res) + } + + for _, action := range actions { + log.Printf("Calling unit command %q on %q'", action.command, action.unit.Name) + res, err := um.RunUnitCommand(action.unit, action.command) + if err != nil { + return err + } + log.Printf("Result of %q on %q: %s", action.command, action.unit.Name, res) + } + + return nil +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/config_test.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/config_test.go new file mode 100755 index 00000000..33be737e --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/config_test.go @@ -0,0 +1,299 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "reflect" + "testing" + + "github.com/coreos/coreos-cloudinit/config" + "github.com/coreos/coreos-cloudinit/network" + "github.com/coreos/coreos-cloudinit/system" +) + +type TestUnitManager struct { + placed []string + enabled []string + masked []string + unmasked []string + commands []UnitAction + reload bool +} + +type UnitAction struct { + unit string + command string +} + +func (tum *TestUnitManager) PlaceUnit(u system.Unit) error { + tum.placed = append(tum.placed, u.Name) + return nil +} +func (tum *TestUnitManager) PlaceUnitDropIn(u system.Unit, d config.UnitDropIn) error { + tum.placed = append(tum.placed, u.Name+".d/"+d.Name) + return nil +} +func (tum *TestUnitManager) EnableUnitFile(u system.Unit) error { + tum.enabled = append(tum.enabled, u.Name) + return nil +} +func (tum *TestUnitManager) RunUnitCommand(u system.Unit, c string) (string, error) { + tum.commands = append(tum.commands, UnitAction{u.Name, c}) + return "", nil +} +func (tum *TestUnitManager) DaemonReload() error { + tum.reload = true + return nil +} +func (tum *TestUnitManager) MaskUnit(u system.Unit) error { + tum.masked = append(tum.masked, u.Name) + return nil +} +func (tum *TestUnitManager) UnmaskUnit(u system.Unit) error { + tum.unmasked = append(tum.unmasked, u.Name) + return nil +} + +type mockInterface struct { + name string + filename string + netdev string + link string + network string + kind string + modprobeParams string +} + +func (i mockInterface) Name() string { + return i.name +} + +func (i mockInterface) Filename() string { + return i.filename +} + +func (i mockInterface) Netdev() string { + return i.netdev +} + +func (i mockInterface) Link() string { + return i.link +} + +func (i mockInterface) Network() string { + return i.network +} + +func (i mockInterface) Type() string { + return i.kind +} + +func (i mockInterface) ModprobeParams() string { + return i.modprobeParams +} + +func TestCreateNetworkingUnits(t *testing.T) { + for _, tt := range []struct { + interfaces []network.InterfaceGenerator + expect []system.Unit + }{ + {nil, nil}, + { + []network.InterfaceGenerator{ + network.InterfaceGenerator(mockInterface{filename: "test"}), + }, + nil, + }, + { + []network.InterfaceGenerator{ + network.InterfaceGenerator(mockInterface{filename: "test1", netdev: "test netdev"}), + network.InterfaceGenerator(mockInterface{filename: "test2", link: "test link"}), + network.InterfaceGenerator(mockInterface{filename: "test3", network: "test network"}), + }, + []system.Unit{ + system.Unit{Unit: config.Unit{Name: "test1.netdev", Runtime: true, Content: "test netdev"}}, + system.Unit{Unit: config.Unit{Name: "test2.link", Runtime: true, Content: "test link"}}, + system.Unit{Unit: config.Unit{Name: "test3.network", Runtime: true, Content: "test network"}}, + }, + }, + { + []network.InterfaceGenerator{ + network.InterfaceGenerator(mockInterface{filename: "test", netdev: "test netdev", link: "test link", network: "test network"}), + }, + []system.Unit{ + system.Unit{Unit: config.Unit{Name: "test.netdev", Runtime: true, Content: "test netdev"}}, + system.Unit{Unit: config.Unit{Name: "test.link", Runtime: true, Content: "test link"}}, + system.Unit{Unit: config.Unit{Name: "test.network", Runtime: true, Content: "test network"}}, + }, + }, + } { + units := createNetworkingUnits(tt.interfaces) + if !reflect.DeepEqual(tt.expect, units) { + t.Errorf("bad units (%+v): want %#v, got %#v", tt.interfaces, tt.expect, units) + } + } +} + +func TestProcessUnits(t *testing.T) { + tests := []struct { + units []system.Unit + + result TestUnitManager + }{ + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "foo", + Mask: true, + }}, + }, + result: TestUnitManager{ + masked: []string{"foo"}, + }, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "baz.service", + Content: "[Service]\nExecStart=/bin/baz", + Command: "start", + }}, + system.Unit{Unit: config.Unit{ + Name: "foo.network", + Content: "[Network]\nFoo=true", + }}, + system.Unit{Unit: config.Unit{ + Name: "bar.network", + Content: "[Network]\nBar=true", + }}, + }, + result: TestUnitManager{ + placed: []string{"baz.service", "foo.network", "bar.network"}, + commands: []UnitAction{ + UnitAction{"systemd-networkd.service", "restart"}, + UnitAction{"baz.service", "start"}, + }, + reload: true, + }, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "baz.service", + Content: "[Service]\nExecStart=/bin/true", + }}, + }, + result: TestUnitManager{ + placed: []string{"baz.service"}, + reload: true, + }, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "locksmithd.service", + Runtime: true, + }}, + }, + result: TestUnitManager{ + unmasked: []string{"locksmithd.service"}, + }, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "woof", + Enable: true, + }}, + }, + result: TestUnitManager{ + enabled: []string{"woof"}, + }, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "hi.service", + Runtime: true, + Content: "[Service]\nExecStart=/bin/echo hi", + DropIns: []config.UnitDropIn{ + { + Name: "lo.conf", + Content: "[Service]\nExecStart=/bin/echo lo", + }, + { + Name: "bye.conf", + Content: "[Service]\nExecStart=/bin/echo bye", + }, + }, + }}, + }, + result: TestUnitManager{ + placed: []string{"hi.service", "hi.service.d/lo.conf", "hi.service.d/bye.conf"}, + unmasked: []string{"hi.service"}, + reload: true, + }, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + DropIns: []config.UnitDropIn{ + { + Name: "lo.conf", + Content: "[Service]\nExecStart=/bin/echo lo", + }, + }, + }}, + }, + result: TestUnitManager{}, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "hi.service", + DropIns: []config.UnitDropIn{ + { + Content: "[Service]\nExecStart=/bin/echo lo", + }, + }, + }}, + }, + result: TestUnitManager{}, + }, + { + units: []system.Unit{ + system.Unit{Unit: config.Unit{ + Name: "hi.service", + DropIns: []config.UnitDropIn{ + { + Name: "lo.conf", + }, + }, + }}, + }, + result: TestUnitManager{}, + }, + } + + for _, tt := range tests { + tum := &TestUnitManager{} + if err := processUnits(tt.units, "", tum); err != nil { + t.Errorf("bad error (%+v): want nil, got %s", tt.units, err) + } + if !reflect.DeepEqual(tt.result, *tum) { + t.Errorf("bad result (%+v): want %+v, got %+v", tt.units, tt.result, tum) + } + } +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/env.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/env.go new file mode 100755 index 00000000..f90cc932 --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/env.go @@ -0,0 +1,116 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "net" + "os" + "path" + "regexp" + "strings" + + "github.com/coreos/coreos-cloudinit/config" + "github.com/coreos/coreos-cloudinit/datasource" + "github.com/coreos/coreos-cloudinit/system" +) + +const DefaultSSHKeyName = "coreos-cloudinit" + +type Environment struct { + root string + configRoot string + workspace string + sshKeyName string + substitutions map[string]string +} + +// TODO(jonboulle): this is getting unwieldy, should be able to simplify the interface somehow +func NewEnvironment(root, configRoot, workspace, sshKeyName string, metadata datasource.Metadata) *Environment { + firstNonNull := func(ip net.IP, env string) string { + if ip == nil { + return env + } + return ip.String() + } + substitutions := map[string]string{ + "$public_ipv4": firstNonNull(metadata.PublicIPv4, os.Getenv("COREOS_PUBLIC_IPV4")), + "$private_ipv4": firstNonNull(metadata.PrivateIPv4, os.Getenv("COREOS_PRIVATE_IPV4")), + "$public_ipv6": firstNonNull(metadata.PublicIPv6, os.Getenv("COREOS_PUBLIC_IPV6")), + "$private_ipv6": firstNonNull(metadata.PrivateIPv6, os.Getenv("COREOS_PRIVATE_IPV6")), + } + return &Environment{root, configRoot, workspace, sshKeyName, substitutions} +} + +func (e *Environment) Workspace() string { + return path.Join(e.root, e.workspace) +} + +func (e *Environment) Root() string { + return e.root +} + +func (e *Environment) ConfigRoot() string { + return e.configRoot +} + +func (e *Environment) SSHKeyName() string { + return e.sshKeyName +} + +func (e *Environment) SetSSHKeyName(name string) { + e.sshKeyName = name +} + +// Apply goes through the map of substitutions and replaces all instances of +// the keys with their respective values. It supports escaping substitutions +// with a leading '\'. +func (e *Environment) Apply(data string) string { + for key, val := range e.substitutions { + matchKey := strings.Replace(key, `$`, `\$`, -1) + replKey := strings.Replace(key, `$`, `$$`, -1) + + // "key" -> "val" + data = regexp.MustCompile(`([^\\]|^)`+matchKey).ReplaceAllString(data, `${1}`+val) + // "\key" -> "key" + data = regexp.MustCompile(`\\`+matchKey).ReplaceAllString(data, replKey) + } + return data +} + +func (e *Environment) DefaultEnvironmentFile() *system.EnvFile { + ef := system.EnvFile{ + File: &system.File{File: config.File{ + Path: "/etc/environment", + }}, + Vars: map[string]string{}, + } + if ip, ok := e.substitutions["$public_ipv4"]; ok && len(ip) > 0 { + ef.Vars["COREOS_PUBLIC_IPV4"] = ip + } + if ip, ok := e.substitutions["$private_ipv4"]; ok && len(ip) > 0 { + ef.Vars["COREOS_PRIVATE_IPV4"] = ip + } + if ip, ok := e.substitutions["$public_ipv6"]; ok && len(ip) > 0 { + ef.Vars["COREOS_PUBLIC_IPV6"] = ip + } + if ip, ok := e.substitutions["$private_ipv6"]; ok && len(ip) > 0 { + ef.Vars["COREOS_PRIVATE_IPV6"] = ip + } + if len(ef.Vars) == 0 { + return nil + } else { + return &ef + } +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/env_test.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/env_test.go new file mode 100755 index 00000000..abb770cf --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/env_test.go @@ -0,0 +1,148 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "io/ioutil" + "net" + "os" + "path" + "testing" + + "github.com/coreos/coreos-cloudinit/datasource" + "github.com/coreos/coreos-cloudinit/system" +) + +func TestEnvironmentApply(t *testing.T) { + os.Setenv("COREOS_PUBLIC_IPV4", "1.2.3.4") + os.Setenv("COREOS_PRIVATE_IPV4", "5.6.7.8") + os.Setenv("COREOS_PUBLIC_IPV6", "1234::") + os.Setenv("COREOS_PRIVATE_IPV6", "5678::") + for _, tt := range []struct { + metadata datasource.Metadata + input string + out string + }{ + { + // Substituting both values directly should always take precedence + // over environment variables + datasource.Metadata{ + PublicIPv4: net.ParseIP("192.0.2.3"), + PrivateIPv4: net.ParseIP("192.0.2.203"), + PublicIPv6: net.ParseIP("fe00:1234::"), + PrivateIPv6: net.ParseIP("fe00:5678::"), + }, + `[Service] +ExecStart=/usr/bin/echo "$public_ipv4 $public_ipv6" +ExecStop=/usr/bin/echo $private_ipv4 $private_ipv6 +ExecStop=/usr/bin/echo $unknown`, + `[Service] +ExecStart=/usr/bin/echo "192.0.2.3 fe00:1234::" +ExecStop=/usr/bin/echo 192.0.2.203 fe00:5678:: +ExecStop=/usr/bin/echo $unknown`, + }, + { + // Substituting one value directly while falling back with the other + datasource.Metadata{ + PrivateIPv4: net.ParseIP("127.0.0.1"), + }, + "$private_ipv4\n$public_ipv4", + "127.0.0.1\n1.2.3.4", + }, + { + // Falling back to environment variables for both values + datasource.Metadata{}, + "$private_ipv4\n$public_ipv4", + "5.6.7.8\n1.2.3.4", + }, + { + // No substitutions + datasource.Metadata{}, + "$private_ipv4\nfoobar", + "5.6.7.8\nfoobar", + }, + { + // Escaping substitutions + datasource.Metadata{ + PrivateIPv4: net.ParseIP("127.0.0.1"), + }, + `\$private_ipv4 +$private_ipv4 +addr: \$private_ipv4 +\\$private_ipv4`, + `$private_ipv4 +127.0.0.1 +addr: $private_ipv4 +\$private_ipv4`, + }, + { + // No substitutions with escaping + datasource.Metadata{}, + "\\$test\n$test", + "\\$test\n$test", + }, + } { + + env := NewEnvironment("./", "./", "./", "", tt.metadata) + got := env.Apply(tt.input) + if got != tt.out { + t.Fatalf("Environment incorrectly applied.\ngot:\n%s\nwant:\n%s", got, tt.out) + } + } +} + +func TestEnvironmentFile(t *testing.T) { + metadata := datasource.Metadata{ + PublicIPv4: net.ParseIP("1.2.3.4"), + PrivateIPv4: net.ParseIP("5.6.7.8"), + PublicIPv6: net.ParseIP("1234::"), + PrivateIPv6: net.ParseIP("5678::"), + } + expect := "COREOS_PRIVATE_IPV4=5.6.7.8\nCOREOS_PRIVATE_IPV6=5678::\nCOREOS_PUBLIC_IPV4=1.2.3.4\nCOREOS_PUBLIC_IPV6=1234::\n" + + dir, err := ioutil.TempDir(os.TempDir(), "coreos-cloudinit-") + if err != nil { + t.Fatalf("Unable to create tempdir: %v", err) + } + defer os.RemoveAll(dir) + + env := NewEnvironment("./", "./", "./", "", metadata) + ef := env.DefaultEnvironmentFile() + err = system.WriteEnvFile(ef, dir) + if err != nil { + t.Fatalf("WriteEnvFile failed: %v", err) + } + + fullPath := path.Join(dir, "etc", "environment") + contents, err := ioutil.ReadFile(fullPath) + if err != nil { + t.Fatalf("Unable to read expected file: %v", err) + } + + if string(contents) != expect { + t.Fatalf("File has incorrect contents: %q", contents) + } +} + +func TestEnvironmentFileNil(t *testing.T) { + os.Clearenv() + metadata := datasource.Metadata{} + + env := NewEnvironment("./", "./", "./", "", metadata) + ef := env.DefaultEnvironmentFile() + if ef != nil { + t.Fatalf("Environment file not nil: %v", ef) + } +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/github.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/github.go new file mode 100755 index 00000000..2f7755fe --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/github.go @@ -0,0 +1,32 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "fmt" + + "github.com/coreos/coreos-cloudinit/system" +) + +func SSHImportGithubUser(system_user string, github_user string) error { + url := fmt.Sprintf("https://api.github.com/users/%s/keys", github_user) + keys, err := fetchUserKeys(url) + if err != nil { + return err + } + + key_name := fmt.Sprintf("github-%s", github_user) + return system.AuthorizeSSHKeys(system_user, key_name, keys) +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/ssh_keys.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/ssh_keys.go new file mode 100755 index 00000000..17b0c4a9 --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/ssh_keys.go @@ -0,0 +1,57 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "encoding/json" + "fmt" + + "github.com/coreos/coreos-cloudinit/pkg" + "github.com/coreos/coreos-cloudinit/system" +) + +type UserKey struct { + ID int `json:"id,omitempty"` + Key string `json:"key"` +} + +func SSHImportKeysFromURL(system_user string, url string) error { + keys, err := fetchUserKeys(url) + if err != nil { + return err + } + + key_name := fmt.Sprintf("coreos-cloudinit-%s", system_user) + return system.AuthorizeSSHKeys(system_user, key_name, keys) +} + +func fetchUserKeys(url string) ([]string, error) { + client := pkg.NewHttpClient() + data, err := client.GetRetry(url) + if err != nil { + return nil, err + } + + var userKeys []UserKey + err = json.Unmarshal(data, &userKeys) + if err != nil { + return nil, err + } + keys := make([]string, 0) + for _, key := range userKeys { + keys = append(keys, key.Key) + } + return keys, err +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/ssh_keys_test.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/ssh_keys_test.go new file mode 100755 index 00000000..86395797 --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/ssh_keys_test.go @@ -0,0 +1,56 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCloudConfigUsersUrlMarshal(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gh_res := ` +[ + { + "key": "ssh-dss AAAAB3NzaC1kc3MAAACBAIHAu822ggSkIHrJYvhmBceOSVjuflfQm8RbMMDNVe9relQfuPbN+nxGGTCKzPLebeOcX+Wwi77TPXWwK3BZMglfXxhABlFPsuMb63Tqp94pBYsJdx/iFj9iGo6pKoM1k8ubOcqsUnq+BR9895zRbE7MjdwkGo67+QhCEwvkwAnNAAAAFQCuddVqXLCubzqnWmeHLQE+2GFfHwAAAIBnlXW5h15ndVuwi0htF4oodVSB1KwnTWcuBK+aE1zRs76yvRb0Ws+oifumThDwB/Tec6FQuAfRKfy6piChZqsu5KvL98I+2t5yyi1td+kMvdTnVL2lW44etDKseOcozmknCOmh4Dqvhl/2MwrDAhlPaN08EEq9h3w3mXtNLWH64QAAAIBAzDOKr17llngaKIdDXh+LtXKh87+zfjlTA36/9r2uF2kYE5uApDtu9sPCkt7+YBQt7R8prADPckwAiXwVdk0xijIOpLDBmoydQJJRQ+zTMxvpQmUr/1kUOv0zb+lB657CgvN0vVTmP2swPeMvgntt3C4vw7Ab+O+MS9peOAJbbQ==" + }, + { + "key": "ssh-dss AAAAB3NzaC1kc3MAAACBANxpzIbTzKTeBRaOIdUxwwGwvDasTfU/PonhbNIuhYjc+xFGvBRTumox2F+luVAKKs4WdvA4nJXaY1OFi6DZftk5Bp4E2JaSzp8ulAzHsMexDdv6LGHGEJj/qdHAL1vHk2K89PpwRFSRZI8XRBLjvkr4ZgBKLG5ZILXPJEPP2j3lAAAAFQCtxoTnV8wy0c4grcGrQ+1sCsD7WQAAAIAqZsW2GviMe1RQrbZT0xAZmI64XRPrnLsoLxycHWlS7r6uUln2c6Ae2MB/YF0d4Kd1XZii9GHj7rrypqEo7MW8uSabhu70nmu1J8m2O3Dsr+4oJLeat9vwPsJV92IKO0jQwjKnAOHOiB9JKGeCw+NfXfogbti9/q38Q6XcS+SI5wAAAIEA1803Y2h+tOOpZXAsNIwl9mRfExWzLQ3L7knwJdznQu/6SW1H/1oyoYLebuk187Qj2UFI5qQ6AZNc49DvohWx0Cg6ABcyubNyoaCjZKWIdxVnItHWNbLe//+tyTu0I2eQwJOORsEPK5gMpf599C7wXQ//DzZOWbTWiHEX52gCTmk=" + }, + { + "id": 5224438, + "key": "ssh-dss AAAAB3NzaC1kc3MAAACBAPKRWdKhzGZuLAJL6M1eM51hWViMqNBC2C6lm2OqGRYLuIf1GJ391widUuSf4wQqnkR22Q9PCmAZ19XCf11wBRMnuw9I/Z3Bt5bXfc+dzFBCmHYGJ6wNSv++H9jxyMb+usmsenWOFZGNO2jN0wrJ4ay8Yt0bwtRU+VCXpuRLszMzAAAAFQDZUIuPjcfK5HLgnwZ/J3lvtvlUjQAAAIEApIkAwLuCQV5j3U6DmI/Y6oELqSUR2purFm8jo8jePFfe1t+ghikgD254/JXlhDCVgY0NLXcak+coJfGCTT23quJ7I5xdpTn/OZO2Q6Woum/bijFC/UWwQbLz0R2nU3DoHv5v6XHQZxuIG4Fsxa91S+vWjZFtI7RuYlBCZA//ANMAAACBAJO0FojzkX6IeaWLqrgu9GTkFwGFazZ+LPH5JOWPoPn1hQKuR32Uf6qNcBZcIjY7SF0P7HF5rLQd6zKZzHqqQQ92MV555NEwjsnJglYU8CaaZsfYooaGPgA1YN7RhTSAuDmUW5Hyfj5BH4NTtrzrvJxIhDoQLf31Fasjw00r4R0O" + } +] +` + fmt.Fprintln(w, gh_res) + })) + defer ts.Close() + + keys, err := fetchUserKeys(ts.URL) + if err != nil { + t.Fatalf("Encountered unexpected error: %v", err) + } + expected := "ssh-dss AAAAB3NzaC1kc3MAAACBAIHAu822ggSkIHrJYvhmBceOSVjuflfQm8RbMMDNVe9relQfuPbN+nxGGTCKzPLebeOcX+Wwi77TPXWwK3BZMglfXxhABlFPsuMb63Tqp94pBYsJdx/iFj9iGo6pKoM1k8ubOcqsUnq+BR9895zRbE7MjdwkGo67+QhCEwvkwAnNAAAAFQCuddVqXLCubzqnWmeHLQE+2GFfHwAAAIBnlXW5h15ndVuwi0htF4oodVSB1KwnTWcuBK+aE1zRs76yvRb0Ws+oifumThDwB/Tec6FQuAfRKfy6piChZqsu5KvL98I+2t5yyi1td+kMvdTnVL2lW44etDKseOcozmknCOmh4Dqvhl/2MwrDAhlPaN08EEq9h3w3mXtNLWH64QAAAIBAzDOKr17llngaKIdDXh+LtXKh87+zfjlTA36/9r2uF2kYE5uApDtu9sPCkt7+YBQt7R8prADPckwAiXwVdk0xijIOpLDBmoydQJJRQ+zTMxvpQmUr/1kUOv0zb+lB657CgvN0vVTmP2swPeMvgntt3C4vw7Ab+O+MS9peOAJbbQ==" + if keys[0] != expected { + t.Fatalf("expected %s, got %s", expected, keys[0]) + } + expected = "ssh-dss AAAAB3NzaC1kc3MAAACBAPKRWdKhzGZuLAJL6M1eM51hWViMqNBC2C6lm2OqGRYLuIf1GJ391widUuSf4wQqnkR22Q9PCmAZ19XCf11wBRMnuw9I/Z3Bt5bXfc+dzFBCmHYGJ6wNSv++H9jxyMb+usmsenWOFZGNO2jN0wrJ4ay8Yt0bwtRU+VCXpuRLszMzAAAAFQDZUIuPjcfK5HLgnwZ/J3lvtvlUjQAAAIEApIkAwLuCQV5j3U6DmI/Y6oELqSUR2purFm8jo8jePFfe1t+ghikgD254/JXlhDCVgY0NLXcak+coJfGCTT23quJ7I5xdpTn/OZO2Q6Woum/bijFC/UWwQbLz0R2nU3DoHv5v6XHQZxuIG4Fsxa91S+vWjZFtI7RuYlBCZA//ANMAAACBAJO0FojzkX6IeaWLqrgu9GTkFwGFazZ+LPH5JOWPoPn1hQKuR32Uf6qNcBZcIjY7SF0P7HF5rLQd6zKZzHqqQQ92MV555NEwjsnJglYU8CaaZsfYooaGPgA1YN7RhTSAuDmUW5Hyfj5BH4NTtrzrvJxIhDoQLf31Fasjw00r4R0O" + if keys[2] != expected { + t.Fatalf("expected %s, got %s", expected, keys[2]) + } +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/user_data.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/user_data.go new file mode 100755 index 00000000..170efaaa --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/user_data.go @@ -0,0 +1,39 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "errors" + "log" + + "github.com/coreos/coreos-cloudinit/config" +) + +func ParseUserData(contents string) (interface{}, error) { + if len(contents) == 0 { + return nil, nil + } + + switch { + case config.IsScript(contents): + log.Printf("Parsing user-data as script") + return config.NewScript(contents) + case config.IsCloudConfig(contents): + log.Printf("Parsing user-data as cloud-config") + return config.NewCloudConfig(contents) + default: + return nil, errors.New("Unrecognized user-data format") + } +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/user_data_test.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/user_data_test.go new file mode 100755 index 00000000..1d883694 --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/user_data_test.go @@ -0,0 +1,74 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "testing" + + "github.com/coreos/coreos-cloudinit/config" +) + +func TestParseHeaderCRLF(t *testing.T) { + configs := []string{ + "#cloud-config\nfoo: bar", + "#cloud-config\r\nfoo: bar", + } + + for i, config := range configs { + _, err := ParseUserData(config) + if err != nil { + t.Errorf("Failed parsing config %d: %v", i, err) + } + } + + scripts := []string{ + "#!bin/bash\necho foo", + "#!bin/bash\r\necho foo", + } + + for i, script := range scripts { + _, err := ParseUserData(script) + if err != nil { + t.Errorf("Failed parsing script %d: %v", i, err) + } + } +} + +func TestParseConfigCRLF(t *testing.T) { + contents := "#cloud-config\r\nhostname: foo\r\nssh_authorized_keys:\r\n - foobar\r\n" + ud, err := ParseUserData(contents) + if err != nil { + t.Fatalf("Failed parsing config: %v", err) + } + + cfg := ud.(*config.CloudConfig) + + if cfg.Hostname != "foo" { + t.Error("Failed parsing hostname from config") + } + + if len(cfg.SSHAuthorizedKeys) != 1 { + t.Error("Parsed incorrect number of SSH keys") + } +} + +func TestParseConfigEmpty(t *testing.T) { + i, e := ParseUserData(``) + if i != nil { + t.Error("ParseUserData of empty string returned non-nil unexpectedly") + } else if e != nil { + t.Error("ParseUserData of empty string returned error unexpectedly") + } +} diff --git a/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/workspace.go b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/workspace.go new file mode 100755 index 00000000..540dcf41 --- /dev/null +++ b/Godeps/_workspace/src/github.com/coreos/coreos-cloudinit/initialize/workspace.go @@ -0,0 +1,66 @@ +// Copyright 2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package initialize + +import ( + "io/ioutil" + "path" + "strings" + + "github.com/coreos/coreos-cloudinit/config" + "github.com/coreos/coreos-cloudinit/system" +) + +func PrepWorkspace(workspace string) error { + if err := system.EnsureDirectoryExists(workspace); err != nil { + return err + } + + scripts := path.Join(workspace, "scripts") + if err := system.EnsureDirectoryExists(scripts); err != nil { + return err + } + + return nil +} + +func PersistScriptInWorkspace(script config.Script, workspace string) (string, error) { + scriptsPath := path.Join(workspace, "scripts") + tmp, err := ioutil.TempFile(scriptsPath, "") + if err != nil { + return "", err + } + tmp.Close() + + relpath := strings.TrimPrefix(tmp.Name(), workspace) + + file := system.File{File: config.File{ + Path: relpath, + RawFilePermissions: "0744", + Content: string(script), + }} + + return system.WriteFile(&file, workspace) +} + +func PersistUnitNameInWorkspace(name string, workspace string) error { + file := system.File{File: config.File{ + Path: path.Join("scripts", "unit-name"), + RawFilePermissions: "0644", + Content: name, + }} + _, err := system.WriteFile(&file, workspace) + return err +} diff --git a/cmd/cloudinit/cloudinit.go b/cmd/cloudinit/cloudinit.go index 7ee24aa6..0f02ec01 100644 --- a/cmd/cloudinit/cloudinit.go +++ b/cmd/cloudinit/cloudinit.go @@ -35,6 +35,7 @@ import ( "github.com/coreos/coreos-cloudinit/datasource/metadata/ec2" "github.com/coreos/coreos-cloudinit/datasource/proc_cmdline" "github.com/coreos/coreos-cloudinit/datasource/url" + "github.com/coreos/coreos-cloudinit/initialize" "github.com/coreos/coreos-cloudinit/pkg" "github.com/coreos/coreos-cloudinit/system" "github.com/rancherio/os/cmd/cloudinit/hostname" @@ -229,6 +230,7 @@ func saveCloudConfig() error { } } + userDataBytes = substituteUserDataVars(userDataBytes, metadata) userData := string(userDataBytes) scriptBytes := []byte{} @@ -514,3 +516,10 @@ func toCompose(bytes []byte) ([]byte, error) { }, }) } + +func substituteUserDataVars(userDataBytes []byte, metadata datasource.Metadata) []byte { + env := initialize.NewEnvironment("", "", "", "", metadata) + userData := env.Apply(string(userDataBytes)) + + return []byte(userData) +} diff --git a/cmd/cloudinit/cloudinit_test.go b/cmd/cloudinit/cloudinit_test.go new file mode 100644 index 00000000..3b5dfeed --- /dev/null +++ b/cmd/cloudinit/cloudinit_test.go @@ -0,0 +1,100 @@ +// Copyright 2015 CoreOS, Inc. +// Copyright 2015 Rancher Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudinit + +import ( + "net" + "testing" + + "github.com/coreos/coreos-cloudinit/datasource" +) + +func TestSubstituteUserDataVars(t *testing.T) { + for _, tt := range []struct { + metadata datasource.Metadata + input string + out string + }{ + { + // Userdata with docker-compose syntax + datasource.Metadata{ + PublicIPv4: net.ParseIP("192.0.2.3"), + PrivateIPv4: net.ParseIP("192.0.2.203"), + PublicIPv6: net.ParseIP("fe00:1234::"), + PrivateIPv6: net.ParseIP("fe00:5678::"), + }, + `servicexyz: + image: rancher/servicexyz:v0.3.1 + ports: + - "$public_ipv4:8001:8001" + - "$public_ipv6:8001:8001" + - "$private_ipv4:8001:8001" + - "$private_ipv6:8001:8001"`, + `servicexyz: + image: rancher/servicexyz:v0.3.1 + ports: + - "192.0.2.3:8001:8001" + - "fe00:1234:::8001:8001" + - "192.0.2.203:8001:8001" + - "fe00:5678:::8001:8001"`, + }, + { + // Userdata with cloud-config/rancher syntax + datasource.Metadata{ + PublicIPv4: net.ParseIP("192.0.2.3"), + PrivateIPv4: net.ParseIP("192.0.2.203"), + PublicIPv6: net.ParseIP("fe00:1234::"), + PrivateIPv6: net.ParseIP("fe00:5678::"), + }, + `write_files: + - path: /etc/environment + content: | + PRIVATE_IPV6=$private_ipv6 + PUBLIC_IPV6=$public_ipv6 + rancher: + network: + interfaces: + eth1: + address: $private_ipv4/16 + user_docker: + tls_args: ['-H=$public_ipv4:2376']`, + `write_files: + - path: /etc/environment + content: | + PRIVATE_IPV6=fe00:5678:: + PUBLIC_IPV6=fe00:1234:: + rancher: + network: + interfaces: + eth1: + address: 192.0.2.203/16 + user_docker: + tls_args: ['-H=192.0.2.3:2376']`, + }, + { + // no metadata + datasource.Metadata{}, + "address: $private_ipv4", + "address: ", + }, + } { + + got := substituteUserDataVars([]byte(tt.input), tt.metadata) + if string(got) != tt.out { + t.Fatalf("Userdata substitution incorrectly applied.\ngot:\n%s\nwant:\n%s", got, tt.out) + } + } +}