14 Commits

Author SHA1 Message Date
765ef2ee36 v0.11.3
All checks were successful
ci/woodpecker/push/publish-docs Pipeline was successful
ci/woodpecker/tag/gitea Pipeline was successful
ci/woodpecker/tag/publish-docs Pipeline was successful
ci/woodpecker/release/publish-docs Pipeline was successful
2026-01-31 01:06:18 -06:00
995e4f91b5 update docs
All checks were successful
ci/woodpecker/push/publish-docs Pipeline was successful
ci/woodpecker/tag/gitea Pipeline was successful
ci/woodpecker/tag/publish-docs Pipeline was successful
ci/woodpecker/release/publish-docs Pipeline was successful
2025-12-27 00:59:57 -06:00
fa62bc1ec6 v0.11.2
Some checks failed
ci/woodpecker/push/publish-docs Pipeline failed
2025-12-27 00:45:54 -06:00
2766ac997a update CI Configs
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
2025-12-27 00:03:39 -06:00
d0b4c0b9df update CI Configs
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
2025-12-26 23:55:13 -06:00
beabe9f041 Upgraded GoCron; web ui viewer for viewing cron jobs
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
ci/woodpecker/push/publish-docs Pipeline was successful
2025-12-26 23:04:25 -06:00
3a038eeab4 Upgraded GoCron; web ui viewer for viewing cron jobs
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
ci/woodpecker/push/publish-docs Pipeline was successful
2025-12-26 22:58:08 -06:00
a95f903e72 more work on integration testing
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
2025-12-15 13:50:20 -06:00
61add23efb v0.11.1
Some checks failed
ci/woodpecker/push/publish-docs Pipeline was successful
ci/woodpecker/tag/gitea Pipeline was successful
ci/woodpecker/tag/publish-docs Pipeline was successful
ci/woodpecker/release/publish-docs Pipeline was successful
ci/woodpecker/push/go-lint Pipeline failed
2025-12-08 18:17:48 -06:00
b228fca371 update docs
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
ci/woodpecker/push/publish-docs Pipeline was successful
2025-12-08 18:15:40 -06:00
e5a9003ed6 start integration testing
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
2025-12-08 18:13:58 -06:00
803b039849 start integration testing
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
ci/woodpecker/push/publish-docs Pipeline failed
2025-12-08 18:12:31 -06:00
2824f8c703 inject ssh env vars by apppending them to the script
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
2025-12-08 10:08:44 -06:00
cfc00262ff inject ssh env vars by apppending them to the script
Some checks failed
ci/woodpecker/push/go-lint Pipeline failed
2025-11-29 20:55:11 -06:00
45 changed files with 1306 additions and 190 deletions

6
.changes/v0.11.1.md Normal file
View File

@@ -0,0 +1,6 @@
## v0.11.1 - 2025-12-08
### Added
* Started integration testing
### Changed
* inject ssh env vars by apppending them to the script/command if SSH setenv fails
* fix local command injection by running in a shell

3
.changes/v0.11.2.md Normal file
View File

@@ -0,0 +1,3 @@
## v0.11.2 - 2025-12-27
### Added
* Upgraded GoCron; web ui viewer for viewing cron jobs

7
.changes/v0.11.3.md Normal file
View File

@@ -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

View File

@@ -21,7 +21,7 @@ jobs:
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version: '1.24'
cache: true
# More assembly might be required: Docker logins, GPG, etc. It all depends
# on your needs.

View File

@@ -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

View File

@@ -31,6 +31,8 @@ archives:
formats: [zip]
checksum:
name_template: 'checksums.txt'
release:
prerelease: auto
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:

View File

@@ -1,6 +1,6 @@
steps:
golang:
image: golang:1.23
image: golang:1.24
commands:
- go install github.com/goreleaser/goreleaser/v2@v2.7.0
- goreleaser release -f .goreleaser/gitea.yml --release-notes=".changes/$(go run backy.go version -V).md"

View File

@@ -1,6 +1,6 @@
steps:
build:
image: golang
image: golang:1.24
commands:
- go build
- go test

View File

@@ -0,0 +1,19 @@
steps:
golang:
image: golang:1.24
commands:
- go install github.com/goreleaser/goreleaser/v2@v2.7.0
- goreleaser release -f .goreleaser/github.yml --release-notes=".changes/$(go run backy.go version -V).md"
environment:
GITHUB_TOKEN:
from_secret: github_token
when:
event: tag
# release:
# image: goreleaser/goreleaser
# commands:
when:
- event: tag
branch: master

View File

@@ -6,6 +6,25 @@ 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
## v0.11.1 - 2025-12-08
### Added
* Started integration testing
### Changed
* inject ssh env vars by apppending them to the script/command if SSH setenv fails
* fix local command injection by running in a shell
## v0.11.0 - 2025-11-24
### Added
* feat: Package operation `versionCheck` supports regular expressions (see [regexp](https://pkg.go.dev/regexp) package for docs)

View File

@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra"
)
const versionStr = "0.11.0"
const versionStr = "0.11.3"
var (
versionCmd = &cobra.Command{

View File

@@ -12,4 +12,4 @@ External directives are for including data that should not be in the config file
See the docs of each command if the field is supported.
If the file path does not begin with a `/`, the config file's directory will be used as the starting point.
If the file path does not begin with the root directory marker, usually `/`, the config file's directory will be used as the starting point.

View File

@@ -0,0 +1,49 @@
---
title: "Configuring Cron"
weight: 3
description: >
Use Cron to run lists at a specified time.
---
Backy provides an easy-to-use way to execute commands at a specified time.
Adding `cron: 0 0 1 * * *` to a `cmdLists` object will schedule the list at 1 in the morning. See [https://crontab.guru/](https://crontab.guru/) for reference.
GoCron allows one to configure a server to view the jobs in the scheduler. See [GoCron UI GitHub](https://github.com/go-co-op/gocron-ui).
GoCron can be configured or left alone for defaults.
GoCron configuration:
| key | description | type | required | default
| --- | --- | --- | --- | ---
| `bindAddress` | Interface's IP to bind to. Must not contain port. | `string` | no | `:port`
| `port` | Port to use. | `int` | no | `8888`
| `useSeconds` | Whether to parse the second cron field. | `bool` | no | `false`
```yaml {lineNos="true" wrap="true" title="yaml"}
goCron:
bindAddress: "0.0.0.0"
port: 8888
useSeconds: true
cmdLists:
  docker-container-backup: # this can be any name you want
    # all commands have to be defined
    order:
      - stop-docker-container
      - backup-docker-container-script
      - shell-cmd
      - hostname
      - start-docker-container
    notifications:
      - matrix.id
    name: backup-some-container
    cron: "0 0 1 * * *"
  hostname:
    name: hostname
    order:
      - hostname
    notifications:
    - mail.prod-email
```

14
go.mod
View File

@@ -1,13 +1,12 @@
module git.andrewnw.xyz/CyberShell/backy
go 1.23.0
toolchain go1.23.7
go 1.24.0
require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.83.0
github.com/dmarkham/enumer v1.5.11
github.com/go-co-op/gocron v1.37.0
github.com/go-co-op/gocron-ui v0.2.0
github.com/go-co-op/gocron/v2 v2.19.0
github.com/google/uuid v1.6.0
github.com/hashicorp/vault/api v1.20.0
github.com/joho/godotenv v1.5.1
@@ -50,6 +49,8 @@ require (
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -60,6 +61,7 @@ require (
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -75,18 +77,18 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
go.mau.fi/util v0.8.8 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
golang.org/x/mod v0.26.0 // indirect

38
go.sum
View File

@@ -26,7 +26,6 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -36,8 +35,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/go-co-op/gocron-ui v0.2.0 h1:f4JqnIfgzeWYgJcNT5ukn86mnyewbXswsa1To1XQroc=
github.com/go-co-op/gocron-ui v0.2.0/go.mod h1:QvFWbaoVY2fHVzQ3DvYdfFTSz22PaKFtNQuT7rXnj4Y=
github.com/go-co-op/gocron/v2 v2.19.0 h1:OKf2y6LXPs/BgBI2fl8PxUpNAI1DA9Mg+hSeGOS38OU=
github.com/go-co-op/gocron/v2 v2.19.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
@@ -54,9 +55,12 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -84,6 +88,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
@@ -103,13 +109,8 @@ github.com/knadh/koanf/v2 v2.2.2 h1:ghbduIkpFui3L587wavneC9e3WIliCgiCgdxYO/wd7A=
github.com/knadh/koanf/v2 v2.2.2/go.mod h1:abWQc0cBXLSF/PSOMCB/SK+T13NXDsPvOksbpi5e/9Q=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -139,7 +140,6 @@ github.com/pascaldekloe/name v1.0.1 h1:9lnXOHeqeHHnWLbKfH6X98+4+ETVqFqxN09UXSjcM
github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
@@ -148,10 +148,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
@@ -167,15 +167,12 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -191,9 +188,8 @@ github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.8.8 h1:OnuEEc/sIJFhnq4kFggiImUpcmnmL/xpvQMRu5Fiy5c=
go.mau.fi/util v0.8.8/go.mod h1:Y/kS3loxTEhy8Vill513EtPXr+CRDdae+Xj2BXXMy/c=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -281,10 +277,8 @@ golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -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)
@@ -350,7 +385,11 @@ func (command *Command) RunCmd(cmdCtxLogger zerolog.Logger, opts *ConfigOpts) ([
ArgsStr = fmt.Sprintf("%s %s", command.Cmd, ArgsStr)
localCMD = exec.Command("/bin/sh", "-c", ArgsStr)
} else {
localCMD = exec.Command(command.Cmd, command.Args...)
if command.Env != "" || command.Environment != nil {
localCMD = exec.Command("/bin/sh", "-c", ArgsStr)
} else {
localCMD = exec.Command(command.Cmd, command.Args...)
}
}
}
@@ -366,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
@@ -954,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 {

View File

@@ -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.

View File

@@ -169,6 +169,8 @@ func (opts *ConfigOpts) ParseConfigurationFile() *ConfigOpts {
logging.ExitWithMSG("No cron fields detected in any command lists", 1, nil)
}
unmarshalConfigIntoStruct(backyKoanf, "goCron", &opts.GoCron, opts.Logger)
if err := processCmds(opts); err != nil {
logging.ExitWithMSG(err.Error(), 1, &opts.Logger)
}
@@ -425,6 +427,9 @@ func generateFileFetchErrorString(file, fileType string, err error) string {
func validateCommandLists(opts *ConfigOpts) {
var cmdNotFoundSliceErr []error
for cmdListName, cmdList := range opts.CmdConfigLists {
if cmdList.Name == "" {
cmdList.Name = cmdListName
}
// if cron is enabled and cron is not set, delete the list
if opts.cronEnabled && strings.TrimSpace(cmdList.Cron) == "" {
opts.Logger.Debug().Str("cron", "enabled").Str("list", cmdListName).Msg("cron not set, deleting list")
@@ -544,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

View File

@@ -6,30 +6,73 @@ package backy
import (
"fmt"
"net/http"
"strings"
"time"
"git.andrewnw.xyz/CyberShell/backy/pkg/logging"
"github.com/go-co-op/gocron"
"github.com/go-co-op/gocron-ui/server"
"github.com/go-co-op/gocron/v2"
)
var defaultPort = 8888
func (opts *ConfigOpts) Cron() {
s := gocron.NewScheduler(time.Local)
s.TagsUnique()
s, _ := gocron.NewScheduler(gocron.WithLocation(time.Local))
defer func() { _ = s.Shutdown() }()
opts.Logger.Info().Msg("Starting cron mode...")
s.Start()
cmdLists := opts.CmdConfigLists
for _, config := range cmdLists {
cron := strings.TrimSpace(config.Cron)
if cron != "" {
opts.Logger.Info().Str("Scheduling cron list", config.Name).Str("Time", cron).Send()
_, err := s.CronWithSeconds(cron).Tag(config.Name).Do(func(cron string) {
opts.RunListConfig(cron)
}, cron)
job, err := s.NewJob(
gocron.CronJob(cron, opts.GoCron.UseSeconds),
gocron.NewTask(
func(cronStr string) {
opts.RunListConfig(cronStr)
},
cron,
),
gocron.WithName(config.Name))
if err != nil {
logging.ExitWithMSG(fmt.Sprintf("error: %v", err), 1, &opts.Logger)
}
nextRun, _ := job.NextRun()
opts.Logger.Info().Str("Scheduling cron list", config.Name).Str("Time", cron).Str("Next run", nextRun.String()).Send()
}
}
opts.Logger.Info().Msg("Starting cron mode...")
s.StartBlocking()
// start the web UI server
if opts.GoCron.BindAddress == "" {
if opts.GoCron.Port == 0 {
opts.GoCron.BindAddress = ":8888"
} else {
opts.GoCron.BindAddress = fmt.Sprintf(":%d", opts.GoCron.Port)
}
} else {
if opts.GoCron.Port != 0 {
opts.GoCron.BindAddress = fmt.Sprintf("%s:%d", opts.GoCron.BindAddress, opts.GoCron.Port)
} else {
opts.GoCron.BindAddress = fmt.Sprintf("%s:%d", opts.GoCron.BindAddress, defaultPort)
}
}
// consensus := externalip.DefaultConsensus(nil, nil)
// By default Ipv4 or Ipv6 is returned,
// use the function below to limit yourself to IPv4,
// or pass in `6` instead to limit yourself to IPv6.
// consensus.UseIPProtocol(4)
// Get your IP,
// which is never <nil> when err is <nil>.
// ip, err := consensus.ExternalIP()
// if err == nil {
// fmt.Println(ip.String()) // print IPv4/IPv6 in string format
// }
srv := server.NewServer(s, opts.GoCron.Port)
// srv := server.NewServer(scheduler, 8080, server.WithTitle("My Custom Scheduler")) // with custom title if you want to customize the title of the UI (optional)
opts.Logger.Info().Msgf("GoCron UI available at http://%s", opts.GoCron.BindAddress)
opts.Logger.Fatal().Msg(http.ListenAndServe(opts.GoCron.BindAddress, srv.Router).Error())
select {} // wait forever
}

193
pkg/backy/file.go Normal file
View File

@@ -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{}
}

78
pkg/backy/file_test.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,67 +1,67 @@
package backy
import (
"testing"
"time"
)
// import (
// "testing"
// "time"
// )
func TestAddingMetricsForCommand(t *testing.T) {
// func TestAddingMetricsForCommand(t *testing.T) {
// Create a new MetricFile
metricFile := NewMetricsFromFile("test_metrics.json")
// // Create a new MetricFile
// metricFile := NewMetricsFromFile("test_metrics.json")
metricFile, err := LoadMetricsFromFile(metricFile.Filename)
if err != nil {
t.Errorf("Failed to load metrics from file: %v", err)
}
// metricFile, err := LoadMetricsFromFile(metricFile.Filename)
// if err != nil {
// t.Errorf("Failed to load metrics from file: %v", err)
// }
// Add metrics for a command
commandName := "test_command"
if _, exists := metricFile.CommandMetrics[commandName]; !exists {
metricFile.CommandMetrics[commandName] = NewMetrics()
}
// // Add metrics for a command
// commandName := "test_command"
// if _, exists := metricFile.CommandMetrics[commandName]; !exists {
// metricFile.CommandMetrics[commandName] = NewMetrics()
// }
// Update the metrics for the command
executionTime := 1.8 // Example execution time in seconds
success := true // Example success status
metricFile.CommandMetrics[commandName].Update(success, executionTime, time.Now())
// // Update the metrics for the command
// executionTime := 1.8 // Example execution time in seconds
// success := true // Example success status
// metricFile.CommandMetrics[commandName].Update(success, executionTime, time.Now())
// Check if the metrics were updated correctly
if metricFile.CommandMetrics[commandName].SuccessfulExecutions > 50 {
t.Errorf("Expected 1 successful execution, got %d", metricFile.CommandMetrics[commandName].SuccessfulExecutions)
}
if metricFile.CommandMetrics[commandName].TotalExecutions > 50 {
t.Errorf("Expected 1 total execution, got %d", metricFile.CommandMetrics[commandName].TotalExecutions)
}
// if metricFile.CommandMetrics[commandName].TotalExecutionTime != executionTime {
// t.Errorf("Expected execution time %f, got %f", executionTime, metricFile.CommandMetrics[commandName].TotalExecutionTime)
// }
// // Check if the metrics were updated correctly
// if metricFile.CommandMetrics[commandName].SuccessfulExecutions > 50 {
// t.Errorf("Expected 1 successful execution, got %d", metricFile.CommandMetrics[commandName].SuccessfulExecutions)
// }
// if metricFile.CommandMetrics[commandName].TotalExecutions > 50 {
// t.Errorf("Expected 1 total execution, got %d", metricFile.CommandMetrics[commandName].TotalExecutions)
// }
// // if metricFile.CommandMetrics[commandName].TotalExecutionTime != executionTime {
// // t.Errorf("Expected execution time %f, got %f", executionTime, metricFile.CommandMetrics[commandName].TotalExecutionTime)
// // }
err = metricFile.SaveToFile()
if err != nil {
t.Errorf("Failed to save metrics to file: %v", err)
}
// err = metricFile.SaveToFile()
// if err != nil {
// t.Errorf("Failed to save metrics to file: %v", err)
// }
listName := "test_list"
if _, exists := metricFile.ListMetrics[listName]; !exists {
metricFile.ListMetrics[listName] = NewMetrics()
}
// Update the metrics for the list
metricFile.ListMetrics[listName].Update(success, executionTime, time.Now())
if metricFile.ListMetrics[listName].SuccessfulExecutions > 50 {
t.Errorf("Expected 1 successful execution for list, got %d", metricFile.ListMetrics[listName].SuccessfulExecutions)
}
if metricFile.ListMetrics[listName].TotalExecutions > 50 {
t.Errorf("Expected 1 total execution for list, got %d", metricFile.ListMetrics[listName].TotalExecutions)
}
// if metricFile.ListMetrics[listName].TotalExecutionTime > executionTime {
// t.Errorf("Expected execution time %f for list, got %f", executionTime, metricFile.ListMetrics[listName].TotalExecutionTime)
// }
// listName := "test_list"
// if _, exists := metricFile.ListMetrics[listName]; !exists {
// metricFile.ListMetrics[listName] = NewMetrics()
// }
// // Update the metrics for the list
// metricFile.ListMetrics[listName].Update(success, executionTime, time.Now())
// if metricFile.ListMetrics[listName].SuccessfulExecutions > 50 {
// t.Errorf("Expected 1 successful execution for list, got %d", metricFile.ListMetrics[listName].SuccessfulExecutions)
// }
// if metricFile.ListMetrics[listName].TotalExecutions > 50 {
// t.Errorf("Expected 1 total execution for list, got %d", metricFile.ListMetrics[listName].TotalExecutions)
// }
// // if metricFile.ListMetrics[listName].TotalExecutionTime > executionTime {
// // t.Errorf("Expected execution time %f for list, got %f", executionTime, metricFile.ListMetrics[listName].TotalExecutionTime)
// // }
// Save the metrics to a file
err = metricFile.SaveToFile()
if err != nil {
t.Errorf("Failed to save metrics to file: %v", err)
}
// // Save the metrics to a file
// err = metricFile.SaveToFile()
// if err != nil {
// t.Errorf("Failed to save metrics to file: %v", err)
// }
}
// }

View File

@@ -5,7 +5,6 @@
package backy
import (
"bufio"
"bytes"
"fmt"
"io"
@@ -471,17 +470,39 @@ func (command *Command) RunCmdOnHost(cmdCtxLogger zerolog.Logger, opts *ConfigOp
}
defer commandSession.Close()
// Inject environment variables
injectEnvIntoSSH(envVars, commandSession, opts, cmdCtxLogger)
// 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
command.ArgStr = fmt.Sprintf("%s %s", command.Cmd, ArgsStr)
//! environment vars and SSH:
//? skip if commandType is not *script*?
//? option to use SSH setenv or add to beginning?
// Inject environment variables
err = injectEnvIntoSSH(envVars, commandSession, opts, cmdCtxLogger)
if err != nil {
cmdCtxLogger.Info().Err(fmt.Errorf("%v; appending env variables to beginning of command", err)).Send()
command.ArgStr = prependEnvVarsToCommand(envVars, opts, command.Cmd, command.Args, cmdCtxLogger)
}
// Handle command execution based on type
switch command.Type {
case ScriptCommandType:
@@ -489,17 +510,23 @@ 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)
default:
if command.Shell != "" {
ArgsStr = fmt.Sprintf("%s -c '%s %s'", command.Shell, command.Cmd, ArgsStr)
command.ArgStr = fmt.Sprintf("%s -c '%s'", command.Shell, command.ArgStr)
} else {
ArgsStr = fmt.Sprintf("%s %s", command.Cmd, ArgsStr)
if command.Env == "" && command.Environment == nil {
// command.ArgStr = fmt.Sprintf("/bin/sh -c '%s'", command.ArgStr)
command.ArgStr = fmt.Sprintf("%s %s", command.Cmd, ArgsStr)
}
}
cmdCtxLogger.Debug().Str("cmd + args", ArgsStr).Send()
// cmdCtxLogger.Debug().Str("cmd + args", ArgsStr).Send()
if command.Type == UserCommandType && command.UserOperation == "password" {
// cmdCtxLogger.Debug().Msgf("adding stdin")
@@ -522,6 +549,7 @@ func (command *Command) RunCmdOnHost(cmdCtxLogger zerolog.Logger, opts *ConfigOp
}
ArgsStr = fmt.Sprintf("cat %s | chpasswd", passFilePath)
command.ArgStr = ArgsStr
defer passFile.Close()
rmFileFunc := func() {
@@ -530,7 +558,7 @@ func (command *Command) RunCmdOnHost(cmdCtxLogger zerolog.Logger, opts *ConfigOp
defer rmFileFunc()
}
if err := commandSession.Run(ArgsStr); err != nil {
if err := commandSession.Run(command.ArgStr); err != nil {
return collectOutput(&cmdOutBuf, command.Name, cmdCtxLogger, command.Output.ToLog), fmt.Errorf("error running command: %w", err)
}
@@ -606,11 +634,10 @@ func checkPackageVersion(cmdCtxLogger zerolog.Logger, command *Command, commandS
for _, v := range command.Args {
ArgsStr += fmt.Sprintf(" %s", v)
}
var err error
var cmdOut []byte
if cmdOut, err = commandSession.CombinedOutput(ArgsStr); err != nil {
if cmdOut, err = commandSession.CombinedOutput(command.ArgStr); err != nil {
cmdOutBuf.Write(cmdOut)
_, parseErr := parsePackageVersion(string(cmdOut), cmdCtxLogger, command, cmdOutBuf)
@@ -651,28 +678,67 @@ 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.
func (command *Command) prepareScriptBuffer() (*bytes.Buffer, error) {
var buffer bytes.Buffer
for _, envVar := range command.Environment {
fmt.Fprintf(&buffer, "export %s", envVar)
buffer.WriteByte('\n')
}
if command.ScriptEnvFile != "" {
envBuffer, err := readFileToBuffer(command.ScriptEnvFile)
if err != nil {
@@ -694,6 +760,15 @@ 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 {
fmt.Fprintf(&buffer, "export %s", envVar)
buffer.WriteByte('\n')
}
// Handle script environment file
if command.ScriptEnvFile != "" {
envBuffer, err := readFileToBuffer(command.ScriptEnvFile)
@@ -753,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()
@@ -836,7 +897,7 @@ func (r RemoteHostPackageExecutor) RunCmdOnHost(command *Command, commandSession
return checkPackageVersion(cmdCtxLogger, command, commandSession, cmdOutBuf)
}
if command.Shell != "" {
ArgsStr = fmt.Sprintf("%s -c '%s %s'", command.Shell, command.Cmd, ArgsStr)
ArgsStr = fmt.Sprintf("%s -c '%s'", command.Shell, command.ArgStr)
} else {
ArgsStr = fmt.Sprintf("%s %s", command.Cmd, ArgsStr)
}

71
pkg/backy/template.go Normal file
View File

@@ -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)
}

View File

@@ -2,6 +2,7 @@ package backy
import (
"bytes"
"io/fs"
"text/template"
"strings"
@@ -68,7 +69,8 @@ type (
RemoteHost *Host `yaml:"-"`
Args []string `yaml:"args,omitempty"`
Args []string `yaml:"args,omitempty"`
ArgStr string
Dir *string `yaml:"dir,omitempty"`
@@ -78,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"`
@@ -137,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 {
@@ -169,6 +186,12 @@ type (
Type string `yaml:"type"`
}
GoCronOpts struct {
BindAddress string `yaml:"bindAddress"`
UseSeconds bool `yaml:"useSeconds"`
Port int `yaml:"port"`
}
ConfigOpts struct {
// Cmds holds the commands for a list.
// Key is the name of the command,
@@ -182,6 +205,8 @@ type (
// key is the host.
Hosts map[string]*Host `yaml:"hosts"`
GoCron GoCronOpts `yaml:"goCron:"`
Logger zerolog.Logger
// Global log level
@@ -303,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
@@ -313,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
@@ -326,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

View File

@@ -5,6 +5,7 @@
package backy
import (
"bufio"
"bytes"
"context"
"errors"
@@ -22,6 +23,7 @@ import (
"github.com/joho/godotenv"
"github.com/knadh/koanf/v2"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/ssh"
"mvdan.cc/sh/v3/shell"
)
@@ -99,7 +101,7 @@ func NewConfigOptions(configFilePath string, opts ...BackyOptionFunc) *ConfigOpt
return b
}
func injectEnvIntoSSH(envVarsToInject environmentVars, process *ssh.Session, opts *ConfigOpts, log zerolog.Logger) {
func injectEnvIntoSSH(envVarsToInject environmentVars, session *ssh.Session, opts *ConfigOpts, log zerolog.Logger) error {
if envVarsToInject.file != "" {
envPath, envPathErr := getFullPathWithHomeDir(envVarsToInject.file)
if envPathErr != nil {
@@ -113,31 +115,31 @@ func injectEnvIntoSSH(envVarsToInject environmentVars, process *ssh.Session, opt
envMap, err := godotenv.Parse(file)
if err != nil {
log.Error().Str("envFile", envPath).Err(err).Send()
goto errEnvFile
log.Fatal().Str("envFile", envPath).Err(err).Send()
}
for key, val := range envMap {
err = process.Setenv(key, getExternalConfigDirectiveValue(val, opts, AllowedExternalDirectiveVault))
err = session.Setenv(key, getExternalConfigDirectiveValue(val, opts, AllowedExternalDirectiveVault))
if err != nil {
log.Error().Err(err).Send()
log.Info().Err(err).Send()
return fmt.Errorf("failed to set environment variable %s: %w", val, err)
}
}
}
errEnvFile:
// fmt.Printf("%v", envVarsToInject.env)
for _, envVal := range envVarsToInject.env {
// don't append env Vars for Backy
if strings.Contains(envVal, "=") {
envVarArr := strings.Split(envVal, "=")
err := process.Setenv(envVarArr[0], getExternalConfigDirectiveValue(envVarArr[1], opts, AllowedExternalDirectiveVaultFile))
err := session.Setenv(envVarArr[0], getExternalConfigDirectiveValue(envVarArr[1], opts, AllowedExternalDirectiveVaultFile))
if err != nil {
log.Error().Err(err).Send()
log.Info().Err(err).Send()
return fmt.Errorf("failed to set environment variable %s: %w", envVarArr[1], err)
}
}
}
return nil
}
func injectEnvIntoLocalCMD(envVarsToInject environmentVars, process *exec.Cmd, log zerolog.Logger, opts *ConfigOpts) {
@@ -171,6 +173,35 @@ errEnvFile:
process.Env = append(process.Env, os.Environ()...)
}
func prependEnvVarsToCommand(envVars environmentVars, opts *ConfigOpts, command string, args []string, cmdCtxLogger zerolog.Logger) string {
var envPrefix strings.Builder
if envVars.file != "" {
envPath, envPathErr := getFullPathWithHomeDir(envVars.file)
if envPathErr != nil {
cmdCtxLogger.Fatal().Str("envFile", envPath).Err(envPathErr).Send()
}
file, err := os.Open(envPath)
if err != nil {
log.Fatal().Str("envFile", envPath).Err(err).Send()
}
defer file.Close()
envMap, err := godotenv.Parse(file)
if err != nil {
log.Fatal().Str("envFile", envPath).Err(err).Send()
}
for key, val := range envMap {
fmt.Fprintf(&envPrefix, "%s=%s ", key, getExternalConfigDirectiveValue(val, opts, AllowedExternalDirectiveVaultEnv))
}
}
for _, value := range envVars.env {
envVarArr := strings.Split(value, "=")
fmt.Fprintf(&envPrefix, "%s=%s ", envVarArr[0], getExternalConfigDirectiveValue(envVarArr[1], opts, AllowedExternalDirectiveVault))
envPrefix.WriteString("\n")
}
return envPrefix.String() + command + " " + strings.Join(args, " ")
}
func contains(s []string, e string) bool {
for _, a := range s {
if a == e {
@@ -190,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 "]<digits>;<text>"
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)
@@ -403,24 +475,22 @@ func getExternalConfigDirectiveValue(key string, opts *ConfigOpts, allowedDirect
key = replaceVarInString(opts.Vars, key, opts.Logger)
opts.Logger.Debug().Str("expanding external key", key).Send()
if strings.HasPrefix(key, envExternDirectiveStart) {
if newKeyStr, directiveFound := strings.CutPrefix(key, envExternDirectiveStart); directiveFound {
if IsExternalDirectiveEnv(allowedDirectives) {
key = strings.TrimPrefix(key, envExternDirectiveStart)
key = strings.TrimSuffix(key, externDirectiveEnd)
key = strings.TrimSuffix(newKeyStr, externDirectiveEnd)
key = os.Getenv(key)
} else {
opts.Logger.Error().Msgf("Config key with value %s does not support env directive", key)
}
}
if strings.HasPrefix(key, externFileDirectiveStart) {
if newKeyStr, directiveFound := strings.CutPrefix(key, externFileDirectiveStart); directiveFound {
if IsExternalDirectiveFile(allowedDirectives) {
var err error
var keyValue []byte
key = strings.TrimPrefix(key, externFileDirectiveStart)
key = strings.TrimSuffix(key, externDirectiveEnd)
key = strings.TrimSuffix(newKeyStr, externDirectiveEnd)
key, err = getFullPathWithHomeDir(key)
if err != nil {
opts.Logger.Err(err).Send()
@@ -440,11 +510,10 @@ func getExternalConfigDirectiveValue(key string, opts *ConfigOpts, allowedDirect
}
}
if strings.HasPrefix(key, vaultExternDirectiveStart) {
if newKeyStr, directiveFound := strings.CutPrefix(key, vaultExternDirectiveStart); directiveFound {
if IsExternalDirectiveVault(allowedDirectives) {
key = strings.TrimPrefix(key, vaultExternDirectiveStart)
key = strings.TrimSuffix(key, externDirectiveEnd)
key = strings.TrimSuffix(newKeyStr, externDirectiveEnd)
key = GetVaultKey(key, opts, opts.Logger)
} else {
opts.Logger.Error().Msgf("Config key with value %s does not support vault directive", key)

View File

@@ -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"
}

12
tests/FileOps.yml Normal file
View File

@@ -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

View File

@@ -7,10 +7,11 @@ commands:
success:
- successCmd
errorCmd:
successCmd:
name: get docker version
cmd: docker
getOutput: true
outputToLog: true
output:
file: docker_version_success.txt
toLog: true
Args:
- "-v"

View File

@@ -0,0 +1 @@
This is some test data.

View File

@@ -0,0 +1 @@
This is some test data.

39
tests/docker/README.md Normal file
View File

@@ -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.

View File

@@ -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
#!/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

8
tests/docker/compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
ssh_server:
build: .
image: backy_ssh_server:latest
container_name: backy_ssh_server
ports:
- "2222:22"
restart: "no"

8
tests/docker/known_hosts Normal file
View File

@@ -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

7
tests/docker/start.sh Executable file
View File

@@ -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"

7
tests/docker/stop.sh Executable file
View File

@@ -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."

5
tests/example.tmpl Normal file
View File

@@ -0,0 +1,5 @@
{{ .greeting }}, {{ .name }}!
port: {{ .port | default "8080" }}
envHOME: {{ env "HOME" "" }}
debugYaml:
{{ toYaml . }}

22
tests/files_test.go Normal file
View File

@@ -0,0 +1,22 @@
package tests
import (
"fmt"
"os/exec"
"testing"
)
func TestRunCommandFileTest(t *testing.T) {
filePath := "packageCommands.yml"
cmdLineStr := fmt.Sprintf("go run ../backy.go exec host -c checkDockerNoVersion -m localhost --cmdStdOut -f %s", filePath)
cmd := exec.Command("bash", "-c", cmdLineStr)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Command failed: %v, Output: %s", err, string(output))
}
if len(output) == 0 {
t.Fatal("Expected command output, got none")
}
}

81
tests/integration_test.go Normal file
View File

@@ -0,0 +1,81 @@
package tests
import (
"os"
"os/exec"
"testing"
)
func TestIntegration_ExecuteCommand(t *testing.T) {
tests := []struct {
name string
args []string
expectFail bool
}{
{
name: "Version Command",
args: []string{"version"},
expectFail: false,
},
{
name: "Invalid Command",
args: []string{"invalid"},
expectFail: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command("go", append([]string{"run", "../backy.go"}, tt.args...)...)
output, err := cmd.CombinedOutput()
if tt.expectFail && err == nil {
t.Fatalf("Expected failure but got success. Output: %s", string(output))
}
if !tt.expectFail && err != nil {
t.Fatalf("Expected success but got failure. Error: %v, Output: %s", err, string(output))
}
})
}
}
func TestIntegration_ExecuteCommandWithConfig(t *testing.T) {
configFile := "./SuccessHook.yml"
if _, err := os.Stat(configFile); os.IsNotExist(err) {
t.Fatalf("Config file not found: %s", configFile)
}
args := []string{"--config", configFile}
hosts := []string{"localhost"}
execListArgs := setupExecListRunOnHosts([]string{"echoTestSuccess"}, hosts, args)
cmd := exec.Command("go", execListArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Command execution failed. Error: %v, Output: %s", err, string(output))
}
if len(output) == 0 {
t.Fatal("Expected command output, got none")
}
t.Logf("Command output:\n\n%s\n\n", string(output))
}
func setupExecListRunOnHosts(cmds, hosts, args []string) []string {
baseArgs := []string{"run", "../backy.go", "exec", "host"}
for _, h := range hosts {
hostArg := "-m"
hostArg += h
baseArgs = append(baseArgs, hostArg)
}
for _, c := range cmds {
cmdArg := "-c"
cmdArg += c
baseArgs = append(baseArgs, cmdArg)
}
return append(baseArgs, args...)
}

View File

@@ -2,6 +2,8 @@ commands:
checkDockerNoVersion:
type: package
shell: zsh
environment:
- TEST_ENV=production
packages:
- name: "docker-ce-cli"
- name: "docker-ce"

14
tests/run_tests.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# This script runs all Go test files in the tests directory.
echo "Running all tests in the tests directory..."
go test ./tests/... -v
if [ $? -eq 0 ]; then
echo "All tests passed successfully."
else
echo "Some tests failed. Check the output above for details."
exit 1
fi

3
tests/vars.yaml Normal file
View File

@@ -0,0 +1,3 @@
name: Alice
greeting: Hello
port: 9090