Files
mgmt/engine/resources/sysctl.go
James Shubin a4f368fc9f engine: resources: Add a sysctl resource
Very useful since these are easy to forget!
2024-09-15 23:07:03 -04:00

399 lines
12 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"
"path"
"strings"
"sync"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/traits"
"github.com/purpleidea/mgmt/util/errwrap"
"github.com/purpleidea/mgmt/util/recwatch"
)
func init() {
engine.RegisterResource("sysctl", func() engine.Res { return &SysctlRes{} })
if !strings.HasSuffix(SysctlConfDir, "/") {
panic("the SysctlConfDir does not end with a slash")
}
}
const (
// SysctlConfDir is the directory to store persistent sysctl files in.
SysctlConfDir = "/etc/sysctl.d/"
// SysctlConfPrefix is the prefix we prepend to any automatically chosen
// filename that we put in the /etc/sysctl.d/ directory.
// TODO: What prefix should we use if any?
SysctlConfPrefix = "99-"
)
// SysctlRes is a resource for setting kernel parameters.
// TODO: Add a sysctl:clean resource that removes any unmanaged files from
// /etc/sysctl.d/ and optionally blanks out the stock /etc/sysctl.conf file too.
type SysctlRes struct {
traits.Base // add the base methods without re-implementation
init *engine.Init
// Value is the string value to set. Make sure you specify it in the
// same format that the kernel parses it as to avoid automation
// "flapping". You can test this by writing a value to the correct
// /proc/sys/ path entry with `echo foo >` and then reading it back out
// and seeing what the "parsed" correct format is. You must not include
// the trailing newline which is present in the readback for all values.
Value string `lang:"value" yaml:"value"`
// Runtime specifies whether this value should be set immediately. It
// defaults to true. If this is not set, then the value must be set in a
// file and the machine will have to reboot for the setting to take
// effect.
Runtime bool `lang:"runtime" yaml:"runtime"`
// Persist specifies whether this value should be stored on disk where
// it will persist across reboots. It defaults to true. Keep in mind,
// that if this is not used, but `Runtime` is true, then the value will
// be restored anyways if `mgmt` runs on boot, which may be what you
// want anyways.
Persist bool `lang:"persist" yaml:"persist"`
// Filename is the full path for the persistence file which is usually
// read on boot. We usually use entries in the /etc/sysctl.d/ directory.
// By convention, they end in .conf and start with a numeric prefix and
// a dash. For example: /etc/sysctl.d/10-dmesg.conf for example. If this
// is omitted, the filename will be chosen automatically.
Filename string `lang:"path" yaml:"path"`
}
// toPath converts our name into the magic kernel path. It does not validate
// that the name is in a valid format.
func (obj *SysctlRes) toPath() string {
return path.Join("/proc/sys/", strings.ReplaceAll(obj.Name(), ".", "/"))
}
// getFilename returns the filename of the config that we would use if we're
// setting one. This does not look at the is persistent aspect.
func (obj *SysctlRes) getFilename() string {
if obj.Filename != "" {
return obj.Filename
}
return SysctlConfDir + SysctlConfPrefix + obj.Name() + ".conf"
}
// Default returns some sensible defaults for this resource.
func (obj *SysctlRes) Default() engine.Res {
return &SysctlRes{
Runtime: true,
Persist: true,
}
}
// Validate reports any problems with the struct definition.
func (obj *SysctlRes) Validate() error {
if strings.Contains(obj.Name(), "/") {
// We do this to avoid having two resources which "fight" with
// each other by using the alternative representation.
return fmt.Errorf("name contains slashes, use the dotted representation")
}
if strings.Contains(obj.Name(), "=") {
return fmt.Errorf("name contains equals sign, this is illegal")
}
if strings.HasPrefix(obj.Name(), ".") || strings.HasSuffix(obj.Name(), ".") {
return fmt.Errorf("name contains leading or trailing periods")
}
if obj.Name() != strings.TrimSpace(obj.Name()) {
return fmt.Errorf("name has leading or trailing whitespace")
}
if obj.Value == "" {
return fmt.Errorf("value is empty")
}
if strings.TrimSpace(obj.Value) != obj.Value {
return fmt.Errorf("value contains leading or trailing whitespace")
}
// TODO: We could probably relax this check I suppose.
if !obj.Runtime && !obj.Persist {
return fmt.Errorf("you must either set the value at runtime or you must persist")
}
// Parse the Name() and see if it's a valid path under /proc/sys/ dir.
if _, err := os.Stat(obj.toPath()); err != nil && !os.IsNotExist(err) {
// system or permissions error?
return errwrap.Wrapf(err, "unknown stat error")
} else if err != nil {
// TODO: Could there be a kernel that doesn't show this path?
return fmt.Errorf("name is not a valid kernel path: %s", obj.toPath())
}
if obj.Persist && !strings.HasSuffix(obj.getFilename(), ".conf") {
return fmt.Errorf("filename must end with .conf")
}
if obj.Persist && !strings.HasPrefix(obj.getFilename(), "/") {
return fmt.Errorf("filename must be absolute and start with slash")
}
return nil
}
// Init runs some startup code for this resource.
func (obj *SysctlRes) Init(init *engine.Init) error {
obj.init = init // save for later
return nil
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *SysctlRes) Cleanup() error {
return nil
}
// Watch is the primary listener for this resource and it outputs events. This
// one watches the on disk filename if it creates one, as well as the runtime
// value the kernel has stored!
func (obj *SysctlRes) Watch(ctx context.Context) error {
wg := &sync.WaitGroup{}
defer wg.Wait()
recurse := false // single file
var events1, events2 chan recwatch.Event
if obj.Runtime {
recWatcher, err := recwatch.NewRecWatcher(obj.toPath(), recurse)
if err != nil {
return err
}
defer recWatcher.Close()
events1 = recWatcher.Events()
}
if obj.Persist {
recWatcher, err := recwatch.NewRecWatcher(obj.getFilename(), recurse)
if err != nil {
return err
}
defer recWatcher.Close()
events2 = recWatcher.Events()
}
obj.init.Running() // when started, notify engine that we're running
var send = false // send event?
for {
select {
case event, ok := <-events1:
if !ok { // channel shutdown
return fmt.Errorf("unexpected close")
}
if err := event.Error; err != nil {
return err
}
if obj.init.Debug { // don't access event.Body if event.Error isn't nil
obj.init.Logf("event(%s): %v", event.Body.Name, event.Body.Op)
}
send = true
case event, ok := <-events2:
if !ok { // channel shutdown
return fmt.Errorf("unexpected close")
}
if err := event.Error; err != nil {
return err
}
if obj.init.Debug { // don't access event.Body if event.Error isn't nil
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 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 *SysctlRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
checkOK := true
// TODO: If there any reason to do one of these before the other? At
// least right now, if the runtime change causes a kernel panic, the
// machine will have a better chance of coming back online without the
// persisted value being stored.
if c, err := obj.runtimeCheckApply(ctx, apply); err != nil {
return false, err
} else if !c {
checkOK = false
}
if c, err := obj.persistCheckApply(ctx, apply); err != nil {
return false, err
} else if !c {
checkOK = false
}
return checkOK, nil // w00t
}
// runtimeCheckApply checks the runtime value in the kernel, and modifies it if
// needed.
func (obj *SysctlRes) runtimeCheckApply(ctx context.Context, apply bool) (bool, error) {
if !obj.Runtime {
return true, nil
}
// Clean off any whitespace it comes with and then always add a newline.
expected := []byte(obj.Value + "\n")
b, err := os.ReadFile(obj.toPath())
if err != nil && !os.IsNotExist(err) {
// system or permissions error?
return false, nil
}
if err == nil && bytes.Equal(expected, b) {
return true, nil // we match!
}
// Down here, file does not exist or does not match...
if !apply {
return false, nil
}
if err := os.WriteFile(obj.toPath(), expected, 0644); err != nil {
return false, err
}
obj.init.Logf("runtime `%s` to: %s\n", obj.Value, obj.toPath())
return false, err
}
// persistCheckApply checks the on-disk value for the kernel, and modifies it if
// needed.
func (obj *SysctlRes) persistCheckApply(ctx context.Context, apply bool) (bool, error) {
if !obj.Persist {
return true, nil
}
// Clean off any whitespace and put it in the standard format.
// TODO: Should we add a "last managed by mgmt on $date" line ?
s := fmt.Sprintf("%s = %s\n", obj.Name(), obj.Value)
expected := []byte(s)
b, err := os.ReadFile(obj.getFilename())
if err != nil && !os.IsNotExist(err) {
// system or permissions error?
return false, nil
}
if err == nil && bytes.Equal(expected, b) {
return true, nil // we match!
}
// Down here, file does not exist or does not match...
if !apply {
return false, nil
}
if err := os.WriteFile(obj.getFilename(), expected, 0644); err != nil {
return false, err
}
obj.init.Logf("persist `%s` to: %s\n", obj.Value, obj.getFilename())
return false, nil
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *SysctlRes) Cmp(r engine.Res) error {
// we can only compare SysctlRes to others of the same resource kind
res, ok := r.(*SysctlRes)
if !ok {
return fmt.Errorf("not a %s", obj.Kind())
}
if obj.Value != res.Value {
return fmt.Errorf("the Value differs")
}
if obj.Runtime != res.Runtime {
return fmt.Errorf("the Runtime value differs")
}
if obj.Persist != res.Persist {
return fmt.Errorf("the Persist value differs")
}
// TODO: We could compare the actual resultant Filename if we're using
// it, even if it comes from different representations, eg: specified vs
// chosen automatically. If they don't differ, it's fine with us!
if obj.Filename != res.Filename {
return fmt.Errorf("the contents of Filename differ")
}
return nil
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *SysctlRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes SysctlRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*SysctlRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to SysctlRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = SysctlRes(raw) // restore from indirection with type conversion!
return nil
}