Files
mgmt/engine/resources/pkg.go
James Shubin a8f194259b legal: Happy 2024 everyone...
Done with:

ack '2023+' -l | xargs sed -i -e 's/2023+/2024+/g'

Checked manually with:

git add -p

Hello to future James from 2025, and Happy Hacking!
2024-01-22 15:52:49 -05:00

697 lines
22 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 <http://www.gnu.org/licenses/>.
package resources
import (
"context"
"fmt"
"path"
"strings"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/resources/packagekit"
"github.com/purpleidea/mgmt/engine/traits"
"github.com/purpleidea/mgmt/util"
"github.com/purpleidea/mgmt/util/errwrap"
)
func init() {
engine.RegisterResource("pkg", func() engine.Res { return &PkgRes{} })
}
const (
// PkgStateInstalled is the string that represents that the package
// should be installed.
PkgStateInstalled = "installed"
// PkgStateUninstalled is the string that represents that the package
// should be uninstalled.
PkgStateUninstalled = "uninstalled"
// PkgStateNewest is the string that represents that the package should
// be installed in the newest available version.
PkgStateNewest = "newest"
)
// PkgRes is a package resource for packagekit.
type PkgRes struct {
traits.Base // add the base methods without re-implementation
traits.Edgeable
traits.Groupable
init *engine.Init
// State determines if we want to install or uninstall the package, and
// what version we want to pin if any. Valid values include: installed,
// uninstalled, newest, and `version`, where you just put the raw
// version string desired.
State string `lang:"state" yaml:"state"`
// AllowUntrusted specifies if we want to allow untrusted packages to be
// installed. Please see the PackageKit documentation for more
// information.
AllowUntrusted bool `lang:"allowuntrusted" yaml:"allowuntrusted"`
// AllowNonFree specifies if we want to allow nonfree packages to be
// found? Please see the PackageKit documentation for more information.
AllowNonFree bool `lang:"allownonfree" yaml:"allownonfree"`
// AllowUnsupported specifies if we want to unsupported packages to be
// found? Please see the PackageKit documentation for more information.
AllowUnsupported bool `lang:"allowunsupported" yaml:"allowunsupported"`
//bus *packagekit.Conn // pk bus connection
fileList []string // FIXME: update if pkg changes
}
// Default returns some sensible defaults for this resource.
func (obj *PkgRes) Default() engine.Res {
return &PkgRes{
State: PkgStateInstalled, // this *is* preferable to "newest"
}
}
// Validate checks if the resource data structure was populated correctly.
func (obj *PkgRes) Validate() error {
if obj.State == "" {
return fmt.Errorf("state cannot be empty")
}
if obj.State == "latest" {
return fmt.Errorf("state is invalid, did you mean `newest` ?")
}
return nil
}
// Init runs some startup code for this resource.
func (obj *PkgRes) Init(init *engine.Init) error {
obj.init = init // save for later
if obj.fileList == nil {
if err := obj.populateFileList(); err != nil {
return errwrap.Wrapf(err, "error populating file list in init")
}
}
return nil
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *PkgRes) Cleanup() error {
return nil
}
// Watch is the primary listener for this resource and it outputs events. It
// uses the PackageKit UpdatesChanged signal to watch for changes.
// TODO: https://github.com/hughsie/PackageKit/issues/109
// TODO: https://github.com/hughsie/PackageKit/issues/110
func (obj *PkgRes) Watch(ctx context.Context) error {
bus := packagekit.NewBus()
if bus == nil {
return fmt.Errorf("can't connect to PackageKit bus")
}
defer bus.Close()
bus.Debug = obj.init.Debug
bus.Logf = func(format string, v ...interface{}) {
obj.init.Logf("packagekit: "+format, v...)
}
ch, err := bus.WatchChanges()
if err != nil {
return errwrap.Wrapf(err, "error adding signal match")
}
obj.init.Running() // when started, notify engine that we're running
var send = false // send event?
for {
if obj.init.Debug {
obj.init.Logf("%s: Watching...", obj.fmtNames(obj.getNames()))
}
select {
case event := <-ch:
// FIXME: ask packagekit for info on what packages changed
if obj.init.Debug {
obj.init.Logf("Event(%s): %s", event.Name, obj.fmtNames(obj.getNames()))
}
// since the chan is buffered, remove any supplemental
// events since they would just be duplicates anyways!
for len(ch) > 0 { // we can detect pending count here!
<-ch // discard
}
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)
}
}
}
// get list of names when grouped or not
func (obj *PkgRes) getNames() []string {
if g := obj.GetGroup(); len(g) > 0 { // grouped elements
names := []string{obj.Name()}
for _, x := range g {
pkg, ok := x.(*PkgRes) // convert from Res
if ok {
names = append(names, pkg.Name())
}
}
return names
}
return []string{obj.Name()}
}
// pretty print for header values
func (obj *PkgRes) fmtNames(names []string) string {
if len(obj.GetGroup()) > 0 { // grouped elements
return fmt.Sprintf("%s[autogroup:(%s)]", obj.Kind(), strings.Join(names, ","))
}
return obj.String()
}
func (obj *PkgRes) groupMappingHelper() map[string]string {
var result = make(map[string]string)
if g := obj.GetGroup(); len(g) > 0 { // add any grouped elements
for _, x := range g {
pkg, ok := x.(*PkgRes) // convert from Res
if !ok {
panic(fmt.Sprintf("grouped member %v is not a %s", x, obj.Kind()))
}
result[pkg.Name()] = pkg.State
}
}
return result
}
func (obj *PkgRes) pkgMappingHelper(bus *packagekit.Conn) (map[string]*packagekit.PkPackageIDActionData, error) {
packageMap := obj.groupMappingHelper() // get the grouped values
packageMap[obj.Name()] = obj.State // key is pkg name, value is pkg state
var filter uint64 // initializes at the "zero" value of 0
filter += packagekit.PkFilterEnumArch // always search in our arch (optional!)
// we're requesting newest version, or to narrow down install choices!
if obj.State == PkgStateNewest || obj.State == PkgStateInstalled {
// if we add this, we'll still see older packages if installed
// this is an optimization, and is *optional*, this logic is
// handled inside of PackagesToPackageIDs now automatically!
filter += packagekit.PkFilterEnumNewest // only search for newest packages
}
if !obj.AllowNonFree {
filter += packagekit.PkFilterEnumFree
}
if !obj.AllowUnsupported {
filter += packagekit.PkFilterEnumSupported
}
result, err := bus.PackagesToPackageIDs(packageMap, filter)
if err != nil {
return nil, errwrap.Wrapf(err, "can't run PackagesToPackageIDs")
}
return result, nil
}
// populateFileList fills in the fileList structure with what is in the package.
// TODO: should this work properly if pkg has been autogrouped ?
func (obj *PkgRes) populateFileList() error {
bus := packagekit.NewBus()
if bus == nil {
return fmt.Errorf("can't connect to PackageKit bus")
}
defer bus.Close()
if obj.init != nil {
bus.Debug = obj.init.Debug
bus.Logf = func(format string, v ...interface{}) {
obj.init.Logf("packagekit: "+format, v...)
}
}
result, err := obj.pkgMappingHelper(bus)
if err != nil {
return errwrap.Wrapf(err, "the pkgMappingHelper failed")
}
data, ok := result[obj.Name()] // lookup single package (init does just one)
// package doesn't exist, this is an error!
if !ok || !data.Found {
return fmt.Errorf("can't find package named '%s'", obj.Name())
}
if data.PackageID == "" {
// this can happen if you specify a bad version like "latest"
return fmt.Errorf("empty PackageID found for '%s'", obj.Name())
}
packageIDs := []string{data.PackageID} // just one for now
filesMap, err := bus.GetFilesByPackageID(packageIDs)
if err != nil {
return errwrap.Wrapf(err, "can't run GetFilesByPackageID")
}
if files, ok := filesMap[data.PackageID]; ok {
obj.fileList = util.DirifyFileList(files, false)
}
return nil
}
// 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 *PkgRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
obj.init.Logf("Check: %s", obj.fmtNames(obj.getNames()))
bus := packagekit.NewBus()
if bus == nil {
return false, fmt.Errorf("can't connect to PackageKit bus")
}
defer bus.Close()
bus.Debug = obj.init.Debug
bus.Logf = func(format string, v ...interface{}) {
obj.init.Logf("packagekit: "+format, v...)
}
result, err := obj.pkgMappingHelper(bus)
if err != nil {
return false, errwrap.Wrapf(err, "the pkgMappingHelper failed")
}
packageMap := obj.groupMappingHelper() // map[string]string
packageList := []string{obj.Name()}
packageList = append(packageList, util.StrMapKeys(packageMap)...)
//stateList := []string{obj.State}
//stateList = append(stateList, util.StrMapValues(packageMap)...)
// TODO: at the moment, all the states are the same, but
// eventually we might be able to drop this constraint!
states, err := packagekit.FilterState(result, packageList, obj.State)
if err != nil {
return false, errwrap.Wrapf(err, "the FilterState method failed")
}
data, _ := result[obj.Name()] // if above didn't error, we won't either!
validState := util.BoolMapTrue(util.BoolMapValues(states))
// obj.State == PkgStateInstalled || PkgStateUninstalled || PkgStateNewest || "4.2-1.fc23"
switch obj.State {
case PkgStateInstalled:
fallthrough
case PkgStateUninstalled:
fallthrough
case PkgStateNewest:
if validState {
return true, nil // state is correct, exit!
}
default: // version string
if obj.State == data.Version && data.Version != "" {
return true, nil
}
}
// state is not okay, no work done, exit, but without error
if !apply {
return false, nil
}
// apply portion
obj.init.Logf("Apply: %s", obj.fmtNames(obj.getNames()))
readyPackages, err := packagekit.FilterPackageState(result, packageList, obj.State)
if err != nil {
return false, err // fail
}
// these are the packages that actually need their states applied!
applyPackages := util.StrFilterElementsInList(readyPackages, packageList)
packageIDs, _ := packagekit.FilterPackageIDs(result, applyPackages) // would be same err as above
var transactionFlags uint64 // initializes at the "zero" value of 0
if !obj.AllowUntrusted { // allow
transactionFlags += packagekit.PkTransactionFlagEnumOnlyTrusted
}
// apply correct state!
obj.init.Logf("Set(%s): %s...", obj.State, obj.fmtNames(util.StrListIntersection(applyPackages, obj.getNames())))
switch obj.State {
case PkgStateUninstalled: // run remove
// NOTE: packageID is different than when installed, because now
// it has the "installed" flag added to the data portion of it!!
err = bus.RemovePackages(packageIDs, transactionFlags)
case PkgStateNewest: // TODO: isn't this the same operation as install, below?
err = bus.UpdatePackages(packageIDs, transactionFlags)
case PkgStateInstalled:
fallthrough // same method as for "set specific version", below
default: // version string
err = bus.InstallPackages(packageIDs, transactionFlags)
}
if err != nil {
return false, err // fail
}
obj.init.Logf("Set(%s) success: %s", obj.State, obj.fmtNames(util.StrListIntersection(applyPackages, obj.getNames())))
return false, nil // success
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *PkgRes) Cmp(r engine.Res) error {
// we can only compare PkgRes to others of the same resource kind
res, ok := r.(*PkgRes)
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)
}
return obj.Adapts(res)
}
// Adapts compares two resources and returns an error if they are not able to be
// equivalently output compatible.
func (obj *PkgRes) Adapts(r engine.CompatibleRes) error {
res, ok := r.(*PkgRes)
if !ok {
return fmt.Errorf("res is not the same kind")
}
if obj.State != res.State {
e := fmt.Errorf("state differs in an incompatible way: %s vs %s", obj.State, res.State)
if obj.State == PkgStateUninstalled || res.State == PkgStateUninstalled {
return e
}
if stateIsVersion(obj.State) || stateIsVersion(res.State) {
return e
}
// one must be installed, and the other must be "newest"
}
if obj.AllowUntrusted != res.AllowUntrusted {
return fmt.Errorf("allowuntrusted differs: %t vs %t", obj.AllowUntrusted, res.AllowUntrusted)
}
if obj.AllowNonFree != res.AllowNonFree {
return fmt.Errorf("allownonfree differs: %t vs %t", obj.AllowNonFree, res.AllowNonFree)
}
if obj.AllowUnsupported != res.AllowUnsupported {
return fmt.Errorf("allowunsupported differs: %t vs %t", obj.AllowUnsupported, res.AllowUnsupported)
}
return nil
}
// Merge returns the best equivalent of the two resources. They must satisfy the
// Adapts test for this to work.
func (obj *PkgRes) Merge(r engine.CompatibleRes) (engine.CompatibleRes, error) {
res, ok := r.(*PkgRes)
if !ok {
return nil, fmt.Errorf("res is not the same kind")
}
if err := obj.Adapts(r); err != nil {
return nil, errwrap.Wrapf(err, "can't merge resources that aren't compatible")
}
// modify the copy, not the original
x, err := engine.ResCopy(obj) // don't call our .Copy() directly!
if err != nil {
return nil, err
}
result, ok := x.(*PkgRes)
if !ok {
// bug!
return nil, fmt.Errorf("res is not the same kind")
}
// if these two were compatible then if they're not identical, then one
// must be PkgStateNewest and the other is PkgStateInstalled, so we
// upgrade to the best common denominator
if obj.State != res.State {
result.State = PkgStateNewest
}
return result, nil
}
// Copy copies the resource. Don't call it directly, use engine.ResCopy instead.
// TODO: should this copy internal state?
func (obj *PkgRes) Copy() engine.CopyableRes {
return &PkgRes{
State: obj.State,
AllowUntrusted: obj.AllowUntrusted,
AllowNonFree: obj.AllowNonFree,
AllowUnsupported: obj.AllowUnsupported,
}
}
// PkgUID is the main UID struct for PkgRes.
type PkgUID struct {
engine.BaseUID
name string // pkg name
state string // pkg state or "version"
}
// PkgFileUID is the UID struct for PkgRes files.
type PkgFileUID struct {
engine.BaseUID
path string // path of the file
}
// IFF aka if and only if they are equivalent, return true. If not, false.
func (obj *PkgUID) IFF(uid engine.ResUID) bool {
res, ok := uid.(*PkgUID)
if !ok {
return false
}
// FIXME: match on obj.state vs. res.state ?
return obj.name == res.name
}
// PkgResAutoEdges holds the state of the auto edge generator.
type PkgResAutoEdges struct {
fileList []string
svcUIDs []engine.ResUID
testIsNext bool // safety
name string // saved data from PkgRes obj
kind string
}
// Next returns the next automatic edge.
func (obj *PkgResAutoEdges) Next() []engine.ResUID {
if obj.testIsNext {
panic("expecting a call to Test()")
}
obj.testIsNext = true // set after all the errors paths are past
// first return any matching svcUIDs
if x := obj.svcUIDs; len(x) > 0 {
return x
}
var result []engine.ResUID
// return UID's for whatever is in obj.fileList
for _, x := range obj.fileList {
var reversed = false // cheat by passing a pointer
result = append(result, &FileUID{
BaseUID: engine.BaseUID{
Name: obj.name,
Kind: obj.kind,
Reversed: &reversed,
},
path: x, // what matters
}) // build list
}
return result
}
// Test gets results of the earlier Next() call, & returns if we should
// continue!
func (obj *PkgResAutoEdges) Test(input []bool) bool {
if !obj.testIsNext {
panic("expecting a call to Next()")
}
// ack the svcUID's...
if x := obj.svcUIDs; len(x) > 0 {
if y := len(x); y != len(input) {
panic(fmt.Sprintf("expecting %d value(s)", y))
}
obj.svcUIDs = []engine.ResUID{} // empty
obj.testIsNext = false
return true
}
count := len(obj.fileList)
if count != len(input) {
panic(fmt.Sprintf("expecting %d value(s)", count))
}
obj.testIsNext = false // set after all the errors paths are past
// while i do believe this algorithm generates the *correct* result, i
// don't know if it does so in the optimal way. improvements welcome!
// the basic logic is:
// 0) Next() returns whatever is in fileList
// 1) Test() computes the dirname of each file, and removes duplicates
// and dirname's that have been in the path of an ack from input results
// 2) It then simplifies the list by removing the common path prefixes
// 3) Lastly, the remaining set of files (dirs) is used as new fileList
// 4) We then iterate in (0) until the fileList is empty!
var dirs = make([]string, count)
done := []string{}
for i := 0; i < count; i++ {
dir := util.Dirname(obj.fileList[i]) // dirname of /foo/ should be /
dirs[i] = dir
if input[i] {
done = append(done, dir)
}
}
nodupes := util.StrRemoveDuplicatesInList(dirs) // remove duplicates
nodones := util.StrFilterElementsInList(done, nodupes) // filter out done
noempty := util.StrFilterElementsInList([]string{""}, nodones) // remove the "" from /
obj.fileList = util.RemoveCommonFilePrefixes(noempty) // magic
if len(obj.fileList) == 0 { // nothing more, don't continue
return false
}
return true // continue, there are more files!
}
// AutoEdges produces an object which generates a minimal pkg file optimization
// sequence of edges.
func (obj *PkgRes) AutoEdges() (engine.AutoEdge, error) {
// in contrast with the FileRes AutoEdges() function which contains
// more of the mechanics, most of the AutoEdge mechanics for the PkgRes
// are contained in the Test() method! This design is completely okay!
if obj.fileList == nil {
if err := obj.populateFileList(); err != nil {
return nil, errwrap.Wrapf(err, "error populating file list for automatic edges")
}
}
// add matches for any svc resources found in pkg definition!
var svcUIDs []engine.ResUID
for _, x := range ReturnSvcInFileList(obj.fileList) {
var reversed = false
svcUIDs = append(svcUIDs, &SvcUID{
BaseUID: engine.BaseUID{
Name: obj.Name(),
Kind: obj.Kind(),
Reversed: &reversed,
},
name: x, // the svc name itself in the SvcUID object!
}) // build list
}
return &PkgResAutoEdges{
fileList: util.RemoveCommonFilePrefixes(obj.fileList), // clean start!
svcUIDs: svcUIDs,
testIsNext: false, // start with Next() call
name: obj.Name(), // save data for PkgResAutoEdges obj
kind: obj.Kind(),
}, 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 *PkgRes) UIDs() []engine.ResUID {
x := &PkgUID{
BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()},
name: obj.Name(),
state: obj.State,
}
result := []engine.ResUID{x}
for _, y := range obj.fileList {
y := &PkgFileUID{
BaseUID: engine.BaseUID{Name: obj.Name(), Kind: obj.Kind()},
path: y,
}
result = append(result, y)
}
return result
}
// GroupCmp returns whether two resources can be grouped together or not. Can
// these two resources be merged, aka, does this resource support doing so? Will
// resource allow itself to be grouped _into_ this obj?
func (obj *PkgRes) GroupCmp(r engine.GroupableRes) error {
res, ok := r.(*PkgRes)
if !ok {
return fmt.Errorf("resource is not the same kind")
}
// TODO: what should we do about the empty string?
if stateIsVersion(obj.State) || stateIsVersion(res.State) {
// can't merge specific version checks atm
return fmt.Errorf("resource uses a version string")
}
// FIXME: keep it simple for now, only merge same states
if obj.State != res.State {
return fmt.Errorf("resource is of a different state")
}
return nil
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *PkgRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes PkgRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*PkgRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to PkgRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = PkgRes(raw) // restore from indirection with type conversion!
return nil
}
// ReturnSvcInFileList returns a list of svc names for matches like:
// `/usr/lib/systemd/system/*.service`.
func ReturnSvcInFileList(fileList []string) []string {
result := []string{}
for _, x := range fileList {
dirname, basename := path.Split(path.Clean(x))
// TODO: do we also want to look for /etc/systemd/system/ ?
if dirname != "/usr/lib/systemd/system/" {
continue
}
if !strings.HasSuffix(basename, ".service") {
continue
}
if s := strings.TrimSuffix(basename, ".service"); !util.StrInList(s, result) {
result = append(result, s)
}
}
return result
}
// stateIsVersion is a simple test to see if the state string is an existing
// well-known flag.
// TODO: what should we do about the empty string?
func stateIsVersion(state string) bool {
return (state != PkgStateInstalled && state != PkgStateUninstalled && state != PkgStateNewest) // must be a ver. string
}