// Mgmt // Copyright (C) James Shubin and the project contributors // Written by James Shubin 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 . // // 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 firstboot import ( "context" "fmt" "os" "path/filepath" "strings" "time" cliUtil "github.com/purpleidea/mgmt/cli/util" "github.com/purpleidea/mgmt/util" "gopkg.in/yaml.v2" ) const ( // LockFilePath is where we store a lock file to prevent more than one // copy of this service running at the same time. // TODO: Is there a better place to put this? LockFilePath string = "/var/lib/mgmt/firstboot.lock" // ScriptsDir is the directory where firstboot scripts can be found. You // can put binaries in here too. Contents must be executable. ScriptsDir string = "/var/lib/mgmt/firstboot/" // StyleSuffix is what we append to executables to specify a style file. StyleSuffix = ".yaml" ) // Start is the standalone entry program for the firstboot start component. type Start struct { *cliUtil.FirstbootStartArgs // embedded config Config // embedded Config // Program is the name of this program, usually set at compile time. Program string // Version is the version of this program, usually set at compile time. Version string // Debug represents if we're running in debug mode or not. Debug bool // Logf is a logger which should be used. Logf func(format string, v ...interface{}) } // Main runs everything for this setup item. func (obj *Start) Main(ctx context.Context) error { if err := obj.Validate(); err != nil { return err } if err := obj.Run(ctx); err != nil { return err } return nil } // Validate verifies that the structure has acceptable data stored within. func (obj *Start) Validate() error { if obj == nil { return fmt.Errorf("data is nil") } if obj.Program == "" { return fmt.Errorf("program is empty") } if obj.Version == "" { return fmt.Errorf("version is empty") } return nil } // Run performs the desired actions. This runs a list of scripts and then // removes them. This is useful for a "firstboot" service. If there exists a // file with the same name as an executable script or binary, but which has a // .yaml extension, that will be parsed to look for command modifiers. func (obj *Start) Run(ctx context.Context) error { lockFile := LockFilePath // default if s := obj.FirstbootStartArgs.LockFilePath; s != "" && !strings.HasSuffix(s, "/") { lockFile = s } // Ensure the directory exists. d := filepath.Dir(lockFile) if err := os.MkdirAll(d, 0750); err != nil { return fmt.Errorf("could not make lockfile dir at: %s", d) } // Make sure only one copy of this service is running at a time. unlock, err := util.NewFlock(lockFile).TryLock() if err != nil { return err // can't get lock } if unlock == nil { return fmt.Errorf("already running") } // now we're locked! defer unlock() scriptsDir := ScriptsDir // default if s := obj.FirstbootStartArgs.ScriptsDir; s != "" && strings.HasSuffix(s, "/") { scriptsDir = s } obj.Logf("scripts dir: %s", scriptsDir) // Loop through all the entries and execute what we can... entries, err := os.ReadDir(scriptsDir) // ([]os.DirEntry, error) if err != nil { return err } for _, entry := range entries { if entry.IsDir() { // skip dirs continue } if !entry.Type().IsRegular() { // skip weird things // TODO: We may wish to relax this constraint eventually. continue } // TODO: Why is entry.Type() always empty? //fmt.Printf("???: %+v\n", entry.Type()) //if m := entry.Type(); m&0100 == 0 { // owner bit is not executable // continue //} fi, err := entry.Info() if os.IsNotExist(err) { continue // we might have deleted a style file } else if err != nil { return err // TODO: continue instead? } if m := fi.Mode(); m&0100 == 0 { // owner bit is not executable continue } p := filepath.Clean(scriptsDir + entry.Name()) obj.Logf("found: %s", p) styleFile := p + StyleSuffix // maybe it exists! style := &Style{ // set defaults here DoneDir: obj.FirstbootStartArgs.DoneDir, } // TODO: check style file is _not_ executable to avoid chains? data, err := os.ReadFile(styleFile) if err != nil && !os.IsNotExist(err) { return err } if err == nil { if err := yaml.Unmarshal(data, style); err != nil { return err } style.exists = true } // Found one! if err := obj.process(ctx, p, style); err != nil { } } return nil } // process runs the command, logs, and deletes the script if needed. func (obj *Start) process(ctx context.Context, name string, style *Style) error { logOutput := "" if !style.NoLog { loggingDir := "/root/" if s := obj.FirstbootStartArgs.LoggingDir; s != "" && strings.HasSuffix(s, "/") { loggingDir = s } logOutput = fmt.Sprintf("%smgmt-firstboot-%v.log", loggingDir, time.Now().UnixNano()) } if err := util.AppendFile(logOutput, []byte(name+"\n"), 0600); err != nil { obj.Logf("error: %v", err) } opts := &util.SimpleCmdOpts{ Debug: obj.Debug, Logf: obj.Logf, LogOutput: logOutput, } args := []string{} err := util.SimpleCmd(ctx, name, args, opts) errStr := fmt.Sprintf("error: %v\n", err) if err := util.AppendFile(logOutput, []byte(errStr), 0600); err != nil { obj.Logf("error: %v", err) } if err != nil && style.KeepOnFail { return err } if style.DoneDir != "" && strings.HasSuffix(style.DoneDir, "/") { dest := func(p string) string { // dest path from input full path return style.DoneDir + filepath.Base(p) } if err := os.MkdirAll(style.DoneDir, 0750); err != nil { // convenience obj.Logf("error: %v", err) //return err // let the real final error be seen... } // Move files! if err := os.Rename(name, dest(name)); err != nil { obj.Logf("error: %v", err) } else { obj.Logf("moved: %s", name) } if style.exists { if err := os.Rename(name+StyleSuffix, dest(name+StyleSuffix)); err != nil { obj.Logf("error: %v", err) } else { obj.Logf("moved: %s", name+StyleSuffix) } } return err } // Remove files! if err := os.Remove(name); err != nil { obj.Logf("error: %v", err) } else { obj.Logf("removed: %s", name) } if style.exists { if err := os.Remove(name + StyleSuffix); err != nil { obj.Logf("error: %v", err) } else { obj.Logf("removed: %s", name+StyleSuffix) } } return err } // Style are some values that are used to specify how each command runs. type Style struct { // TODO: Is this easy to implement? // DeleteFirst should be true to delete the script before it runs. This // is useful to prevent scenarios where a read-only filesystem would // cause the script to run again and again. //DeleteFirst bool `yaml:"delete-first"` // DoneDir specifies the dir to move files to instead of deleting them. DoneDir string `yaml:"done-dir"` // KeepOnFail should be true to preserve the script if it fails. This // means it will likely run again the next time the service runs. KeepOnFail bool `yaml:"keep-on-fail"` // NoLog should be true to skip logging the output of this command. NoLog bool `yaml:"no-log"` // exists specifies if the style file exists on disk. (And as a result, // should it be removed at the end?) exists bool }