engine: resources: Add a gsettings resource

This adds a way to run the gsettings command for configuring dconf
settings usually used by GNOME applications.
This commit is contained in:
James Shubin
2025-09-29 21:40:06 -04:00
parent 52fbc31da7
commit f588703474
2 changed files with 402 additions and 0 deletions

View File

@@ -0,0 +1,391 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> 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 <https://www.gnu.org/licenses/>.
//
// 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
}