Compare commits

...

51 commits

Author SHA1 Message Date
3bc2cd20eb Prepare next dev (4.0.1)
All checks were successful
/ build (push) Successful in 5m25s
2024-10-25 16:42:04 +02:00
2b3d7f8289 Release 4.0.0 #release
All checks were successful
/ build (push) Successful in 5m17s
/ release (push) Successful in 6m27s
2024-10-25 16:20:29 +02:00
d0312a5853 feat(embedded_ui): fully integrate UI into GoLang binary (#43)
All checks were successful
/ build (push) Successful in 5m30s
Reviewed-on: #43
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-10-25 14:12:35 +00:00
f066511eff chore(deps): updates
All checks were successful
/ build (push) Successful in 3m56s
2024-10-24 21:36:51 +02:00
ac5b9da086 chore(deps): updates
All checks were successful
/ build (push) Successful in 3m56s
2024-09-07 16:04:54 +02:00
a76d55c78b chore(renovate): add renovate's CVE scanning dashboard
Some checks are pending
/ build (push) Waiting to run
2024-09-07 15:43:54 +02:00
3d815758ba fix(deps): update all minor dependencies (#38)
All checks were successful
/ build (push) Successful in 3m45s
github.com/adrg/xdg 	require 	minor 	v0.4.0 -> v0.5.0
github.com/go-co-op/gocron/v2 	require 	minor 	v2.7.1 -> v2.11.0
github.com/redis/go-redis/v9 	require 	minor 	v9.5.3 -> v9.6.1
Co-authored-by: Renovate Bot <renovate@myservermanager.com>
Co-committed-by: Renovate Bot <renovate@myservermanager.com>
2024-08-01 21:27:27 +00:00
7db195af6d fix(deps): update all patch dependencies (#37)
All checks were successful
/ build (push) Successful in 4m16s
github.com/urfave/cli/v2 	require 	patch 	v2.27.2 -> v2.27.3
gorm.io/gorm 	require 	patch 	v1.25.10 -> v1.25.11
Co-authored-by: Renovate Bot <renovate@myservermanager.com>
Co-committed-by: Renovate Bot <renovate@myservermanager.com>
2024-08-01 07:15:40 +00:00
0f9ae11bbb fix(deps): update module github.com/go-co-op/gocron/v2 to v2.7.1 (#36)
All checks were successful
/ build (push) Successful in 3m42s
github.com/go-co-op/gocron/v2 	require 	minor 	v2.5.0 -> v2.7.1
Co-authored-by: Renovate Bot <renovate@myservermanager.com>
Co-committed-by: Renovate Bot <renovate@myservermanager.com>
2024-07-03 05:42:19 +00:00
67e9ce31b0 Prepare next dev
All checks were successful
/ build (push) Successful in 5m1s
2024-06-15 12:20:57 +02:00
c5c6249fa7 Release 3.0.2 #release
All checks were successful
/ build (push) Successful in 5m8s
/ release (push) Successful in 5m55s
2024-06-15 12:10:32 +02:00
61506f44f1 fix(filter): fixed filter for Updates ignoring desired state
All checks were successful
/ build (push) Successful in 5m7s
2024-06-14 09:16:30 +02:00
3ece069068 chore(deps): updates
- github.com/go-playground/validator/v10 v10.22.0
- gorm.io/driver/postgres v1.5.9
- gorm.io/driver/sqlite v1.5.6
2024-06-14 09:05:48 +02:00
f37ea4fbbb feat(api): Don't enforce JSON content type for GET and DELETE requests and enhance cross-module code sharing with a commons module
All checks were successful
/ build (push) Successful in 4m52s
2024-06-11 23:47:30 +02:00
20588b44bf Prepare next dev after hotfix
All checks were successful
/ build (push) Successful in 4m46s
2024-06-11 00:10:46 +02:00
4046695f1c hotfix(3.0.1): Fixed finding proper remaining Action invocations by their state
Some checks failed
/ build (push) Has been cancelled
/ release (push) Successful in 5m35s
2024-06-11 00:08:21 +02:00
abc78036d0 Prepare next dev
All checks were successful
/ build (push) Successful in 4m56s
2024-06-10 23:21:44 +02:00
9259299a56 Release 3.0.0 #release
All checks were successful
/ build (push) Successful in 4m47s
/ release (push) Successful in 5m41s
2024-06-10 22:31:56 +02:00
1fc3818d3c feature(api,release): prepare for next major release and switch to requiring content-type set to JSON for all incoming requests and expose more CORS environment variables
All checks were successful
/ build (push) Successful in 5m3s
- Switched to enforce JSON as Content-Type for all incoming requests
- Switched to properly respond with JSON on page not found or method not allowed
- Renamed CORS_ALLOW_ORIGIN to CORS_ALLOW_ORIGINS
- Added CORS_ALLOW_CREDENTIALS which defaults to true
- Added CORS_EXPOSE_HEADERS which defaults to *
- Overhauled package visibility for server module
2024-06-10 20:03:25 +02:00
1d79258670 chore(deps): update go-redis to 9.5.3
All checks were successful
/ build (push) Successful in 5m26s
2024-06-08 10:36:31 +02:00
17592d4fad Minor refactor tackling typos, overhauling README, adding hints about useful resources to README, and avoid any panic/Fatalf from services and init calls #noissue
All checks were successful
/ build (push) Successful in 5m1s
2024-06-03 21:39:03 +02:00
302d0b1ad4 Use capacity to avoid arr copying #noissue 2024-06-02 23:13:44 +02:00
d958f48717 Use recommended race option for testing #noissue 2024-06-02 23:11:12 +02:00
9679166d0a Adapt GOMAXPROCS automatically for k8s and docker deployments #noissue 2024-06-02 23:11:00 +02:00
47a48523a9 Introduce go vet and upgrades #noissue
All checks were successful
/ build (push) Successful in 3m12s
2024-06-02 17:22:43 +02:00
04a3ef39fa Updated OCI image base to alpine 3.20 with Go 1.22 #noissue
All checks were successful
/ build (push) Successful in 3m38s
2024-05-24 01:17:51 +02:00
165b992629 feature(locking): add proper locking and overhaul existing locking service (#34)
All checks were successful
/ build (push) Successful in 3m11s
Reviewed-on: #34
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-05-24 00:54:35 +02:00
95074b2a86 chore(deps): update dependencies and move to gocron v2 (#33)
All checks were successful
/ build (push) Successful in 3m2s
Reviewed-on: #33
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-05-16 07:33:32 +00:00
19c367a5d8 chore(docker,deps): updates and change docker base (#31)
All checks were successful
/ build (push) Successful in 3m22s
- update gin/cors to 1.7.2
- change docker base to alpine 3.19 which requires an indirect dependency upgrade to sqlite3 as long as go-gorm/sqlite bundles the old version
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-05-01 20:45:35 +00:00
dce287d6a3 fix(release): prepare next dev cycle
All checks were successful
/ build (push) Successful in 3m9s
2024-05-01 12:22:31 +02:00
98b37ca289 fix(release,webhooks): Fixed retrieval of encrypted webhook token and prepare patch 2.0.1 release
All checks were successful
/ build (push) Successful in 3m21s
/ release (push) Successful in 4m21s
2024-05-01 12:20:51 +02:00
c1631a0588 fix(deps): update module github.com/go-playground/validator/v10 to v10.20.0 (#29)
All checks were successful
/ build (push) Successful in 3m14s
Co-authored-by: Renovate Bot <renovate@myservermanager.com>
Co-committed-by: Renovate Bot <renovate@myservermanager.com>
2024-05-01 09:01:54 +00:00
1406b53180 fix(deps): update module github.com/urfave/cli/v2 to v2.27.2 (#28)
All checks were successful
/ build (push) Successful in 3m16s
Co-authored-by: Renovate Bot <renovate@myservermanager.com>
Co-committed-by: Renovate Bot <renovate@myservermanager.com>
2024-05-01 07:04:20 +00:00
b20530941e Prepare next dev cycle #noissue
All checks were successful
/ build (push) Successful in 3m19s
2024-04-28 23:04:20 +02:00
52dfb5e21a Prepare release of 2.0.0 #noissue
All checks were successful
/ build (push) Successful in 3m37s
/ release (push) Successful in 3m59s
2024-04-28 22:57:39 +02:00
7c3545976b feat(docs,license): clean up documentation and add license
All checks were successful
/ build (push) Successful in 3m16s
2024-04-28 22:34:26 +02:00
c0928c4128 feat(actions): introduce an explicit event for changes of an update's version 2024-04-28 22:27:34 +02:00
0277868c1b feat(auth): align CLI application with new auth mode wording 2024-04-28 22:27:34 +02:00
927bee30cf fix(locks): access to in-memory locks inside scheduled background tasks are now properly guarded 2024-04-28 22:27:34 +02:00
10bc3a59bb feat(updates,events): Streamline update state changes into one event type only (#27)
Reviewed-on: #27
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-04-28 22:27:34 +02:00
79c5da119a fix(actions): state not considered in test payload (#26)
Reviewed-on: #26
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-04-28 22:27:34 +02:00
faffad851c feat(auth): Add support for multiple basic auth credentials (#25)
Reviewed-on: #25
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-04-28 22:27:34 +02:00
f231e66e7c feat(actions): Add support for <VAR>STATE</VAR> (#24)
Reviewed-on: #24
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-04-28 22:27:34 +02:00
5d9fe621d8 fix(deps): update module gorm.io/gorm to v1.25.10 (#23)
gorm.io/gorm 	require 	patch 	v1.25.9 -> v1.25.10
Co-authored-by: Renovate Bot <renovate@myservermanager.com>
Co-committed-by: Renovate Bot <renovate@myservermanager.com>
2024-04-28 22:27:34 +02:00
66800d26df feat(actions): Add actions and secrets (with proper asynchronous enqueue and dequeue mechanism) (#22)
Reviewed-on: #22
Co-authored-by: Varakh <varakh@varakh.de>
Co-committed-by: Varakh <varakh@varakh.de>
2024-04-28 22:27:34 +02:00
6420e19a8d Change Renovate schedule 2024-04-28 22:27:34 +02:00
e8dba980e1 Add file logging via LOGGING_DIRECTORY and add an example systemd service for native deployments 2024-04-28 22:27:34 +02:00
99c63fdb8d Minor method renaming 2024-04-28 22:27:34 +02:00
5acc136fe8 Update dependencies 2024-04-28 22:27:34 +02:00
0194429c80 Adapted logging which defaults to JSON encoding 2024-01-24 23:21:48 +01:00
feedfad99f Prepare next development cycle (1.1.0)
All checks were successful
/ build (push) Successful in 3m36s
2024-01-21 23:13:04 +01:00
233 changed files with 44041 additions and 2012 deletions

View file

@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '^1.21'
go-version: '^1.22'
- name: Checkout
uses: actions/checkout@v3
- name: Test native build

View file

@ -3,9 +3,9 @@ on:
tags:
- '*'
env:
VERSION_MAJOR: 1
VERSION_MAJOR: 4
VERSION_MINOR: 0
VERSION_PATCH: 3
VERSION_PATCH: 1
IMAGE_TAG: varakh/upda
IMAGE_TAG_PRIVATE: git.myservermanager.com/varakh/upda
FORGEJO_URL: https://git.myservermanager.com
@ -33,7 +33,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '^1.21'
go-version: '^1.22'
- name: Checkout
uses: actions/checkout@v3
- name: Test native build

View file

@ -2,6 +2,65 @@
Changes adhere to [semantic versioning](https://semver.org).
## [4.0.1] - UNRELEASED
* ...
## [4.0.0] - 2024/10/25
> This is a major version upgrade. Other versions are incompatible with this release.
* Embed frontend into Go binary and only ship _one_ OCI image
* Switch license to GPLv3
## [3.0.2] - 2024/06/15
* Don't enforce JSON content type for GET and DELETE requests
* Dependency updates
* github.com/go-playground/validator/v10 v10.22.0
* gorm.io/driver/postgres v1.5.9
* gorm.io/driver/sqlite v1.5.6
* Fixed filter for Updates ignoring desired state
## [3.0.1] - 2024/06/10
* Fixed finding proper remaining Action invocations by their state
## [3.0.0] - 2024/06/10
> This is a major version upgrade. Other versions are incompatible with this release.
* Added automatic detection of `GOMAXPROCS`
* Switched to enforce JSON as `Content-Type` for all incoming requests
* Switched to properly respond with JSON on page not found or method not allowed
* Renamed `CORS_ALLOW_ORIGIN` to `CORS_ALLOW_ORIGINS`
* Added `CORS_ALLOW_CREDENTIALS` which defaults to `true`
* Added `CORS_EXPOSE_HEADERS` which defaults to `*`
* Overhauled package visibility for server module
* Updated dependencies
* Updated OCI image base to alpine `3.20` with Go `1.22`
## [2.0.1] - 2024/05/01
* Fixed retrieval of encrypted webhook token
## [2.0.0] - 2024/04/28
> This is a major version upgrade. Other versions are incompatible with this release.
* Added _Actions_, a simple way to trigger notifications via [shoutrrr](https://containrrr.dev/shoutrrr) which supports
secrets
* Added new auth mode which allows setting multiple basic auth credentials
* Added `AUTH_MODE` which can be one of `basic_single` (_default_) and `basic_credentials`
* For `basic_credentials`: added `BASIC_AUTH_CREDENTIALS` which can be used as list of `username1=password1,...` (
comma separated)
* For `basic_single`: renamed `ADMIN_USER` and `ADMIN_PASSWORD` to `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD`
* Added mandatory `SECRET` environment variable to encrypt some data inside the database
* Switched to producing events only for _Updates_
* Switched to encrypting webhook tokens in database
* Adapted logging which defaults to JSON encoding
* Updated dependencies
## [1.0.3] - 2024/01/21
* Updated dependencies
@ -24,6 +83,16 @@ Changes adhere to [semantic versioning](https://semver.org).
* Initial release
[3.0.2]: https://git.myservermanager.com/varakh/upda/releases/tag/3.0.2
[3.0.1]: https://git.myservermanager.com/varakh/upda/releases/tag/3.0.1
[3.0.0]: https://git.myservermanager.com/varakh/upda/releases/tag/3.0.0
[2.0.1]: https://git.myservermanager.com/varakh/upda/releases/tag/2.0.1
[2.0.0]: https://git.myservermanager.com/varakh/upda/releases/tag/2.0.0
[1.0.3]: https://git.myservermanager.com/varakh/upda/releases/tag/1.0.3
[1.0.2]: https://git.myservermanager.com/varakh/upda/releases/tag/1.0.2

View file

@ -1,11 +1,12 @@
#
# Build image
#
FROM alpine:3.18 AS builder
FROM alpine:3.20 AS builder
LABEL maintainer="Varakh <varakh@varakh.de>"
RUN apk --update upgrade && \
apk add go gcc make sqlite && \
apk add nodejs npm && \
# See https://stackoverflow.com/questions/34729748/installed-go-binary-not-found-in-path-on-alpine-linux-docker
mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 && \
rm -rf /var/cache/apk/*
@ -18,7 +19,7 @@ RUN rm -rf bin/ && \
#
# Actual image
#
FROM alpine:3.18
FROM alpine:3.20
LABEL maintainer="Varakh <varakh@varakh.de>" \
description="upda" \
org.opencontainers.image.authors="Varakh" \
@ -26,7 +27,7 @@ LABEL maintainer="Varakh <varakh@varakh.de>" \
org.opencontainers.image.vendor="Varakh" \
org.opencontainers.image.title="upda" \
org.opencontainers.image.description="upda" \
org.opencontainers.image.base.name="alpine:3.18"
org.opencontainers.image.base.name="alpine:3.20"
ENV USER=appuser \
GROUP=appuser \

View file

@ -1,62 +1,87 @@
BIN_DIR = $(shell pwd)/bin
WEB_DIR = $(shell pwd)/server/web
WEB_BUILD_DIR = $(shell pwd)/server/web/build
WEB_NODE_DIR = $(shell pwd)/server/web/node_modules
WEB_COVERAGE_DIR = $(shell pwd)/server/web/coverage
clean:
# cleanup steps
clean: clean-server clean-web
clean-server:
rm -rf ${BIN_DIR}
clean-web:
rm -rf ${WEB_BUILD_DIR} ${WEB_NODE_DIR} ${WEB_COVERAGE_DIR}
dependencies:
# dependencies steps
dependencies: dependencies-web dependencies-server
dependencies-server:
GO111MODULE=on go mod download
dependencies-web:
cd ${WEB_DIR}; npm install
ci: clean dependencies test-ci build-server-ci build-cli-ci
# checkstyle steps
checkstyle: checkstyle-web checkstyle-server
checkstyle-server:
go vet ./...
checkstyle-web:
cd ${WEB_DIR}; npm run checkstyle
build-server-ci: build-server-linux-amd64
# test steps
test: test-web test-server
test-server:
go test -race ./...
test-web:
cd ${WEB_DIR}; npm run test:ci
# build steps
# server requires CGO_ENABLED=1 for go-sqlite
build-server-freebsd-amd64:
CGO_ENABLED=1 GO111MODULE=on GOOS=freebsd GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-freebsd-amd64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=freebsd GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-server-freebsd-amd64 cmd/server/main.go
build-server-freebsd-arm64:
CGO_ENABLED=1 GO111MODULE=on GOOS=freebsd GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-freebsd-arm64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=freebsd GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-server-freebsd-arm64 cmd/server/main.go
build-server-darwin-amd64:
CGO_ENABLED=1 GO111MODULE=on GOOS=darwin GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-darwin-amd64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=darwin GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-server-darwin-amd64 cmd/server/main.go
build-server-darwin-arm64:
CGO_ENABLED=1 GO111MODULE=on GOOS=darwin GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-darwin-arm64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=darwin GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-server-darwin-arm64 cmd/server/main.go
build-server-linux-amd64:
CGO_ENABLED=1 GO111MODULE=on GOOS=linux GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-linux-amd64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=linux GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-server-linux-amd64 cmd/server/main.go
build-server-linux-arm64:
CGO_ENABLED=1 GO111MODULE=on GOOS=linux GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-linux-arm64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=linux GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-server-linux-arm64 cmd/server/main.go
build-server-windows-amd64:
CGO_ENABLED=1 GO111MODULE=on GOOS=windows GOARCH=amd64 go build -o ${BIN_DIR}/upda-server-windows-amd64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=windows GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-server-windows-amd64 cmd/server/main.go
build-server-windows-arm64:
CGO_ENABLED=1 GO111MODULE=on GOOS=windows GOARCH=arm64 go build -o ${BIN_DIR}/upda-server-windows-arm64 cmd/server.go
CGO_ENABLED=1 GO111MODULE=on GOOS=windows GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-server-windows-arm64 cmd/server/main.go
# cli does not require CGO_ENABLED=1, cross-platform build possible
build-cli-ci: build-cli-linux-amd64
build-cli-all: build-cli-freebsd-amd64 build-cli-freebsd-arm64 build-cli-darwin-amd64 build-cli-darwin-arm64 build-cli-linux-amd64 build-cli-linux-arm64 build-cli-windows-amd64 build-cli-windows-arm64
build-cli-freebsd-amd64:
CGO_ENABLED=0 GO111MODULE=on GOOS=freebsd GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-freebsd-amd64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=freebsd GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-cli-freebsd-amd64 cmd/cli/main.go
build-cli-freebsd-arm64:
CGO_ENABLED=0 GO111MODULE=on GOOS=freebsd GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-freebsd-arm64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=freebsd GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-cli-freebsd-arm64 cmd/cli/main.go
build-cli-darwin-amd64:
CGO_ENABLED=0 GO111MODULE=on GOOS=darwin GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-darwin-amd64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=darwin GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-cli-darwin-amd64 cmd/cli/main.go
build-cli-darwin-arm64:
CGO_ENABLED=0 GO111MODULE=on GOOS=darwin GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-darwin-arm64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=darwin GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-cli-darwin-arm64 cmd/cli/main.go
build-cli-linux-amd64:
CGO_ENABLED=0 GO111MODULE=on GOOS=linux GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-linux-amd64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=linux GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-cli-linux-amd64 cmd/cli/main.go
build-cli-linux-arm64:
CGO_ENABLED=0 GO111MODULE=on GOOS=linux GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-linux-arm64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=linux GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-cli-linux-arm64 cmd/cli/main.go
build-cli-windows-amd64:
CGO_ENABLED=0 GO111MODULE=on GOOS=windows GOARCH=amd64 go build -o ${BIN_DIR}/upda-cli-windows-amd64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=windows GOARCH=amd64 go build -tags prod -o ${BIN_DIR}/upda-cli-windows-amd64 cmd/cli/main.go
build-cli-windows-arm64:
CGO_ENABLED=0 GO111MODULE=on GOOS=windows GOARCH=arm64 go build -o ${BIN_DIR}/upda-cli-windows-arm64 cmd/cli.go
CGO_ENABLED=0 GO111MODULE=on GOOS=windows GOARCH=arm64 go build -tags prod -o ${BIN_DIR}/upda-cli-windows-arm64 cmd/cli/main.go
test: test-server test-cli test-util
# remove built build/conf directory to be served live from the running binary
build-web:
cd ${WEB_DIR}; npm run build; rm -rf build/conf
# ci
clean-ci: clean
dependencies-ci: dependencies
checkstyle-ci: checkstyle
test-ci: test
test-server:
GO111MODULE=on go test ./server/...
test-cli:
GO111MODULE=on go test ./terminal/...
test-util:
GO111MODULE=on go test ./util/...
build-server-ci: build-server-linux-amd64
build-cli-ci: build-cli-linux-amd64
build-web-ci: build-web
ci: clean-ci dependencies-ci checkstyle-ci test-ci build-web-ci build-server-ci build-cli-ci

362
README.md
View file

@ -1,16 +1,6 @@
# README
upda - **Up**date **Da**shboard in Go. Please see [motivation](#motivation) and [concepts](#concepts) what this
application does.
There's also a [upda web interface](https://git.myservermanager.com/varakh/upda-ui). It's recommended to take a look (at
least at the screenshots).
In addition, there's a commandline tool called `upda-cli`. For more information, download it and run `./upda-cli help`
for further instructions. This is especially useful, if you have an `upda` (server) running and like to invoke webhooks
from CLI. `upda-cli` is also bundled in the docker images.
**See the [deployment instructions](./_doc/DEPLOYMENT.md) for examples on how to deploy upda and upda-ui**
upda - **Up**date **Da**shboard in Go.
The main git repository is hosted at
_[https://git.myservermanager.com/varakh/upda](https://git.myservermanager.com/varakh/upda)_.
@ -18,289 +8,55 @@ Other repositories are mirrors and pull requests, issues, and planning are manag
Contributions are very welcome!
* [Motivation](#motivation)
* [Concepts](#concepts)
* [Configuration](#configuration)
* [3rd party integrations](#3rd-party-integrations)
* [Webhooks](#webhooks)
* [Prometheus Metrics](#prometheus-metrics)
* [Deployment](#deployment)
* [Native](#native)
* [Docker](#docker)
* [Build docker image](#build-docker-image)
* [Development & contribution](#development--contribution)
* [Getting started](#getting-started)
* [Windows hints](#windows-hints)
* [Release](#release)
## Motivation
> [duin](https://crazymax.dev/diun/) can determine which OCI images have updates
> available. [Argus](https://release-argus.io) can query other sources like GitHub and even invoke actions when an
> update
> has been found, but there's no _convenient_ way of having **one** dashboard or source of truth for all of them across
> different hosts without tinkering with collecting them somewhere in one place. This application is the result of that
> tinkering. :-)
Managing various application or OCI container image updates can be a tedious task:
* A lot of hosts to operate with a lot of different applications being deployed
* A lot of different OCI containers to watch for updated images
* No convenient dashboard to see and manage all the available updates in one place
_upda_ manages a list of updates with attributes attached to it. For new updates to arrive, _upda_ needs to be called
via a webhook call (created within _upda_) from other applications, such as a bash script, an
application like [duin](https://crazymax.dev/diun/) or simply by using the `upda-cli`.
After an update is being tracked, _upda_ provides a convenient way to have everything in one place. In addition, it
exposes managed _updates_ as [prometheus](https://prometheus.io) metrics, so that you can easily build a dashboard
in [Grafana](https://grafana.com), or even attach alerts to pending updates
via [alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/).
In addition, you can use _upda_'s UI to manage updates, e.g. _approve_ them when they have been rolled out to a host.
Important to note:
* _upda_ is **NOT a scraper** to watch docker registries or GitHub releases, it simply collects and consolidates updates
from different sources via _webhooks_. If you like to watch GitHub releases, write a scraper and use `upda-cli` to
report back to _upda_.
* _upda_ uses basic auth for administrative tasks like viewing available updates or setting up the initial webhooks.
## Concepts
_upda_ retrieves new updates when webhooks of upda are invoked, e.g., [duin](https://crazymax.dev/diun/) invokes it
or any other application which can reach the instance.
Tracked updates are unique for the attributes `(application,provider,host)` which means that subsequent updates for an
identical _application_, _provider_ and _host_ simply updates the `version` and `metadata` attributes for that tracked
_update_ (regardless if the version or metadata payload _actually_ changed - reasoning behind this is to get reflected
metadata updates independent if version attribute has changed).
State management of tracked updates:
* On first creation, state is set to _pending_.
* When an _update_ is in _approved_ state, an invocation for it resets its state to _pending_.
* _Ignored_ updates are skipped entirely and no attribute is updated.
##### The `application` attribute
The _application_ attribute is an arbitrary identifier, name or label of a subject you like to track,
e.g., `docker.io/varakh/upda` for an OCI image.
##### The `provider` attribute
The _provider_ attribute is an arbitrary name or label. During webhook invocation the provider attribute is derived in
priority:
For the _generic_ webhook:
1. If the incoming payload contains a non-blank `provider` attribute, it's taken from the request.
2. If the incoming payload contains a blank or missing `provider` attribute, the issuing webhook's label is taken.
For the _diun_ webhook:
1. If the issuing webhook's label is blank, then `oci` is used.
2. In any other case, the webhook's label is used.
Because the first priority is the issuing webhook's label, setting the _same_ label for all webhooks results in a
grouping. Also see the _ignore host_ setting for `host` below.
_Remember that changing a webhook's label won't be reflected in already created/tracked updates!_
##### The `host` attribute
_host_ should be set to the originating host name a webhook has been issued from. The _host_
attribute can also be "ignored" (a setting in each webhook). If set to ignored, _upda_ sets _host_ to _global_, thus
update versions can be grouped independent of the originating host. If set for all webhooks, you'll end up with a host
independent update dashboard.
##### The `version` attribute
The _version_ attribute is an arbitrary name or label and subject to change across invocations of webhooks. This can be
a version number, a number of total updates, anything.
##### The `metadata` attribute
An update can hold any additional metadata information provided by request payload `metadata`. Metadata can be inspected
via web interface or API.
## Configuration
The following environment variables can be used to modify application behavior.
| Variable | Purpose | Default/Description |
|:-----------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|
| `TZ` | The time zone (**recommended** to set it properly, background tasks depend on it) | Defaults to `Europe/Berlin`, can be any time zone according to _tz database_ |
| `ADMIN_USER` | Admin user name for login | Not set by default, you need to explicitly set it to user name |
| `ADMIN_PASSWORD` | Admin password for login | Not set by default, you need to explicitly set it to a secure random |
| | | |
| `DB_TYPE` | The database type (Postgres is **recommended**) | Defaults to `sqlite`, possible values are `sqlite` or `postgres` |
| `DB_SQLITE_FILE` | Path to the SQLITE file | Defaults to `<XDG_DATA_DIR>/upda/upda.db`, e.g. `~/.local/share/upda/upda.db` |
| `DB_POSTGRES_HOST` | The postgres host | Postgres host address, defaults to `localhost` |
| `DB_POSTGRES_PORT` | The postgres port | Postgres port, defaults to `5432` |
| `DB_POSTGRES_NAME` | The postgres database name | Postgres database name, needs to be set |
| `DB_POSTGRES_TZ` | The postgres time zone | Postgres time zone settings, defaults to `Europe/Berlin` |
| `DB_POSTGRES_USER` | The postgres user | Postgres user name, needs to be set |
| `DB_POSTGRES_PASSWORD` | The postgres password | Postgres user password, needs to be set |
| | | |
| `SERVER_PORT` | Port | Defaults to `8080` |
| `SERVER_LISTEN` | Server's listen address | Defaults to empty which equals `0.0.0.0` |
| `SERVER_TLS_ENABLED` | If server uses TLS | Defaults `false` |
| `SERVER_TLS_CERT_PATH` | When TLS enabled, provide the certificate path | |
| `SERVER_TLS_KEY_PATH` | When TLS enabled, provide the key path | |
| `SERVER_TIMEOUT` | Timeout the server waits before shutting down to end any pending tasks | Defaults to `1s` (1 second), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `CORS_ALLOW_ORIGIN` | CORS configuration | Defaults to `*` |
| `CORS_ALLOW_METHODS` | CORS configuration | Defaults to `GET, POST, PUT, PATCH, DELETE, OPTIONS` |
| `CORS_ALLOW_HEADERS` | CORS configuration | Defaults to `Authorization, Content-Type` |
| | | |
| `LOGGING_LEVEL` | Logging level. Possible are `debug`, `info`, `warn`, `error`, `dpanic`, `panic`, `fatal` | Defaults to `info` |
| | | |
| `WEBHOOKS_TOKEN_LENGTH` | The length of the token | Defaults to `16`, positive number |
| | | |
| `TASK_UPDATE_CLEAN_STALE_ENABLED` | If background task should run to do housekeeping of stale (ignored/approved) updates from the database | Defaults to `false` |
| `TASK_UPDATE_CLEAN_STALE_INTERVAL` | Interval at which a background task does housekeeping by deleting stale (ignored/approved) updates from the database | Defaults to `1h` (1 hour), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_UPDATE_CLEAN_STALE_MAX_AGE` | Number defining at which age stale (ignored/approved) updates are deleted by the background task (_updatedAt_ attribute decides) | Defaults to `168h` (168 hours = 1 week), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_EVENT_CLEAN_STALE_ENABLED` | If background task should run to do housekeeping of stale (old) events from the database | Defaults to `false` |
| `TASK_EVENT_CLEAN_STALE_INTERVAL` | Interval at which a background task does housekeeping by deleting stale (old) events from the database | Defaults to `8h` (8 hours), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_EVENT_CLEAN_STALE_MAX_AGE` | Number defining at which age stale (old) events are deleted by the background task (_updatedAt_ attribute decides) | Defaults to `2190h` (2190 hours = 3 months), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_PROMETHEUS_REFRESH_INTERVAL` | Interval at which a background task updates custom metrics | Defaults to `60s` (60 seconds), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| | | |
| `LOCK_REDIS_ENABLED` | If locking via REDIS (multiple instances) is enabled. Requires REDIS. Otherwise uses in-memory locks. | Defaults to `false` |
| `LOCK_REDIS_URL` | If locking via REDIS is enabled, this should point to a resolvable REDIS instance, e.g. `redis://<user>:<pass>@localhost:6379/<db>`. | |
| | | |
| `PROMETHEUS_ENABLED` | If Prometheus metrics are exposed | Defaults to `false` |
| `PROMETHEUS_METRICS_PATH` | Defines the metrics endpoint path | Defaults to `/metrics` |
| `PROMETHEUS_SECURE_TOKEN_ENABLED` | If Prometheus metrics endpoint is protected by a token when enabled (**recommended**) | Defaults to `true` |
| `PROMETHEUS_SECURE_TOKEN` | The token securing the metrics endpoint when enabled (**recommended**) | Not set by default, you need to explicitly set it to a secure random |
## 3rd party integrations
### Webhooks
This is the core mechanism of _upda_ and why it exists. Webhooks are the central piece of how _upda_ gets notified about
updates.
In order to configure a 3rd party application like [duin](https://crazymax.dev/diun/) to send updates to _upda_ with
the [duin webhook notification configuration](https://crazymax.dev/diun/notif/webhook/), **create** a new _upda_ webhook
token via _upda_'s web interface or via API call. This gives you
* a unique _upda_ URL to configure in the notification part of [duin](https://crazymax.dev/diun/),
e.g., `/api/v1/webhooks/<a unique identifier>`
* a corresponding token for the URL which must be sent as `X-Webhook-Token` header when calling _upda_'s URL
Expected payload is derived from the _type_ of the webhook which has been created in _upda_.
Example for [duin Webhook notification](https://crazymax.dev/diun/notif/webhook/) `notif`:
```yaml
notif:
webhook:
endpoint: https://upda.domain.tld/api/v1/webhooks/ee03cd9e-04d0-4c7f-9866-efe219c2501e
method: POST
headers:
content-type: application/json
X-Webhook-Token: <the token from webhook creation in upda>
timeout: 10s
```
### Prometheus Metrics
When `PROMETHEUS_ENABLED` is set to `true`, default metrics about memory utilization, but also custom metrics specific
to _upda_ are exposed under the `PROMETHEUS_METRICS_PATH` endpoint.
A Prometheus scrape configuration might look like the following if `PROMETHEUS_SECURE_TOKEN_ENABLED` is set to `true`.
```shell
scrape_configs:
- job_name: 'upda'
static_configs:
- targets: ['upda:8080']
bearer_token: 'VALUE_OF_PROMETHEUS_SECURE_TOKEN'
```
Custom exposed metrics are exposed under the `upda_` namespace.
Examples:
```shell
# HELP upda_updates details for all updates, -1=deleted (deleted next restart), 0=pending, 1=approved, 2=ignored
upda_updates{application="codeberg.org/forgejo/forgejo",host="myserver",provider="oci"} 0
upda_updates{application="docker.io/library/mysql",host="myserver",provider="oci"} 2
upda_updates{application="quay.io/navidys/prometheus-podman-exporter",host="myserver",provider="oci"} 1
upda_updates{application="quay.io/navidys/prometheus-podman-exporter",host="myserver2",provider="oci"} 1
# HELP upda_updates_all amount of all updates
upda_updates_all 4
# HELP upda_updates_approved amount of all updates in approved state
upda_updates_approved 2
# HELP upda_updates_ignored amount of all updates in ignored state
upda_updates_ignored 1
# HELP upda_updates_pending amount of all updates in pending state
upda_updates_pending 1
# HELP upda_webhooks amount of all webhooks
upda_webhooks 2
# HELP upda_events amount of all events
upda_events 146
```
There's an example [Grafana](https://grafana.com) dashboard in the `_doc/` folder.
[Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/) could check for the following:
```yaml
- name: update_checks
rules:
- alert: UpdatesAvailable
expr: upda_updates == 0 and upda_updates_pending > 0
for: 4w
labels:
severity: high
class: update
annotations:
summary: "Updates available from upda for {{ $labels.job }}"
description: "Updates available from upda for {{ $labels.job }}"
```
## Deployment
### Native
Use the released binary for your platform or run `make clean build-server-{your-platform}` and the binary will be placed
into the `bin/` folder.
### Docker
For examples how to run, look into [deployment instructions](./_doc/DEPLOYMENT.md) which contains examples
for `docker-compose` files.
#### Build docker image
To build docker images, do the following
```shell
docker build --rm --no-cache -t upda:latest .
```
See [official documentation](./_doc/Home.md).
## Development & contribution
* Ensure to set `LOGGING_LEVEL=debug` for proper debug logs during development.
* Code guidelines
* Each entity has its own repository
* Each entity is only used in repository and service (otherwise, mapping happens)
* Presenter layer is constructed from the entity, e.g., in REST responses and mapped
* No entity is directly returned in any REST response
* All log calls should be handled by `zap.L()`
* Configuration is bootstrapped via separated `struct` types which are given to the service which need them
* Error handling
* Always throw an error with `NewServiceError`
* Always wrap the cause error with `fmt.Errorf`
* Forward/bubble up the error directly, when original error is already a `NewServiceError` (most likely internal
calls)
* Always abort handler chain with `AbortWithError`
* Utils can throw any error
There's also a [embedded frontend](#embedded-frontend).
Please look into the `_doc/` folder for [OpenAPI specification](./_doc/api.yaml) and a Postman Collection.
* Pay attention to `make checkstyle` (uses `go vet ./...`); pipeline fails if issues are detected.
* Each entity has its own repository
* Each entity is only used in repository and service (otherwise, mapping happens)
* Presenter layer is constructed from the entity, e.g., in REST responses and mapped
* No entity is directly returned in any REST response
* All log calls should be handled by `zap.L()`
* Configuration is bootstrapped via separated `struct` types which are given to the service which need them
* Error handling
* Always throw an error with `NewServiceError` for repositories, services and handlers
* Always throw an error wrapping the cause with `fmt.Errorf`
* Forward/bubble up the error directly, when original error is already a `NewServiceError` (most likely internal
calls)
* Always abort handler chain with `AbortWithError`
* Utils can throw any error
* Repositories, handlers and services should always properly return `error` including any `init`-like function (
best to avoid them and call in `newXXX`). **Do not abort with `Fatalf` or similar**
* `log.Fatalf` or `zap.L().Fatal` is allowed in `environment.go` or `app.go`
* Look into the `_doc/` folder for [OpenAPI specification](./_doc/api.yaml) and a Postman Collection.
* Consider reading [Effective Go](https://go.dev/doc/effective_go)
* Consider reading [100 Go Mistakes and How to Avoid Them](https://100go.co/)
## Embedded Frontend
_upda_ includes a frontend in a monorepo fashion inside `server/web/`. For production (binary and OCI), it's
embedded into the GoLang binary itself.
For _development_, no other steps are required. Simply follow the [frontend instructions](./server/web/README.md) and
start the frontend separately.
If you like to have a look on the _production_ experience, the frontend needs to be build first and you need to build
the Golang binary with `-tags prod`. How to properly build the frontend, please look into `build-web` of
the `Makefile` (additional `rm -rf` cmd).
### Getting started
Ensure to set the following environment variables for proper debug logs during development
```shell
DEVELOPMENT=true
LOGGING_ENCODING=console
LOGGING_LEVEL=debug
```
1. Run `make clean dependencies` to fetch dependencies
2. Start `git.myservermanager.com/varakh/upda/cmd/server` (or `cli`) as Go application and ensure to have _required_
environment variables set
@ -332,6 +88,34 @@ path.
For any `go` command you run, ensure that your `PATH` has the `gcc` binary and that you add `CGO_ENABLED=1` as
environment.
### Using the `lockService` correctly
The `lockService` can be used to lock resources. This works in-memory and also in a distributed fashion with REDIS.
Ensure to provide proper locking options when using, although in-memory ignores those.
Example:
```shell
# invoked from an endpoint
context := c.Request.Context()
var err error
var lock appLock
if lock, err = h.lockService.lockWithOptions(context, "TEST-LOCK", withAppLockOptionExpiry(5*time.Minute), withAppLockOptionInfiniteRetries(), withAppLockOptionRetryDelay(5*time.Second)); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
# defer to avoid leakage
defer func(lock appLock) {
_ = lock.unlock(context)
}(lock)
# simulate long running task
time.Sleep(20 * time.Second)
```
### Release
Releases are handled by the SCM platform and pipeline. Creating a **new git tag**, creates a new release in the SCM
@ -339,7 +123,7 @@ platform, uploads produced artifacts to that release and publishes docker images
**Before** doing so, please ensure that the **commit on `master`** has the **correct version settings** and has been
built successfully:
* Adapt `constants_app.go` and change `Version` to the correct version number
* Adapt `commons/constants.go` and change `Version` to the correct version number
* Adapt `CHANGELOG.md` to reflect changes and ensure a date is properly set in the header, also add a reference link
in footer (link to scm git tag source)
* Adapt `api.yaml`: `version` attribute must reflect the to be released version
@ -347,7 +131,7 @@ built successfully:
After the release has been created, ensure to change the following settings for the _next development cycle_:
* Adapt `constants_app.go` and change `Version` to the _next_ version number
* Adapt `commons/constants.go` and change `Version` to the _next_ version number
* Adapt `CHANGELOG.md` and add an _UNRELEASED_ section
* Adapt `api.yaml`: `version` attribute must reflect the _next_ version number
* Adapt `env: VERSION_*` in `.forgejo/workflows/release.yaml` to _next_ version number

63
_doc/Concepts.md Normal file
View file

@ -0,0 +1,63 @@
# Concepts, a deeper dive
The following section goes into a deeper look into upda's internals.
1. Create a webhook in upda.
2. Use the webhook's URL in a 3rd party application to start tracking an update or use `upda-cli` to report an update.
3. Enjoy visualization and state management of tracked updates in one place.
4. Optionally, define _Actions_ for tracked updates as they arrive
_upda_ retrieves new updates when webhooks of upda are invoked, e.g., [duin](https://crazymax.dev/diun/) invokes it or
any other application which can reach the instance. Tracked updates are unique for the
attributes `(application,provider,host)` which means that subsequent updates for an identical _application_, _provider_
and _host_ simply updates the `version` and `metadata` attributes for that tracked _update_ (regardless if the version
or metadata payload _actually_ changed - reasoning behind this is to get reflected metadata updates independent if
version attribute has changed).
State management of tracked updates:
* On first creation, state is set to _pending_.
* When an _update_ is in _approved_ state, an invocation for it resets its state to _pending_.
* _Ignored_ updates are skipped entirely and no attribute is updated.
##### The `application` attribute
The _application_ attribute is an arbitrary identifier, name or label of a subject you like to track,
e.g., `docker.io/varakh/upda` for an OCI image.
##### The `provider` attribute
The _provider_ attribute is an arbitrary name or label. During webhook invocation the provider attribute is derived in
priority:
For the _generic_ webhook:
1. If the incoming payload contains a non-blank `provider` attribute, it's taken from the request.
2. If the incoming payload contains a blank or missing `provider` attribute, the issuing webhook's label is taken.
For the _diun_ webhook:
1. If the issuing webhook's label is blank, then `oci` is used.
2. In any other case, the webhook's label is used.
Because the first priority is the issuing webhook's label, setting the _same_ label for all webhooks results in a
grouping. Also see the _ignore host_ setting for `host` below.
_Remember that changing a webhook's label won't be reflected in already created/tracked updates!_
##### The `host` attribute
_host_ should be set to the originating host name a webhook has been issued from. The _host_
attribute can also be "ignored" (a setting in each webhook). If set to ignored, _upda_ sets _host_ to _global_, thus
update versions can be grouped independent of the originating host. If set for all webhooks, you'll end up with a host
independent update dashboard.
##### The `version` attribute
The _version_ attribute is an arbitrary name or label and subject to change across invocations of webhooks. This can be
a version number, a number of total updates, anything.
##### The `metadata` attribute
An update can hold any additional metadata information provided by request payload `metadata`. Metadata can be inspected
via web interface or API.

74
_doc/Configuration.md Normal file
View file

@ -0,0 +1,74 @@
# Configuration
The following table describe available configuration values.
| Variable | Purpose | Default/Description |
|:------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|
| `SECRET` | A 32 character long secure random secret used for encrypting some data inside the database. When data has been created inside the database, the secret cannot be changed anymore, otherwise decryption fails. | Not set by default, you need to explicitly set it, e.g., generate via `openssl rand -hex 16` |
| `TZ` | The time zone (**recommended** to set it properly, background tasks depend on it) | Defaults to `Europe/Berlin`, can be any time zone according to _tz database_ |
| | | |
| `AUTH_MODE` | The auth mode. Possible values are `basic_single` and `basic_credentials` | Defaults to `basic_single` |
| `BASIC_AUTH_USER` | For auth mode `basic_single`: Username for login | Not set by default, you need to explicitly set it to user name |
| `BASIC_AUTH_PASSWORD` | For auth mode `basic_single`: User's password for login | Not set by default, you need to explicitly set it to a secure random |
| `BASIC_AUTH_CREDENTIALS` | For auth mode `basic_credentials`: list of comma separated credentials, e.g. `username1=password1,username2=password2` | Not set by default, you need to explicitly set it |
| | | |
| `DB_TYPE` | The database type (Postgres is **recommended**) | Defaults to `sqlite`, possible values are `sqlite` or `postgres` |
| `DB_SQLITE_FILE` | Path to the SQLITE file | Defaults to `<XDG_DATA_DIR>/upda/upda.db`, e.g. `~/.local/share/upda/upda.db` |
| `DB_POSTGRES_HOST` | The postgres host | Postgres host address, defaults to `localhost` |
| `DB_POSTGRES_PORT` | The postgres port | Postgres port, defaults to `5432` |
| `DB_POSTGRES_NAME` | The postgres database name | Postgres database name, needs to be set |
| `DB_POSTGRES_TZ` | The postgres time zone | Postgres time zone settings, defaults to `Europe/Berlin` |
| `DB_POSTGRES_USER` | The postgres user | Postgres user name, needs to be set |
| `DB_POSTGRES_PASSWORD` | The postgres password | Postgres user password, needs to be set |
| | | |
| `SERVER_PORT` | Port | Defaults to `8080` |
| `SERVER_LISTEN` | Server's listen address | Defaults to empty which equals `0.0.0.0` |
| `SERVER_TLS_ENABLED` | If server uses TLS | Defaults `false` |
| `SERVER_TLS_CERT_PATH` | When TLS enabled, provide the certificate path | |
| `SERVER_TLS_KEY_PATH` | When TLS enabled, provide the key path | |
| `SERVER_TIMEOUT` | Timeout the server waits before shutting down to end any pending tasks | Defaults to `1s` (1 second), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `CORS_ALLOW_ORIGINS` | CORS configuration | Defaults to `*` |
| `CORS_ALLOW_METHODS` | CORS configuration | Defaults to `GET, POST, PUT, PATCH, DELETE, OPTIONS` |
| `CORS_ALLOW_HEADERS` | CORS configuration | Defaults to `Authorization, Content-Type` |
| `CORS_ALLOW_CREDENTIALS` | CORS configuration | Defaults to `true` |
| `CORS_EXPOSE_HEADERS` | CORS configuration | Defaults to `*` |
| | | |
| `LOGGING_LEVEL` | Logging level. Possible are `debug`, `info`, `warn`, `error`, `dpanic`, `panic`, `fatal`. Setting to `debug` enables high verbosity output. | Defaults to `info` |
| `LOGGING_ENCODING` | Logging encoding. Possible are `console` and `json` | Defaults to `json` |
| `LOGGING_DIRECTORY` | Logging directory. When set, logs will be added to a file called `upda.log` in addition to the standard output. Ensure that upda has access permissions. Use an external program for log rotation if desired. | |
| | | |
| `WEBHOOKS_TOKEN_LENGTH` | The length of the token | Defaults to `16`, positive number |
| | | |
| `TASK_UPDATE_CLEAN_STALE_ENABLED` | If background task should run to do housekeeping of stale (ignored/approved) updates from the database | Defaults to `false` |
| `TASK_UPDATE_CLEAN_STALE_INTERVAL` | Interval at which a background task does housekeeping by deleting stale (ignored/approved) updates from the database | Defaults to `1h` (1 hour), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_UPDATE_CLEAN_STALE_MAX_AGE` | Number defining at which age stale (ignored/approved) updates are deleted by the background task (_updatedAt_ attribute decides) | Defaults to `720h` (168 hours = 1 week), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| | | |
| `TASK_EVENT_CLEAN_STALE_ENABLED` | If background task should run to do housekeeping of stale (old) events from the database | Defaults to `false` |
| `TASK_EVENT_CLEAN_STALE_INTERVAL` | Interval at which a background task does housekeeping by deleting stale (old) events from the database | Defaults to `8h` (8 hours), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_EVENT_CLEAN_STALE_MAX_AGE` | Number defining at which age stale (old) events are deleted by the background task (_updatedAt_ attribute decides) | Defaults to `2190h` (2190 hours = 3 months), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| | | |
| `TASK_ACTIONS_ENQUEUE_ENABLED` | If background task should run to enqueue matching actions derived from events (actions are invocation separately after being enqueued) | Defaults to `true` |
| `TASK_ACTIONS_ENQUEUE_INTERVAL` | Interval at which a background task does check to enqueue actions | Defaults to `10s` (10 seconds), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_ACTIONS_ENQUEUE_BATCH_SIZE` | Number defining how many unhandled events are processed in a batch by the background task | Defaults to `1`, must be positive number |
| | | |
| `TASK_ACTIONS_INVOKE_ENABLED` | If background task should run to invoke enqueued actions derived | Defaults to `true` |
| `TASK_ACTIONS_INVOKE_INTERVAL` | Interval at which a background task does check to invoke enqueued actions | Defaults to `10s` (10 seconds), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_ACTIONS_INVOKE_BATCH_SIZE` | Number defining how many enqueued actions are processed in a batch by the background task | Defaults to `1`, must be positive number |
| `TASK_ACTIONS_INVOKE_MAX_RETRIES` | Number defining how often actions are invoked in case of an error, if exceeded, those actions are not retried again | Defaults to `3`, must be positive number |
| | | |
| `TASK_ACTIONS_CLEAN_STALE_ENABLED` | If background task should run to do housekeeping of stale (handled, meaning success or error state) actions from the database | Defaults to `true` |
| `TASK_ACTIONS_CLEAN_STALE_INTERVAL` | Interval at which a background task does housekeeping by deleting stale (handled) actions from the database | Defaults to `12h` (12 hours), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| `TASK_ACTIONS_CLEAN_STALE_MAX_AGE` | Number defining at which age stale (handled) actions are deleted by the background task (_updatedAt_ attribute decides) | Defaults to `720h` (720 hours = 30 days), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| | | |
| `TASK_PROMETHEUS_REFRESH_INTERVAL` | Interval at which a background task updates custom metrics | Defaults to `60s` (60 seconds), qualifier can be `s = second`, `m = minute`, `h = hour` prefixed with a positive number |
| | | |
| `LOCK_REDIS_ENABLED` | If locking via REDIS (multiple instances) is enabled. Requires REDIS. Otherwise uses in-memory locks. | Defaults to `false` |
| `LOCK_REDIS_URL` | If locking via REDIS is enabled, this should point to a resolvable REDIS instance, e.g. `redis://<user>:<pass>@localhost:6379/<db>`. | |
| | | |
| `PROMETHEUS_ENABLED` | If Prometheus metrics are exposed | Defaults to `false` |
| `PROMETHEUS_METRICS_PATH` | Defines the metrics endpoint path | Defaults to `/metrics` |
| `PROMETHEUS_SECURE_TOKEN_ENABLED` | If Prometheus metrics endpoint is protected by a token when enabled (**recommended**) | Defaults to `true` |
| `PROMETHEUS_SECURE_TOKEN` | The token securing the metrics endpoint when enabled (**recommended**) | Not set by default, you need to explicitly set it to a secure random |
| | | |
| `WEB_API_URL` | Base URL of API, e.g. `https://upda.domain.tld` | `http://localhost` |
| `WEB_TITLE` | The title of the frontend page | `upda` |

View file

@ -1,170 +0,0 @@
# Deployment
Use one of the provided `docker-compose` examples, edit to your needs. Then issue `docker compose up` command.
All applications should be up and running.
As of now, the web interface and the server comes as different container images.
Default image user is `appuser` (`uid=2033`) and group is `appgroup` (`gid=2033`).
The following examples are available
## Postgres
```yaml
version: '3.9'
networks:
internal:
external: false
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-upda
services:
ui:
container_name: upda_ui
image: git.myservermanager.com/varakh/upda-ui:latest
environment:
- VITE_API_URL=https://upda.domain.tld/api/v1/
- VITE_APP_TITLE=upda
- VITE_APP_DESCRIPTION=upda
restart: unless-stopped
networks:
- internal
ports:
- "127.0.0.1:8181:80"
depends_on:
- api
api:
container_name: upda_api
image: git.myservermanager.com/varakh/upda:latest
environment:
- TZ=Europe/Berlin
- DB_POSTGRES_TZ=Europe/Berlin
- DB_TYPE=postgres
- DB_POSTGRES_HOST=db
- DB_POSTGRES_PORT=5432
- DB_POSTGRES_NAME=upda
- DB_POSTGRES_USER=upda
- DB_POSTGRES_PASSWORD=upda
- ADMIN_USER=admin
- ADMIN_PASSWORD=changeit
restart: unless-stopped
networks:
- internal
ports:
- "127.0.0.1:8080:8080"
depends_on:
- db
db:
container_name: upda_db
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=upda
- POSTGRES_PASSWORD=upda
- POSTGRES_DB=upda
networks:
- internal
volumes:
- upda-db-vol:/var/lib/postgresql/data
volumes:
upda-db-vol:
external: false
```
## SQLite
```yaml
version: '3.9'
networks:
internal:
external: false
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-upda
services:
ui:
container_name: upda_ui
image: git.myservermanager.com/varakh/upda-ui:latest
environment:
- VITE_API_URL=https://upda.domain.tld/api/v1/
- VITE_APP_TITLE=upda
- VITE_APP_DESCRIPTION=upda
restart: unless-stopped
networks:
- internal
ports:
- "127.0.0.1:8181:80"
depends_on:
- api
api:
container_name: upda_api
image: git.myservermanager.com/varakh/upda:latest
environment:
- TZ=Europe/Berlin
- DB_SQLITE_FILE=/data/upda.db
- ADMIN_USER=admin
- ADMIN_PASSWORD=changeit
restart: unless-stopped
networks:
- internal
volumes:
- upda-app-vol:/data
ports:
- "127.0.0.1:8080:8080"
volumes:
upda-app-vol:
external: false
```
### Reverse proxy
You may want to use a proxy in front of them on your host, e.g., nginx. Here's a configuration snippet which should do
the work.
The UI and API is reachable through the same domain, e.g., `https://upda.domain.tld`. In addition, Let's Encrypt is used
for transport encryption.
```shell
server {
listen 443 ssl http2;
ssl_certificate /etc/letsencrypt/live/upda.domain.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/upda.domain.tld/privkey.pem;
# ui
location / {
proxy_pass http://localhost:8181;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# api
location ~* ^/(api)/ {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# metrics
location ~* ^/metrics {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```

228
_doc/Deployment.md Normal file
View file

@ -0,0 +1,228 @@
# Deployment
_upda_ is a server application which embeds a webinterface directly in its binary form. This makes it easy to deploy
natively. In addition, a _upda_ docker image is provided to get started quickly.
_upda-cli_ which is an optional commandline helper to quickly invoke webhooks or list tracked updates in
your is also embedded into the docker image, but can also be downloaded for your operating system.
The following sections outline how to deploy _upda_ in a containerized environment and also natively.
## Container
Use one of the provided `docker-compose` examples, edit to your needs. Then issue `docker compose up -d` command and
`docker compose logs -f` to trace the log.
Default image user is `appuser` (`uid=2033`) and group is `appgroup` (`gid=2033`).
The following examples are available
### Postgres
#### docker-compose
```yaml
networks:
internal:
external: false
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-upda
services:
app:
container_name: upda_app
image: git.myservermanager.com/varakh/upda:latest
environment:
- WEB_API_URL=https://upda.domain.tld
- WEB_TITLE=upda
- TZ=Europe/Berlin
- DB_POSTGRES_TZ=Europe/Berlin
- DB_TYPE=postgres
- DB_POSTGRES_HOST=db
- DB_POSTGRES_PORT=5432
- DB_POSTGRES_NAME=upda
- DB_POSTGRES_USER=upda
- DB_POSTGRES_PASSWORD=upda
- BASIC_AUTH_USER=admin
- BASIC_AUTH_PASSWORD=changeit
# generate 32 character long secret, e.g., with "openssl rand -hex 16"
- SECRET=generated-secure-secret-32-chars
restart: unless-stopped
networks:
- internal
ports:
- "127.0.0.1:8080:8080"
depends_on:
- db
db:
container_name: upda_db
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=upda
- POSTGRES_PASSWORD=upda
- POSTGRES_DB=upda
networks:
- internal
volumes:
- upda-db-vol:/var/lib/postgresql/data
volumes:
upda-db-vol:
external: false
```
### SQLite
#### docker-compose
You can use the following to get it up running quickly via docker compose.
```yaml
networks:
internal:
external: false
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-upda
services:
app:
container_name: upda_app
image: git.myservermanager.com/varakh/upda:latest
environment:
- WEB_API_URL=https://upda.domain.tld
- WEB_TITLE=upda
- TZ=Europe/Berlin
- BASIC_AUTH_USER=admin
- BASIC_AUTH_PASSWORD=changeit
# generate 32 character long secret, e.g., with "openssl rand -hex 16"
- SECRET=generated-secure-secret-32-chars
restart: unless-stopped
networks:
- internal
volumes:
- upda-app-vol:/home/appuser
ports:
- "127.0.0.1:8080:8080"
volumes:
upda-app-vol:
external: false
```
#### Local example
For spinning it up **locally** and without a [reverse proxy](#reverse-proxy), you can use the following simple `docker`
commands.
Make sure to adapt `DOMAIN` and pipe in your device IP address (LAN), e.g., `192.168.1.42`.
```shell
# create volume
docker volume create upda-app-vol
# run locally binding to your LAN IP address
docker run --name upda_app \
-p 8080:8080 \
-e TZ=Europe/Berlin \
-e WEB_API_URL=http://192.168.1.42:8080 \
-e BASIC_AUTH_USER=admin \
-e BASIC_AUTH_PASSWORD=changeit \
-v upda-app-vol:/home/appuser \
varakh/upda:latest
```
## High availability
For high availability, pick the [Postgres setup](#postgres) and add [REDIS](https://redis.io/) to support proper
distributed locking.
Make changes to your docker-compose deployment similar to the following:
```yaml
# the existing app service - add these changes to all instances, so they all use the same redis instance
# make sure that all of them can connect to the redis instance
# ...
app:
environment:
- LOCK_REDIS_ENABLED=true
- LOCK_REDIS_URL=redis://redis:6379/0
# the new redis service
redis:
container_name: upda_redis
image: redis
restart: unless-stopped
networks:
- internal
volumes:
- redis-data-vol:/var/redis/data
# optionally expose port depending on your setup
ports:
- "127.0.0.1:6379:6379"
volumes:
# other already defined volumes
# ...
redis-data-vol:
external: false
```
In addition, you need a proper load balancer which routes incoming traffic to all of your instances.
Furthermore, you can also decide to have the frontend in a high-availability setup.
## Reverse proxy
You may want to use a proxy in front of them on your host, e.g., nginx. Here's a configuration snippet which should do
the work.
The UI and API (backend/server) is reachable through the same domain, e.g., `https://upda.domain.tld`. In addition,
Let's Encrypt is used for transport encryption.
```shell
server {
listen 443 ssl http2;
ssl_certificate /etc/letsencrypt/live/upda.domain.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/upda.domain.tld/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Native
Native deployment is also possible.
Download the binary for your operating system. Next, use the binary or execute it locally.
See the provided systemd service example `upda.service` to deploy on a UNIX/Linux machine.
```shell
[Unit]
Description=upda
After=network.target
[Service]
Type=simple
# Using a dynamic user drops privileges and sets some security defaults
# See https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html
DynamicUser=yes
# All environment variables for upda can be put into this file
# upda picks them up (on each restart)
EnvironmentFile=/etc/upda.conf
# Requires upda' binary to be installed at this location, e.g., via package manager or copying it over manually
ExecStart=/usr/local/bin/upda-server
```
For a full set of available configuration, look into the [Configuration](./Configuration.md) section. Furthermore,
it's recommended to set up proper [Monitoring](./Monitoring.md).

47
_doc/Home.md Normal file
View file

@ -0,0 +1,47 @@
# upda
**Up**date **Da**shboard (upda). A simple application to keep track of updates from different hosts and systems.
Managing various application or OCI container image updates can be a tedious task:
* A lot of hosts to operate with a lot of different applications being deployed
* A lot of different OCI containers to watch for updated images
* No convenient dashboard to see and manage all the available updates in one place
_upda_ manages a list of updates with attributes attached to it. For new updates to arrive, _upda_ needs to be called
via a webhook call (created within _upda_) from other applications, such as a bash script, an
application like [duin](https://crazymax.dev/diun/) or simply by using the `upda-cli`.
Please head over to the [Usage](./Usage.md) section for a quick _Getting Started_ once you've [deployed](./Deployment.md)
_upda_.
The code is hosted here: [upda and CLI application including frontend](https://git.myservermanager.com/varakh/upda).
## Features
_upda_ manages a list of updates with attributes attached to it. For new updates to arrive, _upda_ needs to get them
from an external source.
For this, _upda_ allows to manage webhooks, which can be called with a unique URL from any other application or even a
bash script so that upda retrieves these information.
_upda_'s main features include
* Managing [Updates](./Usage.md#manage-updates) by changing their state (pending, ignored, approved)
* Managing [Webhooks](./Usage.md#getting-updates-in-via-webhooks) which allow to get information into _upda_ regarding Updates
and their properties (like version) you like to track
* Managing [Actions](./Usage.md#actions) which allow you to further process changes made to an Update (created, state
changed, version
changed,), basically allowing you to invoke other systems with the help
of [shoutrrr](https://containrrr.dev/shoutrrr/)
* View [past invocation of Actions](./Usage.md#history-of-actions)
* Viewing [events](./Usage.md#see-what-has-changed) which allow you to see what has changed and how Updates
* [Metrics exporter](./Monitoring.md) via prometheus
_upda_ is designed to be simple. Only supported authorization mechanism is basic.
## What it is not
_upda_ is **NOT** a scraper to watch docker registries or GitHub releases, it simply tracks and consolidates updates
from different sources, but you need to feed in these information on your own, e.g., via Webhooks. If you like to watch
GitHub releases, write a scraper and use `upda-cli` to report back to _upda_.

File diff suppressed because it is too large Load diff

174
_doc/Usage.md Normal file
View file

@ -0,0 +1,174 @@
# Usage
Getting started in _upda_ is easy after it has been [deployed](./Deployment.md) successfully and is reachable through your
browser.
![img](./img/updates.png)
## Login
Head over to the deployed _upda_ instance in your browser and login with your credentials.
![img](./img/login.png)
## Getting updates in via Webhooks
To get your first updates into _upda_, create a new Webhook. Webhooks are the central piece of how _upda_ gets notified
about updates.
![img](./img/webhooks.png)
After you've created a new Webhook, you should see
* a unique _upda_ `URL` which serves as entrypoint of other 3rd party applications,
e.g., `/api/v1/webhooks/<a unique identifier>` and
* a corresponding `token` (write it down somewhere, you won't see it again after initial creation) for the URL which
must be sent as `X-Webhook-Token` header when calling _upda_'s URL.
Next step is to make your 3rd party application use this webhook and bring in new updates into _upda_.
A good example is [duin](https://crazymax.dev/diun/), which is able to watch docker images for changes and updates. It
can be configured with a config file
and [diun's "notif" plugin supports calling external webhooks once a change is observed](https://crazymax.dev/diun/notif/webhook/).
We just need to configure _upda_ as the receiving application in diun's configuration file.
```yaml
notif:
webhook:
endpoint: https://upda.domain.tld/api/v1/webhooks/ee03cd9e-04d0-4c7f-9866-efe219c2501e
method: POST
headers:
content-type: application/json
X-Webhook-Token: <the token from webhook creation in upda>
timeout: 10s
```
Expected payload is derived from the _type_ of the webhook which has been created in _upda_.
In addition, a webhook in _upda_ can be set to ignore the host. Please read more on that in the [Concepts](./Concepts.md)
section.
## Actions
Actions can be used to invoke arbitrary third party tools when an _event_ occurs, e.g., an update has been created or
modified. An action is triggered when its conditions are met, which means that the action's definition (event name,
host, application, provider) fits the change which happend in _upda_.
Actions have types. Different types require different payload to set them up. [shoutrrr](#shoutrrr) is supported as
action type, which can send notifications to a variety of services like Gotify, Ntfy, Teams, OpsGenie and many more.
It in turn also support invoking calls to an external URL. This means you can have a stream of events being triggered
when an update arrives in _upda_.
To create an Action, go to the Actions tab and click on _Create new action_. Enter the necessary information and
consult the Action's type documentation if necessary.
![img](./img/actions.png)
Supported events for Actions are the following:
| Event name | Description |
|:-------------------------|:--------------------------------------------------------------------|
| `update_created` | An update has been created |
| `update_updated` | An update has been updated (not necessarily its version attribute!) |
| `update_updated_state` | An update's state changed |
| `update_updated_version` | An update's version changed |
| `update_deleted` | An update has been removed |
For privacy, an action's configuration supports upda's **secrets** vault, which means that before an action is
triggered, any occurrence of `<SECRET>SECRET_KEY</SECRET>` is properly replaced by the value of the `SECRET_KEY` defined
inside the vault.
Secrets can be used in all payload for an Action, including shoutrrr's URL. To create a new secret, go to the _Secrets_
tab and click on _Create new secret_.
![img](./img/secrets.png)
In addition to secrets, upda provides **variables** which can be used with the `<VAR>VARIABLE_NAME</VAR>` syntax and any
occurrence is replaced before invocation as well.
| Variable name | Description |
|:-------------------------|:--------------------------------------------------|
| `<VAR>APPLICATION</VAR>` | The update's application name invoking the action |
| `<VAR>PROVIDER</VAR>` | The update's provider name invoking the action |
| `<VAR>HOST</VAR>` | The update's host invoking the action |
| `<VAR>VERSION</VAR>` | The update's version (latest) invoking the action |
| `<VAR>STATE</VAR>` | The update's state invoking the action |
#### shoutrrr
[shoutrrr](https://github.com/containrrr/shoutrrr?tab=readme-ov-file#documentation) supports multiple services directly
which can be provided as simple URL, e.g., `gotify://gotify.example.com:443/<token>`, where `<token>`
can also be provided as secret: `gotify://gotify.example.com:443/<SECRET>GOTIFY_TOKEN</SECRET>`.
##### shoutrrr: example for sending mails
Before starting, add the following _Secrets_:
```
MAIL_USER
MAIL_PASS
MAIL_HOST
MAIL_PORT
MAIL_FROM
MAIL_TO
```
For each event, now create a new _Action_ with different payload:
_New updates_
| Field | Content |
|:------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| URL 1 | `smtp://<SECRET>MAIL_USER</SECRET>:<SECRET>MAIL_PASS</SECRET>@<SECRET>MAIL_HOST</SECRET>:<SECRET>MAIL_PORT</SECRET>/?from=<SECRET>MAIL_FROM</SECRET>&to=<SECRET>MAIL_TO</SECRET>&Subject=[upda]+New+Update` |
| Body | `New update '<VAR>APPLICATION</VAR>' (<VAR>VERSION</VAR>) arrived on <VAR>HOST</VAR> for provider <VAR>PROVIDER</VAR>.` |
_Update changed_
| Field | Content |
|:------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| URL 1 | `smtp://<SECRET>MAIL_USER</SECRET>:<SECRET>MAIL_PASS</SECRET>@<SECRET>MAIL_HOST</SECRET>:<SECRET>MAIL_PORT</SECRET>/?from=<SECRET>MAIL_FROM</SECRET>&to=<SECRET>MAIL_TO</SECRET>&Subject=[upda]+Update+changed` |
| Body | `Update '<VAR>APPLICATION</VAR>' (<VAR>VERSION</VAR>) changed on <VAR>HOST</VAR> for provider <VAR>PROVIDER</VAR>.` |
_Version changed_
| Field | Content |
|:------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| URL 1 | `smtp://<SECRET>MAIL_USER</SECRET>:<SECRET>MAIL_PASS</SECRET>@<SECRET>MAIL_HOST</SECRET>:<SECRET>MAIL_PORT</SECRET>/?from=<SECRET>MAIL_FROM</SECRET>&to=<SECRET>MAIL_TO</SECRET>&Subject=[upda]+Update+version+changed` |
| Body | `Update's version changed to '<VAR>VERSION</VAR>' for '<VAR>APPLICATION</VAR>' on <VAR>HOST</VAR> and provider <VAR>PROVIDER</VAR>.` |
_State changed_
| Field | Content |
|:------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| URL 1 | `smtp://<SECRET>MAIL_USER</SECRET>:<SECRET>MAIL_PASS</SECRET>@<SECRET>MAIL_HOST</SECRET>:<SECRET>MAIL_PORT</SECRET>/?from=<SECRET>MAIL_FROM</SECRET>&to=<SECRET>MAIL_TO</SECRET>&Subject=[upda]+Update+state+changed` |
| Body | `Update's state changed to '<VAR>STATE</VAR>' for '<VAR>APPLICATION</VAR>' (<VAR>VERSION</VAR>) on <VAR>HOST</VAR> and provider <VAR>PROVIDER</VAR>.` |
In addition, you can have multiple URL fields, e.g., for sending a mail and a push notification.
### History of actions
Whenever new updates come in, are changed or an update's state changes, _upda_ enqueues all matching Actions.
If you head over to the Action History tab, you see pending, currently running, successful or error invocations of
actions.
![img](./img/actions_history.png)
## Manage updates
Once Update are in _upda_, you can filter them by state, application or other properties to only see pending Updates for
example.
Furthermore, you can change their state to be ignored (see [Concepts](./Concepts.md)) or delete them.
![img](./img/updates.png)
In addition, you can view an Update's details by clicking on the small info icon for an Update.
![img](./img/updates_detail.png)
## See what has changed
For a full activity view, head over to the Events tab.
![img](./img/events.png)

File diff suppressed because it is too large Load diff

BIN
_doc/img/actions.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
_doc/img/events.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
_doc/img/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
_doc/img/secrets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
_doc/img/updates.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
_doc/img/updates_detail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
_doc/img/webhooks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,15 @@
package api
const (
HeaderAppName = "X-App-Name"
HeaderAppVersion = "X-App-Version"
HeaderWebhookToken = "X-Webhook-Token"
HeaderContentType = "Content-Type"
HeaderContentTypeApplicationJson = "application/json"
)
// UpdateState state of an update
type UpdateState string
@ -39,16 +49,11 @@ func (e WebhookType) Value() string {
type EventName string
const (
EventNameUpdateCreated EventName = "update_created"
EventNameUpdateUpdated EventName = "update_updated"
EventNameUpdateUpdatedPending EventName = "update_updated_state_pending"
EventNameUpdateUpdatedApproved EventName = "update_updated_state_approved"
EventNameUpdateUpdatedIgnored EventName = "update_updated_state_ignored"
EventNameUpdateDeleted EventName = "update_deleted"
EventNameWebhookCreated EventName = "webhook_created"
EventNameWebhookUpdatedLabel EventName = "webhook_updated_label"
EventNameWebhookUpdatedIgnoreHost EventName = "webhook_updated_ignore_host"
EventNameWebhookDeleted EventName = "webhook_deleted"
EventNameUpdateCreated EventName = "update_created"
EventNameUpdateUpdated EventName = "update_updated"
EventNameUpdateUpdatedState EventName = "update_updated_state"
EventNameUpdateUpdatedVersion EventName = "update_updated_version"
EventNameUpdateDeleted EventName = "update_deleted"
)
func (e *EventName) Scan(value interface{}) error {
@ -64,7 +69,8 @@ func (e EventName) Value() string {
type EventState string
const (
EventStateCreated EventState = "created"
EventStateCreated EventState = "created"
EventStateEnqueued EventState = "enqueued"
)
func (e *EventState) Scan(value interface{}) error {
@ -75,3 +81,39 @@ func (e *EventState) Scan(value interface{}) error {
func (e EventState) Value() string {
return string(e)
}
// ActionType state of an update
type ActionType string
const (
ActionTypeShoutrrr ActionType = "shoutrrr"
)
func (e *ActionType) Scan(value interface{}) error {
*e = ActionType(value.([]byte))
return nil
}
func (e ActionType) Value() string {
return string(e)
}
// ActionInvocationState state of an action invocation
type ActionInvocationState string
const (
ActionInvocationStateCreated ActionInvocationState = "created"
ActionInvocationStateRunning ActionInvocationState = "running"
ActionInvocationStateRetrying ActionInvocationState = "retrying"
ActionInvocationStateSuccess ActionInvocationState = "success"
ActionInvocationStateError ActionInvocationState = "error"
)
func (e *ActionInvocationState) Scan(value interface{}) error {
*e = ActionInvocationState(value.([]byte))
return nil
}
func (e ActionInvocationState) Value() string {
return string(e)
}

View file

@ -25,6 +25,63 @@ type CreateWebhookRequest struct {
IgnoreHost bool `json:"ignoreHost"`
}
type CreateSecretRequest struct {
Key string `json:"key" binding:"required,min=1"`
Value string `json:"value" binding:"required,min=1"`
}
type CreateActionRequest struct {
Label string `json:"label" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=shoutrrr"`
MatchEvent *string `json:"matchEvent"`
MatchHost *string `json:"matchHost"`
MatchApplication *string `json:"matchApplication"`
MatchProvider *string `json:"matchProvider"`
Payload interface{} `json:"payload"`
Enabled bool `json:"enabled"`
}
type ModifySecretValueRequest struct {
Value string `json:"value" binding:"required,min=1"`
}
type ModifyActionLabelRequest struct {
Label string `json:"label" binding:"required,min=1,max=255"`
}
type ModifyActionMatchEventRequest struct {
MatchEvent *string `json:"matchEvent"`
}
type ModifyActionMatchHostRequest struct {
MatchHost *string `json:"matchHost"`
}
type ModifyActionMatchApplicationRequest struct {
MatchApplication *string `json:"matchApplication"`
}
type ModifyActionMatchProviderRequest struct {
MatchProvider *string `json:"matchProvider"`
}
type ModifyActionTypeAndPayloadRequest struct {
Type ActionType `json:"type" binding:"required,oneof=shoutrrr"`
Payload interface{} `json:"payload" binding:"required"`
}
type ModifyActionEnabledRequest struct {
Enabled bool `json:"enabled"`
}
type TestActionRequest struct {
Application string `json:"application" binding:"required,min=1"`
Provider string `json:"provider" binding:"required,min=1"`
Host string `json:"host" binding:"required,min=1"`
Version string `json:"version" binding:"required,min=1"`
State string `json:"state" binding:"required,min=1"`
}
type PaginateUpdateRequest struct {
PageSize int `form:"pageSize,default=5" binding:"numeric,gte=1"`
Page int `form:"page,default=1" binding:"numeric,gte=1"`
@ -35,10 +92,24 @@ type PaginateUpdateRequest struct {
}
type PaginateWebhookRequest struct {
PageSize int `form:"pageSize,default=5" binding:"numeric,gte=1"`
Page int `form:"page,default=1" binding:"numeric,gte=1"`
Order string `form:"order,default=asc" binding:"oneof=asc desc"`
OrderBy string `form:"orderBy,default=label" binding:"oneof=id label type created_at updated_at"`
}
type PaginateActionRequest struct {
PageSize int `form:"pageSize,default=5" binding:"numeric,gte=1"`
Page int `form:"page,default=1" binding:"numeric,gte=1"`
Order string `form:"order,default=asc" binding:"oneof=asc desc"`
OrderBy string `form:"orderBy,default=label" binding:"oneof=id label type created_at updated_at"`
}
type PaginateActionInvocationRequest struct {
PageSize int `form:"pageSize,default=5" binding:"numeric,gte=1"`
Page int `form:"page,default=1" binding:"numeric,gte=1"`
Order string `form:"order,default=desc" binding:"oneof=asc desc"`
OrderBy string `form:"orderBy,default=updated_at" binding:"oneof=id label type created_at updated_at"`
OrderBy string `form:"orderBy,default=created_at" binding:"oneof=id state retry_count created_at updated_at"`
}
type WebhookGenericRequest struct {
@ -216,12 +287,25 @@ func NewWebhookPageResponse(content []*WebhookResponse, page int, pageSize int,
type EventResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
State string `json:"state"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Payload interface{} `json:"payload,omitempty"`
}
type EventSingleResponse struct {
Data EventResponse `json:"data"`
}
func NewEventSingleResponse(id uuid.UUID, name string, createdAt time.Time, updatedAt time.Time, payload interface{}) *EventSingleResponse {
e := new(EventSingleResponse)
e.Data.ID = id
e.Data.Name = name
e.Data.CreatedAt = createdAt
e.Data.UpdatedAt = updatedAt
e.Data.Payload = payload
return e
}
type EventWindowResponse struct {
Content []*EventResponse `json:"content"`
Size int `json:"size"`
@ -256,6 +340,7 @@ type EventPayloadUpdateDeletedDto struct {
Provider string `json:"provider,omitempty"`
Host string `json:"host,omitempty"`
Version string `json:"version,omitempty"`
State string `json:"state,omitempty"`
}
func NewEventWindowResponse(content []*EventResponse, size int, skip int, orderBy string, order string, hasNext bool) *EventWindowResponse {
@ -269,24 +354,160 @@ func NewEventWindowResponse(content []*EventResponse, size int, skip int, orderB
return e
}
type EventPayloadWebhookCreatedDto struct {
ID uuid.UUID `json:"id,omitempty"`
Label string `json:"label,omitempty"`
Type string `json:"type,omitempty"`
IgnoreHost bool `json:"ignoreHost"`
type SecretResponse struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type EventPayloadWebhookUpdatedDto struct {
ID uuid.UUID `json:"id,omitempty"`
LabelPrior string `json:"labelPrior,omitempty"`
Label string `json:"label,omitempty"`
IgnoreHostPrior bool `json:"ignoreHostPrior"`
IgnoreHost bool `json:"ignoreHost"`
Type string `json:"type,omitempty"`
type SecretSingleResponse struct {
Data SecretResponse `json:"data"`
}
type EventPayloadWebhookDeletedDto struct {
Label string `json:"label,omitempty"`
Type string `json:"type,omitempty"`
IgnoreHost bool `json:"ignoreHost"`
func NewSecretSingleResponse(id uuid.UUID, key string, value string, createdAt time.Time, updatedAt time.Time) *SecretSingleResponse {
e := new(SecretSingleResponse)
e.Data.ID = id
e.Data.Key = key
e.Data.Value = value
e.Data.CreatedAt = createdAt
e.Data.UpdatedAt = updatedAt
return e
}
type SecretPageResponse struct {
Content []*SecretResponse `json:"content"`
}
type SecretDataPageResponse struct {
Data *SecretPageResponse `json:"data"`
}
func NewSecretPageResponse(content []*SecretResponse) *SecretPageResponse {
e := new(SecretPageResponse)
e.Content = content
return e
}
type ActionResponse struct {
ID uuid.UUID `json:"id"`
Label string `json:"label"`
Type string `json:"type"`
MatchEvent *string `json:"matchEvent,omitempty"`
MatchHost *string `json:"matchHost,omitempty"`
MatchApplication *string `json:"matchApplication,omitempty"`
MatchProvider *string `json:"matchProvider,omitempty"`
Payload interface{} `json:"payload,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ActionSingleResponse struct {
Data ActionResponse `json:"data"`
}
func NewActionSingleResponse(id uuid.UUID, label string, t string, matchEvent *string, matchHost *string, matchApplication *string, matchProvider *string, payload interface{}, enabled bool, createdAt time.Time, updatedAt time.Time) *ActionSingleResponse {
e := new(ActionSingleResponse)
e.Data.ID = id
e.Data.Label = label
e.Data.Type = t
e.Data.MatchEvent = matchEvent
e.Data.MatchHost = matchHost
e.Data.MatchApplication = matchApplication
e.Data.MatchProvider = matchProvider
e.Data.Payload = payload
e.Data.Enabled = enabled
e.Data.CreatedAt = createdAt
e.Data.UpdatedAt = updatedAt
return e
}
type ActionPageResponse struct {
Content []*ActionResponse `json:"content"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
OrderBy string `json:"orderBy"`
Order string `json:"order"`
TotalElements int64 `json:"totalElements"`
TotalPages int64 `json:"totalPages"`
}
func NewActionPageResponse(content []*ActionResponse, page int, pageSize int, orderBy string, order string, totalElements int64, totalPages int64) *ActionPageResponse {
e := new(ActionPageResponse)
e.Content = content
e.Page = page
e.PageSize = pageSize
e.OrderBy = orderBy
e.Order = order
e.TotalElements = totalElements
e.TotalPages = totalPages
return e
}
type ActionTestResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type ActionTestSingleResponse struct {
Data ActionTestResponse `json:"data"`
}
func NewActionTestSingleResponse(success bool, message string) *ActionTestSingleResponse {
e := new(ActionTestSingleResponse)
e.Data.Success = success
e.Data.Message = message
return e
}
type ActionInvocationResponse struct {
ID uuid.UUID `json:"id"`
RetryCount int `json:"retryCount"`
State string `json:"state"`
Message *string `json:"message,omitempty"`
ActionID string `json:"actionId"`
EventID string `json:"eventId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ActionInvocationSingleResponse struct {
Data ActionInvocationResponse `json:"data"`
}
func NewActionInvocationSingleResponse(id uuid.UUID, retryCount int, state string, message *string, actionId string, eventId string, createdAt time.Time, updatedAt time.Time) *ActionInvocationSingleResponse {
e := new(ActionInvocationSingleResponse)
e.Data.ID = id
e.Data.RetryCount = retryCount
e.Data.State = state
e.Data.Message = message
e.Data.ActionID = actionId
e.Data.EventID = eventId
e.Data.CreatedAt = createdAt
e.Data.UpdatedAt = updatedAt
return e
}
type ActionInvocationPageResponse struct {
Content []*ActionInvocationResponse `json:"content"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
OrderBy string `json:"orderBy"`
Order string `json:"order"`
TotalElements int64 `json:"totalElements"`
TotalPages int64 `json:"totalPages"`
}
func NewActionInvocationPageResponse(content []*ActionInvocationResponse, page int, pageSize int, orderBy string, order string, totalElements int64, totalPages int64) *ActionInvocationPageResponse {
e := new(ActionInvocationPageResponse)
e.Content = content
e.Page = page
e.PageSize = pageSize
e.OrderBy = orderBy
e.Order = order
e.TotalElements = totalElements
e.TotalPages = totalPages
return e
}

5
commons/constants.go Normal file
View file

@ -0,0 +1,5 @@
package commons
const (
Version = "4.0.1"
)

102
go.mod
View file

@ -1,77 +1,87 @@
module git.myservermanager.com/varakh/upda
go 1.21
go 1.22
toolchain go1.22.3
require (
github.com/Depado/ginprom v1.8.0
github.com/adrg/xdg v0.4.0
github.com/gin-contrib/cors v1.5.0
github.com/gin-contrib/zap v0.2.0
github.com/gin-gonic/gin v1.9.1
github.com/go-co-op/gocron v1.37.0
github.com/go-co-op/gocron-redis-lock v1.3.0
github.com/go-playground/validator/v10 v10.17.0
github.com/go-resty/resty/v2 v2.11.0
github.com/google/uuid v1.5.0
github.com/redis/go-redis/v9 v9.4.0
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.27.1
go.uber.org/zap v1.26.0
gorm.io/driver/postgres v1.5.4
gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5
github.com/Depado/ginprom v1.8.1
github.com/adrg/xdg v0.5.1
github.com/containrrr/shoutrrr v0.8.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/static v1.1.2
github.com/gin-contrib/zap v1.1.4
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron-redis-lock/v2 v2.0.1
github.com/go-co-op/gocron/v2 v2.12.1
github.com/go-playground/validator/v10 v10.22.1
github.com/go-redsync/redsync/v4 v4.13.0
github.com/go-resty/resty/v2 v2.15.3
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.7.0
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.5
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
gorm.io/driver/postgres v1.5.9
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.12
moul.io/zapgorm2 v1.3.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.10.1 // indirect
github.com/bytedance/sonic v1.12.1 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-redsync/redsync/v4 v4.11.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
github.com/prometheus/client_golang v1.18.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.uber.org/atomic v1.11.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/arch v0.9.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

299
go.sum
View file

@ -2,14 +2,14 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Depado/ginprom v1.8.0 h1:zaaibRLNI1dMiiuj1MKzatm8qrcHzikMlCc1anqOdyo=
github.com/Depado/ginprom v1.8.0/go.mod h1:XBaKzeNBqPF4vxJpNLincSQZeMDnZp1tIbU0FU0UKgg=
github.com/Depado/ginprom v1.8.1 h1:lrQTddbRqlHq1j6SpJDySDumJlR7FEybzdX0PS3HXPc=
github.com/Depado/ginprom v1.8.1/go.mod h1:9Z+ahPJLSeMndDfnDTfiuBn2SKVAuL2yvihApWzof9A=
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/Microsoft/hcsshim v0.11.1 h1:hJ3s7GbWlGK4YVV92sO88BQSyF4ZLVy7/awqOlPxFbA=
github.com/Microsoft/hcsshim v0.11.1/go.mod h1:nFJmaO4Zr5Y7eADdFOpYswDDlNVbvcIJJNJLECr5JQg=
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
github.com/adrg/xdg v0.5.1 h1:Im8iDbEFARltY09yOJlSGu4Asjk2vF85+3Dyru8uJ0U=
github.com/adrg/xdg v0.5.1/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@ -19,29 +19,29 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/containerd/containerd v1.7.7 h1:QOC2K4A42RQpcrZyptP6z9EJZnlHfHJUfZrAAHe15q4=
github.com/containerd/containerd v1.7.7/go.mod h1:3c4XZv6VeT9qgf9GMTxNTMFxGJrGpI2vz1yk4ye+YY8=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E=
github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
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=
@ -55,20 +55,26 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/zap v0.2.0 h1:HLvt3rZXyC8XC+s2lHzMFow3UDqiEbfrBWJyHHS6L8A=
github.com/gin-contrib/zap v0.2.0/go.mod h1:eqfbe9ZmI+GgTZF6nRiC2ZwDeM4DK1Viwc8OxTCphh0=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/go-co-op/gocron-redis-lock v1.3.0 h1:PKwtuc/BhrDll/DxJfnXoW/+D1VXubd47xcGaB9pDuM=
github.com/go-co-op/gocron-redis-lock v1.3.0/go.mod h1:9+H7ZfqVtJfx94uEAELwH+uHkn1UpM6lRM99wOBTGtg=
github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
github.com/gin-contrib/zap v1.1.4 h1:xvxTybg6XBdNtcQLH3Tf0lFr4vhDkwzgLLrIGlNTqIo=
github.com/gin-contrib/zap v1.1.4/go.mod h1:7lgEpe91kLbeJkwBTPgtVBy4zMa6oSBEcvj662diqKQ=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron-redis-lock/v2 v2.0.1 h1:xM+mzO88L+kODvY4vIUVLlZuyWazK5vJfK0DiFachdQ=
github.com/go-co-op/gocron-redis-lock/v2 v2.0.1/go.mod h1:FSHZ13f4bfH37RpJi9l3vl2GTiJRUI6xTDbUvXLoqrY=
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -77,39 +83,35 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-redis/redis/v7 v7.4.0 h1:7obg6wUoj05T0EpY0o8B59S9w5yeMWql7sw2kwNW1x4=
github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-redsync/redsync/v4 v4.11.0 h1:OPEcAxHBb95EzfwCKWM93ksOwHd5bTce2BD4+R14N6k=
github.com/go-redsync/redsync/v4 v4.11.0/go.mod h1:ZfayzutkgeBmEmBlUR3j+rF6kN44UUGtEdfzhBFZTPc=
github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo=
github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI=
github.com/go-redis/redis/v7 v7.4.1/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA=
github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ=
github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8=
github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -119,42 +121,49 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
@ -168,15 +177,18 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
github.com/opencontainers/runc v1.1.5 h1:L44KXEpKmfWDcS02aeGm8QNTFXTo2D+8MYGDIJ/GDEs=
github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg=
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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -184,24 +196,22 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds=
github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo=
github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@ -215,14 +225,15 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM=
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKNihBbwlX8dLpwxCl3+HnXKV/R0e+sRLd9C8=
github.com/testcontainers/testcontainers-go v0.26.0 h1:uqcYdoOHBy1ca7gKODfBd9uTHVK3a7UL848z09MVZ0c=
@ -235,132 +246,98 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI=
github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4=
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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-20190620200207-3b0461eec859/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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
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.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 h1:0nDDozoAU19Qb2HwhXadU8OcsiO/09cnTqhUtq2MEOM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
google.golang.org/grpc v1.57.1 h1:upNTNqv0ES+2ZOOqACwVtS3Il8M12/+Hz41RCPzAjQg=
google.golang.org/grpc v1.57.1/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
moul.io/zapgorm2 v1.3.0 h1:+CzUTMIcnafd0d/BvBce8T4uPn6DQnpIrz64cyixlkk=
moul.io/zapgorm2 v1.3.0/go.mod h1:nPVy6U9goFKHR4s+zfSo1xVFaoU7Qgd5DoCdOfzoCqs=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View file

@ -8,8 +8,15 @@
"group:recommended"
],
"prConcurrentLimit": 0,
"schedule": [
"monthly"
],
// security
"osvVulnerabilityAlerts": true,
"dependencyDashboardOSVVulnerabilitySummary": "all",
// skip next alpine, see https://github.com/mattn/go-sqlite3/issues/1164
"packageRules": [
// oci
{
"matchPackageNames": [
"alpine"
@ -20,25 +27,70 @@
],
"enabled": false
},
// go
{
"matchManagers": [
"gomod"
],
"matchPackagePrefixes": [
"github.com/go-co-op/gocron"
],
"groupName": "gocron"
},
{
"matchManagers": [
"gomod"
],
"matchUpdateTypes": [
"minor"
],
"groupName": "all minor dependencies",
"groupSlug": "all-minor-deps"
"groupName": "GoLang: all minor dependencies",
"groupSlug": "golang-all-minor-deps"
},
{
"matchManagers": [
"gomod"
],
"matchUpdateTypes": [
"patch"
],
"groupName": "all patch dependencies",
"groupSlug": "all-patch-deps"
"groupName": "GoLang: all patch dependencies",
"groupSlug": "golang-all-patch-deps"
},
// node
// GLOBAL: ignore @types/node major and minor (manual upgrade with pipeline required)
{
"matchManagers": [
"npm"
],
"matchPackageNames": [
"@types/node"
],
"matchUpdateTypes": [
"major",
"minor"
],
"enabled": false
},
{
"matchManagers": [
"npm"
],
"matchUpdateTypes": [
"minor"
],
"groupName": "Node: all minor dependencies",
"groupSlug": "node-all-minor-deps"
},
{
"matchManagers": [
"npm"
],
"matchUpdateTypes": [
"patch"
],
"groupName": "Node: all patch dependencies",
"groupSlug": "node-all-patch-deps"
}
]
}

View file

@ -0,0 +1,230 @@
package server
import (
"git.myservermanager.com/varakh/upda/api"
"github.com/gin-gonic/gin"
"net/http"
)
type actionHandler struct {
service actionService
}
func newActionHandler(s *actionService) *actionHandler {
return &actionHandler{service: *s}
}
func (h *actionHandler) paginate(c *gin.Context) {
var queryParams api.PaginateActionRequest
var err error
if err = c.ShouldBindQuery(&queryParams); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
var actions []*Action
if actions, err = h.service.paginate(queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
var data []*api.ActionResponse
data = make([]*api.ActionResponse, 0, len(actions))
for _, e := range actions {
data = append(data, &api.ActionResponse{
ID: e.ID,
Label: e.Label,
Type: e.Type,
MatchEvent: e.MatchEvent,
MatchHost: e.MatchHost,
MatchApplication: e.MatchApplication,
MatchProvider: e.MatchProvider,
Payload: e.Payload,
Enabled: e.Enabled,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
})
}
var totalElements int64
if totalElements, err = h.service.count(); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
totalPages := (totalElements + int64(queryParams.PageSize) - 1) / int64(queryParams.PageSize)
c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewActionPageResponse(data, queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order, totalElements, totalPages)))
}
func (h *actionHandler) get(c *gin.Context) {
e, err := h.service.get(c.Param("id"))
if err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) create(c *gin.Context) {
var e *Action
var err error
var req api.CreateActionRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.create(req.Label, api.ActionType(req.Type), req.MatchEvent, req.MatchHost, req.MatchApplication, req.MatchProvider, req.Payload, req.Enabled); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) updateLabel(c *gin.Context) {
var e *Action
var err error
var req api.ModifyActionLabelRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateLabel(c.Param("id"), req.Label); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) updateMatchEvent(c *gin.Context) {
var e *Action
var err error
var req api.ModifyActionMatchEventRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateMatchEvent(c.Param("id"), req.MatchEvent); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) updateMatchHost(c *gin.Context) {
var e *Action
var err error
var req api.ModifyActionMatchHostRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateMatchHost(c.Param("id"), req.MatchHost); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) updateMatchApplication(c *gin.Context) {
var e *Action
var err error
var req api.ModifyActionMatchApplicationRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateMatchApplication(c.Param("id"), req.MatchApplication); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) updateMatchProvider(c *gin.Context) {
var e *Action
var err error
var req api.ModifyActionMatchProviderRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateMatchProvider(c.Param("id"), req.MatchProvider); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) updatePayload(c *gin.Context) {
var e *Action
var err error
var req api.ModifyActionTypeAndPayloadRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateTypeAndPayload(c.Param("id"), req.Type, req.Payload); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) updateEnabled(c *gin.Context) {
var e *Action
var err error
var req api.ModifyActionEnabledRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateEnabled(c.Param("id"), req.Enabled); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionSingleResponse(e.ID, e.Label, e.Type, e.MatchEvent, e.MatchHost, e.MatchApplication, e.MatchProvider, e.Payload, e.Enabled, e.CreatedAt, e.UpdatedAt))
}
func (h *actionHandler) delete(c *gin.Context) {
if err := h.service.delete(c.Param("id")); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,102 @@
package server
import (
"git.myservermanager.com/varakh/upda/api"
"github.com/gin-gonic/gin"
"net/http"
)
type actionInvocationHandler struct {
actionService actionService
actionInvocationService actionInvocationService
}
func newActionInvocationHandler(as *actionService, ais *actionInvocationService) *actionInvocationHandler {
return &actionInvocationHandler{actionService: *as, actionInvocationService: *ais}
}
func (h *actionInvocationHandler) test(c *gin.Context) {
var err error
var req api.TestActionRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
var e *Action
if e, err = h.actionService.get(c.Param("id")); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
err = h.actionInvocationService.execute(e, &eventPayloadInformationDto{Application: req.Application, Host: req.Host, Provider: req.Provider, Version: req.Version, State: req.State})
isSuccess := err == nil
var message string
if err != nil {
message = err.Error()
}
c.JSON(http.StatusOK, api.NewActionTestSingleResponse(isSuccess, message))
}
func (h *actionInvocationHandler) paginate(c *gin.Context) {
var queryParams api.PaginateActionInvocationRequest
var err error
if err = c.ShouldBindQuery(&queryParams); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
var actionInvocations []*ActionInvocation
if actionInvocations, err = h.actionInvocationService.paginate(queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
var data []*api.ActionInvocationResponse
data = make([]*api.ActionInvocationResponse, 0, len(actionInvocations))
for _, e := range actionInvocations {
data = append(data, &api.ActionInvocationResponse{
ID: e.ID,
RetryCount: e.RetryCount,
State: e.State,
Message: e.Message,
ActionID: e.ActionID,
EventID: e.EventID,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
})
}
var totalElements int64
if totalElements, err = h.actionInvocationService.count(); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
totalPages := (totalElements + int64(queryParams.PageSize) - 1) / int64(queryParams.PageSize)
c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewActionInvocationPageResponse(data, queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order, totalElements, totalPages)))
}
func (h *actionInvocationHandler) get(c *gin.Context) {
e, err := h.actionInvocationService.get(c.Param("id"))
if err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewActionInvocationSingleResponse(e.ID, e.RetryCount, e.State, e.Message, e.ActionID, e.EventID, e.CreatedAt, e.UpdatedAt))
}
func (h *actionInvocationHandler) delete(c *gin.Context) {
if err := h.actionInvocationService.delete(c.Param("id")); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -1,6 +1,7 @@
package server
import (
"git.myservermanager.com/varakh/upda/api"
"github.com/gin-gonic/gin"
"net/http"
)
@ -13,6 +14,6 @@ func newAuthHandler() *authHandler {
}
func (h *authHandler) login(c *gin.Context) {
c.Header(headerContentType, headerContentTypeApplicationJson)
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -3,6 +3,7 @@ package server
import (
"errors"
"fmt"
"git.myservermanager.com/varakh/upda/api"
"git.myservermanager.com/varakh/upda/util"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
@ -19,8 +20,8 @@ func errAbortWithValidatorPayload(c *gin.Context, err error) {
errorMap[key] = txt
}
resErr := newServiceError(IllegalArgument, fmt.Errorf("validation error: %v (%w)", util.ValuesString(errorMap), err))
c.Header(headerContentType, headerContentTypeApplicationJson)
resErr := newServiceError(illegalArgument, fmt.Errorf("validation error: %v (%w)", util.ValuesString(errorMap), err))
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
_ = c.AbortWithError(http.StatusBadRequest, resErr)
return
}
@ -29,17 +30,19 @@ func errToHttpStatus(err error) int {
var e *serviceError
switch {
case errors.As(err, &e):
if e.Status == IllegalArgument {
if e.Status == illegalArgument {
return http.StatusBadRequest
} else if e.Status == Unauthorized {
} else if e.Status == unauthorized {
return http.StatusUnauthorized
} else if e.Status == Forbidden {
} else if e.Status == forbidden {
return http.StatusForbidden
} else if e.Status == NotFound {
} else if e.Status == notFound {
return http.StatusNotFound
} else if e.Status == Conflict {
} else if e.Status == methodNotAllowed {
return http.StatusMethodNotAllowed
} else if e.Status == conflict {
return http.StatusConflict
} else if e.Status == General {
} else if e.Status == general {
return http.StatusInternalServerError
}
default:
@ -57,7 +60,7 @@ func errCodeToStr(err error) string {
return string(e.Status)
}
return string(General)
return string(general)
}
func validatorErrorToText(e *validator.FieldError) (string, string) {

View file

@ -29,13 +29,12 @@ func (h *eventHandler) window(c *gin.Context) {
}
var data []*api.EventResponse
data = make([]*api.EventResponse, 0)
data = make([]*api.EventResponse, 0, len(events))
for _, e := range events {
data = append(data, &api.EventResponse{
ID: e.ID,
Name: e.Name,
State: e.State,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Payload: e.Payload,
@ -51,12 +50,22 @@ func (h *eventHandler) window(c *gin.Context) {
c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewEventWindowResponse(data, queryParams.Size, queryParams.Skip, queryParams.OrderBy, queryParams.Order, hasNext)))
}
func (h *eventHandler) get(c *gin.Context) {
e, err := h.service.get(c.Param("id"))
if err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewEventSingleResponse(e.ID, e.Name, e.CreatedAt, e.UpdatedAt, e.Payload))
}
func (h *eventHandler) delete(c *gin.Context) {
if err := h.service.delete(c.Param("id")); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.Header(headerContentType, headerContentTypeApplicationJson)
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -13,7 +13,7 @@ func newHealthHandler() *healthHandler {
return &healthHandler{}
}
func (h *healthHandler) showHealth(c *gin.Context) {
func (h *healthHandler) show(c *gin.Context) {
c.JSON(http.StatusOK, api.DataResponse{Data: gin.H{
"healthy": true,
}})

View file

@ -2,6 +2,7 @@ package server
import (
"git.myservermanager.com/varakh/upda/api"
"git.myservermanager.com/varakh/upda/commons"
"github.com/gin-gonic/gin"
"net/http"
)
@ -14,10 +15,10 @@ func newInfoHandler(a *appConfig) *infoHandler {
return &infoHandler{appConfig: *a}
}
func (h *infoHandler) showInfo(c *gin.Context) {
func (h *infoHandler) show(c *gin.Context) {
c.JSON(http.StatusOK, api.DataResponse{Data: gin.H{
"name": Name,
"version": Version,
"name": name,
"Version": commons.Version,
"timeZone": h.appConfig.timeZone,
}})
}

View file

@ -0,0 +1,97 @@
package server
import (
"git.myservermanager.com/varakh/upda/api"
"github.com/gin-gonic/gin"
"net/http"
)
type secretHandler struct {
service secretService
}
func newSecretHandler(s *secretService) *secretHandler {
return &secretHandler{service: *s}
}
func (h *secretHandler) getAll(c *gin.Context) {
var secrets []*Secret
var err error
if secrets, err = h.service.getAll(); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
var data []*api.SecretResponse
data = make([]*api.SecretResponse, 0, len(secrets))
for _, e := range secrets {
data = append(data, &api.SecretResponse{
ID: e.ID,
Key: e.Key,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
})
}
c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewSecretPageResponse(data)))
}
func (h *secretHandler) create(c *gin.Context) {
var e *Secret
var err error
var req api.CreateSecretRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.upsert(req.Key, req.Value); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewSecretSingleResponse(e.ID, e.Key, e.Value, e.CreatedAt, e.UpdatedAt))
}
func (h *secretHandler) updateValue(c *gin.Context) {
var e *Secret
var err error
var req api.ModifySecretValueRequest
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
if e, err = h.service.updateValue(c.Param("id"), req.Value); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewSecretSingleResponse(e.ID, e.Key, e.Value, e.CreatedAt, e.UpdatedAt))
}
func (h *secretHandler) get(c *gin.Context) {
e, err := h.service.get(c.Param("id"))
if err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewSecretSingleResponse(e.ID, e.Key, "", e.CreatedAt, e.UpdatedAt))
}
func (h *secretHandler) delete(c *gin.Context) {
if err := h.service.delete(c.Param("id")); err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -28,7 +28,7 @@ func (h *updateHandler) paginate(c *gin.Context) {
s, stateQueryContainsAtLeastOne := c.GetQueryArray("state")
var states []api.UpdateState
states := make([]api.UpdateState, 0)
if stateQueryContainsAtLeastOne {
for _, state := range s {
states = append(states, api.UpdateState(state))
@ -82,7 +82,7 @@ func (h *updateHandler) updateState(c *gin.Context) {
var req api.ModifyUpdateStateRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
@ -101,6 +101,6 @@ func (h *updateHandler) delete(c *gin.Context) {
return
}
c.Header(headerContentType, headerContentTypeApplicationJson)
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -29,7 +29,7 @@ func (h *webhookHandler) paginate(c *gin.Context) {
}
var data []*api.WebhookResponse
data = make([]*api.WebhookResponse, 0)
data = make([]*api.WebhookResponse, 0, len(webhooks))
for _, e := range webhooks {
data = append(data, &api.WebhookResponse{
@ -52,13 +52,23 @@ func (h *webhookHandler) paginate(c *gin.Context) {
c.JSON(http.StatusOK, api.NewDataResponseWithPayload(api.NewWebhookPageResponse(data, queryParams.Page, queryParams.PageSize, queryParams.OrderBy, queryParams.Order, totalElements, totalPages)))
}
func (h *webhookHandler) get(c *gin.Context) {
e, err := h.service.get(c.Param("id"))
if err != nil {
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.JSON(http.StatusOK, api.NewWebhookSingleResponse(e.ID, e.Label, e.Type, e.IgnoreHost, "", e.CreatedAt, e.UpdatedAt))
}
func (h *webhookHandler) create(c *gin.Context) {
var e *Webhook
var err error
var req api.CreateWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
@ -77,7 +87,7 @@ func (h *webhookHandler) updateLabel(c *gin.Context) {
var req api.ModifyWebhookLabelRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
@ -96,7 +106,7 @@ func (h *webhookHandler) updateIgnoreHost(c *gin.Context) {
var req api.ModifyWebhookIgnoreHostRequest
if err := c.ShouldBindJSON(&req); err != nil {
if err = c.ShouldBindJSON(&req); err != nil {
errAbortWithValidatorPayload(c, err)
return
}
@ -115,6 +125,6 @@ func (h *webhookHandler) delete(c *gin.Context) {
return
}
c.Header(headerContentType, headerContentTypeApplicationJson)
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -16,8 +16,8 @@ func newWebhookInvocationHandler(i *webhookInvocationService, w *webhookService)
return &webhookInvocationHandler{invocationService: *i, webhookService: *w}
}
func (h *webhookInvocationHandler) executeWebhookGeneric(c *gin.Context) {
tokenHeader := c.GetHeader(HeaderWebhookToken)
func (h *webhookInvocationHandler) execute(c *gin.Context) {
tokenHeader := c.GetHeader(api.HeaderWebhookToken)
webhookId := c.Param("id")
var w *Webhook
@ -52,11 +52,11 @@ func (h *webhookInvocationHandler) executeWebhookGeneric(c *gin.Context) {
}
break
default:
err = newServiceError(IllegalArgument, errors.New("no default handler for webhook type found"))
err = newServiceError(illegalArgument, errors.New("no default handler for webhook type found"))
_ = c.AbortWithError(errToHttpStatus(err), err)
return
}
c.Header(headerContentType, headerContentTypeApplicationJson)
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.Status(http.StatusNoContent)
}

View file

@ -3,27 +3,46 @@ package server
import (
"fmt"
"git.myservermanager.com/varakh/upda/api"
"git.myservermanager.com/varakh/upda/commons"
"github.com/gin-gonic/gin"
"net/http"
"strings"
)
func middlewareAppName() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header(HeaderAppName, Name)
c.Header(api.HeaderAppName, name)
c.Next()
}
}
func middlewareGlobalNotFound() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusNotFound, api.NewErrorResponseWithStatusAndMessage(string(notFound), "page not found"))
return
}
}
func middlewareGlobalMethodNotAllowed() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, api.NewErrorResponseWithStatusAndMessage(string(methodNotAllowed), "method not allowed"))
return
}
}
func middlewareEnforceJsonContentType() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodOptions && !strings.HasPrefix(c.GetHeader(api.HeaderContentType), api.HeaderContentTypeApplicationJson) {
c.AbortWithStatusJSON(http.StatusBadRequest, api.NewErrorResponseWithStatusAndMessage(string(illegalArgument), "content-type must be application/json"))
return
}
c.Next()
}
}
func middlewareAppVersion() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header(HeaderAppVersion, Version)
c.Next()
}
}
func middlewareAppContentType() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header(headerContentType, headerContentTypeApplicationJson)
c.Header(api.HeaderAppVersion, commons.Version)
c.Next()
}
}
@ -36,7 +55,7 @@ func middlewareErrorHandler() gin.HandlerFunc {
if len(c.Errors) > 0 {
// status -1 doesn't overwrite existing status code
c.Header(headerContentType, headerContentTypeApplicationJson)
c.Header(api.HeaderContentType, api.HeaderContentTypeApplicationJson)
c.JSON(-1, api.NewErrorResponseWithStatusAndMessage(errCodeToStr(c.Errors.Last()), c.Errors.Last().Error()))
return
}
@ -47,7 +66,7 @@ func middlewareAppErrorRecoveryHandler() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, api.NewErrorResponseWithStatusAndMessage(string(General), fmt.Sprintf("%s", err)))
c.AbortWithStatusJSON(http.StatusInternalServerError, api.NewErrorResponseWithStatusAndMessage(string(general), fmt.Sprintf("%s", err)))
}
}()
c.Next()

View file

@ -6,8 +6,10 @@ import (
"fmt"
"git.myservermanager.com/varakh/upda/util"
"github.com/gin-contrib/cors"
ginstatic "github.com/gin-contrib/static"
ginzap "github.com/gin-contrib/zap"
"github.com/gin-gonic/gin"
_ "go.uber.org/automaxprocs"
"go.uber.org/zap"
"net/http"
"os"
@ -23,88 +25,186 @@ func Start() {
// secure init
util.AssertAvailablePRNG()
// set gin mode derived from logging level
if zap.L().Level() == zap.DebugLevel {
// set gin mode derived
if env.appConfig.isDevelopment {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
// app init (router, services, handlers)
router := gin.New()
router.Use(ginzap.Ginzap(zap.L(), time.RFC3339, false))
router.Use(ginzap.RecoveryWithZap(zap.L(), true))
// metrics
prometheusService := newPrometheusService(router, env.prometheusConfig)
var err error
ps := newPrometheusService(router, env.prometheusConfig)
if env.prometheusConfig.enabled {
prometheusService.init()
router.Use(prometheusService.prometheus.Instrument())
if err = ps.init(); err != nil {
zap.L().Sugar().Fatalf("Prometheus service init failed: %s", err.Error())
}
router.Use(ps.prometheus.Instrument())
}
updateRepo := newUpdateDbRepo(env.db)
webhookRepo := newWebhookDbRepo(env.db)
eventRepo := newEventDbRepo(env.db)
secretRepo := newSecretDbRepo(env.db)
actionRepo := newActionDbRepo(env.db)
actionInvocationRepo := newActionInvocationDbRepo(env.db)
lockService := newLockMemService()
var ls lockService
eventService := newEventService(eventRepo)
updateService := newUpdateService(updateRepo, eventService, prometheusService)
webhookService := newWebhookService(webhookRepo, env.webhookConfig, eventService)
webhookInvocationService := newWebhookInvocationService(webhookService, updateService, env.webhookConfig)
if env.lockConfig.redisEnabled {
var e error
ls, e = newLockRedisService(env.lockConfig)
taskService := newTaskService(updateService, eventService, webhookService, lockService, prometheusService, env.appConfig, env.taskConfig, env.lockConfig, env.prometheusConfig)
taskService.init()
taskService.start()
if err != nil {
zap.L().Fatal("Failed to create lock service", zap.Error(e))
}
} else {
ls = newLockMemService()
}
updateHandler := newUpdateHandler(updateService, env.appConfig)
webhookHandler := newWebhookHandler(webhookService)
webhookInvocationHandler := newWebhookInvocationHandler(webhookInvocationService, webhookService)
eventHandler := newEventHandler(eventService)
infoHandler := newInfoHandler(env.appConfig)
healthHandler := newHealthHandler()
authHandler := newAuthHandler()
es := newEventService(eventRepo)
us := newUpdateService(updateRepo, es)
ws := newWebhookService(webhookRepo, env.webhookConfig)
wis := newWebhookInvocationService(ws, us, env.webhookConfig)
ss := newSecretService(secretRepo)
as := newActionService(actionRepo, es)
ais := newActionInvocationService(actionInvocationRepo, as, es, ss)
var ts *taskService
if ts, err = newTaskService(us, es, ws, as, ais, ls, ps, env.appConfig, env.taskConfig, env.lockConfig, env.prometheusConfig); err != nil {
zap.L().Sugar().Fatalf("Task service creation failed: %v", err)
}
if err = ts.init(); err != nil {
zap.L().Sugar().Fatalf("Task service initialization failed: %v", err)
}
ts.start()
uh := newUpdateHandler(us, env.appConfig)
wh := newWebhookHandler(ws)
wih := newWebhookInvocationHandler(wis, ws)
eh := newEventHandler(es)
sh := newSecretHandler(ss)
ah := newActionHandler(as)
aih := newActionInvocationHandler(as, ais)
ih := newInfoHandler(env.appConfig)
hh := newHealthHandler()
authH := newAuthHandler()
router.Use(middlewareAppName())
router.Use(middlewareAppVersion())
router.Use(middlewareAppContentType())
router.Use(middlewareErrorHandler())
router.Use(middlewareAppErrorRecoveryHandler())
router.NoRoute(middlewareGlobalNotFound())
router.NoMethod(middlewareGlobalMethodNotAllowed())
// in production mode, the frontend is embedded on / during compile time utilizing -tags prod
// if the prod tag is missing, development setup is used and a dummy frontend is shown on /
var targetPath string
if env.appConfig.isDevelopment {
targetPath = "web_dev"
} else {
targetPath = "web/build"
}
router.Use(ginstatic.Serve("/", ginstatic.EmbedFolder(embeddedFiles, targetPath)))
if !env.appConfig.isDevelopment {
embeddedFrontendGroup := router.Group("/")
embeddedFrontendGroup.GET("/conf/runtime-config.js", func(c *gin.Context) {
config := `
const runtime_config = Object.freeze({
VITE_API_URL: '%s/api/v1/',
VITE_APP_TITLE: '%s'
});
Object.defineProperty(window, 'runtime_config', {
value: runtime_config,
writable: false
});
`
c.Data(http.StatusOK, "text/javascript; charset=utf-8", []byte(fmt.Sprintf(config, env.webConfig.apiUrl, env.webConfig.title)))
})
}
router.Use(cors.New(cors.Config{
AllowOrigins: env.serverConfig.corsAllowOrigin,
AllowOrigins: env.serverConfig.corsAllowOrigins,
AllowMethods: env.serverConfig.corsAllowMethods,
AllowHeaders: env.serverConfig.corsAllowHeaders,
AllowCredentials: true,
AllowCredentials: env.serverConfig.corsAllowCredentials,
ExposeHeaders: env.serverConfig.corsExposeHeaders,
}))
apiPublicGroup := router.Group("/api/v1")
apiPublicGroup.GET("/health", healthHandler.showHealth)
apiPublicGroup.GET("/info", infoHandler.showInfo)
apiPublicGroup.GET("/health", hh.show)
apiPublicGroup.GET("/info", ih.show)
apiPublicGroup.POST("/webhooks/:id", webhookInvocationHandler.executeWebhookGeneric)
apiPublicGroup.POST("/webhooks/:id", middlewareEnforceJsonContentType(), wih.execute)
apiAuthGroup := router.Group("/api/v1", gin.BasicAuth(gin.Accounts{
env.authConfig.adminUser: env.authConfig.adminPassword,
}))
var authMethodHandler gin.HandlerFunc
apiAuthGroup.GET("/login", authHandler.login)
if authModeBasicSingle == env.authConfig.authMethod {
authMethodHandler = gin.BasicAuth(gin.Accounts{
env.authConfig.basicAuthUser: env.authConfig.basicAuthPassword,
})
} else if authModeBasicCredentials == env.authConfig.authMethod {
authMethodHandler = gin.BasicAuth(env.authConfig.basicAuthCredentials)
} else {
zap.L().Fatal("No valid auth mode found")
}
apiAuthGroup.GET("/updates", updateHandler.paginate)
apiAuthGroup.GET("/updates/:id", updateHandler.get)
apiAuthGroup.PATCH("/updates/:id/state", updateHandler.updateState)
apiAuthGroup.DELETE("/updates/:id", updateHandler.delete)
apiAuthGroup := router.Group("/api/v1", authMethodHandler)
apiAuthGroup.GET("/webhooks", webhookHandler.paginate)
apiAuthGroup.POST("/webhooks", webhookHandler.create)
apiAuthGroup.PATCH("/webhooks/:id/label", webhookHandler.updateLabel)
apiAuthGroup.PATCH("/webhooks/:id/ignore-host", webhookHandler.updateIgnoreHost)
apiAuthGroup.DELETE("/webhooks/:id", webhookHandler.delete)
apiAuthGroup.GET("/login", authH.login)
apiAuthGroup.GET("/events", eventHandler.window)
apiAuthGroup.DELETE("/events/:id", eventHandler.delete)
apiAuthGroup.GET("/updates", uh.paginate)
apiAuthGroup.GET("/updates/:id", uh.get)
apiAuthGroup.PATCH("/updates/:id/state", middlewareEnforceJsonContentType(), uh.updateState)
apiAuthGroup.DELETE("/updates/:id", uh.delete)
apiAuthGroup.GET("/webhooks", wh.paginate)
apiAuthGroup.POST("/webhooks", middlewareEnforceJsonContentType(), wh.create)
apiAuthGroup.GET("/webhooks/:id", wh.get)
apiAuthGroup.PATCH("/webhooks/:id/label", middlewareEnforceJsonContentType(), wh.updateLabel)
apiAuthGroup.PATCH("/webhooks/:id/ignore-host", middlewareEnforceJsonContentType(), wh.updateIgnoreHost)
apiAuthGroup.DELETE("/webhooks/:id", wh.delete)
apiAuthGroup.GET("/events", eh.window)
apiAuthGroup.GET("/events/:id", eh.get)
apiAuthGroup.DELETE("/events/:id", eh.delete)
apiAuthGroup.GET("/secrets", sh.getAll)
apiAuthGroup.GET("/secrets/:id", sh.get)
apiAuthGroup.POST("/secrets", middlewareEnforceJsonContentType(), sh.create)
apiAuthGroup.PATCH("/secrets/:id/value", middlewareEnforceJsonContentType(), sh.updateValue)
apiAuthGroup.DELETE("/secrets/:id", sh.delete)
apiAuthGroup.GET("/actions", ah.paginate)
apiAuthGroup.POST("/actions", middlewareEnforceJsonContentType(), ah.create)
apiAuthGroup.GET("/actions/:id", ah.get)
apiAuthGroup.PATCH("/actions/:id/label", middlewareEnforceJsonContentType(), ah.updateLabel)
apiAuthGroup.PATCH("/actions/:id/match-event", middlewareEnforceJsonContentType(), ah.updateMatchEvent)
apiAuthGroup.PATCH("/actions/:id/match-host", middlewareEnforceJsonContentType(), ah.updateMatchHost)
apiAuthGroup.PATCH("/actions/:id/match-application", middlewareEnforceJsonContentType(), ah.updateMatchApplication)
apiAuthGroup.PATCH("/actions/:id/match-provider", middlewareEnforceJsonContentType(), ah.updateMatchProvider)
apiAuthGroup.PATCH("/actions/:id/payload", middlewareEnforceJsonContentType(), ah.updatePayload)
apiAuthGroup.PATCH("/actions/:id/enabled", middlewareEnforceJsonContentType(), ah.updateEnabled)
apiAuthGroup.DELETE("/actions/:id", ah.delete)
apiAuthGroup.POST("/actions/:id/test", middlewareEnforceJsonContentType(), aih.test)
apiAuthGroup.GET("/action-invocations", aih.paginate)
apiAuthGroup.GET("/action-invocations/:id", aih.get)
apiAuthGroup.DELETE("/action-invocations/:id", aih.delete)
// start server
serverAddress := fmt.Sprintf("%s:%d", env.serverConfig.listen, env.serverConfig.port)
srv := &http.Server{
Addr: serverAddress,
@ -112,34 +212,34 @@ func Start() {
}
go func() {
var err error
var e error
if env.serverConfig.tlsEnabled {
err = srv.ListenAndServeTLS(env.serverConfig.tlsCertPath, env.serverConfig.tlsKeyPath)
e = srv.ListenAndServeTLS(env.serverConfig.tlsCertPath, env.serverConfig.tlsKeyPath)
} else {
err = srv.ListenAndServe()
e = srv.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
zap.L().Sugar().Fatalf("Application cannot be started: %v", err)
if e != nil && !errors.Is(e, http.ErrServerClosed) {
zap.L().Sugar().Fatalf("Application cannot be started: %v", e)
}
}()
// gracefully handle shut down
// Wait for interrupt signal to gracefully shut down the server with
// a timeout of x seconds.
quit := make(chan os.Signal)
quit := make(chan os.Signal, 1)
// kill (no param) default send syscall.SIGTERM
// kill -2 is syscall.SIGINT
// kill -9 is syscall. SIGKILL but cannot be caught, thus no need to add
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
zap.L().Info("Shutting down...")
taskService.stop()
ts.stop()
ctx, cancel := context.WithTimeout(context.Background(), env.serverConfig.timeout)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
if err = srv.Shutdown(ctx); err != nil {
zap.L().Sugar().Fatalf("Shutdown failed, exited directly: %v", err)
}
// catching ctx.Done() for configured timeout

View file

@ -0,0 +1,9 @@
//go:build !prod
// +build !prod
package server
import "embed"
//go:embed web_dev
var embeddedFiles embed.FS

View file

@ -0,0 +1,9 @@
//go:build prod
// +build prod
package server
import "embed"
//go:embed web/build/*
var embeddedFiles embed.FS

View file

@ -1,11 +0,0 @@
package server
const (
HeaderAppName = "X-App-Name"
HeaderAppVersion = "X-App-Version"
HeaderWebhookToken = "X-Webhook-Token"
headerContentType = "Content-Type"
headerContentTypeApplicationJson = "application/json"
)

View file

@ -1,6 +1,5 @@
package server
const (
Name = "upda"
Version = "1.0.3"
name = "upda"
)

View file

@ -1,13 +1,34 @@
package server
const (
envDevelopment = "DEVELOPMENT"
envLoggingLevel = "LOGGING_LEVEL"
loggingLevelDefault = "info"
envLoggingEncoding = "LOGGING_ENCODING"
loggingEncodingDefault = "json"
envLoggingDirectory = "LOGGING_DIRECTORY"
loggingFileNameDefault = "upda.log"
envSecret = "SECRET"
envTZ = "TZ"
tzDefault = "Europe/Berlin"
envAdminUser = "ADMIN_USER"
envAdminPassword = "ADMIN_PASSWORD"
envWebApiUrl = "WEB_API_URL"
webApiUrlDefault = "http://localhost"
envLoggingLevel = "LOGGING_LEVEL"
envWebTitle = "WEB_TITLE"
webTitleDefault = "upda"
envAuthMode = "AUTH_MODE"
authModeDefault = authModeBasicSingle
authModeBasicSingle = "basic_single"
authModeBasicCredentials = "basic_credentials"
envBasicAuthUser = "BASIC_AUTH_USER"
envBasicAuthPassword = "BASIC_AUTH_PASSWORD"
envBasicAuthCredentials = "BASIC_AUTH_CREDENTIALS"
envServerPort = "SERVER_PORT"
envServerListen = "SERVER_LISTEN"
@ -20,12 +41,16 @@ const (
serverTlsEnabledDefault = "false"
serverTimeoutDefault = "1s"
envCorsAllowOrigin = "CORS_ALLOW_ORIGIN"
envCorsAllowMethods = "CORS_ALLOW_METHODS"
envCorsAllowHeaders = "CORS_ALLOW_HEADERS"
corsAllowOriginDefault = "*"
corsAllowMethodsDefault = "HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS"
corsAllowHeadersDefault = "Authorization, Content-Type"
envCorsAllowOrigins = "CORS_ALLOW_ORIGINS"
envCorsAllowMethods = "CORS_ALLOW_METHODS"
envCorsAllowHeaders = "CORS_ALLOW_HEADERS"
envCorsAllowCredentials = "CORS_ALLOW_CREDENTIALS"
envCorsExposeHeaders = "CORS_EXPOSE_HEADERS"
corsAllowOriginsDefault = "*"
corsAllowMethodsDefault = "HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS"
corsAllowHeadersDefault = "Authorization, Content-Type"
corsAllowCredentialsDefault = "true"
corsExposeHeadersDefault = "*"
dbTypeSqlite = "sqlite"
dbTypePostgres = "postgres"
@ -62,7 +87,7 @@ const (
envTaskUpdateCleanStaleMaxAge = "TASK_UPDATE_CLEAN_STALE_MAX_AGE"
taskUpdateCleanStaleEnabledDefault = "false"
taskUpdateCleanStaleIntervalDefault = "1h"
taskUpdateCleanStaleMaxAgeDefault = "168h"
taskUpdateCleanStaleMaxAgeDefault = "720h"
envTaskEventCleanStaleEnabled = "TASK_EVENT_CLEAN_STALE_ENABLED"
envTaskEventCleanStaleInterval = "TASK_EVENT_CLEAN_STALE_INTERVAL"
@ -71,6 +96,29 @@ const (
taskEventCleanStaleIntervalDefault = "8h"
taskEventCleanStaleMaxAgeDefault = "2190h"
envTaskActionsEnqueueEnabled = "TASK_ACTIONS_ENQUEUE_ENABLED"
envTaskActionsEnqueueInterval = "TASK_ACTIONS_ENQUEUE_INTERVAL"
envTaskActionsEnqueueBatchSize = "TASK_ACTIONS_ENQUEUE_BATCH_SIZE"
taskActionsEnqueueEnabledDefault = "true"
taskActionsEnqueueIntervalDefault = "10s"
taskActionsEnqueueBatchSizeDefault = "1"
envTaskActionsInvokeEnabled = "TASK_ACTIONS_INVOKE_ENABLED"
envTaskActionsInvokeInterval = "TASK_ACTIONS_INVOKE_INTERVAL"
envTaskActionsInvokeBatchSize = "TASK_ACTIONS_INVOKE_BATCH_SIZE"
envTaskActionsInvokeMaxRetries = "TASK_ACTIONS_INVOKE_MAX_RETRIES"
taskActionsInvokeEnabledDefault = "true"
taskActionsInvokeIntervalDefault = "10s"
taskActionsInvokeBatchSizeDefault = "1"
taskActionsInvokeMaxRetriesDefault = "3"
envTaskActionsCleanStaleEnabled = "TASK_ACTIONS_CLEAN_STALE_ENABLED"
envTaskActionsCleanStaleInterval = "TASK_ACTIONS_CLEAN_STALE_INTERVAL"
envTaskActionsCleanStaleMaxAge = "TASK_ACTIONS_CLEAN_STALE_MAX_AGE"
taskActionsCleanStaleEnabledDefault = "true"
taskActionsCleanStaleIntervalDefault = "12h"
taskActionsCleanStaleMaxAgeDefault = "720h"
envLockRedisEnabled = "LOCK_REDIS_ENABLED"
envLockRedisUrl = "LOCK_REDIS_URL"
redisEnabledDefault = "false"

View file

@ -13,12 +13,12 @@ const (
metricUpdatesApproved = "updates_approved"
metricUpdatesApprovedHelp = "amount of all updates in approved state"
metricUpdates = "updates"
metricUpdatesHelp = "details for all updates, 0=pending, 1=approved, 2=ignored"
metricWebhooks = "webhooks"
metricWebhooksHelp = "amount of all webhooks"
metricEvents = "events"
metricEventsHelp = "amount of all events"
metricActions = "actions"
metricActionsHelp = "amount of all actions"
)

View file

@ -14,7 +14,7 @@ import (
"gorm.io/gorm/clause"
)
// JSONMap defined JSON data type, need to implements driver.Valuer, sql.Scanner interface
// JSONMap defined JSON data type, need to implement driver.Valuer, sql.Scanner interface
type JSONMap map[string]interface {
}
@ -27,7 +27,7 @@ func (m JSONMap) Value() (driver.Value, error) {
return string(ba), err
}
// Scan scan value into Jsonb, implements sql.Scanner interface
// Scan value into JSONB, implements sql.Scanner interface
func (m *JSONMap) Scan(val interface{}) error {
if val == nil {
*m = make(JSONMap)

16
server/dto.go Normal file
View file

@ -0,0 +1,16 @@
package server
// DTOs
type actionPayloadShoutrrrDto struct {
Body string `json:"body" binding:"required" validate:"required"`
Urls []string `json:"urls" binding:"required" validate:"required,min=1"`
}
type eventPayloadInformationDto struct {
Host string
Application string
Provider string
Version string
State string
}

View file

@ -1,8 +1,10 @@
package server
import (
"git.myservermanager.com/varakh/upda/util"
"github.com/google/uuid"
"gorm.io/gorm"
"os"
"time"
)
@ -11,16 +13,6 @@ func (u *Update) BeforeCreate(tx *gorm.DB) (err error) {
return
}
func (wh *Webhook) BeforeCreate(tx *gorm.DB) (err error) {
wh.ID = uuid.New()
return
}
func (e *Event) BeforeCreate(tx *gorm.DB) (err error) {
e.ID = uuid.New()
return
}
// Update entity holding information for updates
type Update struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"`
@ -34,6 +26,57 @@ type Update struct {
UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"`
}
// BeforeCreate encrypts webhook token before storing to database
func (wh *Webhook) BeforeCreate(tx *gorm.DB) (err error) {
var er error
var encryptedToken string
if encryptedToken, er = util.EncryptAndEncode(wh.Token, os.Getenv(envSecret)); er != nil {
return er
}
wh.ID = uuid.New()
wh.Token = encryptedToken
return
}
// BeforeUpdate encrypts webhook token before storing to database
func (wh *Webhook) BeforeUpdate(tx *gorm.DB) (err error) {
var er error
var encryptedValue string
if encryptedValue, er = util.EncryptAndEncode(wh.Token, os.Getenv(envSecret)); er != nil {
return er
}
wh.Token = encryptedValue
return
}
// AfterSave decrypt webhook token after encrypted value has been retrieved from database
func (wh *Webhook) AfterSave(tx *gorm.DB) (err error) {
var er error
var decrypted string
if decrypted, er = util.DecryptAndDecode(wh.Token, os.Getenv(envSecret)); er != nil {
return er
}
wh.Token = decrypted
return
}
// AfterFind decrypt webhook token after encrypted value has been retrieved from database
func (wh *Webhook) AfterFind(tx *gorm.DB) (err error) {
var er error
var decrypted string
if decrypted, er = util.DecryptAndDecode(wh.Token, os.Getenv(envSecret)); er != nil {
return er
}
wh.Token = decrypted
return
}
// Webhook entity holding information for webhooks
type Webhook struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"`
@ -45,6 +88,11 @@ type Webhook struct {
UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"`
}
func (e *Event) BeforeCreate(tx *gorm.DB) (err error) {
e.ID = uuid.New()
return
}
// Event entity holding information for events
type Event struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"`
@ -54,3 +102,102 @@ type Event struct {
CreatedAt time.Time `gorm:"time;autoCreateTime;not null"`
UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"`
}
// BeforeCreate encrypts secret value before storing to database
func (e *Secret) BeforeCreate(tx *gorm.DB) (err error) {
var er error
var encryptedValue string
if encryptedValue, er = util.EncryptAndEncode(e.Value, os.Getenv(envSecret)); er != nil {
return er
}
e.ID = uuid.New()
e.Value = encryptedValue
return
}
// BeforeUpdate encrypts secret value before storing to database
func (e *Secret) BeforeUpdate(tx *gorm.DB) (err error) {
var er error
var encryptedValue string
if encryptedValue, er = util.EncryptAndEncode(e.Value, os.Getenv(envSecret)); er != nil {
return er
}
e.Value = encryptedValue
return
}
// AfterSave decrypt secret value after encrypted value has been retrieved from database
func (e *Secret) AfterSave(tx *gorm.DB) (err error) {
var er error
var decrypted string
if decrypted, er = util.DecryptAndDecode(e.Value, os.Getenv(envSecret)); er != nil {
return er
}
e.Value = decrypted
return
}
// AfterFind decrypt secret value after encrypted value has been retrieved from database
func (e *Secret) AfterFind(tx *gorm.DB) (err error) {
var er error
var decrypted string
if decrypted, er = util.DecryptAndDecode(e.Value, os.Getenv(envSecret)); er != nil {
return er
}
e.Value = decrypted
return
}
// Secret entity holding information for secrets
type Secret struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"`
Key string `gorm:"unique;not null"`
Value string `gorm:"not null"`
CreatedAt time.Time `gorm:"time;autoCreateTime;not null"`
UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"`
}
func (e *Action) BeforeCreate(tx *gorm.DB) (err error) {
e.ID = uuid.New()
return
}
// Action entity holding information for actions
type Action struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"`
Label string `gorm:"not null"`
Type string `gorm:"not null"`
MatchEvent *string `gorm:""`
MatchApplication *string `gorm:""`
MatchProvider *string `gorm:""`
MatchHost *string `gorm:""`
Payload JSONMap `gorm:"jsonb"`
Enabled bool `gorm:"not null"`
CreatedAt time.Time `gorm:"time;autoCreateTime;not null"`
UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"`
}
func (e *ActionInvocation) BeforeCreate(tx *gorm.DB) (err error) {
e.ID = uuid.New()
return
}
// ActionInvocation entity holding information for invocations of actions
type ActionInvocation struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;unique;not null"`
RetryCount int `gorm:"not null;default:1"`
State string `gorm:"not null"`
Message *string
Event Event `gorm:"foreignKey:EventID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
EventID string `gorm:"not null"`
Action Action `gorm:"foreignKey:ActionID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
ActionID string `gorm:"not null"`
CreatedAt time.Time `gorm:"time;autoCreateTime;not null"`
UpdatedAt time.Time `gorm:"time;autoUpdateTime;not null"`
}

View file

@ -1,49 +1,75 @@
package server
import (
"errors"
"fmt"
"git.myservermanager.com/varakh/upda/util"
"github.com/adrg/xdg"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"log"
"moul.io/zapgorm2"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type appConfig struct {
timeZone string
timeZone string
isDevelopment bool
isDebug bool
}
type webConfig struct {
title string
apiUrl string
}
type serverConfig struct {
port int
listen string
tlsEnabled bool
tlsCertPath string
tlsKeyPath string
timeout time.Duration
corsAllowOrigin []string
corsAllowMethods []string
corsAllowHeaders []string
port int
listen string
tlsEnabled bool
tlsCertPath string
tlsKeyPath string
timeout time.Duration
corsAllowCredentials bool
corsAllowOrigins []string
corsAllowMethods []string
corsAllowHeaders []string
corsExposeHeaders []string
}
type authConfig struct {
adminUser string
adminPassword string
authMethod string
basicAuthUser string
basicAuthPassword string
basicAuthCredentials map[string]string
}
type taskConfig struct {
updateCleanStaleEnabled bool
updateCleanStaleInterval string
updateCleanStaleInterval time.Duration
updateCleanStaleMaxAge time.Duration
eventCleanStaleEnabled bool
eventCleanStaleInterval string
eventCleanStaleInterval time.Duration
eventCleanStaleMaxAge time.Duration
prometheusRefreshInterval string
actionsEnqueueEnabled bool
actionsEnqueueInterval time.Duration
actionsEnqueueBatchSize int
actionsInvokeEnabled bool
actionsInvokeInterval time.Duration
actionsInvokeBatchSize int
actionsInvokeMaxRetries int
actionsCleanStaleEnabled bool
actionsCleanStaleInterval time.Duration
actionsCleanStaleMaxAge time.Duration
prometheusRefreshInterval time.Duration
}
type lockConfig struct {
@ -64,6 +90,7 @@ type prometheusConfig struct {
type Environment struct {
appConfig *appConfig
webConfig *webConfig
authConfig *authConfig
serverConfig *serverConfig
taskConfig *taskConfig
@ -74,34 +101,99 @@ type Environment struct {
}
func bootstrapEnvironment() *Environment {
// logging (configured independently)
var logger *zap.Logger
var err error
level := zap.NewAtomicLevelAt(zapcore.InfoLevel)
envLoggingLevel := os.Getenv(envLoggingLevel)
if envLoggingLevel != "" {
if level, err = zap.ParseAtomicLevel(envLoggingLevel); err != nil {
log.Fatalf("Cannot parse logging level: %v", err)
// bootstrap logging (configured independently and required before any other action)
loggingLevel := os.Getenv(envLoggingLevel)
if loggingLevel == "" {
if err = os.Setenv(envLoggingLevel, loggingLevelDefault); err != nil {
log.Fatalf("Cannot set logging level: %v", err)
}
loggingLevel = os.Getenv(envLoggingLevel)
}
var level zap.AtomicLevel
if level, err = zap.ParseAtomicLevel(loggingLevel); err != nil {
log.Fatalf("Cannot parse logging level: %v", err)
}
loggingEncoding := os.Getenv(envLoggingEncoding)
if loggingEncoding == "" {
if err = os.Setenv(envLoggingEncoding, loggingEncodingDefault); err != nil {
log.Fatalf("Cannot set logging encoding: %v", err)
}
loggingEncoding = os.Getenv(envLoggingEncoding)
}
if loggingEncoding != "json" && loggingEncoding != "console" {
log.Fatalf("Cannot parse logging level: %v", errors.New("only 'json' and 'console' are allowed logging encodings"))
}
isDebug := level.Level() == zap.DebugLevel
isDevelopment := os.Getenv(envDevelopment) == "true"
var loggingEncoderConfig zapcore.EncoderConfig
if loggingEncoding == "json" {
loggingEncoderConfig = zap.NewProductionEncoderConfig()
} else {
loggingEncoderConfig = zap.NewDevelopmentEncoderConfig()
}
logPaths := []string{"stderr"}
loggingDirectory := os.Getenv(envLoggingDirectory)
if loggingDirectory != "" {
logFile := filepath.Join(loggingDirectory, loggingFileNameDefault)
if err = util.CreateFileWithParent(logFile); err != nil {
log.Fatalf("Log file '%s' cannot be created: %v", loggingDirectory, err)
}
logPaths = append(logPaths, logFile)
}
var zapConfig *zap.Config
if isDebug {
zapConfig = &zap.Config{
Level: level,
Development: isDevelopment,
Encoding: loggingEncoding,
EncoderConfig: loggingEncoderConfig,
OutputPaths: logPaths,
ErrorOutputPaths: logPaths,
}
} else {
zapConfig = &zap.Config{
Level: level,
Development: isDevelopment,
Sampling: &zap.SamplingConfig{
Initial: 100,
Thereafter: 100,
},
Encoding: loggingEncoding,
EncoderConfig: loggingEncoderConfig,
OutputPaths: logPaths,
ErrorOutputPaths: logPaths,
}
}
logger, err = zap.NewDevelopment(zap.IncreaseLevel(level))
if err != nil {
log.Fatalf("Can't initialize logger: %v", err)
}
// flushes buffer, if any
defer logger.Sync()
zap.ReplaceGlobals(logger)
zapLogger := zap.Must(zapConfig.Build())
defer func(zapLogger *zap.Logger) {
_ = zapLogger.Sync()
}(zapLogger)
zap.ReplaceGlobals(zapLogger)
// assign defaults from given environment variables and validate
bootstrapFromEnvironmentAndValidate()
// parse environment variables in actual configuration structs
// app config
appConfig := &appConfig{
timeZone: os.Getenv(envTZ),
ac := &appConfig{
timeZone: os.Getenv(envTZ),
isDebug: isDebug,
isDevelopment: isDevelopment,
}
// web config
var webC *webConfig
webC = &webConfig{
title: os.Getenv(envWebTitle),
apiUrl: os.Getenv(envWebApiUrl),
}
// server config
@ -126,43 +218,95 @@ func bootstrapEnvironment() *Environment {
}
sc = &serverConfig{
port: serverPort,
timeout: serverTimeout,
listen: os.Getenv(envServerListen),
tlsEnabled: serverTlsEnabled,
tlsCertPath: os.Getenv(envServerTlsCertPath),
tlsKeyPath: os.Getenv(envServerTlsKeyPath),
corsAllowOrigin: []string{os.Getenv(envCorsAllowOrigin)},
corsAllowMethods: []string{os.Getenv(envCorsAllowMethods)},
corsAllowHeaders: []string{os.Getenv(envCorsAllowHeaders)},
port: serverPort,
timeout: serverTimeout,
listen: os.Getenv(envServerListen),
tlsEnabled: serverTlsEnabled,
tlsCertPath: os.Getenv(envServerTlsCertPath),
tlsKeyPath: os.Getenv(envServerTlsKeyPath),
corsAllowCredentials: os.Getenv(envCorsAllowCredentials) == "true",
corsExposeHeaders: []string{os.Getenv(envCorsExposeHeaders)},
corsAllowOrigins: []string{os.Getenv(envCorsAllowOrigins)},
corsAllowMethods: []string{os.Getenv(envCorsAllowMethods)},
corsAllowHeaders: []string{os.Getenv(envCorsAllowHeaders)},
}
authConfig := &authConfig{
adminUser: os.Getenv(envAdminUser),
adminPassword: os.Getenv(envAdminPassword),
authMode := os.Getenv(envAuthMode)
if authMode != authModeBasicSingle && authMode != authModeBasicCredentials {
zap.L().Sugar().Fatalln("Invalid auth mode. Reason: must be one of ['basic_single','basic_credentials'")
}
authC := &authConfig{
authMethod: authMode,
}
if authModeBasicSingle == authMode {
failIfEnvKeyNotPresent(envBasicAuthUser)
failIfEnvKeyNotPresent(envBasicAuthPassword)
authC.basicAuthUser = os.Getenv(envBasicAuthUser)
authC.basicAuthPassword = os.Getenv(envBasicAuthPassword)
}
if authModeBasicCredentials == authMode {
failIfEnvKeyNotPresent(envBasicAuthCredentials)
authC.basicAuthCredentials = parseBasicAuthCredentials(envBasicAuthCredentials)
}
// task config
var tc *taskConfig
var updateCleanStaleMaxAge time.Duration
if updateCleanStaleMaxAge, errParse = time.ParseDuration(os.Getenv(envTaskUpdateCleanStaleMaxAge)); errParse != nil {
zap.L().Sugar().Fatalf("Could not parse max age for cleaning stale updates. Reason: %s", errParse.Error())
updateCleanStaleInterval := parseDuration(envTaskUpdateCleanStaleInterval)
updateCleanStaleMaxAge := parseDuration(envTaskUpdateCleanStaleMaxAge)
eventCleanStaleMaxAge := parseDuration(envTaskEventCleanStaleMaxAge)
actionsCleanStaleMaxAge := parseDuration(envTaskActionsCleanStaleMaxAge)
eventCleanStaleInterval := parseDuration(envTaskEventCleanStaleInterval)
actionsEnqueueInterval := parseDuration(envTaskActionsEnqueueInterval)
actionsInvokeInterval := parseDuration(envTaskActionsInvokeInterval)
actionsCleanStaleInterval := parseDuration(envTaskActionsCleanStaleInterval)
prometheusRefreshInterval := parseDuration(envTaskPrometheusRefreshInterval)
var actionsEnqueueBatchSize int
if actionsEnqueueBatchSize, err = strconv.Atoi(os.Getenv(envTaskActionsEnqueueBatchSize)); err != nil {
zap.L().Sugar().Fatalf("Invalid actions enqueue batch size. Reason: %v", err)
}
if actionsEnqueueBatchSize <= 0 {
zap.L().Sugar().Fatalf("Invalid actions enqueue batch size, must be a positive number.")
}
var eventCleanStaleMaxAge time.Duration
if eventCleanStaleMaxAge, errParse = time.ParseDuration(os.Getenv(envTaskEventCleanStaleMaxAge)); errParse != nil {
zap.L().Sugar().Fatalf("Could not parse max age for cleaning stale events. Reason: %s", errParse.Error())
var actionsInvokeBatchSize int
if actionsInvokeBatchSize, err = strconv.Atoi(os.Getenv(envTaskActionsInvokeBatchSize)); err != nil {
zap.L().Sugar().Fatalf("Invalid actions invoke batch size. Reason: %v", err)
}
if actionsInvokeBatchSize <= 0 {
zap.L().Sugar().Fatalf("Invalid actions invoke batch size, must be a positive number.")
}
var actionsInvokeMaxRetries int
if actionsInvokeMaxRetries, err = strconv.Atoi(os.Getenv(envTaskActionsInvokeMaxRetries)); err != nil {
zap.L().Sugar().Fatalf("Invalid actions invoke max retries. Reason: %v", err)
}
if actionsInvokeMaxRetries <= 0 {
zap.L().Sugar().Fatalf("Invalid actions invoke max retries, must be a positive number.")
}
tc = &taskConfig{
updateCleanStaleEnabled: os.Getenv(envTaskUpdateCleanStaleEnabled) == "true",
updateCleanStaleInterval: os.Getenv(envTaskUpdateCleanStaleInterval),
updateCleanStaleInterval: updateCleanStaleInterval,
updateCleanStaleMaxAge: updateCleanStaleMaxAge,
eventCleanStaleEnabled: os.Getenv(envTaskEventCleanStaleEnabled) == "true",
eventCleanStaleInterval: os.Getenv(envTaskEventCleanStaleInterval),
eventCleanStaleInterval: eventCleanStaleInterval,
eventCleanStaleMaxAge: eventCleanStaleMaxAge,
prometheusRefreshInterval: os.Getenv(envTaskPrometheusRefreshInterval),
actionsEnqueueEnabled: os.Getenv(envTaskActionsEnqueueEnabled) == "true",
actionsEnqueueInterval: actionsEnqueueInterval,
actionsEnqueueBatchSize: actionsEnqueueBatchSize,
actionsInvokeEnabled: os.Getenv(envTaskActionsInvokeEnabled) == "true",
actionsInvokeInterval: actionsInvokeInterval,
actionsInvokeBatchSize: actionsInvokeBatchSize,
actionsInvokeMaxRetries: actionsInvokeMaxRetries,
actionsCleanStaleEnabled: os.Getenv(envTaskActionsCleanStaleEnabled) == "true",
actionsCleanStaleInterval: actionsCleanStaleInterval,
actionsCleanStaleMaxAge: actionsCleanStaleMaxAge,
prometheusRefreshInterval: prometheusRefreshInterval,
}
var lc *lockConfig
@ -179,24 +323,31 @@ func bootstrapEnvironment() *Environment {
zap.L().Sugar().Fatalln("Invalid webhook token length. Reason: must be a positive number")
}
webhookConfig := &webhookConfig{
wc := &webhookConfig{
tokenLength: webhookTokenLength,
}
prometheusConfig := &prometheusConfig{
pc := &prometheusConfig{
enabled: os.Getenv(envPrometheusEnabled) == "true",
path: os.Getenv(envPrometheusMetricsPath),
secureTokenEnabled: os.Getenv(envPrometheusSecureTokenEnabled) == "true",
secureToken: os.Getenv(envPrometheusSecureToken),
}
if prometheusConfig.enabled && prometheusConfig.secureTokenEnabled {
if pc.enabled && pc.secureTokenEnabled {
failIfEnvKeyNotPresent(envPrometheusSecureToken)
}
// database setup
gormLogger := zapgorm2.New(logger)
gormLogger.SetAsDefault()
gormConfig := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
if isDebug && isDevelopment {
gormZapLogger := zap.Must(zapConfig.Build())
defer func(gormZapLogger *zap.Logger) {
_ = gormZapLogger.Sync()
}(gormZapLogger)
gormLogger := zapgorm2.New(gormZapLogger)
gormConfig = &gorm.Config{Logger: gormLogger}
}
var db *gorm.DB
zap.L().Sugar().Infof("Using database type '%s'", os.Getenv(envDbType))
@ -204,7 +355,7 @@ func bootstrapEnvironment() *Environment {
if os.Getenv(envDbType) == dbTypeSqlite {
if os.Getenv(envDbSqliteFile) == "" {
var defaultDbFile string
if defaultDbFile, err = xdg.DataFile(Name + "/" + dbTypeSqliteDbNameDefault); err != nil {
if defaultDbFile, err = xdg.DataFile(name + "/" + dbTypeSqliteDbNameDefault); err != nil {
zap.L().Sugar().Fatalf("Database file '%s' could not be created. Reason: %v", defaultDbFile, err)
}
setEnvKeyDefault(envDbSqliteFile, defaultDbFile)
@ -213,12 +364,16 @@ func bootstrapEnvironment() *Environment {
dbFile := os.Getenv(envDbSqliteFile)
zap.L().Sugar().Infof("Using database file '%s'", dbFile)
if db, err = gorm.Open(sqlite.Open(dbFile), &gorm.Config{Logger: gormLogger}); err != nil {
if err = util.CreateFileWithParent(dbFile); err != nil {
zap.L().Sugar().Fatalf("Database file '%s' cannot be created: %v", dbFile, err)
}
if db, err = gorm.Open(sqlite.Open(dbFile), gormConfig); err != nil {
zap.L().Sugar().Fatalf("Could not setup database: %v", err)
}
if res := db.Exec("PRAGMA foreign_keys = ON"); res.Error != nil {
zap.L().Sugar().Fatalf("Could not invoke foreign key for SQLite: %v", res.Error)
zap.L().Sugar().Fatalf("Could not execute foreign key for SQLite: %v", res.Error)
}
sqlDb, _ := db.DB()
@ -237,7 +392,7 @@ func bootstrapEnvironment() *Environment {
}
dsn := fmt.Sprintf("host=%v user=%v password=%v dbname=%v port=%v sslmode=disable TimeZone=%v", host, dbUser, dbPass, dbName, port, dbTZ)
if db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormLogger}); err != nil {
if db, err = gorm.Open(postgres.Open(dsn), gormConfig); err != nil {
zap.L().Sugar().Fatalf("Could not setup database: %v", err)
}
} else {
@ -248,16 +403,17 @@ func bootstrapEnvironment() *Environment {
zap.L().Sugar().Fatalf("Could not setup database")
}
env := &Environment{appConfig: appConfig,
authConfig: authConfig,
env := &Environment{appConfig: ac,
webConfig: webC,
authConfig: authC,
serverConfig: sc,
taskConfig: tc,
lockConfig: lc,
webhookConfig: webhookConfig,
prometheusConfig: prometheusConfig,
webhookConfig: wc,
prometheusConfig: pc,
db: db}
if err = env.db.AutoMigrate(&Update{}, &Webhook{}, &Event{}); err != nil {
if err = env.db.AutoMigrate(&Update{}, &Webhook{}, &Event{}, &Secret{}, &Action{}, &ActionInvocation{}); err != nil {
zap.L().Sugar().Fatalf("Could not migrate database schema: %s", err)
}
@ -270,11 +426,17 @@ func bootstrapEnvironment() *Environment {
}
func bootstrapFromEnvironmentAndValidate() {
failIfEnvKeyNotPresent(envSecret)
// auth mode
setEnvKeyDefault(envAuthMode, authModeDefault)
// app
setEnvKeyDefault(envTZ, tzDefault)
failIfEnvKeyNotPresent(envAdminUser)
failIfEnvKeyNotPresent(envAdminPassword)
// web
setEnvKeyDefault(envWebTitle, webTitleDefault)
setEnvKeyDefault(envWebApiUrl, webApiUrlDefault)
// webhook
setEnvKeyDefault(envWebhooksTokenLength, webhooksTokenLengthDefault)
@ -291,6 +453,19 @@ func bootstrapFromEnvironmentAndValidate() {
setEnvKeyDefault(envTaskEventCleanStaleInterval, taskEventCleanStaleIntervalDefault)
setEnvKeyDefault(envTaskEventCleanStaleMaxAge, taskEventCleanStaleMaxAgeDefault)
setEnvKeyDefault(envTaskActionsEnqueueEnabled, taskActionsEnqueueEnabledDefault)
setEnvKeyDefault(envTaskActionsEnqueueInterval, taskActionsEnqueueIntervalDefault)
setEnvKeyDefault(envTaskActionsEnqueueBatchSize, taskActionsEnqueueBatchSizeDefault)
setEnvKeyDefault(envTaskActionsInvokeEnabled, taskActionsInvokeEnabledDefault)
setEnvKeyDefault(envTaskActionsInvokeInterval, taskActionsInvokeIntervalDefault)
setEnvKeyDefault(envTaskActionsInvokeBatchSize, taskActionsInvokeBatchSizeDefault)
setEnvKeyDefault(envTaskActionsInvokeMaxRetries, taskActionsInvokeMaxRetriesDefault)
setEnvKeyDefault(envTaskActionsCleanStaleEnabled, taskActionsCleanStaleEnabledDefault)
setEnvKeyDefault(envTaskActionsCleanStaleInterval, taskActionsCleanStaleIntervalDefault)
setEnvKeyDefault(envTaskActionsCleanStaleMaxAge, taskActionsCleanStaleMaxAgeDefault)
setEnvKeyDefault(envTaskPrometheusRefreshInterval, taskPrometheusRefreshDefault)
// prometheus
@ -311,9 +486,11 @@ func bootstrapFromEnvironmentAndValidate() {
setEnvKeyDefault(envServerPort, serverPortDefault)
setEnvKeyDefault(envServerListen, serverListenDefault)
setEnvKeyDefault(envServerTlsEnabled, serverTlsEnabledDefault)
setEnvKeyDefault(envCorsAllowOrigin, corsAllowOriginDefault)
setEnvKeyDefault(envCorsAllowOrigins, corsAllowOriginsDefault)
setEnvKeyDefault(envCorsAllowMethods, corsAllowMethodsDefault)
setEnvKeyDefault(envCorsAllowHeaders, corsAllowHeadersDefault)
setEnvKeyDefault(envCorsAllowCredentials, corsAllowCredentialsDefault)
setEnvKeyDefault(envCorsExposeHeaders, corsExposeHeadersDefault)
setEnvKeyDefault(envServerTimeout, serverTimeoutDefault)
}
@ -333,3 +510,46 @@ func setEnvKeyDefault(key string, defaultValue string) {
zap.L().Sugar().Infof("Set '%s' to '%s'", key, defaultValue)
}
}
func parseDuration(envProperty string) time.Duration {
var duration time.Duration
var err error
if duration, err = time.ParseDuration(os.Getenv(envProperty)); err != nil {
zap.L().Sugar().Fatalf("Could not parse duration for '%s'. Reason: %s", envProperty, err.Error())
}
return duration
}
func parseBasicAuthCredentials(envProperty string) map[string]string {
if envProperty == "" {
zap.L().Sugar().Fatalln("Invalid env for parsing basic auth credentials")
}
credentialsFromEnv := os.Getenv(envProperty)
var credentials []string
credentials = strings.Split(credentialsFromEnv, ",")
basicAuthCredentials := make(map[string]string)
for _, c := range credentials {
pair := strings.Split(c, "=")
if len(pair) != 2 {
zap.L().Sugar().Fatalln("Invalid basic auth credentials. Reason: credentials must be specified with the = separator per credential entry")
}
if pair[0] == "" {
zap.L().Sugar().Fatalln("Invalid basic auth credentials. Reason: username must not be blank")
}
if pair[1] == "" {
zap.L().Sugar().Fatalln("Invalid basic auth credentials. Reason: password must not be blank")
}
basicAuthCredentials[pair[0]] = pair[1]
}
return basicAuthCredentials
}

View file

@ -6,40 +6,44 @@ import (
)
var (
errorValidationNotEmpty = newServiceError(IllegalArgument, errors.New("assert: empty values are not allowed"))
errorValidationNotBlank = newServiceError(IllegalArgument, errors.New("assert: blank values are not allowed"))
errorValidationPageGreaterZero = newServiceError(IllegalArgument, errors.New("assert: page has to be greater 0"))
errorValidationPageSizeGreaterZero = newServiceError(IllegalArgument, errors.New("assert: pageSize has to be greater 0"))
errorValidationNotEmpty = newServiceError(illegalArgument, errors.New("assert: empty values are not allowed"))
errorValidationNotBlank = newServiceError(illegalArgument, errors.New("assert: blank values are not allowed"))
errorValidationPageGreaterZero = newServiceError(illegalArgument, errors.New("assert: page has to be greater 0"))
errorValidationPageSizeGreaterZero = newServiceError(illegalArgument, errors.New("assert: pageSize has to be greater 0"))
errorValidationLimitGreaterZero = newServiceError(illegalArgument, errors.New("assert: limit has to be greater 0"))
errorValidationSizeGreaterZero = newServiceError(illegalArgument, errors.New("assert: size has to be greater 0"))
errorValidationMaxRetriesGreaterZero = newServiceError(illegalArgument, errors.New("assert: max retries has to be greater 0"))
errorResourceNotFound = newServiceError(NotFound, errors.New("resource not found"))
errorResourceAccessDenied = newServiceError(Forbidden, errors.New("resource access denied"))
errorResourceNotFound = newServiceError(notFound, errors.New("resource not found"))
errorResourceAccessDenied = newServiceError(forbidden, errors.New("resource access denied"))
errorDatabaseRowsExpected = newServiceDatabaseError(errors.New("action failed, expected affected rows, but got none"))
)
type ErrorCode string
type errorCode string
const (
IllegalArgument ErrorCode = "IllegalArgument"
Unauthorized ErrorCode = "Unauthorized"
Forbidden ErrorCode = "Forbidden"
NotFound ErrorCode = "NotFound"
Conflict ErrorCode = "Conflict"
General ErrorCode = "General"
illegalArgument errorCode = "IllegalArgument"
unauthorized errorCode = "Unauthorized"
forbidden errorCode = "Forbidden"
notFound errorCode = "NotFound"
methodNotAllowed errorCode = "MethodNotAllowed"
conflict errorCode = "Conflict"
general errorCode = "General"
)
// newServiceError returns an error that formats as the given text and aligns with builtin error
func newServiceError(status ErrorCode, err error) error {
func newServiceError(status errorCode, err error) error {
return &serviceError{status, fmt.Errorf("service error (%v): %w", status, err)}
}
// newServiceDatabaseError returns an error that formats as the given text and aligns with builtin error
func newServiceDatabaseError(error error) error {
return newServiceError(General, fmt.Errorf("database error: %w", error))
return newServiceError(general, fmt.Errorf("database error: %w", error))
}
type serviceError struct {
Status ErrorCode
Status errorCode
Cause error
}

375
server/repository_action.go Normal file
View file

@ -0,0 +1,375 @@
package server
import (
"encoding/json"
"git.myservermanager.com/varakh/upda/api"
"gorm.io/gorm"
)
type ActionRepository interface {
paginate(page int, pageSize int, orderBy string, order string) ([]*Action, error)
count() (int64, error)
find(id string) (*Action, error)
findByEnabled(enabled bool) ([]*Action, error)
findAll() ([]*Action, error)
create(label string, t api.ActionType, matchEvent *string, matchHost *string, matchApplication *string, matchProvider *string, payload interface{}, enabled bool) (*Action, error)
updateLabel(id string, label string) (*Action, error)
updateMatchEvent(id string, matchEvent *string) (*Action, error)
updateMatchApplication(id string, matchApplication *string) (*Action, error)
updateMatchProvider(id string, matchProvider *string) (*Action, error)
updateMatchHost(id string, matchHost *string) (*Action, error)
updateTypeAndPayload(id string, t api.ActionType, payload interface{}) (*Action, error)
updateEnabled(id string, enabled bool) (*Action, error)
delete(id string) (int64, error)
}
type actionDbRepo struct {
db *gorm.DB
}
func newActionDbRepo(db *gorm.DB) *actionDbRepo {
return &actionDbRepo{
db: db,
}
}
func (r *actionDbRepo) find(id string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e Action
var res *gorm.DB
if res = r.db.Find(&e, "id = ?", id); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
if res.RowsAffected == 0 {
return nil, errorResourceNotFound
}
return &e, nil
}
func (r *actionDbRepo) findByEnabled(enabled bool) ([]*Action, error) {
var e []*Action
res := r.db.Find(&e, "enabled = ?", enabled)
if res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
return e, nil
}
func (r *actionDbRepo) create(label string, t api.ActionType, matchEvent *string, matchHost *string, matchApplication *string, matchProvider *string, payload interface{}, enabled bool) (*Action, error) {
if label == "" || t == "" {
return nil, errorValidationNotBlank
}
e := &Action{
Label: label,
Type: t.Value(),
MatchEvent: matchEvent,
MatchHost: matchHost,
MatchApplication: matchApplication,
MatchProvider: matchProvider,
Enabled: enabled,
}
if payload != nil {
unmarshalledPayload := JSONMap{}
marshalledMetadata, err := json.Marshal(payload)
if err != nil {
return nil, err
}
err = unmarshalledPayload.UnmarshalJSON(marshalledMetadata)
if err != nil {
return nil, err
}
e.Payload = unmarshalledPayload
}
var res *gorm.DB
if res = r.db.Create(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
if res.RowsAffected == 0 {
return nil, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateLabel(id string, label string) (*Action, error) {
if id == "" || label == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
e.Label = label
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateType(id string, t api.ActionType) (*Action, error) {
if id == "" || t == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
e.Type = t.Value()
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateMatchEvent(id string, matchEvent *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
e.MatchEvent = matchEvent
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateMatchApplication(id string, matchApplication *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
e.MatchApplication = matchApplication
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateMatchProvider(id string, matchProvider *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
e.MatchProvider = matchProvider
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateMatchHost(id string, matchHost *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
e.MatchHost = matchHost
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateTypeAndPayload(id string, t api.ActionType, payload interface{}) (*Action, error) {
if id == "" || t == "" {
return nil, errorValidationNotBlank
}
if payload == nil {
return nil, errorValidationNotEmpty
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
unmarshalledPayload := JSONMap{}
marshalledMetadata, err := json.Marshal(payload)
if err != nil {
return nil, err
}
err = unmarshalledPayload.UnmarshalJSON(marshalledMetadata)
if err != nil {
return nil, err
}
e.Payload = unmarshalledPayload
e.Type = t.Value()
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) updateEnabled(id string, enabled bool) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Action
if e, err = r.find(id); err != nil {
return nil, err
}
e.Enabled = enabled
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionDbRepo) delete(id string) (int64, error) {
if id == "" {
return 0, errorValidationNotBlank
}
var res *gorm.DB
if res = r.db.Delete(&Action{}, "id = ?", id); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return res.RowsAffected, nil
}
func (r *actionDbRepo) paginate(page int, pageSize int, orderBy string, order string) ([]*Action, error) {
if page == 0 {
return nil, errorValidationPageGreaterZero
}
if pageSize <= 0 {
return nil, errorValidationPageSizeGreaterZero
}
offset := (page - 1) * pageSize
var e []*Action
var res *gorm.DB
if orderBy != "" && order != "" {
res = r.db.Order(orderBy + " " + order).Offset(offset).Limit(pageSize).Find(&e)
} else {
res = r.db.Offset(offset).Limit(pageSize).Find(&e)
}
if res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
return e, nil
}
func (r *actionDbRepo) count() (int64, error) {
var c int64
var res *gorm.DB
if res = r.db.Model(&Action{}).Count(&c); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return c, nil
}
func (r *actionDbRepo) findAll() ([]*Action, error) {
var e []*Action
if res := r.db.Model(&Action{}).Order("updated_at desc").Find(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
return e, nil
}

View file

@ -0,0 +1,265 @@
package server
import (
"git.myservermanager.com/varakh/upda/api"
"gorm.io/gorm"
"time"
)
type ActionInvocationRepository interface {
paginate(page int, pageSize int, orderBy string, order string) ([]*ActionInvocation, error)
count() (int64, error)
find(id string) (*ActionInvocation, error)
findAllByState(limit int, maxRetries int, state ...api.ActionInvocationState) ([]*ActionInvocation, error)
create(eventId string, actionId string, state api.ActionInvocationState) (*ActionInvocation, error)
updateState(id string, state api.ActionInvocationState) (*ActionInvocation, error)
updateMessage(id string, message *string) (*ActionInvocation, error)
updateRetryCount(id string, retryCount int) (*ActionInvocation, error)
delete(id string) (int64, error)
deleteByUpdatedAtBeforeAndStates(time time.Time, retryCount int, state ...api.ActionInvocationState) (int64, error)
}
type actionInvocationDbRepo struct {
db *gorm.DB
}
func newActionInvocationDbRepo(db *gorm.DB) *actionInvocationDbRepo {
return &actionInvocationDbRepo{
db: db,
}
}
func (r *actionInvocationDbRepo) find(id string) (*ActionInvocation, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e ActionInvocation
var res *gorm.DB
if res = r.db.Find(&e, "id = ?", id); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
if res.RowsAffected == 0 {
return nil, errorResourceNotFound
}
return &e, nil
}
func (r *actionInvocationDbRepo) create(eventId string, actionId string, state api.ActionInvocationState) (*ActionInvocation, error) {
if eventId == "" || actionId == "" || state == "" {
return nil, errorValidationNotBlank
}
e := &ActionInvocation{
EventID: eventId,
ActionID: actionId,
State: state.Value(),
}
var res *gorm.DB
if res = r.db.Create(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
if res.RowsAffected == 0 {
return nil, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionInvocationDbRepo) updateRetryCount(id string, retryCount int) (*ActionInvocation, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var err error
var e *ActionInvocation
if e, err = r.find(id); err != nil {
return nil, err
}
e.RetryCount = retryCount
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionInvocationDbRepo) updateState(id string, state api.ActionInvocationState) (*ActionInvocation, error) {
if id == "" || state == "" {
return nil, errorValidationNotBlank
}
var err error
var e *ActionInvocation
if e, err = r.find(id); err != nil {
return nil, err
}
e.State = state.Value()
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionInvocationDbRepo) updateMessage(id string, message *string) (*ActionInvocation, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var err error
var e *ActionInvocation
if e, err = r.find(id); err != nil {
return nil, err
}
e.Message = message
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *actionInvocationDbRepo) delete(id string) (int64, error) {
if id == "" {
return 0, errorValidationNotBlank
}
var res *gorm.DB
if res = r.db.Delete(&ActionInvocation{}, "id = ?", id); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return res.RowsAffected, nil
}
func (r *actionInvocationDbRepo) paginate(page int, pageSize int, orderBy string, order string) ([]*ActionInvocation, error) {
if page == 0 {
return nil, errorValidationPageGreaterZero
}
if pageSize <= 0 {
return nil, errorValidationPageSizeGreaterZero
}
offset := (page - 1) * pageSize
var e []*ActionInvocation
var res *gorm.DB
if orderBy != "" && order != "" {
res = r.db.Order(orderBy + " " + order).Offset(offset).Limit(pageSize).Find(&e)
} else {
res = r.db.Offset(offset).Limit(pageSize).Find(&e)
}
if res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
return e, nil
}
func (r *actionInvocationDbRepo) count() (int64, error) {
var c int64
var res *gorm.DB
if res = r.db.Model(&ActionInvocation{}).Count(&c); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return c, nil
}
func (r *actionInvocationDbRepo) findAllByState(limit int, maxRetries int, state ...api.ActionInvocationState) ([]*ActionInvocation, error) {
if limit <= 0 {
return nil, errorValidationLimitGreaterZero
}
var e []*ActionInvocation
states := translateActionInvocationState(state...)
if res := r.db.Model(&ActionInvocation{}).Scopes(allGetActionInvocationCriterion(states, maxRetries)).Order("created_at asc").Limit(limit).Find(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
return e, nil
}
func (r *actionInvocationDbRepo) deleteByUpdatedAtBeforeAndStates(time time.Time, maxRetries int, state ...api.ActionInvocationState) (int64, error) {
if len(state) == 0 {
return 0, errorValidationNotEmpty
}
states := translateActionInvocationState(state...)
var res *gorm.DB
if res = r.db.Where("retry_count >= ?", maxRetries).Where("state IN ?", states).Where("updated_at < ?", time).Delete(&ActionInvocation{}); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return res.RowsAffected, nil
}
func translateActionInvocationState(state ...api.ActionInvocationState) []string {
states := make([]string, 0, len(state))
if len(state) > 0 {
for _, s := range state {
states = append(states, s.Value())
}
}
return states
}
func criterionActonInvocationMaxRetries(maxRetries int) func(db *gorm.DB) *gorm.DB {
if maxRetries > 0 {
return func(db *gorm.DB) *gorm.DB {
return db.Where("retry_count < ? ", maxRetries)
}
}
return func(db *gorm.DB) *gorm.DB {
return db
}
}
func criterionActionInvocationState(states []string) func(db *gorm.DB) *gorm.DB {
if states != nil && len(states) > 0 {
return func(db *gorm.DB) *gorm.DB {
return db.Where("state IN (?)", states)
}
}
return func(db *gorm.DB) *gorm.DB {
return db
}
}
func allGetActionInvocationCriterion(states []string, maxRetries int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Scopes(criterionActionInvocationState(states), criterionActonInvocationMaxRetries(maxRetries))
}
}

View file

@ -12,7 +12,9 @@ type eventRepository interface {
window(size int, skip int, orderBy string, order string) ([]*Event, error)
windowHasNext(size int, skip int, orderBy string, order string) (bool, error)
count(state ...api.EventState) (int64, error)
findAllByState(limit int, state ...api.EventState) ([]*Event, error)
create(name api.EventName, state api.EventState, payload interface{}) (*Event, error)
updateState(id string, state api.EventState) (*Event, error)
delete(id string) (int64, error)
deleteByUpdatedAtBeforeAndStates(time time.Time, state ...api.EventState) (int64, error)
}
@ -81,6 +83,31 @@ func (r *eventDbRepo) create(name api.EventName, state api.EventState, payload i
return e, nil
}
func (r *eventDbRepo) updateState(id string, state api.EventState) (*Event, error) {
if id == "" || state == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Event
if e, err = r.find(id); err != nil {
return nil, err
}
e.State = state.Value()
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *eventDbRepo) delete(id string) (int64, error) {
if id == "" {
return 0, errorValidationNotBlank
@ -98,7 +125,7 @@ func (r *eventDbRepo) deleteByUpdatedAtBeforeAndStates(time time.Time, state ...
return 0, errorValidationNotEmpty
}
states := make([]string, 0)
states := make([]string, 0, len(state))
for _, i := range state {
states = append(states, i.Value())
}
@ -112,8 +139,9 @@ func (r *eventDbRepo) deleteByUpdatedAtBeforeAndStates(time time.Time, state ...
}
func (r *eventDbRepo) window(size int, skip int, orderBy string, order string) ([]*Event, error) {
var e []*Event
if size <= 0 {
return nil, errorValidationSizeGreaterZero
}
if orderBy == "" {
orderBy = "created_at"
}
@ -121,6 +149,7 @@ func (r *eventDbRepo) window(size int, skip int, orderBy string, order string) (
order = "asc"
}
var e []*Event
if res := r.db.Order(orderBy + " " + order).Offset(skip).Limit(size).Find(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
@ -145,9 +174,37 @@ func (r *eventDbRepo) windowHasNext(size int, skip int, orderBy string, order st
return len(e) > 0, nil
}
func (r *eventDbRepo) findAllByState(limit int, state ...api.EventState) ([]*Event, error) {
if len(state) == 0 {
return nil, errorValidationNotEmpty
}
if limit <= 0 {
return nil, errorValidationLimitGreaterZero
}
var e []*Event
states := translateEventState(state...)
if res := r.db.Model(&Event{}).Scopes(allGetEventCriterion(states)).Order("created_at asc").Limit(limit).Find(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
return e, nil
}
func (r *eventDbRepo) count(state ...api.EventState) (int64, error) {
var c int64
states := translateEventState(state...)
if res := r.db.Model(&Event{}).Scopes(allGetEventCriterion(states)).Count(&c); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return c, nil
}
func translateEventState(state ...api.EventState) []string {
states := make([]string, 0)
if len(state) > 0 {
for _, s := range state {
@ -155,11 +212,7 @@ func (r *eventDbRepo) count(state ...api.EventState) (int64, error) {
}
}
if res := r.db.Model(&Event{}).Scopes(allGetEventCriterion(states)).Count(&c); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return c, nil
return states
}
func criterionEventState(states []string) func(db *gorm.DB) *gorm.DB {

131
server/repository_secret.go Normal file
View file

@ -0,0 +1,131 @@
package server
import (
"gorm.io/gorm"
)
type secretRepository interface {
findAll() ([]*Secret, error)
findById(id string) (*Secret, error)
findByKey(key string) (*Secret, error)
create(key string, value string) (*Secret, error)
update(id string, value string) (*Secret, error)
delete(id string) (int64, error)
}
type secretDbRepo struct {
db *gorm.DB
}
func newSecretDbRepo(db *gorm.DB) *secretDbRepo {
return &secretDbRepo{
db: db,
}
}
func (r *secretDbRepo) findAll() ([]*Secret, error) {
var e []*Secret
var res *gorm.DB
if res = r.db.Order("key asc").Find(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
return e, nil
}
func (r *secretDbRepo) findById(id string) (*Secret, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e Secret
var res *gorm.DB
if res = r.db.Find(&e, "id = ?", id); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
if res.RowsAffected == 0 {
return nil, errorResourceNotFound
}
return &e, nil
}
func (r *secretDbRepo) findByKey(key string) (*Secret, error) {
if key == "" {
return nil, errorValidationNotBlank
}
var e Secret
var res *gorm.DB
if res = r.db.Find(&e, "key = ?", key); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
if res.RowsAffected == 0 {
return nil, errorResourceNotFound
}
return &e, nil
}
func (r *secretDbRepo) create(key string, value string) (*Secret, error) {
if key == "" || value == "" {
return nil, errorValidationNotBlank
}
var e *Secret
e = &Secret{
Key: key,
Value: value,
}
var res *gorm.DB
if res = r.db.Create(&e); res.Error != nil {
return nil, newServiceDatabaseError(res.Error)
}
if res.RowsAffected == 0 {
return nil, errorDatabaseRowsExpected
}
return e, nil
}
func (r *secretDbRepo) update(id string, value string) (*Secret, error) {
if id == "" || value == "" {
return nil, errorValidationNotBlank
}
var err error
var e *Secret
if e, err = r.findById(id); err != nil {
return nil, err
}
e.Value = value
var res *gorm.DB
if res = r.db.Save(&e); res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return e, errorDatabaseRowsExpected
}
return e, nil
}
func (r *secretDbRepo) delete(id string) (int64, error) {
if id == "" {
return 0, errorValidationNotBlank
}
var res *gorm.DB
if res = r.db.Delete(&Secret{}, "id = ?", id); res.Error != nil {
return 0, newServiceDatabaseError(res.Error)
}
return res.RowsAffected, nil
}

View file

@ -199,7 +199,7 @@ func (r *updateDbRepo) deleteByUpdatedAtBeforeAndStates(time time.Time, state ..
return 0, errorValidationNotEmpty
}
states := make([]string, 0)
states := make([]string, 0, len(state))
for _, i := range state {
states = append(states, i.Value())
}
@ -213,7 +213,7 @@ func (r *updateDbRepo) deleteByUpdatedAtBeforeAndStates(time time.Time, state ..
}
func (r *updateDbRepo) paginate(page int, pageSize int, orderBy string, order string, searchTerm string, searchIn string, state ...api.UpdateState) ([]*Update, error) {
if page == 0 || pageSize <= 0 {
if page == 0 {
return nil, errorValidationPageGreaterZero
}
if pageSize <= 0 {
@ -231,7 +231,7 @@ func (r *updateDbRepo) paginate(page int, pageSize int, orderBy string, order st
order = "desc"
}
states := make([]string, 0)
states := make([]string, 0, len(state))
if len(state) > 0 {
for _, s := range state {
states = append(states, s.Value())
@ -248,7 +248,7 @@ func (r *updateDbRepo) paginate(page int, pageSize int, orderBy string, order st
func (r *updateDbRepo) count(searchTerm string, searchIn string, state ...api.UpdateState) (int64, error) {
var c int64
states := make([]string, 0)
states := make([]string, 0, len(state))
if len(state) > 0 {
for _, s := range state {
states = append(states, s.Value())

View file

@ -5,7 +5,7 @@ import (
"gorm.io/gorm"
)
type WebhookRepository interface {
type webhookRepository interface {
paginate(page int, pageSize int, orderBy string, order string) ([]*Webhook, error)
count() (int64, error)
find(id string) (*Webhook, error)
@ -129,7 +129,7 @@ func (r *webhookDbRepo) delete(id string) (int64, error) {
}
func (r *webhookDbRepo) paginate(page int, pageSize int, orderBy string, order string) ([]*Webhook, error) {
if page == 0 || pageSize <= 0 {
if page == 0 {
return nil, errorValidationPageGreaterZero
}
if pageSize <= 0 {

264
server/service_action.go Normal file
View file

@ -0,0 +1,264 @@
package server
import (
"encoding/json"
"git.myservermanager.com/varakh/upda/api"
"github.com/go-playground/validator/v10"
"go.uber.org/zap"
)
type actionService struct {
repo ActionRepository
eventService *eventService
}
func newActionService(r ActionRepository, e *eventService) *actionService {
return &actionService{
repo: r,
eventService: e,
}
}
func (s *actionService) get(id string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
e, err := s.repo.find(id)
if err != nil {
return nil, err
}
return e, nil
}
func (s *actionService) create(label string, t api.ActionType, matchEvent *string, matchHost *string, matchApplication *string, matchProvider *string, payload interface{}, enabled bool) (*Action, error) {
if label == "" || t == "" {
return nil, errorValidationNotBlank
}
if isValid, validationErr := s.isValidPayload(t, payload); !isValid {
return nil, newServiceError(illegalArgument, validationErr)
}
var err error
var e *Action
if e, err = s.repo.create(label, t, matchEvent, matchHost, matchApplication, matchProvider, payload, enabled); err != nil {
return nil, err
} else {
zap.L().Sugar().Info("Created action")
return e, nil
}
}
func (s *actionService) isValidPayload(t api.ActionType, payload interface{}) (bool, error) {
if t == "" {
return false, errorValidationNotBlank
}
if payload == nil {
return false, errorValidationNotEmpty
}
var err error
if api.ActionTypeShoutrrr == t {
var pb []byte
if pb, err = json.Marshal(payload); err != nil {
return false, err
}
var p actionPayloadShoutrrrDto
if err = json.Unmarshal(pb, &p); err != nil {
return false, err
}
valid := validator.New()
if err = valid.Struct(p); err != nil {
return false, err
}
}
return true, nil
}
func (s *actionService) updateLabel(id string, label string) (*Action, error) {
if id == "" || label == "" {
return nil, errorValidationNotBlank
}
var e *Action
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateLabel(id, label); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action '%v'", id)
return e, nil
}
func (s *actionService) updateMatchEvent(id string, matchEvent *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e *Action
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateMatchEvent(id, matchEvent); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action '%v'", id)
return e, nil
}
func (s *actionService) updateMatchApplication(id string, matchApplication *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e *Action
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateMatchApplication(id, matchApplication); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action '%v'", id)
return e, nil
}
func (s *actionService) updateMatchProvider(id string, matchProvider *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e *Action
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateMatchProvider(id, matchProvider); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action '%v'", id)
return e, nil
}
func (s *actionService) updateMatchHost(id string, matchHost *string) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e *Action
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateMatchHost(id, matchHost); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action '%v'", id)
return e, nil
}
func (s *actionService) updateTypeAndPayload(id string, t api.ActionType, payload interface{}) (*Action, error) {
if id == "" || t == "" {
return nil, errorValidationNotBlank
}
if payload == nil {
return nil, errorValidationNotEmpty
}
var e *Action
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if isValid, validationErr := s.isValidPayload(t, payload); !isValid {
return nil, newServiceError(illegalArgument, validationErr)
}
if e, err = s.repo.updateTypeAndPayload(id, t, payload); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action '%v'", id)
return e, nil
}
func (s *actionService) updateEnabled(id string, enabled bool) (*Action, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e *Action
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateEnabled(id, enabled); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action '%v'", id)
return e, nil
}
func (s *actionService) delete(id string) error {
if id == "" {
return errorValidationNotBlank
}
e, err := s.get(id)
if err != nil {
return err
}
if _, err = s.repo.delete(e.ID.String()); err != nil {
return err
}
zap.L().Sugar().Infof("Deleted action '%v'", id)
return nil
}
func (s *actionService) paginate(page int, pageSize int, orderBy string, order string) ([]*Action, error) {
return s.repo.paginate(page, pageSize, orderBy, order)
}
func (s *actionService) count() (int64, error) {
return s.repo.count()
}
func (s *actionService) getAll() ([]*Action, error) {
return s.repo.findAll()
}
func (s *actionService) getByEnabled(enabled bool) ([]*Action, error) {
return s.repo.findByEnabled(enabled)
}

View file

@ -0,0 +1,404 @@
package server
import (
"errors"
"git.myservermanager.com/varakh/upda/api"
"git.myservermanager.com/varakh/upda/util"
"github.com/containrrr/shoutrrr"
"go.uber.org/zap"
"strings"
"time"
)
type actionInvocationService struct {
repo ActionInvocationRepository
actionService *actionService
eventService *eventService
secretService *secretService
}
func newActionInvocationService(r ActionInvocationRepository, a *actionService, e *eventService, s *secretService) *actionInvocationService {
return &actionInvocationService{
repo: r,
actionService: a,
eventService: e,
secretService: s,
}
}
func (s *actionInvocationService) enqueue(batchSize int) error {
if batchSize <= 0 {
return newServiceError(general, errors.New("cannot enqueue actions from events with invalid configured batch size"))
}
var events []*Event
var err error
if events, err = s.eventService.getByState(batchSize, api.EventStateCreated); err != nil {
return err
}
var actions []*Action
if actions, err = s.actionService.getByEnabled(true); err != nil {
return err
}
for _, event := range events {
if err = s.enqueueFromEvent(event, actions); err != nil {
zap.L().Sugar().Errorf("Could not enqueue action for event '%s' (%s). Reason: %s", event.Name, event.ID, err.Error())
}
}
return nil
}
func (s *actionInvocationService) enqueueFromEvent(event *Event, actions []*Action) error {
if event == nil || actions == nil {
return newServiceError(illegalArgument, errorValidationNotEmpty)
}
var err error
// match requires event payload
var eventPayload *eventPayloadInformationDto
if eventPayload, err = s.eventService.extractPayloadInfo(event); err != nil {
return err
}
filteredActions := make([]*Action, 0)
for _, action := range actions {
matchesEvent := action.MatchEvent == nil || *action.MatchEvent == event.Name
matchesHost := action.MatchHost == nil || *action.MatchHost == eventPayload.Host
matchesApplication := action.MatchApplication == nil || *action.MatchApplication == eventPayload.Application
matchesProvider := action.MatchProvider == nil || *action.MatchProvider == eventPayload.Provider
if matchesEvent && matchesHost && matchesApplication && matchesProvider {
filteredActions = append(filteredActions, action)
}
}
if len(filteredActions) == 0 {
zap.L().Sugar().Debugf("No actions found which match event '%s', nothing to enqueue", event.Name)
}
for _, action := range filteredActions {
if _, err = s.create(event, action, api.ActionInvocationStateCreated); err != nil {
zap.L().Sugar().Errorf("Could not enqueue action '%s' (%v). Reason: %s", action.Label, action.ID, err.Error())
continue
}
}
// mark event as enqueued
if _, err = s.eventService.updateState(event.ID.String(), api.EventStateEnqueued); err != nil {
return err
}
return nil
}
func (s *actionInvocationService) invoke(batchSize int, maxRetries int) error {
if batchSize <= 0 {
return newServiceError(general, errors.New("cannot invoke actions with invalid configured batch size"))
}
if maxRetries <= 0 {
return newServiceError(general, errors.New("cannot invoke actions with invalid configured max retries"))
}
var err error
var actionInvocations []*ActionInvocation
if actionInvocations, err = s.getByState(batchSize, maxRetries, api.ActionInvocationStateCreated, api.ActionInvocationStateRetrying); err != nil {
return err
}
if len(actionInvocations) == 0 {
zap.L().Sugar().Debugf("No action invocations found to process")
return nil
}
for _, actionInvocation := range actionInvocations {
if _, err = s.updateState(actionInvocation.ID.String(), api.ActionInvocationStateRunning); err != nil {
zap.L().Sugar().Errorf("Could not mark action invocation '%v' as running. Reason: %s", actionInvocation.ID, err.Error())
continue
}
zap.L().Sugar().Debugf("Invoking action '%v' for event '%v'", actionInvocation.ActionID, actionInvocation.EventID)
var event *Event
if event, err = s.eventService.get(actionInvocation.EventID); err != nil {
zap.L().Sugar().Errorf("Could not find event '%v' for action '%v' and action invocation '%v'. Reason: %s", actionInvocation.EventID, actionInvocation.ActionID, actionInvocation.ID, err.Error())
// with cascade, cannot happen
continue
}
var eventPayload *eventPayloadInformationDto
if eventPayload, err = s.eventService.extractPayloadInfo(event); err != nil {
zap.L().Sugar().Errorf("Could not extract event's '%v' information for action '%v' and action invocation '%v'. Reason: %s", actionInvocation.EventID, actionInvocation.ActionID, actionInvocation.ID, err.Error())
// with layout of attached payload, cannot happen
continue
}
var action *Action
if action, err = s.actionService.get(actionInvocation.ActionID); err != nil {
zap.L().Sugar().Errorf("Could not find action '%v' for action invocation '%v'. Reason: %s", actionInvocation.ActionID, actionInvocation.ID, err.Error())
// with cascade, cannot happen
continue
}
if err = s.execute(action, eventPayload); err != nil {
var cause error
cause = err
zap.L().Sugar().Errorf("Could not invoke action '%s' (%v) for action invocation '%v'. Reason: %s", action.Label, action.ID, actionInvocation.ID, err.Error())
var newState api.ActionInvocationState
newRetryCount := actionInvocation.RetryCount + 1
newState = api.ActionInvocationStateRetrying
if newRetryCount >= maxRetries {
zap.L().Sugar().Infof("Action invocation '%v' exceeded max retry count of '%d'. Not trying again.", actionInvocation.ID, newRetryCount)
newState = api.ActionInvocationStateError
}
if _, err = s.updateState(actionInvocation.ID.String(), newState); err != nil {
zap.L().Sugar().Errorf("Could not mark action invocation '%v' as '%v'. Reason: %s", actionInvocation.ID, newState, err.Error())
}
if _, err = s.updateRetryCount(actionInvocation.ID.String(), newRetryCount); err != nil {
zap.L().Sugar().Errorf("Could not update action invocation '%v' retry count to '%d'. Reason: %s", actionInvocation.ID, newRetryCount, err.Error())
}
msg := cause.Error()
if _, err = s.updateMessage(actionInvocation.ID.String(), &msg); err != nil {
zap.L().Sugar().Errorf("Could not update action invocation '%v' message. Reason: %s", actionInvocation.ID, err.Error())
}
continue
}
zap.L().Sugar().Debugf("Processed action invocation '%v' for event '%s' (%v) and action '%s' (%v)", actionInvocation.ID, event.Name, event.ID, action.Label, action.ID)
if _, err = s.updateState(actionInvocation.ID.String(), api.ActionInvocationStateSuccess); err != nil {
zap.L().Sugar().Errorf("Could not mark action invocation '%v' as success. Reason: %s", actionInvocation.ID, err.Error())
}
if _, err = s.updateMessage(actionInvocation.ID.String(), nil); err != nil {
zap.L().Sugar().Errorf("Could not update action invocation '%v' message. Reason: %s", actionInvocation.ID, err.Error())
}
}
return nil
}
func (s *actionInvocationService) execute(action *Action, eventPayloadInfo *eventPayloadInformationDto) error {
if action == nil || eventPayloadInfo == nil {
return errorValidationNotEmpty
}
var err error
var bytes []byte
if bytes, err = action.Payload.MarshalJSON(); err != nil {
return newServiceError(general, err)
}
switch action.Type {
case api.ActionTypeShoutrrr.Value():
var payload actionPayloadShoutrrrDto
if payload, err = util.UnmarshalGenericJSON[actionPayloadShoutrrrDto](bytes); err != nil {
return newServiceError(general, err)
}
body := s.replaceVars(payload.Body, eventPayloadInfo)
body = s.replaceSecrets(body)
for _, url := range payload.Urls {
url = s.replaceSecrets(url)
url = s.replaceVars(url, eventPayloadInfo)
if err = shoutrrr.Send(url, body); err != nil {
return err
}
}
break
default:
return newServiceError(general, errors.New("no matching action type found for invocation"))
}
return nil
}
func (s *actionInvocationService) replaceSecrets(str string) string {
if str == "" {
return str
}
var matches [][]string
matches = util.ExtractBetween(str, "<SECRET>", "</SECRET>")
var err error
for _, match := range matches {
var val string
if val, err = s.secretService.getValueByKey(match[1]); err != nil {
zap.L().Sugar().Warnf("Could not inject secret '%s'. Reason: %s", match[1], err.Error())
continue
}
str = strings.ReplaceAll(str, match[0], val)
}
return str
}
func (s *actionInvocationService) replaceVars(str string, eventPayloadInfo *eventPayloadInformationDto) string {
if str == "" || eventPayloadInfo == nil {
return str
}
str = strings.ReplaceAll(str, "<VAR>APPLICATION</VAR>", eventPayloadInfo.Application)
str = strings.ReplaceAll(str, "<VAR>PROVIDER</VAR>", eventPayloadInfo.Provider)
str = strings.ReplaceAll(str, "<VAR>HOST</VAR>", eventPayloadInfo.Host)
str = strings.ReplaceAll(str, "<VAR>VERSION</VAR>", eventPayloadInfo.Version)
str = strings.ReplaceAll(str, "<VAR>STATE</VAR>", eventPayloadInfo.State)
return str
}
func (s *actionInvocationService) paginate(page int, pageSize int, orderBy string, order string) ([]*ActionInvocation, error) {
return s.repo.paginate(page, pageSize, orderBy, order)
}
func (s *actionInvocationService) get(id string) (*ActionInvocation, error) {
if id == "" {
return nil, errorValidationNotBlank
}
e, err := s.repo.find(id)
if err != nil {
return nil, err
}
return e, nil
}
func (s *actionInvocationService) getByState(limit int, maxRetries int, state ...api.ActionInvocationState) ([]*ActionInvocation, error) {
if len(state) == 0 {
return nil, errorValidationNotEmpty
}
if limit <= 0 {
return nil, errorValidationLimitGreaterZero
}
if maxRetries <= 0 {
return nil, errorValidationMaxRetriesGreaterZero
}
return s.repo.findAllByState(limit, maxRetries, state...)
}
func (s *actionInvocationService) count() (int64, error) {
return s.repo.count()
}
func (s *actionInvocationService) delete(id string) error {
if id == "" {
return errorValidationNotBlank
}
e, err := s.get(id)
if err != nil {
return err
}
if _, err = s.repo.delete(e.ID.String()); err != nil {
return err
}
zap.L().Sugar().Infof("Deleted action '%v'", id)
return nil
}
func (s *actionInvocationService) updateState(id string, state api.ActionInvocationState) (*ActionInvocation, error) {
if id == "" || state == "" {
return nil, errorValidationNotBlank
}
var e *ActionInvocation
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateState(id, state); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action invocation '%v'", id)
return e, nil
}
func (s *actionInvocationService) updateMessage(id string, message *string) (*ActionInvocation, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e *ActionInvocation
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateMessage(id, message); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action invocation '%v'", id)
return e, nil
}
func (s *actionInvocationService) updateRetryCount(id string, retryCount int) (*ActionInvocation, error) {
if id == "" {
return nil, errorValidationNotBlank
}
var e *ActionInvocation
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateRetryCount(id, retryCount); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified action invocation '%v'", id)
return e, nil
}
func (s *actionInvocationService) create(event *Event, action *Action, state api.ActionInvocationState) (*ActionInvocation, error) {
if state == "" {
return nil, errorValidationNotBlank
}
if action == nil || event == nil {
return nil, errorValidationNotEmpty
}
var err error
var e *ActionInvocation
if e, err = s.repo.create(event.ID.String(), action.ID.String(), state); err != nil {
return nil, err
} else {
zap.L().Sugar().Info("Created action invocation")
return e, nil
}
}
func (s *actionInvocationService) cleanStale(time time.Time, maxRetries int, state ...api.ActionInvocationState) (int64, error) {
if len(state) == 0 {
return 0, errorValidationNotEmpty
}
return s.repo.deleteByUpdatedAtBeforeAndStates(time, maxRetries, state...)
}

View file

@ -1,7 +1,9 @@
package server
import (
"errors"
"git.myservermanager.com/varakh/upda/api"
"git.myservermanager.com/varakh/upda/util"
"go.uber.org/zap"
"time"
)
@ -38,22 +40,14 @@ func (s *eventService) createUpdateUpdated(old *Update, new *Update) *Event {
return nil
}
var eventName api.EventName
eventName := api.EventNameUpdateUpdated
if old.State == new.State {
eventName = api.EventNameUpdateUpdated
} else {
switch new.State {
case api.UpdateStatePending.Value():
eventName = api.EventNameUpdateUpdatedPending
break
case api.UpdateStateApproved.Value():
eventName = api.EventNameUpdateUpdatedApproved
break
case api.UpdateStateIgnored.Value():
eventName = api.EventNameUpdateUpdatedIgnored
break
}
if old.State != new.State {
eventName = api.EventNameUpdateUpdatedState
}
if old.Version != new.Version {
eventName = api.EventNameUpdateUpdatedVersion
}
s.createWithWarnOnly(eventName, &api.EventPayloadUpdateUpdatedDto{
@ -80,60 +74,7 @@ func (s *eventService) createUpdateDeleted(e *Update) *Event {
Provider: e.Provider,
Host: e.Host,
Version: e.Version,
})
return nil
}
func (s *eventService) createWebhookCreated(e *Webhook) *Event {
if e == nil {
return nil
}
s.createWithWarnOnly(api.EventNameWebhookCreated, &api.EventPayloadWebhookCreatedDto{
ID: e.ID,
Label: e.Label,
Type: e.Type,
IgnoreHost: e.IgnoreHost,
})
return nil
}
func (s *eventService) createWebhookUpdated(old *Webhook, new *Webhook) *Event {
if old == nil || new == nil {
return nil
}
var eventName api.EventName
if old.Label == new.Label {
eventName = api.EventNameWebhookUpdatedIgnoreHost
} else {
eventName = api.EventNameWebhookUpdatedLabel
}
s.createWithWarnOnly(eventName, &api.EventPayloadWebhookUpdatedDto{
ID: new.ID,
LabelPrior: old.Label,
Label: new.Label,
IgnoreHostPrior: old.IgnoreHost,
IgnoreHost: new.IgnoreHost,
Type: new.Type,
})
return nil
}
func (s *eventService) createWebhookDeleted(e *Webhook) *Event {
if e == nil {
return nil
}
s.createWithWarnOnly(api.EventNameWebhookDeleted, &api.EventPayloadWebhookDeletedDto{
Label: e.Label,
Type: e.Type,
IgnoreHost: e.IgnoreHost,
State: e.State,
})
return nil
@ -212,3 +153,82 @@ func (s *eventService) windowHasNext(size int, skip int, orderBy string, order s
func (s *eventService) count(state ...api.EventState) (int64, error) {
return s.repo.count(state...)
}
func (s *eventService) getByState(limit int, state ...api.EventState) ([]*Event, error) {
if len(state) == 0 {
return nil, errorValidationNotEmpty
}
if limit <= 0 {
return nil, errorValidationLimitGreaterZero
}
return s.repo.findAllByState(limit, state...)
}
func (s *eventService) updateState(id string, state api.EventState) (*Event, error) {
if id == "" || state == "" {
return nil, errorValidationNotBlank
}
var e *Event
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.updateState(id, state); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified event '%v'", id)
return e, nil
}
func (s *eventService) extractPayloadInfo(event *Event) (*eventPayloadInformationDto, error) {
if event == nil {
return nil, errorValidationNotEmpty
}
var err error
var bytes []byte
if bytes, err = event.Payload.MarshalJSON(); err != nil {
return nil, newServiceError(general, err)
}
switch event.Name {
case api.EventNameUpdateCreated.Value():
var p api.EventPayloadUpdateCreatedDto
if p, err = util.UnmarshalGenericJSON[api.EventPayloadUpdateCreatedDto](bytes); err != nil {
return nil, newServiceError(general, err)
}
return &eventPayloadInformationDto{Host: p.Host, Application: p.Application, Provider: p.Provider, Version: p.Version, State: p.State}, nil
case api.EventNameUpdateDeleted.Value():
var p api.EventPayloadUpdateDeletedDto
if p, err = util.UnmarshalGenericJSON[api.EventPayloadUpdateDeletedDto](bytes); err != nil {
return nil, newServiceError(general, err)
}
return &eventPayloadInformationDto{Host: p.Host, Application: p.Application, Provider: p.Provider, Version: p.Version, State: p.State}, nil
case api.EventNameUpdateUpdatedState.Value():
var p api.EventPayloadUpdateUpdatedDto
if p, err = util.UnmarshalGenericJSON[api.EventPayloadUpdateUpdatedDto](bytes); err != nil {
return nil, newServiceError(general, err)
}
return &eventPayloadInformationDto{Host: p.Host, Application: p.Application, Provider: p.Provider, Version: p.Version, State: p.State}, nil
case api.EventNameUpdateUpdatedVersion.Value():
var p api.EventPayloadUpdateUpdatedDto
if p, err = util.UnmarshalGenericJSON[api.EventPayloadUpdateUpdatedDto](bytes); err != nil {
return nil, newServiceError(general, err)
}
return &eventPayloadInformationDto{Host: p.Host, Application: p.Application, Provider: p.Provider, Version: p.Version, State: p.State}, nil
case api.EventNameUpdateUpdated.Value():
var p api.EventPayloadUpdateUpdatedDto
if p, err = util.UnmarshalGenericJSON[api.EventPayloadUpdateUpdatedDto](bytes); err != nil {
return nil, newServiceError(general, err)
}
return &eventPayloadInformationDto{Host: p.Host, Application: p.Application, Provider: p.Provider, Version: p.Version, State: p.State}, nil
}
return nil, newServiceError(general, errors.New("no matching event found"))
}

View file

@ -1,9 +1,65 @@
package server
import (
"context"
"math"
"time"
)
// lockService provides methods for locking resources, behavior depends on underlying implementation
type lockService interface {
init() error
tryLock(resource string) error
release(resource string) error
exists(resource string) bool
stop()
// lock locks a resource applying default options (varies for implementations)
lock(ctx context.Context, resource string) (appLock, error)
// lockWithOptions locks a resource with given options, not all options are applied (varies for implementations)
lockWithOptions(ctx context.Context, resource string, options ...appLockOption) (appLock, error)
}
type appLock interface {
// unlock unlocks a lock
unlock(ctx context.Context) error
}
type appLockOption interface {
apply(l *appLockOptions)
}
type appLockOptionFunc func(o *appLockOptions)
func (f appLockOptionFunc) apply(o *appLockOptions) {
f(o)
}
type appLockOptions struct {
expiry *time.Duration
retryDelay *time.Duration
maxRetries *int
}
func withAppLockOptionExpiry(expiry time.Duration) appLockOption {
return appLockOptionFunc(func(o *appLockOptions) {
o.expiry = &expiry
})
}
func withAppLockOptionRetries(retries int) appLockOption {
return appLockOptionFunc(func(o *appLockOptions) {
o.maxRetries = &retries
})
}
var (
appLockOptionMaxRetries = math.MaxInt32
)
func withAppLockOptionInfiniteRetries() appLockOption {
return appLockOptionFunc(func(o *appLockOptions) {
o.maxRetries = &appLockOptionMaxRetries
})
}
func withAppLockOptionRetryDelay(retryDelay time.Duration) appLockOption {
return appLockOptionFunc(func(o *appLockOptions) {
o.retryDelay = &retryDelay
})
}

View file

@ -1,52 +1,75 @@
package server
import (
"context"
"errors"
"git.myservermanager.com/varakh/upda/util"
"go.uber.org/zap"
"time"
)
type lockMemService struct {
registry *util.InMemoryLockRegistry
}
var (
errLockMemNotReleased = newServiceError(conflict, errors.New("lock service: could not release lock"))
)
func newLockMemService() lockService {
zap.L().Info("Initializing in-memory locking service")
return &lockMemService{registry: util.NewInMemoryLockRegistry()}
}
func (s *lockMemService) init() error {
zap.L().Info("Initialized in-memory locking service")
return nil
// lock locks a given resource without any options (default expiration)
func (s *lockMemService) lock(ctx context.Context, resource string) (appLock, error) {
return s.lockWithOptions(ctx, resource, withAppLockOptionExpiry(0))
}
func (s *lockMemService) tryLock(resource string) error {
// lockWithOptions locks a given resource, only TTL as option is supported
func (s *lockMemService) lockWithOptions(ctx context.Context, resource string, options ...appLockOption) (appLock, error) {
if resource == "" {
return errorValidationNotBlank
return nil, errorValidationNotBlank
}
var expiration time.Duration = 0
if options != nil {
lockOptions := &appLockOptions{}
for _, o := range options {
o.apply(lockOptions)
}
if lockOptions.expiry != nil {
expiration = *lockOptions.expiry
}
}
zap.L().Sugar().Debugf("Trying to lock '%s'", resource)
s.registry.Lock(resource)
s.registry.LockWithTTL(resource, expiration)
zap.L().Sugar().Debugf("Locked '%s'", resource)
return nil
}
func (s *lockMemService) release(resource string) error {
if resource == "" {
return errorValidationNotBlank
l := &inMemoryLock{
registry: s.registry,
resource: resource,
}
zap.L().Sugar().Debugf("Releasing lock '%s'", resource)
err := s.registry.Unlock(resource)
zap.L().Sugar().Debugf("Released lock '%s'", resource)
return err
return l, nil
}
func (s *lockMemService) exists(resource string) bool {
return s.registry.Exists(resource)
var _ appLock = (*inMemoryLock)(nil)
type inMemoryLock struct {
registry *util.InMemoryLockRegistry
resource string
}
func (s *lockMemService) stop() {
zap.L().Info("Clearing in-memory locking service")
s.registry.Clear()
func (r inMemoryLock) unlock(ctx context.Context) error {
zap.L().Sugar().Debugf("Unlocking '%s'", r.resource)
if err := r.registry.Unlock(r.resource); err != nil {
return errLockMemNotReleased
}
return nil
}

View file

@ -0,0 +1,29 @@
package server
import (
"context"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
const (
testLockName = "test_lock"
)
func TestLockExpiresAndCannotBeReleased(t *testing.T) {
a := assert.New(t)
s := newLockMemService()
ctx := context.Background()
lock, lockErr := s.lockWithOptions(ctx, testLockName, withAppLockOptionExpiry(250*time.Millisecond))
a.Nil(lockErr)
a.NotNil(lock)
time.Sleep(251 * time.Millisecond)
unlockErr := lock.unlock(ctx)
a.NotNil(unlockErr)
a.ErrorContains(unlockErr, "could not release lock")
}

View file

@ -0,0 +1,109 @@
package server
import (
"context"
"errors"
"fmt"
"github.com/go-redsync/redsync/v4"
redsyncgoredis "github.com/go-redsync/redsync/v4/redis/goredis/v9"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
type lockRedisService struct {
rs *redsync.Redsync
}
var (
errLockRedisNotObtained = newServiceError(conflict, errors.New("lock service: could not obtain lock"))
errLockRedisNotReleased = newServiceError(conflict, errors.New("lock service: could not release lock"))
)
func newLockRedisService(lc *lockConfig) (lockService, error) {
zap.L().Info("Initializing REDIS locking service")
var err error
var redisOptions *redis.Options
redisOptions, err = redis.ParseURL(lc.redisUrl)
if err != nil {
return nil, fmt.Errorf("lock service: cannot parse REDIS URL '%s' to set up locking: %s", lc.redisUrl, err)
}
c := redis.NewClient(redisOptions)
if err = c.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("lock service: failed to connect to REDIS: %w", err)
}
pool := redsyncgoredis.NewPool(c)
rs := redsync.New(pool)
return &lockRedisService{rs: rs}, nil
}
// lock locks a given resource without any options
func (s *lockRedisService) lock(ctx context.Context, resource string) (appLock, error) {
return s.lockWithOptions(ctx, resource, nil)
}
// lockWithOptions locks a given resource considering all options
func (s *lockRedisService) lockWithOptions(ctx context.Context, resource string, options ...appLockOption) (appLock, error) {
if resource == "" {
return nil, errorValidationNotBlank
}
var rsOptions []redsync.Option
if options != nil {
lockOptions := &appLockOptions{}
for _, o := range options {
o.apply(lockOptions)
}
if lockOptions.expiry != nil {
rsOptions = append(rsOptions, redsync.WithExpiry(*lockOptions.expiry))
}
if lockOptions.maxRetries != nil {
rsOptions = append(rsOptions, redsync.WithTries(*lockOptions.maxRetries))
}
if lockOptions.retryDelay != nil {
rsOptions = append(rsOptions, redsync.WithRetryDelay(*lockOptions.retryDelay))
}
}
mu := s.rs.NewMutex(resource, rsOptions...)
zap.L().Sugar().Debugf("Trying to lock '%s'", resource)
if err := mu.LockContext(ctx); err != nil {
return nil, errLockRedisNotObtained
}
zap.L().Sugar().Debugf("Locked '%s'", resource)
l := &redisLock{
mu: mu,
}
return l, nil
}
var _ appLock = (*redisLock)(nil)
type redisLock struct {
mu *redsync.Mutex
}
func (r redisLock) unlock(ctx context.Context) error {
zap.L().Sugar().Debugf("Unlocking '%s'", r.mu.Name())
unlocked, err := r.mu.UnlockContext(ctx)
if err != nil {
return errLockRedisNotReleased
}
if !unlocked {
return errLockRedisNotReleased
}
return nil
}

View file

@ -3,7 +3,6 @@ package server
import (
"github.com/Depado/ginprom"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type prometheusService struct {
@ -19,7 +18,7 @@ func newPrometheusService(r *gin.Engine, c *prometheusConfig) *prometheusService
if c.secureTokenEnabled {
p = ginprom.New(
ginprom.Engine(r),
ginprom.Namespace(Name),
ginprom.Namespace(name),
ginprom.Subsystem(""),
ginprom.Path(c.path),
ginprom.Ignore(c.path),
@ -28,7 +27,7 @@ func newPrometheusService(r *gin.Engine, c *prometheusConfig) *prometheusService
} else {
p = ginprom.New(
ginprom.Engine(r),
ginprom.Namespace(Name),
ginprom.Namespace(name),
ginprom.Subsystem(""),
ginprom.Ignore(c.path),
ginprom.Path(c.path),
@ -42,25 +41,34 @@ func newPrometheusService(r *gin.Engine, c *prometheusConfig) *prometheusService
}
}
func (s *prometheusService) init() {
func (s *prometheusService) init() error {
if !s.config.enabled {
return
return nil
}
var err error
err = s.registerGaugeNoLabels(metricUpdatesTotal, metricUpdatesTotalHelp)
err = s.registerGaugeNoLabels(metricUpdatesPending, metricUpdatesPendingHelp)
err = s.registerGaugeNoLabels(metricUpdatesIgnored, metricUpdatesIgnoredHelp)
err = s.registerGaugeNoLabels(metricUpdatesApproved, metricUpdatesApprovedHelp)
err = s.registerGauge(metricUpdates, metricUpdatesHelp, []string{"application", "provider", "host"})
err = s.registerGaugeNoLabels(metricWebhooks, metricWebhooksHelp)
err = s.registerGaugeNoLabels(metricEvents, metricEventsHelp)
if err != nil {
zap.L().Sugar().Fatalf("Cannot initialize service. Reason: %v", err)
if err := s.registerGaugeNoLabels(metricUpdatesTotal, metricUpdatesTotalHelp); err != nil {
return err
}
if err := s.registerGaugeNoLabels(metricUpdatesPending, metricUpdatesPendingHelp); err != nil {
return err
}
if err := s.registerGaugeNoLabels(metricUpdatesIgnored, metricUpdatesIgnoredHelp); err != nil {
return err
}
if err := s.registerGaugeNoLabels(metricUpdatesApproved, metricUpdatesApprovedHelp); err != nil {
return err
}
if err := s.registerGaugeNoLabels(metricWebhooks, metricWebhooksHelp); err != nil {
return err
}
if err := s.registerGaugeNoLabels(metricEvents, metricEventsHelp); err != nil {
return err
}
if err := s.registerGaugeNoLabels(metricActions, metricActionsHelp); err != nil {
return err
}
return nil
}
func (s *prometheusService) registerGaugeNoLabels(name string, help string) error {

108
server/service_secret.go Normal file
View file

@ -0,0 +1,108 @@
package server
import (
"errors"
"go.uber.org/zap"
)
type secretService struct {
repo secretRepository
}
func newSecretService(r secretRepository) *secretService {
return &secretService{
repo: r,
}
}
func (s *secretService) get(id string) (*Secret, error) {
if id == "" {
return nil, errorValidationNotBlank
}
return s.repo.findById(id)
}
func (s *secretService) getValueByKey(key string) (string, error) {
if key == "" {
return "", errorValidationNotBlank
}
var err error
var e *Secret
if e, err = s.repo.findByKey(key); err != nil {
return "", err
}
return e.Value, nil
}
func (s *secretService) getAll() ([]*Secret, error) {
return s.repo.findAll()
}
func (s *secretService) upsert(key string, value string) (*Secret, error) {
if key == "" || value == "" {
return nil, errorValidationNotBlank
}
var e *Secret
var err error
e, err = s.repo.findByKey(key)
if err != nil && !errors.Is(err, errorResourceNotFound) {
return nil, err
} else if err != nil && errors.Is(err, errorResourceNotFound) {
if e, err = s.repo.create(key, value); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Created secret '%s'", e.Key)
} else {
if e, err = s.repo.update(e.ID.String(), value); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Updated secret '%s'", e.Key)
}
return e, err
}
func (s *secretService) updateValue(id string, value string) (*Secret, error) {
if id == "" || value == "" {
return nil, errorValidationNotBlank
}
var e *Secret
var err error
if e, err = s.get(id); err != nil {
return nil, err
}
if e, err = s.repo.update(id, value); err != nil {
return nil, err
}
zap.L().Sugar().Infof("Modified secret '%v'", id)
return e, nil
}
func (s *secretService) delete(id string) error {
if id == "" {
return errorValidationNotBlank
}
var err error
if _, err = s.get(id); err != nil {
return err
}
if _, err = s.repo.delete(id); err != nil {
return err
}
zap.L().Sugar().Infof("Deleted secret '%v'", id)
return nil
}

View file

@ -1,273 +1,342 @@
package server
import (
"fmt"
"git.myservermanager.com/varakh/upda/api"
"github.com/go-co-op/gocron"
redislock "github.com/go-co-op/gocron-redis-lock"
redislock "github.com/go-co-op/gocron-redis-lock/v2"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"time"
)
type taskService struct {
updateService *updateService
eventService *eventService
webhookService *webhookService
lockService lockService
prometheusService *prometheusService
appConfig *appConfig
taskConfig *taskConfig
lockConfig *lockConfig
prometheusConfig *prometheusConfig
scheduler *gocron.Scheduler
updateService *updateService
eventService *eventService
actionService *actionService
actionInvocationService *actionInvocationService
webhookService *webhookService
lockService lockService
prometheusService *prometheusService
appConfig *appConfig
taskConfig *taskConfig
lockConfig *lockConfig
prometheusConfig *prometheusConfig
scheduler gocron.Scheduler
}
const (
taskLockNameUpdatesCleanStale = "updates_clean_stale"
taskLockNameEventsCleanStale = "events_clean_stale"
taskLockNamePrometheusUpdate = "prometheus_update"
jobUpdatesCleanStale = "UPDATES_CLEAN_STALE"
jobEventsCleanStale = "EVENTS_CLEAN_STALE"
jobActionsEnqueue = "ACTIONS_ENQUEUE"
jobActionsInvoke = "ACTIONS_INVOKE"
jobActionsCleanStale = "ACTIONS_CLEAN_STALE"
jobPrometheusRefresh = "PROMETHEUS_REFRESH"
)
func newTaskService(u *updateService, e *eventService, w *webhookService, l lockService, p *prometheusService, ac *appConfig, tc *taskConfig, lc *lockConfig, pc *prometheusConfig) *taskService {
location, err := time.LoadLocation(ac.timeZone)
var (
initialTasksStartDelay = time.Now().Add(10 * time.Second)
)
if err != nil {
zap.L().Sugar().Fatalf("Could not initialize correct timezone for scheduler. Reason: %s", err.Error())
func newTaskService(u *updateService, e *eventService, w *webhookService, a *actionService, ai *actionInvocationService, l lockService, p *prometheusService, ac *appConfig, tc *taskConfig, lc *lockConfig, pc *prometheusConfig) (*taskService, error) {
var err error
var location *time.Location
if location, err = time.LoadLocation(ac.timeZone); err != nil {
return nil, fmt.Errorf("could not initialize correct timezone for scheduler: %s", err)
}
gocron.SetPanicHandler(func(jobName string, value any) {
zap.L().Sugar().Errorf("Job '%s' had a panic %v", jobName, value)
// global job options
singletonModeOption := gocron.WithSingletonMode(gocron.LimitModeReschedule)
errorEventListener := gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
zap.L().Sugar().Errorf("Job '%s' (%v) had a panic %v", jobName, jobID, err)
})
successEventListener := gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
zap.L().Sugar().Debugf("Job '%s' (%v) finished", jobName, jobID)
})
eventListenerOption := gocron.WithEventListeners(successEventListener, errorEventListener)
startAtOption := gocron.WithStartAt(gocron.WithStartDateTime(initialTasksStartDelay))
scheduler := gocron.NewScheduler(location)
// global scheduler options
schedulerOptions := []gocron.SchedulerOption{gocron.WithLocation(location), gocron.WithGlobalJobOptions(singletonModeOption, eventListenerOption, startAtOption)}
if lc.redisEnabled {
var redisOptions *redis.Options
redisOptions, err = redis.ParseURL(lc.redisUrl)
if err != nil {
zap.L().Sugar().Fatalf("Cannot parse REDIS URL '%s' to set up locking. Reason: %s", lc.redisUrl, err.Error())
return nil, fmt.Errorf("cannot parse REDIS URL '%s' to set up locking for scheduler: %s", lc.redisUrl, err)
}
redisClient := redis.NewClient(redisOptions)
locker, err := redislock.NewRedisLocker(redisClient, redislock.WithTries(1))
if err != nil {
zap.L().Sugar().Fatalf("Cannot set up REDIS locker. Reason: %s", err.Error())
var locker gocron.Locker
if locker, err = redislock.NewRedisLocker(redisClient, redislock.WithTries(1), redislock.WithExpiry(30*time.Second), redislock.WithRetryDelay(5*time.Second)); err != nil {
return nil, fmt.Errorf("cannot set up REDIS locker for scheduler: %s", err)
}
scheduler.WithDistributedLocker(locker)
schedulerOptions = append(schedulerOptions, gocron.WithDistributedLocker(locker))
}
scheduler, _ := gocron.NewScheduler(schedulerOptions...)
return &taskService{
updateService: u,
eventService: e,
webhookService: w,
lockService: l,
prometheusService: p,
appConfig: ac,
taskConfig: tc,
lockConfig: lc,
prometheusConfig: pc,
scheduler: scheduler,
}
updateService: u,
eventService: e,
actionService: a,
actionInvocationService: ai,
webhookService: w,
lockService: l,
prometheusService: p,
appConfig: ac,
taskConfig: tc,
lockConfig: lc,
prometheusConfig: pc,
scheduler: scheduler,
}, nil
}
func (s *taskService) init() {
s.configureCleanupStaleUpdatesTask()
s.configureCleanupStaleEventsTask()
s.configurePrometheusRefreshTask()
func (s *taskService) init() error {
if err := s.configureCleanupStaleUpdatesTask(); err != nil {
return err
}
if err := s.configureCleanupStaleEventsTask(); err != nil {
return err
}
if err := s.configureActionsEnqueueTask(); err != nil {
return err
}
if err := s.configureActionsInvokeTask(); err != nil {
return err
}
if err := s.configureCleanupStaleActionsTask(); err != nil {
return err
}
if err := s.configurePrometheusRefreshTask(); err != nil {
return err
}
return nil
}
func (s *taskService) stop() {
zap.L().Sugar().Infof("Stopping %d periodic tasks...", len(s.scheduler.Jobs()))
s.scheduler.Stop()
s.lockService.stop()
if err := s.scheduler.StopJobs(); err != nil {
zap.L().Sugar().Warnf("Cannot stop periodic tasks. Reason: %v", err)
}
if err := s.scheduler.Shutdown(); err != nil {
zap.L().Sugar().Warnf("Cannot shut down scheduler. Reason: %v", err)
}
zap.L().Info("Stopped all periodic tasks")
}
func (s *taskService) start() {
s.scheduler.StartAsync()
s.scheduler.Start()
zap.L().Sugar().Infof("Started %d periodic tasks", len(s.scheduler.Jobs()))
}
func (s *taskService) configureCleanupStaleUpdatesTask() {
func (s *taskService) configureCleanupStaleUpdatesTask() error {
if !s.taskConfig.updateCleanStaleEnabled {
return
return nil
}
initialDelay := time.Now().Add(10 * time.Second)
_, err := s.scheduler.Every(s.taskConfig.updateCleanStaleInterval).
StartAt(initialDelay).
Do(func() {
resource := taskLockNameUpdatesCleanStale
// distributed lock handled via gocron-redis-lock for tasks
if !s.lockConfig.redisEnabled {
// skip execution if lock already exists, wait otherwise
if lockExists := s.lockService.exists(resource); lockExists {
zap.L().Sugar().Debugf("Skipping task execution because task lock '%s' exists", resource)
return
}
_ = s.lockService.tryLock(resource)
defer func(lockService lockService, resource string) {
err := lockService.release(resource)
if err != nil {
zap.L().Sugar().Warnf("Could not release task lock '%s'", resource)
}
}(s.lockService, resource)
}
t := time.Now()
t = t.Add(-s.taskConfig.updateCleanStaleMaxAge)
runnable := func() {
t := time.Now()
t = t.Add(-s.taskConfig.updateCleanStaleMaxAge)
var err error
var c int64
var err error
var c int64
if c, err = s.updateService.cleanStale(t, api.UpdateStateApproved, api.UpdateStateIgnored); err != nil {
zap.L().Sugar().Errorf("Could not clean up ignored or approved updates older than %s (%s). Reason: %s", s.taskConfig.updateCleanStaleMaxAge, t, err.Error())
return
}
if c, err = s.updateService.cleanStale(t, api.UpdateStateApproved, api.UpdateStateIgnored); err != nil {
zap.L().Sugar().Errorf("Could not clean up ignored or approved updates older than %s (%s). Reason: %s", s.taskConfig.updateCleanStaleMaxAge, t, err.Error())
return
}
if c > 0 {
zap.L().Sugar().Infof("Cleaned up '%d' stale updates", c)
} else {
zap.L().Info("No stale updates found to clean up")
}
})
if err != nil {
zap.L().Sugar().Fatalf("Could not create task for cleaning stale updates. Reason: %s", err.Error())
if c > 0 {
zap.L().Sugar().Infof("Cleaned up '%d' stale updates", c)
} else {
zap.L().Debug("No stale updates found to clean up")
}
}
scheduledJob := gocron.DurationJob(s.taskConfig.updateCleanStaleInterval)
if _, err := s.scheduler.NewJob(scheduledJob, gocron.NewTask(runnable), gocron.WithName(jobUpdatesCleanStale)); err != nil {
return fmt.Errorf("could not create task for cleaning stale updates: %w", err)
}
return nil
}
func (s *taskService) configureCleanupStaleEventsTask() {
func (s *taskService) configureCleanupStaleEventsTask() error {
if !s.taskConfig.eventCleanStaleEnabled {
return
return nil
}
initialDelay := time.Now().Add(5 * time.Second)
_, err := s.scheduler.Every(s.taskConfig.eventCleanStaleInterval).
StartAt(initialDelay).
Do(func() {
resource := taskLockNameEventsCleanStale
// distributed lock handled via gocron-redis-lock for tasks
if !s.lockConfig.redisEnabled {
// skip execution if lock already exists, wait otherwise
if lockExists := s.lockService.exists(resource); lockExists {
zap.L().Sugar().Debugf("Skipping task execution because task lock '%s' exists", resource)
return
}
_ = s.lockService.tryLock(resource)
defer func(lockService lockService, resource string) {
err := lockService.release(resource)
if err != nil {
zap.L().Sugar().Warnf("Could not release task lock '%s'", resource)
}
}(s.lockService, resource)
}
runnable := func() {
t := time.Now()
t = t.Add(-s.taskConfig.eventCleanStaleMaxAge)
t := time.Now()
t = t.Add(-s.taskConfig.eventCleanStaleMaxAge)
var err error
var c int64
var err error
var c int64
if c, err = s.eventService.cleanStale(t, api.EventStateCreated, api.EventStateEnqueued); err != nil {
zap.L().Sugar().Errorf("Could not clean up stale events older than %s (%s). Reason: %s", s.taskConfig.eventCleanStaleMaxAge, t, err.Error())
return
}
if c, err = s.eventService.cleanStale(t, api.EventStateCreated); err != nil {
zap.L().Sugar().Errorf("Could not clean up stale events older than %s (%s). Reason: %s", s.taskConfig.eventCleanStaleMaxAge, t, err.Error())
return
}
if c > 0 {
zap.L().Sugar().Infof("Cleaned up '%d' stale events", c)
} else {
zap.L().Info("No stale events found to clean up")
}
})
if err != nil {
zap.L().Sugar().Fatalf("Could not create task for cleaning stale events. Reason: %s", err.Error())
if c > 0 {
zap.L().Sugar().Infof("Cleaned up '%d' stale events", c)
} else {
zap.L().Debug("No stale events found to clean up")
}
}
scheduledJob := gocron.DurationJob(s.taskConfig.eventCleanStaleInterval)
if _, err := s.scheduler.NewJob(scheduledJob, gocron.NewTask(runnable), gocron.WithName(jobEventsCleanStale)); err != nil {
return fmt.Errorf("could not create task for cleaning stale events: %w", err)
}
return nil
}
func (s *taskService) configurePrometheusRefreshTask() {
func (s *taskService) configureActionsEnqueueTask() error {
if !s.taskConfig.actionsEnqueueEnabled {
return nil
}
runnable := func() {
if err := s.actionInvocationService.enqueue(s.taskConfig.actionsEnqueueBatchSize); err != nil {
zap.L().Sugar().Errorf("Could enqueue actions. Reason: %s", err.Error())
}
}
scheduledJob := gocron.DurationJob(s.taskConfig.actionsEnqueueInterval)
if _, err := s.scheduler.NewJob(scheduledJob, gocron.NewTask(runnable), gocron.WithName(jobActionsEnqueue)); err != nil {
return fmt.Errorf("could not create task for enqueueing actions: %w", err)
}
return nil
}
func (s *taskService) configureActionsInvokeTask() error {
if !s.taskConfig.actionsInvokeEnabled {
return nil
}
runnable := func() {
if err := s.actionInvocationService.invoke(s.taskConfig.actionsInvokeBatchSize, s.taskConfig.actionsInvokeMaxRetries); err != nil {
zap.L().Sugar().Errorf("Could invoke actions. Reason: %s", err.Error())
}
}
scheduledJob := gocron.DurationJob(s.taskConfig.actionsInvokeInterval)
if _, err := s.scheduler.NewJob(scheduledJob, gocron.NewTask(runnable), gocron.WithName(jobActionsInvoke)); err != nil {
return fmt.Errorf("could not create task for invoking actions: %w", err)
}
return nil
}
func (s *taskService) configureCleanupStaleActionsTask() error {
if !s.taskConfig.actionsCleanStaleEnabled {
return nil
}
runnable := func() {
t := time.Now()
t = t.Add(-s.taskConfig.actionsCleanStaleMaxAge)
var cError int64
var err error
if cError, err = s.actionInvocationService.cleanStale(t, s.taskConfig.actionsInvokeMaxRetries, api.ActionInvocationStateError); err != nil {
zap.L().Sugar().Errorf("Could not clean up error stale actions older than %s (%s). Reason: %s", s.taskConfig.actionsCleanStaleMaxAge, t, err.Error())
return
}
var cSuccess int64
if cSuccess, err = s.actionInvocationService.cleanStale(t, 0, api.ActionInvocationStateSuccess); err != nil {
zap.L().Sugar().Errorf("Could not clean up success stale actions older than %s (%s). Reason: %s", s.taskConfig.actionsCleanStaleMaxAge, t, err.Error())
return
}
c := cError + cSuccess
if c > 0 {
zap.L().Sugar().Infof("Cleaned up '%d' stale actions", c)
} else {
zap.L().Debug("No stale actions found to clean up")
}
}
scheduledJob := gocron.DurationJob(s.taskConfig.actionsCleanStaleInterval)
if _, err := s.scheduler.NewJob(scheduledJob, gocron.NewTask(runnable), gocron.WithName(jobActionsCleanStale)); err != nil {
return fmt.Errorf("could not create task for cleaning stale actions: %w", err)
}
return nil
}
func (s *taskService) configurePrometheusRefreshTask() error {
if !s.prometheusConfig.enabled {
return
return nil
}
initialDelay := time.Now().Add(10 * time.Second)
_, err := s.scheduler.Every(s.taskConfig.prometheusRefreshInterval).
StartAt(initialDelay).
Do(func() {
resource := taskLockNamePrometheusUpdate
// distributed lock handled via gocron-redis-lock for tasks
if !s.lockConfig.redisEnabled {
// skip execution if lock already exists, wait otherwise
if lockExists := s.lockService.exists(resource); lockExists {
zap.L().Sugar().Debugf("Skipping task execution because task lock '%s' exists", resource)
return
}
_ = s.lockService.tryLock(resource)
defer func(lockService lockService, resource string) {
err := lockService.release(resource)
if err != nil {
zap.L().Sugar().Warnf("Could not release task lock '%s'", resource)
}
}(s.lockService, resource)
runnable := func() {
updates, updatesError := s.updateService.getAll()
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesTotal, float64(len(updates))); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates all prometheus metric. Reason: %s", updatesError.Error())
}
var pendingTotal int64
var ignoredTotal int64
var ackTotal int64
for _, update := range updates {
if api.UpdateStatePending.Value() == update.State {
pendingTotal += 1
} else if api.UpdateStateIgnored.Value() == update.State {
ignoredTotal += 1
} else if api.UpdateStateApproved.Value() == update.State {
ackTotal += 1
}
}
// updates with labels and collect stats about state
updates, updatesError := s.updateService.getAll()
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesPending, float64(pendingTotal)); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates pending prometheus metric. Reason: %s", updatesError.Error())
}
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesIgnored, float64(ignoredTotal)); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates ignored prometheus metric. Reason: %s", updatesError.Error())
}
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesApproved, float64(ackTotal)); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates approved prometheus metric. Reason: %s", updatesError.Error())
}
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesTotal, float64(len(updates))); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates all prometheus metric. Reason: %s", updatesError.Error())
}
var webhooksTotal int64
var webhooksError error
webhooksTotal, webhooksError = s.webhookService.count()
if webhooksError = s.prometheusService.setGaugeNoLabels(metricWebhooks, float64(webhooksTotal)); webhooksError != nil {
zap.L().Sugar().Errorf("Could not refresh webhooks prometheus metric. Reason: %s", webhooksError.Error())
}
var pendingTotal int64
var ignoredTotal int64
var ackTotal int64
var eventsTotal int64
var eventsError error
eventsTotal, eventsError = s.eventService.count()
if eventsError = s.prometheusService.setGaugeNoLabels(metricEvents, float64(eventsTotal)); eventsError != nil {
zap.L().Sugar().Errorf("Could not refresh events prometheus metric. Reason: %s", eventsError.Error())
}
for _, update := range updates {
var updateState float64
if api.UpdateStatePending.Value() == update.State {
pendingTotal += 1
updateState = 0
} else if api.UpdateStateIgnored.Value() == update.State {
ignoredTotal += 1
updateState = 2
} else if api.UpdateStateApproved.Value() == update.State {
ackTotal += 1
updateState = 1
}
if updatesError = s.prometheusService.setGauge(metricUpdates, []string{update.Application, update.Provider, update.Host}, updateState); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates prometheus metric. Reason: %s", updatesError.Error())
}
}
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesPending, float64(pendingTotal)); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates pending prometheus metric. Reason: %s", updatesError.Error())
}
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesIgnored, float64(ignoredTotal)); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates ignored prometheus metric. Reason: %s", updatesError.Error())
}
if updatesError = s.prometheusService.setGaugeNoLabels(metricUpdatesApproved, float64(ackTotal)); updatesError != nil {
zap.L().Sugar().Errorf("Could not refresh updates approved prometheus metric. Reason: %s", updatesError.Error())
}
// webhooks
var webhooksTotal int64
var webhooksError error
webhooksTotal, webhooksError = s.webhookService.count()
if webhooksError = s.prometheusService.setGaugeNoLabels(metricWebhooks, float64(webhooksTotal)); webhooksError != nil {
zap.L().Sugar().Errorf("Could not refresh webhooks prometheus metric. Reason: %s", webhooksError.Error())
}
// events
var eventsTotal int64
var eventsError error
eventsTotal, eventsError = s.eventService.count()
if eventsError = s.prometheusService.setGaugeNoLabels(metricEvents, float64(eventsTotal)); eventsError != nil {
zap.L().Sugar().Errorf("Could not refresh events prometheus metric. Reason: %s", eventsError.Error())
}
})
if err != nil {
zap.L().Sugar().Fatalf("Could not create task for refreshing prometheus. Reason: %s", err.Error())
var actionsTotal int64
var actionsError error
actionsTotal, actionsError = s.actionService.count()
if actionsError = s.prometheusService.setGaugeNoLabels(metricActions, float64(actionsTotal)); actionsError != nil {
zap.L().Sugar().Errorf("Could not refresh actions prometheus metric. Reason: %s", actionsError.Error())
}
}
scheduledJob := gocron.DurationJob(s.taskConfig.prometheusRefreshInterval)
if _, err := s.scheduler.NewJob(scheduledJob, gocron.NewTask(runnable), gocron.WithName(jobPrometheusRefresh)); err != nil {
return fmt.Errorf("could not create task for refreshing prometheus: %w", err)
}
return nil
}

View file

@ -8,16 +8,14 @@ import (
)
type updateService struct {
repo updateRepository
eventService *eventService
prometheusService *prometheusService
repo updateRepository
eventService *eventService
}
func newUpdateService(r updateRepository, e *eventService, p *prometheusService) *updateService {
func newUpdateService(r updateRepository, e *eventService) *updateService {
return &updateService{
repo: r,
eventService: e,
prometheusService: p,
repo: r,
eventService: e,
}
}
@ -118,10 +116,6 @@ func (s *updateService) delete(id string) error {
s.eventService.createUpdateDeleted(e)
if err = s.prometheusService.setGauge(metricUpdates, []string{e.Application, e.Provider, e.Host}, -1); err != nil {
zap.L().Sugar().Errorf("Could not refresh updates prometheus metric for deleted update '%v'. Reason: %v", e.ID, err)
}
zap.L().Sugar().Infof("Deleted update '%v'", id)
return nil
}

View file

@ -8,16 +8,14 @@ import (
)
type webhookService struct {
repo WebhookRepository
repo webhookRepository
webhookConfig *webhookConfig
eventService *eventService
}
func newWebhookService(r WebhookRepository, c *webhookConfig, e *eventService) *webhookService {
func newWebhookService(r webhookRepository, c *webhookConfig) *webhookService {
return &webhookService{
repo: r,
webhookConfig: c,
eventService: e,
}
}
@ -42,16 +40,15 @@ func (s *webhookService) create(label string, t api.WebhookType, ignoreHost bool
var err error
var token string
var e *Webhook
if token, err = util.GenerateSecureRandomString(s.webhookConfig.tokenLength); err != nil {
return nil, newServiceError(General, fmt.Errorf("token generation failed: %w", err))
return nil, newServiceError(general, fmt.Errorf("token generation failed: %w", err))
}
var e *Webhook
if e, err = s.repo.create(label, t, token, ignoreHost); err != nil {
return nil, err
} else {
s.eventService.createWebhookCreated(e)
zap.L().Sugar().Info("Created webhook")
return e, nil
}
@ -69,12 +66,10 @@ func (s *webhookService) updateLabel(id string, label string) (*Webhook, error)
return nil, err
}
old := e
if e, err = s.repo.updateLabel(id, label); err != nil {
return nil, err
}
s.eventService.createWebhookUpdated(old, e)
zap.L().Sugar().Infof("Modified webhook '%v'", id)
return e, nil
}
@ -91,12 +86,10 @@ func (s *webhookService) updateIgnoreHost(id string, ignoreHost bool) (*Webhook,
return nil, err
}
old := e
if e, err = s.repo.updateIgnoreHost(id, ignoreHost); err != nil {
return nil, err
}
s.eventService.createWebhookUpdated(old, e)
zap.L().Sugar().Infof("Modified webhook '%v'", id)
return e, nil
}
@ -115,7 +108,6 @@ func (s *webhookService) delete(id string) error {
return err
}
s.eventService.createWebhookDeleted(e)
zap.L().Sugar().Infof("Deleted webhook '%v'", id)
return nil

2
server/web/.env Normal file
View file

@ -0,0 +1,2 @@
VITE_API_URL=
VITE_APP_TITLE=upda

View file

@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8080/api/v1/
VITE_APP_TITLE='upda dev'

1
server/web/.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
_doc/*.png filter=lfs diff=lfs merge=lfs -text

32
server/web/.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
/build*
.run/
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
ci/*
!ci/.gitkeep
# production
build/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.eslintcache
.stylelintcache
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea
/hs_err_pid14224.log

2
server/web/.npmrc Normal file
View file

@ -0,0 +1,2 @@
engine-strict=true
legacy-peer-deps=true

2
server/web/.nvmrc Normal file
View file

@ -0,0 +1,2 @@
lts/iron
v20

View file

@ -0,0 +1,7 @@
node_modules
storybook-static
package-lock.json
dist
ci
build
public

9
server/web/.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"printWidth": 120,
"singleQuote": true,
"useTabs": true,
"arrowParens": "always",
"endOfLine": "lf",
"trailingComma": "none",
"bracketSameLine": true
}

41
server/web/.stylelintrc Normal file
View file

@ -0,0 +1,41 @@
{
"extends": ["stylelint-config-standard", "stylelint-prettier/recommended"],
"plugins": ["stylelint-order", "stylelint-declaration-block-no-ignored-properties", "stylelint-prettier"],
"rules": {
"comment-empty-line-before": null,
"selector-class-pattern": null,
"function-name-case": [
"lower",
{
"ignoreFunctions": []
}
],
"no-invalid-double-slash-comments": null,
"no-descending-specificity": null,
"declaration-empty-line-before": null,
"selector-pseudo-element-colon-notation": "single",
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
],
"prettier/prettier": true
},
"overrides": [
{
"files": [
"**/*.less"
],
"customSyntax": "postcss-less"
}
],
"ignoreFiles": [
"**/*.json",
"**/*.svg",
"**/*.ts",
"**/*.tsx",
"**/*.js",
"node_modules/"
]
}

75
server/web/README.md Normal file
View file

@ -0,0 +1,75 @@
# upda-ui
Frontend for upda - **Up**date **Da**shboard in React, TypeScript, Redux.
The main git repository is hosted at
_[https://git.myservermanager.com/varakh/upda-ui](https://git.myservermanager.com/varakh/upda-ui)_. Other repositories
are mirrors and pull requests, issues, and planning are managed there.
Contributions are very welcome!
[Official documentation](https://git.myservermanager.com/varakh/upda-docs) is hosted in a separate git repository.
## Development & contribution
Contributions are very welcome!
### Prerequisites
It's probably worth checking out a node environment manager like [nvm manager](https://github.com/nvm-sh/nvm).
Required node and npm versions are outlined in the `package.json`.
### Setup instructions
Run `npm install` which should install all dependencies.
### Start
Use the `npm run start` command to start the development setup. Backend should be running.
### Configuration magic in docker
What about configuration? How does the pre-compiled set of html, js and css files know about the environment variables?
In contrast to manual build, the docker image allows dynamic override of configuration, but only those outlined in
the `.env` file.
In production, all configuration values are dynamically generated inside the Docker image with a helper script
called `docker-env.sh`:
1. During docker build a template `.env` file and the helper script are copied to the docker image
2. Before the container's nginx is started, the helper script
1. scans the `.env` file for known configuration variables and then
2. adds their values to `conf/runtime-config.js` which is sourced inside the application in the
immutable `window.runtime_config` object
During development, this `runtime-config.js` file is still loaded, but empty and thus the `getConfiguration()` ignores
it and prefers values from the sourced `.env.development`.
This means that new environment variables need to be added to all `.env*` files!
### Release
Releases are handled by the SCM platform and pipeline. Creating a **new git tag**, creates a new release in the SCM
platform, uploads produced artifacts to that release and publishes docker images automatically.
**Before** doing so, please ensure that the **commit on `master`** has the **correct version settings** and has been
built successfully:
* Adapt `package.json` and change `version` to the correct version number
* Invoke `npm install` once which properly sets the version inside the lock file
* Adapt language files, e.g., `en.json` and change `version` to the correct version number
* Adapt `CHANGELOG.md` to reflect changes and ensure a date is properly set in the header, also add a reference link
in footer
* Adapt `env: VERSION_*` in `.forgejo/workflows/release.yaml`
After the release has been created, ensure to change the following settings for the _next development cycle_:
* Adapt `package.json` and change `version` to the _next_ version number (semantic versioning applied, so `patch`
should bump patch level version and prepare branch for `develop` should bump minor or major version)
* Invoke `npm install` for each of those branches which properly sets the version inside the lock file
* Adapt language files, e.g., `en.json` and change `version` to the _next_ version number (semantic versioning
applied, so `patch` should bump patch level version and prepare branch for `develop` should bump minor or major
version)
* Adapt `CHANGELOG.md` and add an _UNRELEASED_ section
* Adapt `env: VERSION_*` in `.forgejo/workflows/release.yaml` to _next_ version number

0
server/web/ci/.gitkeep Normal file
View file

132
server/web/eslint.config.js Normal file
View file

@ -0,0 +1,132 @@
import jsRecommendedLib from '@eslint/js';
import typescriptPlugin from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import prettierPlugin from 'eslint-plugin-prettier';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import sonarjsPlugin from 'eslint-plugin-sonarjs';
import testingLibPlugin from 'eslint-plugin-testing-library';
import { fixupPluginRules } from '@eslint/compat';
// eslint.config.js
export default [
{
files: ['**/styles/**', '**/__tests__/**', '**/*.test.tsx', '**/*.test.ts', '*.less', 'src/**/*.tsx'],
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true
}
},
parser: typescriptParser
},
plugins: {
react: reactPlugin,
'react-hooks': fixupPluginRules(reactHooksPlugin),
sonarjs: sonarjsPlugin,
import: fixupPluginRules(importPlugin),
'jsx-a11y': jsxA11yPlugin,
'@typescript-eslint': typescriptPlugin,
prettier: prettierPlugin,
js: jsRecommendedLib,
'testing-library': fixupPluginRules(testingLibPlugin)
},
settings: {
react: {
version: 'detect'
},
'import/resolver': {
typescript: {},
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx']
}
}
},
rules: {
...jsRecommendedLib.configs.recommended.rules,
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules,
...reactHooksPlugin.configs.recommended.rules,
...importPlugin.configs.recommended.rules,
...jsxA11yPlugin.flatConfigs.recommended.rules,
semi: 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'error',
'no-undef': 'off',
'prefer-const': 'error',
'testing-library/no-debugging-utils': 'warn',
'testing-library/no-dom-import': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-uses-react': 'off',
'no-console': 'warn',
'no-duplicate-imports': 'warn',
'jsx-a11y/no-autofocus': 'off',
'sonarjs/cognitive-complexity': 'off',
'sonarjs/elseif-without-else': 'off',
'sonarjs/max-switch-cases': 'error',
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-collapsible-if': 'error',
'sonarjs/no-collection-size-mischeck': 'error',
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-empty-collection': 'error',
'sonarjs/no-extra-arguments': 'error',
'sonarjs/no-gratuitous-expressions': 'error',
'sonarjs/no-identical-conditions': 'error',
'sonarjs/no-identical-expressions': 'error',
'sonarjs/no-identical-functions': 'error',
'sonarjs/no-ignored-return': 'error',
'sonarjs/no-inverted-boolean-check': 'error',
'sonarjs/no-nested-switch': 'error',
'sonarjs/no-nested-template-literals': 'error',
'sonarjs/no-one-iteration-loop': 'error',
'sonarjs/no-redundant-boolean': 'error',
'sonarjs/no-redundant-jump': 'error',
'sonarjs/no-same-line-conditional': 'error',
'sonarjs/no-small-switch': 'error',
'sonarjs/no-unused-collection': 'error',
'sonarjs/no-use-of-empty-return-value': 'error',
'sonarjs/no-useless-catch': 'error',
'sonarjs/non-existent-operator': 'error',
'sonarjs/prefer-immediate-return': 'error',
'sonarjs/prefer-object-literal': 'error',
'sonarjs/prefer-single-boolean-return': 'error',
'sonarjs/prefer-while': 'error',
'import/order': [
'warn',
{
alphabetize: {
caseInsensitive: true,
order: 'asc'
},
groups: [['builtin', 'external', 'index', 'sibling', 'parent', 'internal']],
'newlines-between': 'always',
pathGroups: [
{
pattern: '*.less',
group: 'index',
patternOptions: {
matchBase: true
},
position: 'before'
},
{
pattern: '*.json',
group: 'index',
patternOptions: {
matchBase: true
},
position: 'after'
}
]
}
]
}
}
];

18
server/web/index.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/x-icon" href="/Favicon16x16.png" sizes="16x16" />
<link rel="icon" type="image/x-icon" href="/Favicon32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000" />
<script type="module" src="/conf/runtime-config.js"></script>
<link rel="manifest" href="/manifest.json" />
<title>upda</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

28419
server/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

116
server/web/package.json Normal file
View file

@ -0,0 +1,116 @@
{
"name": "upda-ui",
"version": "4.0.1",
"private": true,
"author": "varakh@varakh.de",
"type": "module",
"engines": {
"node": "^20",
"npm": "^10"
},
"scripts": {
"start": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"test": "vitest watch",
"test:no-watch": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ci": "vitest run --coverage --passWithNoTests",
"format": "prettier --write ./src/**/*.{ts,tsx,less}",
"format:check": "prettier --check ./src/**/*.{ts,tsx,less}",
"lint": "eslint \"./src/**/*.{tsx,ts}\"",
"lintfix": "eslint --fix \"./src/**/*.{tsx,ts}\"",
"lint:style": "stylelint \"./src/**/*.less\"",
"lint-style-fix": "stylelint \"./src/**/*.less\" --fix",
"checkstyle": "npm run checkstyle:ts && npm run checkstyle:less && npm run checkstyle:format",
"checkstyle:format": "npm run format:check",
"checkstyle:ts": "eslint \"./src/**/*.{ts,tsx}\" -f checkstyle > ci/eslint.xml",
"checkstyle:less": "stylelint \"./src/**/*.less\"",
"clean": "npx --quiet rimraf build && npx --quiet rimraf node_modules"
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@ant-design/pro-layout": "^7.21.1",
"@reduxjs/toolkit": "^2.3.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@uiw/react-md-editor": "^4.0.4",
"antd": "^5.21.5",
"file-saver": "^2.0.5",
"html-react-parser": "^5.1.18",
"i18next": "^23.16.3",
"i18next-browser-languagedetector": "^8.0.0",
"linkify-html": "^4.1.3",
"linkifyjs": "^4.1.3",
"lodash": "^4.17.21",
"moment-timezone": "^0.5.46",
"react": "^18.3.1",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.3.1",
"react-i18next": "^15.1.0",
"react-redux": "^9.1.2",
"react-router-dom": "^6.27.0",
"react-virtualized": "^9.22.5",
"typescript": "^5.6.3"
},
"devDependencies": {
"@babel/register": "^7.25.9",
"@eslint/compat": "^1.2.1",
"@eslint/js": "^9.13.0",
"@types/file-saver": "^2.0.7",
"@types/lodash": "^4.17.12",
"@types/node": "^20.16.5",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-virtualized": "^9.21.30",
"@typescript-eslint/eslint-plugin": "^8.11.0",
"@typescript-eslint/parser": "^8.11.0",
"@vitejs/plugin-react": "^4.3.3",
"@vitest/coverage-v8": "^2.1.3",
"babel-plugin-import": "^1.13.8",
"c8": "^10.1.2",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-formatter-checkstyle": "^8.40.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-disable": "^2.0.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.1",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-sonarjs": "^2.0.4",
"eslint-plugin-testing-library": "^6.4.0",
"jsdom": "^25.0.1",
"less": "^4.2.0",
"less-loader": "^12.2.0",
"postcss-less": "^6.0.0",
"postcss-markdown": "^1.2.0",
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"stylelint": "^16.10.0",
"stylelint-config-standard": "^36.0.1",
"stylelint-declaration-block-no-ignored-properties": "^2.8.0",
"stylelint-order": "^6.0.4",
"stylelint-prettier": "^5.0.2",
"vite": "^5.4.10",
"vite-plugin-eslint2": "^5.0.1",
"vite-plugin-stylelint": "^5.3.1",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.3"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,7 @@
// Note that Object.freeze is NOT recursive
const runtime_config = Object.freeze({});
Object.defineProperty(window, 'runtime_config', {
value: runtime_config,
writable: false
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,8 @@
{
"short_name": "",
"name": "",
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

15
server/web/src/App.tsx Normal file
View file

@ -0,0 +1,15 @@
import getConfiguration from './getConfiguration';
import AppRouter from './router/AppRouter';
import { isDevelopment } from './utils/envHelper';
import { useEffect } from 'react';
const App = () => {
useEffect(() => {
if (isDevelopment()) {
document.title = getConfiguration().VITE_APP_TITLE;
}
}, []);
return <AppRouter />;
};
export default App;

View file

@ -0,0 +1,8 @@
const mockConfig = {};
Object.defineProperty(window, 'runtime_config', {
writable: true,
value: mockConfig
});
export { mockConfig };

View file

@ -0,0 +1,4 @@
vi.mock('react-i18next', () => ({
useTranslation: (): [(key: string) => string] => [(key: string): string => key]
}));
export {};

View file

@ -0,0 +1,67 @@
import { injectEndpoints } from './index';
import ActionInvocationFilterQueryParamNames from '../constants/api/actionInvocationFilterQueryParamNames';
import ApiTags from '../constants/apiTags';
import { ActionInvocationsRequestParams, ActionInvocationSingleResponse, ActionInvocationsResponse } from '../types';
import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
const TAG_LIST_ID = 'LIST';
const invalidatesTags = (
results?: ActionInvocationsResponse | ActionInvocationSingleResponse | void,
error?: FetchBaseQueryError
) => {
if (error) {
return [];
}
return [ApiTags.ActionInvocations] as any;
};
export const actionInvocationsApi = injectEndpoints({
endpoints: (build) => {
return {
getActionInvocations: build.query<ActionInvocationsResponse, ActionInvocationsRequestParams>({
query: ({ page, pageSize, order, orderBy }) => {
const params = new URLSearchParams();
if (page) {
params.append(ActionInvocationFilterQueryParamNames.PAGE, `${page}`);
}
if (pageSize) {
params.append(ActionInvocationFilterQueryParamNames.PAGE_SIZE, `${pageSize}`);
}
if (order) {
params.append(ActionInvocationFilterQueryParamNames.ORDER, `${order}`);
}
if (orderBy) {
params.append(ActionInvocationFilterQueryParamNames.ORDER_BY, `${orderBy}`);
}
return { url: `action-invocations?${params.toString()}` };
},
providesTags: (result, error) => {
if (!error && result?.data.content) {
return [
{ type: ApiTags.ActionInvocations, id: TAG_LIST_ID },
...result.data.content.map(({ id }) => ({ type: ApiTags.ActionInvocations, id }))
];
}
return [];
}
}),
getActionInvocationById: build.query<ActionInvocationSingleResponse, { id: string }>({
query: ({ id }) => ({ url: `action-invocations/${id}` }),
providesTags: (result, error) => {
if (!error && result?.data) {
return [{ type: ApiTags.ActionInvocations, id: result.data.id }];
}
return [];
}
}),
deleteActionInvocation: build.mutation<void, { id: string }>({
query: ({ id }) => ({ url: `action-invocations/${id}`, method: 'DELETE' }),
invalidatesTags
})
};
}
});
export const { useGetActionInvocationsQuery, useGetActionInvocationByIdQuery, useDeleteActionInvocationMutation } =
actionInvocationsApi;

View file

@ -0,0 +1,197 @@
import { injectEndpoints } from './index';
import ActionFilterQueryParamNames from '../constants/api/actionFilterQueryParamNames';
import ApiTags from '../constants/apiTags';
import {
ActionSingleResponse,
ActionsRequestParams,
ActionsResponse,
ActionTestSingleResponse,
CreateActionRequest,
ModifyActionEnabledRequest,
ModifyActionLabelRequest,
ModifyActionMatchApplicationRequest,
ModifyActionMatchEventRequest,
ModifyActionMatchHostRequest,
ModifyActionMatchProviderRequest,
ModifyActionPayloadRequest,
TestActionRequest
} from '../types';
import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
const TAG_LIST_ID = 'LIST';
const invalidatesTags = (results?: ActionsResponse | ActionSingleResponse | void, error?: FetchBaseQueryError) => {
if (error) {
return [];
}
return [ApiTags.Actions] as any;
};
export const actionsApi = injectEndpoints({
endpoints: (build) => {
return {
getActions: build.query<ActionsResponse, ActionsRequestParams>({
query: ({ page, pageSize, order, orderBy }) => {
const params = new URLSearchParams();
if (page) {
params.append(ActionFilterQueryParamNames.PAGE, `${page}`);
}
if (pageSize) {
params.append(ActionFilterQueryParamNames.PAGE_SIZE, `${pageSize}`);
}
if (order) {
params.append(ActionFilterQueryParamNames.ORDER, `${order}`);
}
if (orderBy) {
params.append(ActionFilterQueryParamNames.ORDER_BY, `${orderBy}`);
}
return { url: `actions?${params.toString()}` };
},
providesTags: (result, error) => {
if (!error && result?.data.content) {
return [
{ type: ApiTags.Actions, id: TAG_LIST_ID },
...result.data.content.map(({ id }) => ({ type: ApiTags.Actions, id }))
];
}
return [];
}
}),
getActionById: build.query<ActionSingleResponse, { id: string }>({
query: ({ id }) => ({ url: `actions/${id}` }),
providesTags: (result, error) => {
if (!error && result?.data) {
return [{ type: ApiTags.Actions, id: result.data.id }];
}
return [];
}
}),
createAction: build.mutation<ActionSingleResponse, CreateActionRequest>({
query: (body) => ({ url: 'actions', method: 'POST', body }),
invalidatesTags
}),
testAction: build.mutation<ActionTestSingleResponse, { id: string; body: TestActionRequest }>({
query: ({ id, body }) => ({ url: `actions/${id}/test`, method: 'POST', body })
}),
modifyLabelAction: build.mutation<ActionSingleResponse, { id: string; body: ModifyActionLabelRequest }>({
query: ({ id, body }) => ({ url: `actions/${id}/label`, method: 'PATCH', body }),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Actions, id: arg.id }];
}
}),
modifyMatchApplicationAction: build.mutation<
ActionSingleResponse,
{ id: string; body: ModifyActionMatchApplicationRequest }
>({
query: ({ id, body }) => ({
url: `actions/${id}/match-application`,
method: 'PATCH',
body
}),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Actions, id: arg.id }];
}
}),
modifyMatchHostAction: build.mutation<
ActionSingleResponse,
{ id: string; body: ModifyActionMatchHostRequest }
>({
query: ({ id, body }) => ({
url: `actions/${id}/match-host`,
method: 'PATCH',
body
}),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Actions, id: arg.id }];
}
}),
modifyMatchEventAction: build.mutation<
ActionSingleResponse,
{ id: string; body: ModifyActionMatchEventRequest }
>({
query: ({ id, body }) => ({
url: `actions/${id}/match-event`,
method: 'PATCH',
body
}),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Actions, id: arg.id }];
}
}),
modifyMatchProviderAction: build.mutation<
ActionSingleResponse,
{ id: string; body: ModifyActionMatchProviderRequest }
>({
query: ({ id, body }) => ({
url: `actions/${id}/match-provider`,
method: 'PATCH',
body
}),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Actions, id: arg.id }];
}
}),
modifyTypeAndPayloadAction: build.mutation<
ActionSingleResponse,
{ id: string; body: ModifyActionPayloadRequest }
>({
query: ({ id, body }) => ({
url: `actions/${id}/payload`,
method: 'PATCH',
body
}),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Actions, id: arg.id }];
}
}),
modifyEnabledAction: build.mutation<ActionSingleResponse, { id: string; body: ModifyActionEnabledRequest }>(
{
query: ({ id, body }) => ({ url: `actions/${id}/enabled`, method: 'PATCH', body }),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Actions, id: arg.id }];
}
}
),
deleteAction: build.mutation<void, { id: string }>({
query: ({ id }) => ({ url: `actions/${id}`, method: 'DELETE' }),
invalidatesTags
})
};
}
});
export const {
useGetActionsQuery,
useGetActionByIdQuery,
useDeleteActionMutation,
useModifyLabelActionMutation,
useModifyMatchEventActionMutation,
useModifyMatchApplicationActionMutation,
useModifyMatchHostActionMutation,
useModifyMatchProviderActionMutation,
useModifyTypeAndPayloadActionMutation,
useModifyEnabledActionMutation,
useCreateActionMutation,
useTestActionMutation
} = actionsApi;

View file

@ -0,0 +1,60 @@
import { injectEndpoints } from './index';
import EventFilterQueryParamNames from '../constants/api/eventFilterQueryParamNames';
import ApiTags from '../constants/apiTags';
import { EventSingleResponse, EventsRequestParams, EventsResponse } from '../types/event';
const TAG_LIST_ID = 'LIST';
export const eventsApi = injectEndpoints({
endpoints: (build) => {
return {
getEvents: build.query<EventsResponse, EventsRequestParams>({
query: ({ size, skip, order, orderBy }) => {
const params = new URLSearchParams();
if (size) {
params.append(EventFilterQueryParamNames.SIZE, `${size}`);
}
if (skip) {
params.append(EventFilterQueryParamNames.SKIP, `${skip}`);
}
if (order) {
params.append(EventFilterQueryParamNames.ORDER, `${order}`);
}
if (orderBy) {
params.append(EventFilterQueryParamNames.ORDER_BY, `${orderBy}`);
}
return { url: `events?${params.toString()}` };
},
providesTags: (result, error) => {
if (!error && result?.data.content) {
return [
{ type: ApiTags.Events, id: TAG_LIST_ID },
...result.data.content.map(({ id }) => ({ type: ApiTags.Events, id }))
];
}
return [];
}
}),
getEventById: build.query<EventSingleResponse, { id: string }>({
query: ({ id }) => ({ url: `events/${id}` }),
providesTags: (result, error) => {
if (!error && result?.data) {
return [{ type: ApiTags.Events, id: result.data.id }];
}
return [];
}
}),
deleteEvent: build.mutation<void, { id: string }>({
query: ({ id }) => ({ url: `events/${id}`, method: 'DELETE' }),
invalidatesTags: (result, error, arg) => {
if (error) {
return [];
}
return [{ type: ApiTags.Events, id: arg.id }];
}
})
};
}
});
export const { useLazyGetEventsQuery, useGetEventByIdQuery, useDeleteEventMutation } = eventsApi;

Some files were not shown because too many files have changed in this diff Show more