diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..843930a --- /dev/null +++ b/.drone.yml @@ -0,0 +1,19 @@ +kind: pipeline +name: build + +steps: +- name: gofmt + image: golang:1.21.5 + commands: + - gofmt -l -s . + when: + event: + - push +- name: vuln-check + image: golang:1.21.5 + commands: + - go install golang.org/x/vuln/cmd/govulncheck@latest + - govulncheck ./... + when: + event: + - push diff --git a/cfg/cfg.go b/cfg/cfg.go new file mode 100644 index 0000000..88634d9 --- /dev/null +++ b/cfg/cfg.go @@ -0,0 +1,55 @@ +package cfg + +import ( + "fmt" + "os" + + "github.com/alecthomas/kong" +) + +type AppSettings struct { + GrafanaURL string + Token string +} + +var cliStruct struct { + GrafanaURL string `name:"grafana-url" env:"GRAFANA_URL" help:"Grafana URL to access the API"` + AuthToken string `name:"grafana-auth-token" env:"GRAFANA_AUTH_TOKEN" help:"Grafana auth token to access the API"` +} + +func Parse() *AppSettings { + ctx := kong.Parse( + &cliStruct, + kong.Name("grafana-backuper"), + kong.Description("🚀 CLI tool to backup grafana dashboards"), + kong.UsageOnError(), + ) + + validateFlags(ctx) + settings := &AppSettings{ + GrafanaURL: cliStruct.GrafanaURL, + Token: cliStruct.AuthToken, + } + 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.AuthToken == "" { + messages = append(messages, "error: invalid auth token for grafana, 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/go.mod b/go.mod new file mode 100644 index 0000000..0ba43cf --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.ar21.de/yolokube/grafana-backuper + +go 1.21.5 + +require github.com/alecthomas/kong v0.8.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..021aabe --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +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/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= +github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..69928c1 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "log" + + "git.ar21.de/yolokube/grafana-backuper/pkg/grafana" +) + +const ( + grafanaURL = "https://grafana.services.yolokube.de" + grafanaAPIKey = "" + gitRepoURL = "" + gitRepoDirectory = "" +) + +func main() { + ctx := context.Background() + client, err := grafana.NewClient(grafanaURL, grafanaAPIKey, grafana.DefaultHTTPClient) + if err != nil { + log.Fatalf("Error creating the grafana client: %v", err) + } + + dashboards, err := client.SearchDashboards(ctx, "", false) + if err != nil { + log.Fatalf("Error fetching dashboards: %v", err) + } + + for _, dashboard := range dashboards { + versions, err := client.GetDashboardVersionsByDashboardID(ctx, dashboard.ID) + if err != nil { + log.Fatalf("Error fetching versions for dashboard %s: %v", dashboard.Title, err) + } + + fmt.Println(dashboard.Title) + fmt.Println(versions) + for _, version := range versions { + fmt.Printf("%s: %s\n", version.CreatedBy, version.Message) + } + } +} diff --git a/pkg/grafana/dashboard.go b/pkg/grafana/dashboard.go new file mode 100644 index 0000000..9459431 --- /dev/null +++ b/pkg/grafana/dashboard.go @@ -0,0 +1,60 @@ +package grafana + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "time" +) + +type DashboardVersion struct { + ID uint `json:"id"` + DashboardID uint `json:"dashboardId"` + 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"` +} + +func queryParams(params ...QueryParam) url.Values { + u := url.URL{} + q := u.Query() + for _, p := range params { + p(&q) + } + return q +} + +func (c *Client) GetDashboardVersionsByDashboardID(ctx context.Context, dashboardID uint, params ...QueryParam) ([]DashboardVersion, error) { + var ( + raw []byte + code int + err error + ) + + if raw, code, err = c.get(ctx, fmt.Sprintf("api/dashboards/id/%d/versions", dashboardID), queryParams(params...)); 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) 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...) +} diff --git a/pkg/grafana/requests.go b/pkg/grafana/requests.go new file mode 100644 index 0000000..dd7caf0 --- /dev/null +++ b/pkg/grafana/requests.go @@ -0,0 +1,80 @@ +package grafana + +import ( + "bytes" + "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) +} + +func (c *Client) post(ctx context.Context, query string, params url.Values, body []byte) ([]byte, int, error) { + return c.doRequest(ctx, "POST", query, params, bytes.NewBuffer(body)) +} diff --git a/pkg/grafana/search.go b/pkg/grafana/search.go new file mode 100644 index 0000000..94dd1ea --- /dev/null +++ b/pkg/grafana/search.go @@ -0,0 +1,89 @@ +package grafana + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" +) + +const ( + SearchTypeFolder SearchParamType = "dash-folder" + SearchTypeDashboard SearchParamType = "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"` +} + +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 { + return func(v *url.Values) { + if query != "" { + v.Set("query", query) + } + } +} + +func SearchStarred(starred bool) SearchParam { + return func(v *url.Values) { + v.Set("starred", strconv.FormatBool(starred)) + } +} + +func SearchTag(tag string) SearchParam { + return func(v *url.Values) { + if tag != "" { + v.Add("tag", tag) + } + } +} + +func SearchType(searchType SearchParamType) SearchParam { + return func(v *url.Values) { + v.Set("type", string(searchType)) + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..98cd62a --- /dev/null +++ b/renovate.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "packageRules": [ + { + "matchPackagePatterns": ["*"], + "automerge": true, + "automergeType": "pr", + "platformAutomerge": true, + "dependencyDashboard": true + } + ] +} \ No newline at end of file