engine: resources: Add a sysctl resource
Very useful since these are easy to forget!
This commit is contained in:
398
engine/resources/sysctl.go
Normal file
398
engine/resources/sysctl.go
Normal file
@@ -0,0 +1,398 @@
|
||||
// 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
|
||||
}
|
||||
9
examples/lang/sysctl.mcl
Normal file
9
examples/lang/sysctl.mcl
Normal file
@@ -0,0 +1,9 @@
|
||||
# To know where this value is, just prepend /proc/sys/ and then replace the dots
|
||||
# with slashes, so this becomes /proc/sys/net/ipv4/ip_forward which you can cat!
|
||||
sysctl "net.ipv4.ip_forward" { # firewalls love this!
|
||||
value => "1",
|
||||
}
|
||||
|
||||
#sysctl "kernel.hostname" {
|
||||
# value => "example.com",
|
||||
#}
|
||||
Reference in New Issue
Block a user