diff --git a/README.md b/README.md index 8b0a979..485f423 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ The value of `coreos.etcd.discovery_url` will be used to discover the instance's [disco-proto]: https://github.com/coreos/etcd/blob/master/Documentation/discovery-protocol.md [disco-service]: http://discovery.etcd.io +#### coreos.units + +Arbitrary systemd units may be provided in the `coreos.units` attribute. +`coreos.units` is a list of objects with the following fields: + +- **name**: string representing unit's name +- **runtime**: boolean indicating whether or not to persist the unit across reboots. This is analagous to the `--runtime` flag to `systemd enable`. +- **content**: plaintext string representing entire unit file + +See docker example below. ## user-data Script @@ -40,8 +50,7 @@ echo 'Hello, world!' ## Examples -### Inject an SSH key, bootstrap etcd, and start fleet using a cloud-config - +### Inject an SSH key, bootstrap etcd, and start fleet ``` #cloud-config @@ -50,8 +59,29 @@ coreos: discovery_url: https://discovery.etcd.io/827c73219eeb2fa5530027c37bf18877 fleet: autostart: yes - ssh_authorized_keys: - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0g+ZTxC7weoIJLUafOgrm+h... ``` +### Start a docker container on boot + +``` +#cloud-config + +coreos: + units: + - name: docker-redis.service + content: | + [Unit] + Description=Redis container + Author=Me + After=docker.service + + [Service] + Restart=always + ExecStart=/usr/bin/docker start -a redis_server + ExecStop=/usr/bin/docker stop -t 2 redis_server + + [Install] + WantedBy=local.target +``` diff --git a/cloudinit/cloud_config.go b/cloudinit/cloud_config.go index e0b49fe..1091df8 100644 --- a/cloudinit/cloud_config.go +++ b/cloudinit/cloud_config.go @@ -13,6 +13,7 @@ type CloudConfig struct { Coreos struct { Etcd struct{ Discovery_URL string } Fleet struct{ Autostart bool } + Units []Unit } Write_Files []WriteFile } @@ -60,8 +61,25 @@ func ApplyCloudConfig(cfg CloudConfig, sshKeyName string) error { } } + if len(cfg.Coreos.Units) > 0 { + for _, unit := range cfg.Coreos.Units { + dst, err := PlaceUnit("/", &unit) + if err != nil { + return err + } + log.Printf("Placed unit %s at %s", unit.Name, dst) + + if err := EnableUnitFile(dst, unit.Runtime); err != nil { + return err + } + log.Printf("Enabled unit %s", unit.Name) + } + DaemonReload() + StartUnits(cfg.Coreos.Units) + } + if cfg.Coreos.Fleet.Autostart { - err := StartUnit("fleet.service") + err := StartUnitByName("fleet.service") if err == nil { log.Printf("Started fleet service.") } else { diff --git a/cloudinit/cloud_config_test.go b/cloudinit/cloud_config_test.go index fe75f4b..46a1e19 100644 --- a/cloudinit/cloud_config_test.go +++ b/cloudinit/cloud_config_test.go @@ -37,6 +37,19 @@ coreos: discovery_url: "https://discovery.etcd.io/827c73219eeb2fa5530027c37bf18877" fleet: autostart: Yes + units: + - name: 50-eth0.network + runtime: yes + content: '[Match] + + Name=eth47 + + + [Network] + + Address=10.209.171.177/19 + +' ssh_authorized_keys: - foobar - foobaz @@ -90,6 +103,31 @@ write_files: t.Errorf("WriteFile has incorrect owner %s", wf.Owner) } } + + if len(cfg.Coreos.Units) != 1 { + t.Error("Failed to parse correct number of units") + } else { + u := cfg.Coreos.Units[0] + expect := `[Match] +Name=eth47 + +[Network] +Address=10.209.171.177/19 +` + if u.Content != expect { + t.Errorf("Unit has incorrect contents '%s'.\nExpected '%s'.", u.Content, expect) + } + if u.Runtime != true { + t.Errorf("Unit has incorrect runtime value") + } + if u.Name != "50-eth0.network" { + t.Errorf("Unit has incorrect name %s", u.Name) + } + if u.Type() != "network" { + t.Errorf("Unit has incorrect type '%s'", u.Type()) + } + } + } // Assert that our interface conversion doesn't panic diff --git a/cloudinit/systemd.go b/cloudinit/systemd.go index 4185e39..c3f2335 100644 --- a/cloudinit/systemd.go +++ b/cloudinit/systemd.go @@ -2,15 +2,124 @@ package cloudinit import ( "fmt" + "io/ioutil" "log" + "os" "path" + "path/filepath" + "strings" "github.com/coreos/go-systemd/dbus" ) +type Unit struct { + Name string + Runtime bool + Content string +} + +func (u *Unit) Type() string { + ext := filepath.Ext(u.Name) + return strings.TrimLeft(ext, ".") +} + +func (u *Unit) Group() (group string) { + t := u.Type() + if t == "network" || t == "netdev" || t == "link" { + group = "network" + } else { + group = "system" + } + return +} + type Script []byte -func StartUnit(name string) error { +func PlaceUnit(root string, u *Unit) (string, error) { + dir := "etc" + if u.Runtime { + dir = "run" + } + + dst := path.Join(root, dir, "systemd", u.Group()) + if _, err := os.Stat(dst); os.IsNotExist(err) { + if err := os.MkdirAll(dst, os.FileMode(0755)); err != nil { + return "", err + } + } + + dst = path.Join(dst, u.Name) + err := ioutil.WriteFile(dst, []byte(u.Content), os.FileMode(0644)) + if err != nil { + return "", err + } + + return dst, nil +} + +func EnableUnitFile(file string, runtime bool) error { + conn, err := dbus.New() + if err != nil { + return err + } + + files := []string{file} + _, _, err = conn.EnableUnitFiles(files, runtime, true) + return err +} + +func separateNetworkUnits(units []Unit) ([]Unit, []Unit) { + networkUnits := make([]Unit, 0) + nonNetworkUnits := make([]Unit, 0) + for _, unit := range units { + if unit.Group() == "network" { + networkUnits = append(networkUnits, unit) + } else { + nonNetworkUnits = append(nonNetworkUnits, unit) + } + } + return networkUnits, nonNetworkUnits +} + +func StartUnits(units []Unit) error { + networkUnits, nonNetworkUnits := separateNetworkUnits(units) + if len(networkUnits) > 0 { + if err := RestartUnitByName("systemd-networkd.service"); err != nil { + return err + } + } + + for _, unit := range nonNetworkUnits { + if err := RestartUnitByName(unit.Name); err != nil { + return err + } + } + + return nil +} + +func DaemonReload() error { + conn, err := dbus.New() + if err != nil { + return err + } + + _, err = conn.Reload() + return err +} + +func RestartUnitByName(name string) error { + log.Printf("Restarting unit %s", name) + conn, err := dbus.New() + if err != nil { + return err + } + + _, err = conn.RestartUnit(name, "replace") + return err +} + +func StartUnitByName(name string) error { conn, err := dbus.New() if err != nil { return err diff --git a/cloudinit/systemd_test.go b/cloudinit/systemd_test.go new file mode 100644 index 0000000..f7089ea --- /dev/null +++ b/cloudinit/systemd_test.go @@ -0,0 +1,102 @@ +package cloudinit + +import ( + "io/ioutil" + "os" + "path" + "syscall" + "testing" +) + +func TestPlaceNetworkUnit(t *testing.T) { + u := Unit{ + Name: "50-eth0.network", + Runtime: true, + Content: `[Match] +Name=eth47 + +[Network] +Address=10.209.171.177/19 +`, + } + + dir, err := ioutil.TempDir(os.TempDir(), "coreos-cloudinit-") + if err != nil { + t.Fatalf("Unable to create tempdir: %v", err) + } + defer syscall.Rmdir(dir) + + if _, err := PlaceUnit(dir, &u); err != nil { + t.Fatalf("PlaceUnit failed: %v", err) + } + + fullPath := path.Join(dir, "run", "systemd", "network", "50-eth0.network") + fi, err := os.Stat(fullPath) + if err != nil { + t.Fatalf("Unable to stat file: %v", err) + } + + if fi.Mode() != os.FileMode(0644) { + t.Errorf("File has incorrect mode: %v", fi.Mode()) + } + + contents, err := ioutil.ReadFile(fullPath) + if err != nil { + t.Fatalf("Unable to read expected file: %v", err) + } + + expect := `[Match] +Name=eth47 + +[Network] +Address=10.209.171.177/19 +` + if string(contents) != expect { + t.Fatalf("File has incorrect contents '%s'.\nExpected '%s'", string(contents), expect) + } +} + +func TestPlaceMountUnit(t *testing.T) { + u := Unit{ + Name: "media-state.mount", + Runtime: false, + Content: `[Mount] +What=/dev/sdb1 +Where=/media/state +`, + } + + dir, err := ioutil.TempDir(os.TempDir(), "coreos-cloudinit-") + if err != nil { + t.Fatalf("Unable to create tempdir: %v", err) + } + defer syscall.Rmdir(dir) + + if _, err := PlaceUnit(dir, &u); err != nil { + t.Fatalf("PlaceUnit failed: %v", err) + } + + fullPath := path.Join(dir, "etc", "systemd", "system", "media-state.mount") + fi, err := os.Stat(fullPath) + if err != nil { + t.Fatalf("Unable to stat file: %v", err) + } + + if fi.Mode() != os.FileMode(0644) { + t.Errorf("File has incorrect mode: %v", fi.Mode()) + } + + contents, err := ioutil.ReadFile(fullPath) + if err != nil { + t.Fatalf("Unable to read expected file: %v", err) + } + + expect := `[Mount] +What=/dev/sdb1 +Where=/media/state +` + if string(contents) != expect { + t.Fatalf("File has incorrect contents '%s'.\nExpected '%s'", string(contents), expect) + } +} +