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

View File

@@ -119,6 +119,10 @@ type Args struct {
DeployCmd *DeployArgs `arg:"subcommand:deploy" help:"deploy code into a cluster"` DeployCmd *DeployArgs `arg:"subcommand:deploy" help:"deploy code into a cluster"`
SetupCmd *SetupArgs `arg:"subcommand:setup" help:"setup some bootstrapping tasks"`
FirstbootCmd *FirstbootArgs `arg:"subcommand:firstboot" help:"run some tasks on first boot"`
// This never runs, it gets preempted in the real main() function. // This never runs, it gets preempted in the real main() function.
// XXX: Can we do it nicely with the new arg parser? can it ignore all args? // XXX: Can we do it nicely with the new arg parser? can it ignore all args?
EtcdCmd *EtcdArgs `arg:"subcommand:etcd" help:"run standalone etcd"` EtcdCmd *EtcdArgs `arg:"subcommand:etcd" help:"run standalone etcd"`
@@ -155,6 +159,14 @@ func (obj *Args) Run(ctx context.Context, data *cliUtil.Data) (bool, error) {
return cmd.Run(ctx, data) return cmd.Run(ctx, data)
} }
if cmd := obj.SetupCmd; cmd != nil {
return cmd.Run(ctx, data)
}
if cmd := obj.FirstbootCmd; cmd != nil {
return cmd.Run(ctx, data)
}
// NOTE: we could return true, fmt.Errorf("...") if more than one did // NOTE: we could return true, fmt.Errorf("...") if more than one did
return false, nil // nobody activated return false, nil // nobody activated
} }

151
cli/firstboot.go Normal file
View File

@@ -0,0 +1,151 @@
// 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 cli
import (
"context"
"os"
"os/signal"
"sync"
"syscall"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/firstboot"
)
// FirstbootArgs is the CLI parsing structure and type of the parsed result.
// This particular one contains all the common flags for the `firstboot`
// subcommand.
type FirstbootArgs struct {
firstboot.Config // embedded config (can't be a pointer) https://github.com/alexflint/go-arg/issues/240
FirstbootStart *cliUtil.FirstbootStartArgs `arg:"subcommand:start" help:"start firstboot service"`
}
// Run executes the correct subcommand. It errors if there's ever an error. It
// returns true if we did activate one of the subcommands. It returns false if
// we did not. This information is used so that the top-level parser can return
// usage or help information if no subcommand activates. This particular Run is
// the run for the main `firstboot` subcommand. The firstboot command as a
// service that lets you run commands once on the first boot of a system.
func (obj *FirstbootArgs) Run(ctx context.Context, data *cliUtil.Data) (bool, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var name string
var args interface{}
if cmd := obj.FirstbootStart; cmd != nil {
name = cliUtil.LookupSubcommand(obj, cmd) // "pkg"
args = cmd
}
_ = name
Logf := func(format string, v ...interface{}) {
// Don't block this globally...
//if !data.Flags.Debug {
// return
//}
data.Flags.Logf("main: "+format, v...)
}
var api firstboot.API
if cmd := obj.FirstbootStart; cmd != nil {
api = &firstboot.Start{
FirstbootStartArgs: args.(*cliUtil.FirstbootStartArgs),
Config: obj.Config,
Program: data.Program,
Version: data.Version,
Debug: data.Flags.Debug,
Logf: Logf,
}
}
if api == nil {
return false, nil // nothing found (display help!)
}
// We don't use these for the setup command in normal operation.
if data.Flags.Debug {
cliUtil.Hello(data.Program, data.Version, data.Flags) // say hello!
defer Logf("goodbye!")
}
// install the exit signal handler
wg := &sync.WaitGroup{}
defer wg.Wait()
exit := make(chan struct{})
defer close(exit)
wg.Add(1)
go func() {
defer cancel()
defer wg.Done()
// must have buffer for max number of signals
signals := make(chan os.Signal, 3+1) // 3 * ^C + 1 * SIGTERM
signal.Notify(signals, os.Interrupt) // catch ^C
//signal.Notify(signals, os.Kill) // catch signals
signal.Notify(signals, syscall.SIGTERM)
var count uint8
for {
select {
case sig := <-signals: // any signal will do
if sig != os.Interrupt {
data.Flags.Logf("interrupted by signal")
return
}
switch count {
case 0:
data.Flags.Logf("interrupted by ^C")
cancel()
case 1:
data.Flags.Logf("interrupted by ^C (fast pause)")
cancel()
case 2:
data.Flags.Logf("interrupted by ^C (hard interrupt)")
cancel()
}
count++
case <-exit:
return
}
}
}()
if err := api.Main(ctx); err != nil {
if data.Flags.Debug {
data.Flags.Logf("main: %+v", err)
}
return false, err
}
return true, nil
}

180
cli/setup.go Normal file
View File

@@ -0,0 +1,180 @@
// 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 cli
import (
"context"
"os"
"os/signal"
"sync"
"syscall"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/setup"
)
// SetupArgs is the CLI parsing structure and type of the parsed result. This
// particular one contains all the common flags for the `setup` subcommand.
type SetupArgs struct {
setup.Config // embedded config (can't be a pointer) https://github.com/alexflint/go-arg/issues/240
SetupPkg *cliUtil.SetupPkgArgs `arg:"subcommand:pkg" help:"setup packages"`
SetupSvc *cliUtil.SetupSvcArgs `arg:"subcommand:svc" help:"setup services"`
SetupFirstboot *cliUtil.SetupFirstbootArgs `arg:"subcommand:firstboot" help:"setup firstboot"`
}
// Run executes the correct subcommand. It errors if there's ever an error. It
// returns true if we did activate one of the subcommands. It returns false if
// we did not. This information is used so that the top-level parser can return
// usage or help information if no subcommand activates. This particular Run is
// the run for the main `setup` subcommand. The setup command does some
// bootstrap work to help get things going.
func (obj *SetupArgs) Run(ctx context.Context, data *cliUtil.Data) (bool, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var name string
var args interface{}
if cmd := obj.SetupPkg; cmd != nil {
name = cliUtil.LookupSubcommand(obj, cmd) // "pkg"
args = cmd
}
if cmd := obj.SetupSvc; cmd != nil {
name = cliUtil.LookupSubcommand(obj, cmd) // "svc"
args = cmd
}
if cmd := obj.SetupFirstboot; cmd != nil {
name = cliUtil.LookupSubcommand(obj, cmd) // "firstboot"
args = cmd
}
_ = name
Logf := func(format string, v ...interface{}) {
// Don't block this globally...
//if !data.Flags.Debug {
// return
//}
data.Flags.Logf("main: "+format, v...)
}
var api setup.API
if cmd := obj.SetupPkg; cmd != nil {
api = &setup.Pkg{
SetupPkgArgs: args.(*cliUtil.SetupPkgArgs),
Config: obj.Config,
Program: data.Program,
Version: data.Version,
Debug: data.Flags.Debug,
Logf: Logf,
}
}
if cmd := obj.SetupSvc; cmd != nil {
api = &setup.Svc{
SetupSvcArgs: args.(*cliUtil.SetupSvcArgs),
Config: obj.Config,
Program: data.Program,
Version: data.Version,
Debug: data.Flags.Debug,
Logf: Logf,
}
}
if cmd := obj.SetupFirstboot; cmd != nil {
api = &setup.Firstboot{
SetupFirstbootArgs: args.(*cliUtil.SetupFirstbootArgs),
Config: obj.Config,
Program: data.Program,
Version: data.Version,
Debug: data.Flags.Debug,
Logf: Logf,
}
}
if api == nil {
return false, nil // nothing found (display help!)
}
// We don't use these for the setup command in normal operation.
if data.Flags.Debug {
cliUtil.Hello(data.Program, data.Version, data.Flags) // say hello!
defer Logf("goodbye!")
}
// install the exit signal handler
wg := &sync.WaitGroup{}
defer wg.Wait()
exit := make(chan struct{})
defer close(exit)
wg.Add(1)
go func() {
defer cancel()
defer wg.Done()
// must have buffer for max number of signals
signals := make(chan os.Signal, 3+1) // 3 * ^C + 1 * SIGTERM
signal.Notify(signals, os.Interrupt) // catch ^C
//signal.Notify(signals, os.Kill) // catch signals
signal.Notify(signals, syscall.SIGTERM)
var count uint8
for {
select {
case sig := <-signals: // any signal will do
if sig != os.Interrupt {
data.Flags.Logf("interrupted by signal")
return
}
switch count {
case 0:
data.Flags.Logf("interrupted by ^C")
cancel()
case 1:
data.Flags.Logf("interrupted by ^C (fast pause)")
cancel()
case 2:
data.Flags.Logf("interrupted by ^C (hard interrupt)")
cancel()
}
count++
case <-exit:
return
}
}
}()
if err := api.Main(ctx); err != nil {
if data.Flags.Debug {
data.Flags.Logf("main: %+v", err)
}
return false, err
}
return true, nil
}

View File

@@ -149,3 +149,41 @@ type LangPuppetArgs struct {
// end LangArgs // end LangArgs
} }
// SetupPkgArgs is the setup service CLI parsing structure and type of the
// parsed result.
type SetupPkgArgs struct {
Distro string `arg:"--distro" help:"build for this distro"`
Sudo bool `arg:"--sudo" help:"include sudo in the command"`
Exec bool `arg:"--exec" help:"actually run these commands"`
}
// SetupSvcArgs is the setup service CLI parsing structure and type of the
// parsed result.
type SetupSvcArgs struct {
BinaryPath string `arg:"--binary-path" help:"path to the binary"`
Install bool `arg:"--install" help:"install the systemd mgmt service"`
Start bool `arg:"--start" help:"start the mgmt service"`
Enable bool `arg:"--enable" help:"enable the mgmt service"`
}
// SetupFirstbootArgs is the setup service CLI parsing structure and type of the
// parsed result.
type SetupFirstbootArgs struct {
BinaryPath string `arg:"--binary-path" help:"path to the binary"`
Mkdir bool `arg:"--mkdir" help:"make the necessary firstboot dirs"`
Install bool `arg:"--install" help:"install the systemd firstboot service"`
Start bool `arg:"--start" help:"start the firstboot service (typically not used)"`
Enable bool `arg:"--enable" help:"enable the firstboot service"`
FirstbootStartArgs // Include these options if we want to specify them.
}
// FirstbootStartArgs is the firstboot service CLI parsing structure and type of
// the parsed result.
type FirstbootStartArgs struct {
LockFilePath string `arg:"--lock-file-path" help:"path to the lock file"`
ScriptsDir string `arg:"--scripts-dir" help:"path to the scripts dir"`
DoneDir string `arg:"--done-dir" help:"dir to move done scripts to"`
LoggingDir string `arg:"--logging-dir" help:"directory to store logs in"`
}

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
}

175
setup/firstboot.go Normal file
View File

@@ -0,0 +1,175 @@
// 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 setup
import (
"context"
"fmt"
"os"
"strings"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/util"
)
// Firstboot is the standalone entry program for the firstboot setup component.
type Firstboot struct {
*cliUtil.SetupFirstbootArgs // 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 *Firstboot) 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 *Firstboot) 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 templates and installs a systemd
// service and enables and starts it if so desired.
func (obj *Firstboot) Run(ctx context.Context) error {
cmdNameSystemctl := "/usr/bin/systemctl"
opts := &util.SimpleCmdOpts{
Debug: obj.Debug,
Logf: obj.Logf,
}
if obj.SetupFirstbootArgs.Mkdir {
// TODO: Should we also make LoggingDir and LockFilePath's dir?
if s := obj.SetupFirstbootArgs.ScriptsDir; s != "" {
if err := os.MkdirAll(s, 0755); err != nil {
return err
}
obj.Logf("mkdir: %s", s)
}
}
if obj.SetupFirstbootArgs.Install {
binaryPath := "/usr/bin/mgmt" // default
if s := obj.SetupFirstbootArgs.BinaryPath; s != "" {
binaryPath = s
}
args := []string{}
if s := obj.SetupFirstbootArgs.LockFilePath; s != "" {
arg := fmt.Sprintf("--lock-file-path=%s", s)
args = append(args, arg)
}
if s := obj.SetupFirstbootArgs.ScriptsDir; s != "" {
arg := fmt.Sprintf("--scripts-dir=%s", s)
args = append(args, arg)
}
if s := obj.SetupFirstbootArgs.DoneDir; s != "" {
arg := fmt.Sprintf("--done-dir=%s", s)
args = append(args, arg)
}
if s := obj.SetupFirstbootArgs.LoggingDir; s != "" {
arg := fmt.Sprintf("--logging-dir=%s", s)
args = append(args, arg)
}
unit := &util.UnitData{
Description: "Mgmt firstboot service",
Documentation: "https://github.com/purpleidea/mgmt/",
After: []string{"network.target"}, // TODO: systemd-networkd.service ?
Type: "oneshot",
ExecStart: fmt.Sprintf("%s firstboot start %s", binaryPath, strings.Join(args, " ")),
RemainAfterExit: true,
StandardOutput: "journal+console",
StandardError: "inherit",
WantedBy: []string{"multi-user.target"},
}
unitData, err := unit.Template()
if err != nil {
return err
}
unitPath := "/etc/systemd/system/mgmt-firstboot.service"
if err := os.WriteFile(unitPath, []byte(unitData), 0644); err != nil {
return err
}
obj.Logf("wrote file to: %s", unitPath)
}
if obj.SetupFirstbootArgs.Start {
cmdArgs := []string{"start", "mgmt-firsboot.service"}
if err := util.SimpleCmd(ctx, cmdNameSystemctl, cmdArgs, opts); err != nil {
return err
}
}
if obj.SetupFirstbootArgs.Enable {
cmdArgs := []string{"enable", "mgmt-firstboot.service"}
if err := util.SimpleCmd(ctx, cmdNameSystemctl, cmdArgs, opts); err != nil {
return err
}
}
return nil
}

144
setup/pkg.go Normal file
View File

@@ -0,0 +1,144 @@
// 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 setup
import (
"context"
"fmt"
"strings"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/util"
distroUtil "github.com/purpleidea/mgmt/util/distro"
)
// Pkg is the standalone entry program for the pkg setup component.
type Pkg struct {
*cliUtil.SetupPkgArgs // 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 *Pkg) 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 *Pkg) 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")
}
if obj.SetupPkgArgs.Distro == "" {
return fmt.Errorf("distro is empty")
}
return nil
}
// Run performs the desired actions. This generates a list of bash commands to
// run since we might not be able to run this binary to install these packages!
// The output (stdout) of this command can be run from a shell.
func (obj *Pkg) Run(ctx context.Context) error {
cmdName := ""
packages, exists := distroUtil.ToBootstrapPackages(obj.SetupPkgArgs.Distro)
if !exists {
return fmt.Errorf("unknown distro")
}
// TODO: Consider moving cmdName into the util/distro package.
if obj.SetupPkgArgs.Distro == "fedora" {
cmdName = "/usr/bin/dnf --assumeyes install"
}
if obj.SetupPkgArgs.Distro == "debian" {
cmdName = "/usr/bin/apt --yes install"
}
if cmdName == "" {
return fmt.Errorf("no command name found")
}
if len(packages) == 0 {
return nil // nothing to do
}
cmdArgs := []string{}
cmdArgs = append(cmdArgs, packages...)
if !obj.SetupPkgArgs.Exec { // print, don't exec
cmd := ""
if obj.SetupPkgArgs.Sudo {
cmd += "sudo" + " "
}
cmd += cmdName + " "
cmd += strings.Join(cmdArgs, " ")
fmt.Printf("%s\n", cmd)
return nil
}
// Split off any bonus elements to the command...
realCmdName := strings.Split(cmdName, " ")
realCmdArgs := []string{}
realCmdArgs = append(realCmdArgs, realCmdName[1:]...)
realCmdArgs = append(realCmdArgs, cmdArgs...)
opts := &util.SimpleCmdOpts{
Debug: obj.Debug,
Logf: obj.Logf,
}
return util.SimpleCmd(ctx, realCmdName[0], realCmdArgs, opts)
}

48
setup/setup.go Normal file
View File

@@ -0,0 +1,48 @@
// 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 setup provides some simple facilities to help bootstrap things.
package setup
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
}

137
setup/svc.go Normal file
View File

@@ -0,0 +1,137 @@
// 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 setup
import (
"context"
"fmt"
"os"
cliUtil "github.com/purpleidea/mgmt/cli/util"
"github.com/purpleidea/mgmt/util"
)
// Svc is the standalone entry program for the svc setup component.
type Svc struct {
*cliUtil.SetupSvcArgs // 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 *Svc) 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 *Svc) 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 templates and installs a systemd
// service and enables and starts it if so desired.
func (obj *Svc) Run(ctx context.Context) error {
cmdNameSystemctl := "/usr/bin/systemctl"
opts := &util.SimpleCmdOpts{
Debug: obj.Debug,
Logf: obj.Logf,
}
if obj.SetupSvcArgs.Install {
binaryPath := "/usr/bin/mgmt" // default
if s := obj.SetupSvcArgs.BinaryPath; s != "" {
binaryPath = s
}
unit := &util.UnitData{
Description: "Mgmt configuration management service",
Documentation: "https://github.com/purpleidea/mgmt/",
ExecStart: fmt.Sprintf("%s run empty $OPTS", binaryPath),
RestartSec: "5s",
Restart: "always",
WantedBy: []string{"multi-user.target"},
}
unitData, err := unit.Template()
if err != nil {
return err
}
unitPath := "/etc/systemd/system/mgmt.service"
if err := os.WriteFile(unitPath, []byte(unitData), 0644); err != nil {
return err
}
obj.Logf("wrote file to: %s", unitPath)
}
if obj.SetupSvcArgs.Start {
cmdArgs := []string{"start", "mgmt.service"}
if err := util.SimpleCmd(ctx, cmdNameSystemctl, cmdArgs, opts); err != nil {
return err
}
}
if obj.SetupSvcArgs.Enable {
cmdArgs := []string{"enable", "mgmt.service"}
if err := util.SimpleCmd(ctx, cmdNameSystemctl, cmdArgs, opts); err != nil {
return err
}
}
return nil
}