From 0ffb933b52619ed8fea22db5c6d95bae8e3a1368 Mon Sep 17 00:00:00 2001 From: Nemric <56299157+Nemric@users.noreply.github.com> Date: Thu, 31 Mar 2022 20:05:45 +0000 Subject: [PATCH] Add systemd user unit feature --- config/shared/errors/errors.go | 3 + config/v3_4_experimental/schema/ignition.json | 9 + .../v3_4_experimental/translate/translate.go | 11 + config/v3_4_experimental/types/schema.go | 14 +- config/v3_4_experimental/types/unit.go | 35 ++- docs/configuration-v3_4_experimental.md | 2 + internal/exec/stages/files/units.go | 113 ++++---- internal/exec/stages/files/units_test.go | 266 ++++++++++++++++++ internal/exec/util/passwd.go | 12 +- internal/exec/util/path.go | 56 +++- internal/exec/util/unit.go | 182 +++++++----- tests/negative/systemd/units.go | 127 +++++++++ tests/positive/systemd/create_unit.go | 90 ++++++ tests/registry/registry.go | 1 + 14 files changed, 788 insertions(+), 133 deletions(-) create mode 100644 tests/negative/systemd/units.go diff --git a/config/shared/errors/errors.go b/config/shared/errors/errors.go index 7761280d07..06e0261288 100644 --- a/config/shared/errors/errors.go +++ b/config/shared/errors/errors.go @@ -85,6 +85,9 @@ var ( ErrInvalidSystemdDropinExt = errors.New("invalid systemd drop-in extension") ErrNoSystemdExt = errors.New("no systemd unit extension") ErrInvalidInstantiatedUnit = errors.New("invalid systemd instantiated unit") + ErrInvalidUnitScope = errors.New("unit scope must be system, user or global") + ErrUnitNoUsersDefined = errors.New("when 'user' scope is used you must set at least one user") + ErrUnitUsersDefined = errors.New("'users' should be specified only if scope is 'user'") // Misc errors ErrSourceRequired = errors.New("source is required") diff --git a/config/v3_4_experimental/schema/ignition.json b/config/v3_4_experimental/schema/ignition.json index d93ce6c2d0..f0499db934 100644 --- a/config/v3_4_experimental/schema/ignition.json +++ b/config/v3_4_experimental/schema/ignition.json @@ -507,6 +507,15 @@ "enabled": { "type": ["boolean", "null"] }, + "scope": { + "type": ["string", "null"] + }, + "users": { + "type": "array", + "items": { + "type": "string" + } + }, "mask": { "type": ["boolean", "null"] }, diff --git a/config/v3_4_experimental/translate/translate.go b/config/v3_4_experimental/translate/translate.go index 5b39cae9b8..3b6deb9ae2 100644 --- a/config/v3_4_experimental/translate/translate.go +++ b/config/v3_4_experimental/translate/translate.go @@ -27,9 +27,20 @@ func translateIgnition(old old_types.Ignition) (ret types.Ignition) { return } +func translateUnit(old old_types.Unit) (ret types.Unit) { + tr := translate.NewTranslator() + tr.Translate(&old.Contents, &ret.Contents) + tr.Translate(&old.Dropins, &ret.Dropins) + tr.Translate(&old.Enabled, &ret.Enabled) + tr.Translate(&old.Mask, &ret.Mask) + tr.Translate(&old.Name, &ret.Name) + return +} + func Translate(old old_types.Config) (ret types.Config) { tr := translate.NewTranslator() tr.AddCustomTranslator(translateIgnition) + tr.AddCustomTranslator(translateUnit) tr.Translate(&old, &ret) return } diff --git a/config/v3_4_experimental/types/schema.go b/config/v3_4_experimental/types/schema.go index ca25b99ea7..e75e3880db 100644 --- a/config/v3_4_experimental/types/schema.go +++ b/config/v3_4_experimental/types/schema.go @@ -242,13 +242,17 @@ type Timeouts struct { } type Unit struct { - Contents *string `json:"contents,omitempty"` - Dropins []Dropin `json:"dropins,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - Mask *bool `json:"mask,omitempty"` - Name string `json:"name"` + Contents *string `json:"contents,omitempty"` + Dropins []Dropin `json:"dropins,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Mask *bool `json:"mask,omitempty"` + Name string `json:"name"` + Scope *string `json:"scope,omitempty"` + Users []UnitUser `json:"users,omitempty"` } +type UnitUser string + type Verification struct { Hash *string `json:"hash,omitempty"` } diff --git a/config/v3_4_experimental/types/unit.go b/config/v3_4_experimental/types/unit.go index bc2d3299c4..888687894e 100644 --- a/config/v3_4_experimental/types/unit.go +++ b/config/v3_4_experimental/types/unit.go @@ -29,7 +29,11 @@ import ( ) func (u Unit) Key() string { - return u.Name + if u.Scope != nil { + return *u.Scope + "." + u.Name + } else { + return "system." + u.Name + } } func (d Dropin) Key() string { @@ -43,10 +47,39 @@ func (u Unit) Validate(c cpath.ContextPath) (r report.Report) { r.AddOnError(c, err) r.AddOnWarn(c, validations.ValidateInstallSection(u.Name, util.IsTrue(u.Enabled), util.NilOrEmpty(u.Contents), opts)) + r.AddOnError(c.Append("scope"), validateScope(u.Scope)) + + err = validateUsers(u) + if err != nil && err == errors.ErrUnitUsersDefined { + r.AddOnWarn(c.Append("users"), err) + } else { + r.AddOnError(c.Append("users"), err) + } return } +func validateScope(scope *string) error { + if scope == nil { + return nil + } + switch *scope { + case "system", "user", "global": + return nil + default: + return errors.ErrInvalidUnitScope + } +} + +func validateUsers(u Unit) error { + if u.Scope != nil && *u.Scope == "user" && len(u.Users) == 0 { + return errors.ErrUnitNoUsersDefined + } else if len(u.Users) > 0 && *u.Scope != "user" { + return errors.ErrUnitUsersDefined + } + return nil +} + func validateName(name string) error { switch path.Ext(name) { case ".service", ".socket", ".device", ".mount", ".automount", ".swap", ".target", ".path", ".timer", ".snapshot", ".slice", ".scope": diff --git a/docs/configuration-v3_4_experimental.md b/docs/configuration-v3_4_experimental.md index 1520e9605b..23fbc49ffb 100644 --- a/docs/configuration-v3_4_experimental.md +++ b/docs/configuration-v3_4_experimental.md @@ -156,6 +156,8 @@ The Ignition configuration is a JSON document conforming to the following specif * **_dropins_** (list of objects): the list of drop-ins for the unit. Every drop-in must have a unique `name`. * **name** (string): the name of the drop-in. This must be suffixed with ".conf". * **_contents_** (string): the contents of the drop-in. + * **_scope_** (string): `system` for a system unit, `global` for a user unit applied to all users, or `user` for a user unit applied to users specified by **_users_**. Default is `system`. + * **_users_** (list of strings): usernames of users to be affected by a user unit. * **_passwd_** (object): describes the desired additions to the passwd database. * **_users_** (list of objects): the list of accounts that shall exist. All users must have a unique `name`. * **name** (string): the username for the account. diff --git a/internal/exec/stages/files/units.go b/internal/exec/stages/files/units.go index f7ded9e895..035407b4b3 100644 --- a/internal/exec/stages/files/units.go +++ b/internal/exec/stages/files/units.go @@ -33,6 +33,7 @@ type Preset struct { enabled bool instantiatable bool instances []string + scope util.UnitScope } // warnOnOldSystemdVersion checks the version of Systemd @@ -69,16 +70,16 @@ func (s *stage) createUnits(config types.Config) error { if err != nil { return err } - key := fmt.Sprintf("%s-%s", unitName, identifier) + key := fmt.Sprintf("%s.%s-%s", util.GetUnitScope(unit), unitName, identifier) if _, ok := presets[key]; ok { presets[key].instances = append(presets[key].instances, instance) } else { - presets[key] = &Preset{unitName, *unit.Enabled, true, []string{instance}} + presets[key] = &Preset{unitName, *unit.Enabled, true, []string{instance}, util.GetUnitScope(unit)} } } else { - key := fmt.Sprintf("%s-%s", unit.Name, identifier) - if _, ok := presets[unit.Name]; !ok { - presets[key] = &Preset{unit.Name, *unit.Enabled, false, []string{}} + key := fmt.Sprintf("%s-%s", unit.Key(), identifier) + if _, ok := presets[key]; !ok { + presets[key] = &Preset{unit.Name, *unit.Enabled, false, []string{}, util.GetUnitScope(unit)} } else { return fmt.Errorf("%q key is already present in the presets map", key) } @@ -86,18 +87,16 @@ func (s *stage) createUnits(config types.Config) error { } if unit.Mask != nil { if *unit.Mask { // mask: true - relabelpath := "" if err := s.Logger.LogOp( func() error { - var err error - relabelpath, err = s.MaskUnit(unit) + var err error = s.MaskUnit(unit) return err }, - "masking unit %q", unit.Name, + "masking unit %q for scope %q", unit.Name, string(util.GetUnitScope(unit)), ); err != nil { return err } - s.relabel(relabelpath) + } else { // mask: false masked, err := s.IsUnitMasked(unit) if err != nil { @@ -108,7 +107,7 @@ func (s *stage) createUnits(config types.Config) error { func() error { return s.UnmaskUnit(unit) }, - "unmasking unit %q", unit.Name, + "unmasking unit %q for scope %q", unit.Name, string(util.GetUnitScope(unit)), ); err != nil { return err } @@ -118,7 +117,7 @@ func (s *stage) createUnits(config types.Config) error { } // if we have presets then create the systemd preset file. if len(presets) != 0 { - if err := s.createSystemdPresetFile(presets); err != nil { + if err := s.createSystemdPresetFiles(presets); err != nil { return err } } @@ -145,31 +144,31 @@ func parseInstanceUnit(unit types.Unit) (string, string, error) { // createSystemdPresetFile creates the presetfile for enabled/disabled // systemd units. -func (s *stage) createSystemdPresetFile(presets map[string]*Preset) error { - if err := s.relabelPath(filepath.Join(s.DestDir, util.PresetPath)); err != nil { - return err - } +func (s *stage) createSystemdPresetFiles(presets map[string]*Preset) error { hasInstanceUnit := false - for _, value := range presets { - unitString := value.unit - if value.instantiatable { + for _, preset := range presets { + if err := s.relabelPath(filepath.Join(s.DestDir, s.SystemdPresetPath(preset.scope))); err != nil { + return err + } + unitString := preset.unit + if preset.instantiatable { hasInstanceUnit = true // Let's say we have two instantiated enabled units listed under // the systemd units i.e. echo@foo.service, echo@bar.service // then the unitString will look like "echo@.service foo bar" - unitString = fmt.Sprintf("%s %s", unitString, strings.Join(value.instances, " ")) + unitString = fmt.Sprintf("%s %s", unitString, strings.Join(preset.instances, " ")) } - if value.enabled { + if preset.enabled { if err := s.Logger.LogOp( - func() error { return s.EnableUnit(unitString) }, - "setting preset to enabled for %q", unitString, + func() error { return s.EnableUnit(unitString, preset.scope) }, + "setting %q preset to enabled for %q", preset.scope, unitString, ); err != nil { return err } } else { if err := s.Logger.LogOp( - func() error { return s.DisableUnit(unitString) }, - "setting preset to disabled for %q", unitString, + func() error { return s.DisableUnit(unitString, preset.scope) }, + "setting %q preset to disabled for %q", preset.scope, unitString, ); err != nil { return err } @@ -191,30 +190,32 @@ func (s *stage) createSystemdPresetFile(presets map[string]*Preset) error { // applies to the unit's dropins. func (s *stage) writeSystemdUnit(unit types.Unit) error { return s.Logger.LogOp(func() error { - relabeledDropinDir := false for _, dropin := range unit.Dropins { if dropin.Contents == nil { continue } - f, err := s.FileFromSystemdUnitDropin(unit, dropin) + fetchops, err := s.FilesFromSystemdUnitDropin(unit, dropin) if err != nil { s.Logger.Crit("error converting systemd dropin: %v", err) return err } - // trim off prefix since this needs to be relative to the sysroot - if !strings.HasPrefix(f.Node.Path, s.DestDir) { - panic(fmt.Sprintf("Dropin path %s isn't under prefix %s", f.Node.Path, s.DestDir)) - } - relabelPath := f.Node.Path[len(s.DestDir):] - if err := s.Logger.LogOp( - func() error { return s.PerformFetch(f) }, - "writing systemd drop-in %q at %q", dropin.Name, f.Node.Path, - ); err != nil { - return err - } - if !relabeledDropinDir { - s.relabel(filepath.Dir(relabelPath)) - relabeledDropinDir = true + for _, f := range fetchops { + relabeledDropinDir := false + // trim off prefix since this needs to be relative to the sysroot + if !strings.HasPrefix(f.Node.Path, s.DestDir) { + panic(fmt.Sprintf("Dropin path %s isn't under prefix %s", f.Node.Path, s.DestDir)) + } + relabelPath := f.Node.Path[len(s.DestDir):] + if err := s.Logger.LogOp( + func() error { return s.PerformFetch(f) }, + "writing systemd drop-in %q at %q", dropin.Name, f.Node.Path, + ); err != nil { + return err + } + if !relabeledDropinDir { + s.relabel(filepath.Dir(relabelPath)) + relabeledDropinDir = true + } } } @@ -222,24 +223,28 @@ func (s *stage) writeSystemdUnit(unit types.Unit) error { return nil } - f, err := s.FileFromSystemdUnit(unit) + fetchops, err := s.FilesFromSystemdUnit(unit) if err != nil { s.Logger.Crit("error converting unit: %v", err) return err } - // trim off prefix since this needs to be relative to the sysroot - if !strings.HasPrefix(f.Node.Path, s.DestDir) { - panic(fmt.Sprintf("Unit path %s isn't under prefix %s", f.Node.Path, s.DestDir)) - } - relabelPath := f.Node.Path[len(s.DestDir):] - if err := s.Logger.LogOp( - func() error { return s.PerformFetch(f) }, - "writing unit %q at %q", unit.Name, f.Node.Path, - ); err != nil { - return err + + for _, f := range fetchops { + // trim off prefix since this needs to be relative to the sysroot + if !strings.HasPrefix(f.Node.Path, s.DestDir) { + panic(fmt.Sprintf("Unit path %s isn't under prefix %s", f.Node.Path, s.DestDir)) + } + relabelPath := f.Node.Path[len(s.DestDir):] + if err := s.Logger.LogOp( + func() error { return s.PerformFetch(f) }, + "writing unit %q at %q", unit.Name, f.Node.Path, + ); err != nil { + return err + } + + s.relabel(relabelPath) } - s.relabel(relabelPath) return nil - }, "processing unit %q", unit.Name) + }, "processing unit %q for scope %q", unit.Name, string(util.GetUnitScope(unit))) } diff --git a/internal/exec/stages/files/units_test.go b/internal/exec/stages/files/units_test.go index b7020f7efc..9fa4ae396b 100644 --- a/internal/exec/stages/files/units_test.go +++ b/internal/exec/stages/files/units_test.go @@ -15,11 +15,20 @@ package files import ( + "fmt" + "io/ioutil" + "os" + "os/user" + "path/filepath" "reflect" "testing" + "github.com/coreos/ignition/v2/config" "github.com/coreos/ignition/v2/config/shared/errors" + cfgutil "github.com/coreos/ignition/v2/config/util" "github.com/coreos/ignition/v2/config/v3_4_experimental/types" + "github.com/coreos/ignition/v2/internal/exec/util" + "github.com/coreos/ignition/v2/internal/log" ) func TestParseInstanceUnit(t *testing.T) { @@ -74,3 +83,260 @@ func TestParseInstanceUnit(t *testing.T) { } } } + +func TestSystemdUnitPath(t *testing.T) { + + var logg log.Logger = log.New(true) + var st stage + + st.DestDir = "/" + st.Logger = &logg + + tests := []struct { + in types.Unit + out []string + }{ + { + types.Unit{Name: "test.service", Scope: cfgutil.StrToPtr("system")}, + []string{"etc/systemd/system"}, + }, + { + types.Unit{Name: "test.service"}, + []string{"etc/systemd/system"}, + }, + { + types.Unit{Name: "test.service", Scope: cfgutil.StrToPtr("global")}, + []string{"etc/systemd/user"}, + }, + } + + for i, test := range tests { + paths, err := st.SystemdUnitPaths(test.in) + if err != nil { + t.Errorf("Failed to get paths") + t.FailNow() + } + if paths[len(paths)-1] != test.out[len(test.out)-1] { + t.Errorf("#%d: bad error: want %v, got %v", i, test.out, paths) + } + } +} + +func TestSystemdDropinsPaths(t *testing.T) { + + var logg log.Logger = log.New(true) + var st stage + + st.DestDir = "/" + st.Logger = &logg + + tests := []struct { + in types.Unit + out []string + }{ + { + types.Unit{Name: "test.service", Scope: cfgutil.StrToPtr("system")}, + []string{"etc/systemd/system/test.service.d"}, + }, + { + types.Unit{Name: "test.service"}, + []string{"etc/systemd/system/test.service.d"}, + }, + { + types.Unit{Name: "test.service", Scope: cfgutil.StrToPtr("global")}, + []string{"etc/systemd/user/test.service.d"}, + }, + } + + for i, test := range tests { + paths, err := st.SystemdDropinsPaths(test.in) + if err != nil { + t.Errorf("failed to get paths") + t.FailNow() + } + if paths[len(paths)-1] != test.out[len(test.out)-1] { + t.Errorf("#%d: bad error: want %v, got %v", i, test.out, paths) + } + } +} + +func TestSystemdPresetPath(t *testing.T) { + + var logg log.Logger = log.New(true) + var st stage + + st.DestDir = "/" + st.Logger = &logg + + tests := []struct { + in types.Unit + out string + }{ + { + types.Unit{Name: "test.service", Scope: cfgutil.StrToPtr("system")}, + "etc/systemd/system-preset/20-ignition.preset", + }, + { + types.Unit{Name: "test.service"}, + "etc/systemd/system-preset/20-ignition.preset", + }, + { + types.Unit{Name: "test.service", Scope: cfgutil.StrToPtr("user")}, + "etc/systemd/user-preset/21-ignition.preset", + }, + { + types.Unit{Name: "test.service", Scope: cfgutil.StrToPtr("global")}, + "etc/systemd/user-preset/20-ignition.preset", + }, + } + + for i, test := range tests { + path := st.SystemdPresetPath(util.GetUnitScope(test.in)) + if path != test.out { + t.Errorf("#%d: bad error: want %v, got %v", i, test.out, path) + } + } +} + +func TestCreateUnits(t *testing.T) { + + if os.Geteuid() != 0 { + t.Skip("test requires root for chroot(), skipping") + } + + _, err := user.Lookup("root") + if err != nil { + t.Fatalf("user lookup failed (libnss_files.so might not be loaded): %v", err) + } + + tmpdir, err := tempBase() + if err != nil { + t.Fatalf("temp base error: %v", err) + } + + var logg log.Logger = log.New(true) + var st stage + + err = st.checkRelabeling() + + if err != nil { + t.Fatalf("checkRelabeling error: %v", err) + } + + st.DestDir = tmpdir + st.Logger = &logg + + defer os.RemoveAll(tmpdir) + defer st.Logger.Close() + + var conf string = `{ + "ignition": { + "version": "3.4.0-experimental" + }, + "systemd": { + "units": [ + { + "contents": "[Unit]\nDescription=Prometheus node exporter\n[Install]\nWantedBy=multi-user.target\n", + "enabled": true, + "name": "unit1.service", + "dropins": [{ + "name": "debug.conf", + "contents": "[Service]\nEnvironment=SYSTEMD_LOG_LEVEL=debug" + }] + }, + { + "contents": "[Unit]\nDescription=promtail.service\n[Install]\nWantedBy=multi-user.target default.target", + "enabled": true, + "name": "unit1.service", + "scope": "user", + "users" : ["tester1", "tester2"], + "dropins": [{ + "name": "debug.conf", + "contents": "[Service]\nEnvironment=SYSTEMD_LOG_LEVEL=debug" + }] + }, + { + "contents": "[Unit]\nDescription=promtail.service\n[Install]\nWantedBy=multi-user.target default.target", + "enabled": true, + "name": "unit2.service", + "scope": "system" + }, + { + "contents": "[Unit]\nDescription=promtail.service\n[Install]\nWantedBy=multi-user.target default.target", + "enabled": true, + "name": "unit2.service", + "scope": "global" + }, + { + "contents": "[Unit]\nDescription=promtail.service\n[Install]\nWantedBy=multi-user.target default.target", + "enabled": true, + "name": "unit3.service", + "scope": "global", + "mask": true + }, + { + "contents": "[Unit]\nDescription=promtail.service\n[Install]\nWantedBy=multi-user.target default.target", + "enabled": false, + "name": "unit4.service", + "scope": "user", + "users" : ["tester1", "tester2"], + "mask" : true, + "dropins": [{ + "name": "debug.conf", + "contents": "[Service]\nEnvironment=SYSTEMD_LOG_LEVEL=debug" + }] + }, + { + "contents": "[Unit]\nDescription=promtail.service\n[Install]\nWantedBy=multi-user.target default.target", + "enabled": true, + "name": "unit5.service", + "scope": "global", + "users" : ["tester1", "tester2"] + } + ] + } + }` + + config, report, err := config.Parse([]byte(conf)) + + if err != nil { + fmt.Printf("error %v : \n%+v", err.Error(), report) + t.FailNow() + } + fmt.Printf("validation report : \n%v", report) + err = st.createUnits(config) + if err != nil { + t.Errorf("error occured: %v", err) + } +} + +func tempBase() (string, error) { + td, err := ioutil.TempDir("", "igntests") + if err != nil { + return "", err + } + + if err := os.MkdirAll(filepath.Join(td, "etc"), 0755); err != nil { + return "", err + } + + gp := filepath.Join(td, "etc/group") + err = ioutil.WriteFile(gp, []byte("foo:x:4242:\n"), 0644) + if err != nil { + return "", err + } + + pp := filepath.Join(td, "etc/passwd") + err = ioutil.WriteFile(pp, []byte("tester1:x:44:4242::/home/tester1:/bin/false\ntester2:x:45:4242::/home/tester2:/bin/false"), 0644) + if err != nil { + return "", err + } + + nsp := filepath.Join(td, "etc/nsswitch.conf") + err = ioutil.WriteFile(nsp, []byte("passwd: files\ngroup: files\nshadow: files\ngshadow: files\n"), 0644) + if err != nil { + return "", err + } + + return td, nil +} diff --git a/internal/exec/util/passwd.go b/internal/exec/util/passwd.go index 267ed86147..9317215682 100644 --- a/internal/exec/util/passwd.go +++ b/internal/exec/util/passwd.go @@ -135,7 +135,17 @@ func (u Util) EnsureUser(c types.PasswdUser) error { // GetUserHomeDir returns the user home directory. Note that DestDir is not // prefixed. func (u Util) GetUserHomeDir(c types.PasswdUser) (string, error) { - usr, err := u.userLookup(c.Name) + homedir, err := u.GetUserHomeDirByName(c.Name) + if err != nil { + return "", err + } + return homedir, nil +} + +// GetUserHomeDirByName returns the user home directory. Note that DestDir is not +// prefixed. +func (u Util) GetUserHomeDirByName(name string) (string, error) { + usr, err := u.userLookup(name) if err != nil { return "", err } diff --git a/internal/exec/util/path.go b/internal/exec/util/path.go index 73f96f84d4..60b35bdf93 100644 --- a/internal/exec/util/path.go +++ b/internal/exec/util/path.go @@ -16,12 +16,60 @@ package util import ( "path/filepath" + + "github.com/coreos/ignition/v2/config/v3_4_experimental/types" ) -func SystemdUnitsPath() string { - return filepath.Join("etc", "systemd", "system") +const SystemPresetPath = "etc/systemd/system-preset/20-ignition.preset" +const GlobalPresetPath = "etc/systemd/user-preset/20-ignition.preset" +const UserPresetPath = "etc/systemd/user-preset/21-ignition.preset" + +const SystemUnitPath = "etc/systemd/system" +const GlobalUnitPath = "etc/systemd/user" +const UserUnitPath = ".config/systemd/user" + +func (u Util) SystemdUnitPaths(unit types.Unit) ([]string, error) { + var paths []string + switch GetUnitScope(unit) { + case UserUnit: + for _, user := range unit.Users { + home, err := u.GetUserHomeDirByName(string(user)) + if err != nil { + return nil, err + } + paths = append(paths, filepath.Join(home, UserUnitPath)) + } + case SystemUnit: + paths = append(paths, SystemUnitPath) + case GlobalUnit: + paths = append(paths, GlobalUnitPath) + default: + paths = append(paths, SystemUnitPath) + } + return paths, nil +} + +func (u Util) SystemdPresetPath(scope UnitScope) string { + switch scope { + case UserUnit: + return UserPresetPath + case SystemUnit: + return SystemPresetPath + case GlobalUnit: + return GlobalPresetPath + default: + return SystemPresetPath + } } -func SystemdDropinsPath(unitName string) string { - return filepath.Join("etc", "systemd", "system", unitName+".d") +func (u Util) SystemdDropinsPaths(unit types.Unit) ([]string, error) { + var paths []string + unitpaths, err := u.SystemdUnitPaths(unit) + if err != nil { + return nil, err + } + for _, path := range unitpaths { + paths = append(paths, filepath.Join(path, unit.Name+".d")) + } + return paths, err } diff --git a/internal/exec/util/unit.go b/internal/exec/util/unit.go index 01321cfa86..3d147fc786 100644 --- a/internal/exec/util/unit.go +++ b/internal/exec/util/unit.go @@ -18,144 +18,190 @@ import ( "fmt" "net/url" "os" - "path/filepath" "syscall" + "github.com/coreos/ignition/v2/config/util" "github.com/coreos/ignition/v2/config/v3_4_experimental/types" "github.com/vincent-petithory/dataurl" ) const ( - PresetPath string = "/etc/systemd/system-preset/20-ignition.preset" DefaultPresetPermissions os.FileMode = 0644 ) -func (ut Util) FileFromSystemdUnit(unit types.Unit) (FetchOp, error) { +type UnitScope string + +const ( + SystemUnit UnitScope = "system" + UserUnit UnitScope = "user" + GlobalUnit UnitScope = "global" +) + +func GetUnitScope(unit types.Unit) UnitScope { + if util.NilOrEmpty(unit.Scope) { + return SystemUnit + } + + switch *unit.Scope { + case "user", "system", "global": + return UnitScope(*unit.Scope) + default: + panic("Error: Invalid scope defined") + } +} + +func (ut Util) FilesFromSystemdUnit(unit types.Unit) ([]FetchOp, error) { + var fetchops []FetchOp + if unit.Contents == nil { empty := "" unit.Contents = &empty } u, err := url.Parse(dataurl.EncodeBytes([]byte(*unit.Contents))) if err != nil { - return FetchOp{}, err + return []FetchOp{}, err } - path, err := ut.JoinPath(SystemdUnitsPath(), unit.Name) + UnitPaths, err := ut.SystemdUnitPaths(unit) if err != nil { - return FetchOp{}, err + return []FetchOp{}, err + } + for _, path := range UnitPaths { + fpath, err := ut.JoinPath(path, unit.Name) + if err != nil { + return []FetchOp{}, err + } + + fetchops = append(fetchops, FetchOp{Node: types.Node{Path: fpath}, Url: *u}) } - return FetchOp{ - Node: types.Node{ - Path: path, - }, - Url: *u, - }, nil + return fetchops, nil } -func (ut Util) FileFromSystemdUnitDropin(unit types.Unit, dropin types.Dropin) (FetchOp, error) { +func (ut Util) FilesFromSystemdUnitDropin(unit types.Unit, dropin types.Dropin) ([]FetchOp, error) { + var fetchops []FetchOp + if dropin.Contents == nil { empty := "" dropin.Contents = &empty } + u, err := url.Parse(dataurl.EncodeBytes([]byte(*dropin.Contents))) if err != nil { - return FetchOp{}, err + return []FetchOp{}, err } - path, err := ut.JoinPath(SystemdDropinsPath(string(unit.Name)), dropin.Name) + DropinsPaths, err := ut.SystemdDropinsPaths(unit) if err != nil { - return FetchOp{}, err + return []FetchOp{}, err + } + for _, path := range DropinsPaths { + fpath, err := ut.JoinPath(path, dropin.Name) + if err != nil { + return []FetchOp{}, err + } + fetchops = append(fetchops, FetchOp{Node: types.Node{Path: fpath}, Url: *u}) } - return FetchOp{ - Node: types.Node{ - Path: path, - }, - Url: *u, - }, nil + return fetchops, nil } -// MaskUnit writes a symlink to /dev/null to mask the specified unit and returns the path of that unit -// without the sysroot prefix -func (ut Util) MaskUnit(unit types.Unit) (string, error) { - path, err := ut.JoinPath(SystemdUnitsPath(), unit.Name) +// MaskUnit writes a symlink to /dev/null to mask the specified unit +func (ut Util) MaskUnit(unit types.Unit) error { + UnitPaths, err := ut.SystemdUnitPaths(unit) if err != nil { - return "", err + return err } + for _, path := range UnitPaths { + unitpath, err := ut.JoinPath(path, unit.Name) + if err != nil { + return err + } - if err := MkdirForFile(path); err != nil { - return "", err - } - if err := os.RemoveAll(path); err != nil { - return "", err - } - if err := os.Symlink("/dev/null", path); err != nil { - return "", err + if err := MkdirForFile(unitpath); err != nil { + return err + } + if err := os.RemoveAll(unitpath); err != nil { + return err + } + if err := os.Symlink("/dev/null", unitpath); err != nil { + return err + } } - // not the same as the path above, since this lacks the sysroot prefix - return filepath.Join("/", SystemdUnitsPath(), unit.Name), nil + return nil } // UnmaskUnit deletes the symlink to /dev/null for a masked unit func (ut Util) UnmaskUnit(unit types.Unit) error { - path, err := ut.JoinPath(SystemdUnitsPath(), unit.Name) + UnitPaths, err := ut.SystemdUnitPaths(unit) if err != nil { return err } - // Make a final check to make sure the unit is masked - masked, err := ut.IsUnitMasked(unit) - if err != nil { - return err - } - // If masked, remove the symlink - if masked { - if err = os.Remove(path); err != nil { + + for _, path := range UnitPaths { + unitpath, err := ut.JoinPath(path, unit.Name) + if err != nil { + return err + } + // Make a final check to make sure the unit is masked + masked, err := ut.IsUnitMasked(unit) + if err != nil { return err } + // If masked, remove the symlink + if masked { + if err = os.Remove(unitpath); err != nil { + return err + } + } } return nil } // IsUnitMasked returns true/false if a systemd unit is masked func (ut Util) IsUnitMasked(unit types.Unit) (bool, error) { - path, err := ut.JoinPath(SystemdUnitsPath(), unit.Name) + UnitPaths, err := ut.SystemdUnitPaths(unit) if err != nil { return false, err } - - target, err := os.Readlink(path) - if err != nil { - if os.IsNotExist(err) { - // The path doesn't exist, hence the unit isn't masked - return false, nil - } else if e, ok := err.(*os.PathError); ok && e.Err == syscall.EINVAL { - // The path isn't a symlink, hence the unit isn't masked - return false, nil - } else { + for _, path := range UnitPaths { + unitpath, err := ut.JoinPath(path, unit.Name) + if err != nil { return false, err } - } - if target != "/dev/null" { - // The symlink doesn't point to /dev/null, hence the unit isn't masked - return false, nil - } + target, err := os.Readlink(unitpath) + if err != nil { + if os.IsNotExist(err) { + // The path doesn't exist, hence the unit isn't masked + return false, nil + } else if e, ok := err.(*os.PathError); ok && e.Err == syscall.EINVAL { + // The path isn't a symlink, hence the unit isn't masked + return false, nil + } else { + return false, err + } + } + if target != "/dev/null" { + // The symlink doesn't point to /dev/null, hence the unit isn't masked + return false, nil + } + } return true, nil } -func (ut Util) EnableUnit(enabledUnit string) error { - return ut.appendLineToPreset(fmt.Sprintf("enable %s", enabledUnit)) +func (ut Util) EnableUnit(enabledUnit string, scope UnitScope) error { + return ut.appendLineToPreset(fmt.Sprintf("enable %s", enabledUnit), ut.SystemdPresetPath(scope)) } -func (ut Util) DisableUnit(disabledUnit string) error { - return ut.appendLineToPreset(fmt.Sprintf("disable %s", disabledUnit)) +func (ut Util) DisableUnit(disabledUnit string, scope UnitScope) error { + return ut.appendLineToPreset(fmt.Sprintf("disable %s", disabledUnit), ut.SystemdPresetPath(scope)) } -func (ut Util) appendLineToPreset(data string) error { - path, err := ut.JoinPath(PresetPath) +func (ut Util) appendLineToPreset(data string, presetpath string) error { + path, err := ut.JoinPath(presetpath) if err != nil { return err } diff --git a/tests/negative/systemd/units.go b/tests/negative/systemd/units.go new file mode 100644 index 0000000000..9fdc11f9cc --- /dev/null +++ b/tests/negative/systemd/units.go @@ -0,0 +1,127 @@ +// Copyright 2018 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 systemd + +import ( + "github.com/coreos/ignition/v2/tests/register" + "github.com/coreos/ignition/v2/tests/types" +) + +func init() { + register.Register(register.NegativeTest, CreateSystemdUserServiceNouser()) +} + +func CreateDefaultDisk(Disk []types.Disk) []types.Disk { + Disk[0].Partitions.AddFiles("ROOT", []types.File{ + { + Node: types.Node{ + Name: "passwd", + Directory: "etc", + }, + Contents: "root:x:0:0:root:/root:/bin/bash\ncore:x:500:500:CoreOS Admin:/home/core:/bin/bash\nsystemd-coredump:x:998:998:systemd Core Dumper:/:/sbin/nologin\nfleet:x:253:253::/:/sbin/nologin\n", + }, + { + Node: types.Node{ + Name: "shadow", + Directory: "etc", + }, + Contents: "root:*:15887:0:::::\ncore:*:15887:0:::::\nsystemd-coredump:!!:17301::::::\nfleet:!!:17301::::::\n", + }, + { + Node: types.Node{ + Name: "group", + Directory: "etc", + }, + Contents: "root:x:0:root\nwheel:x:10:root,core\nsudo:x:150:\ndocker:x:233:core\nsystemd-coredump:x:998:\nfleet:x:253:core\ncore:x:500:\nrkt-admin:x:999:\nrkt:x:251:core\n", + }, + { + Node: types.Node{ + Name: "gshadow", + Directory: "etc", + }, + Contents: "root:*::root\nusers:*::\nsudo:*::\nwheel:*::root,core\nsudo:*::\ndocker:*::core\nsystemd-coredump:!!::\nfleet:!!::core\nrkt-admin:!!::\nrkt:!!::core\ncore:*::\n", + }, + { + Node: types.Node{ + Name: "nsswitch.conf", + Directory: "etc", + }, + Contents: "# /etc/nsswitch.conf:\n\npasswd: files\nshadow: files\ngroup: files\n\nhosts: files dns myhostname\nnetworks: files dns\n\nservices: files\nprotocols: files\nrpc: files\n\nethers: files\nnetmasks: files\nnetgroup: files\nbootparams: files\nautomount: files\naliases: files\n", + }, + { + Node: types.Node{ + Name: "login.defs", + Directory: "etc", + }, + Contents: `MAIL_DIR /var/spool/mail + PASS_MAX_DAYS 99999 + PASS_MIN_DAYS 0 + PASS_MIN_LEN 5 + PASS_WARN_AGE 7 + UID_MIN 1000 + UID_MAX 60000 + SYS_UID_MIN 201 + SYS_UID_MAX 999 + GID_MIN 1000 + GID_MAX 60000 + SYS_GID_MIN 201 + SYS_GID_MAX 999 + CREATE_HOME yes + UMASK 077 + USERGROUPS_ENAB yes + ENCRYPT_METHOD SHA512 + `, + }, + }) + return Disk +} + +func CreateSystemdUserServiceNouser() types.Test { + name := "systemd.unit.userunit.wronguser" + in := CreateDefaultDisk(types.GetBaseDisk()) + out := in + config := `{ + "passwd": { + "users": [{ + "name": "tester1" + } + ] + }, + "ignition": { + "version": "$version" + }, + "systemd": { + "units": [ + { + "contents": "[Unit]\nDescription=promtail.service\n[Install]\nWantedBy=multi-user.target default.target", + "enabled": true, + "name": "unit1.service", + "scope": "user", + "users" : ["tester2"] + } + ] + } + }` + + configMinVersion := "3.4.0-experimental" + + return types.Test{ + Name: name, + In: in, + Out: out, + Config: config, + ConfigMinVersion: configMinVersion, + } +} diff --git a/tests/positive/systemd/create_unit.go b/tests/positive/systemd/create_unit.go index 1964a60278..2d12656054 100644 --- a/tests/positive/systemd/create_unit.go +++ b/tests/positive/systemd/create_unit.go @@ -21,6 +21,7 @@ import ( func init() { register.Register(register.PositiveTest, CreateSystemdService()) + register.Register(register.PositiveTest, CreateSystemdUserService()) } func CreateSystemdService() types.Test { @@ -63,3 +64,92 @@ func CreateSystemdService() types.Test { ConfigMinVersion: configMinVersion, } } + +func CreateSystemdUserService() types.Test { + name := "systemd.unit.userunit.create" + in := types.GetBaseDisk() + out := types.GetBaseDisk() + config := `{ + "ignition": { "version": "$version" }, + "systemd": { + "units": [{ + "name": "example.service", + "enabled": true, + "scope": "user", + "users": ["tester1", "tester2"], + "contents": "[Service]\nType=oneshot\nExecStart=/usr/bin/echo Hello World\n\n[Install]\nWantedBy=multi-user.target" + }, + { + "contents": "[Service]\nType=oneshot\nExecStart=/usr/bin/echo Hello World\n\n[Install]\nWantedBy=multi-user.target", + "enabled": true, + "name": "example.service", + "scope": "global" + }] + } + }` + configMinVersion := "3.4.0-experimental" + + in[0].Partitions.AddFiles("ROOT", []types.File{ + { + Node: types.Node{ + Name: "passwd", + Directory: "etc", + }, + Contents: "tester1:x:44:4242::/var/users/tester1:/bin/false\ntester2:x:45:4242::/home/tester2:/bin/false", + }, + { + Node: types.Node{ + Name: "nsswitch.conf", + Directory: "etc", + }, + Contents: "passwd: files\ngroup: files\nshadow: files\ngshadow: files\n", + }, + }) + + out[0].Partitions.AddFiles("ROOT", []types.File{ + { + Node: types.Node{ + Name: "example.service", + Directory: "var/users/tester1/.config/systemd/user", + }, + Contents: "[Service]\nType=oneshot\nExecStart=/usr/bin/echo Hello World\n\n[Install]\nWantedBy=multi-user.target", + }, + { + Node: types.Node{ + Name: "example.service", + Directory: "home/tester2/.config/systemd/user", + }, + Contents: "[Service]\nType=oneshot\nExecStart=/usr/bin/echo Hello World\n\n[Install]\nWantedBy=multi-user.target", + }, + { + Node: types.Node{ + Name: "21-ignition.preset", + Directory: "etc/systemd/user-preset", + }, + Contents: "enable example.service\n", + }, + + { + Node: types.Node{ + Name: "example.service", + Directory: "etc/systemd/user", + }, + Contents: "[Service]\nType=oneshot\nExecStart=/usr/bin/echo Hello World\n\n[Install]\nWantedBy=multi-user.target", + }, + { + Node: types.Node{ + Name: "20-ignition.preset", + Directory: "etc/systemd/user-preset", + }, + Contents: "enable example.service\n", + }, + }) + + return types.Test{ + Name: name, + In: in, + Out: out, + Config: config, + ConfigMinVersion: configMinVersion, + } +} diff --git a/tests/registry/registry.go b/tests/registry/registry.go index 917106b1ef..2cc9e26201 100644 --- a/tests/registry/registry.go +++ b/tests/registry/registry.go @@ -23,6 +23,7 @@ import ( _ "github.com/coreos/ignition/v2/tests/negative/proxy" _ "github.com/coreos/ignition/v2/tests/negative/regression" _ "github.com/coreos/ignition/v2/tests/negative/security" + _ "github.com/coreos/ignition/v2/tests/negative/systemd" _ "github.com/coreos/ignition/v2/tests/negative/timeouts" _ "github.com/coreos/ignition/v2/tests/positive/files" _ "github.com/coreos/ignition/v2/tests/positive/filesystems"