cli: Add setup and firstboot commands

This adds two new top-level commands: setup and firstboot.

Firstboot is pure-golang implementation of a service that runs some
commands once when a system first boots. You need to install this
service, and put the scripts to run in a special directory. This is
inspired by the virt-builder --firstboot mechanism.

Setup is a general purpose command that makes it easy to setup certain
facilities on a new machine. These include the mgmt package dependencies
it might need, a service to run it from, and the necessary service to
use the mgmt firstboot service as well.

All of this has been built to facilitate handoff between provisioning a
new machine and running configuration management on it.
This commit is contained in:
James Shubin
2024-10-25 03:00:22 -04:00
parent b140b2dfeb
commit b074386c26
10 changed files with 1228 additions and 0 deletions

53
firstboot/firstboot.go Normal file
View File

@@ -0,0 +1,53 @@
// 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 firstboot provides a service that runs scripts once on first boot.
// You can easily test this with:
//
// mkdir /tmp/mgmt-firstboot/ ; echo 'echo hello' > /tmp/mgmt-firstboot/foo.sh
// ./mgmt firstboot start --lock-file-path=/tmp/flock.lock \
// --scripts-dir=/tmp/mgmt-firstboot/ --logging-dir=/tmp/
package firstboot
import (
"context"
)
// API is the simple interface we expect for any setup items.
type API interface {
// Main runs everything for this setup item.
Main(context.Context) error
}
// Config is a struct of all the configuration values which are shared by all of
// the setup utilities. By including this as a separate struct, it can be used
// as part of the API if we want.
type Config struct {
//Foo string `arg:"--foo,env:MGMT_FIRSTBOOT_FOO" help:"Foo..."` // TODO: foo
}

290
firstboot/start.go Normal file
View File

@@ -0,0 +1,290 @@
// 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 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
}