diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index bce580a..0000000 --- a/.drone.yml +++ /dev/null @@ -1,124 +0,0 @@ -kind: pipeline -name: build - -steps: -- name: gofmt - image: golang:1.22.4 - commands: - - gofmt -l -s . - when: - event: - - push -- name: golangci-linter - image: golangci/golangci-lint:v1.59.1 - commands: - - golangci-lint run --enable-all ./... - when: - event: - - push -- name: vuln-check - image: golang:1.22.4 - commands: - - go install golang.org/x/vuln/cmd/govulncheck@latest - - govulncheck ./... - when: - event: - - push -- name: docker - image: thegeeklab/drone-docker-buildx - privileged: true - settings: - registry: git.ar21.de - username: - from_secret: REGISTRY_USER - password: - from_secret: REGISTRY_PASS - repo: git.ar21.de/yolokube/grafana-backuper - tags: - - latest - - ${DRONE_BUILD_NUMBER} - platforms: - - linux/arm64 - - linux/amd64 - when: - branch: - - main - event: - - push - depends_on: - - gofmt - - golangci-linter - - vuln-check -- name: docker-build - image: thegeeklab/drone-docker-buildx - privileged: true - settings: - registry: git.ar21.de - username: - from_secret: REGISTRY_USER - password: - from_secret: REGISTRY_PASS - repo: git.ar21.de/yolokube/grafana-backuper - tags: - - latest - - ${DRONE_BUILD_NUMBER} - platforms: - - linux/arm64 - - linux/amd64 - dry_run: true - when: - branch: - exclude: - - main - event: - - push - depends_on: - - gofmt - - golangci-linter - - vuln-check -- name: bump tag in deployment-repo - image: git.ar21.de/aaron/kustomize-ci - commands: - - cd /deployment-repo - - git clone https://git.ar21.de/yolokube/grafana-backuper-deployment.git . - - cd /deployment-repo/overlay - - kustomize edit set image git.ar21.de/yolokube/grafana-backuper=git.ar21.de/yolokube/grafana-backuper:${DRONE_BUILD_NUMBER} - volumes: - - name: deployment-repo - path: /deployment-repo - when: - branch: - - main - event: - - push - depends_on: - - docker -- name: push new tag to deployment-repo - image: appleboy/drone-git-push - settings: - branch: main - remote: ssh://git@git.ar21.de:2222/yolokube/grafana-backuper-deployment.git - path: /deployment-repo - force: false - commit: true - commit_message: "GRAFANA-BACKUPER: update image tag to ${DRONE_BUILD_NUMBER} (done automagically via Drone pipeline)" - ssh_key: - from_secret: GITEA_SSH_KEY - volumes: - - name: deployment-repo - path: /deployment-repo - when: - branch: - - main - event: - - push - depends_on: - - bump tag in deployment-repo -volumes: -- name: deployment-repo - temp: {} -when: - event: - exclude: - - pull_request - diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..147fb08 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,342 @@ +run: + # Timeout for analysis, e.g. 30s, 5m. + # Default: 1m + timeout: 5m + + +# This file contains only configs which differ from defaults. +# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml +linters-settings: + cyclop: + # The maximal code complexity to report. + # Default: 10 + max-complexity: 30 + # The maximal average package complexity. + # If it's higher than 0.0 (float) the check is enabled + # Default: 0.0 + package-average: 10.0 + + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: true + + exhaustive: + # Program elements to check for exhaustiveness. + # Default: [ switch ] + check: + - switch + - map + + exhaustruct: + # List of regular expressions to exclude struct packages and their names from checks. + # Regular expressions must match complete canonical struct package/name/structname. + # Default: [] + exclude: + # std libs + - "^net/http.Client$" + - "^net/http.Cookie$" + - "^net/http.Request$" + - "^net/http.Response$" + - "^net/http.Server$" + - "^net/http.Transport$" + - "^net/url.URL$" + - "^os/exec.Cmd$" + - "^reflect.StructField$" + # public libs + - "^github.com/Shopify/sarama.Config$" + - "^github.com/Shopify/sarama.ProducerMessage$" + - "^github.com/mitchellh/mapstructure.DecoderConfig$" + - "^github.com/prometheus/client_golang/.+Opts$" + - "^github.com/spf13/cobra.Command$" + - "^github.com/spf13/cobra.CompletionOptions$" + - "^github.com/stretchr/testify/mock.Mock$" + - "^github.com/testcontainers/testcontainers-go.+Request$" + - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" + - "^golang.org/x/tools/go/analysis.Analyzer$" + - "^google.golang.org/protobuf/.+Options$" + - "^gopkg.in/yaml.v3.Node$" + + funlen: + # Checks the number of lines in a function. + # If lower than 0, disable the check. + # Default: 60 + lines: 100 + # Checks the number of statements in a function. + # If lower than 0, disable the check. + # Default: 40 + statements: 50 + # Ignore comments when counting lines. + # Default false + ignore-comments: true + + gocognit: + # Minimal code complexity to report. + # Default: 30 (but we recommend 10-20) + min-complexity: 20 + + gocritic: + # Settings passed to gocritic. + # The settings key is the name of a supported gocritic checker. + # The list of supported checkers can be find in https://go-critic.github.io/overview. + settings: + captLocal: + # Whether to restrict checker to params only. + # Default: true + paramsOnly: false + underef: + # Whether to skip (*x).method() calls where x is a pointer receiver. + # Default: true + skipRecvDeref: false + + gomodguard: + blocked: + # List of blocked modules. + # Default: [] + modules: + - github.com/golang/protobuf: + recommendations: + - google.golang.org/protobuf + reason: "see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" + - github.com/satori/go.uuid: + recommendations: + - github.com/google/uuid + reason: "satori's package is not maintained" + - github.com/gofrs/uuid: + recommendations: + - github.com/gofrs/uuid/v5 + reason: "gofrs' package was not go module before v5" + + govet: + # Enable all analyzers. + # Default: false + enable-all: true + # Disable analyzers by name. + # Run `go tool vet help` to see all analyzers. + # Default: [] + disable: + - fieldalignment # too strict + # Settings per analyzer. + settings: + shadow: + # Whether to be strict about shadowing; can be noisy. + # Default: false + strict: true + + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + mnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - args.Error + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + # Default: 30 + max-func-lines: 0 + + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [ funlen, gocognit, lll ] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + + rowserrcheck: + # database/sql is always checked + # Default: [] + packages: + - github.com/jmoiron/sqlx + + sloglint: + # Enforce not using global loggers. + # Values: + # - "": disabled + # - "all": report all global loggers + # - "default": report only the default slog logger + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global + # Default: "" + no-global: "all" + # Enforce using methods that accept a context. + # Values: + # - "": disabled + # - "all": report all contextless calls + # - "scope": report only if a context exists in the scope of the outermost function + # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only + # Default: "" + context: "scope" + + tenv: + # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. + # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. + # Default: false + all: true + + +linters: + disable-all: true + enable: + ## enabled by default + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - gosimple # specializes in simplifying a code + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - typecheck # like the front-end of a Go compiler, parses and type-checks Go code + - unused # checks for unused constants, variables, functions and types + ## disabled by default + - asasalint # checks for pass []any as any in variadic func(...any) + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - canonicalheader # checks whether net/http.Header uses canonical header + - copyloopvar # detects places where loop variables are copied + - cyclop # checks function and package cyclomatic complexity + - dupl # tool for code clone detection + - durationcheck # checks for two durations multiplied together + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - exhaustive # checks exhaustiveness of enum switch statements + - exportloopref # checks for pointers to enclosing loop variables + - fatcontext # detects nested contexts in loops + - forbidigo # forbids identifiers + - funlen # tool for detection of long functions + - gocheckcompilerdirectives # validates go compiler directive comments (//go:) + - gochecknoglobals # checks that no global variables exist + - gochecknoinits # checks that no init functions are present in Go code + - gochecksumtype # checks exhaustiveness on Go "sum types" + - gocognit # computes and checks the cognitive complexity of functions + - goconst # finds repeated strings that could be replaced by a constant + - gocritic # provides diagnostics that check for bugs, performance and style issues + - gocyclo # computes and checks the cyclomatic complexity of functions + - godot # checks if comments end in a period + - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt + - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod + - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations + - goprintffuncname # checks that printf-like functions are named with f at the end + - gosec # inspects source code for security problems + - intrange # finds places where for loops could make use of an integer range + - lll # reports long lines + - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) + - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage + - misspell # finds commonly misspelled English words in comments + - mnd # detects magic numbers + - musttag # enforces field tags in (un)marshaled structs + - nakedret # finds naked returns in functions greater than a specified function length + - nestif # reports deeply nested if statements + - nilerr # finds the code that returns nil even if it checks that the error is not nil + - nilnil # checks that there is no simultaneous return of nil error and an invalid value + - noctx # finds sending http request without context.Context + - nolintlint # reports ill-formed or insufficient nolint directives + - nonamedreturns # reports all named returns + - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + - predeclared # finds code that shadows one of Go's predeclared identifiers + - promlinter # checks Prometheus metrics naming via promlint + - protogetter # reports direct reads from proto message fields when getters should be used + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - rowserrcheck # checks whether Err of rows is checked successfully + - sloglint # ensure consistent code style when using log/slog + - spancheck # checks for mistakes with OpenTelemetry/Census spans + - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed + - stylecheck # is a replacement for golint + - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 + - testableexamples # checks if examples are testable (have an expected output) + - testifylint # checks usage of github.com/stretchr/testify + - testpackage # makes you use a separate _test package + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - usestdlibvars # detects the possibility to use variables/constants from the Go standard library + - wastedassign # finds wasted assignment statements + - whitespace # detects leading and trailing whitespace + + ## you may want to enable + #- decorder # checks declaration order and count of types, constants, variables and functions + #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized + #- gci # controls golang package import order and makes it always deterministic + #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega + #- godox # detects FIXME, TODO and other comment keywords + #- goheader # checks is file header matches to pattern + #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters + #- interfacebloat # checks the number of methods inside an interface + #- ireturn # accept interfaces, return concrete types + #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated + #- tagalign # checks that struct tags are well aligned + #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope + #- wrapcheck # checks that errors returned from external packages are wrapped + #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + + ## disabled + #- containedctx # detects struct contained context.Context field + #- contextcheck # [too many false positives] checks the function whether use a non-inherited context + #- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages + #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + #- dupword # [useless without config] checks for duplicate words in the source code + #- err113 # [too strict] checks the errors handling expressions + #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- execinquery # [deprecated] checks query string in Query function which reads your Go src files and warning it finds + #- forcetypeassert # [replaced by errcheck] finds forced type assertions + #- gofmt # [replaced by goimports] checks whether code was gofmt-ed + #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed + #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase + #- grouper # analyzes expression groups + #- importas # enforces consistent import aliases + #- maintidx # measures the maintainability index of each function + #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity + #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test + #- tagliatelle # checks the struct tags + #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines + + +issues: + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 50 + + exclude-rules: + - source: "(noinspection|TODO)" + linters: [ godot ] + - source: "//noinspection" + linters: [ gocritic ] + - path: "_test\\.go" + linters: + - bodyclose + - dupl + - funlen + - goconst + - gosec + - noctx + - wrapcheck diff --git a/.woodpecker/.build.yaml b/.woodpecker/.build.yaml new file mode 100644 index 0000000..27c4fc3 --- /dev/null +++ b/.woodpecker/.build.yaml @@ -0,0 +1,37 @@ +steps: +- name: docker + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.ar21.de + username: + from_secret: REGISTRY_USER + password: + from_secret: REGISTRY_PASS + repo: git.ar21.de/yolokube/grafana-backuper + tags: + - latest + - ${CI_PIPELINE_NUMBER} + when: + - branch: main + event: push +- name: docker-staging + image: woodpeckerci/plugin-docker-buildx + settings: + registry: git.ar21.de + username: + from_secret: REGISTRY_USER + password: + from_secret: REGISTRY_PASS + repo: git.ar21.de/yolokube/grafana-backuper + tags: + - staging + - staging-${CI_PIPELINE_NUMBER} + dry_run: true + when: + - branch: + exclude: main + event: push +depends_on: + - gofmt + - lint + - vulncheck diff --git a/.woodpecker/.deploy.yaml b/.woodpecker/.deploy.yaml new file mode 100644 index 0000000..952c42c --- /dev/null +++ b/.woodpecker/.deploy.yaml @@ -0,0 +1,26 @@ +steps: +- name: bump tag in deployment-repo + image: git.ar21.de/aaron/kustomize-ci + commands: + - git clone https://git.ar21.de/yolokube/grafana-backuper-deployment.git deployment-repo + - cd deployment-repo/overlay + - kustomize edit set image git.ar21.de/yolokube/grafana-backuper=git.ar21.de/yolokube/grafana-backuper:${CI_PIPELINE_NUMBER} + when: + - branch: main + event: push +- name: push new tag to deployment-repo + image: appleboy/drone-git-push + settings: + branch: main + remote: ssh://git@git.ar21.de:2222/yolokube/grafana-backuper-deployment.git + path: deployment-repo + force: false + commit: true + commit_message: "GRAFANA-BACKUPER: update image tag to ${CI_PIPELINE_NUMBER} (done automagically via Woodpecker pipeline)" + ssh_key: + from_secret: FORGEJO_SSH_KEY + when: + - branch: main + event: push +depends_on: + - build \ No newline at end of file diff --git a/.woodpecker/.gofmt.yaml b/.woodpecker/.gofmt.yaml new file mode 100644 index 0000000..8db41f6 --- /dev/null +++ b/.woodpecker/.gofmt.yaml @@ -0,0 +1,7 @@ +steps: +- name: gofmt + image: golang:1.22.5 + commands: + - gofmt -l -s . + when: + - event: push diff --git a/.woodpecker/.lint.yaml b/.woodpecker/.lint.yaml new file mode 100644 index 0000000..d2477ad --- /dev/null +++ b/.woodpecker/.lint.yaml @@ -0,0 +1,7 @@ +steps: +- name: golangci-linter + image: golangci/golangci-lint:v1.59.1 + commands: + - golangci-lint run ./... + when: + - event: push diff --git a/.woodpecker/.vulncheck.yaml b/.woodpecker/.vulncheck.yaml new file mode 100644 index 0000000..2be22f8 --- /dev/null +++ b/.woodpecker/.vulncheck.yaml @@ -0,0 +1,8 @@ +steps: +- name: vuln-check + image: golang:1.22.5 + commands: + - go install golang.org/x/vuln/cmd/govulncheck@latest + - govulncheck ./... + when: + - event: push diff --git a/Dockerfile b/Dockerfile index 62b802a..d25a8ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.4-bookworm AS build +FROM golang:1.22.5-bookworm AS build # Create build workspace folder WORKDIR /workspace @@ -9,7 +9,7 @@ RUN apt-get update --yes && \ apt-get install --yes build-essential # Build the actual binary -RUN CGO_ENABLED=0 go build -o grafana-backuper main.go +RUN CGO_ENABLED=0 go build -o grafana-backuper cmd/main.go # -- -- -- -- -- -- @@ -22,4 +22,4 @@ WORKDIR /app # Copy built binary from build image COPY --from=build /workspace/grafana-backuper /app -ENTRYPOINT ["/app/grafana-backuper"] +ENTRYPOINT ["/app/grafana-backuper backup --json"] diff --git a/cfg/cfg.go b/cfg/cfg.go deleted file mode 100644 index b79bdc3..0000000 --- a/cfg/cfg.go +++ /dev/null @@ -1,91 +0,0 @@ -package cfg - -import ( - "fmt" - "os" - - "github.com/alecthomas/kong" -) - -type AppSettings struct { - Force bool - GrafanaURL string - GrafanaToken string - GitBranch string - GitRepoURL string - GitUser string - GitEmail string - GitPass string - GPGKey string -} - -var cliStruct struct { - Force bool `name:"force" short:"f" help:"Force git commits / ignore existence check."` - GrafanaURL string `name:"grafana-url" env:"GRAFANA_URL" help:"Grafana URL to access the API"` - GrafanaToken string `name:"grafana-auth-token" env:"GRAFANA_AUTH_TOKEN" help:"Grafana auth token to access the API"` - GitBranch string `name:"git-branch" env:"GIT_BRANCH" help:"Git branch name" default:"${default_git_branch}"` - GitRepoURL string `name:"git-repo-url" env:"GIT_REPO_URL" help:"Complete Git repository URL"` - GitUser string `name:"git-user" env:"GIT_USER" help:"Git username"` - GitEmail string `name:"git-email" env:"GIT_EMAIL" help:"Git email address"` - GitPass string `name:"git-pass" env:"GIT_PASS" help:"Git user password"` - GPGKey string `name:"signing-key" env:"GIT_SIGNING_KEY" help:"GPG signing key"` -} - -func Parse() *AppSettings { - ctx := kong.Parse( - &cliStruct, - kong.Vars{ - "default_git_branch": "main", - }, - kong.Name("grafana-backuper"), - kong.Description("🚀 CLI tool to convert grafana dashboards to git"), - kong.UsageOnError(), - ) - - validateFlags(ctx) - settings := &AppSettings{ - Force: cliStruct.Force, - GrafanaURL: cliStruct.GrafanaURL, - GrafanaToken: cliStruct.GrafanaToken, - GitBranch: cliStruct.GitBranch, - GitRepoURL: cliStruct.GitRepoURL, - GitUser: cliStruct.GitUser, - GitEmail: cliStruct.GitEmail, - GitPass: cliStruct.GitPass, - GPGKey: cliStruct.GPGKey, - } - return settings -} - -func validateFlags(cliCtx *kong.Context) { - var flagsValid = true - var messages = []string{} - if cliStruct.GrafanaURL == "" { - messages = append(messages, "error: invalid grafana URL, must not be blank") - flagsValid = false - } - if cliStruct.GrafanaToken == "" { - messages = append(messages, "error: invalid auth token for grafana, must not be blank") - flagsValid = false - } - if cliStruct.GitRepoURL == "" { - messages = append(messages, "error: invalid repo url for git, must not be blank") - flagsValid = false - } - if cliStruct.GitUser == "" { - messages = append(messages, "error: invalid username for git, must not be blank") - flagsValid = false - } - if cliStruct.GitPass == "" { - messages = append(messages, "error: invalid password for git, must not be blank") - flagsValid = false - } - if !flagsValid { - cliCtx.PrintUsage(false) - fmt.Println() - for i := 0; i < len(messages); i++ { - fmt.Println(messages[i]) - } - os.Exit(1) - } -} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..33d3200 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "os" + + "git.ar21.de/yolokube/grafana-backuper/internal/cmd" + "git.ar21.de/yolokube/grafana-backuper/internal/config" +) + +func main() { + cfg := &config.Config{} + + rootCmd := cmd.NewRootCommand(cfg) + rootCmd.AddCommand( + cmd.NewBackupCommand(cfg), + cmd.NewRestoreCommand(cfg), + ) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 81a1762..44497ab 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,52 @@ module git.ar21.de/yolokube/grafana-backuper -go 1.22.2 +go 1.22.5 require ( github.com/ProtonMail/go-crypto v1.0.0 - github.com/alecthomas/kong v0.9.0 github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.12.0 + github.com/rs/zerolog v1.33.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.19.0 + golang.org/x/net v0.28.0 ) require ( - dario.cat/mergo v1.0.0 // indirect - github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/cloudflare/circl v1.3.7 // indirect - github.com/cyphar/filepath-securejoin v0.2.4 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cloudflare/circl v1.3.9 // indirect + github.com/cyphar/filepath-securejoin v0.3.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/skeema/knownhosts v1.2.2 // indirect + github.com/skeema/knownhosts v1.3.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/mod v0.15.0 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/tools v0.18.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b1688f7..6af7d4b 100644 --- a/go.sum +++ b/go.sum @@ -1,53 +1,53 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= -github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= -github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= -github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY= -github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= -github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= -github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= -github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= -github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= -github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= -github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= +github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cyphar/filepath-securejoin v0.3.1 h1:1V7cHiaW+C+39wEfpH6XlLBQo3j/PciWFrgfCLS8XrE= +github.com/cyphar/filepath-securejoin v0.3.1/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= -github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= +github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= -github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -59,46 +59,83 @@ 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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.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.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +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/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= -github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= -github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= -github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= +github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +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-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= +golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= 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.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -106,15 +143,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +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-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.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= -golang.org/x/sync v0.6.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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -124,21 +157,21 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -146,22 +179,23 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= -golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/backup.go b/internal/cmd/backup.go new file mode 100644 index 0000000..e06c9fe --- /dev/null +++ b/internal/cmd/backup.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "context" + "os" + + "git.ar21.de/yolokube/grafana-backuper/internal/config" + "git.ar21.de/yolokube/grafana-backuper/pkg/grafanabackuper" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func NewBackupCommand(c *config.Config) *cobra.Command { + backupCmd := &cobra.Command{ + Use: "backup", + Short: "Back up the dashboards from grafana to a git repository.", + Long: "Back up the dashboards from grafana to a git repository.", + Run: func(cmd *cobra.Command, _ []string) { + if err := backup(cmd.Context(), c); err != nil { + log.Fatal().Err(err).Send() + } + }, + PreRun: func(cmd *cobra.Command, _ []string) { + errs := c.Validate() + for _, err := range errs { + log.Error().Err(err).Send() + } + + if len(errs) > 0 { + if err := cmd.Help(); err != nil { + log.Error().Err(err).Send() + } + os.Exit(1) + } + }, + } + + backupCmd.PersistentFlags().BoolVar(&c.ForceCommits, "force", false, "Force git commits / ignore existence check") + backupCmd.PersistentFlags().StringVar(&c.GitEmail, "git-email", "", "Git email address") + backupCmd.PersistentFlags().StringVar(&c.GPGKey, "signing-key", "", "Path to the GPG signing key") + + return backupCmd +} + +func backup(ctx context.Context, cfg *config.Config) error { + client := grafanabackuper.NewClient( + grafanabackuper.WithZerologLogger(cfg.Logger), + ) + + if err := client.GetSigner(cfg); err != nil { + return err + } + + if err := client.Prepare(ctx, cfg); err != nil { + return err + } + + return client.Backup.Start(ctx, cfg) +} diff --git a/internal/cmd/restore.go b/internal/cmd/restore.go new file mode 100644 index 0000000..20d307d --- /dev/null +++ b/internal/cmd/restore.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "context" + "os" + + "git.ar21.de/yolokube/grafana-backuper/internal/config" + "git.ar21.de/yolokube/grafana-backuper/pkg/grafanabackuper" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func NewRestoreCommand(c *config.Config) *cobra.Command { + backupCmd := &cobra.Command{ + Use: "restore", + Short: "Restore the dashboards from a git repository to grafana.", + Long: "Restore the dashboards from a git repository to grafana.", + Run: func(cmd *cobra.Command, _ []string) { + if err := restore(cmd.Context(), c); err != nil { + log.Fatal().Err(err).Send() + } + }, + PreRun: func(cmd *cobra.Command, _ []string) { + errs := c.Validate() + for _, err := range errs { + log.Error().Err(err).Send() + } + + if len(errs) > 0 { + if err := cmd.Help(); err != nil { + log.Error().Err(err).Send() + } + os.Exit(1) + } + }, + } + + backupCmd.PersistentFlags().BoolVar(&c.ForceCommits, "force", false, "Force dashboards / ignore existence check") + + return backupCmd +} + +func restore(ctx context.Context, cfg *config.Config) error { + client := grafanabackuper.NewClient( + grafanabackuper.WithZerologLogger(cfg.Logger), + ) + + if err := client.Prepare(ctx, cfg); err != nil { + return err + } + + return client.Restore.Start(ctx, cfg) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 0000000..896dc8e --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "git.ar21.de/yolokube/grafana-backuper/internal/config" + "git.ar21.de/yolokube/grafana-backuper/internal/logger" + "git.ar21.de/yolokube/grafana-backuper/internal/version" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const envVarPrefix = "GB" + +func NewRootCommand(c *config.Config) *cobra.Command { + rootCmd := &cobra.Command{ + Use: "grafana-backuper", + Short: "Grafana Backuper CLI", + Long: "A command-line tool to back up and restore Grafana dashboards", + Version: version.Version(), + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + initializeConfig(cmd) + + // c.Output = os.Stdout + cmd.SetOut(os.Stdout) + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + if c.Debug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } else if c.Quiet { + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + } + + if c.JSONFormat { + log.Logger = logger.JSONLoggerLayout(os.Stderr) + c.Logger = logger.JSONLoggerLayout(os.Stdout) + } else { + log.Logger = logger.CliLoggerLayout(os.Stderr) + c.Logger = logger.CliLoggerLayout(os.Stdout) + } + }, + SilenceUsage: true, + } + + rootCmd.PersistentFlags().BoolVarP(&c.Debug, "debug", "d", false, "Debug output") + rootCmd.PersistentFlags().BoolVar(&c.JSONFormat, "json", false, "JSON output") + rootCmd.PersistentFlags().BoolVar(&c.Quiet, "quiet", false, "Quiet output (only errors)") + rootCmd.PersistentFlags().StringVar(&c.GrafanaURL, "grafana-url", "", "Grafana URL to access the API") + rootCmd.PersistentFlags().StringVar(&c.GrafanaToken, "grafana-token", "", "Grafana auth token to access the API") + rootCmd.PersistentFlags().StringVar(&c.GitBranch, "git-branch", "main", "Git branch name") + rootCmd.PersistentFlags().StringVar(&c.GitRepo, "git-repo", "", "Complete Git repository URL") + rootCmd.PersistentFlags().StringVar(&c.GitUser, "git-user", "", "Git user name") + rootCmd.PersistentFlags().StringVar(&c.GitPass, "git-pass", "", "Git user password") + + return rootCmd +} + +func initializeConfig(cmd *cobra.Command) { + v := viper.New() + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.SetEnvPrefix(envVarPrefix) + v.AutomaticEnv() + bindFlags(cmd, v) +} + +func bindFlags(cmd *cobra.Command, v *viper.Viper) { + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + // Apply the viper config value to the flag when the flag is not set and viper has a value + if !flag.Changed && v.IsSet(flag.Name) { + err := cmd.Flags().Set( + flag.Name, + fmt.Sprintf("%v", v.Get(flag.Name)), + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + } + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..331dcea --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,56 @@ +package config + +import ( + "errors" + "io" + + "github.com/rs/zerolog" +) + +type Config struct { + Debug bool + ForceCommits bool + JSONFormat bool + GrafanaURL string + GrafanaToken string + GitBranch string + GitEmail string + GitRepo string + GitUser string + GitPass string + GPGKey string + Quiet bool + + Output io.Writer + Logger zerolog.Logger +} + +func (c *Config) Validate() []error { + var errs []error + + if c.GrafanaURL == "" { + errs = append(errs, errors.New("invalid grafana URL, must not be blank")) + } + + if c.GrafanaToken == "" { + errs = append(errs, errors.New("invalid auth token for grafana, must not be blank")) + } + + if c.GitRepo == "" { + errs = append(errs, errors.New("invalid repo url for git, must not be blank")) + } + + if c.GitUser == "" { + errs = append(errs, errors.New("invalid username for git, must not be blank")) + } + + if c.GitPass == "" { + errs = append(errs, errors.New("invalid password for git, must not be blank")) + } + + if c.GitBranch == "" { + errs = append(errs, errors.New("invalid branch name for git, must not be blank")) + } + + return errs +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..fbcc944 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,28 @@ +package logger + +import ( + "fmt" + "io" + "path/filepath" + "time" + + "github.com/rs/zerolog" +) + +func CliLoggerLayout(out io.Writer) zerolog.Logger { + zerolog.CallerMarshalFunc = func(_ uintptr, file string, line int) string { + return fmt.Sprintf("%s:%d", filepath.Base(file), line) + } + + if zerolog.GlobalLevel() == zerolog.DebugLevel { + debugOutput := zerolog.ConsoleWriter{Out: out, TimeFormat: time.RFC3339} + return zerolog.New(debugOutput).With().Timestamp().Caller().Logger() + } + + output := zerolog.ConsoleWriter{Out: out, PartsExclude: []string{"time", "level"}} + return zerolog.New(output).With().Logger() +} + +func JSONLoggerLayout(out io.Writer) zerolog.Logger { + return zerolog.New(out).With().Timestamp().Caller().Logger() +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..bd65be1 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,17 @@ +package version + +import ( + "fmt" +) + +var ( + version = "undefined" + versionPrerelease = "dev" //nolint:gochecknoglobals // this has to be a variable to set the version during release. +) + +func Version() string { + if versionPrerelease != "" { + return fmt.Sprintf("%s-%s", version, versionPrerelease) + } + return version +} diff --git a/main.go b/main.go deleted file mode 100644 index 72162df..0000000 --- a/main.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "slices" - - "git.ar21.de/yolokube/grafana-backuper/cfg" - "git.ar21.de/yolokube/grafana-backuper/pkg/git" - "git.ar21.de/yolokube/grafana-backuper/pkg/grafana" -) - -func main() { - appSettings := cfg.Parse() - - ctx := context.Background() - client, err := grafana.NewClient(appSettings.GrafanaURL, appSettings.GrafanaToken, grafana.DefaultHTTPClient) - if err != nil { - log.Fatalf("Error creating the grafana client: %v", err) - } - - gitdata := git.NewPayload(appSettings.GPGKey) - if err = gitdata.GetRepo(appSettings.GitRepoURL, appSettings.GitUser, appSettings.GitPass); err != nil { - log.Fatalf("Error cloning git repo: %v", err) - } - - gitdata.AddCommitter(appSettings.GitUser, appSettings.GitEmail) - - dashboards, err := client.SearchDashboards(ctx, "", false) - if err != nil { - log.Fatalf("Error fetching dashboards: %v", err) - } - - for _, dashboard := range dashboards { - gitdata.UpdateDashboard(dashboard) - - _, dashboardInfo, err := client.GetRawDashboardByUID(ctx, dashboard.UID) - if err != nil { - log.Fatalf("Error fetching information of dashboard %s: %v", dashboard.Title, err) - } - - gitdata.UpdateDashboardInfo(dashboardInfo) - - versions, err := client.GetDashboardVersionsByDashboardUID(ctx, dashboard.UID) - if err != nil { - log.Fatalf("Error fetching versions for dashboard %s: %v", dashboard.Title, err) - } - - slices.Reverse(versions) - - for _, version := range versions { - gitdata.UpdateVersion(version) - - if !appSettings.Force && gitdata.IsVersionCommitted(appSettings.GitBranch) { - fmt.Printf("%s/%s - %s: %s -> already committed\n", dashboardInfo.FolderTitle, dashboard.Title, version.CreatedBy, version.Message) - continue - } - - fmt.Printf("%s/%s - %s: %s\n", dashboardInfo.FolderTitle, dashboard.Title, version.CreatedBy, version.Message) - - raw, info, err := client.GetRawDashboardByUIDAndVersion(ctx, dashboard.UID, version.Version) - if err != nil { - log.Fatalf("Error fetching dashboard %s version %d: %v", dashboard.Title, version.Version, err) - } - - gitdata.AddAuthor(info.CreatedBy, "") - - output, err := grafana.ConvertRawToIndent(raw) - if err != nil { - log.Fatalf("Error pritty-printing dashboard %s version %d: %v", dashboard.Title, info.Version, err) - } - - gitdata.UpdateContent([]byte(output)) - gitdata.UpdateCommitter() - if err = gitdata.CreateCommit(); err != nil { - log.Fatalf("Error creating commit for dashboard %s version %d: %v", dashboard.Title, info.Version, err) - } - - if err = gitdata.PushToRemote(appSettings.GitUser, appSettings.GitPass); err != nil { - log.Fatalf("Error pushing to remote repo %s: %v", appSettings.GitRepoURL, err) - } - } - } -} diff --git a/pkg/git/commit.go b/pkg/git/commit.go new file mode 100644 index 0000000..a4508c1 --- /dev/null +++ b/pkg/git/commit.go @@ -0,0 +1,105 @@ +package git + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type CommitOption func(*Commit) + +func WithAuthor(name, email string) CommitOption { + return func(c *Commit) { + c.Author = &object.Signature{ + Name: name, + Email: email, + When: time.Now(), + } + } +} + +func WithCommitter(name, email string) CommitOption { + return func(c *Commit) { + c.Committer = &object.Signature{ + Name: name, + Email: email, + When: time.Now(), + } + } +} + +func WithFileContent(content []byte, filename, folder string) CommitOption { + return func(c *Commit) { + c.Content = content + c.Filename = filepath.Join(folder, fmt.Sprintf("%s.json", filename)) + } +} + +func WithSigner(signKey SignKey) CommitOption { + return func(c *Commit) { + c.signKey = signKey.entity + } +} + +type Commit struct { + Author *object.Signature + Committer *object.Signature + Content []byte + Filename string + KeyFile string + + project *Project + signKey *openpgp.Entity +} + +func (p *Project) NewCommit(options ...CommitOption) *Commit { + commit := &Commit{project: p} + + for _, option := range options { + option(commit) + } + + return commit +} + +func (c *Commit) Create(msg string) error { + if err := c.addContent(); err != nil { + return err + } + + if _, err := c.project.worktree.Add(c.Filename); err != nil { + return err + } + + commitOpts := git.CommitOptions{Author: c.Author} + + if c.Committer != nil { + commitOpts.Committer = c.Committer + } + + if c.signKey != nil { + commitOpts.SignKey = c.signKey + } + + _, err := c.project.worktree.Commit(msg, &commitOpts) + if err != nil { + return err + } + + return nil +} + +func (c *Commit) addContent() error { + file, err := c.project.worktree.Filesystem.Create(c.Filename) + if err != nil { + return err + } + defer file.Close() + + _, err = file.Write(c.Content) + return err +} diff --git a/pkg/git/git.go b/pkg/git/git.go deleted file mode 100644 index d696f55..0000000 --- a/pkg/git/git.go +++ /dev/null @@ -1,240 +0,0 @@ -package git - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "git.ar21.de/yolokube/grafana-backuper/pkg/grafana" - "github.com/ProtonMail/go-crypto/openpgp" - "github.com/go-git/go-billy/v5" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/go-git/go-git/v5/storage/memory" -) - -var ( - fs billy.Filesystem - storer *memory.Storage -) - -type Payload struct { - Author *object.Signature - Committer *object.Signature - Content []byte - Dashboard grafana.FoundBoard - DashboardInfo grafana.BoardProperties - Directory string - KeyFile string - Repository *git.Repository - Version grafana.DashboardVersion -} - -func NewPayload(keyFile string) *Payload { - fs = memfs.New() - storer = memory.NewStorage() - - return &Payload{ - Author: nil, - Committer: nil, - Content: []byte{}, - Dashboard: grafana.FoundBoard{}, - DashboardInfo: grafana.BoardProperties{}, - Directory: "", - KeyFile: keyFile, - Repository: nil, - Version: grafana.DashboardVersion{}, - } -} - -func (p *Payload) AddAuthor(name, email string) { - p.Author = &object.Signature{ - Name: name, - Email: email, - When: time.Now(), - } -} - -func (p *Payload) UpdateAuthor(timestamp time.Time) { - p.Author.When = timestamp -} - -func (p *Payload) AddCommitter(name, email string) { - p.Committer = &object.Signature{ - Name: name, - Email: email, - When: time.Now(), - } -} - -func (p *Payload) UpdateCommitter() { - p.Committer.When = time.Now() -} - -func (p *Payload) UpdateContent(content []byte) { - p.Content = content -} - -func (p *Payload) UpdateDashboard(dashboard grafana.FoundBoard) { - p.Dashboard = dashboard -} - -func (p *Payload) UpdateDashboardInfo(dashboardInfo grafana.BoardProperties) { - p.DashboardInfo = dashboardInfo -} - -func (p *Payload) UpdateVersion(version grafana.DashboardVersion) { - p.Version = version -} - -func (p *Payload) GetRepo(repoURL, user, password string) (err error) { - p.Repository, err = git.Clone( - storer, - fs, - &git.CloneOptions{ - Auth: genAuth(user, password), - URL: repoURL, - Progress: os.Stdout, - }, - ) - - p.Directory = filepath.Base(repoURL) - - return -} - -func (p *Payload) IsVersionCommitted(branch string) bool { - refName := plumbing.NewBranchReferenceName(branch) - ref, err := p.Repository.Reference(refName, false) - if err != nil { - return false - } - - commitIter, err := p.Repository.Log(&git.LogOptions{From: ref.Hash()}) - if err != nil { - return false - } - - err = commitIter.ForEach(func(commit *object.Commit) error { - if strings.Contains(commit.Message, fmt.Sprintf("Update %s", p.Version.DashboardUID)) && strings.Contains(commit.Message, fmt.Sprintf("version %d", p.Version.ID)) { - return fmt.Errorf("version already committed") - } - return nil - }) - - return err != nil -} - -func genAuth(user, password string) *http.BasicAuth { - return &http.BasicAuth{ - Username: user, - Password: password, - } -} - -func commitDashboard(repo *git.Repository, content []byte, commitMsg, dashboardTitle, folderTitle, gitRepoDirectory, keyFile string, author, committer *object.Signature) (err error) { - var ( - file billy.File - signer *openpgp.Entity - worktree *git.Worktree - ) - - if strings.TrimSpace(keyFile) != "" { - signer, err = getSigner(keyFile) - if err != nil { - return - } - } - - worktree, err = repo.Worktree() - if err != nil { - return - } - - if err = fs.MkdirAll(folderTitle, 0755); err != nil { - return - } - - filePath := filepath.Join(folderTitle, fmt.Sprintf("%s.json", dashboardTitle)) - if file, err = fs.Create(filePath); err != nil { - return - } - - if _, err = file.Write(content); err != nil { - return - } - - if err = file.Close(); err != nil { - return - } - - if _, err = worktree.Add(filePath); err != nil { - return - } - - _, err = worktree.Commit( - commitMsg, - &git.CommitOptions{ - Author: author, - Committer: committer, - SignKey: signer, - }, - ) - - return -} - -func (p *Payload) CreateCommit() error { - var commitmsg string - - if p.Version.Message != "" { - commitmsg = fmt.Sprintf( - "%s: Update %s to version %d => %s", - p.Dashboard.Title, - p.Version.DashboardUID, - p.Version.ID, - p.Version.Message, - ) - } else { - commitmsg = fmt.Sprintf( - "%s: Update %s to version %d", - p.Dashboard.Title, - p.Version.DashboardUID, - p.Version.ID, - ) - } - - p.UpdateAuthor(p.Version.Created) - p.UpdateCommitter() - - return commitDashboard( - p.Repository, - p.Content, - commitmsg, - p.Dashboard.Title, - p.DashboardInfo.FolderTitle, - p.Directory, - p.KeyFile, - p.Author, - p.Committer, - ) -} - -func (p *Payload) PushToRemote(user, password string) error { - origin, err := p.Repository.Remote("origin") - if err != nil { - return err - } - - return origin.Push( - &git.PushOptions{ - Auth: genAuth(user, password), - Progress: os.Stdout, - }, - ) -} diff --git a/pkg/git/project.go b/pkg/git/project.go new file mode 100644 index 0000000..21d9bb7 --- /dev/null +++ b/pkg/git/project.go @@ -0,0 +1,251 @@ +package git + +import ( + "context" + "errors" + "io" + "path/filepath" + "strings" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage/memory" +) + +type ProjectOption func(*Project) + +func WithBasicAuth(user, pass string) ProjectOption { + return func(p *Project) { + p.auth = &http.BasicAuth{ + Username: user, + Password: pass, + } + } +} + +func WithBranch(branch string) ProjectOption { + return func(p *Project) { + p.Branch = branch + } +} + +func WithOutputWriter(o io.Writer) ProjectOption { + return func(p *Project) { + p.writer = o + } +} + +type Project struct { + Branch string + Force bool + RepoURL string + CommitLogs map[string]*object.Commit + + auth transport.AuthMethod + fs billy.Filesystem + storer *memory.Storage + repository *git.Repository + worktree *git.Worktree + writer io.Writer +} + +func NewProject(url string, options ...ProjectOption) *Project { + project := &Project{ + RepoURL: url, + CommitLogs: make(map[string]*object.Commit), + fs: memfs.New(), + storer: memory.NewStorage(), + repository: nil, + } + + for _, option := range options { + option(project) + } + + return project +} + +func (p *Project) Checkout() error { + branchRef := plumbing.NewBranchReferenceName(p.Branch) + + _, err := p.repository.Reference(branchRef, true) + if errors.Is(err, plumbing.ErrReferenceNotFound) { + var headRef *plumbing.Reference + headRef, err = p.repository.Head() + if err != nil { + return err + } + + ref := plumbing.NewHashReference(branchRef, headRef.Hash()) + if err = p.repository.Storer.SetReference(ref); err != nil { + return err + } + } else if err != nil { + return err + } + + p.worktree, err = p.repository.Worktree() + if err != nil { + return err + } + + checkoutOpts := git.CheckoutOptions{ + Branch: branchRef, + Create: false, + } + + if err = checkoutOpts.Validate(); err != nil { + return err + } + + return p.worktree.Checkout(&checkoutOpts) +} + +func (p *Project) Clone(ctx context.Context) error { + cloneOpts := git.CloneOptions{ + URL: p.RepoURL, + RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, + } + + if p.auth != nil { + cloneOpts.Auth = p.auth + } + + if p.writer != nil { + cloneOpts.Progress = p.writer + } + + if err := cloneOpts.Validate(); err != nil { + return err + } + + var err error + p.repository, err = git.CloneContext( + ctx, + p.storer, + p.fs, + &cloneOpts, + ) + if err != nil { + return err + } + + return nil +} + +func (p *Project) LoadLogs() error { + commitIter, err := p.repository.Log(&git.LogOptions{}) + if err != nil { + return err + } + + return commitIter.ForEach(func(c *object.Commit) error { + p.CommitLogs[c.Message] = c + return nil + }) +} + +func (p *Project) CommitExists(commitmsg string) bool { + if _, ok := p.CommitLogs[commitmsg]; ok { + return true + } + + return false +} + +func (p *Project) HasChanges() bool { + localBranchRef, err := p.repository.Head() + if err != nil { + return false + } + + remoteBranchRef, err := p.repository.Reference(plumbing.NewRemoteReferenceName("origin", p.Branch), true) + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return true + } else if err != nil { + return false + } + + return localBranchRef.Hash() != remoteBranchRef.Hash() +} + +func (p *Project) Pull(ctx context.Context) error { + pullOpts := git.PullOptions{ + ReferenceName: plumbing.NewBranchReferenceName(p.Branch), + } + + if p.writer != nil { + pullOpts.Progress = p.writer + } + + if err := pullOpts.Validate(); err != nil { + return err + } + + err := p.worktree.PullContext(ctx, &pullOpts) + if !errors.Is(err, plumbing.ErrReferenceNotFound) && + !errors.Is(err, git.NoErrAlreadyUpToDate) && + err != nil { + return err + } + + return nil +} + +func (p *Project) Push(ctx context.Context) error { + pushOpts := git.PushOptions{ + RemoteName: "origin", + } + + if p.auth != nil { + pushOpts.Auth = p.auth + } + + if p.writer != nil { + pushOpts.Progress = p.writer + } + + if err := pushOpts.Validate(); err != nil { + return err + } + + return p.repository.PushContext(ctx, &pushOpts) +} + +func (p *Project) ListJSONFiles(directory string) ([]string, error) { + files, err := p.fs.ReadDir(directory) + if err != nil { + return nil, err + } + + var allFiles []string + for _, file := range files { + if file.IsDir() { + var childFiles []string + childFiles, err = p.ListJSONFiles(filepath.Join(directory, file.Name())) + if err != nil { + return nil, err + } + allFiles = append(allFiles, childFiles...) + } else if strings.HasSuffix(file.Name(), ".json") { + allFiles = append(allFiles, filepath.Join(directory, file.Name())) + } + } + + return allFiles, nil +} + +func (p *Project) ReadFile(filepath string) ([]byte, error) { + file, err := p.fs.Open(filepath) + if err != nil { + return nil, err + } + defer file.Close() + + return io.ReadAll(file) +} diff --git a/pkg/git/signer.go b/pkg/git/sign_key.go similarity index 52% rename from pkg/git/signer.go rename to pkg/git/sign_key.go index 1834d06..5ac350b 100644 --- a/pkg/git/signer.go +++ b/pkg/git/sign_key.go @@ -7,22 +7,29 @@ import ( "github.com/ProtonMail/go-crypto/openpgp/armor" ) -func getSigner(keyFile string) (*openpgp.Entity, error) { - file, err := os.Open(keyFile) +type SignKey struct { + KeyFile string + + entity *openpgp.Entity +} + +func (s *SignKey) ReadKeyFile() error { + file, err := os.Open(s.KeyFile) if err != nil { - return nil, err + return err } defer file.Close() block, err := armor.Decode(file) if err != nil { - return nil, err + return err } entityList, err := openpgp.ReadKeyRing(block.Body) - if err != nil { - return nil, err + if err != nil || len(entityList) < 1 { + return err } - return entityList[0], nil + s.entity = entityList[0] + return nil } diff --git a/pkg/grafana/client.go b/pkg/grafana/client.go new file mode 100644 index 0000000..ee4ab86 --- /dev/null +++ b/pkg/grafana/client.go @@ -0,0 +1,143 @@ +package grafana + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana/schema" + "golang.org/x/net/http/httpguts" +) + +const UserAgent = "grafana-backuper" + +type Client struct { + endpoint string + token string + tokenValid bool + userAgent string + httpClient *http.Client + + Dashboard DashboardClient + DashboardVersion DashboardVersionClient + File SearchClient +} + +type ClientOption func(*Client) + +func WithToken(token string) ClientOption { + return func(client *Client) { + client.token = token + client.tokenValid = httpguts.ValidHeaderFieldName(token) + } +} + +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(client *Client) { + client.httpClient = httpClient + } +} + +func WithUserAgent(userAgent string) ClientOption { + return func(client *Client) { + client.userAgent = userAgent + } +} + +func NewClient(endpoint string, options ...ClientOption) *Client { + client := &Client{ + endpoint: endpoint, + tokenValid: true, + httpClient: &http.Client{}, + userAgent: UserAgent, + } + + for _, option := range options { + option(client) + } + + client.Dashboard = DashboardClient{client: client} + client.DashboardVersion = DashboardVersionClient{client: client} + client.File = SearchClient{client: client} + + return client +} + +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + url := fmt.Sprintf("%s%s", c.endpoint, path) + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.userAgent) + + if !c.tokenValid { + return nil, errors.New("authorization token contains invalid characters") + } + + if c.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req.WithContext(ctx), nil +} + +type Response struct { + *http.Response + ErrorResponse *ErrorResponse + + body []byte +} + +type ErrorResponse struct { + Message string + TraceID uint +} + +func (c *Client) do(req *http.Request, v any) (*Response, error) { + httpResp, err := c.httpClient.Do(req) + resp := &Response{Response: httpResp} + if err != nil { + return resp, err + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return resp, err + } + resp.body = body + + resp.Body = io.NopCloser(bytes.NewReader(body)) + + if resp.StatusCode != http.StatusOK { + var errorResponse schema.ErrorResponse + if err = json.Unmarshal(resp.body, &errorResponse); err == nil { + return resp, fmt.Errorf( + "grafana: got error with status code %d: %s", + resp.StatusCode, + ErrorResponseFromSchema(errorResponse).Message, + ) + } + return resp, fmt.Errorf("grafana: server responded with an unexpected status code %d", resp.StatusCode) + } + + if v != nil { + if writer, ok := v.(io.Writer); ok { + _, err = io.Copy(writer, bytes.NewReader(resp.body)) + } else { + err = json.Unmarshal(resp.body, v) + } + } + + return resp, err +} diff --git a/pkg/grafana/dashboard.go b/pkg/grafana/dashboard.go index d501ad7..666be85 100644 --- a/pkg/grafana/dashboard.go +++ b/pkg/grafana/dashboard.go @@ -4,134 +4,147 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" - "net/url" "time" + + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana/schema" ) -type BoardProperties struct { - IsStarred bool `json:"isStarred,omitempty"` - IsHome bool `json:"isHome,omitempty"` - IsSnapshot bool `json:"isSnapshot,omitempty"` - Type string `json:"type,omitempty"` - CanSave bool `json:"canSave"` - CanEdit bool `json:"canEdit"` - CanStar bool `json:"canStar"` - Slug string `json:"slug"` - Expires time.Time `json:"expires"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - UpdatedBy string `json:"updatedBy"` - CreatedBy string `json:"createdBy"` - Version int `json:"version"` - FolderID int `json:"folderId"` - FolderTitle string `json:"folderTitle"` - FolderURL string `json:"folderUrl"` +type Dashboard struct { + IsStarred bool + Type string + CanSave bool + CanEdit bool + CanAdmin bool + CanStar bool + CanDelete bool + Slug string + URL string + Expires time.Time + Created time.Time + Updated time.Time + UpdatedBy string + CreatedBy string + Version uint + HasACL bool + IsFolder bool + FolderID uint + FolderUID string + FolderTitle string + FolderURL string + Provisioned bool + ProvisionedExternalID string + AnnotationsPermissions AnnotationsPermissions + Dashboard any } -type DashboardVersion struct { - ID uint `json:"id"` - DashboardID uint `json:"dashboardId"` - DashboardUID string `json:"uid"` - ParentVersion uint `json:"parentVersion"` - RestoredFrom uint `json:"restoredFrom"` - Version uint `json:"version"` - Created time.Time `json:"created"` - CreatedBy string `json:"createdBy"` - Message string `json:"message"` +type AnnotationsPermissions struct { + Dashboard AnnotationPermissions + Organization AnnotationPermissions } -func (c *Client) getRawDashboardByUID(ctx context.Context, path string) ([]byte, BoardProperties, error) { - raw, code, err := c.get(ctx, fmt.Sprintf("api/dashboards/%s", path), nil) +type AnnotationPermissions struct { + CanAdd bool + CanEdit bool + CanDelete bool +} + +type DashboardClient struct { + client *Client +} + +func (c *DashboardClient) Get(ctx context.Context, uid string) (*Dashboard, *Response, error) { + req, err := c.client.newRequest(ctx, "GET", fmt.Sprintf("/dashboards/uid/%s", uid), nil) if err != nil { - return nil, BoardProperties{}, err + return nil, nil, err } - if code != 200 { - return raw, BoardProperties{}, fmt.Errorf("HTTP error %d: returns %s", code, raw) - } - var result struct { - Meta BoardProperties `json:"meta"` - } - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - if err := dec.Decode(&result); err != nil { - return raw, BoardProperties{}, fmt.Errorf("failed unmarshalling dashboard from path %s: %v", path, err) - } - return raw, result.Meta, nil -} -func (c *Client) getRawDashboardFromVersion(ctx context.Context, path string) ([]byte, DashboardVersion, error) { - var versionInfo DashboardVersion - raw, code, err := c.get(ctx, fmt.Sprintf("api/dashboards/%s", path), nil) + var body schema.Dashboard + + resp, err := c.client.do(req, &body) if err != nil { - return nil, versionInfo, err + return nil, resp, err } - if code != 200 { - return raw, versionInfo, fmt.Errorf("HTTP error %d: returns %s", code, raw) - } - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - if err := dec.Decode(&versionInfo); err != nil { - return raw, versionInfo, fmt.Errorf("failed unmarshalling dashboard from path %s: %v", path, err) - } - return raw, versionInfo, nil + + return DashboardFromSchema(body), resp, nil } -func queryParams(params ...QueryParam) url.Values { - u := url.URL{} - q := u.Query() - for _, p := range params { - p(&q) - } - return q +type DashboardCreateOpts struct { + Dashboard any + FolderID uint + FolderUID string + Message string + Overwrite bool } -func (c *Client) GetDashboardVersionsByDashboardUID(ctx context.Context, uid string, params ...QueryParam) ([]DashboardVersion, error) { - var ( - raw []byte - code int - err error - ) +func (o DashboardCreateOpts) Validate() error { + if o.Dashboard == nil { + return errors.New("dashboard is nil") + } + return nil +} - if raw, code, err = c.get(ctx, fmt.Sprintf("api/dashboards/uid/%s/versions", uid), queryParams(params...)); err != nil { +type DashboardCreateResponse struct { + DashboardID uint + DashboardUID string + URL string + Status string + Version uint + Slug string +} + +func (c *DashboardClient) Create( + ctx context.Context, + opts DashboardCreateOpts, +) (*DashboardCreateResponse, *Response, error) { + if err := opts.Validate(); err != nil { + return nil, nil, err + } + + var reqBody schema.DashboardCreateRequest + reqBody.Dashboard = opts.Dashboard + reqBody.Overwrite = opts.Overwrite + reqBody.Message = opts.Message + if opts.FolderUID != "" { + reqBody.FolderUID = opts.FolderUID + } else if opts.FolderID > 0 { + reqBody.FolderID = opts.FolderID + } + + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, err + } + + req, err := c.client.newRequest(ctx, "POST", "/dashboards/db", bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + var respBody schema.DashboardCreateResponse + resp, err := c.client.do(req, &respBody) + if err != nil { + return nil, resp, err + } + + return DashboardCreateResponseFromSchema(respBody), resp, nil +} + +func (c *DashboardClient) Delete(ctx context.Context, uid string) (*Response, error) { + req, err := c.client.newRequest(ctx, "DELETE", fmt.Sprintf("/dashboards/uid/%s", uid), nil) + if err != nil { return nil, err } - if code != 200 { - return nil, fmt.Errorf("HTTP error %d: returns %s", code, raw) - } - var versions []DashboardVersion - err = json.Unmarshal(raw, &versions) - return versions, err + return c.client.do(req, nil) } -func (c *Client) GetRawDashboardByUID(ctx context.Context, uid string) ([]byte, BoardProperties, error) { - return c.getRawDashboardByUID(ctx, "uid/"+uid) -} - -func (c *Client) GetRawDashboardByUIDAndVersion(ctx context.Context, uid string, version uint) ([]byte, DashboardVersion, error) { - return c.getRawDashboardFromVersion(ctx, "uid/"+uid+"/versions/"+fmt.Sprint(version)) -} - -func (c *Client) SearchDashboards(ctx context.Context, query string, starred bool, tags ...string) ([]FoundBoard, error) { - params := []SearchParam{ - SearchType(SearchTypeDashboard), - SearchQuery(query), - SearchStarred(starred), - } - for _, tag := range tags { - params = append(params, SearchTag(tag)) - } - return c.Search(ctx, params...) -} - -func ConvertRawToIndent(raw []byte) (string, error) { - var buf bytes.Buffer - - err := json.Indent(&buf, raw, "", " ") - if err != nil { - return "", fmt.Errorf("error pritty-printing raw json string: %v", err) +func (c *DashboardClient) ParseRaw(raw []byte) (*Dashboard, error) { + var body schema.Dashboard + if err := json.Unmarshal(raw, &body); err != nil { + return nil, err } - return buf.String(), nil + return DashboardFromSchema(body), nil } diff --git a/pkg/grafana/dashboard_version.go b/pkg/grafana/dashboard_version.go new file mode 100644 index 0000000..fa0404f --- /dev/null +++ b/pkg/grafana/dashboard_version.go @@ -0,0 +1,183 @@ +package grafana + +import ( + "context" + "fmt" + "net/url" + "strconv" + "time" + + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana/schema" +) + +type DashboardVersionParam func(*url.Values) + +func WithLimit(limit uint) DashboardVersionParam { + return func(v *url.Values) { + v.Set("limit", strconv.FormatUint(uint64(limit), 10)) + } +} + +func WithStart(start uint) DashboardVersionParam { + return func(v *url.Values) { + v.Set("start", strconv.FormatUint(uint64(start), 10)) + } +} + +type DashboardVersion struct { + ID uint + DashboardID uint + DashboardUID string + ParentVersion uint + RestoredFrom uint + Version uint + Created time.Time + CreatedBy string + Message string + Data any +} + +type DashboardVersionClient struct { + client *Client +} + +func (c *DashboardVersionClient) GetByID( + ctx context.Context, + id, version uint, + params ...DashboardVersionParam, +) (*DashboardVersion, *Response, error) { + dashboardVersionURL, err := url.Parse(fmt.Sprintf("/dashboards/id/%d/versions/%d", id, version)) + if err != nil { + return nil, nil, err + } + + return c.get(ctx, dashboardVersionURL, params...) +} + +func (c *DashboardVersionClient) GetByUID( + ctx context.Context, + uid string, + version uint, + params ...DashboardVersionParam, +) (*DashboardVersion, *Response, error) { + dashboardVersionURL, err := url.Parse(fmt.Sprintf("/dashboards/uid/%s/versions/%d", uid, version)) + if err != nil { + return nil, nil, err + } + + return c.get(ctx, dashboardVersionURL, params...) +} + +func (c *DashboardVersionClient) Get( + ctx context.Context, + input string, + version uint, + params ...DashboardVersionParam, +) (*DashboardVersion, *Response, error) { + if id, err := strconv.Atoi(input); err == nil { + return c.GetByID(ctx, uint(id), version, params...) + } + return c.GetByUID(ctx, input, version, params...) +} + +func (c *DashboardVersionClient) ListByID( + ctx context.Context, + id uint, + params ...DashboardVersionParam, +) ([]*DashboardVersion, *Response, error) { + dashboardVersionURL, err := url.Parse(fmt.Sprintf("/dashboards/id/%d/versions", id)) + if err != nil { + return nil, nil, err + } + + return c.list(ctx, dashboardVersionURL, params...) +} + +func (c *DashboardVersionClient) ListByUID( + ctx context.Context, + uid string, + params ...DashboardVersionParam, +) ([]*DashboardVersion, *Response, error) { + dashboardVersionURL, err := url.Parse(fmt.Sprintf("/dashboards/uid/%s/versions", uid)) + if err != nil { + return nil, nil, err + } + + return c.list(ctx, dashboardVersionURL, params...) +} + +func (c *DashboardVersionClient) List( + ctx context.Context, + input string, + params ...DashboardVersionParam, +) ([]*DashboardVersion, *Response, error) { + if id, err := strconv.Atoi(input); err == nil { + return c.ListByID(ctx, uint(id), params...) + } + return c.ListByUID(ctx, input, params...) +} + +func (c *DashboardVersionClient) get( + ctx context.Context, + dashboardVersionURL *url.URL, + params ...DashboardVersionParam, +) (*DashboardVersion, *Response, error) { + if len(params) > 0 { + query := dashboardVersionURL.Query() + + for _, param := range params { + param(&query) + } + + dashboardVersionURL.RawQuery = query.Encode() + } + + req, err := c.client.newRequest(ctx, "GET", dashboardVersionURL.String(), nil) + if err != nil { + return nil, nil, err + } + + var body schema.DashboardVersion + + resp, err := c.client.do(req, &body) + if err != nil { + return nil, resp, err + } + + return DashboardVersionFromSchema(body), resp, nil +} + +func (c *DashboardVersionClient) list( + ctx context.Context, + dashboardVersionURL *url.URL, + params ...DashboardVersionParam, +) ([]*DashboardVersion, *Response, error) { + if len(params) > 0 { + query := dashboardVersionURL.Query() + + for _, param := range params { + param(&query) + } + + dashboardVersionURL.RawQuery = query.Encode() + } + + req, err := c.client.newRequest(ctx, "GET", dashboardVersionURL.String(), nil) + if err != nil { + return nil, nil, err + } + + var body schema.DashboardVersionListResponse + + resp, err := c.client.do(req, &body) + if err != nil { + return nil, resp, err + } + + dashboardVersions := make([]*DashboardVersion, 0, len(body)) + for _, dashboardVersion := range body { + dashboardVersions = append(dashboardVersions, DashboardVersionFromSchema(dashboardVersion)) + } + + return dashboardVersions, resp, nil +} diff --git a/pkg/grafana/requests.go b/pkg/grafana/requests.go deleted file mode 100644 index d9f9c42..0000000 --- a/pkg/grafana/requests.go +++ /dev/null @@ -1,75 +0,0 @@ -package grafana - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "path" - "strings" -) - -var DefaultHTTPClient = http.DefaultClient - -type Client struct { - baseURL string - key string - basicAuth bool - client *http.Client -} - -func NewClient(apiURL, authString string, client *http.Client) (*Client, error) { - basicAuth := strings.Contains(authString, ":") - baseURL, err := url.Parse(apiURL) - if err != nil { - return nil, err - } - - var key string - if len(authString) > 0 { - if !basicAuth { - key = fmt.Sprintf("Bearer %s", authString) - } else { - parts := strings.SplitN(authString, ":", 2) - baseURL.User = url.UserPassword(parts[0], parts[1]) - } - } - - return &Client{ - baseURL: baseURL.String(), - basicAuth: basicAuth, - key: key, - client: client, - }, nil -} - -func (c *Client) doRequest(ctx context.Context, method, query string, params url.Values, buf io.Reader) ([]byte, int, error) { - u, _ := url.Parse(c.baseURL) - u.Path = path.Join(u.Path, query) - if params != nil { - u.RawQuery = params.Encode() - } - req, err := http.NewRequest(method, u.String(), buf) - if err != nil { - return nil, 0, err - } - req = req.WithContext(ctx) - if !c.basicAuth && len(c.key) > 0 { - req.Header.Set("Authorization", c.key) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "grafana-backuper") - resp, err := c.client.Do(req) - if err != nil { - return nil, 0, err - } - data, err := io.ReadAll(resp.Body) - resp.Body.Close() - return data, resp.StatusCode, err -} - -func (c *Client) get(ctx context.Context, query string, params url.Values) ([]byte, int, error) { - return c.doRequest(ctx, "GET", query, params, nil) -} diff --git a/pkg/grafana/schema.go b/pkg/grafana/schema.go new file mode 100644 index 0000000..afed52c --- /dev/null +++ b/pkg/grafana/schema.go @@ -0,0 +1,141 @@ +package grafana + +import ( + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana/schema" +) + +func DashboardCreateResponseFromSchema(source schema.DashboardCreateResponse) *DashboardCreateResponse { + return &DashboardCreateResponse{ + DashboardID: source.DashboardID, + DashboardUID: source.DashboardUID, + URL: source.URL, + Status: source.Status, + Version: source.Version, + Slug: source.Slug, + } +} + +func DashboardFromSchema(source schema.Dashboard) *Dashboard { + return &Dashboard{ + IsStarred: source.Meta.IsStarred, + Type: source.Meta.Type, + CanSave: source.Meta.CanSave, + CanEdit: source.Meta.CanEdit, + CanAdmin: source.Meta.CanAdmin, + CanStar: source.Meta.CanStar, + CanDelete: source.Meta.CanDelete, + Slug: source.Meta.Slug, + URL: source.Meta.URL, + Expires: source.Meta.Expires, + Created: source.Meta.Created, + Updated: source.Meta.Updated, + UpdatedBy: source.Meta.UpdatedBy, + CreatedBy: source.Meta.CreatedBy, + Version: source.Meta.Version, + HasACL: source.Meta.HasACL, + IsFolder: source.Meta.IsFolder, + FolderID: source.Meta.FolderID, + FolderUID: source.Meta.FolderUID, + FolderTitle: source.Meta.FolderTitle, + FolderURL: source.Meta.FolderURL, + Provisioned: source.Meta.Provisioned, + ProvisionedExternalID: source.Meta.ProvisionedExternalID, + AnnotationsPermissions: AnnotationsPermissions{ + Dashboard: AnnotationPermissions{ + CanAdd: source.Meta.AnnotationsPermissions.Dashboard.CanAdd, + CanEdit: source.Meta.AnnotationsPermissions.Dashboard.CanEdit, + CanDelete: source.Meta.AnnotationsPermissions.Dashboard.CanDelete, + }, + Organization: AnnotationPermissions{ + CanAdd: source.Meta.AnnotationsPermissions.Organization.CanAdd, + CanEdit: source.Meta.AnnotationsPermissions.Organization.CanEdit, + CanDelete: source.Meta.AnnotationsPermissions.Organization.CanDelete, + }, + }, + Dashboard: source.Dashboard, + } +} + +func DashboardVersionFromSchema(source schema.DashboardVersion) *DashboardVersion { + return &DashboardVersion{ + ID: source.ID, + DashboardID: source.DashboardID, + DashboardUID: source.UID, + ParentVersion: source.ParentVersion, + RestoredFrom: source.RestoredFrom, + Version: source.Version, + Created: source.Created, + CreatedBy: source.CreatedBy, + Message: source.Message, + Data: source.Data, + } +} + +func ErrorResponseFromSchema(source schema.ErrorResponse) *ErrorResponse { + return &ErrorResponse{ + Message: source.Message, + TraceID: source.TraceID, + } +} + +func SearchResultFromSchema(source schema.SearchResult) *SearchResult { + return &SearchResult{ + ID: source.ID, + UID: source.UID, + Title: source.Title, + URI: source.URI, + URL: source.URL, + Slug: source.Slug, + Type: source.Type, + Tags: source.Tags, + IsStarred: source.IsStarred, + SortMeta: source.SortMeta, + FolderID: source.FolderID, + FolderUID: source.FolderUID, + FolderTitle: source.FolderTitle, + FolderURL: source.FolderURL, + } +} + +func SchemaFromDashboardMeta(dm *Dashboard) schema.Dashboard { + return schema.Dashboard{ + Meta: schema.DashboardMeta{ + IsStarred: dm.IsStarred, + Type: dm.Type, + CanSave: dm.CanSave, + CanEdit: dm.CanEdit, + CanAdmin: dm.CanAdmin, + CanStar: dm.CanStar, + CanDelete: dm.CanDelete, + Slug: dm.Slug, + URL: dm.URL, + Expires: dm.Expires, + Created: dm.Created, + Updated: dm.Updated, + UpdatedBy: dm.UpdatedBy, + CreatedBy: dm.CreatedBy, + Version: dm.Version, + HasACL: dm.HasACL, + IsFolder: dm.IsFolder, + FolderID: dm.FolderID, + FolderUID: dm.FolderUID, + FolderTitle: dm.FolderTitle, + FolderURL: dm.FolderURL, + Provisioned: dm.Provisioned, + ProvisionedExternalID: dm.ProvisionedExternalID, + AnnotationsPermissions: schema.AnnotationsPermissions{ + Dashboard: schema.AnnotationPermissions{ + CanAdd: dm.AnnotationsPermissions.Dashboard.CanAdd, + CanEdit: dm.AnnotationsPermissions.Dashboard.CanEdit, + CanDelete: dm.AnnotationsPermissions.Dashboard.CanDelete, + }, + Organization: schema.AnnotationPermissions{ + CanAdd: dm.AnnotationsPermissions.Organization.CanAdd, + CanEdit: dm.AnnotationsPermissions.Organization.CanEdit, + CanDelete: dm.AnnotationsPermissions.Organization.CanDelete, + }, + }, + }, + Dashboard: dm.Dashboard, + } +} diff --git a/pkg/grafana/schema/dashboard.go b/pkg/grafana/schema/dashboard.go new file mode 100644 index 0000000..c6207fb --- /dev/null +++ b/pkg/grafana/schema/dashboard.go @@ -0,0 +1,63 @@ +package schema + +import "time" + +type DashboardCreateRequest struct { + Dashboard any `json:"dashboard"` + FolderID uint `json:"folderId,omitempty"` + FolderUID string `json:"folderUid"` + Message string `json:"message,omitempty"` + Overwrite bool `json:"overwrite"` +} + +type DashboardCreateResponse struct { + DashboardID uint `json:"id"` + DashboardUID string `json:"uid"` + URL string `json:"url"` + Status string `json:"status"` + Version uint `json:"version"` + Slug string `json:"slug"` +} + +type Dashboard struct { + Meta DashboardMeta `json:"meta"` + Dashboard any `json:"dashboard"` +} + +type DashboardMeta struct { + IsStarred bool `json:"isStarred"` + Type string `json:"type"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanStar bool `json:"canStar"` + CanDelete bool `json:"canDelete"` + Slug string `json:"slug"` + URL string `json:"url"` + Expires time.Time `json:"expires"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + UpdatedBy string `json:"updatedBy"` + CreatedBy string `json:"createdBy"` + Version uint `json:"version"` + HasACL bool `json:"hasAcl"` + IsFolder bool `json:"isFolder"` + FolderID uint `json:"folderId"` + FolderUID string `json:"folderUid"` + FolderTitle string `json:"folderTitle"` + FolderURL string `json:"folderUrl"` + Provisioned bool `json:"provisioned"` + ProvisionedExternalID string `json:"provisionedExternalId"` + AnnotationsPermissions AnnotationsPermissions `json:"annotationsPermissions"` +} + +type AnnotationsPermissions struct { + Dashboard AnnotationPermissions `json:"dashboard"` + Organization AnnotationPermissions `json:"organization"` +} + +type AnnotationPermissions struct { + CanAdd bool `json:"canAdd"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} diff --git a/pkg/grafana/schema/dashboard_version.go b/pkg/grafana/schema/dashboard_version.go new file mode 100644 index 0000000..45f2643 --- /dev/null +++ b/pkg/grafana/schema/dashboard_version.go @@ -0,0 +1,18 @@ +package schema + +import "time" + +type DashboardVersion struct { + ID uint `json:"id"` + DashboardID uint `json:"dashboardId"` + UID string `json:"uid"` + ParentVersion uint `json:"parentVersion"` + RestoredFrom uint `json:"restoredFrom"` + Version uint `json:"version"` + Created time.Time `json:"created"` + CreatedBy string `json:"createdBy"` + Message string `json:"message"` + Data any `json:"data"` +} + +type DashboardVersionListResponse []DashboardVersion diff --git a/pkg/grafana/schema/error.go b/pkg/grafana/schema/error.go new file mode 100644 index 0000000..f3f06a0 --- /dev/null +++ b/pkg/grafana/schema/error.go @@ -0,0 +1,6 @@ +package schema + +type ErrorResponse struct { + Message string `json:"message"` + TraceID uint `json:"traceid"` +} diff --git a/pkg/grafana/schema/search.go b/pkg/grafana/schema/search.go new file mode 100644 index 0000000..eef07e2 --- /dev/null +++ b/pkg/grafana/schema/search.go @@ -0,0 +1,20 @@ +package schema + +type SearchResult struct { + ID uint `json:"id"` + UID string `json:"uid"` + Title string `json:"title"` + URI string `json:"uri"` + URL string `json:"url"` + Slug string `json:"slug"` + Type string `json:"type"` + Tags []string `json:"tags"` + IsStarred bool `json:"isStarred"` + SortMeta uint `json:"sortMeta"` + FolderID int `json:"folderId"` + FolderUID string `json:"folderUid"` + FolderTitle string `json:"folderTitle"` + FolderURL string `json:"folderUrl"` +} + +type SearchResultListResponse []SearchResult diff --git a/pkg/grafana/search.go b/pkg/grafana/search.go index 94dd1ea..16912c2 100644 --- a/pkg/grafana/search.go +++ b/pkg/grafana/search.go @@ -2,65 +2,30 @@ package grafana import ( "context" - "encoding/json" - "fmt" "net/url" "strconv" + + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana/schema" +) + +type ( + SearchParam func(*url.Values) + + SearchType string ) const ( - SearchTypeFolder SearchParamType = "dash-folder" - SearchTypeDashboard SearchParamType = "dash-db" + SearchTypeFolder SearchType = "dash-folder" + SearchTypeDashboard SearchType = "dash-db" ) -type FoundBoard struct { - ID uint `json:"id"` - UID string `json:"uid"` - Title string `json:"title"` - URI string `json:"uri"` - URL string `json:"url"` - Slug string `json:"slug"` - Type string `json:"type"` - Tags []string `json:"tags"` - IsStarred bool `json:"isStarred"` - FolderID int `json:"folderId"` - FolderUID string `json:"folderUid"` - FolderTitle string `json:"folderTitle"` - FolderURL string `json:"folderUrl"` +func WithType(searchType SearchType) SearchParam { + return func(v *url.Values) { + v.Set("type", string(searchType)) + } } -type ( - // SearchParam is a type for specifying Search params. - SearchParam func(*url.Values) - // SearchParamType is a type accepted by SearchType func. - SearchParamType string - // QueryParam is a type for specifying arbitrary API parameters - QueryParam func(*url.Values) -) - -func (c *Client) Search(ctx context.Context, params ...SearchParam) ([]FoundBoard, error) { - var ( - raw []byte - boards []FoundBoard - code int - err error - ) - u := url.URL{} - q := u.Query() - for _, p := range params { - p(&q) - } - if raw, code, err = c.get(ctx, "api/search", q); err != nil { - return nil, err - } - if code != 200 { - return nil, fmt.Errorf("HTTP error %d: returns %s", code, raw) - } - err = json.Unmarshal(raw, &boards) - return boards, err -} - -func SearchQuery(query string) SearchParam { +func WithQuery(query string) SearchParam { return func(v *url.Values) { if query != "" { v.Set("query", query) @@ -68,13 +33,13 @@ func SearchQuery(query string) SearchParam { } } -func SearchStarred(starred bool) SearchParam { +func WithStarred() SearchParam { return func(v *url.Values) { - v.Set("starred", strconv.FormatBool(starred)) + v.Set("starred", strconv.FormatBool(true)) } } -func SearchTag(tag string) SearchParam { +func WithTag(tag string) SearchParam { return func(v *url.Values) { if tag != "" { v.Add("tag", tag) @@ -82,8 +47,59 @@ func SearchTag(tag string) SearchParam { } } -func SearchType(searchType SearchParamType) SearchParam { - return func(v *url.Values) { - v.Set("type", string(searchType)) - } +type SearchResult struct { + ID uint + UID string + Title string + URI string + URL string + Slug string + Type string + Tags []string + IsStarred bool + SortMeta uint + FolderID int + FolderUID string + FolderTitle string + FolderURL string +} + +type SearchClient struct { + client *Client +} + +func (c *SearchClient) Search(ctx context.Context, params ...SearchParam) ([]*SearchResult, *Response, error) { + searchURL, err := url.Parse("/search") + if err != nil { + return nil, nil, err + } + + if len(params) > 0 { + query := searchURL.Query() + + for _, param := range params { + param(&query) + } + + searchURL.RawQuery = query.Encode() + } + + req, err := c.client.newRequest(ctx, "GET", searchURL.String(), nil) + if err != nil { + return nil, nil, err + } + + var body schema.SearchResultListResponse + + resp, err := c.client.do(req, &body) + if err != nil { + return nil, resp, err + } + + searchResults := make([]*SearchResult, 0, len(body)) + for _, searchResult := range body { + searchResults = append(searchResults, SearchResultFromSchema(searchResult)) + } + + return searchResults, resp, nil } diff --git a/pkg/grafanabackuper/backup.go b/pkg/grafanabackuper/backup.go new file mode 100644 index 0000000..67fc63b --- /dev/null +++ b/pkg/grafanabackuper/backup.go @@ -0,0 +1,159 @@ +package grafanabackuper + +import ( + "context" + "encoding/json" + "errors" + "slices" + + "git.ar21.de/yolokube/grafana-backuper/internal/config" + "git.ar21.de/yolokube/grafana-backuper/pkg/git" + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana" +) + +var ErrAlreadyCommited = errors.New("already committed") + +type BackupClient struct { + client *Client +} + +func (b *BackupClient) Start(ctx context.Context, cfg *config.Config) error { + b.client.logger.Debug().Str("search-type", string(grafana.SearchTypeDashboard)).Msg("Searching dashboards by type") + dashboards, _, err := b.client.grafana.File.Search(ctx, grafana.WithType(grafana.SearchTypeDashboard)) + if err != nil { + return err + } + b.client.logger.Debug(). + Str("search-type", string(grafana.SearchTypeDashboard)). + Int("counter", len(dashboards)). + Msg("Found dashboards") + + for _, dashboardInfo := range dashboards { + b.client.logger.Debug(). + Str("dashboard-uid", dashboardInfo.UID). + Str("dashboard", dashboardInfo.Title). + Msg("Fetching dashboard data") + var dashboard *grafana.Dashboard + dashboard, _, err = b.client.grafana.Dashboard.Get(ctx, dashboardInfo.UID) + if err != nil { + return err + } + + b.client.logger.Debug(). + Str("dashboard-uid", dashboardInfo.UID). + Str("dashboard", dashboardInfo.Title). + Msg("Fetching dashboard versions") + var versions []*grafana.DashboardVersion + versions, _, err = b.client.grafana.DashboardVersion.List(ctx, dashboardInfo.UID) + if err != nil { + return err + } + b.client.logger.Debug(). + Str("dashboard-uid", dashboardInfo.UID). + Str("dashboard", dashboardInfo.Title). + Int("counter", len(versions)). + Msg("Found dashboard versions") + + slices.Reverse(versions) + + uncommitedVersion := cfg.ForceCommits + for _, version := range versions { + b.client.logger.Debug(). + Str("dashboard-uid", dashboardInfo.UID). + Uint("version", version.Version). + Msg("Fetching version data") + var dashboardVersion *grafana.DashboardVersion + dashboardVersion, _, err = b.client.grafana.DashboardVersion.Get(ctx, dashboardInfo.UID, version.Version) + if err != nil { + return err + } + + var commitmsg string + commitmsg, err = b.commitDashboardVersion( + dashboard, + dashboardVersion, + dashboardInfo, + cfg, + uncommitedVersion, + ) + if errors.Is(err, ErrAlreadyCommited) { + b.client.logger.Info().Str("commit-msg", commitmsg).Msg("Already committed") + continue + } else if err != nil { + return err + } + + uncommitedVersion = true + b.client.logger.Info().Str("commit-msg", commitmsg).Msg("Commit created") + } + } + + if !b.client.git.HasChanges() { + return nil + } + + return b.client.git.Push(ctx) +} + +func (b *BackupClient) commitDashboardVersion( + dashboard *grafana.Dashboard, + dashboardVersion *grafana.DashboardVersion, + dashboardInfo *grafana.SearchResult, + cfg *config.Config, + force bool, +) (string, error) { + commitmsg := Message{ + ID: dashboardVersion.ID, + Path: dashboardInfo.Title, + Title: dashboardVersion.Message, + UID: dashboardInfo.UID, + }.String() + + b.client.logger.Debug().Str("commit", commitmsg).Msg("Checking commit existence") + if !force && b.client.git.CommitExists(commitmsg) { + return commitmsg, ErrAlreadyCommited + } + + b.updateDashboardInfo(dashboard, dashboardVersion) + + b.client.logger.Debug(). + Str("folder", dashboard.FolderTitle). + Str("dashboard-uid", dashboardVersion.DashboardUID). + Msg("Marshalling the dashboard version data") + data, err := json.MarshalIndent(grafana.SchemaFromDashboardMeta(dashboard), "", " ") + if err != nil { + return commitmsg, err + } + + commitOpts := []git.CommitOption{ + git.WithAuthor(dashboardVersion.CreatedBy, ""), + git.WithFileContent(data, dashboardInfo.Title, dashboard.FolderTitle), + } + + if b.client.signer != nil { + commitOpts = append(commitOpts, git.WithSigner(*b.client.signer)) + } + + if cfg.GitUser != "" { + commitOpts = append(commitOpts, git.WithCommitter(cfg.GitUser, cfg.GitEmail)) + } + + commit := b.client.git.NewCommit(commitOpts...) + b.client.logger.Debug().Str("commit", commitmsg).Msg("Creating new commit") + return commitmsg, commit.Create(commitmsg) +} + +func (b *BackupClient) updateDashboardInfo(d *grafana.Dashboard, dv *grafana.DashboardVersion) { + b.client.logger.Debug(). + Str("dashboard-uid", dv.DashboardUID). + Str("folder", d.FolderTitle). + Msg("Updating dashboard information") + d.Dashboard = dv.Data + d.UpdatedBy = dv.CreatedBy + d.Updated = dv.Created + d.Version = dv.Version + b.client.logger.Debug(). + Str("dashboard-uid", dv.DashboardUID). + Str("folder", d.FolderTitle). + Msg("Updated dashboard information successfully") +} diff --git a/pkg/grafanabackuper/client.go b/pkg/grafanabackuper/client.go new file mode 100644 index 0000000..5fda218 --- /dev/null +++ b/pkg/grafanabackuper/client.go @@ -0,0 +1,86 @@ +package grafanabackuper + +import ( + "context" + + "git.ar21.de/yolokube/grafana-backuper/internal/config" + "git.ar21.de/yolokube/grafana-backuper/pkg/git" + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana" + "github.com/rs/zerolog" +) + +type ClientOption func(*Client) + +func WithZerologLogger(logger zerolog.Logger) ClientOption { + return func(c *Client) { + c.logger = logger + } +} + +type Client struct { + git *git.Project + grafana *grafana.Client + logger zerolog.Logger + signer *git.SignKey + + Backup BackupClient + Restore RestoreClient +} + +func NewClient(options ...ClientOption) *Client { + client := &Client{} + + for _, option := range options { + option(client) + } + + client.Backup = BackupClient{client: client} + client.Restore = RestoreClient{client: client} + + return client +} + +func (c *Client) Prepare(ctx context.Context, cfg *config.Config) error { + c.logger.Debug().Msg("Creating new Grafana Client") + c.grafana = grafana.NewClient( + cfg.GrafanaURL, + grafana.WithToken(cfg.GrafanaToken), + ) + c.logger.Debug().Msg("Created new Grafana Client successfully") + + c.git = git.NewProject( + cfg.GitRepo, + git.WithBasicAuth(cfg.GitUser, cfg.GitPass), + git.WithBranch(cfg.GitBranch), + git.WithOutputWriter(cfg.Output), + ) + + c.logger.Debug().Msg("Cloning git project") + if err := c.git.Clone(ctx); err != nil { + return err + } + + c.logger.Debug().Msg("Checking out git project") + if err := c.git.Checkout(); err != nil { + return err + } + + c.logger.Debug().Msg("Pulling git project content") + if err := c.git.Pull(ctx); err != nil { + return err + } + + c.logger.Debug().Msg("Loading git logs") + return c.git.LoadLogs() +} + +func (c *Client) GetSigner(cfg *config.Config) error { + if cfg.GPGKey == "" { + c.logger.Debug().Msg("GPG key path parameter is empty") + return nil + } + + c.signer = &git.SignKey{KeyFile: cfg.GPGKey} + c.logger.Debug().Msg("Reading GPG key file") + return c.signer.ReadKeyFile() +} diff --git a/pkg/grafanabackuper/message.go b/pkg/grafanabackuper/message.go new file mode 100644 index 0000000..66a364b --- /dev/null +++ b/pkg/grafanabackuper/message.go @@ -0,0 +1,22 @@ +package grafanabackuper + +import ( + "fmt" +) + +type Message struct { + ID uint + Path string + Title string + UID string +} + +func (m Message) String() string { + commitMsg := fmt.Sprintf("%s: Update %s to version %d", m.Path, m.UID, m.ID) + + if m.Title != "" { + commitMsg += fmt.Sprintf(" => %s", m.Title) + } + + return commitMsg +} diff --git a/pkg/grafanabackuper/restore.go b/pkg/grafanabackuper/restore.go new file mode 100644 index 0000000..d6576d4 --- /dev/null +++ b/pkg/grafanabackuper/restore.go @@ -0,0 +1,103 @@ +package grafanabackuper + +import ( + "context" + "fmt" + "io" + + "git.ar21.de/yolokube/grafana-backuper/internal/config" + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana" +) + +type RestoreClient struct { + client *Client +} + +func (r *RestoreClient) Start(ctx context.Context, cfg *config.Config) error { + files, err := r.client.git.ListJSONFiles("./") + if err != nil { + return err + } + r.client.logger.Debug().Int("files", len(files)).Msg("Collected all files") + + for _, filename := range files { + r.client.logger.Debug().Str("file", filename).Msg("Reading data") + var data []byte + data, err = r.client.git.ReadFile(filename) + if err != nil { + return err + } + + r.client.logger.Debug().Msg("Parsing raw dashboard data") + var dashboard *grafana.Dashboard + dashboard, err = r.client.grafana.Dashboard.ParseRaw(data) + if err != nil { + return err + } + + content, ok := dashboard.Dashboard.(map[string]any) + if !ok { + continue + } + + uid := fmt.Sprint(content["uid"]) + title := fmt.Sprint(content["title"]) + + r.client.logger.Debug(). + Str("dashboard-uid", uid). + Str("title", title). + Msg("Fetching current dashboard version from Grafana") + var current *grafana.Dashboard + current, _, err = r.client.grafana.Dashboard.Get(ctx, uid) + if err != nil { + return err + } + + if current.Version == dashboard.Version && !cfg.ForceCommits { + r.client.logger.Info(). + Any("dashboard-uid", uid). + Str("folder", dashboard.FolderTitle). + Str("dashboard", title). + Msg("Dashboard already up to date") + continue + } + + r.client.logger.Debug().Str("dashboard-uid", uid).Str("title", title).Msg("Syncing dashboard with Grafana") + var createResp *grafana.DashboardCreateResponse + createResp, err = r.syncDashboard(ctx, dashboard, cfg.ForceCommits) + if err != nil { + return err + } + + r.client.logger.Info().Any("resp", createResp).Msg("Created / Updated dashboard successfully") + } + + return nil +} + +func (r *RestoreClient) syncDashboard( + ctx context.Context, + d *grafana.Dashboard, + force bool, +) (*grafana.DashboardCreateResponse, error) { + createOpts := grafana.DashboardCreateOpts{ + Dashboard: d.Dashboard, + FolderUID: d.FolderUID, + Message: "sync git repository to grafana", + Overwrite: force, + } + + r.client.logger.Debug().Msg("Validating create options") + if err := createOpts.Validate(); err != nil { + return nil, err + } + + createResp, resp, err := r.client.grafana.Dashboard.Create(ctx, createOpts) + if err != nil { + body, _ := io.ReadAll(resp.Body) + r.client.logger.Debug().Str("resp", string(body)).Msg("Got error during dashboard creation / update") + return nil, err + } + + return createResp, nil +} diff --git a/renovate.json b/renovate.json index 98cd62a..9dcb565 100644 --- a/renovate.json +++ b/renovate.json @@ -1,7 +1,8 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base" + "config:base", + "docker:enableMajor" ], "packageRules": [ { @@ -11,5 +12,10 @@ "platformAutomerge": true, "dependencyDashboard": true } - ] + ], + "postUpdateOptions": [ + "gomodTidy", + "gomodUpdateImportPaths" + ], + "semanticCommits": "enabled" } \ No newline at end of file