You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
backy/pkg/backy/backy.go

490 lines
14 KiB

// backy.go
// Copyright (C) Andrew Woodlee 2023
// License: Apache-2.0
package backy
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"time"
"git.andrewnw.xyz/CyberShell/backy/pkg/logging"
"github.com/rs/zerolog"
"github.com/spf13/viper"
"golang.org/x/crypto/ssh"
"gopkg.in/natefinch/lumberjack.v2"
)
var requiredKeys = []string{"commands", "cmd-configs"}
var Sprintf = fmt.Sprintf
type BackyOptionFunc func(*BackyConfigOpts)
func (c *BackyConfigOpts) LogLvl(level string) BackyOptionFunc {
return func(bco *BackyConfigOpts) {
c.BackyLogLvl = &level
}
}
func (c *BackyConfigOpts) GetConfig() {
c.ConfigFile = ReadAndParseConfigFile(c.ConfigFilePath)
}
func NewOpts(configFilePath string, opts ...BackyOptionFunc) *BackyConfigOpts {
b := &BackyConfigOpts{}
b.ConfigFilePath = configFilePath
for _, opt := range opts {
opt(b)
}
return b
}
/*
NewConfig initializes new config that holds information from the config file
*/
func NewConfig() *BackyConfigFile {
return &BackyConfigFile{
Cmds: make(map[string]Command),
CmdConfigLists: make(map[string]*CmdConfig),
Hosts: make(map[string]Host),
Notifications: make(map[string]*NotificationsConfig),
}
}
type environmentVars struct {
file string
env []string
}
/*
* Runs a backup configuration
*/
func (command *Command) RunCmd(log *zerolog.Logger) {
var envVars = environmentVars{
file: command.Env,
env: command.Environment,
}
envVars.env = append(envVars.env, os.Environ()...)
var cmdArgsStr string
for _, v := range command.CmdArgs {
cmdArgsStr += fmt.Sprintf(" %s", v)
}
var hostStr string
if command.Host != nil {
hostStr = *command.Host
}
log.Info().Str("Command", fmt.Sprintf("Running command: %s %s on host %s", command.Cmd, cmdArgsStr, hostStr)).Send()
if command.Host != nil {
command.RemoteHost.Host = *command.Host
command.RemoteHost.Port = 22
sshc, err := command.RemoteHost.ConnectToSSHHost(log)
if err != nil {
log.Err(fmt.Errorf("ssh dial: %w", err)).Send()
}
defer sshc.Close()
commandSession, err := sshc.NewSession()
if err != nil {
log.Err(fmt.Errorf("new ssh session: %w", err)).Send()
}
defer commandSession.Close()
injectEnvIntoSSH(envVars, commandSession, log)
cmd := command.Cmd
for _, a := range command.CmdArgs {
cmd += " " + a
}
var stdoutBuf, stderrBuf bytes.Buffer
commandSession.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
commandSession.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
err = commandSession.Run(cmd)
log.Info().Bytes(fmt.Sprintf("%s stdout", command.Cmd), stdoutBuf.Bytes()).Send()
log.Info().Bytes(fmt.Sprintf("%s stderr", command.Cmd), stderrBuf.Bytes()).Send()
if err != nil {
log.Error().Err(fmt.Errorf("error when running cmd: %s: %w", command.Cmd, err)).Send()
}
} else {
// shell := "/bin/bash"
var err error
if command.Shell != "" {
cmdArgsStr = fmt.Sprintf("%s %s", command.Cmd, cmdArgsStr)
localCMD := exec.Command(command.Shell, "-c", cmdArgsStr)
if command.Dir != nil {
localCMD.Dir = *command.Dir
}
var stdoutBuf, stderrBuf bytes.Buffer
localCMD.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
localCMD.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
injectEnvIntoLocalCMD(envVars, localCMD, log)
err = localCMD.Run()
log.Info().Bytes(fmt.Sprintf("%s stdout", command.Cmd), stdoutBuf.Bytes()).Send()
log.Info().Bytes(fmt.Sprintf("%s stderr", command.Cmd), stderrBuf.Bytes()).Send()
if err != nil {
log.Error().Err(fmt.Errorf("error when running cmd: %s: %w", command.Cmd, err)).Send()
}
return
}
localCMD := exec.Command(command.Cmd, command.CmdArgs...)
if command.Dir != nil {
localCMD.Dir = *command.Dir
}
var stdoutBuf, stderrBuf bytes.Buffer
localCMD.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
localCMD.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
injectEnvIntoLocalCMD(envVars, localCMD, log)
err = localCMD.Run()
log.Info().Bytes(fmt.Sprintf("%s stdout", command.Cmd), stdoutBuf.Bytes()).Send()
log.Info().Bytes(fmt.Sprintf("%s stderr", command.Cmd), stderrBuf.Bytes()).Send()
if err != nil {
log.Error().Err(fmt.Errorf("error when running cmd: %s: %w", command.Cmd, err)).Send()
}
}
}
func cmdListWorker(id int, jobs <-chan *CmdConfig, config *BackyConfigFile, results chan<- string) {
for j := range jobs {
// fmt.Println("worker", id, "started job", j)
for _, cmd := range j.Order {
cmdToRun := config.Cmds[cmd]
cmdToRun.RunCmd(&config.Logger)
}
// fmt.Println("worker", id, "finished job", j)
results <- "done"
}
}
// RunBackyConfig runs a command list from the BackyConfigFile.
func (config *BackyConfigFile) RunBackyConfig() {
configListsLen := len(config.CmdConfigLists)
jobs := make(chan *CmdConfig, configListsLen)
results := make(chan string)
// configChan := make(chan map[string]Command)
// This starts up 3 workers, initially blocked
// because there are no jobs yet.
for w := 1; w <= 3; w++ {
go cmdListWorker(w, jobs, config, results)
}
// Here we send 5 `jobs` and then `close` that
// channel to indicate that's all the work we have.
// configChan <- config.Cmds
for _, cmdConfig := range config.CmdConfigLists {
jobs <- cmdConfig
// fmt.Println("sent job", config.Order)
}
close(jobs)
for a := 1; a <= configListsLen; a++ {
<-results
}
}
// ReadAndParseConfigFile validates and reads the config file.
func ReadAndParseConfigFile(configFile string) *BackyConfigFile {
backyConfigFile := NewConfig()
backyViper := viper.New()
if configFile != "" {
backyViper.SetConfigFile(configFile)
} else {
backyViper.SetConfigName("backy.yaml") // name of config file (with extension)
backyViper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name
backyViper.AddConfigPath(".") // optionally look for config in the working directory
backyViper.AddConfigPath("$HOME/.config/backy") // call multiple times to add many search paths
}
err := backyViper.ReadInConfig() // Find and read the config file
if err != nil { // Handle errors reading the config file
panic(fmt.Errorf("fatal error reading config file %s: %w", backyViper.ConfigFileUsed(), err))
}
CheckForConfigValues(backyViper)
var backyLoggingOpts *viper.Viper
backyLoggingOptsSet := backyViper.IsSet("logging")
if backyLoggingOptsSet {
backyLoggingOpts = backyViper.Sub("logging")
}
verbose := backyLoggingOpts.GetBool("verbose")
logFile := backyLoggingOpts.GetString("file")
if verbose {
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
globalLvl := zerolog.GlobalLevel().String()
os.Setenv("BACKY_LOGLEVEL", globalLvl)
}
output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC1123}
output.FormatLevel = func(i interface{}) string {
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
}
output.FormatMessage = func(i interface{}) string {
return fmt.Sprintf("%s", i)
}
output.FormatFieldName = func(i interface{}) string {
return fmt.Sprintf("%s: ", i)
}
output.FormatFieldValue = func(i interface{}) string {
return strings.ToUpper(fmt.Sprintf("%s", i))
}
fileLogger := &lumberjack.Logger{
MaxSize: 500, // megabytes
MaxBackups: 3,
MaxAge: 28, //days
Compress: true, // disabled by default
}
if strings.TrimSpace(logFile) != "" {
fileLogger.Filename = logFile
} else {
fileLogger.Filename = "./backy.log"
}
// UNIX Time is faster and smaller than most timestamps
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
// zerolog.TimeFieldFormat = time.RFC1123
writers := zerolog.MultiLevelWriter(os.Stdout, fileLogger)
log := zerolog.New(writers).With().Timestamp().Logger()
backyConfigFile.Logger = log
commandsMap := backyViper.GetStringMapString("commands")
commandsMapViper := backyViper.Sub("commands")
unmarshalErr := commandsMapViper.Unmarshal(&backyConfigFile.Cmds)
if unmarshalErr != nil {
panic(fmt.Errorf("error unmarshalling cmds struct: %w", unmarshalErr))
}
var cmdNames []string
for k := range commandsMap {
cmdNames = append(cmdNames, k)
}
hostConfigsMap := make(map[string]*viper.Viper)
for _, cmdName := range cmdNames {
var backupCmdStruct Command
subCmd := backyViper.Sub(getNestedConfig("commands", cmdName))
hostSet := subCmd.IsSet("host")
host := subCmd.GetString("host")
if hostSet {
log.Debug().Timestamp().Str(cmdName, "host is set").Str("host", host).Send()
backupCmdStruct.Host = &host
if backyViper.IsSet(getNestedConfig("hosts", host)) {
hostconfig := backyViper.Sub(getNestedConfig("hosts", host))
hostConfigsMap[host] = hostconfig
}
} else {
log.Debug().Timestamp().Str(cmdName, "host is not set").Send()
}
// backyConfigFile.Cmds[cmdName] = backupCmdStruct
}
cmdListCfg := backyViper.Sub("cmd-configs")
unmarshalErr = cmdListCfg.Unmarshal(&backyConfigFile.CmdConfigLists)
if unmarshalErr != nil {
panic(fmt.Errorf("error unmarshalling cmd list struct: %w", unmarshalErr))
}
var cmdNotFoundSliceErr []error
for cmdListName, cmdList := range backyConfigFile.CmdConfigLists {
for _, cmdInList := range cmdList.Order {
// log.Info().Msgf("CmdList %s Cmd %s", cmdListName, cmdInList)
_, cmdNameFound := backyConfigFile.Cmds[cmdInList]
if !cmdNameFound {
cmdNotFoundStr := fmt.Sprintf("command %s is not defined in config file", cmdInList)
cmdNotFoundErr := errors.New(cmdNotFoundStr)
cmdNotFoundSliceErr = append(cmdNotFoundSliceErr, cmdNotFoundErr)
} else {
log.Info().Str(cmdInList, "found in "+cmdListName).Send()
// backyConfigFile.CmdLists[cmdListName] = append(backyConfigFile.CmdLists[cmdListName], cmdInList)
}
}
}
if len(cmdNotFoundSliceErr) > 0 {
var cmdNotFoundErrorLog = log.Fatal()
for _, err := range cmdNotFoundSliceErr {
if err != nil {
cmdNotFoundErrorLog.Err(err)
}
}
cmdNotFoundErrorLog.Send()
}
// var notificationSlice []string
for name, cmdCfg := range backyConfigFile.CmdConfigLists {
for _, notificationID := range cmdCfg.Notifications {
// if !contains(notificationSlice, notificationID) {
cmdCfg.NotificationsConfig = make(map[string]*NotificationsConfig)
notifConfig := backyViper.Sub(getNestedConfig("notifications", notificationID))
config := &NotificationsConfig{
Config: notifConfig,
Enabled: true,
}
cmdCfg.NotificationsConfig[notificationID] = config
// First we get a "copy" of the entry
if entry, ok := cmdCfg.NotificationsConfig[notificationID]; ok {
// Then we modify the copy
entry.Config = notifConfig
entry.Enabled = true
// Then we reassign the copy
cmdCfg.NotificationsConfig[notificationID] = entry
}
backyConfigFile.CmdConfigLists[name].NotificationsConfig[notificationID] = config
}
// }
}
var notificationsMap = make(map[string]interface{})
if backyViper.IsSet("notifications") {
notificationsMap = backyViper.GetStringMap("notifications")
for id := range notificationsMap {
notifConfig := backyViper.Sub(getNestedConfig("notifications", id))
config := &NotificationsConfig{
Config: notifConfig,
Enabled: true,
}
backyConfigFile.Notifications[id] = config
}
// for _, notif := range backyConfigFile.Notifications {
// fmt.Printf("Type: %s\n", notif.Config.GetString("type"))
// notificationID := notif.Config.GetString("id")
// if !contains(notificationSlice, notificationID) {
// config := backyConfigFile.Notifications[notificationID]
// config.Enabled = false
// backyConfigFile.Notifications[notificationID] = config
// }
// }
}
return backyConfigFile
}
func getNestedConfig(nestedConfig, key string) string {
return fmt.Sprintf("%s.%s", nestedConfig, key)
}
func resolveDir(path string) (string, error) {
usr, err := user.Current()
if err != nil {
return path, err
}
dir := usr.HomeDir
if path == "~" {
// In case of "~", which won't be caught by the "else if"
path = dir
} else if strings.HasPrefix(path, "~/") {
// Use strings.HasPrefix so we don't match paths like
// "/something/~/something/"
path = filepath.Join(dir, path[2:])
}
return path, nil
}
func injectEnvIntoSSH(envVarsToInject environmentVars, process *ssh.Session, log *zerolog.Logger) {
if envVarsToInject.file != "" {
envPath, envPathErr := resolveDir(envVarsToInject.file)
if envPathErr != nil {
log.Error().Err(envPathErr).Send()
}
file, err := os.Open(envPath)
if err != nil {
log.Err(err).Send()
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
envVar := scanner.Text()
envVarArr := strings.Split(envVar, "=")
process.Setenv(envVarArr[0], envVarArr[1])
}
if err := scanner.Err(); err != nil {
log.Err(err).Send()
}
}
if len(envVarsToInject.env) > 0 {
for _, envVal := range envVarsToInject.env {
if strings.Contains(envVal, "=") {
envVarArr := strings.Split(envVal, "=")
process.Setenv(strings.ToUpper(envVarArr[0]), envVarArr[1])
}
}
}
}
func injectEnvIntoLocalCMD(envVarsToInject environmentVars, process *exec.Cmd, log *zerolog.Logger) {
if envVarsToInject.file != "" {
envPath, envPathErr := resolveDir(envVarsToInject.file)
if envPathErr != nil {
log.Error().Err(envPathErr).Send()
}
file, err := os.Open(envPath)
if err != nil {
log.Err(err).Send()
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
envVar := scanner.Text()
process.Env = append(process.Env, envVar)
}
if err := scanner.Err(); err != nil {
log.Err(err).Send()
}
}
if len(envVarsToInject.env) > 0 {
for _, envVal := range envVarsToInject.env {
if strings.Contains(envVal, "=") {
process.Env = append(process.Env, envVal)
}
}
}
}
func contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
func CheckForConfigValues(config *viper.Viper) {
for _, key := range requiredKeys {
isKeySet := config.IsSet(key)
if !isKeySet {
logging.ExitWithMSG(Sprintf("Config key %s is not defined in %s", key, config.ConfigFileUsed()), 1, nil)
}
}
}