From 765ef2ee36d5ae08aca2bc1cda95dd1147800150 Mon Sep 17 00:00:00 2001 From: Andrew Woodlee Date: Sat, 31 Jan 2026 01:06:18 -0600 Subject: [PATCH] v0.11.3 --- .changes/v0.11.3.md | 7 + .goreleaser/gitea.yml | 2 + .goreleaser/github.yml | 2 + CHANGELOG.md | 8 + cmd/version.go | 2 +- pkg/backy/backy.go | 95 ++++++++--- pkg/backy/commandtype_enumer.go | 12 +- pkg/backy/config.go | 1 - pkg/backy/file.go | 193 +++++++++++++++++++++++ pkg/backy/file_test.go | 78 +++++++++ pkg/backy/filecommandoperation_enumer.go | 141 +++++++++++++++++ pkg/backy/ssh.go | 94 +++++++---- pkg/backy/template.go | 71 +++++++++ pkg/backy/types.go | 30 +++- pkg/backy/utils.go | 52 +++++- pkg/logging/logging.go | 43 +++++ tests/FileOps.yml | 12 ++ tests/SuccessHook.yml | 5 +- tests/data/fileops/destination.txt | 1 + tests/data/fileops/source.txt | 1 + tests/docker/README.md | 39 +++++ tests/docker/buildDocker.sh | 15 +- tests/docker/compose.yml | 8 + tests/docker/known_hosts | 8 + tests/docker/start.sh | 7 + tests/docker/stop.sh | 7 + tests/example.tmpl | 5 + tests/vars.yaml | 3 + 28 files changed, 873 insertions(+), 69 deletions(-) create mode 100644 .changes/v0.11.3.md create mode 100644 pkg/backy/file.go create mode 100644 pkg/backy/file_test.go create mode 100644 pkg/backy/filecommandoperation_enumer.go create mode 100644 pkg/backy/template.go create mode 100644 tests/FileOps.yml create mode 100644 tests/data/fileops/destination.txt create mode 100644 tests/data/fileops/source.txt create mode 100644 tests/docker/README.md create mode 100644 tests/docker/compose.yml create mode 100644 tests/docker/known_hosts create mode 100755 tests/docker/start.sh create mode 100755 tests/docker/stop.sh create mode 100644 tests/example.tmpl create mode 100644 tests/vars.yaml diff --git a/.changes/v0.11.3.md b/.changes/v0.11.3.md new file mode 100644 index 0000000..8b9a745 --- /dev/null +++ b/.changes/v0.11.3.md @@ -0,0 +1,7 @@ +## v0.11.3 - 2026-01-31 +### Added +* Command: saveShellHistory for scriptFile commands over SSH +* Starting on Variables and Templates +### Changed +* File output for commands now adds hostname to beginning of filename +* Testing: docker testing infra diff --git a/.goreleaser/gitea.yml b/.goreleaser/gitea.yml index 8a4ee67..f51e917 100755 --- a/.goreleaser/gitea.yml +++ b/.goreleaser/gitea.yml @@ -34,6 +34,8 @@ snapshot: version_template: "{{ incpatch .Version }}-next" changelog: disable: false +release: + prerelease: auto gitea_urls: api: https://git.andrewnw.xyz/api/v1 diff --git a/.goreleaser/github.yml b/.goreleaser/github.yml index 7ffaa22..ee681b6 100755 --- a/.goreleaser/github.yml +++ b/.goreleaser/github.yml @@ -31,6 +31,8 @@ archives: formats: [zip] checksum: name_template: 'checksums.txt' +release: + prerelease: auto snapshot: version_template: "{{ incpatch .Version }}-next" changelog: diff --git a/CHANGELOG.md b/CHANGELOG.md index 729477b..cb7d244 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). +## v0.11.3 - 2026-01-31 +### Added +* Command: saveShellHistory for scriptFile commands over SSH +* Starting on Variables and Templates +### Changed +* File output for commands now adds hostname to beginning of filename +* Testing: docker testing infra + ## v0.11.2 - 2025-12-27 ### Added * Upgraded GoCron; web ui viewer for viewing cron jobs diff --git a/cmd/version.go b/cmd/version.go index 0b114b5..a95333f 100755 --- a/cmd/version.go +++ b/cmd/version.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -const versionStr = "0.11.2" +const versionStr = "0.11.3" var ( versionCmd = &cobra.Command{ diff --git a/pkg/backy/backy.go b/pkg/backy/backy.go index 7348422..8b9fd79 100755 --- a/pkg/backy/backy.go +++ b/pkg/backy/backy.go @@ -14,10 +14,12 @@ import ( "strings" "sync" "text/template" + "time" "embed" "github.com/rs/zerolog" + "gopkg.in/natefinch/lumberjack.v2" ) //go:embed templates/*.txt @@ -65,10 +67,10 @@ func (e *PackageCommandExecutor) Run(cmd *Command, opts *ConfigOpts, logger zero // Execute the package version command execCmd := exec.Command(cmd.Cmd, cmd.Args...) - cmdOutWriters = io.MultiWriter(&cmdOutBuf) - - if IsCmdStdOutEnabled() { - cmdOutWriters = io.MultiWriter(os.Stdout, &cmdOutBuf) + var err error + cmdOutWriters, _, err = makeCmdOutWriters(&cmdOutBuf, "") + if err != nil { + return nil, err } execCmd.Stdout = cmdOutWriters execCmd.Stderr = cmdOutWriters @@ -145,6 +147,48 @@ func (e *LocalCommandExecutor) Run(cmd *Command, opts *ConfigOpts, logger zerolo return outputArr, nil } +// makeCmdOutWriters constructs an io.Writer that writes to the provided buffer, +// optionally also to stdout and/or a file. If a file path is provided the +// caller is responsible for closing the returned *os.File when non-nil. +func makeCmdOutWriters(buf *bytes.Buffer, outputFile string) (io.Writer, *os.File, error) { + writers := io.MultiWriter(buf) + if IsCmdStdOutEnabled() { + + console := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC1123} + console.FormatLevel = func(i interface{}) string { + return strings.ToUpper(fmt.Sprintf("| %-6s|", i)) + } + console.FormatMessage = func(i any) string { + if i == nil { + return "" + } + return fmt.Sprintf("MSG: %s", i) + } + console.FormatFieldName = func(i interface{}) string { + return fmt.Sprintf("%s: ", i) + } + console.FormatFieldValue = func(i interface{}) string { + return fmt.Sprintf("%s", i) + // return strings.ToUpper(fmt.Sprintf("%s", i)) + } + + writers = io.MultiWriter(console, writers) + } + if outputFile != "" { + + fileLogger := &lumberjack.Logger{ + MaxSize: 50, // megabytes + MaxBackups: 3, + MaxAge: 28, //days + Compress: false, // disabled by default + } + fileLogger.Filename = outputFile + + writers = io.MultiWriter(fileLogger, writers) + } + return writers, nil, nil +} + // ensureRemoteHost ensures localCmd.RemoteHost is set for the given host. // It prefers opts.Hosts lookup and falls back to a minimal Host entry so remote execution can proceed. func (opts *ConfigOpts) ensureRemoteHost(localCmd *Command, host string) { @@ -250,6 +294,7 @@ func (command *Command) RunCmd(cmdCtxLogger zerolog.Logger, opts *ConfigOpts) ([ } if command.Type == UserCommandType { + if command.UserOperation == "password" { cmdCtxLogger.Info().Str("password", command.UserPassword).Msg("user password to be updated") } @@ -283,23 +328,13 @@ func (command *Command) RunCmd(cmdCtxLogger zerolog.Logger, opts *ConfigOpts) ([ localCMD = exec.Command(command.Shell, command.Args...) injectEnvIntoLocalCMD(envVars, localCMD, cmdCtxLogger, opts) - cmdOutWriters = io.MultiWriter(&cmdOutBuf) - - if IsCmdStdOutEnabled() { - cmdOutWriters = io.MultiWriter(os.Stdout, &cmdOutBuf) + var outFile *os.File + cmdOutWriters, outFile, err := makeCmdOutWriters(&cmdOutBuf, command.Output.File) + if err != nil { + return nil, err } - if command.Output.File != "" { - file, err := os.Create(command.Output.File) - if err != nil { - return nil, fmt.Errorf("error creating output file: %w", err) - } - defer file.Close() - cmdOutWriters = io.MultiWriter(file, &cmdOutBuf) - - if IsCmdStdOutEnabled() { - cmdOutWriters = io.MultiWriter(os.Stdout, file, &cmdOutBuf) - } - + if outFile != nil { + defer outFile.Close() } localCMD.Stdin = bytes.NewReader(script) @@ -370,10 +405,9 @@ func (command *Command) RunCmd(cmdCtxLogger zerolog.Logger, opts *ConfigOpts) ([ injectEnvIntoLocalCMD(envVars, localCMD, cmdCtxLogger, opts) - cmdOutWriters = io.MultiWriter(&cmdOutBuf) - - if IsCmdStdOutEnabled() { - cmdOutWriters = io.MultiWriter(os.Stdout, &cmdOutBuf) + cmdOutWriters, _, err = makeCmdOutWriters(&cmdOutBuf, "") + if err != nil { + return outputArr, err } localCMD.Stdout = cmdOutWriters @@ -958,6 +992,19 @@ func (cmd *Command) GenerateLogger(opts *ConfigOpts) zerolog.Logger { return cmdLogger } +func (cmd *Command) GenerateLoggerForCmd(logger zerolog.Logger) zerolog.Logger { + cmdLogger := logger.With(). + Str("Backy-cmd", cmd.Name).Str("Host", "local machine"). + Logger() + + if !IsHostLocal(cmd.Host) { + cmdLogger = logger.With(). + Str("Backy-cmd", cmd.Name).Str("Host", cmd.Host). + Logger() + } + return cmdLogger +} + func (opts *ConfigOpts) ExecCmdsOnHosts(cmdList []string, hostsList []string) { // Iterate over hosts and exec commands for _, h := range hostsList { diff --git a/pkg/backy/commandtype_enumer.go b/pkg/backy/commandtype_enumer.go index a802039..3c52752 100644 --- a/pkg/backy/commandtype_enumer.go +++ b/pkg/backy/commandtype_enumer.go @@ -8,11 +8,11 @@ import ( "strings" ) -const _CommandTypeName = "scriptscriptFileremoteScriptpackageuser" +const _CommandTypeName = "scriptscriptFileremoteScriptpackageuserfile" -var _CommandTypeIndex = [...]uint8{0, 0, 6, 16, 28, 35, 39} +var _CommandTypeIndex = [...]uint8{0, 0, 6, 16, 28, 35, 39, 43} -const _CommandTypeLowerName = "scriptscriptfileremotescriptpackageuser" +const _CommandTypeLowerName = "scriptscriptfileremotescriptpackageuserfile" func (i CommandType) String() string { if i < 0 || i >= CommandType(len(_CommandTypeIndex)-1) { @@ -31,9 +31,10 @@ func _CommandTypeNoOp() { _ = x[RemoteScriptCommandType-(3)] _ = x[PackageCommandType-(4)] _ = x[UserCommandType-(5)] + _ = x[FileCommandType-(6)] } -var _CommandTypeValues = []CommandType{DefaultCommandType, ScriptCommandType, ScriptFileCommandType, RemoteScriptCommandType, PackageCommandType, UserCommandType} +var _CommandTypeValues = []CommandType{DefaultCommandType, ScriptCommandType, ScriptFileCommandType, RemoteScriptCommandType, PackageCommandType, UserCommandType, FileCommandType} var _CommandTypeNameToValueMap = map[string]CommandType{ _CommandTypeName[0:0]: DefaultCommandType, @@ -48,6 +49,8 @@ var _CommandTypeNameToValueMap = map[string]CommandType{ _CommandTypeLowerName[28:35]: PackageCommandType, _CommandTypeName[35:39]: UserCommandType, _CommandTypeLowerName[35:39]: UserCommandType, + _CommandTypeName[39:43]: FileCommandType, + _CommandTypeLowerName[39:43]: FileCommandType, } var _CommandTypeNames = []string{ @@ -57,6 +60,7 @@ var _CommandTypeNames = []string{ _CommandTypeName[16:28], _CommandTypeName[28:35], _CommandTypeName[35:39], + _CommandTypeName[39:43], } // CommandTypeString retrieves an enum value from the enum constants string name. diff --git a/pkg/backy/config.go b/pkg/backy/config.go index a167c40..a8e57a5 100755 --- a/pkg/backy/config.go +++ b/pkg/backy/config.go @@ -549,7 +549,6 @@ func processCmds(opts *ConfigOpts) error { } if !IsHostLocal(cmd.Host) { - cmdHost := replaceVarInString(opts.Vars, cmd.Host, opts.Logger) if cmdHost != cmd.Host { cmd.Host = cmdHost diff --git a/pkg/backy/file.go b/pkg/backy/file.go new file mode 100644 index 0000000..a254ea8 --- /dev/null +++ b/pkg/backy/file.go @@ -0,0 +1,193 @@ +package backy + +import ( + "fmt" + "io/fs" + "os" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type LocalFileCommandExecutor struct{} + +func (f *LocalFileCommandExecutor) Execute(cmd *Command) error { + + localExecutor := LocalCommandExecutor{} + + switch cmd.FileOperation { + case "copy": + return localExecutor.copyFile(cmd.Source, cmd.Destination, cmd.Permissions) + default: + return fmt.Errorf("unsupported file operation: %s", cmd.FileOperation) + } +} + +func (f *LocalFileCommandExecutor) ReadLocalFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return data, nil +} + +func (f *LocalFileCommandExecutor) WriteLocalFile(path string, data []byte, Perms fs.FileMode) error { + err := os.WriteFile(path, data, Perms) + if err != nil { + return err + } + return nil +} + +func (f *LocalCommandExecutor) copyFile(source, destination string, Perms fs.FileMode) error { + input, err := os.ReadFile(source) + if err != nil { + return err + } + err = os.WriteFile(destination, input, Perms) + if err != nil { + return err + } + return nil +} + +type RemoteFileCommandExecutor struct{} + +func (r *RemoteFileCommandExecutor) Execute(cmd *Command) error { + + remoteExecutor := RemoteFileCommandExecutor{} + sourceTypeLocal := false + + if cmd.SourceType == "local" { + sourceTypeLocal = true + } + switch cmd.FileOperation { + case "copy": + return remoteExecutor.copyFile(cmd.Source, cmd.Destination, cmd.Permissions, cmd.RemoteHost.SshClient, sourceTypeLocal) + default: + return fmt.Errorf("unsupported file operation: %s", cmd.FileOperation) + } +} + +func (r *RemoteFileCommandExecutor) copyFile(source, destination string, Perms fs.FileMode, sshClient *ssh.Client, sourceTypeLocal bool) error { + if sourceTypeLocal { + input, err := os.ReadFile(source) + if err != nil { + return err + } + + sftpClient, sftpErr := sftp.NewClient(sshClient) + if sftpErr != nil { + return sftpErr + } + defer sftpClient.Close() + + destFile, err := sftpClient.Create(destination) + if err != nil { + return err + } + defer destFile.Close() + + _, err = destFile.Write(input) + if err != nil { + return err + } + + err = destFile.Chmod(Perms) + if err != nil { + return err + } + + return nil + } + + client, err := sftp.NewClient(sshClient) + + if err != nil { + return err + } + + srcFile, err := client.Open(source) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := client.Create(destination) + if err != nil { + return err + } + defer destFile.Close() + + _, err = srcFile.WriteTo(destFile) + if err != nil { + return err + } + + err = destFile.Chmod(Perms) + if err != nil { + return err + } + + return nil +} + +func (r *RemoteFileCommandExecutor) ReadRemoteFile(path string, sshClient *ssh.Client) ([]byte, error) { + sftpClient, sftpErr := sftp.NewClient(sshClient) + if sftpErr != nil { + return nil, sftpErr + } + defer sftpClient.Close() + + file, err := sftpClient.Open(path) + + if err != nil { + return nil, err + } + defer file.Close() + var fileData []byte + + _, err = file.Read(fileData) + if err != nil { + return nil, err + } + return fileData, nil +} + +func (r *RemoteFileCommandExecutor) WriteRemoteFile(path string, data []byte, Perms fs.FileMode, sshClient *ssh.Client) error { + sftpClient, sftpErr := sftp.NewClient(sshClient) + if sftpErr != nil { + return sftpErr + } + defer sftpClient.Close() + + file, err := sftpClient.Create(path) + + if err != nil { + return err + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return err + } + + err = file.Chmod(Perms) + if err != nil { + return err + } + + return nil +} + +type FileCommandExecutor interface { + Execute(cmd *Command) error +} + +func NewFileCommandExecutor(isRemote bool) FileCommandExecutor { + if isRemote { + return &RemoteFileCommandExecutor{} + } + return &LocalFileCommandExecutor{} +} diff --git a/pkg/backy/file_test.go b/pkg/backy/file_test.go new file mode 100644 index 0000000..9f7a6ea --- /dev/null +++ b/pkg/backy/file_test.go @@ -0,0 +1,78 @@ +package backy + +import ( + "os" + "testing" +) + +func TestCopyFileLocal(t *testing.T) { + FileCommand := Command{} + FileCommand.Type = FileCommandType + FileCommand.FileOperation = "copy" + FileCommand.Permissions = 0644 + FileCommand.Source = "/home/andrew/Projects/backy/tests/data/fileops/source.txt" + FileCommand.Destination = "/home/andrew/Projects/backy/tests/data/fileops/destination.txt" + var FileCommandExecutor = LocalFileCommandExecutor{} + err := FileCommandExecutor.Execute(&FileCommand) + if err != nil { + t.Errorf("Error executing file command: %v", err) + } + + srcBytes, srcErr := os.ReadFile(FileCommand.Source) + if srcErr != nil { + t.Errorf("Error reading source file: %v", srcErr) + } + destBytes, destErr := os.ReadFile(FileCommand.Destination) + if destErr != nil { + t.Errorf("Error reading destination file: %v", destErr) + } + if string(srcBytes) != string(destBytes) { + t.Errorf("Source and destination files do not match") + } + + // Additional checks can be added here to verify the file was copied correctly +} + +func TestCopyFileRemote(t *testing.T) { + opts := NewConfigOptions("") + opts.Hosts = map[string]*Host{} + RemoteFileCommand := Command{} + RemoteFileCommand.Type = FileCommandType + RemoteFileCommand.FileOperation = "copy" + RemoteFileCommand.Permissions = 0644 + RemoteFileCommand.Destination = "/home/backy/destination.txt" + RemoteFileCommand.Source = "/home/andrew/Projects/backy/tests/data/fileops/source.txt" + RemoteFileCommand.Host = "localhost" + RemoteFileCommand.RemoteHost = &Host{ + HostName: "localhost", + User: "backy", + Password: "backy", + Port: 2222, + PrivateKeyPath: "/home/andrew/Projects/backy/tests/docker/backytest", + KnownHostsFile: "/home/andrew/Projects/backy/tests/docker/known_hosts", + } + + sshErr := RemoteFileCommand.RemoteHost.ConnectToHost(opts) + if sshErr != nil { + t.Errorf("Error connecting to remote host: %v", sshErr) + } + var RemoteFileCommandExecutor = RemoteFileCommandExecutor{} + err := RemoteFileCommandExecutor.Execute(&RemoteFileCommand) + if err != nil { + t.Errorf("Error executing remote file command: %v", err) + } + + srcBytes, srcErr := os.ReadFile(RemoteFileCommand.Source) + if srcErr != nil { + t.Errorf("Error reading source file: %v", srcErr) + } + destBytes, destErr := RemoteFileCommandExecutor.ReadRemoteFile(RemoteFileCommand.Destination, RemoteFileCommand.RemoteHost.SshClient) + if destErr != nil { + t.Errorf("Error reading destination file: %v", destErr) + } + if string(srcBytes) != string(destBytes) { + t.Errorf("Source and destination files do not match") + } + + // Additional checks can be added here to verify the file was copied correctly +} diff --git a/pkg/backy/filecommandoperation_enumer.go b/pkg/backy/filecommandoperation_enumer.go new file mode 100644 index 0000000..b5442b1 --- /dev/null +++ b/pkg/backy/filecommandoperation_enumer.go @@ -0,0 +1,141 @@ +// Code generated by "enumer -linecomment -yaml -text -json -type=FileCommandOperation"; DO NOT EDIT. + +package backy + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _FileCommandOperationName = "copymovedeletechownchmod" + +var _FileCommandOperationIndex = [...]uint8{0, 0, 4, 8, 14, 19, 24} + +const _FileCommandOperationLowerName = "copymovedeletechownchmod" + +func (i FileCommandOperation) String() string { + if i < 0 || i >= FileCommandOperation(len(_FileCommandOperationIndex)-1) { + return fmt.Sprintf("FileCommandOperation(%d)", i) + } + return _FileCommandOperationName[_FileCommandOperationIndex[i]:_FileCommandOperationIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _FileCommandOperationNoOp() { + var x [1]struct{} + _ = x[DefaultFCO-(0)] + _ = x[FileCommandOperationCopy-(1)] + _ = x[FileCommandOperationMove-(2)] + _ = x[FileCommandOperationDelete-(3)] + _ = x[FileCommandOperationChown-(4)] + _ = x[FileCommandOperationChmod-(5)] +} + +var _FileCommandOperationValues = []FileCommandOperation{DefaultFCO, FileCommandOperationCopy, FileCommandOperationMove, FileCommandOperationDelete, FileCommandOperationChown, FileCommandOperationChmod} + +var _FileCommandOperationNameToValueMap = map[string]FileCommandOperation{ + _FileCommandOperationName[0:0]: DefaultFCO, + _FileCommandOperationLowerName[0:0]: DefaultFCO, + _FileCommandOperationName[0:4]: FileCommandOperationCopy, + _FileCommandOperationLowerName[0:4]: FileCommandOperationCopy, + _FileCommandOperationName[4:8]: FileCommandOperationMove, + _FileCommandOperationLowerName[4:8]: FileCommandOperationMove, + _FileCommandOperationName[8:14]: FileCommandOperationDelete, + _FileCommandOperationLowerName[8:14]: FileCommandOperationDelete, + _FileCommandOperationName[14:19]: FileCommandOperationChown, + _FileCommandOperationLowerName[14:19]: FileCommandOperationChown, + _FileCommandOperationName[19:24]: FileCommandOperationChmod, + _FileCommandOperationLowerName[19:24]: FileCommandOperationChmod, +} + +var _FileCommandOperationNames = []string{ + _FileCommandOperationName[0:0], + _FileCommandOperationName[0:4], + _FileCommandOperationName[4:8], + _FileCommandOperationName[8:14], + _FileCommandOperationName[14:19], + _FileCommandOperationName[19:24], +} + +// FileCommandOperationString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func FileCommandOperationString(s string) (FileCommandOperation, error) { + if val, ok := _FileCommandOperationNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _FileCommandOperationNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to FileCommandOperation values", s) +} + +// FileCommandOperationValues returns all values of the enum +func FileCommandOperationValues() []FileCommandOperation { + return _FileCommandOperationValues +} + +// FileCommandOperationStrings returns a slice of all String values of the enum +func FileCommandOperationStrings() []string { + strs := make([]string, len(_FileCommandOperationNames)) + copy(strs, _FileCommandOperationNames) + return strs +} + +// IsAFileCommandOperation returns "true" if the value is listed in the enum definition. "false" otherwise +func (i FileCommandOperation) IsAFileCommandOperation() bool { + for _, v := range _FileCommandOperationValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for FileCommandOperation +func (i FileCommandOperation) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for FileCommandOperation +func (i *FileCommandOperation) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("FileCommandOperation should be a string, got %s", data) + } + + var err error + *i, err = FileCommandOperationString(s) + return err +} + +// MarshalText implements the encoding.TextMarshaler interface for FileCommandOperation +func (i FileCommandOperation) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for FileCommandOperation +func (i *FileCommandOperation) UnmarshalText(text []byte) error { + var err error + *i, err = FileCommandOperationString(string(text)) + return err +} + +// MarshalYAML implements a YAML Marshaler for FileCommandOperation +func (i FileCommandOperation) MarshalYAML() (interface{}, error) { + return i.String(), nil +} + +// UnmarshalYAML implements a YAML Unmarshaler for FileCommandOperation +func (i *FileCommandOperation) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + + var err error + *i, err = FileCommandOperationString(s) + return err +} diff --git a/pkg/backy/ssh.go b/pkg/backy/ssh.go index fa7bbf5..06ff4eb 100755 --- a/pkg/backy/ssh.go +++ b/pkg/backy/ssh.go @@ -5,7 +5,6 @@ package backy import ( - "bufio" "bytes" "fmt" "io" @@ -472,10 +471,25 @@ func (command *Command) RunCmdOnHost(cmdCtxLogger zerolog.Logger, opts *ConfigOp defer commandSession.Close() // Set output writers - cmdOutWriters = io.MultiWriter(&cmdOutBuf) - if IsCmdStdOutEnabled() { - cmdOutWriters = io.MultiWriter(os.Stdout, &cmdOutBuf) + var file *os.File + if !IsHostLocal(command.Host) && command.Output.File != "" { + command.Output.File = fmt.Sprintf("%s_%s", command.RemoteHost.Host, command.Output.File) } + + cmdOutWriters, file, err = makeCmdOutWriters(&cmdOutBuf, command.Output.File) + if err != nil { + return nil, fmt.Errorf("error creating command output writers: %w", err) + } + defer func() { + if file != nil { + file.Close() + } + }() + // cmdOutWriters = logging.SetLoggingWriterForCommand(&cmdOutBuf, command.Output.File, IsCmdStdOutEnabled()) + cmdCtxLogger = zerolog.New(cmdOutWriters).With().Timestamp().Logger() + cmdCtxLogger = command.GenerateLoggerForCmd(cmdCtxLogger) + + // cmdCtxLogger.Info().Msgf("Executing %s", command.Cmd) commandSession.Stdout = cmdOutWriters commandSession.Stderr = cmdOutWriters @@ -496,7 +510,9 @@ func (command *Command) RunCmdOnHost(cmdCtxLogger zerolog.Logger, opts *ConfigOp case RemoteScriptCommandType: return command.runRemoteScript(commandSession, cmdCtxLogger, &cmdOutBuf) case ScriptFileCommandType: - return command.runScriptFile(commandSession, cmdCtxLogger, &cmdOutBuf) + commandSession.Stdout = nil + commandSession.Stderr = nil + return command.runScriptFile(commandSession, cmdCtxLogger, opts.Logger, &cmdOutBuf) case PackageCommandType: var remoteHostPackageExecutor RemoteHostPackageExecutor return remoteHostPackageExecutor.RunCmdOnHost(command, commandSession, cmdCtxLogger, cmdOutBuf) @@ -662,22 +678,56 @@ func (command *Command) runScript(session *ssh.Session, cmdCtxLogger zerolog.Log } // runScriptFile handles the execution of script files. -func (command *Command) runScriptFile(session *ssh.Session, cmdCtxLogger zerolog.Logger, outputBuf *bytes.Buffer) ([]string, error) { +func (command *Command) runScriptFile(session *ssh.Session, cmdCtxLogger, globalLogger zerolog.Logger, outputBuf *bytes.Buffer) ([]string, error) { script, err := command.prepareScriptFileBuffer() if err != nil { return nil, err } - session.Stdin = script + // session.Stdin = script + + modes := ssh.TerminalModes{ + ssh.ECHO: 0, + ssh.ECHOCTL: 0, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + + session.RequestPty("xterm", 80, 40, modes) + + stdin, _ := session.StdinPipe() + stdout, stdOutErr := session.StdoutPipe() + if stdOutErr != nil { + return nil, fmt.Errorf("error getting stdout pipe: %w", stdOutErr) + } if err := session.Shell(); err != nil { return nil, fmt.Errorf("error starting shell: %w", err) } - - if err := session.Wait(); err != nil { - return collectOutput(outputBuf, command.Name, cmdCtxLogger, true), fmt.Errorf("error waiting for shell: %w", err) + var LogOutputToFile bool + if command.Output.File != "" || command.Output.ToLog { + if command.Output.File != "" { + globalLogger.Info().Str("file", command.Output.File).Msg("Writing script output to file") + } + LogOutputToFile = true } - return collectOutput(outputBuf, command.Name, cmdCtxLogger, command.Output.ToLog), nil + stdin.Write(script.Bytes()) + + stdOutput, stdoOutReadErr := io.ReadAll(stdout) + if err := session.Wait(); err != nil { + stdOutBuff := bytes.NewBuffer(stdOutput) + // outputBuf.Write(stdOutBuff.Bytes()) + // Read output + return collectOutput(stdOutBuff, command.Name, cmdCtxLogger, LogOutputToFile), fmt.Errorf("error waiting for shell: %w", err) + } + + // Read output + if stdoOutReadErr != nil { + return collectOutput(outputBuf, command.Name, cmdCtxLogger, LogOutputToFile), fmt.Errorf("error reading stdout after shell error: %w", stdoOutReadErr) + } + stdOutBuff := bytes.NewBuffer(stdOutput) + + return collectOutput(stdOutBuff, command.Name, cmdCtxLogger, LogOutputToFile), nil } // prepareScriptBuffer prepares a buffer for inline scripts. @@ -685,7 +735,7 @@ func (command *Command) prepareScriptBuffer() (*bytes.Buffer, error) { var buffer bytes.Buffer for _, envVar := range command.Environment { - buffer.WriteString(fmt.Sprintf("export %s", envVar)) + fmt.Fprintf(&buffer, "export %s", envVar) buffer.WriteByte('\n') } @@ -710,8 +760,12 @@ func (command *Command) prepareScriptBuffer() (*bytes.Buffer, error) { func (command *Command) prepareScriptFileBuffer() (*bytes.Buffer, error) { var buffer bytes.Buffer + if !command.SaveShellHistory { + buffer.WriteString("unset HISTFILE\nexport HISTSIZE=0\nexport SAVEHIST=0\n") + } + for _, envVar := range command.Environment { - buffer.WriteString(fmt.Sprintf("export %s", envVar)) + fmt.Fprintf(&buffer, "export %s", envVar) buffer.WriteByte('\n') } @@ -774,20 +828,6 @@ func readFileToBuffer(filePath string) (*bytes.Buffer, error) { return &buffer, nil } -// collectOutput collects output from a buffer and logs it. -func collectOutput(buf *bytes.Buffer, commandName string, logger zerolog.Logger, wantOutput bool) []string { - var outputArr []string - scanner := bufio.NewScanner(buf) - for scanner.Scan() { - line := scanner.Text() - outputArr = append(outputArr, line) - if wantOutput { - logger.Info().Str("cmd", commandName).Str("output", line).Send() - } - } - return outputArr -} - // createSSHSession attempts to create a new SSH session and retries on failure. func (h *Host) createSSHSession(opts *ConfigOpts) (*ssh.Session, error) { session, err := h.SshClient.NewSession() diff --git a/pkg/backy/template.go b/pkg/backy/template.go new file mode 100644 index 0000000..a44a0ba --- /dev/null +++ b/pkg/backy/template.go @@ -0,0 +1,71 @@ +package backy + +import ( + "bytes" + "os" + "path/filepath" + "text/template" + + "gopkg.in/yaml.v3" +) + +func LoadVarsYAML(path string) (map[string]interface{}, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := yaml.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +func RenderTemplateFile(templatePath string, vars map[string]interface{}) ([]byte, error) { + tmplText, err := os.ReadFile(templatePath) + if err != nil { + return nil, err + } + funcs := template.FuncMap{ + "env": func(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d + }, + "default": func(def interface{}, v interface{}) interface{} { + if v == nil { + return def + } + if s, ok := v.(string); ok && s == "" { + return def + } + return v + }, + "toYaml": func(v interface{}) string { + b, _ := yaml.Marshal(v) + return string(b) + }, + } + t := template.New(filepath.Base(templatePath)).Funcs(funcs) + t, err = t.Parse(string(tmplText)) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := t.Execute(&buf, vars); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func WriteRenderedFile(templatePath string, vars map[string]interface{}, dest string, perm os.FileMode) error { + out, err := RenderTemplateFile(templatePath, vars) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + return os.WriteFile(dest, out, perm) +} diff --git a/pkg/backy/types.go b/pkg/backy/types.go index 763aac6..6885ecd 100755 --- a/pkg/backy/types.go +++ b/pkg/backy/types.go @@ -2,6 +2,7 @@ package backy import ( "bytes" + "io/fs" "text/template" "strings" @@ -79,6 +80,8 @@ type ( ScriptEnvFile string `yaml:"scriptEnvFile"` + SaveShellHistory bool `yaml:"saveShellHistory,omitempty"` + Output struct { File string `yaml:"file,omitempty"` ToLog bool `yaml:"toLog,omitempty"` @@ -138,7 +141,20 @@ type ( // stdin only for userOperation = password (for now) stdin *strings.Reader - // END USER STRUCommandType FIELDS + // END USER CommandType FIELDS + + // BEGIN FILE COMMAND FIELDS + + FileOperation string `yaml:"fileOperation,omitempty"` + Source string `yaml:"source,omitempty"` + DestinationType string `yaml:"destinationType,omitempty"` + SourceType string `yaml:"sourceType,omitempty"` + Destination string `yaml:"destination,omitempty"` + Permissions fs.FileMode `yaml:"permissions,omitempty"` + Owner string `yaml:"owner,omitempty"` + Group string `yaml:"group,omitempty"` + + // END FILE COMMAND FIELDS } RemoteSource struct { @@ -312,6 +328,7 @@ type ( CommandType int PackageOperation int AllowedExternalDirectives int + FileCommandOperation int ) //go:generate go run github.com/dmarkham/enumer -linecomment -yaml -text -json -type=CommandType @@ -322,6 +339,7 @@ const ( RemoteScriptCommandType // remoteScript PackageCommandType // package UserCommandType // user + FileCommandType // file ) //go:generate go run github.com/dmarkham/enumer -linecomment -yaml -text -json -type=PackageOperation @@ -335,6 +353,16 @@ const ( PackageOperationIsInstalled // isInstalled ) +//go:generate go run github.com/dmarkham/enumer -linecomment -yaml -text -json -type=FileCommandOperation +const ( + DefaultFCO FileCommandOperation = iota // + FileCommandOperationCopy // copy + FileCommandOperationMove // move + FileCommandOperationDelete // delete + FileCommandOperationChown // chown + FileCommandOperationChmod // chmod +) + //go:generate go run github.com/dmarkham/enumer -linecomment -yaml -text -json -type=AllowedExternalDirectives const ( DefaultExternalDir AllowedExternalDirectives = iota diff --git a/pkg/backy/utils.go b/pkg/backy/utils.go index 7ff36d7..f4bac02 100755 --- a/pkg/backy/utils.go +++ b/pkg/backy/utils.go @@ -5,6 +5,7 @@ package backy import ( + "bufio" "bytes" "context" "errors" @@ -173,7 +174,7 @@ errEnvFile: } func prependEnvVarsToCommand(envVars environmentVars, opts *ConfigOpts, command string, args []string, cmdCtxLogger zerolog.Logger) string { - var envPrefix string + var envPrefix strings.Builder if envVars.file != "" { envPath, envPathErr := getFullPathWithHomeDir(envVars.file) if envPathErr != nil { @@ -190,15 +191,15 @@ func prependEnvVarsToCommand(envVars environmentVars, opts *ConfigOpts, command log.Fatal().Str("envFile", envPath).Err(err).Send() } for key, val := range envMap { - envPrefix += fmt.Sprintf("%s=%s ", key, getExternalConfigDirectiveValue(val, opts, AllowedExternalDirectiveVaultEnv)) + fmt.Fprintf(&envPrefix, "%s=%s ", key, getExternalConfigDirectiveValue(val, opts, AllowedExternalDirectiveVaultEnv)) } } for _, value := range envVars.env { envVarArr := strings.Split(value, "=") - envPrefix += fmt.Sprintf("%s=%s ", envVarArr[0], getExternalConfigDirectiveValue(envVarArr[1], opts, AllowedExternalDirectiveVault)) - envPrefix += "\n" + fmt.Fprintf(&envPrefix, "%s=%s ", envVarArr[0], getExternalConfigDirectiveValue(envVarArr[1], opts, AllowedExternalDirectiveVault)) + envPrefix.WriteString("\n") } - return envPrefix + command + " " + strings.Join(args, " ") + return envPrefix.String() + command + " " + strings.Join(args, " ") } func contains(s []string, e string) bool { @@ -220,6 +221,47 @@ func CheckConfigValues(config *koanf.Koanf, file string) { } } +// collectOutput collects output from a buffer and logs it. +func collectOutput(buf *bytes.Buffer, commandName string, logger zerolog.Logger, wantOutput bool) []string { + var outputArr []string + scanner := bufio.NewScanner(buf) + for scanner.Scan() { + line := scanner.Text() + clean := sanitizeString(line) + outputArr = append(outputArr, clean) + if wantOutput { + logger.Info().Str("cmd", commandName).Str("output", clean).Send() + } + } + return outputArr +} + +// sanitizeString removes ANSI escape sequences and non-printable control characters +// while preserving tabs. This helps remove color codes and other terminal control +// characters from remote command output. +func sanitizeString(s string) string { + // Remove common ANSI CSI sequences like "\x1b[31m" etc. + var ansiCSI = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + s = ansiCSI.ReplaceAllString(s, "") + + // Remove OSC sequences started by ESC ] and terminated by BEL or ESC\ + var osc = regexp.MustCompile(`(?s)"].*?(?:|\\)`) + s = osc.ReplaceAllString(s, "") + + // Sometimes the ESC has been stripped earlier and we are left with sequences like "]2;title]1;" + // Remove leftover bracketed sequences like "];" + var leftoverOSC = regexp.MustCompile(`\][0-9]+[^\]]*`) + s = leftoverOSC.ReplaceAllString(s, "") + + var b strings.Builder + for _, r := range s { + if r == '\t' || (r >= 0x20 && r != 0x7f) { + b.WriteRune(r) + } + } + return b.String() +} + func testFile(c string) error { if strings.TrimSpace(c) != "" { file, fileOpenErr := os.Open(c) diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index 3aa01d5..a28acbc 100755 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -1,6 +1,7 @@ package logging import ( + "bytes" "fmt" "os" "strings" @@ -66,6 +67,48 @@ func SetLoggingWriters(logFile string) (writers zerolog.LevelWriter) { return } +func SetLoggingWriterForCommand(buf *bytes.Buffer, logFile string, logToConsole bool) (writers zerolog.LevelWriter) { + + console := zerolog.ConsoleWriter{} + if logToConsole { + + console = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC1123} + console.FormatLevel = func(i interface{}) string { + return strings.ToUpper(fmt.Sprintf("| %-6s|", i)) + } + console.FormatMessage = func(i any) string { + if i == nil { + return "" + } + return fmt.Sprintf("MSG: %s", i) + } + console.FormatFieldName = func(i interface{}) string { + return fmt.Sprintf("%s: ", i) + } + console.FormatFieldValue = func(i interface{}) string { + return fmt.Sprintf("%s", i) + // return strings.ToUpper(fmt.Sprintf("%s", i)) + } + } + + fileLogger := &lumberjack.Logger{ + MaxSize: 50, // megabytes + MaxBackups: 3, + MaxAge: 28, //days + Compress: true, // disabled by default + } + fileLogger.Filename = logFile + // UNIX Time is faster and smaller than most timestamps + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + // zerolog.TimeFieldFormat = time.RFC1123 + writers = zerolog.MultiLevelWriter(fileLogger) + + if logToConsole { + writers = zerolog.MultiLevelWriter(console, fileLogger) + } + return +} + func IsConsoleLoggingEnabled() bool { return os.Getenv("BACKY_CONSOLE_LOGGING") == "enabled" } diff --git a/tests/FileOps.yml b/tests/FileOps.yml new file mode 100644 index 0000000..b49e681 --- /dev/null +++ b/tests/FileOps.yml @@ -0,0 +1,12 @@ +commands: + copyFile: + type: file + fileOperation: copy + source: /home/andrew/Projects/backy/tests/data/fileops/source.txt + destination: /home/andrew/Projects/backy/tests/data/fileops/destination.txt + copyRemoteFile: + type: file + fileOperation: copy + sourceType: rempte + source: ssh://backy@localhost:2222/home/backy/remote_source.txt + destination: /home/andrew/Projects/backy/tests/data/fileops/remote_destination.txt \ No newline at end of file diff --git a/tests/SuccessHook.yml b/tests/SuccessHook.yml index 8efa72a..53748e9 100755 --- a/tests/SuccessHook.yml +++ b/tests/SuccessHook.yml @@ -10,7 +10,8 @@ commands: successCmd: name: get docker version cmd: docker - getOutput: true - outputToLog: true + output: + file: docker_version_success.txt + toLog: true Args: - "-v" \ No newline at end of file diff --git a/tests/data/fileops/destination.txt b/tests/data/fileops/destination.txt new file mode 100644 index 0000000..4fb6595 --- /dev/null +++ b/tests/data/fileops/destination.txt @@ -0,0 +1 @@ +This is some test data. \ No newline at end of file diff --git a/tests/data/fileops/source.txt b/tests/data/fileops/source.txt new file mode 100644 index 0000000..4fb6595 --- /dev/null +++ b/tests/data/fileops/source.txt @@ -0,0 +1 @@ +This is some test data. \ No newline at end of file diff --git a/tests/docker/README.md b/tests/docker/README.md new file mode 100644 index 0000000..cc32ccc --- /dev/null +++ b/tests/docker/README.md @@ -0,0 +1,39 @@ +SSH test container +================== + +This folder contains a simple Docker-based SSH server used for integration tests. + +Quick start +----------- + +Start the container (builds image if needed): + +```bash +./start.sh +``` + +Stop the container: + +```bash +./stop.sh +``` + +Access +------ + +- SSH endpoint: `localhost:2222` +- Test user: `backy` with password `backy` (password auth enabled) +- Root user: `root` with password `test` +- Public key `backytest.pub` is installed for both `backy` and `root` + +Running tests +------------- + +1. Start the container (`./start.sh`). +2. From the repo root, run your tests (example): + +```bash +GO_TEST_SSH_ADDR=localhost:2222 go test ./... -v +``` + +If your tests rely on an SSH private key, use `tests/docker/backytest` as the private key and restrict access appropriately. diff --git a/tests/docker/buildDocker.sh b/tests/docker/buildDocker.sh index 8bba2bd..977f5df 100755 --- a/tests/docker/buildDocker.sh +++ b/tests/docker/buildDocker.sh @@ -1,4 +1,11 @@ -cd ~/Projects/backy/tests/docker -docker container rm -f ssh_server_container -docker build -t ssh_server_image . -docker run -d -p 2222:22 --name ssh_server_container ssh_server_image \ No newline at end of file +#!/usr/bin/env bash +set -euo pipefail + +# Build and run the test SSH container from the tests/docker directory +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +docker container rm -f ssh_server_container 2>/dev/null || true +docker build -t ssh_server_image "$SCRIPT_DIR" +docker run -d -p 2222:22 --name ssh_server_container ssh_server_image +sleep 5 +ssh-keyscan -p 2222 localhost > $SCRIPT_DIR/known_hosts \ No newline at end of file diff --git a/tests/docker/compose.yml b/tests/docker/compose.yml new file mode 100644 index 0000000..75030cc --- /dev/null +++ b/tests/docker/compose.yml @@ -0,0 +1,8 @@ +services: + ssh_server: + build: . + image: backy_ssh_server:latest + container_name: backy_ssh_server + ports: + - "2222:22" + restart: "no" \ No newline at end of file diff --git a/tests/docker/known_hosts b/tests/docker/known_hosts new file mode 100644 index 0000000..92c97e4 --- /dev/null +++ b/tests/docker/known_hosts @@ -0,0 +1,8 @@ +# localhost:2222 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.14 +# localhost:2222 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.14 +[localhost]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDATufWA1HRnNayIQLjSpA2+P9N6h0WF+jP+abMaINlZkiHFnFVDAoqD5/onVXymskrgQaKEYmBOs+Kv0t+Acvdor2IcvYgFueSm+jkslpSK/uuf1mx0gVJO77S2BIjqyWtUzVv96Iy4Gjt2RsrnalgYNYmi3OyPkG0IUA+3Im+2gztSECCy+nW3R/vaoPLwr4kImpLlrijcSHc4mHOY6BurrcWKNuGrsvTAOKgUZqlya6uDd+yD7fUfsmL1MqBKwZqfP3JAdp/Dd+laNNGcvEM4WhzYFSPfhqblewD0rjbto9MSOSXLyQz5RPmdITj/m5M4lj2ECmcI2gzraDMoj8ZkuJAss50oX6fmVUZestN5jlz7Y7XKEvXuH8qfLHKwaOUTZlcGbfAMz6uSrh8DNT6KzRG4j5nZ9Z5pTn1huz/p6jnJUGuHt2Ez3EK+isM+sHS6TntXavIkebaq7ErcBCO8A1fZFZlhlHoI9o9W62tMY7gbtlGodW8dKxK89+1a88= +# localhost:2222 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.14 +[localhost]:2222 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBK9fEYfiGGgu0Eh7X2JT4jR4+utcfpm6Ee+Cer1x/XbMHzCPZg6YmYy6OaCSms/0VJ/QWxD+0HlsO7sqO5oeO60= +# localhost:2222 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.14 +[localhost]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKT5+Cbi/ynOAPzwv0IaOVBtGFYtW33LIvNUuBKYqqyJ +# localhost:2222 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.14 diff --git a/tests/docker/start.sh b/tests/docker/start.sh new file mode 100755 index 0000000..4e509e8 --- /dev/null +++ b/tests/docker/start.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +echo "Starting SSH test container (building if needed)..." +docker compose -f "$DIR/compose.yml" up -d --build +echo "Container started on localhost:2222" \ No newline at end of file diff --git a/tests/docker/stop.sh b/tests/docker/stop.sh new file mode 100755 index 0000000..6d05724 --- /dev/null +++ b/tests/docker/stop.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +echo "Stopping and removing SSH test container..." +docker compose -f "$DIR/compose.yml" down --remove-orphans +echo "Stopped." \ No newline at end of file diff --git a/tests/example.tmpl b/tests/example.tmpl new file mode 100644 index 0000000..7bb63b6 --- /dev/null +++ b/tests/example.tmpl @@ -0,0 +1,5 @@ +{{ .greeting }}, {{ .name }}! +port: {{ .port | default "8080" }} +envHOME: {{ env "HOME" "" }} +debugYaml: +{{ toYaml . }} \ No newline at end of file diff --git a/tests/vars.yaml b/tests/vars.yaml new file mode 100644 index 0000000..c9bfc17 --- /dev/null +++ b/tests/vars.yaml @@ -0,0 +1,3 @@ +name: Alice +greeting: Hello +port: 9090 \ No newline at end of file