Files
mgmt/engine/resources/cron.go
James Shubin 69e84fbbed engine: resources: cron: Add ctx where possible.
Lots of the API's here now support this. Here's an example, work on the
others too.
2024-11-06 21:09:50 -05:00

579 lines
19 KiB
Go

// Mgmt
// Copyright (C) 2013-2024+ 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 (
"bytes"
"context"
"fmt"
"os/user"
"path"
"strings"
"time"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/traits"
engineUtil "github.com/purpleidea/mgmt/engine/util"
"github.com/purpleidea/mgmt/util"
"github.com/purpleidea/mgmt/util/errwrap"
"github.com/purpleidea/mgmt/util/recwatch"
sdbus "github.com/coreos/go-systemd/v22/dbus"
"github.com/coreos/go-systemd/v22/unit"
systemdUtil "github.com/coreos/go-systemd/v22/util"
"github.com/godbus/dbus/v5"
)
const (
// OnCalendar is a systemd-timer trigger, whose behaviour is defined in
// 'man systemd-timer', and whose format is defined in the 'Calendar
// Events' section of 'man systemd-time'.
OnCalendar = "OnCalendar"
// OnActiveSec is a systemd-timer trigger, whose behaviour is defined in
// 'man systemd-timer', and whose format is a time span as defined in
// 'man systemd-time'.
OnActiveSec = "OnActiveSec"
// OnBootSec is a systemd-timer trigger, whose behaviour is defined in
// 'man systemd-timer', and whose format is a time span as defined in
// 'man systemd-time'.
OnBootSec = "OnBootSec"
// OnStartupSec is a systemd-timer trigger, whose behaviour is defined in
// 'man systemd-timer', and whose format is a time span as defined in
// 'man systemd-time'.
OnStartupSec = "OnStartupSec"
// OnUnitActiveSec is a systemd-timer trigger, whose behaviour is defined
// in 'man systemd-timer', and whose format is a time span as defined in
// 'man systemd-time'.
OnUnitActiveSec = "OnUnitActiveSec"
// OnUnitInactiveSec is a systemd-timer trigger, whose behaviour is defined
// in 'man systemd-timer', and whose format is a time span as defined in
// 'man systemd-time'.
OnUnitInactiveSec = "OnUnitInactiveSec"
// ctxTimeout is the delay, in seconds, before the calls to restart or stop
// the systemd unit will error due to timeout.
ctxTimeout = 30
)
func init() {
engine.RegisterResource("cron", func() engine.Res { return &CronRes{} })
}
// CronRes is a systemd-timer cron resource.
// TODO: If we want to have an actual `crond` resource, name it LegacyCron.
type CronRes struct {
traits.Base
traits.Edgeable
traits.Recvable
traits.Refreshable // needed because we embed a svc res
init *engine.Init
// Unit is the name of the systemd service unit. It is only necessary to
// set if you want to specify a service with a different name than the
// resource.
Unit string `lang:"unit" yaml:"unit"`
// State must be 'exists' or 'absent'.
State string `lang:"state" yaml:"state"`
// Session, if true, creates the timer as the current user, rather than
// root. The service it points to must also be a user unit. It defaults
// to false.
Session bool `lang:"session" yaml:"session"`
// Trigger is the type of timer. Valid types are 'OnCalendar',
// 'OnActiveSec'. 'OnBootSec'. 'OnStartupSec'. 'OnUnitActiveSec', and
// 'OnUnitInactiveSec'. For more information see 'man systemd.timer'.
Trigger string `lang:"trigger" yaml:"trigger"`
// Time must be used with all triggers. For 'OnCalendar', it must be in
// the format defined in 'man systemd-time' under the heading 'Calendar
// Events'. For all other triggers, time should be a valid time span as
// defined in 'man systemd-time'
Time string `lang:"time" yaml:"time"`
// AccuracySec is the accuracy of the timer in systemd-time time span
// format. It defaults to one minute.
AccuracySec string `lang:"accuracysec" yaml:"accuracysec"`
// RandomizedDelaySec delays the timer by a randomly selected, evenly
// distributed amount of time between 0 and the specified time value.
// The value must be a valid systemd-time time span.
RandomizedDelaySec string `lang:"randomizeddelaysec" yaml:"randomizeddelaysec"`
// Persistent, if true, means the time when the service unit was last
// triggered is stored on disk. When the timer is activated, the service
// unit is triggered immediately if it would have been triggered at
// least once during the time when the timer was inactive. It defaults
// to false.
Persistent bool `lang:"persistent" yaml:"persistent"`
// WakeSystem, if true, will cause the system to resume from suspend,
// should it be suspended and if the system supports this. It defaults
// to false.
WakeSystem bool `lang:"wakesystem" yaml:"wakesystem"`
// RemainAfterElapse, if true, means an elapsed timer will stay loaded,
// and its state remains queriable. If false, an elapsed timer unit that
// cannot elapse anymore is unloaded. It defaults to true.
RemainAfterElapse bool `lang:"remainafterelapse" yaml:"remainafterelapse"`
file *FileRes // nested file resource
recWatcher *recwatch.RecWatcher // recwatcher for nested file
}
// Default returns some sensible defaults for this resource.
func (obj *CronRes) Default() engine.Res {
return &CronRes{
State: "exists",
RemainAfterElapse: true,
}
}
// makeComposite creates a pointer to a FileRes. The pointer is used to validate
// and initialize the nested file resource and to apply the file state in
// CheckApply.
func (obj *CronRes) makeComposite() (*FileRes, error) {
p, err := obj.UnitFilePath()
if err != nil {
return nil, errwrap.Wrapf(err, "error generating unit file path")
}
res, err := engine.NewNamedResource("file", p)
if err != nil {
return nil, errwrap.Wrapf(err, "error creating nested file resource")
}
file, ok := res.(*FileRes)
if !ok {
return nil, fmt.Errorf("error casting fileres")
}
file.State = obj.State
if obj.State != "absent" {
s := obj.unitFileContents()
file.Content = &s
}
return file, nil
}
// Validate if the params passed in are valid data.
func (obj *CronRes) Validate() error {
// validate state
if obj.State != "absent" && obj.State != "exists" {
return fmt.Errorf("state must be 'absent' or 'exists'")
}
// validate trigger
if obj.State == "absent" && obj.Trigger == "" {
return nil // if trigger is undefined we can't make a unit file
}
if obj.Trigger == "" || obj.Time == "" {
return fmt.Errorf("trigger and must be set together")
}
if obj.Trigger != OnCalendar &&
obj.Trigger != OnActiveSec &&
obj.Trigger != OnBootSec &&
obj.Trigger != OnStartupSec &&
obj.Trigger != OnUnitActiveSec &&
obj.Trigger != OnUnitInactiveSec {
return fmt.Errorf("invalid trigger")
}
// TODO: Validate time (regex?)
// validate nested file
file, err := obj.makeComposite()
if err != nil {
return errwrap.Wrapf(err, "makeComposite failed in validate")
}
if err := file.Validate(); err != nil { // composite resource
return errwrap.Wrapf(err, "validate failed for embedded file: %s", obj.file)
}
return nil
}
// Init runs some startup code for this resource.
func (obj *CronRes) Init(init *engine.Init) error {
var err error
obj.init = init // save for later
obj.file, err = obj.makeComposite()
if err != nil {
return errwrap.Wrapf(err, "makeComposite failed in init")
}
return obj.file.Init(init)
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *CronRes) Cleanup() error {
if obj.file != nil {
return obj.file.Cleanup()
}
return nil
}
// Watch for state changes and sends a message to the bus if there is a change.
func (obj *CronRes) Watch(ctx context.Context) error {
var bus *dbus.Conn
var err error
// this resource depends on systemd
if !systemdUtil.IsRunningSystemd() {
return fmt.Errorf("systemd is not running")
}
// create a private message bus
if obj.Session {
bus, err = util.SessionBusPrivateUsable()
} else {
bus, err = util.SystemBusPrivateUsable()
}
if err != nil {
return errwrap.Wrapf(err, "failed to connect to bus")
}
defer bus.Close()
// dbus addmatch arguments for the timer unit
args := []string{}
args = append(args, "type='signal'")
args = append(args, "interface='org.freedesktop.systemd1.Manager'")
args = append(args, "eavesdrop='true'")
args = append(args, fmt.Sprintf("arg2='%s.timer'", obj.Name()))
// match dbus messsages
if call := bus.BusObject().Call(engineUtil.DBusAddMatch, 0, strings.Join(args, ",")); call.Err != nil {
return err
}
defer bus.BusObject().Call(engineUtil.DBusRemoveMatch, 0, args) // ignore the error
// channels for dbus signal
dbusChan := make(chan *dbus.Signal)
defer close(dbusChan)
bus.Signal(dbusChan)
defer bus.RemoveSignal(dbusChan) // not needed here, but nice for symmetry
p, err := obj.UnitFilePath()
if err != nil {
return errwrap.Wrapf(err, "error generating unit file path")
}
// recwatcher for the systemd-timer unit file
obj.recWatcher, err = recwatch.NewRecWatcher(p, false)
if err != nil {
return err
}
defer obj.recWatcher.Close()
obj.init.Running() // when started, notify engine that we're running
var send = false // send event?
for {
select {
case event := <-dbusChan:
// process dbus events
if obj.init.Debug {
obj.init.Logf("%+v", event)
}
send = true
case event, ok := <-obj.recWatcher.Events():
// process unit file recwatch events
if !ok { // channel shutdown
return nil
}
if err := event.Error; err != nil {
return errwrap.Wrapf(err, "Unknown %s watcher error", obj)
}
if obj.init.Debug {
obj.init.Logf("Event(%s): %v", event.Body.Name, event.Body.Op)
}
send = true
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
// do all our event sending all together to avoid duplicate msgs
if send {
send = false
obj.init.Event() // notify engine of an event (this can block)
}
}
}
// CheckApply is run to check the state and, if apply is true, to apply the
// necessary changes to reach the desired state. This is run before Watch and
// again if Watch finds a change occurring to the state.
func (obj *CronRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
checkOK := true
// use the embedded file resource to apply the correct state
if c, err := obj.file.CheckApply(ctx, apply); err != nil {
return false, errwrap.Wrapf(err, "nested file failed")
} else if !c {
checkOK = false
}
// check timer state and apply the defined state if needed
if c, err := obj.unitCheckApply(ctx, apply); err != nil {
return false, errwrap.Wrapf(err, "unitCheckApply error")
} else if !c {
checkOK = false
}
return checkOK, nil
}
// unitCheckApply checks the state of the systemd-timer unit and, if apply is
// true, applies the defined state.
func (obj *CronRes) unitCheckApply(ctx context.Context, apply bool) (bool, error) {
var conn *sdbus.Conn
var godbusConn *dbus.Conn
var err error
// this resource depends on systemd to ensure that it's running
if !systemdUtil.IsRunningSystemd() {
return false, fmt.Errorf("systemd is not running")
}
// go-systemd connection
if obj.Session {
conn, err = sdbus.NewUserConnection()
} else {
conn, err = sdbus.New() // system bus
}
if err != nil {
return false, errwrap.Wrapf(err, "error making go-systemd dbus connection")
}
defer conn.Close()
// get the load state and active state of the timer unit
loadState, err := conn.GetUnitProperty(fmt.Sprintf("%s.timer", obj.Name()), "LoadState")
if err != nil {
return false, errwrap.Wrapf(err, "failed to get load state")
}
activeState, err := conn.GetUnitProperty(fmt.Sprintf("%s.timer", obj.Name()), "ActiveState")
if err != nil {
return false, errwrap.Wrapf(err, "failed to get active state")
}
// check the timer unit state
if obj.State == "absent" && loadState.Value == dbus.MakeVariant("not-found") {
return true, nil
}
if obj.State == "exists" && activeState.Value == dbus.MakeVariant("active") {
return true, nil
}
if !apply {
return false, nil
}
// systemctl daemon-reload
if err := conn.ReloadContext(ctx); err != nil {
return false, errwrap.Wrapf(err, "error reloading daemon")
}
// context for stopping/restarting the unit
ctx, cancel := context.WithTimeout(ctx, ctxTimeout*time.Second)
defer cancel()
// godbus connection for stopping/restarting the unit
if obj.Session {
godbusConn, err = util.SessionBusPrivateUsable()
} else {
godbusConn, err = util.SystemBusPrivateUsable()
}
if err != nil {
return false, errwrap.Wrapf(err, "error making godbus connection")
}
defer godbusConn.Close()
// stop or restart the unit
if obj.State == "absent" {
return false, engineUtil.StopUnit(ctx, godbusConn, fmt.Sprintf("%s.timer", obj.Name()))
}
return false, engineUtil.RestartUnit(ctx, godbusConn, fmt.Sprintf("%s.timer", obj.Name()))
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *CronRes) Cmp(r engine.Res) error {
res, ok := r.(*CronRes)
if !ok {
return fmt.Errorf("res is not the same kind")
}
if obj.State != res.State {
return fmt.Errorf("state differs: %s vs %s", obj.State, res.State)
}
if obj.Trigger != res.Trigger {
return fmt.Errorf("trigger differs: %s vs %s", obj.Trigger, res.Trigger)
}
if obj.Time != res.Time {
return fmt.Errorf("time differs: %s vs %s", obj.Time, res.Time)
}
if obj.AccuracySec != res.AccuracySec {
return fmt.Errorf("accuracysec differs: %s vs %s", obj.AccuracySec, res.AccuracySec)
}
if obj.RandomizedDelaySec != res.RandomizedDelaySec {
return fmt.Errorf("randomizeddelaysec differs: %s vs %s", obj.RandomizedDelaySec, res.RandomizedDelaySec)
}
if obj.Unit != res.Unit {
return fmt.Errorf("unit differs: %s vs %s", obj.Unit, res.Unit)
}
if obj.Persistent != res.Persistent {
return fmt.Errorf("persistent differs: %t vs %t", obj.Persistent, res.Persistent)
}
if obj.WakeSystem != res.WakeSystem {
return fmt.Errorf("wakesystem differs: %t vs %t", obj.WakeSystem, res.WakeSystem)
}
if obj.RemainAfterElapse != res.RemainAfterElapse {
return fmt.Errorf("remainafterelapse differs: %t vs %t", obj.RemainAfterElapse, res.RemainAfterElapse)
}
return obj.file.Cmp(r)
}
// CronUID is a unique resource identifier.
type CronUID struct {
// NOTE: There is also a name variable in the BaseUID struct, this is
// information about where this UID came from, and is unrelated to the
// information about the resource we're matching. That data which is
// used in the IFF function, is what you see in the struct fields here.
engine.BaseUID
unit string // name of target unit
session bool // user session
}
// IFF aka if and only if they are equivalent, return true. If not, false.
func (obj *CronUID) IFF(uid engine.ResUID) bool {
res, ok := uid.(*CronUID)
if !ok {
return false
}
if obj.unit != res.unit {
return false
}
if obj.session != res.session {
return false
}
return true
}
// AutoEdges returns the AutoEdge interface.
func (obj *CronRes) AutoEdges() (engine.AutoEdge, error) {
return nil, nil
}
// UIDs includes all params to make a unique identification of this object. Most
// resources only return one although some resources can return multiple.
func (obj *CronRes) UIDs() []engine.ResUID {
unit := fmt.Sprintf("%s.service", obj.Name())
if obj.Unit != "" {
unit = obj.Unit
}
uids := []engine.ResUID{
&CronUID{
BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()},
unit: unit, // name of target unit
session: obj.Session, // user session
},
}
if file, err := obj.makeComposite(); err == nil {
uids = append(uids, file.UIDs()...) // add the file uid if we can
}
return uids
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *CronRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes CronRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*CronRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to CronRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = CronRes(raw) // restore from indirection with type conversion!
return nil
}
// UnitFilePath returns the path to the systemd-timer unit file.
func (obj *CronRes) UnitFilePath() (string, error) {
// root timer
if !obj.Session {
return fmt.Sprintf("/etc/systemd/system/%s.timer", obj.Name()), nil
}
// user timer
u, err := user.Current()
if err != nil {
return "", errwrap.Wrapf(err, "error getting current user")
}
if u.HomeDir == "" {
return "", fmt.Errorf("user has no home directory")
}
return path.Join(u.HomeDir, "/.config/systemd/user/", fmt.Sprintf("%s.timer", obj.Name())), nil
}
// unitFileContents returns the contents of the unit file representing the
// CronRes struct.
func (obj *CronRes) unitFileContents() string {
u := []*unit.UnitOption{}
// [Unit]
u = append(u, &unit.UnitOption{Section: "Unit", Name: "Description", Value: "timer generated by mgmt"})
// [Timer]
u = append(u, &unit.UnitOption{Section: "Timer", Name: obj.Trigger, Value: obj.Time})
if obj.AccuracySec != "" {
u = append(u, &unit.UnitOption{Section: "Timer", Name: "AccuracySec", Value: obj.AccuracySec})
}
if obj.RandomizedDelaySec != "" {
u = append(u, &unit.UnitOption{Section: "Timer", Name: "RandomizedDelaySec", Value: obj.RandomizedDelaySec})
}
if obj.Unit != "" {
u = append(u, &unit.UnitOption{Section: "Timer", Name: "Unit", Value: obj.Unit})
}
if obj.Persistent != false { // defaults to false
u = append(u, &unit.UnitOption{Section: "Timer", Name: "Persistent", Value: "true"})
}
if obj.WakeSystem != false { // defaults to false
u = append(u, &unit.UnitOption{Section: "Timer", Name: "WakeSystem", Value: "true"})
}
if obj.RemainAfterElapse != true { // defaults to true
u = append(u, &unit.UnitOption{Section: "Timer", Name: "RemainAfterElapse", Value: "false"})
}
// [Install]
u = append(u, &unit.UnitOption{Section: "Install", Name: "WantedBy", Value: "timers.target"})
buf := new(bytes.Buffer)
buf.ReadFrom(unit.Serialize(u))
return buf.String()
}