From b074386c26a282326b8837e46351cf2eae141968 Mon Sep 17 00:00:00 2001 From: James Shubin Date: Fri, 25 Oct 2024 03:00:22 -0400 Subject: [PATCH] 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. --- cli/cli.go | 12 ++ cli/firstboot.go | 151 +++++++++++++++++++++ cli/setup.go | 180 +++++++++++++++++++++++++ cli/util/args.go | 38 ++++++ firstboot/firstboot.go | 53 ++++++++ firstboot/start.go | 290 +++++++++++++++++++++++++++++++++++++++++ setup/firstboot.go | 175 +++++++++++++++++++++++++ setup/pkg.go | 144 ++++++++++++++++++++ setup/setup.go | 48 +++++++ setup/svc.go | 137 +++++++++++++++++++ 10 files changed, 1228 insertions(+) create mode 100644 cli/firstboot.go create mode 100644 cli/setup.go create mode 100644 firstboot/firstboot.go create mode 100644 firstboot/start.go create mode 100644 setup/firstboot.go create mode 100644 setup/pkg.go create mode 100644 setup/setup.go create mode 100644 setup/svc.go diff --git a/cli/cli.go b/cli/cli.go index 073c9bd2..bb31a1a5 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -119,6 +119,10 @@ type Args struct { 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. // 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"` @@ -155,6 +159,14 @@ func (obj *Args) Run(ctx context.Context, data *cliUtil.Data) (bool, error) { 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 return false, nil // nobody activated } diff --git a/cli/firstboot.go b/cli/firstboot.go new file mode 100644 index 00000000..f8ab81a4 --- /dev/null +++ b/cli/firstboot.go @@ -0,0 +1,151 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 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 +} diff --git a/cli/setup.go b/cli/setup.go new file mode 100644 index 00000000..a20647e2 --- /dev/null +++ b/cli/setup.go @@ -0,0 +1,180 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 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 +} diff --git a/cli/util/args.go b/cli/util/args.go index 415abb0a..645226da 100644 --- a/cli/util/args.go +++ b/cli/util/args.go @@ -149,3 +149,41 @@ type LangPuppetArgs struct { // 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"` +} diff --git a/firstboot/firstboot.go b/firstboot/firstboot.go new file mode 100644 index 00000000..a6336dd2 --- /dev/null +++ b/firstboot/firstboot.go @@ -0,0 +1,53 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 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 +} diff --git a/firstboot/start.go b/firstboot/start.go new file mode 100644 index 00000000..aaee821f --- /dev/null +++ b/firstboot/start.go @@ -0,0 +1,290 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 +} diff --git a/setup/firstboot.go b/setup/firstboot.go new file mode 100644 index 00000000..b7c88f9a --- /dev/null +++ b/setup/firstboot.go @@ -0,0 +1,175 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 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 +} diff --git a/setup/pkg.go b/setup/pkg.go new file mode 100644 index 00000000..13fcfda7 --- /dev/null +++ b/setup/pkg.go @@ -0,0 +1,144 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 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) +} diff --git a/setup/setup.go b/setup/setup.go new file mode 100644 index 00000000..c83cdc6e --- /dev/null +++ b/setup/setup.go @@ -0,0 +1,48 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 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 +} diff --git a/setup/svc.go b/setup/svc.go new file mode 100644 index 00000000..ec681f51 --- /dev/null +++ b/setup/svc.go @@ -0,0 +1,137 @@ +// Mgmt +// Copyright (C) 2013-2024+ 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 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 +}