diff --git a/cli/cli.go b/cli/cli.go index 1d06307e..87f731e5 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -125,6 +125,8 @@ type Args struct { DocsCmd *DocsGenerateArgs `arg:"subcommand:docs" help:"generate documentation"` + ToolsCmd *ToolsArgs `arg:"subcommand:tools" help:"collection of useful tools"` + // 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"` @@ -173,6 +175,10 @@ func (obj *Args) Run(ctx context.Context, data *cliUtil.Data) (bool, error) { return cmd.Run(ctx, data) } + if cmd := obj.ToolsCmd; 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/tools.go b/cli/tools.go new file mode 100644 index 00000000..b1fb0547 --- /dev/null +++ b/cli/tools.go @@ -0,0 +1,150 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this program, or any covered work, by linking or combining it +// with embedded mcl code and modules (and that the embedded mcl code and +// modules which link with this program, contain a copy of their source code in +// the authoritative form) containing parts covered by the terms of any other +// license, the licensors of this program grant you additional permission to +// convey the resulting work. Furthermore, the licensors of this program grant +// the original author, James Shubin, additional permission to update this +// additional permission if he deems it necessary to achieve the goals of this +// additional permission. + +package cli + +import ( + "context" + "os" + "os/signal" + "sync" + "syscall" + + cliUtil "github.com/purpleidea/mgmt/cli/util" + "github.com/purpleidea/mgmt/tools" +) + +// ToolsArgs is the CLI parsing structure and type of the parsed result. This +// particular one contains all the common flags for the `tools` subcommand. +type ToolsArgs struct { + tools.Config // embedded config (can't be a pointer) https://github.com/alexflint/go-arg/issues/240 + + ToolsGrow *cliUtil.ToolsGrowArgs `arg:"subcommand:grow" help:"tools for growing storage"` +} + +// 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 `tools` subcommand. The tools command provides some +// functionality which can be helpful with provisioning and config management. +func (obj *ToolsArgs) 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.ToolsGrow; cmd != nil { + name = cliUtil.LookupSubcommand(obj, cmd) // "grow" + 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 tools.API + + if cmd := obj.ToolsGrow; cmd != nil { + api = &tools.Grow{ + ToolsGrowArgs: args.(*cliUtil.ToolsGrowArgs), + 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 tools 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 99b4a3fd..f12ea881 100644 --- a/cli/util/args.go +++ b/cli/util/args.go @@ -204,3 +204,11 @@ type DocsGenerateArgs struct { NoResources bool `arg:"--no-resources" help:"skip resource doc generation"` NoFunctions bool `arg:"--no-functions" help:"skip function doc generation"` } + +// ToolsGrowArgs is the util tool CLI parsing structure and type of the parsed +// result. +type ToolsGrowArgs struct { + Mount string `arg:"--mount,required" help:"root mount point to start with"` + Exec bool `arg:"--exec" help:"actually run these commands"` + Done string `arg:"--done" help:"create this file when done, skip if it exists"` +} diff --git a/modules/misc/main.mcl b/modules/misc/main.mcl index f935967a..556432c0 100644 --- a/modules/misc/main.mcl +++ b/modules/misc/main.mcl @@ -27,6 +27,7 @@ # additional permission if he deems it necessary to achieve the goals of this # additional permission. +import "deploy" import "fmt" import "golang" import "golang/strings" as golang_strings @@ -403,3 +404,34 @@ class copr_enable($name) { user => "root", } } + +# For some reason, newly provisioned Fedora machines, don't have their root +# partitions consuming 100% of the available space. Fix that. Make sure that +# this runs on firstboot and *before* we change any LUKS password, as this only +# works with the empty password. +class grow($mount) { + pkg "cloud-utils-growpart" { + state => "installed", + } + + $done = "/root/.mgmt_grow" # must be absolute + + # The xfs_growfs, resize2fs, cryptsetup, and findmnt tools should + # already be installed on any resonable Linux system by default. + exec "mgmt grow ${mount}" { + cmd => deploy.binary_path(), # this is mgmt + args => [ + "tools", # tools is a subcommand of mgmt + "grow", + "--mount", + "${mount}", # typically root, eg: / + "--exec", # !noop + "--done", + "${done}", + ], + creates => $done, # TODO: is there something better? + user => "root", # need root to be able to run normally + + Depend => Pkg["cloud-utils-growpart"], + } +} diff --git a/tools/grow.go b/tools/grow.go new file mode 100644 index 00000000..06d8ff35 --- /dev/null +++ b/tools/grow.go @@ -0,0 +1,68 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this program, or any covered work, by linking or combining it +// with embedded mcl code and modules (and that the embedded mcl code and +// modules which link with this program, contain a copy of their source code in +// the authoritative form) containing parts covered by the terms of any other +// license, the licensors of this program grant you additional permission to +// convey the resulting work. Furthermore, the licensors of this program grant +// the original author, James Shubin, additional permission to update this +// additional permission if he deems it necessary to achieve the goals of this +// additional permission. + +package tools + +import ( + "context" + + cliUtil "github.com/purpleidea/mgmt/cli/util" + growUtil "github.com/purpleidea/mgmt/util/grow" +) + +// Grow is the standalone entry program for the grow tools component. +type Grow struct { + *cliUtil.ToolsGrowArgs // 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 tools item. +func (obj *Grow) Main(ctx context.Context) error { + + g := &growUtil.Grow{ + Noop: !obj.Exec, + Done: obj.Done, + Debug: obj.Debug, + Logf: obj.Logf, + } + + return g.Run(ctx, obj.Mount) +} diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 00000000..b62e170c --- /dev/null +++ b/tools/tools.go @@ -0,0 +1,48 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this program, or any covered work, by linking or combining it +// with embedded mcl code and modules (and that the embedded mcl code and +// modules which link with this program, contain a copy of their source code in +// the authoritative form) containing parts covered by the terms of any other +// license, the licensors of this program grant you additional permission to +// convey the resulting work. Furthermore, the licensors of this program grant +// the original author, James Shubin, additional permission to update this +// additional permission if he deems it necessary to achieve the goals of this +// additional permission. + +// Package tools provides some simple standalone tools to help with things. +package tools + +import ( + "context" +) + +// API is the simple interface we expect for any tools items. +type API interface { + // Main runs everything for this tools item. + Main(context.Context) error +} + +// Config is a struct of all the configuration values which are shared by all of +// the tools 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_TOOLS_FOO" help:"Foo..."` // TODO: foo +} diff --git a/util/grow/grow.go b/util/grow/grow.go new file mode 100644 index 00000000..3f98de40 --- /dev/null +++ b/util/grow/grow.go @@ -0,0 +1,408 @@ +// Mgmt +// Copyright (C) James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this program, or any covered work, by linking or combining it +// with embedded mcl code and modules (and that the embedded mcl code and +// modules which link with this program, contain a copy of their source code in +// the authoritative form) containing parts covered by the terms of any other +// license, the licensors of this program grant you additional permission to +// convey the resulting work. Furthermore, the licensors of this program grant +// the original author, James Shubin, additional permission to update this +// additional permission if he deems it necessary to achieve the goals of this +// additional permission. + +// Package grow is a utility for growing storage. +package grow + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/purpleidea/mgmt/util" +) + +const ( + // DevDir is where linux system devices are found. + DevDir = "/dev/" +) + +// Grow is a utility that grows the underlying partition, luks device (if +// encrypted) and then finally the partition. It makes many assumptions about +// the luks device being encrypted with an empty password, and that nobody uses +// lvm anymore. This utility is useful when provisioning new machines which +// don't get their maximum disk utilization by default. (All the Fedora machines +// whether physical or virtual seem to have this problem.) +// +// This whole utility should only be run by the Run entrypoint if you want to +// receive the benefit of the various options, such as Noop and Done. If you +// bypass those then you're on your own. +// +// Patches are welcome to expand the functionality of this software, however it +// was intended as the minimally viable piece of reliable code to solve the +// specific grow problem commonly seen, without adding unused features. +type Grow struct { + // Noop is set to true to prevent any actual grow operation. + Noop bool + + // Done specifies a file path to create once the operations complete + // successfully. If this file exists, these operations won't run. + Done 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{}) +} + +// Run the whole sequence. This is probably the only function you want to run. +func (obj *Grow) Run(ctx context.Context, mount string) (reterr error) { + if obj.Done != "" { + if !strings.HasPrefix(obj.Done, "/") { + return fmt.Errorf("done must be absolute") + } + if strings.HasSuffix(obj.Done, "/") { + return fmt.Errorf("done must be a file") + } + + _, err := os.Stat(obj.Done) + if err != nil && !os.IsNotExist(err) { + return err // permission err + } + if err == nil { // exists! + obj.Logf("done mode") + return nil + } + defer func() { + // Store error in case we have a problem here. + reterr = os.WriteFile(obj.Done, []byte(fmt.Sprintf("%s\n", mount)), 0600) + }() + } + + fsList, err := obj.RunFindMnt(ctx, mount) + if err != nil { + return err + } + + if len(fsList) == 0 { + return fmt.Errorf("no mount found") + } + if len(fsList) != 1 { + return fmt.Errorf("ambiguous mount found, expected one") + } + + if target := fsList[0].Target; target != mount { + return fmt.Errorf("unexpected target: %s", target) + } + + fsType := fsList[0].FsType + + if err := obj.IsValidFsType(fsType); err != nil { + return err + } + + // If we're using LUKS, this will be something like: + // /dev/mapper/luks- but keep in mind that `cryptsetup isLuks` on + // that device will be FALSE because that's the unencrypted + // device-mapper device, not the actual encrypted block device. + source := fsList[0].Source + if source == "" { + return fmt.Errorf("empty source") + } + if source == "/" { + return fmt.Errorf("invalid source") + } + + // Source is probably a device mapper device. Let's resolve it. + // /usr/bin/realpath --relative-to /dev /dev/mapper/luks- + // + // It might just be something like /dev/sda3 which means this is a noop. + if !strings.HasPrefix(source, "/") { + return fmt.Errorf("source is not absolute") + } + dev, err := filepath.EvalSymlinks(source) + if err != nil { + return err + } + + // This should output something like /dev/dm-0 or maybe not if it's not + // using LUKS. + + if !strings.HasPrefix(dev, DevDir) { + return fmt.Errorf("unexpected resolved link of: %s", dev) + } + + // This is now dm-0 if we're using luks/device mapper or maybe sda3. + + luks := "" // assume false + if err := obj.IsDeviceMapper(dev); err == nil { + + // XXX: Is there a better way to resolve device-mapper? I know I + // can cryptsetup status directly on the /dev/mapper/luks- + // but I'd like a more flexible approach where I do it stepwise. + dm := strings.TrimPrefix(dev, DevDir) + s := fmt.Sprintf("/sys/block/%s/slaves/", dm) + dirs, err := os.ReadDir(s) // ([]DirEntry, error) + if err != nil { + return err + } + if l := len(dirs); l != 1 { + return fmt.Errorf("unexpected number of device-mapper slaves: %d", l) + } + + //luks = source // this would work + luks = dev // this feels more accurate + + // This is the underlying device. You could also get this from + // cryptsetup status $dev or $source but since the cryptsetup + // command seems to be a bit sloppy in what it expects, we just + // look up the device mapper stuff in the kernel /sys/ dirs... + dev = DevDir + dirs[0].Name() // eg: /dev/ + nvme0n1p3 + } + + if obj.Debug { + obj.Logf("source: %s", source) + obj.Logf("luks: %s", luks) + obj.Logf("dev: %s", dev) + } + + if obj.Noop { + obj.Logf("noop mode") + } + + if err := obj.GrowPart(ctx, dev); err != nil { + return err + } + + if luks != "" { + if err := obj.GrowLUKS(ctx, luks); err != nil { + return err + } + } + + if err := obj.GrowFs(ctx, source, fsType); err != nil { + return err + } + + return nil +} + +// GrowPart runs the "growpart" command from the "cloud-utils-growpart" package. +func (obj *Grow) GrowPart(ctx context.Context, part string) error { + if part == "" { + return fmt.Errorf("empty part") + } + + base, part, err := obj.GrowPartArgs(part) + if err != nil { + return err + } + + if obj.Debug { + obj.Logf("base: %s", base) + obj.Logf("part: %s", part) + } + + cmd, err := exec.LookPath("growpart") + if err != nil { + return err + } + cmdArgs := []string{base, part} + obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " ")) + if obj.Noop { + return nil + } + return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts()) +} + +// GrowLUKS grows the luks device that has an empty password. +func (obj *Grow) GrowLUKS(ctx context.Context, luks string) error { + if luks == "" { + return fmt.Errorf("empty luks") + } + + // XXX: this hack is necessary because cryptsetup is dumb + empty := "/tmp/empty" + if !obj.Noop { + if err := os.WriteFile(empty, []byte{}, 0600); err != nil { + return err + } + } + + cmd, err := exec.LookPath("cryptsetup") + if err != nil { + return err + } + cmdArgs := []string{"resize", fmt.Sprintf("--key-file=%s", empty), luks} + obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " ")) + if obj.Noop { + return nil + } + return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts()) +} + +// GrowFs expands the filesystem while it's online. +func (obj *Grow) GrowFs(ctx context.Context, dev, fsType string) error { + if dev == "" { + return fmt.Errorf("empty dev") + } + + if fsType == "ext4" { + cmd, err := exec.LookPath("resize2fs") + if err != nil { + return err + } + cmdArgs := []string{dev} + obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " ")) + if obj.Noop { + return nil + } + return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts()) + } + + if fsType == "xfs" { + cmd, err := exec.LookPath("xfs_growfs") + if err != nil { + return err + } + cmdArgs := []string{dev} + obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " ")) + if obj.Noop { + return nil + } + return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts()) + } + + // XXX: btrfs filesystem resize max /path + + return obj.IsValidFsType(fsType) +} + +// GrowPartArgs get the special args needed for the `growpart` command. +func (obj *Grow) GrowPartArgs(dev string) (string, string, error) { + if dev == "" { + return "", "", fmt.Errorf("empty dev") + } + if !strings.HasPrefix(dev, DevDir) { + return "", "", fmt.Errorf("missing prefix for dev in: %s", dev) + } + + // Simple parsing for /dev/sda3 and friends... Walk backwards until we + // encounter a non-number, and if it's a weird nvme thing, remove the p. + i := len(dev) - 1 + for i >= 0 && dev[i] >= '0' && dev[i] <= '9' { // is a number + i-- + } + if i == len(dev)-1 { + return "", "", fmt.Errorf("no partition number found") + } + base := dev[:i+1] + part := dev[i+1:] + + // fancy parsing for /dev/nvme0n1p3 which has the "p". + if strings.HasPrefix(dev, DevDir+"nvme") && dev[i] == 'p' { + base = dev[:i] + } + if base == "" || part == "" { + return "", "", fmt.Errorf("unexpected empty result") + } + + return base, part, nil +} + +// IsDeviceMapper runs a simple check to see if we're something like: +// `/dev/dm-0`. +func (obj *Grow) IsDeviceMapper(name string) error { + if !strings.HasPrefix(name, "/dev/dm-") { + return fmt.Errorf("missing device mapper prefix") + } + s := strings.TrimPrefix(name, "/dev/dm-") + + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("missing device mapper number") + } + + return nil +} + +// IsValidFsType are the types of filesystems we currently know how to grow. +func (obj *Grow) IsValidFsType(fsType string) error { + if fsType == "ext4" || fsType == "xfs" { + return nil + } + // TODO: add btrfs support + //if fsType == "btrfs" { + // return nil + //} + + return fmt.Errorf("can't grow fstype: %s", fsType) +} + +// RunFindMnt runs the linux util `findmnt` command. +func (obj *Grow) RunFindMnt(ctx context.Context, p string) ([]*MountInfo, error) { + cmd, err := exec.LookPath("findmnt") + if err != nil { + return nil, err + } + cmdArgs := []string{"--json", "--all", "--mountpoint", p} + b, err := util.SimpleCmdOut(ctx, cmd, cmdArgs, obj.cmdOpts()) + if err != nil { + return nil, err + } + + var st FindMnt + if err := json.Unmarshal(b, &st); err != nil { + return nil, err + } + + return st.Filesystems, nil +} + +// cmdOpts is just a helper to return the same struct repeatedly. +func (obj *Grow) cmdOpts() *util.SimpleCmdOpts { + logf := func(format string, v ...interface{}) { + if !obj.Debug { // ignore the noisy "always on" log messages... + return + } + obj.Logf(format, v...) + } + return &util.SimpleCmdOpts{ + Debug: obj.Debug, + Logf: logf, + } +} + +// FindMnt is the --json output of the `findmnt` command. +type FindMnt struct { + Filesystems []*MountInfo `json:"filesystems"` +} + +// MountInfo is the type of each entry in the FindMnt Filesystems field. +type MountInfo struct { + Target string `json:"target"` + Source string `json:"source"` + FsType string `json:"fstype"` + Options string `json:"options"` +}