diff --git a/.gitignore b/.gitignore index 261efb4..e0988b3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ .vscode # go -/vertag +/wrangler # other dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index eb6dcc5..2ba6242 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,7 +7,7 @@ before: # you may remove this if you don't need go generate - go generate ./... builds: - - main: ./cmd/vertag/vertag.go + - main: ./cmd/wrangler/wrangler.go env: - CGO_ENABLED=0 goos: diff --git a/CHANGELOG.md b/CHANGELOG.md index 450ac30..0451982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -# vertag Changelog +# Wrangler Changelog diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 3595cbf..7526795 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,10 +1,10 @@ -# vertag Maintainers +# Wrangler Maintainers ## Maintainers | Maintainer | GitHub ID | Affiliation | | --------------- | --------- | ----------- | -| Anthony Gibbons | [tonipepperoniuk](https://github.com/tonipepperoniuk) | [Frontier Digital](https://github.com/gofrontier-com/) | +| James Howe | [jameshowe](https://github.com/jameshowe) | [Frontier Digital](https://github.com/gofrontier-com/) | | Craig Anderson | [cda0](https://github.com/cda0) | [Frontier Digital](https://github.com/gofrontier-com/) | | Fraser Davidson | [frasdav](https://github.com/frasdav) | [Frontier Digital](https://github.com/gofrontier-com/) | | Neil Cowlin | [n-cow](https://github.com/n-cow) | [Frontier Digital](https://github.com/gofrontier-com/) | diff --git a/Makefile b/Makefile index 7c31a75..c44dfed 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ build: - go build cmd/vertag/vertag.go + go build cmd/wrangler/wrangler.go install: - go install cmd/vertag/vertag.go + go install cmd/wrangler/wrangler.go test: go test -v ./... diff --git a/README.rst b/README.rst index 4cbcb7f..c5ceb68 100644 --- a/README.rst +++ b/README.rst @@ -1,14 +1,13 @@ -.. image:: https://pkg.go.dev/badge/github.com/gofrontier-com/vertag.svg - :target: https://pkg.go.dev/github.com/gofrontier-com/vertag -.. image:: https://github.com/gofrontier-com/vertag/actions/workflows/ci.yml/badge.svg - :target: https://github.com/gofrontier-com/vertag/actions/workflows/ci.yml +.. image:: https://pkg.go.dev/badge/github.com/gofrontier-com/wrangler.svg + :target: https://pkg.go.dev/github.com/gofrontier-com/wrangler +.. image:: https://github.com/gofrontier-com/wrangler/actions/workflows/ci.yml/badge.svg + :target: https://github.com/gofrontier-com/wrangler/actions/workflows/ci.yml -======= -Vertag -======= +======== +Wrangler +======== -Vertag is a command line tool to manage versions of terraform modules with semver where the modules -are stored in the same repository. +Wrangler is a command line tool for cost management. .. contents:: Table of Contents :local: @@ -17,14 +16,41 @@ are stored in the same repository. About ----- -Vertag has been built to assist with the management of terraform modules that are stored in the same -repository. It is designed to be used as part of a CI/CD pipeline. +Wrangler was built to simplify cloud cost management. In the world of distributed cloud applications, +tracking costs can be challenging. With Wrangler, teams gain control and visibility over costs by +centralising budget configuration and triggering configurable actions when thresholds are reached. --------- -Download --------- +Consuming data +############## + +By default Wrangler will attempt to read data from the CLI standard input in CSV format:: + + timestamp, resource_id, period, value, baseline, currency, category + +- **timestamp** - Date/time of charge (Unix timestamp or ISO 8601 date/time) +- **resource_id** - Unique name/ID of the resource that accrued the charge (any) +- **period** - Period the charge covers (one of [``daily``, ``monthly``]) +- **value** - Amount accrued for period (decimal) +- **baseline** - Optional, if specified then allows for dynamic budgeting (decimal) +- **currency** - Optional, if specified then only budgets or rules of qualifying currencies will apply (ISO-4217 code) +- **category** - Optional, if specified can be used to group budgets / rules (any) + +Data can also be consumed through a `custom provider <#providers>`_. If you wish to disable the default +behaviour then you can pass the ``--no-stdin`` flag. + +Some examples of the types of use-cases Wrangler can cover with the relevant data are: -Binaries and packages of the latest stable release are available at `https://github.com/gofrontier-com/vertag/releases `_. +- Flagging over and underspend of resources +- Forecasting future overspend +- Detecting cost anomalies (e.g. unusual spike or reduction in cost over a defined period of time) +- Automating budgets by analysing previous spend + +.. _providers: + +Custom providers +################ + +TODO ----- Usage @@ -32,26 +58,160 @@ Usage .. code:: bash - $ vertag --help - Vertag is the command line tool for managing terraform modules versioning + $ wrangler --help + Wrangler is a command line tool for cost management. + + Usage: + wrangler [flags] + + Flags: + -c, --config string configuration file (default "/Users/jameshowe/Desktop/frontier/wrangler/.wrangler.yaml") + -h, --help help for wrangler + --no-stdin disable reading data from stdin + -v, --verbose Verbose logging + --version version for wrangler + +Stream data from standard input: + +.. code:: bash + + $ echo "..." | wrangler + +Stream data from a file: + +.. code:: bash + + $ cat costdata.csv | wrangler + +Disable streaming: + +.. code:: bash + + $ wrangler --no-stdin + +------------- +Configuration +------------- + +Examples +-------- + +Monthly budget - Usage: - vertag [flags] +.. code:: yaml + --- + budgets: + - resource_id: my-resource + monthly_amount: 100 + ... + +Daily budget + +.. code:: yaml + --- + budgets: + - resource_id: my-resource + daily_amount: 10 + ... + + +Monthly & daily budget + +.. code:: yaml + --- + budgets: + - resource_id: my-resource + monthly_amount: 100 + daily_amount: 10 + ... + +Currency-specific budgets + +.. code:: yaml + --- + budgets: + - resource_id: my-resource + monthly_amount: 100 + currency: GBP + - resource_id: my-resource + monthly_amount: 140 + currency: USD + ... + +*note - rules will only trigger for cost data matching same currency* + +Budget with local rule + +.. code:: yaml + --- + budgets: + - resource_id: my-resource + monthly_amount: 100 + rules: + - name: nearing-budget-alert + type: percentage + value: 85 + ... + +Budget with global rule + +.. code:: yaml + --- + budgets: + - resource_id: my-resource + monthly_amount: 100 + + rules: + - name: overspend-alert + type: percentage + value: 135 + ... + +Budget with global rule filter + +.. code:: yaml + --- + budgets: + - resource_id: my-resource + monthly_amount: 100 + + rules: + - name: infra-overspend-alert + type: percentage + value: 135 + categories: + - infra + ... + +*note - rule will only trigger for cost data matching same categories* + +Scenarios +--------- + +TODO + +.. _trigger-rules: + +Trigger rules +------------- + +Fixed amount +------------ + +Percentage +---------- + +Overrun +------- + +-------- +Download +-------- - Flags: - -e, --author-email string Email of the commiter - -n, --author-name string Name of the commiter - -d, --dry-run Email of the commiter - -h, --help Version - -m, --modules-dir string Directory of the modules - -o, --output string Output format (default "json") - -u, --remote-url string CI Remote URL - -r, --repo string Root directory of the repo - -s, --short Print just the version number - -v, --version Version +Binaries and packages of the latest stable release are available at `https://github.com/gofrontier-com/wrangler/releases `_. ------------ Contributing ------------ -We welcome contributions to this repository. Please see `CONTRIBUTING.md `_ for more information. +We welcome contributions to this repository. Please see `CONTRIBUTING.md `_ for more information. diff --git a/SUPPORT.md b/SUPPORT.md index 65bb123..58c31ca 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,3 +1,3 @@ -# vertag Support +# Wrangler Support -Thanks for trying out vertag! We're setting up an IRC channel, but please raise a GitHub issue in the meantime if you've got any questions. +Thanks for trying out Wrangler! We're setting up an IRC channel, but please raise a GitHub issue in the meantime if you've got any questions. diff --git a/cmd/vertag/vertag.go b/cmd/wrangler/wrangler.go similarity index 69% rename from cmd/vertag/vertag.go rename to cmd/wrangler/wrangler.go index 49299e1..b46abba 100644 --- a/cmd/vertag/vertag.go +++ b/cmd/wrangler/wrangler.go @@ -4,7 +4,7 @@ import ( "os" "github.com/gofrontier-com/go-utils/output" - "github.com/gofrontier-com/vertag/pkg/cmd/vertag" + "github.com/gofrontier-com/wrangler/pkg/cmd/wrangler" ) var ( @@ -14,7 +14,7 @@ var ( ) func main() { - command := vertag.NewRootCmd(version, commit, date) + command := wrangler.NewRootCmd(version, commit, date) if err := command.Execute(); err != nil { output.PrintlnError(err) os.Exit(1) diff --git a/cmd/vertag/vertag_test.go b/cmd/wrangler/wrangler_test.go similarity index 100% rename from cmd/vertag/vertag_test.go rename to cmd/wrangler/wrangler_test.go diff --git a/go.mod b/go.mod index 19e5fb7..c5114c5 100644 --- a/go.mod +++ b/go.mod @@ -1,42 +1,44 @@ -module github.com/gofrontier-com/vertag +module github.com/gofrontier-com/wrangler go 1.20 require ( github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be - github.com/go-git/go-git/v5 v5.11.0 github.com/gofrontier-com/go-utils v0.1.0 + github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.6.1 - go.hein.dev/go-version v0.1.0 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 + gopkg.in/yaml.v2 v2.4.0 ) require ( - dario.cat/mergo v1.0.0 // indirect - github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect - github.com/cloudflare/circl v1.3.3 // indirect - github.com/cyphar/filepath-securejoin v0.2.4 // indirect - github.com/emirpasic/gods v1.18.1 // indirect - github.com/fatih/color v1.13.0 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.5.0 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/fatih/color v1.14.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect - github.com/pjbgf/sha1cd v0.3.0 // indirect - github.com/sergi/go-diff v1.1.0 // indirect - github.com/skeema/knownhosts v1.2.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/rivo/uniseg v0.4.6 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/rs/zerolog v1.32.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.16.0 // indirect - golang.org/x/mod v0.14.0 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/tools v0.16.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - sigs.k8s.io/yaml v1.1.0 // indirect + github.com/spf13/viper v1.18.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 93dcff2..1a7121e 100644 --- a/go.sum +++ b/go.sum @@ -1,99 +1,32 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= -github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +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= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= -github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= -github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrontier-com/go-utils v0.1.0 h1:n/PzHfokrWD7Z1Pr8JLwazNu5JTzUwUtkXk3FK34KoU= github.com/gofrontier-com/go-utils v0.1.0/go.mod h1:iNQRvodEbHcUj2pRQcayq+anXy30/UKhAKU/smz5i/8= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -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.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/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -101,185 +34,85 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= -github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= +github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= -github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.hein.dev/go-version v0.1.0 h1:hz3epLdx+cim8EN9XRt6pqAHxwWVW0D87Xm3mUbvKvI= -go.hein.dev/go-version v0.1.0/go.mod h1:WOEm7DWMroRe5GdUgHMvx+Pji5WWIpMuXmK/3foylXs= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190812172437-4e8604ab3aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230206171751-46f607a40771 h1:xP7rWLUr1e1n2xkK5YB4LI0hPEy3LJC6Wk+D4pGlOJg= +golang.org/x/exp v0.0.0-20230206171751-46f607a40771/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190812233024-afc3694995b6/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= -golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= -sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/pkg/alerts/cli_alert_provider.go b/pkg/alerts/cli_alert_provider.go new file mode 100644 index 0000000..a5c8da8 --- /dev/null +++ b/pkg/alerts/cli_alert_provider.go @@ -0,0 +1,45 @@ +package alerts + +import ( + "github.com/gofrontier-com/go-utils/output" + "github.com/gofrontier-com/wrangler/pkg/core" + "github.com/gofrontier-com/wrangler/pkg/serializers" + "github.com/rs/zerolog/log" +) + +type CliBudgetAlertService struct { +} + +func (h CliBudgetAlertService) HandleViolations(violations []core.BudgetRuleViolation) error { + log.Trace(). + Int("violation_count", len(violations)). + Msg("Processing violations") + rows := [][]string{ + {"Resource ID", "Rule name", "Condition", "Date", "Budget amount", "Actual amount"}, + } + for _, v := range violations { + rows = append(rows, []string{ + v.ResourceId, + v.Name, + v.Description, + v.Date, + core.FormatCurrency(v.BudgetAmount, v.Currency), + core.FormatCurrency(v.ActualAmount, v.Currency), + }) + } + + output.PrintlnWarn( + serializers.SerializeTable(rows, serializers.TableOptions{ + FirstRowIsHeader: true, + HasBorder: true, + HeaderAlignment: serializers.AlignLeft, + Alignment: serializers.AlignLeft, + }), + ) + + return nil +} + +func init() { + core.RegisterService("alert_provider.cli", CliBudgetAlertService{}) +} diff --git a/pkg/alerts/msteams_alert_provider.go b/pkg/alerts/msteams_alert_provider.go new file mode 100644 index 0000000..9d53e0e --- /dev/null +++ b/pkg/alerts/msteams_alert_provider.go @@ -0,0 +1,20 @@ +package alerts + +import ( + "github.com/gofrontier-com/wrangler/pkg/core" + "github.com/rs/zerolog/log" +) + +type MSTeamsBudgetAlertService struct { +} + +func (h MSTeamsBudgetAlertService) HandleViolations(violations []core.BudgetRuleViolation) error { + log.Trace(). + Int("violation_count", len(violations)). + Msg("Processing violations") + return nil +} + +func init() { + core.RegisterService("alert_provider.msteams", MSTeamsBudgetAlertService{}) +} diff --git a/pkg/cmd/vertag/vertag.go b/pkg/cmd/vertag/vertag.go deleted file mode 100644 index bcbb0eb..0000000 --- a/pkg/cmd/vertag/vertag.go +++ /dev/null @@ -1,108 +0,0 @@ -package vertag - -import ( - "os" - "path" - - "github.com/common-nighthawk/go-figure" - "github.com/gofrontier-com/go-utils/output" - "github.com/gofrontier-com/vertag/pkg/core" - "github.com/spf13/cobra" - goVersion "go.hein.dev/go-version" -) - -var ( - outputFmt string - shortened bool - modulesDir string - repoRoot string - authorName string - authorEmail string - remoteUrl string - dryRun bool - vers bool - help bool -) - -func Apply(repoRoot string, modulesDir string, authorName string, authorEmail string, dryRun bool, remoteUrl string) error { - myFigure := figure.NewFigure("VerTag", "", true) - myFigure.Print() - - vt := core.NewVertag(repoRoot, modulesDir, authorName, authorEmail, dryRun, remoteUrl) - err := vt.Init() - if err != nil { - output.PrintlnError(err) - return err - } - - err = vt.GetRefs() - if err != nil { - output.PrintlnError(err) - return err - } - - err = vt.GetChanges() - if err != nil { - output.PrintlnError(err) - return err - } - - err = vt.CalculateNextTags() - if err != nil { - output.PrintlnError(err) - return err - } - - err = vt.WriteTags() - if err != nil { - output.PrintlnError(err) - return err - } - - return nil -} - -func NewRootCmd(version string, commit string, date string) *cobra.Command { - cmd := &cobra.Command{ - Use: "vertag", - Short: "Vertag is the command line tool for managing terraform modules versioning", - RunE: func(cmd *cobra.Command, args []string) error { - if help { - if err := cmd.Help(); err != nil { - return err - } - } - - if vers { - resp := goVersion.FuncWithOutput(shortened, version, commit, date, outputFmt) - output.PrintfInfo(resp) - - return nil - } - - if err := Apply(repoRoot, modulesDir, authorName, authorEmail, dryRun, remoteUrl); err != nil { - return err - } - - return nil - }, - } - - wd, err := os.Getwd() - if err != nil { - panic(err) - } - - cmd.Flags().StringVarP(&modulesDir, "modules-dir", "m", path.Join(wd, "modules"), "Directory of the modules") - cmd.Flags().StringVarP(&repoRoot, "repo", "r", wd, "Root directory of the repo") - cmd.Flags().StringVarP(&authorName, "author-name", "n", wd, "Name of the commiter") - cmd.Flags().StringVarP(&authorEmail, "author-email", "e", wd, "Email of the commiter") - cmd.Flags().StringVarP(&remoteUrl, "remote-url", "u", "", "CI Remote URL") - cmd.Flags().BoolVarP(&dryRun, "dry-run", "d", false, "Email of the commiter") - cmd.Flags().BoolVarP(&vers, "version", "v", false, "Version") - cmd.Flags().BoolVarP(&shortened, "short", "s", false, "Print just the version number") - cmd.Flags().StringVarP(&outputFmt, "output", "o", "json", "Output format") - cmd.Flags().BoolVarP(&help, "help", "h", false, "Version") - - return cmd -} diff --git a/pkg/cmd/wrangler/wrangler.go b/pkg/cmd/wrangler/wrangler.go new file mode 100644 index 0000000..ed49ee6 --- /dev/null +++ b/pkg/cmd/wrangler/wrangler.go @@ -0,0 +1,128 @@ +package wrangler + +import ( + "os" + "path" + "reflect" + "strings" + + "github.com/common-nighthawk/go-figure" + "github.com/gofrontier-com/go-utils/output" + _ "github.com/gofrontier-com/wrangler/pkg/alerts" + "github.com/gofrontier-com/wrangler/pkg/core" + _ "github.com/gofrontier-com/wrangler/pkg/data" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var ( + configFile string + outputFmt string + disableStdin bool + verbose bool +) + +func printHeader(title string, version string, tagline string) { + output.Println() + output.Println(strings.Repeat("=", 80)) + appFigure := figure.NewFigure(title, "slant", true) + appFigure.Print() + output.PrintfInfo("Version: %s", version) + if len(tagline) > 0 { + tagFigure := figure.NewFigure(tagline, "pepper", true) + tagFigure.Print() + } + output.Println(strings.Repeat("-", 80)) +} + +func getDataProviders() []core.CostDataProvider { + return core.GetServices[core.CostDataProvider]() +} + +func getAlertProviders() []core.BudgetAlertService { + return core.GetServices[core.BudgetAlertService]() +} + +func NewRootCmd(version string, commit string, date string) *cobra.Command { + cmd := &cobra.Command{ + Use: "wrangler", + Short: "Wrangler is a command line tool for cost management.", + Version: version, + RunE: func(cmd *cobra.Command, args []string) error { + core.ConfigureLogger(verbose) + // TODO: validate input + printHeader(cmd.Name(), version, "Keeping costs under control") + + log.Info().Msg("Reading configuration...") + config, err := core.LoadConfig(configFile) + if err != nil { + log.Fatal(). + Err(err). + Str("file", configFile). + Msg("Unable to load config.") + } + + cliParams := core.CliParameters{ + ConfigFile: configFile, + OutputFmt: outputFmt, + DisableStdin: disableStdin, + } + + records := []core.CostRecord{} + for _, svc := range getDataProviders() { + data, err := svc.GetData(cliParams) + if err != nil { + log.Fatal(). + Err(err). + Str("provider", reflect.TypeOf(svc).Name()). + Msg("Provider failed with error") + } + + records = append(records, data...) + } + + log.Info().Msgf("Evaluating %d record(s)...", len(records)) + violations, err := core.CheckBudgets(records, config) + if err != nil { + return err + } + + violationCount := len(violations) + if violationCount > 0 { + log.Info().Msgf("%d violation(s) found", violationCount) + + for _, svc := range getAlertProviders() { + err := svc.HandleViolations(violations) + if err != nil { + log.Fatal(). + Err(err). + Str("provider", reflect.TypeOf(svc).Name()). + Msg("Provider failed with error") + } + } + + log.Fatal().Msg("Failed with violations") + } + + // TODO: save config + + log.Info().Msg("Check complete.") + return nil + }, + } + + wd, err := os.Getwd() + if err != nil { + panic(err) + } + + if strings.Contains(version, ".") { + cmd.SetVersionTemplate("{{printf \"v%s\" .Version}}\n") + } else { + cmd.SetVersionTemplate("{{printf \"%s\" .Version}}\n") + } + cmd.Flags().StringVarP(&configFile, "config", "c", path.Join(wd, ".wrangler.yaml"), "configuration file") + cmd.Flags().BoolVar(&disableStdin, "no-stdin", false, "disable reading data from stdin") + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose logging") + return cmd +} diff --git a/pkg/cmd/vertag/vertag_test.go b/pkg/cmd/wrangler/wrangler_test.go similarity index 76% rename from pkg/cmd/vertag/vertag_test.go rename to pkg/cmd/wrangler/wrangler_test.go index a4d2a8c..c6e4ea4 100644 --- a/pkg/cmd/vertag/vertag_test.go +++ b/pkg/cmd/wrangler/wrangler_test.go @@ -1,4 +1,4 @@ -package vertag +package wrangler import ( "testing" @@ -7,7 +7,7 @@ import ( func TestNewCmdRoot(t *testing.T) { cmd := NewRootCmd("0.0.0", "commitid", "date") - if cmd.Use != "vertag" { + if cmd.Use != "wrangler" { t.Errorf("Use is not correct") } } diff --git a/pkg/core/budgets.go b/pkg/core/budgets.go new file mode 100644 index 0000000..845c9d8 --- /dev/null +++ b/pkg/core/budgets.go @@ -0,0 +1,207 @@ +package core + +import ( + "fmt" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +type BudgetRuleType string + +const ( + PercentageRule BudgetRuleType = "percentage" + FixedRule BudgetRuleType = "fixed" + OverrunRule BudgetRuleType = "overrun" +) + +type Budget struct { + ResourceId string `mapstructure:"resource_id"` + Category *string `mapstructure:"category,omitempty"` + MonthlyAmount *float64 `mapstructure:"monthly_amount,omitempty"` + DailyAmount *float64 `mapstructure:"daily_amount,omitempty"` + Currency Currency `mapstructure:"currency"` + Rules *[]BudgetRule `mapstructure:"rules,omitempty"` +} + +func (b *Budget) HasAmount() bool { + return b.MonthlyAmount != nil || b.DailyAmount != nil +} + +func (b *Budget) GetAmount(period Period) *float64 { + if period == Monthly { + return b.MonthlyAmount + } else if period == Daily { + return b.DailyAmount + } else { + return nil + } +} + +type BudgetRule struct { + Name string `mapstructure:"name,omitempty"` + Type BudgetRuleType `mapstructure:"type"` + Value float64 `mapstructure:"value"` + Period Period `mapstructure:"period,omitempty"` + Currency Currency `mapstructure:"currency,omitempty"` + Categories []string `mapstructure:"categories,omitempty"` +} + +func (r *BudgetRule) Evaluate(budget Budget, record CostRecord) bool { + log.Trace(). + Fields(map[string]interface{}{ + "rule": r, + }).Msg("") + // if rule is not scoped to a specific charge period then apply rule to any charge + period := r.Period + if period == "" { + period = record.Period + } + if period == "" { + log.Warn(). + Str("rule", r.Name). + Msg("Unable to determine charge period, rule will be skipped") + return false + } else if period != record.Period { + log.Warn(). + Str("rule", r.Name). + Str("rule_period", string(period)). + Str("charge_period", string(record.Period)). + Msg("Unable to determine charge period, rule will be skipped") + return false + } + + amountPtr := budget.GetAmount(period) + if r.Type != FixedRule && amountPtr == nil { + log.Warn(). + Str("rule", r.Name). + Str("rule_period", string(period)). + Msgf("No %s budget found, rule will be skipped", period) + return false + } + + if r.Type == FixedRule { + return record.Value >= r.Value + } else if r.Type == PercentageRule { + amount := *budget.GetAmount(period) + return record.Value >= (r.Value/100)*amount + } else if r.Type == OverrunRule { + amount := *budget.GetAmount(period) + return (record.Value - amount) >= r.Value + } else { + return false + } +} + +func (r *BudgetRule) GetDescription() string { + switch r.Type { + case PercentageRule: + return fmt.Sprintf("actual amount >= %.2f%% of budget", r.Value) + case FixedRule: + return fmt.Sprintf("actual amount >= %s", FormatCurrency(r.Value, r.Currency)) + case OverrunRule: + return fmt.Sprintf("actual amount >= %s overrun", FormatCurrency(r.Value, r.Currency)) + default: + return "" + } +} + +func CheckBudgets(data []CostRecord, config Config) ([]BudgetRuleViolation, error) { + violations := []BudgetRuleViolation{} + for _, record := range data { + log.Debug(). + Fields(map[string]interface{}{ + "record": record, + }). + Msg("") + budgetComparer := func(b Budget) bool { + return strings.EqualFold(b.ResourceId, record.ResourceId) && + (b.Currency == "" || b.Currency == record.Currency) + } + budget, budgetExists := FindInSlice[Budget](*config.Budgets, budgetComparer) + if !budgetExists && record.Baseline > 0 { + log.Trace(). + Str("resource_id", record.ResourceId). + Msgf("No %s budget defined for resource", record.Period) + budget = Budget{ + ResourceId: record.ResourceId, + Currency: record.Currency, + } + if record.Period == Monthly { + budget.MonthlyAmount = &record.Baseline + } else if record.Period == Daily { + budget.DailyAmount = &record.Baseline + } + log.Trace(). + Fields(map[string]interface{}{ + "monthly_amount": budget.GetAmount(Monthly), + "daily_amount": budget.GetAmount(Daily), + }).Msg("Budget estimated from baseline") + } + if budget.HasAmount() { + if budget.Rules != nil { + rules := *budget.Rules + log.Trace(). + Str("resource_id", record.ResourceId). + Int("rule_count", len(rules)). + Msg("Evaluating local rules") + localViolations, err := getViolations(rules, budget, record) + if err != nil { + return nil, fmt.Errorf("error evaluating local rules %+v", err) + } + violations = append(violations, localViolations...) + } else { + log.Trace().Msg("No local rules found.") + } + + if config.Rules != nil { + rules := *config.Rules + log.Trace(). + Str("resource_id", record.ResourceId). + Int("rule_count", len(rules)). + Msg("Evaluating global rules") + globalViolations, err := getViolations(rules, budget, record) + if err != nil { + return nil, err + } + violations = append(violations, globalViolations...) + } else { + log.Trace().Msg("No global rules found.") + } + } + } + + return violations, nil +} + +func getViolations(rules []BudgetRule, budget Budget, record CostRecord) ([]BudgetRuleViolation, error) { + violations := []BudgetRuleViolation{} + for _, rule := range rules { + shouldEvaluate := true + if len(rule.Categories) > 0 { + _, shouldEvaluate = FindInSlice[string](rule.Categories, func(category string) bool { + return strings.EqualFold(record.Category, category) + }) + if shouldEvaluate { + log.Trace(). + Str("resource_id", record.ResourceId). + Str("category_match", record.Category). + Msg("Rule matched by category") + } + } + shouldEvaluate = shouldEvaluate && (rule.Currency == "" || rule.Currency == record.Currency) + if shouldEvaluate && rule.Evaluate(budget, record) { + violations = append(violations, BudgetRuleViolation{ + ResourceId: record.ResourceId, + Name: rule.Name, + Description: rule.GetDescription(), + Date: record.Timestamp.Format(time.DateOnly), + BudgetAmount: *budget.GetAmount(record.Period), + ActualAmount: record.Value, + Currency: record.Currency, + }) + } + } + return violations, nil +} diff --git a/pkg/core/budgets_test.go b/pkg/core/budgets_test.go new file mode 100644 index 0000000..5a520f7 --- /dev/null +++ b/pkg/core/budgets_test.go @@ -0,0 +1,672 @@ +package core + +import "testing" + +func float64Ptr(f float64) *float64 { + return &f +} + +func TestCheckBudgets_ReturnsViolationWhenMonthlyFixedRuleIsExceeded(t *testing.T) { + config := Config{ + Budgets: &[]Budget{ + { + ResourceId: "app-1", + MonthlyAmount: float64Ptr(200), + }, + { + ResourceId: "app-2", + MonthlyAmount: float64Ptr(50), + Rules: &[]BudgetRule{ + { + Name: "test-rule", + Type: FixedRule, + Period: Monthly, + Value: 20, + }, + }, + }, + }, + } + data := []CostRecord{ + { + ResourceId: "app-2", + Value: 100, + Currency: GBP, + Period: Monthly, + }, + } + actual, err := CheckBudgets(data, config) + expected := []BudgetRuleViolation{ + { + ResourceId: "app-2", + Description: "actual amount >= 20.00", + BudgetAmount: 50, + ActualAmount: 100, + }, + } + if err != nil { + t.Errorf("CheckBudgets(data, config) failed with error %t", err) + } + if len(actual) != len(expected) { + t.Errorf("CheckBudgets(data, config) = %d; want %d", len(actual), len(expected)) + } + + violation := actual[0] + if violation.ResourceId != expected[0].ResourceId { + t.Errorf("Violation.ResourceId = %s; want %s", violation.ResourceId, expected[0].ResourceId) + } + if violation.Description != expected[0].Description { + t.Errorf("Violation.Description = %s; want %s", violation.Description, expected[0].Description) + } + if violation.BudgetAmount != expected[0].BudgetAmount { + t.Errorf("Violation.BudgetAmount = %f; want %f", violation.BudgetAmount, expected[0].BudgetAmount) + } + if violation.ActualAmount != expected[0].ActualAmount { + t.Errorf("Violation.ActualAmount = %f; want %f", violation.ActualAmount, expected[0].ActualAmount) + } +} + +func TestCheckBudgets_ReturnsViolationWhenDailyFixedRuleIsExceeded(t *testing.T) { + config := Config{ + Budgets: &[]Budget{ + { + ResourceId: "app-1", + DailyAmount: float64Ptr(15), + }, + { + ResourceId: "app-2", + DailyAmount: float64Ptr(20), + Rules: &[]BudgetRule{ + { + Name: "test-rule", + Type: FixedRule, + Period: Daily, + Value: 10, + }, + }, + }, + }, + } + data := []CostRecord{ + { + ResourceId: "app-2", + Value: 100, + Currency: GBP, + Period: Daily, + }, + } + actual, err := CheckBudgets(data, config) + expected := []BudgetRuleViolation{ + { + ResourceId: "app-2", + Description: "actual amount >= 10.00", + BudgetAmount: 20, + ActualAmount: 100, + }, + } + if err != nil { + t.Errorf("CheckBudgets(data, config) failed with error %t", err) + } + if len(actual) != len(expected) { + t.Errorf("CheckBudgets(data, config) = %d; want %d", len(actual), len(expected)) + } + + violation := actual[0] + if violation.ResourceId != expected[0].ResourceId { + t.Errorf("Violation.ResourceId = %s; want %s", violation.ResourceId, expected[0].ResourceId) + } + if violation.Description != expected[0].Description { + t.Errorf("Violation.Description = %s; want %s", violation.Description, expected[0].Description) + } + if violation.BudgetAmount != expected[0].BudgetAmount { + t.Errorf("Violation.BudgetAmount = %f; want %f", violation.BudgetAmount, expected[0].BudgetAmount) + } + if violation.ActualAmount != expected[0].ActualAmount { + t.Errorf("Violation.ActualAmount = %f; want %f", violation.ActualAmount, expected[0].ActualAmount) + } +} + +func TestCheckBudgets_ReturnsNoViolationsWhenNoBudgets(t *testing.T) { + config := Config{ + Budgets: &[]Budget{}, + } + data := []CostRecord{ + { + ResourceId: "test-svc", + Value: 100, + }, + } + actual, err := CheckBudgets(data, config) + expected := []BudgetRuleViolation{} + if err != nil { + t.Errorf("CheckBudgets(data, config) failed with error %t", err) + } + if len(actual) != len(expected) { + t.Errorf("CheckBudgets(data, config) = %d; want %d", len(actual), len(expected)) + } +} + +func TestCheckBudgets_ReturnsNoViolationsWhenNoBudgetsMatchResource(t *testing.T) { + config := Config{ + Budgets: &[]Budget{ + { + ResourceId: "app-1", + MonthlyAmount: float64Ptr(100), + }, + { + ResourceId: "app-2", + MonthlyAmount: float64Ptr(50), + }, + }, + } + data := []CostRecord{ + { + ResourceId: "app-3", + Value: 100, + }, + } + actual, err := CheckBudgets(data, config) + expected := []BudgetRuleViolation{} + if err != nil { + t.Errorf("CheckBudgets(data, config) failed with error %t", err) + } + if len(actual) != len(expected) { + t.Errorf("CheckBudgets(data, config) = %d; want %d", len(actual), len(expected)) + } +} + +func TestCheckBudgets_ReturnsNoViolationsWhenNoBudgetsMatchCurrency(t *testing.T) { + config := Config{ + Budgets: &[]Budget{ + { + ResourceId: "app-1", + MonthlyAmount: float64Ptr(200), + }, + { + ResourceId: "app-2", + MonthlyAmount: float64Ptr(50), + Currency: EUR, + Rules: &[]BudgetRule{ + { + Name: "test-rule", + Type: FixedRule, + Value: 10, + Period: Monthly, + }, + }, + }, + }, + } + data := []CostRecord{ + { + ResourceId: "app-2", + Value: 100, + Currency: GBP, + }, + } + actual, err := CheckBudgets(data, config) + expected := []BudgetRuleViolation{} + if err != nil { + t.Errorf("CheckBudgets(data, config) failed with error %t", err) + } + if len(actual) != len(expected) { + t.Errorf("CheckBudgets(data, config) = %d; want %d", len(actual), len(expected)) + } +} + +/** Budget **/ + +func TestBudget_GetAmount_ReturnsDailyAmount(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + DailyAmount: float64Ptr(5.00), + } + actual := *budget.GetAmount(Daily) + expected := 5.00 + if actual != expected { + t.Errorf("GetAmount(Daily) = %f; want %f", actual, expected) + } +} + +func TestBudget_GetAmount_ReturnsMonthlyAmount(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + DailyAmount: float64Ptr(5.00), + } + actual := *budget.GetAmount(Monthly) + expected := 100.00 + if actual != expected { + t.Errorf("GetAmount(Daily) = %f; want %f", actual, expected) + } +} + +func TestBudget_HasAmount_ReturnsTrueWhenMonthlyBudgetIsSet(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + actual := budget.HasAmount() + expected := true + if actual != expected { + t.Errorf("HasAmount() = %t; want %t", actual, expected) + } +} + +func TestBudget_HasAmount_ReturnsTrueWhenDailyBudgetIsSet(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(10.00), + } + actual := budget.HasAmount() + expected := true + if actual != expected { + t.Errorf("HasAmount() = %t; want %t", actual, expected) + } +} + +func TestBudget_HasAmount_ReturnsFalseWhenNoAmountIsSet(t *testing.T) { + budget := Budget{} + actual := budget.HasAmount() + expected := false + if actual != expected { + t.Errorf("HasAmount() = %t; want %t", actual, expected) + } +} + +/** Fixed budget rules **/ + +func TestFixedBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsRuleValue(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(0), + } + rule := BudgetRule{ + Name: "test-rule", + Type: FixedRule, + Value: 10.00, + } + record := CostRecord{ + Value: 11.00, + Period: Daily, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestFixedBudgetRule_Evaluate_ReturnsFalseWhenNoDailyAmountSet(t *testing.T) { + budget := Budget{} + rule := BudgetRule{ + Name: "test-rule", + Type: FixedRule, + } + record := CostRecord{} + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestFixedBudgetRule_Evaluate_ReturnsFalseWhenNoMonthlyAmountSet(t *testing.T) { + budget := Budget{} + rule := BudgetRule{ + Name: "test-rule", + Type: FixedRule, + } + record := CostRecord{} + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestFixedBudgetRule_Evaluate_ReturnsFalseWhenCostValueIsEqualToRuleValue(t *testing.T) { + budget := Budget{} + rule := BudgetRule{ + Name: "test-rule", + Type: FixedRule, + Value: 10.00, + } + record := CostRecord{ + Value: 10.00, + } + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestFixedBudgetRule_Evaluate_ReturnsFalseWhenCostValueIsLessThanRuleValue(t *testing.T) { + budget := Budget{} + rule := BudgetRule{ + Name: "test-rule", + Type: FixedRule, + Value: 10.00, + } + record := CostRecord{ + Value: 9.00, + } + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +/** Percentage budget rules **/ + +func TestPercentageBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsPercentageOfDailyBudgetUnder100Percent(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(10.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Daily, + Value: 50, + } + record := CostRecord{ + Value: 7.00, + Period: Daily, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsPercentageOfMonthlyBudgetUnder100Percent(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Monthly, + Value: 50, + } + record := CostRecord{ + Value: 60.00, + Period: Monthly, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsPercentageOfDailyBudgetOf100Percent(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(10.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Daily, + Value: 100, + } + record := CostRecord{ + Value: 11.00, + Period: Daily, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsPercentageOfMonthlyBudgetOf100Percent(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Monthly, + Value: 100, + } + record := CostRecord{ + Value: 101.00, + Period: Monthly, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsPercentageOfDailyBudgetOver100Percent(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(10.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Daily, + Value: 135, + } + record := CostRecord{ + Value: 15.00, + Period: Daily, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsPercentageOfMonthlyBudgetOver100Percent(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Monthly, + Value: 135, + } + record := CostRecord{ + Value: 150.00, + Period: Monthly, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsFalseWhenNoMonthlyAmountSet(t *testing.T) { + budget := Budget{} + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Monthly, + } + record := CostRecord{} + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsFalseWhenNoDailyAmountSet(t *testing.T) { + budget := Budget{} + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Period: Daily, + } + record := CostRecord{} + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestPercentageBudgetRule_Evaluate_ReturnsFalseForMonthlyBudgetWhenDailyCost(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: PercentageRule, + Value: 135, + } + record := CostRecord{ + Value: 10, + Period: Daily, + } + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +/** Overrun budget rules **/ + +func TestOverrunBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsDailyBudgetAmountPlusRuleValue(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(10.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: OverrunRule, + Period: Daily, + Value: 5, + } + record := CostRecord{ + Value: 17.00, + Period: Daily, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestOverrunBudgetRule_Evaluate_ReturnsTrueWhenCostValueExceedsMonthlyBudgetAmountPlusRuleValue(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: OverrunRule, + Period: Monthly, + Value: 20, + } + record := CostRecord{ + Value: 125.00, + Period: Monthly, + } + actual := rule.Evaluate(budget, record) + expected := true + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestOverrunBudgetRule_Evaluate_ReturnsFalseWhenCostValueDoesNotExceedMonthlyBudget(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: OverrunRule, + Period: Monthly, + Value: 20, + } + record := CostRecord{ + Value: 80.00, + } + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestOverrunBudgetRule_Evaluate_ReturnsFalseWhenCostValueDoesNotExceedDailyBudget(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(10.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: OverrunRule, + Period: Daily, + Value: 10, + } + record := CostRecord{ + Value: 8.00, + } + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestOverrunBudgetRule_Evaluate_ReturnsFalseWhenCostValueExceedsMonthlyBudgetButNotOverrun(t *testing.T) { + budget := Budget{ + MonthlyAmount: float64Ptr(100.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: OverrunRule, + Period: Monthly, + Value: 20, + } + record := CostRecord{ + Value: 110.00, + } + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestOverrunBudgetRule_Evaluate_ReturnsFalseWhenCostValueExceedsDailyBudgetButNotOverrun(t *testing.T) { + budget := Budget{ + DailyAmount: float64Ptr(10.00), + } + rule := BudgetRule{ + Name: "test-rule", + Type: OverrunRule, + Period: Daily, + Value: 5, + } + record := CostRecord{ + Value: 13.00, + } + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} + +func TestOverrunBudgetRule_Evaluate_ReturnsFalseWhenNoValidChargePeriod(t *testing.T) { + budget := Budget{} + rule := BudgetRule{ + Name: "test-rule", + Type: OverrunRule, + } + record := CostRecord{} + actual := rule.Evaluate(budget, record) + expected := false + if actual != expected { + t.Errorf("Evaluate(budget, record) = %t; want %t", actual, expected) + } +} diff --git a/pkg/core/config.go b/pkg/core/config.go new file mode 100644 index 0000000..831f04a --- /dev/null +++ b/pkg/core/config.go @@ -0,0 +1,41 @@ +package core + +import ( + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" + "github.com/spf13/viper" +) + +type Config struct { + AlertProvider interface{} `mapstructure:"alert_provider,omitempty"` + DataProvider interface{} `mapstructure:"data_provider,omitempty"` + Budgets *[]Budget `mapstructure:"budgets,omitempty"` + Rules *[]BudgetRule `mapstructure:"rules,omitempty"` +} + +func LoadConfig(filePath string) (config Config, err error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return Config{}, err + } + log.Debug(). + Str("file", absPath). + Msg("Reading config...") + viper.SetConfigFile(filePath) + viper.AutomaticEnv() + if err = viper.ReadInConfig(); err != nil { + return Config{}, err + } + err = viper.Unmarshal(&config) + + return +} + +func SaveConfig(data interface{}) error { + log.Debug(). + Str("path", viper.ConfigFileUsed()). + Msg("Saving config...") + return viper.WriteConfigAs(strings.Replace(viper.ConfigFileUsed(), ".wrangler", ".wranglerexp", 1)) +} diff --git a/pkg/core/git.go b/pkg/core/git.go deleted file mode 100644 index 13ea7b0..0000000 --- a/pkg/core/git.go +++ /dev/null @@ -1,121 +0,0 @@ -package core - -import ( - "fmt" - "time" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/gofrontier-com/go-utils/output" -) - -// AddRemote will add a named remote -func (r *GitRepo) AddRemote(name string, url string) { - r.Repo.CreateRemote(&config.RemoteConfig{ - Name: name, - URLs: []string{url}, - }) -} - -func (r *GitRepo) PushWithTags() error { - rs := config.RefSpec("refs/tags/*:refs/tags/*") - return r.Repo.Push(&git.PushOptions{ - RefSpecs: []config.RefSpec{rs}, - }) -} - -func (r *GitRepo) PushWithTagsTo(remoteName string) error { - rs := config.RefSpec("refs/tags/*:refs/tags/*") - return r.Repo.Push(&git.PushOptions{ - RefSpecs: []config.RefSpec{rs}, - RemoteName: remoteName, - }) -} - -func (r *GitRepo) CreateTag(tag string) error { - headRef, _ := r.Repo.Head() - headHash := headRef.Hash() - tagger := &object.Signature{ - Name: r.Author.Name, - Email: r.Author.Email, - When: time.Now(), - } - _, err := r.Repo.CreateTag(tag, headHash, &git.CreateTagOptions{ - Tagger: tagger, - Message: tag, - }) - if err != nil { - return err - } - - output.PrintlnInfo("Created tag: ", tag) - return nil -} - -func (r *GitRepo) diff(tag string) (*object.Patch, error) { - revision := plumbing.Revision(tag) - tagCommitHash, err := r.Repo.ResolveRevision(revision) - if err != nil { - return nil, err - } - - tagCommit, err := r.Repo.CommitObject(*tagCommitHash) - headRef, _ := r.Repo.Head() - - headHash := headRef.Hash() - headCommit, _ := r.Repo.CommitObject(headHash) - return tagCommit.Patch(headCommit) -} - -func (r *GitRepo) branchName() (string, error) { - head, err := r.Repo.Head() - if err != nil { - return "", err - } - - return head.Name().String(), nil -} - -func (r *GitRepo) initialCommitHash() string { - commits, _ := r.Repo.CommitObjects() - var initialHash plumbing.Hash - _ = commits.ForEach(func(c *object.Commit) error { - if c.NumParents() == 0 { - initialHash = c.Hash - } - return nil - }) - return initialHash.String() -} - -func (r *GitRepo) changedFiles(latestTagOrHash string) []string { - fileschanged := make([]string, 0) - diff, _ := r.diff(latestTagOrHash) - stats := diff.Stats() - - for _, stat := range stats { - fileschanged = append(fileschanged, stat.Name) - } - - fps := diff.FilePatches() - for _, fp := range fps { - f, t := fp.Files() - if t == nil { - fileschanged = removeFromSlice(fileschanged, f.Path()) - } - } - return fileschanged -} - -func (r *GitRepo) getTagSuffix() string { - cb, err := r.branchName() - if err != nil { - fmt.Println(err) - } - if cb != "refs/heads/main" && cb != "refs/heads/master" { - return "-unstable" - } - return "" -} diff --git a/pkg/core/logger.go b/pkg/core/logger.go new file mode 100644 index 0000000..9687338 --- /dev/null +++ b/pkg/core/logger.go @@ -0,0 +1,21 @@ +package core + +import ( + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func ConfigureLogger(verbose bool) { + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: time.DateTime, + }) + zerolog.SetGlobalLevel(zerolog.InfoLevel) + if verbose { + zerolog.SetGlobalLevel(zerolog.TraceLevel) + log.Logger = log.Logger.With().Caller().Logger() + } +} diff --git a/pkg/core/registry.go b/pkg/core/registry.go new file mode 100644 index 0000000..ff70ff6 --- /dev/null +++ b/pkg/core/registry.go @@ -0,0 +1,17 @@ +package core + +var services = make(map[string]interface{}) + +func RegisterService(name string, svc interface{}) { + services[name] = svc +} + +func GetServices[T interface{}]() []T { + results := []T{} + for _, v := range services { + if svc, ok := v.(T); ok { + results = append(results, svc) + } + } + return results +} diff --git a/pkg/core/types.go b/pkg/core/types.go index 1b8981b..fed309c 100644 --- a/pkg/core/types.go +++ b/pkg/core/types.go @@ -1,30 +1,56 @@ package core -import ( - "github.com/go-git/go-git/v5" +import "time" + +type CliParameters struct { + ConfigFile string + OutputFmt string + DisableStdin bool +} + +type BudgetAlertService interface { + HandleViolations(violations []BudgetRuleViolation) error +} + +type BudgetRuleViolation struct { + ResourceId string + Name string + Description string + Date string + BudgetAmount float64 + ActualAmount float64 + Currency Currency +} + +type Currency string +type Period string + +const ( + Monthly Period = "monthly" + Daily Period = "daily" + GBP Currency = "GBP" + USD Currency = "USD" + EUR Currency = "EUR" ) -type Vertag struct { - Repo *GitRepo - RepoRoot string - ModulesDir string - ModulesFullPath string - DryRun bool - LatestStableTag string - LatestStableSHA string - LatestTag string - CurrentBranch string - ModulesChanged []string - NextTags []string +var currencies []Currency = []Currency{GBP, USD, EUR} + +var currencySymbols = map[Currency]string{ + GBP: "£", + USD: "$", + EUR: "€", } -type GitRepo struct { - Repo *git.Repository - Author *GitAuthor - RemoteUrl string +type CostDataProvider interface { + GetData(params CliParameters) ([]CostRecord, error) } -type GitAuthor struct { - Name string - Email string +type CostRecord struct { + ResourceId string + Timestamp time.Time + Period Period + Value float64 + Currency Currency + Baseline float64 + Category string } diff --git a/pkg/core/util.go b/pkg/core/util.go index bcce09c..83a17da 100644 --- a/pkg/core/util.go +++ b/pkg/core/util.go @@ -1,12 +1,36 @@ package core import ( + "fmt" "io" "os" "path" "strings" ) +func FindInSlice[K comparable](arr []K, comparer func(K) bool) (K, bool) { + var result K + found := false + for _, cur := range arr { + if comparer(cur) { + found = true + result = cur + break + } + } + return result, found +} + +func FindAllInSlice[K comparable](arr []K, comparer func(K) bool) ([]K, bool) { + var results []K + for _, cur := range arr { + if comparer(cur) { + results = append(results, cur) + } + } + return results, len(results) > 0 +} + func removeFromSlice(s []string, r string) []string { for i, v := range s { if v == r { @@ -58,3 +82,18 @@ func getVersion(dir string) (string, error) { return retval, nil } + +func FormatCurrency(value float64, currency Currency) string { + return FormatCurrencyWithPrecision(value, currency, 2) +} + +func FormatCurrencyWithPrecision(value float64, currency Currency, decimals int) string { + symbol := currencySymbols[currency] + precisionFormat := fmt.Sprintf("%%.%df", decimals) + formattedValue := fmt.Sprintf(precisionFormat, value) + if symbol == "" && currency != "" { + return fmt.Sprintf("%s %s", formattedValue, currency) + } else { + return fmt.Sprintf("%s%s", symbol, formattedValue) + } +} diff --git a/pkg/core/vertag.go b/pkg/core/vertag.go deleted file mode 100644 index 505cb31..0000000 --- a/pkg/core/vertag.go +++ /dev/null @@ -1,250 +0,0 @@ -package core - -import ( - "fmt" - "path" - "strconv" - "strings" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/gofrontier-com/go-utils/output" -) - -func NewVertag(repoRoot string, modulesDir string, authorName string, authorEmail string, dryRun bool, remoteUrl string) *Vertag { - r := &GitRepo{ - Author: &GitAuthor{ - Name: authorName, - Email: authorEmail, - }, - RemoteUrl: remoteUrl, - } - - return &Vertag{ - Repo: r, - RepoRoot: repoRoot, - ModulesDir: modulesDir, - ModulesFullPath: path.Join(repoRoot, modulesDir), - DryRun: dryRun, - } -} - -func (v *Vertag) Init() error { - r, err := git.PlainOpen(v.RepoRoot) - if err != nil { - return err - } - v.Repo.Repo = r - - return nil -} - -// GetLatestStableTag returns the most recent tag on the repository. -func (v *Vertag) GetLatestStableTag() error { - tagRefs, err := v.Repo.Repo.Tags() - if err != nil { - return err - } - - var latestTagCommit *object.Commit - var latestTagName string - err = tagRefs.ForEach(func(tagRef *plumbing.Reference) error { - if strings.Contains(tagRef.Name().String(), "-unstable") { - // output.PrintlnInfo("Skipping unstable tag: ", tagRef.Name().String()) - return nil - } - revision := plumbing.Revision(tagRef.Name().String()) - tagCommitHash, err := v.Repo.Repo.ResolveRevision(revision) - if err != nil { - return err - } - - commit, err := v.Repo.Repo.CommitObject(*tagCommitHash) - if err != nil { - return err - } - - if latestTagCommit == nil { - latestTagCommit = commit - latestTagName = tagRef.Name().String() - } - - if commit.Committer.When.After(latestTagCommit.Committer.When) { - latestTagCommit = commit - latestTagName = tagRef.Name().String() - } - - return nil - }) - if err != nil { - return err - } - - v.LatestStableTag = latestTagName - if latestTagCommit == nil { - v.LatestStableSHA = v.Repo.initialCommitHash() - } else { - v.LatestStableSHA = latestTagCommit.Hash.String() - } - return nil -} - -func (v *Vertag) latestTagContains(tagContains string) error { - tagRefs, err := v.Repo.Repo.Tags() - if err != nil { - return err - } - - var latestTagCommit *object.Commit - var latestTagName string - err = tagRefs.ForEach(func(tagRef *plumbing.Reference) error { - if strings.Contains(tagRef.Name().String(), tagContains) { - revision := plumbing.Revision(tagRef.Name().String()) - tagCommitHash, err := v.Repo.Repo.ResolveRevision(revision) - if err != nil { - return err - } - - commit, err := v.Repo.Repo.CommitObject(*tagCommitHash) - if err != nil { - return err - } - - if latestTagCommit == nil { - latestTagCommit = commit - latestTagName = tagRef.Name().String() - } - - if commit.Committer.When.After(latestTagCommit.Committer.When) { - latestTagCommit = commit - latestTagName = tagRef.Name().String() - } - - if commit.Committer.When.Equal(latestTagCommit.Committer.When) { - if !strings.Contains(tagRef.Name().String(), "-unstable") { - latestTagCommit = commit - latestTagName = tagRef.Name().String() - } - } - } - return nil - }) - if err != nil { - return err - } - - v.LatestTag = latestTagName - - return nil -} - -func (v *Vertag) GetRefs() error { - err := v.getDiffRefs() - if err != nil { - return err - } - - output.PrintfInfo("Comparing\n\tCurrent Branch: %s\nto\n\tLatest Tagged SHA: %s\n\n", v.CurrentBranch, v.LatestStableSHA) - - return nil -} - -func (v *Vertag) getDiffRefs() error { - cb, err := v.Repo.branchName() - if err != nil { - return err - } - v.CurrentBranch = cb - - err = v.GetLatestStableTag() - if err != nil { - return err - } - - return nil -} - -func (v *Vertag) GetChanges() error { - fileschanged := v.Repo.changedFiles(v.LatestStableSHA) - dirschanged := changedDirs(fileschanged, v.ModulesDir, v.ModulesFullPath) - output.PrintlnInfo("Modules changed") - for _, d := range dirschanged { - output.PrintfInfo("\t%s\n", d) - } - output.PrintlnInfo("") - v.ModulesChanged = dirschanged - return nil -} - -func (v *Vertag) CalculateNextTags() error { - tags := make([]string, 0) - - for _, d := range v.ModulesChanged { - err := v.latestTagContains(d) - if err != nil { - output.PrintlnError(err) - } - - patchVersion := 0 - versionFromFile, _ := getVersion(path.Join(v.ModulesFullPath, d)) - ns := d - - if v.LatestTag != "" { - ltcSplit := strings.Split(v.LatestTag, "/") // gives /refs/tags// - ns = ltcSplit[2] - versionFromTagSplit := strings.Split(ltcSplit[3], ".") - versionFromTag := versionFromTagSplit[0] + "." + versionFromTagSplit[1] - patchFromTagIncSuffix := versionFromTagSplit[2] - patchFromTag := strings.TrimSuffix(patchFromTagIncSuffix, "-unstable") - latestPatch, _ := strconv.Atoi(patchFromTag) - if versionFromFile == versionFromTag { - patchVersion = latestPatch + 1 - } else { - patchVersion = 0 - } - } - - suffix := v.Repo.getTagSuffix() - tags = append(tags, fmt.Sprintf("%s/%s.%d%s", ns, versionFromFile, patchVersion, suffix)) - } - - v.NextTags = tags - - return nil -} - -func (v *Vertag) WriteTags() error { - if len(v.NextTags) == 0 { - output.PrintlnInfo("No tags to write and push") - return nil - } - - for _, tag := range v.NextTags { - if v.DryRun { - output.Println("[Dry run] Would have created tag: ", tag) - } else { - err := v.Repo.CreateTag(tag) - if err != nil { - return err - } - } - } - - if !v.DryRun { - if v.Repo.RemoteUrl != "" { - v.Repo.AddRemote("ci", v.Repo.RemoteUrl) - err := v.Repo.PushWithTagsTo("ci") - if err != nil { - return err - } - } else { - err := v.Repo.PushWithTags() - if err != nil { - return err - } - } - } - - return nil -} diff --git a/pkg/data/az_costmgmt_data_provider.go b/pkg/data/az_costmgmt_data_provider.go new file mode 100644 index 0000000..e79371a --- /dev/null +++ b/pkg/data/az_costmgmt_data_provider.go @@ -0,0 +1,16 @@ +package data + +import ( + "github.com/gofrontier-com/wrangler/pkg/core" +) + +type AzCostMgmtDataProvider struct { +} + +func (p AzCostMgmtDataProvider) GetData(params core.CliParameters) ([]core.CostRecord, error) { + return []core.CostRecord{}, nil +} + +func init() { + core.RegisterService("data_provider.azcostmgmt", AzCostMgmtDataProvider{}) +} diff --git a/pkg/data/cli_data_provider.go b/pkg/data/cli_data_provider.go new file mode 100644 index 0000000..aa2d804 --- /dev/null +++ b/pkg/data/cli_data_provider.go @@ -0,0 +1,122 @@ +package data + +import ( + "encoding/csv" + "errors" + "fmt" + "io" + "os" + "reflect" + "strconv" + "time" + + "github.com/gofrontier-com/wrangler/pkg/core" + "github.com/rs/zerolog/log" +) + +type CliCostDataProvider struct { +} + +func (p CliCostDataProvider) GetData(params core.CliParameters) ([]core.CostRecord, error) { + if params.DisableStdin { + return nil, nil + } + + reader := csv.NewReader(os.Stdin) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + + log.Info().Msg("Reading data from stdin...") + records := []core.CostRecord{} + rowNumber := 1 + for { + row, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + log.Warn(). + Err(err). + Msg("Unable to read CSV row") + continue + } + + log.Debug().Msgf("Raw data: %s", row) + record, err := costRecordFromCsv(row) + if err != nil { + return records, fmt.Errorf("parsing failed: %+v", err) + } + + log.Debug().Fields(map[string]interface{}{ + "record": record, + }).Msgf("Row %d parsed successfully", rowNumber) + records = append(records, record) + } + return records, nil +} + +func costRecordFromCsv(data []string) (core.CostRecord, error) { + if len(data) < 4 { + return core.CostRecord{}, errors.New("insufficient number of fields") + } + + log.Debug().Msgf("%d fields detected", len(data)) + log.Debug(). + Str("value", data[0]). + Str("target_type", reflect.TypeOf(time.Now()).Name()). + Msg("Parsing timestamp field") + + var ts time.Time + unixTimestamp, convErr := strconv.ParseInt(data[0], 10, 64) + if convErr != nil { + log.Debug(). + Err(convErr). + Msg("Value cannot be parsed as Unix timestamp") + unix, err := time.Parse(time.RFC3339, data[0]) + if err != nil { + return core.CostRecord{}, err + } + + ts = unix + } else { + ts = time.Unix(unixTimestamp, 0) + } + + log.Debug(). + Str("value", data[3]). + Str("target_type", reflect.TypeOf(float64(0)).Name()). + Msg("Parsing value field") + value, err := strconv.ParseFloat(data[3], 64) + if err != nil { + return core.CostRecord{}, err + } + + var curr core.Currency + var cat string + if len(data) > 5 { + log.Debug(). + Str("value", data[3]). + Str("target_type", reflect.TypeOf(core.Currency("")).Name()). + Msg("Parsing value field") + curr = core.Currency(data[4]) + cat = data[5] + } else if len(data) > 4 { + curr = core.Currency(data[4]) + } + + record := core.CostRecord{ + ResourceId: data[1], + Timestamp: ts, + Period: core.Period(data[2]), + Value: value, + Baseline: -1, + Currency: curr, + Category: cat, + } + + return record, nil +} + +func init() { + core.RegisterService("data_provider.cli", CliCostDataProvider{}) +} diff --git a/pkg/serializers/table.go b/pkg/serializers/table.go new file mode 100644 index 0000000..00898d9 --- /dev/null +++ b/pkg/serializers/table.go @@ -0,0 +1,45 @@ +package serializers + +import ( + "bytes" + + "github.com/olekukonko/tablewriter" +) + +type TableAlignment int + +const ( + AlignLeft TableAlignment = tablewriter.ALIGN_LEFT + AlignRight TableAlignment = tablewriter.ALIGN_RIGHT + AlignCenter TableAlignment = tablewriter.ALIGN_CENTER +) + +type TableOptions struct { + FirstRowIsHeader bool + HasBorder bool + HeaderAlignment TableAlignment + Alignment TableAlignment +} + +func SerializeTable(data [][]string, options TableOptions) string { + buffer := new(bytes.Buffer) + table := tablewriter.NewWriter(buffer) + + startIndex := 0 + if options.FirstRowIsHeader { + table.SetHeader(data[0]) // use first row as header + startIndex = 1 + } + + table.AppendBulk(data[startIndex:]) + + // Set table formatting options + table.SetBorder(options.HasBorder) + table.SetHeaderAlignment(int(options.HeaderAlignment)) + table.SetAlignment(int(options.Alignment)) + table.SetAutoWrapText(true) + table.SetAutoFormatHeaders(false) + table.Render() + + return buffer.String() +} diff --git a/samples/StreamFromCli.rst b/samples/StreamFromCli.rst new file mode 100644 index 0000000..15a2809 --- /dev/null +++ b/samples/StreamFromCli.rst @@ -0,0 +1,56 @@ +=========================== +Streaming data from the CLI +=========================== + +The following example creates a budget for a resource named ``storage-account`` and a +rule that will trigger if 135% or more of the monthly budget amount has been reached. + +.. code-block:: bash + + # create budget / rules + cat < config.yaml + budgets: + - resource_id: storage-account + monthly_amount: 100 + rules: + - type: percentage + name: early-warning + value: 135 + + EOF + + # execute + wrangler -c ./config.yaml + +When executed, the application will prompt for input: + +.. code-block:: bash + + 2024-02-29 18:38:14 INF Reading data from stdin... + +You can then enter CSV data from the CLI. To demonstrate, enter the following: + +.. code-block:: bash + + 2024-02-05T12:00:00Z,storage-account,monthly,110 + 2024-02-05T12:00:00Z,storage-account,daily,14 + 2024-02-06T12:00:00Z,storage-account,monthly,150 + 2024-02-06T12:00:00Z,storage-account,daily,21 + +Then enter `Ctrl+D` to indicate the end of input. Wrangler will evaluate each input +against the budget rules we created earlier and it should fail with a single violation: + +.. code-block:: bash + + 2024-02-29 18:38:37 INF Evaluating 4 record(s)... + ... + 2024-02-29 18:38:37 INF 1 violation(s) found + +-----------------+---------------+--------------------------------+------------+---------------+---------------+ + | Resource ID | Rule name | Condition | Date | Budget amount | Actual amount | + +-----------------+---------------+--------------------------------+------------+---------------+---------------+ + | storage-account | early-warning | actual amount >= 135.00% of | 2024-03-06 | 100.00 | 150.00 | + | | | budget | | | | + +-----------------+---------------+--------------------------------+------------+---------------+---------------+ + + 2024-02-29 18:38:37 FTL Failed with violations + diff --git a/samples/StreamFromFile.rst b/samples/StreamFromFile.rst new file mode 100644 index 0000000..27ac50c --- /dev/null +++ b/samples/StreamFromFile.rst @@ -0,0 +1,83 @@ +========================== +Streaming data from a file +========================== + +The following example creates a budget for a resource named ``storage-account`` and a +rule that will trigger if 100% or more of the daily budget amount has been reached. + +The sample data has two records that violate this rule and therefore should trigger when executed. + +.. code-block:: bash + + # create budget / rules + cat < config.yaml + budgets: + - resource_id: storage-account + monthly_amount: 1000 + daily_amount: 20 + rules: + - name: budget-reached + type: percentage + period: daily + value: 100 + + EOF + + # create sample data + echo "1707044241,storage-account,daily,14" > data.csv + echo "1707138019,storage-account,daily,5.5" >> data.csv + echo "1707376221,storage-account,daily,50" >> data.csv + echo "1707743640,storage-account,daily,20" >> data.csv + echo "1707889825,storage-account,monthly,1050" >> data.csv + + # execute + cat ./data.csv | wrangler -c ./config.yaml + +When run, the application should fail with 2 violations: + +.. code-block:: bash + + 2024-02-29 18:01:18 INF 2 violation(s) found + +-----------------+----------------+--------------------------------+------------+---------------+---------------+ + | Resource ID | Rule name | Condition | Date | Budget amount | Actual amount | + +-----------------+----------------+--------------------------------+------------+---------------+---------------+ + | storage-account | budget-reached | actual amount >= 100.00% of | 2024-02-08 | 20.00 | 50.00 | + | | | budget | | | | + | storage-account | budget-reached | actual amount >= 100.00% of | 2024-02-12 | 20.00 | 20.00 | + | | | budget | | | | + +-----------------+----------------+--------------------------------+------------+---------------+---------------+ + + 2024-02-29 18:01:19 FTL Failed with violations + + +Lets adjust our rule to handle both daily & monthly budgets. Open up ``config.yaml`` and delete the ``period`` field +from the rule. Your config should now look like: + +.. code-block:: bash + + budgets: + - resource_id: storage-account + monthly_amount: 1000 + daily_amount: 20 + rules: + - name: budget-reached + type: percentage + value: 100 + +Save the config and re-run wrangler, it should now fail with 3 violations: + +.. code-block:: bash + + 2024-02-29 18:01:18 INF 3 violation(s) found + +-----------------+----------------+--------------------------------+------------+---------------+---------------+ + | Resource ID | Rule name | Condition | Date | Budget amount | Actual amount | + +-----------------+----------------+--------------------------------+------------+---------------+---------------+ + | storage-account | budget-reached | actual amount >= 100.00% of | 2024-02-08 | 20.00 | 50.00 | + | | | budget | | | | + | storage-account | budget-reached | actual amount >= 100.00% of | 2024-02-12 | 20.00 | 20.00 | + | | | budget | | | | + | storage-account | budget-reached | actual amount >= 100.00% of | 2024-02-14 | 1000.00 | 1050.00 | + | | | budget | | | | + +-----------------+----------------+--------------------------------+------------+---------------+---------------+ + + 2024-02-29 18:01:19 FTL Failed with violations \ No newline at end of file