package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net" "net/http" "net/url" "os" "path" "path/filepath" "strings" "time" "github.com/urfave/cli/v2" "github.com/grafana/grafana/pkg/build/config" "github.com/grafana/grafana/pkg/build/gcloud" "github.com/grafana/grafana/pkg/build/gcloud/storage" "github.com/grafana/grafana/pkg/build/gcom" "github.com/grafana/grafana/pkg/build/packaging" "github.com/grafana/grafana/pkg/build/versions" ) const grafanaAPI = "https://grafana.com/api" var httpClient = http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: func(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { return dialer.DialContext }(&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }), ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, } // GrafanaCom implements the sub-command "grafana-com". func GrafanaCom(c *cli.Context) error { bucketStr := c.String("src-bucket") edition := config.Edition(c.String("edition")) if err := gcloud.ActivateServiceAccount(); err != nil { return fmt.Errorf("couldn't activate service account, err: %w", err) } metadata, err := config.GenerateMetadata(c) if err != nil { return err } releaseMode, err := metadata.GetReleaseMode() if err != nil { return err } version := metadata.GrafanaVersion semver := versions.ParseSemver(version) if releaseMode.Mode == config.Cronjob { gcs, err := storage.New() if err != nil { return err } bucket := gcs.Bucket(bucketStr) latestMainVersion, err := storage.GetLatestMainBuild(c.Context, bucket, filepath.Join(string(edition), "main")) if err != nil { return err } version = latestMainVersion } dryRun := c.Bool("dry-run") simulateRelease := c.Bool("simulate-release") // Test release mode and dryRun imply simulateRelease if releaseMode.IsTest || dryRun { simulateRelease = true } grafanaAPIKey := strings.TrimSpace(os.Getenv("GRAFANA_COM_API_KEY")) if grafanaAPIKey == "" { return cli.Exit("the environment variable GRAFANA_COM_API_KEY must be set", 1) } pkgjson, err := getPackageJSON() if err != nil { return cli.Exit(err.Error(), 1) } whatsNewURL, releaseNotesURL, err := getReleaseURLs(semver, pkgjson) if err != nil { return cli.Exit(err.Error(), 1) } // TODO: Verify config values cfg := packaging.PublishConfig{ Config: config.Config{ Version: version, }, Edition: edition, ReleaseMode: releaseMode, GrafanaAPIKey: grafanaAPIKey, WhatsNewURL: whatsNewURL, ReleaseNotesURL: releaseNotesURL, DryRun: dryRun, TTL: c.String("ttl"), SimulateRelease: simulateRelease, } if err := publishPackages(cfg); err != nil { return cli.Exit(err.Error(), 1) } log.Println("Successfully published packages to grafana.com!") return nil } type grafanaConf struct { WhatsNewURL string `json:"whatsNewUrl"` ReleaseNotesURL string `json:"releaseNotesUrl"` } type packageConf struct { Grafana grafanaConf `json:"grafana"` } func getPackageJSON() (*packageConf, error) { pkgB, err := os.ReadFile("package.json") if err != nil { return nil, fmt.Errorf("failed to read package.json: %w", err) } var pconf packageConf if err := json.Unmarshal(pkgB, &pconf); err != nil { return nil, fmt.Errorf("failed to decode package.json: %w", err) } return &pconf, nil } func getReleaseURLs(semver versions.Semver, pconf *packageConf) (string, string, error) { u := fmt.Sprintf(pconf.Grafana.WhatsNewURL, semver.Major, semver.Minor, semver.Patch) if _, err := url.ParseRequestURI(u); err != nil { return "", "", fmt.Errorf("grafana.whatsNewUrl is invalid in package.json: %q", pconf.Grafana.WhatsNewURL) } if _, err := url.ParseRequestURI(pconf.Grafana.ReleaseNotesURL); err != nil { return "", "", fmt.Errorf("grafana.releaseNotesUrl is invalid in package.json: %q", pconf.Grafana.ReleaseNotesURL) } return u, pconf.Grafana.ReleaseNotesURL, nil } func Builds(baseURL *url.URL, grafana, version string, packages []packaging.BuildArtifact) ([]GCOMPackage, error) { builds := make([]GCOMPackage, len(packages)) for i, v := range packages { var ( os = v.Distro arch = v.Arch ) if v.Distro == "windows" { os = "win" if v.Ext == "msi" { os = "win-installer" } } if v.Distro == "rhel" { if arch == "aarch64" { arch = "arm64" } if arch == "x86_64" { arch = "amd64" } } if v.Distro == "deb" { if arch == "armhf" { arch = "armv7" if v.RaspberryPi { log.Println(v.Distro, arch, "raspberrypi == true") arch = "armv6" } } } u := gcom.GetURL(baseURL, version, grafana, v.Distro, v.Arch, v.Ext, v.Musl, v.RaspberryPi) builds[i] = GCOMPackage{ OS: os, URL: u.String(), Arch: arch, } } return builds, nil } // publishPackages publishes packages to grafana.com. func publishPackages(cfg packaging.PublishConfig) error { log.Printf("Publishing Grafana packages, version %s, %s edition, %s mode, dryRun: %v, simulating: %v...\n", cfg.Version, cfg.Edition, cfg.ReleaseMode.Mode, cfg.DryRun, cfg.SimulateRelease) versionStr := fmt.Sprintf("v%s", cfg.Version) log.Printf("Creating release %s at grafana.com...\n", versionStr) var ( pth string grafana = "grafana" ) switch cfg.Edition { case config.EditionOSS: pth = "oss" case config.EditionEnterprise: grafana = "grafana-enterprise" pth = "enterprise" default: return fmt.Errorf("unrecognized edition %q", cfg.Edition) } switch cfg.ReleaseMode.Mode { case config.MainMode, config.DownstreamMode, config.CronjobMode: pth = path.Join(pth, packaging.MainFolder) default: pth = path.Join(pth, packaging.ReleaseFolder) } pth = path.Join(pth) baseArchiveURL := &url.URL{ Scheme: "https", Host: "dl.grafana.com", Path: pth, } builds, err := Builds(baseArchiveURL, grafana, cfg.Version, packaging.ArtifactConfigs) if err != nil { return err } r := Release{ Version: cfg.Version, ReleaseDate: time.Now().UTC(), Builds: builds, Stable: cfg.ReleaseMode.Mode == config.TagMode && !cfg.ReleaseMode.IsPreview && !cfg.ReleaseMode.IsTest, Beta: cfg.ReleaseMode.IsPreview, Nightly: cfg.ReleaseMode.Mode == config.CronjobMode, } if cfg.ReleaseMode.Mode == config.TagMode || r.Beta { r.WhatsNewURL = cfg.WhatsNewURL r.ReleaseNotesURL = cfg.ReleaseNotesURL } if err := postRequest(cfg, "versions", r, fmt.Sprintf("create release %s", r.Version)); err != nil { return err } if err := postRequest(cfg, fmt.Sprintf("versions/%s", cfg.Version), r, fmt.Sprintf("update release %s", cfg.Version)); err != nil { return err } for i, v := range r.Builds { sha, err := getSHA256(v.URL) if err != nil { return err } r.Builds[i].SHA256 = string(sha) } for _, b := range r.Builds { if err := postRequest(cfg, fmt.Sprintf("versions/%s/packages", cfg.Version), b, fmt.Sprintf("create build %s %s", b.OS, b.Arch)); err != nil { return err } if err := postRequest(cfg, fmt.Sprintf("versions/%s/packages/%s/%s", cfg.Version, b.Arch, b.OS), b, fmt.Sprintf("update build %s %s", b.OS, b.Arch)); err != nil { return err } } return nil } func getSHA256(u string) ([]byte, error) { shaURL := fmt.Sprintf("%s.sha256", u) // nolint:gosec resp, err := http.Get(shaURL) if err != nil { return nil, err } defer func() { if err := resp.Body.Close(); err != nil { log.Println("failed to close response body, err: %w", err) } }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("failed downloading %s: %s", u, resp.Status) } sha256, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return sha256, nil } func postRequest(cfg packaging.PublishConfig, pth string, body any, descr string) error { var sfx string switch cfg.Edition { case config.EditionOSS: case config.EditionEnterprise: sfx = packaging.EnterpriseSfx default: return fmt.Errorf("unrecognized edition %q", cfg.Edition) } product := fmt.Sprintf("grafana%s", sfx) jsonB, err := json.Marshal(body) if err != nil { return fmt.Errorf("failed to JSON encode release: %w", err) } u, err := constructURL(product, pth) if err != nil { return err } req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(jsonB)) if err != nil { return err } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", cfg.GrafanaAPIKey)) req.Header.Add("Content-Type", "application/json") log.Printf("Posting to grafana.com API, %s - JSON: %s\n", u, string(jsonB)) if cfg.SimulateRelease { log.Println("Only simulating request") return nil } resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("failed posting to %s (%s): %s", u, descr, err) } defer func() { if err := resp.Body.Close(); err != nil { log.Println("failed to close response body, err: %w", err) } }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, err := io.ReadAll(resp.Body) if err != nil { return err } if strings.Contains(string(body), "already exists") || strings.Contains(string(body), "Nothing to update") { log.Printf("Already exists: %s\n", descr) return nil } return fmt.Errorf("failed posting to %s (%s): %s", u, descr, resp.Status) } log.Printf("Successfully posted to grafana.com API, %s\n", u) return nil } func constructURL(product string, pth string) (string, error) { productPath := filepath.Clean(filepath.Join("/", product, pth)) u, err := url.Parse(grafanaAPI) if err != nil { return "", err } u.Path = path.Join(u.Path, productPath) return u.String(), err } type GCOMPackage struct { OS string `json:"os"` URL string `json:"url"` SHA256 string `json:"sha256"` Arch string `json:"arch"` } type Release struct { Version string `json:"version"` ReleaseDate time.Time `json:"releaseDate"` Stable bool `json:"stable"` Beta bool `json:"beta"` Nightly bool `json:"nightly"` WhatsNewURL string `json:"whatsNewUrl"` ReleaseNotesURL string `json:"releaseNotesUrl"` Builds []GCOMPackage `json:"-"` }