diff --git a/engine/resources/gsettings.go b/engine/resources/gsettings.go new file mode 100644 index 00000000..cb4f54d9 --- /dev/null +++ b/engine/resources/gsettings.go @@ -0,0 +1,391 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this program, or any covered work, by linking or combining it +// with embedded mcl code and modules (and that the embedded mcl code and +// modules which link with this program, contain a copy of their source code in +// the authoritative form) containing parts covered by the terms of any other +// license, the licensors of this program grant you additional permission to +// convey the resulting work. Furthermore, the licensors of this program grant +// the original author, James Shubin, additional permission to update this +// additional permission if he deems it necessary to achieve the goals of this +// additional permission. + +package resources + +import ( + "context" + "fmt" + "os/exec" + "os/user" + "reflect" + "strconv" + "strings" + + "github.com/purpleidea/mgmt/engine" + "github.com/purpleidea/mgmt/engine/traits" + engineUtil "github.com/purpleidea/mgmt/engine/util" + "github.com/purpleidea/mgmt/util/errwrap" +) + +func init() { + engine.RegisterResource("gsettings", func() engine.Res { return &GsettingsRes{} }) +} + +const ( + gsettingsTmpl = "gsettings@%s" +) + +// GsettingsRes is a resource for setting dconf values through gsettings. The +// ideal scenario is that this runs as the same user that wants settings set. +// This should be done by a local user-specific mgmt daemon. As a special case, +// we can run as root (or anyone with permission) which launches a subprocess +// which setuid/setgid's to that user to run the needed operations. To specify +// the schema and key, set the resource name as "schema key" (separated by a +// single space character) or use the parameters. +type GsettingsRes struct { + // XXX: add a dbus version of this-- it will require running as the user + // directly since in that scenario we can't spawn a process of the right + // uid/gid, and if we set either of those we would interfere with all of + // the normal mgmt stuff running inside this process. + + traits.Base // add the base methods without re-implementation + + init *engine.Init + + // Schema is the schema to use in. This can be schema:path if the schema + // doesn't have a fixed path. See the `gsettings` manual for more info. + Schema string `lang:"schema" yaml:"schema"` + + // Key is the key to set. + Key string `lang:"key" yaml:"key"` + + // Type is the type value to set. This can be "bool", "str", "int", or + // "custom". + // XXX: add support for [][]str and so on... + Type string `lang:"type" yaml:"type"` + + // Value is the value to set. It is interface{} because it can hold any + // value type. + // XXX: Add resource unification to this key + Value interface{} `lang:"value" yaml:"value"` + + // User is the (optional) user to use to execute the command. It is used + // for any command being run. + User string `lang:"user" yaml:"user"` + + // Group is the (optional) group to use to execute the command. It is + // used for any command being run. + Group string `lang:"group" yaml:"group"` + + // XXX: We should have a "once" functionality if this param is set true. + // XXX: Basically it would change that field once, and store a "tag" + // file to say it was done. + // XXX: Maybe that should be a metaparam called Once that works anywhere. + // XXX: Maybe there should be a way to reset the "once" tag too... + //Once string `lang:"once" yaml:"once"` + + // We're using the exec resource to build the resources because it's all + // done through exec. + exec *ExecRes +} + +// Default returns some sensible defaults for this resource. +func (obj *GsettingsRes) Default() engine.Res { + return &GsettingsRes{} +} + +// parse is a helper to pull out the correct schema and key to use. +func (obj *GsettingsRes) parse() (string, string, error) { + schema := obj.Schema + key := obj.Key + + sp := strings.Split(obj.Name(), " ") + if len(sp) == 2 && obj.Schema == "" && obj.Key == "" { + schema = sp[0] + key = sp[1] + } + + if schema == "" { + return "", "", fmt.Errorf("empty schema") + } + if key == "" { + return "", "", fmt.Errorf("empty key") + } + + return schema, key, nil +} + +// value is a helper to pull out the value in the correct format to use. +func (obj *GsettingsRes) value() (string, error) { + if obj.Type == "bool" { + v, ok := obj.Value.(bool) + if !ok { + return "", fmt.Errorf("invalid bool") + } + if v { + return "true", nil + } + return "false", nil + } + + if obj.Type == "str" { + v, ok := obj.Value.(string) + if !ok { + return "", fmt.Errorf("invalid str") + } + return v, nil + } + + if obj.Type == "int" { + v, ok := obj.Value.(int) + if !ok { + return "", fmt.Errorf("invalid int") + } + return strconv.Itoa(v), nil + } + + if obj.Type == "custom" { + v, ok := obj.Value.(string) + if !ok { + return "", fmt.Errorf("invalid custom") + } + return v, nil + } + + // XXX: add proper type parsing + + return "", fmt.Errorf("invalid type: %s", obj.Type) +} + +// uid is a helper to get the correct uid. +func (obj *GsettingsRes) uid() (int, error) { + uid := obj.User // something or empty + if obj.User == "" { + u, err := user.Current() + if err != nil { + return -1, err + } + + uid = u.Uid + } + + out, err := engineUtil.GetUID(uid) + if err != nil { + return -1, errwrap.Wrapf(err, "error looking up uid for %s", uid) + } + return out, nil +} + +// makeComposite creates a pointer to a ExecRes. The pointer is used to validate +// and initialize the nested exec. +func (obj *GsettingsRes) makeComposite() (*ExecRes, error) { + cmd, err := exec.LookPath("gsettings") + if err != nil { + return nil, err + } + + schema, key, err := obj.parse() + if err != nil { + return nil, err + } + val, err := obj.value() + if err != nil { + return nil, err + } + uid, err := obj.uid() + if err != nil { + return nil, err + } + + res, err := engine.NewNamedResource("exec", fmt.Sprintf(gsettingsTmpl, obj.Name())) + if err != nil { + return nil, err + } + exec := res.(*ExecRes) + + exec.Cmd = cmd + exec.Args = []string{ + "set", + schema, + key, + val, + } + exec.Cwd = "/" + + exec.IfCmd = fmt.Sprintf("%s get %s %s", cmd, schema, key) + exec.IfCwd = "/" + expected := val + "\n" // value comes with a trailing newline + exec.IfEquals = &expected + + exec.WatchCmd = fmt.Sprintf("%s monitor %s %s", cmd, schema, key) + exec.WatchCwd = "/" + + exec.User = obj.User + exec.Group = obj.Group + + exec.Env = map[string]string{ + // Either of these will work, so we'll include both for fun. + "DBUS_SESSION_BUS_ADDRESS": fmt.Sprintf("unix:path=/run/user/%d/bus", uid), + "XDG_RUNTIME_DIR": fmt.Sprintf("/run/user/%d/", uid), + } + //exec.Timeout = ? // TODO: should we have a timeout to prevent blocking? + + return exec, nil +} + +// Validate reports any problems with the struct definition. +func (obj *GsettingsRes) Validate() error { + if _, _, err := obj.parse(); err != nil { + return err + } + // validation of obj.Type happens in this function. + if _, err := obj.value(); err != nil { + return err + } + + exec, err := obj.makeComposite() + if err != nil { + return errwrap.Wrapf(err, "makeComposite failed in validate") + } + if err := exec.Validate(); err != nil { // composite resource + return errwrap.Wrapf(err, "validate failed for embedded exec: %s", exec) + } + return nil +} + +// Init runs some startup code for this resource. +func (obj *GsettingsRes) Init(init *engine.Init) error { + obj.init = init // save for later + + exec, err := obj.makeComposite() + if err != nil { + return errwrap.Wrapf(err, "makeComposite failed in init") + } + obj.exec = exec + + newInit := obj.init.Copy() + newInit.Send = func(interface{}) error { // override so exec can't send + return nil + } + newInit.Logf = func(format string, v ...interface{}) { + //if format == "cmd out empty!" { + // return + //} + //obj.init.Logf("exec: "+format, v...) + } + + return obj.exec.Init(newInit) +} + +// Cleanup is run by the engine to clean up after the resource is done. +func (obj *GsettingsRes) Cleanup() error { + if obj.exec != nil { + return obj.exec.Cleanup() + } + return nil +} + +// Watch is the primary listener for this resource and it outputs events. +func (obj *GsettingsRes) Watch(ctx context.Context) error { + return obj.exec.Watch(ctx) +} + +// CheckApply checks the resource state and applies the resource if the bool +// input is true. It returns error info and if the state check passed or not. +func (obj *GsettingsRes) CheckApply(ctx context.Context, apply bool) (bool, error) { + obj.init.Logf("%s", obj.exec.IfCmd) // "gsettings get" + + checkOK, err := obj.exec.CheckApply(ctx, apply) + if err != nil { + return checkOK, err + } + + if !checkOK { + // "gsettings set" + obj.init.Logf("%s %s", obj.exec.Cmd, strings.Join(obj.exec.Args, " ")) + } + + return checkOK, nil +} + +// Cmp compares two resources and returns an error if they are not equivalent. +func (obj *GsettingsRes) Cmp(r engine.Res) error { + // we can only compare GsettingsRes to others of the same resource kind + res, ok := r.(*GsettingsRes) + if !ok { + return fmt.Errorf("not a %s", obj.Kind()) + } + + if obj.Schema != res.Schema { + return fmt.Errorf("the Schema differs") + } + if obj.Key != res.Key { + return fmt.Errorf("the Key differs") + } + if obj.Type != res.Type { + return fmt.Errorf("the Type differs") + } + + //if obj.Value != res.Value { + // return fmt.Errorf("the Value differs") + //} + if !reflect.DeepEqual(obj.Value, res.Value) { + return fmt.Errorf("the Value field differs") + } + + if obj.User != res.User { + return fmt.Errorf("the User differs") + } + if obj.Group != res.Group { + return fmt.Errorf("the Group differs") + } + + // TODO: why is res.exec ever nil? + if (obj.exec == nil) != (res.exec == nil) { // xor + return fmt.Errorf("the exec differs") + } + if obj.exec != nil && res.exec != nil { + if err := obj.exec.Cmp(res.exec); err != nil { + return errwrap.Wrapf(err, "the exec differs") + } + } + + return nil +} + +// UnmarshalYAML is the custom unmarshal handler for this struct. It is +// primarily useful for setting the defaults. +func (obj *GsettingsRes) UnmarshalYAML(unmarshal func(interface{}) error) error { + type rawRes GsettingsRes // indirection to avoid infinite recursion + + def := obj.Default() // get the default + res, ok := def.(*GsettingsRes) // put in the right format + if !ok { + return fmt.Errorf("could not convert to GsettingsRes") + } + raw := rawRes(*res) // convert; the defaults go here + + if err := unmarshal(&raw); err != nil { + return err + } + + *obj = GsettingsRes(raw) // restore from indirection with type conversion! + return nil +} diff --git a/examples/lang/gsettings.mcl b/examples/lang/gsettings.mcl new file mode 100644 index 00000000..2a797ff5 --- /dev/null +++ b/examples/lang/gsettings.mcl @@ -0,0 +1,11 @@ +gsettings "org.gnome.desktop.interface clock-show-seconds" { + type => "bool", + value => true, +} + +gsettings "org.gnome.desktop.input-sources sources" { # happens to match schema/key args + schema => "org.gnome.desktop.input-sources", + key => "sources", + type => "custom", + value => "[('xkb', 'ca+eng'), ('xkb', 'ca'), ('xkb', 'us')]", +}