diff --git a/.gitignore b/.gitignore index 3210b7f..d7cbcaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ .golangci-lint/ mango +mh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 96b43d1..6e8c9e0 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,8 @@ version: 2 builds: - - env: + - id: mango + env: - CGO_ENABLED=0 goos: - linux @@ -11,6 +12,17 @@ builds: - -X github.com/tjhop/mango/internal/version.Commit={{ .Commit }} binary: mango main: './cmd/mango' + - id: mh + env: + - CGO_ENABLED=0 + goos: + - linux + ldflags: + - -X github.com/tjhop/mango/internal/version.BuildDate={{ .CommitDate }} + - -X github.com/tjhop/mango/internal/version.Version={{ .Version }} + - -X github.com/tjhop/mango/internal/version.Commit={{ .Commit }} + binary: mh + main: './cmd/mh' gomod: proxy: true mod: mod diff --git a/Dockerfile b/Dockerfile index 07d17b4..7f8d2c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,5 +5,6 @@ FROM cgr.dev/chainguard/busybox:latest COPY --from=certs /etc/ssl/certs /etc/ssl/certs COPY mango /usr/bin/mango +COPY mh /usr/bin/mh ENTRYPOINT ["/usr/bin/mango"] CMD ["--inventory.path", "/opt/mango/inventory"] diff --git a/Dockerfile-testbox-arch b/Dockerfile-testbox-arch index 111d072..72ab440 100644 --- a/Dockerfile-testbox-arch +++ b/Dockerfile-testbox-arch @@ -20,6 +20,7 @@ RUN pacman -Syu --needed --noconfirm && \ # setup mango COPY ./mango /usr/bin/mango +COPY ./mh /usr/bin/mh COPY ./packaging/systemd/mango.service /etc/systemd/system/ COPY ./test/mockup/services/mango/test-flags.conf /etc/systemd/system/mango.service.d/test-flags.conf RUN systemctl enable mango.service diff --git a/Dockerfile-testbox-ubuntu b/Dockerfile-testbox-ubuntu index f449b5d..f9bc33f 100644 --- a/Dockerfile-testbox-ubuntu +++ b/Dockerfile-testbox-ubuntu @@ -21,6 +21,7 @@ RUN apt-get update && \ # setup mango COPY ./mango /usr/bin/mango +COPY ./mh /usr/bin/mh COPY ./packaging/systemd/mango.service /etc/systemd/system/ COPY ./test/mockup/services/mango/test-flags.conf /etc/systemd/system/mango.service.d/test-flags.conf RUN systemctl enable mango.service diff --git a/Makefile b/Makefile index 367e1cd..10335c8 100644 --- a/Makefile +++ b/Makefile @@ -24,12 +24,19 @@ lint: mkdir -p ${GOLANGCILINT_CACHE} || true podman run --rm -v ${CURDIR}:/app -v ${GOLANGCILINT_CACHE}:/root/.cache -w /app docker.io/golangci/golangci-lint:latest golangci-lint run -v -## binary: build a binary -binary: fmt tidy lint - goreleaser build --clean --single-target --snapshot --output . +## build-mango build the `mango` configuration management server +build-mango: fmt tidy lint + goreleaser build --clean --single-target --snapshot --output . --id "mango" -## build: alias for `binary` -build: binary +## build-mh build `mh`, the helper tool for mango +build-mh: fmt tidy lint + goreleaser build --clean --single-target --snapshot --output . --id "mh" + +## build: alias for `build-mango build-mh` +build: build-mango build-mh + +## binary: alias for `build` +binary: build ## container: build container image with binary container: binary @@ -45,7 +52,7 @@ podman: container docker: container ## test-container: build test containers with binary for testing purposes -test-container: binary container +test-container: binary podman image build -t "mango-test-ubuntu" -f Dockerfile-testbox-ubuntu . podman image build -t "mango-test-arch" -f Dockerfile-testbox-arch . diff --git a/README.md b/README.md index b112ff4..6abecce 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ While packages are built for several systems, there are currently no plans to at ## Usage -### Binary Usage +### `mango` +#### Binary Usage ```bash mango --inventory.path /path/to/inventory @@ -61,11 +62,11 @@ https://www.vim.org/iccf/ https://www.iccf.nl/ ``` -### Container Usage +#### Container Usage Since `mango` is intended to be run on the system it is managing and thus requires access to the host system, if you must run `mango` as a container, you may want to use the `--privileged` flag. -``` +```bash # docker works too, but podman is wonderful podman run \ -v /path/to/inventory:/opt/mango/inventory \ @@ -73,6 +74,89 @@ podman run \ ghcr.io/tjhop/mango ``` +### `mango-helper` +`mango-helper` is a helper utility that ships with `mango` to make it easier to interact with various aspects of `mango`: + +```bash +mh -h +Mango Helper is a utility tool to aid in working with mango + +Usage: + mh [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + help Help about any command + inventory Command to interact with mango inventory + mango Command to interact with a running mango server + +Flags: + -h, --help help for mh + -l, --logging.level string Logging level may be one of: [debug, info, warning, error] (default "info") + --logging.output string Logging format may be one of: [logfmt, json] (default "logfmt") + -v, --version version for mh + +Use "mh [command] --help" for more information about a command. +``` + +The `mh inventory` command has several subcommands available to assist in working with the mango inventory: + +```bash + +mh inventory -h +Command to interact with the mango inventory, such as initializing skeleton inventory directory structures + +Usage: + mh inventory [command] + +Aliases: + inventory, inv + +Available Commands: + directive Command to interact with mango directives in the inventory + group Command to interact with mango groups in the inventory + host Command to interact with mango hosts in the inventory + init Create an empty inventory + module Command to interact with mango modules in the inventory + role Command to interact with mango roles in the inventory + +Flags: + --enrolled-only Only return modules that the provided host is enrolled for + -h, --help help for inventory + --hostname string (Requires root) Custom hostname to use [default is system hostname] + -i, --inventory.path string Path to mango configuration inventory + +Global Flags: + -l, --logging.level string Logging level may be one of: [debug, info, warning, error] (default "info") + --logging.output string Logging format may be one of: [logfmt, json] (default "logfmt") + +Use "mh inventory [command] --help" for more information about a command. +``` + +The `mh mango` command has further subcommands available to interact with a running mango server: + +```bash +mh mango -h +Command to interact with a running mango server, including interacting with pprofs, metrics, etc + +Usage: + mh mango [command] + +Available Commands: + metrics Command to simplify metrics interactions for mango + pprof Command to simplify pprof interactions for mango + +Flags: + --address string Address of the running mango server (default "127.0.0.1:9555") + -h, --help help for mango + +Global Flags: + -l, --logging.level string Logging level may be one of: [debug, info, warning, error] (default "info") + --logging.output string Logging format may be one of: [logfmt, json] (default "logfmt") + +Use "mh mango [command] --help" for more information about a command. +``` + ## Configuration Management `Mango` is intended to be run as a daemon on the system that it will be managing. @@ -81,21 +165,22 @@ ghcr.io/tjhop/mango ### Inventory `Mango`'s inventory is based on [aviary.sh's](https://github.com/frameable/aviary.sh) inventory. -Initially, `mango` will be an aviary.sh-compatible daemon, with configurations written as scripts/executables. +A detailed explanation of the differences between `mango` and `aviary.sh`, as well as a detailed explanation of each component/file in the mango inventory, can be found below. #### Inventory Setup -Please see [aviary.sh's documentation on inventory setup](https://github.com/frameable/aviary.sh#inventory-setup) for more information. +An inventory can be created using the companion `mango-helper` tool that ships with mango releases/builds: -``` +```bash mkdir inventory cd inventory -mkdir {groups,hosts,modules,roles,directives} -touch {groups,hosts,modules,roles,directives}/.gitkeep +mh inventory init git init git add . git commit -m "initial commit" ``` +The `mh inventory` command also has other utility functions to make working with the inventory easier. See [mango-helper](#mango-helper) for more details. + *NOTE*: While `aviary.sh`'s inventory system is designed to work with bash scripts, it's possible to write a module in any language. Mango treats module test scripts as optional (yet recommended), and module/host variables are diff --git a/cmd/mh/directive.go b/cmd/mh/directive.go new file mode 100644 index 0000000..eaf1cee --- /dev/null +++ b/cmd/mh/directive.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "log/slog" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + dirCmd = &cobra.Command{ + Use: "directive", + Aliases: []string{"directives"}, + Short: "Command to interact with mango directives in the inventory", + Long: "Command to interact with mango directive, including adding, deleting, etc", + } + + dirAddCmd = &cobra.Command{ + Use: "add", + Aliases: addCmdAliases, + Short: "Create an empty directive with the provided name", + Long: "Command to add a new directive by adding a new directory with the given" + + " name and creating empty directive files to bootstrap", + Args: cobra.ExactArgs(1), + Run: directiveAdd, + } + + dirDeleteCmd = &cobra.Command{ + Use: "delete", + Aliases: delCmdAliases, + Short: "Delete the directive with the provided name", + Long: "Command to delete a directive by recursively removing it from the inventory", + Args: cobra.ExactArgs(1), + Run: directiveDelete, + } + + dirListCmd = &cobra.Command{ + Use: "list", + Aliases: listCmdAliases, + Short: "List directives in the inventory", + Long: "List directives in the inventory", + Args: cobra.ExactArgs(0), + Run: directiveList, + } +) + +func init() { + inventoryCmd.AddCommand(dirCmd) + dirCmd.AddCommand(dirAddCmd) + dirCmd.AddCommand(dirDeleteCmd) + dirCmd.AddCommand(dirListCmd) +} + +func directiveAdd(cmd *cobra.Command, args []string) { + dirName := args[0] + logger := slog.Default().With("component", "directive", "directive", dirName) + + dirDir := filepath.Join(viper.GetString("inventory.path"), "directives") + dirPath := filepath.Join(dirDir, dirName) + + if err := inventoryAddFile(dirPath); err != nil { + logger.Warn("Error creating directive file", "err", err, "file", dirPath) + } else { + logger.Debug("Created directive file", "file", dirPath) + } +} + +func directiveDelete(cmd *cobra.Command, args []string) { + dirName := args[0] + logger := slog.Default().With("component", "directive", "directive", dirName) + + dirDir := filepath.Join(viper.GetString("inventory.path"), "directives") + dirPath := filepath.Join(dirDir, dirName) + + if err := inventoryRemoveAll(dirPath); err != nil { + logger.Warn("Error deleting directive", "err", err) + } +} + +func directiveList(cmd *cobra.Command, args []string) { + inv := loadInventory() + for _, d := range inv.GetDirectives() { + fmt.Println(d.String()) + } +} diff --git a/cmd/mh/group.go b/cmd/mh/group.go new file mode 100644 index 0000000..a253eca --- /dev/null +++ b/cmd/mh/group.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "log/slog" + "path/filepath" + + "github.com/tjhop/mango/internal/inventory" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + groupCmd = &cobra.Command{ + Use: "group", + Aliases: []string{"groups"}, + Short: "Command to interact with mango groups in the inventory", + Long: "Command to interact with mango group, including adding, deleting, etc", + } + + groupAddCmd = &cobra.Command{ + Use: "add", + Aliases: addCmdAliases, + Short: "Create an empty group with the provided name", + Long: "Command to add a new group by adding a new directory with the given" + + " name and creating empty group files to bootstrap", + Args: cobra.ExactArgs(1), + Run: groupAdd, + } + + groupDeleteCmd = &cobra.Command{ + Use: "delete", + Aliases: delCmdAliases, + Short: "Delete the group with the provided name", + Long: "Command to delete a group by recursively removing it from the inventory", + Args: cobra.ExactArgs(1), + Run: groupDelete, + } + + groupListCmd = &cobra.Command{ + Use: "list", + Aliases: listCmdAliases, + Short: "List groups in the inventory", + Long: "List groups in the inventory", + Args: cobra.ExactArgs(0), + Run: groupList, + } +) + +func init() { + inventoryCmd.AddCommand(groupCmd) + groupCmd.AddCommand(groupAddCmd) + groupCmd.AddCommand(groupDeleteCmd) + groupCmd.AddCommand(groupListCmd) +} + +func groupAdd(cmd *cobra.Command, args []string) { + groupName := args[0] + logger := slog.Default().With("component", "group", "group", groupName) + + groupDir := filepath.Join(viper.GetString("inventory.path"), "groups") + groupPath := filepath.Join(groupDir, groupName) + + if err := inventoryAddDir(groupPath); err != nil { + logger.Warn("Error initializing group", "err", err) + } + + for _, gFile := range inventory.ValidGroupFiles { + file := filepath.Join(groupPath, gFile) + if err := inventoryAddFile(file); err != nil { + logger.Warn("Error creating group file", "err", err, "file", file) + } else { + logger.Debug("Created group file", "file", file) + } + } + + for _, gDir := range inventory.ValidGroupDirs { + dir := filepath.Join(groupPath, gDir) + if err := inventoryAddDir(dir); err != nil { + logger.Warn("Error initializing group", "err", err, "dir", dir) + } else { + logger.Debug("Created group directory", "dir", dir) + } + } +} + +func groupDelete(cmd *cobra.Command, args []string) { + groupName := args[0] + logger := slog.Default().With("component", "group", "group", groupName) + + groupDir := filepath.Join(viper.GetString("inventory.path"), "groups") + groupPath := filepath.Join(groupDir, groupName) + + if err := inventoryRemoveAll(groupPath); err != nil { + logger.Warn("Error deleting group", "err", err) + } +} + +func groupList(cmd *cobra.Command, args []string) { + var groups []inventory.Group + inv := loadInventory() + + if viper.GetBool("enrolled-only") && inv.IsEnrolled() { + groups = inv.GetGroupsForSelf() + } else { + groups = inv.GetGroups() + } + + for _, g := range groups { + fmt.Println(g.String()) + } +} diff --git a/cmd/mh/host.go b/cmd/mh/host.go new file mode 100644 index 0000000..4c65183 --- /dev/null +++ b/cmd/mh/host.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "log/slog" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/tjhop/mango/internal/inventory" +) + +var ( + hostCmd = &cobra.Command{ + Use: "host", + Aliases: []string{"hosts"}, + Short: "Command to interact with mango hosts in the inventory", + Long: "Command to interact with mango host, including adding, deleting, etc", + } + + hostAddCmd = &cobra.Command{ + Use: "add", + Aliases: addCmdAliases, + Short: "Create an empty host with the provided name", + Long: "Command to add a new host by adding a new directory with the given" + + " name and creating empty host files to bootstrap", + Args: cobra.ExactArgs(1), + Run: hostAdd, + } + + hostDeleteCmd = &cobra.Command{ + Use: "delete", + Aliases: delCmdAliases, + Short: "Delete the host with the provided name", + Long: "Command to delete a host by recursively removing it from the inventory", + Args: cobra.ExactArgs(1), + Run: hostDelete, + } + + hostListCmd = &cobra.Command{ + Use: "list", + Aliases: listCmdAliases, + Short: "List hosts in the inventory", + Long: "List hosts in the inventory", + Args: cobra.ExactArgs(0), + Run: hostList, + } +) + +func init() { + inventoryCmd.AddCommand(hostCmd) + hostCmd.AddCommand(hostAddCmd) + hostCmd.AddCommand(hostDeleteCmd) + hostCmd.AddCommand(hostListCmd) +} + +func hostAdd(cmd *cobra.Command, args []string) { + hostName := args[0] + logger := slog.Default().With("component", "host", "host", hostName) + + hostDir := filepath.Join(viper.GetString("inventory.path"), "hosts") + hostPath := filepath.Join(hostDir, hostName) + + if err := inventoryAddDir(hostPath); err != nil { + logger.Warn("Error initializing host", "err", err) + } + + for _, hFile := range inventory.ValidHostFiles { + file := filepath.Join(hostPath, hFile) + if err := inventoryAddFile(file); err != nil { + logger.Warn("Error creating host file", "err", err, "file", file) + } else { + logger.Debug("Created host file", "file", file) + } + } + + for _, hDir := range inventory.ValidHostDirs { + dir := filepath.Join(hostPath, hDir) + if err := inventoryAddDir(dir); err != nil { + logger.Warn("Error initializing host", "err", err, "dir", dir) + } else { + logger.Debug("Created host directory", "dir", dir) + } + } +} + +func hostDelete(cmd *cobra.Command, args []string) { + hostName := args[0] + logger := slog.Default().With("component", "host", "host", hostName) + + hostDir := filepath.Join(viper.GetString("inventory.path"), "hosts") + hostPath := filepath.Join(hostDir, hostName) + + if err := inventoryRemoveAll(hostPath); err != nil { + logger.Warn("Error deleting host", "err", err) + } +} + +func hostList(cmd *cobra.Command, args []string) { + var hosts []inventory.Host + inv := loadInventory() + + if viper.GetBool("enrolled-only") && inv.IsEnrolled() { + host, _ := inv.GetHost(inv.GetHostname()) + hosts = append(hosts, host) + } else { + hosts = inv.GetHosts() + } + + for _, h := range hosts { + fmt.Println(h.String()) + } +} diff --git a/cmd/mh/inventory.go b/cmd/mh/inventory.go new file mode 100644 index 0000000..4da3347 --- /dev/null +++ b/cmd/mh/inventory.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/tjhop/mango/internal/inventory" +) + +var ( + inventoryDirectories = []string{"groups", "hosts", "modules", "roles", "directives"} + + inventoryCmd = &cobra.Command{ + Use: "inventory", + Aliases: []string{"inv"}, + Short: "Command to interact with mango inventory", + Long: "Command to interact with the mango inventory, such as initializing skeleton inventory directory structures", + } + + invInitCmd = &cobra.Command{ + Use: "init", + Aliases: []string{"create", "new"}, + Short: "Create an empty inventory", + Long: "Command to initialize a skeleton directory structure for use with mango inventory", + Args: cobra.ExactArgs(1), + Run: inventoryInit, + } +) + +func loadInventory() *inventory.Inventory { + logger := slog.Default().With("component", "inventory") + inventoryPath := viper.GetString("inventory.path") + hostname := viper.GetString("hostname") + + inv := inventory.NewInventory(inventoryPath, hostname) + logger.Debug("Created new inventory", "inventory_path", inventoryPath, "hostname", hostname) + inv.Reload(context.Background(), logger) + return inv +} + +func init() { + inventoryCmdFlagSet := inventoryCmd.PersistentFlags() + inventoryCmdFlagSet.StringP("inventory.path", "i", "", "Path to mango configuration inventory") + inventoryCmdFlagSet.String("hostname", "", "(Requires root) Custom hostname to use [default is system hostname]") + if err := viper.BindPFlags(inventoryCmdFlagSet); err != nil { + panic(fmt.Errorf("Error binding flags for command <%s>: %s", "inventory", err)) + } + rootCmd.AddCommand(inventoryCmd) + + inventoryCmd.AddCommand(invInitCmd) +} + +func inventoryInit(cmd *cobra.Command, args []string) { + logger := slog.Default().With("component", "inventory") + + // attempt to make all directories and place a `.gitkeep` file inside them + inventoryPath := viper.GetString("inventory.path") + for _, inventoryDir := range inventoryDirectories { + dir := filepath.Join(inventoryPath, inventoryDir) + if err := inventoryAddDir(dir); err != nil { + logger.Warn("Error initializing inventory", "err", err) + } else { + logger.Debug("Created inventory directory", "dir", dir) + } + } +} diff --git a/cmd/mh/main.go b/cmd/mh/main.go new file mode 100644 index 0000000..94c12fa --- /dev/null +++ b/cmd/mh/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/tjhop/mango/internal/version" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + logLevel = &slog.LevelVar{} + rootCmd = &cobra.Command{ + Use: "mh", + Short: "Mango Helper -- Tool to work with Mango", + Long: "Mango Helper is a utility tool to aid in working with mango", + Version: version.Print(os.Args[0]), + } +) + +func init() { + rootCmdFlagSet := rootCmd.PersistentFlags() + rootCmdFlagSet.StringP("logging.level", "l", "info", "Logging level may be one of: [debug, info, warning, error]") + rootCmdFlagSet.String("logging.output", "logfmt", "Logging format may be one of: [logfmt, json]") + if err := viper.BindPFlags(rootCmdFlagSet); err != nil { + panic(fmt.Errorf("Error binding flags for command <%s>: %s", "mh", err)) + } + + logHandlerOpts := &slog.HandlerOptions{ + Level: logLevel, + AddSource: true, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + key := a.Key + switch key { + case slog.SourceKey: + src, _ := a.Value.Any().(*slog.Source) + a.Value = slog.StringValue(filepath.Base(src.File) + ":" + strconv.Itoa(src.Line)) + default: + } + + return a + }, + } + + // parse log output format from flag, create root logger with default configs + var logger *slog.Logger + logOutputFormat := strings.ToLower(viper.GetString("logging.output")) + if logOutputFormat == "json" { + logger = slog.New(slog.NewJSONHandler(os.Stdout, logHandlerOpts)) + } else { + logger = slog.New(slog.NewTextHandler(os.Stdout, logHandlerOpts)) + } + + // parse log level from flag + logLevelFlagVal := strings.ToLower(viper.GetString("logging.level")) + switch logLevelFlagVal { + case "": + logLevel.Set(slog.LevelInfo) + logger.Warn("Log level flag not set, defaulting to <info> level") + case "info": // default is info, we're good + case "warn": + logLevel.Set(slog.LevelWarn) + case "debug": + logLevel.Set(slog.LevelDebug) + case "error": + logLevel.Set(slog.LevelError) + default: + logLevel.Set(slog.LevelInfo) + logger.Warn("Failed to parse log level from flag, defaulting to <info> level", + slog.String("err", "Unsupported log level"), + slog.String("log_level", logLevelFlagVal), + ) + } +} + +func main() { + if err := rootCmd.Execute(); err != nil { + slog.Error("Error running root cobra command", "err", err) + os.Exit(1) + } +} diff --git a/cmd/mh/mango.go b/cmd/mh/mango.go new file mode 100644 index 0000000..f27e5a6 --- /dev/null +++ b/cmd/mh/mango.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + defaultMangoAddr = "127.0.0.1:9555" + + mangoCmd = &cobra.Command{ + Use: "mango", + Short: "Command to interact with a running mango server", + Long: "Command to interact with a running mango server, including interacting with pprofs, metrics, etc", + } +) + +func init() { + mangoCmdFlagSet := mangoCmd.PersistentFlags() + mangoCmdFlagSet.String("address", defaultMangoAddr, "Address of the running mango server") + if err := viper.BindPFlags(mangoCmdFlagSet); err != nil { + panic(fmt.Errorf("Error binding flags for command <%s>: %s", "mango", err)) + } + rootCmd.AddCommand(mangoCmd) +} diff --git a/cmd/mh/module.go b/cmd/mh/module.go new file mode 100644 index 0000000..1c67de3 --- /dev/null +++ b/cmd/mh/module.go @@ -0,0 +1,118 @@ +package main + +import ( + "fmt" + "log/slog" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/tjhop/mango/internal/inventory" +) + +var ( + moduleCmd = &cobra.Command{ + Use: "module", + Aliases: []string{"mod", "modules"}, + Short: "Command to interact with mango modules in the inventory", + Long: "Command to interact with mango module, including adding, deleting, etc", + } + + modAddCmd = &cobra.Command{ + Use: "add", + Aliases: addCmdAliases, + Short: "Create an empty module with the provided name", + Long: "Command to add a new module by adding a new directory with the given" + + " name and creating empty module files to bootstrap", + Args: cobra.ExactArgs(1), + Run: moduleAdd, + } + + modDeleteCmd = &cobra.Command{ + Use: "delete", + Aliases: delCmdAliases, + Short: "Delete the module with the provided name", + Long: "Command to delete a module by recursively removing it from the inventory", + Args: cobra.ExactArgs(1), + Run: moduleDelete, + } + + modListCmd = &cobra.Command{ + Use: "list", + Aliases: listCmdAliases, + Short: "List modules in the inventory", + Long: "Command to list modules in the inventory", + Args: cobra.ExactArgs(0), + Run: moduleList, + } +) + +func init() { + inventoryCmd.AddCommand(moduleCmd) + moduleCmd.AddCommand(modAddCmd) + moduleCmd.AddCommand(modDeleteCmd) + + modListCmdFlagSet := inventoryCmd.PersistentFlags() + modListCmdFlagSet.Bool("enrolled-only", false, "Only return modules that the provided host is enrolled for") + if err := viper.BindPFlags(modListCmdFlagSet); err != nil { + panic(fmt.Errorf("Error binding flags for command <%s>: %s", "inventory", err)) + } + moduleCmd.AddCommand(modListCmd) +} + +func moduleAdd(cmd *cobra.Command, args []string) { + modName := args[0] + logger := slog.Default().With("component", "module", "module", modName) + + modDir := filepath.Join(viper.GetString("inventory.path"), "modules") + modPath := filepath.Join(modDir, modName) + + if err := inventoryAddDir(modPath); err != nil { + logger.Warn("Error initializing module", "err", err) + } + + for _, mFile := range inventory.ValidModuleFiles { + file := filepath.Join(modPath, mFile) + if err := inventoryAddFile(file); err != nil { + logger.Warn("Error creating module file", "err", err, "file", file) + } else { + logger.Debug("Created module file", "file", file) + } + } + + for _, mDir := range inventory.ValidModuleDirs { + dir := filepath.Join(modPath, mDir) + if err := inventoryAddDir(dir); err != nil { + logger.Warn("Error initializing module", "err", err, "dir", dir) + } else { + logger.Debug("Created module directory", "dir", dir) + } + } +} + +func moduleDelete(cmd *cobra.Command, args []string) { + modName := args[0] + logger := slog.Default().With("component", "module", "module", modName) + + modDir := filepath.Join(viper.GetString("inventory.path"), "modules") + modPath := filepath.Join(modDir, modName) + + if err := inventoryRemoveAll(modPath); err != nil { + logger.Warn("Error deleting module", "err", err) + } +} + +func moduleList(cmd *cobra.Command, args []string) { + var modules []inventory.Module + inv := loadInventory() + + if viper.GetBool("enrolled-only") && inv.IsEnrolled() { + modules = inv.GetModulesForSelf() + } else { + modules = inv.GetModules() + } + + for _, mod := range modules { + fmt.Println(mod.String()) + } +} diff --git a/cmd/mh/pprof.go b/cmd/mh/pprof.go new file mode 100644 index 0000000..9390604 --- /dev/null +++ b/cmd/mh/pprof.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "log/slog" + "path" + "strconv" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + pprofProfiles = []string{"allocs", "block", "cmdline", "goroutine", "heap", "mutex", "profile", "threadcreate", "trace"} + + pprofCmd = &cobra.Command{ + Use: "pprof", + Short: "Command to simplify pprof interactions for mango", + Long: "Command to interact with pprof for mango, including collecting profiles and opening them via `pprof`", + } + + pprofListProfilesCmd = &cobra.Command{ + Use: "list-profiles", + Aliases: []string{"list", "profiles", "all"}, + Short: "Command to list all available pprof profiles from the given mango server", + Long: "Command to list all available pprof profiles from the given mango server", + Args: cobra.ExactArgs(0), + Run: pprofListProfiles, + } + + pprofGetProfileCmd = &cobra.Command{ + Use: "get", + Aliases: []string{"collect"}, + Short: "Command to get the specified profile from the given mango server", + Long: "Command to get the specified profile from the given mango server. Flags are available to help control pprof params for things like profile durations.", + Args: cobra.ExactArgs(1), + Run: pprofGetProfile, + } +) + +func init() { + mangoCmd.AddCommand(pprofCmd) + + pprofCmd.AddCommand(pprofListProfilesCmd) + + pprofGetProfileCmdFlagSet := pprofGetProfileCmd.Flags() + pprofGetProfileCmdFlagSet.Int("debug", 0, "Corresponds to debug query param - https://pkg.go.dev/net/http/pprof#hdr-Parameters") + pprofGetProfileCmdFlagSet.Int("gc", 0, "Corresponds to gc query param - https://pkg.go.dev/net/http/pprof#hdr-Parameters") + pprofGetProfileCmdFlagSet.Int("seconds", 0, "Corresponds to seconds query param - https://pkg.go.dev/net/http/pprof#hdr-Parameters") + if err := viper.BindPFlags(pprofGetProfileCmdFlagSet); err != nil { + panic(fmt.Errorf("Error binding flags for command <%s>: %s", "pprof get", err)) + } + pprofCmd.AddCommand(pprofGetProfileCmd) +} + +func pprofListProfiles(cmd *cobra.Command, args []string) { + for _, p := range pprofProfiles { + fmt.Println(p) + } +} + +func pprofGetProfile(cmd *cobra.Command, args []string) { + addr := viper.GetString("address") + profile := args[0] + pprofProfilePath := path.Join("debug/pprof", profile) + + params := []urlParam{} + debug := viper.GetInt("debug") + if debug > 0 { + params = append(params, urlParam{key: "debug", value: strconv.Itoa(debug)}) + } + gc := viper.GetInt("gc") + if gc > 0 { + params = append(params, urlParam{key: "gc", value: strconv.Itoa(gc)}) + } + seconds := viper.GetInt("seconds") + if seconds > 0 { + params = append(params, urlParam{key: "seconds", value: strconv.Itoa(seconds)}) + } + + body, err := httpGetBody(addr, pprofProfilePath, params) + if err != nil { + slog.Error("Error getting body for pprof profile", "err", err, "profile", profile) + } + + fmt.Printf("%s", body) +} diff --git a/cmd/mh/role.go b/cmd/mh/role.go new file mode 100644 index 0000000..5a422e9 --- /dev/null +++ b/cmd/mh/role.go @@ -0,0 +1,106 @@ +package main + +import ( + "fmt" + "log/slog" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + roleFiles = []string{"modules", "variables"} + roleDirs = []string{"templates"} + + roleCmd = &cobra.Command{ + Use: "role", + Aliases: []string{"roles"}, + Short: "Command to interact with mango roles in the inventory", + Long: "Command to interact with mango role, including adding, deleting, etc", + } + + roleAddCmd = &cobra.Command{ + Use: "add", + Aliases: addCmdAliases, + Short: "Create an empty role with the provided name", + Long: "Command to add a new role by adding a new directory with the given" + + " name and creating empty role files to bootstrap", + Args: cobra.ExactArgs(1), + Run: roleAdd, + } + + roleDeleteCmd = &cobra.Command{ + Use: "delete", + Aliases: delCmdAliases, + Short: "Delete the role with the provided name", + Long: "Command to delete a role by recursively removing it from the inventory", + Args: cobra.ExactArgs(1), + Run: roleDelete, + } + + roleListCmd = &cobra.Command{ + Use: "list", + Aliases: listCmdAliases, + Short: "List roles in the inventory", + Long: "List roles in the inventory", + Args: cobra.ExactArgs(0), + Run: roleList, + } +) + +func init() { + inventoryCmd.AddCommand(roleCmd) + roleCmd.AddCommand(roleAddCmd) + roleCmd.AddCommand(roleDeleteCmd) + roleCmd.AddCommand(roleListCmd) +} + +func roleAdd(cmd *cobra.Command, args []string) { + roleName := args[0] + logger := slog.Default().With("component", "role", "role", roleName) + + roleDir := filepath.Join(viper.GetString("inventory.path"), "roles") + rolePath := filepath.Join(roleDir, roleName) + + if err := inventoryAddDir(rolePath); err != nil { + logger.Warn("Error initializing role", "err", err) + } + + for _, rFile := range roleFiles { + file := filepath.Join(rolePath, rFile) + if err := inventoryAddFile(file); err != nil { + logger.Warn("Error creating role file", "err", err, "file", file) + } else { + logger.Debug("Created role file", "file", file) + } + } + + for _, rDir := range roleDirs { + dir := filepath.Join(rolePath, rDir) + if err := inventoryAddDir(dir); err != nil { + logger.Warn("Error initializing role", "err", err, "dir", dir) + } else { + logger.Debug("Created role directory", "dir", dir) + } + } +} + +func roleDelete(cmd *cobra.Command, args []string) { + roleName := args[0] + logger := slog.Default().With("component", "role", "role", roleName) + + roleDir := filepath.Join(viper.GetString("inventory.path"), "roles") + rolePath := filepath.Join(roleDir, roleName) + + if err := inventoryRemoveAll(rolePath); err != nil { + logger.Warn("Error deleting role", "err", err) + } +} + +func roleList(cmd *cobra.Command, args []string) { + inv := loadInventory() + for _, g := range inv.GetRoles() { + fmt.Println(g.String()) + } +} diff --git a/cmd/mh/util.go b/cmd/mh/util.go new file mode 100644 index 0000000..6eb04bf --- /dev/null +++ b/cmd/mh/util.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +var ( + addCmdAliases = []string{"create", "init", "new"} + delCmdAliases = []string{"remove", "rm", "del"} + listCmdAliases = []string{"show", "print", "ls"} +) + +func inventoryAddFile(name string) error { + file, err := os.OpenFile(name, os.O_RDONLY|os.O_CREATE, 0644) + if err != nil { + return fmt.Errorf("Error opening file <%s>: %s", name, err) + } + return file.Close() +} + +func inventoryAddDir(name string) error { + if err := os.MkdirAll(name, 0755); err != nil { + return fmt.Errorf("Error making directory <%s>: %s", name, err) + } + + if err := inventoryAddFile(filepath.Join(name, ".gitkeep")); err != nil { + return fmt.Errorf("Error adding directory file <%s>: %s", name, err) + } + + return nil +} + +func inventoryRemoveAll(name string) error { + err := os.RemoveAll(name) + if err != nil { + return fmt.Errorf("Error recursively removing <%s>: %s", name, err) + } + + return nil +} + +type urlParam struct { + key string + value string +} + +func httpGetBody(addr string, path string, urlParams []urlParam) (string, error) { + if !strings.HasPrefix(addr, "http") { + addr = "http://" + addr + } + + pprofUrl, err := url.Parse(addr) + if err != nil { + return "", fmt.Errorf("Error parsing url <%s>: %s", addr, err) + } + + if pprofUrl.Scheme == "" { + pprofUrl.Scheme = "http" + } + + pprofUrl.Path += path + + params := url.Values{} + for _, p := range urlParams { + params.Add(p.key, p.value) + } + pprofUrl.RawQuery = params.Encode() + + // fmt.Printf("TJ DEBUG | assembled url is: %s\n", pprofUrl.String()) + res, err := http.Get(pprofUrl.String()) + if err != nil { + return "", fmt.Errorf("Error making HTTP Get request to <%s>: %s", addr, err) + } + + body, err := io.ReadAll(res.Body) + res.Body.Close() + + if res.StatusCode != http.StatusOK { + return string(body), fmt.Errorf("HTTP response failed (status code: %d)", res.StatusCode) + } + if err != nil { + return "", fmt.Errorf("Error reading HTTP response body: %s", err) + } + + return string(body), nil +} diff --git a/docker-compose-test-mango.yaml b/docker-compose-test-mango.yaml index 3cf171f..95cc25a 100644 --- a/docker-compose-test-mango.yaml +++ b/docker-compose-test-mango.yaml @@ -27,6 +27,7 @@ services: - mango volumes: - ./mango:/usr/bin/mango + - ./mh:/usr/bin/mh - ./test/mockup/inventory:/opt/mango/inventory/:ro mango-archlinux: @@ -39,4 +40,4 @@ services: - mango volumes: - ./mango:/usr/bin/mango - - ./test/mockup/inventory:/opt/mango/inventory/:ro + - ./mh:/usr/bin/mh diff --git a/go.mod b/go.mod index f40f034..2aa0979 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/prometheus/client_golang v1.20.2 github.com/prometheus/procfs v0.15.1 github.com/quay/claircore v1.5.29 + github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 mvdan.cc/sh/v3 v3.9.0 @@ -30,6 +31,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index b4c7ec3..c461b55 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -43,6 +44,8 @@ github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEy github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= @@ -119,6 +122,7 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -131,6 +135,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= diff --git a/internal/inventory/group.go b/internal/inventory/group.go index eadfee4..dbd8f5a 100644 --- a/internal/inventory/group.go +++ b/internal/inventory/group.go @@ -13,6 +13,11 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +var ( + ValidGroupFiles = []string{"glob", "regex", "roles", "modules", "variables"} + ValidGroupDirs = []string{"templates"} +) + // Group contains fields that represent a given group of hosts in the inventory. // - id: string idenitfying the group // - globs: a slice of glob patterns to match against the instance's hostname diff --git a/internal/inventory/host.go b/internal/inventory/host.go index fd9d65f..c58518d 100644 --- a/internal/inventory/host.go +++ b/internal/inventory/host.go @@ -11,6 +11,11 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +var ( + ValidHostFiles = []string{"modules", "roles", "variables"} + ValidHostDirs = []string{"templates"} +) + // Host contains fields that represent a given host in the inventory. // - id: string idenitfying the host (generally the hostname of the system) // - roles: a slice of roles that are applied to this host diff --git a/internal/inventory/module.go b/internal/inventory/module.go index adc7141..c280e67 100644 --- a/internal/inventory/module.go +++ b/internal/inventory/module.go @@ -11,6 +11,11 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +var ( + ValidModuleFiles = []string{"apply", "test", "variables", "requires"} + ValidModuleDirs = []string{"templates"} +) + // Module contains fields that represent a single module in the inventory. // - ID: string idenitfying the module (generally the file path to the module) // - Apply: path to apply script for the module