From 7ce90dacc44d6ee810ff27eabcca1ca33cca953d Mon Sep 17 00:00:00 2001 From: Tom Neuber Date: Sun, 23 Jun 2024 02:37:22 +0200 Subject: [PATCH] refactor(grafana): rework grafana package and make it more modular --- go.mod | 3 +- go.sum | 34 ++-- pkg/grafana/client.go | 143 +++++++++++++++ pkg/grafana/dashboard.go | 229 ++++++++++++------------ pkg/grafana/dashboard_version.go | 163 +++++++++++++++++ pkg/grafana/requests.go | 82 --------- pkg/grafana/schema.go | 98 ++++++++++ pkg/grafana/schema/dashboard.go | 59 ++++++ pkg/grafana/schema/dashboard_version.go | 20 +++ pkg/grafana/schema/error.go | 6 + pkg/grafana/schema/search.go | 22 +++ pkg/grafana/search.go | 133 +++++++------- 12 files changed, 710 insertions(+), 282 deletions(-) create mode 100644 pkg/grafana/client.go create mode 100644 pkg/grafana/dashboard_version.go delete mode 100644 pkg/grafana/requests.go create mode 100644 pkg/grafana/schema.go create mode 100644 pkg/grafana/schema/dashboard.go create mode 100644 pkg/grafana/schema/dashboard_version.go create mode 100644 pkg/grafana/schema/error.go create mode 100644 pkg/grafana/schema/search.go diff --git a/go.mod b/go.mod index 81a1762..f25a302 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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 + golang.org/x/net v0.22.0 ) require ( @@ -25,8 +26,8 @@ require ( 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/text v0.14.0 // indirect golang.org/x/tools v0.18.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index b1688f7..8b762db 100644 --- a/go.sum +++ b/go.sum @@ -5,14 +5,12 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 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/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= +github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 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/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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= @@ -30,16 +28,14 @@ github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcej 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/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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -69,20 +65,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/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/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/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/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= @@ -91,8 +83,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y 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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -106,8 +96,6 @@ 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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -128,8 +116,6 @@ 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -137,8 +123,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX 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.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 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= diff --git a/pkg/grafana/client.go b/pkg/grafana/client.go new file mode 100644 index 0000000..3f973e8 --- /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 8b736d1..2fa27a5 100644 --- a/pkg/grafana/dashboard.go +++ b/pkg/grafana/dashboard.go @@ -4,142 +4,141 @@ 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 DashboardMeta 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) (*DashboardMeta, *Response, error) { + req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/dashboards/uid/%s", uid), nil) if err != nil { - return nil, BoardProperties{}, err - } - if code != 200 { - return raw, BoardProperties{}, fmt.Errorf("HTTP error %d: returns %s", code, raw) + return nil, nil, err } - var result struct { - Meta BoardProperties `json:"meta"` - } + var body schema.DashboardMeta - 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) + resp, err := c.client.Do(req, &body) if err != nil { - return nil, versionInfo, err - } - if code != 200 { - return raw, versionInfo, fmt.Errorf("HTTP error %d: returns %s", code, raw) + return nil, resp, err } - 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 DashboardMetaFromSchema(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.FolderUID == "" && o.FolderID == 0 { + return errors.New("folder ID or UID missing") + } + 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 { + 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 -} - -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) - } - - return buf.String(), nil + return c.client.Do(req, nil) } diff --git a/pkg/grafana/dashboard_version.go b/pkg/grafana/dashboard_version.go new file mode 100644 index 0000000..cee05c7 --- /dev/null +++ b/pkg/grafana/dashboard_version.go @@ -0,0 +1,163 @@ +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 + } + + dashboards, resp, err := c.get(ctx, dashboardVersionURL, params...) + if err != nil || len(dashboards) < 1 { + return nil, resp, err + } + + return dashboards[0], resp, nil +} + +func (c *DashboardVersionClient) GetByUID( + ctx context.Context, + uid string, + version uint, + params ...DashboardVersionParam, +) (*DashboardVersion, *Response, error) { + dashboardVersionURL, err := url.Parse(fmt.Sprintf("/dashboards/id/%s/versions/%d", uid, version)) + if err != nil { + return nil, nil, err + } + + dashboards, resp, err := c.get(ctx, dashboardVersionURL, params...) + if err != nil || len(dashboards) < 1 { + return nil, resp, err + } + + return dashboards[0], resp, nil +} + +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.get(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.get(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.DashboardVersionListResponse + + resp, err := c.client.Do(req, &body) + if err != nil { + return nil, resp, err + } + + dashboardVersions := make([]*DashboardVersion, 0, len(body.DashboardVersions)) + for _, dashboardVersion := range body.DashboardVersions { + 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 176060d..0000000 --- a/pkg/grafana/requests.go +++ /dev/null @@ -1,82 +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) { - baseURL, err := url.Parse(apiURL) - if err != nil { - return nil, err - } - - var key string - basicAuth := strings.Contains(authString, ":") - - 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..3c4891b --- /dev/null +++ b/pkg/grafana/schema.go @@ -0,0 +1,98 @@ +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 DashboardMetaFromSchema(source schema.DashboardMeta) *DashboardMeta { + return &DashboardMeta{ + IsStarred: source.IsStarred, + Type: source.Type, + CanSave: source.CanSave, + CanEdit: source.CanEdit, + CanAdmin: source.CanAdmin, + CanStar: source.CanStar, + CanDelete: source.CanDelete, + Slug: source.Slug, + URL: source.URL, + Expires: source.Expires, + Created: source.Created, + Updated: source.Updated, + UpdatedBy: source.UpdatedBy, + CreatedBy: source.CreatedBy, + Version: source.Version, + HasACL: source.HasACL, + IsFolder: source.IsFolder, + FolderID: source.FolderID, + FolderUID: source.FolderUID, + FolderTitle: source.FolderTitle, + FolderURL: source.FolderURL, + Provisioned: source.Provisioned, + ProvisionedExternalID: source.ProvisionedExternalID, + AnnotationsPermissions: AnnotationsPermissions{ + Dashboard: AnnotationPermissions{ + CanAdd: source.AnnotationsPermissions.Dashboard.CanAdd, + CanEdit: source.AnnotationsPermissions.Dashboard.CanEdit, + CanDelete: source.AnnotationsPermissions.Dashboard.CanDelete, + }, + Organization: AnnotationPermissions{ + CanAdd: source.AnnotationsPermissions.Organization.CanAdd, + CanEdit: source.AnnotationsPermissions.Organization.CanEdit, + CanDelete: source.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, + } +} diff --git a/pkg/grafana/schema/dashboard.go b/pkg/grafana/schema/dashboard.go new file mode 100644 index 0000000..c7afb1c --- /dev/null +++ b/pkg/grafana/schema/dashboard.go @@ -0,0 +1,59 @@ +package schema + +import "time" + +type DashboardCreateRequest struct { + Dashboard any `json:"dasboard"` + 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 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 struct { + Dashboard struct { + CanAdd bool `json:"canAdd"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` + } `json:"dashboard"` + Organization struct { + CanAdd bool `json:"canAdd"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` + } `json:"organization"` + } `json:"annotationsPermissions"` + Dashboard any `json:"dashboard"` +} diff --git a/pkg/grafana/schema/dashboard_version.go b/pkg/grafana/schema/dashboard_version.go new file mode 100644 index 0000000..6dc11cf --- /dev/null +++ b/pkg/grafana/schema/dashboard_version.go @@ -0,0 +1,20 @@ +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 struct { + DashboardVersions []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..71fd4a3 --- /dev/null +++ b/pkg/grafana/schema/search.go @@ -0,0 +1,22 @@ +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 struct { + SearchResults []SearchResult +} diff --git a/pkg/grafana/search.go b/pkg/grafana/search.go index 7303a2a..3722a2b 100644 --- a/pkg/grafana/search.go +++ b/pkg/grafana/search.go @@ -2,68 +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) @@ -71,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) @@ -85,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.SearchResults)) + for _, searchResult := range body.SearchResults { + searchResults = append(searchResults, SearchResultFromSchema(searchResult)) + } + + return searchResults, resp, nil }