From 9a68f8704f30898eaa7c246f150f7dc219037099 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Thu, 29 Sep 2022 14:32:42 +0300 Subject: [PATCH] CI: Move `publish-packages` command over from `grabpl` (#55850) * Move publish-packages command over from * Lint fixes * Update test --- pkg/build/cmd/artifacts.go | 22 ++ pkg/build/cmd/deb.go | 243 ++++++++++++++++++++ pkg/build/cmd/flags.go | 9 + pkg/build/cmd/main.go | 49 +++++ pkg/build/cmd/publishpackages.go | 112 ++++++++++ pkg/build/cmd/rpm.go | 365 +++++++++++++++++++++++++++++++ pkg/build/cmd/rpm_test.go | 146 +++++++++++++ 7 files changed, 946 insertions(+) create mode 100644 pkg/build/cmd/artifacts.go create mode 100644 pkg/build/cmd/deb.go create mode 100644 pkg/build/cmd/publishpackages.go create mode 100644 pkg/build/cmd/rpm.go create mode 100644 pkg/build/cmd/rpm_test.go diff --git a/pkg/build/cmd/artifacts.go b/pkg/build/cmd/artifacts.go new file mode 100644 index 00000000000..53807878518 --- /dev/null +++ b/pkg/build/cmd/artifacts.go @@ -0,0 +1,22 @@ +package main + +import ( + "github.com/grafana/grafana/pkg/build/config" +) + +const ReleaseFolder = "release" +const EnterpriseSfx = "-enterprise" +const CacheSettings = "Cache-Control:public, max-age=" + +type PublishConfig struct { + config.Config + + Edition config.Edition + ReleaseMode config.ReleaseMode + GrafanaAPIKey string + WhatsNewURL string + ReleaseNotesURL string + DryRun bool + TTL string + SimulateRelease bool +} diff --git a/pkg/build/cmd/deb.go b/pkg/build/cmd/deb.go new file mode 100644 index 00000000000..8ec651a91ec --- /dev/null +++ b/pkg/build/cmd/deb.go @@ -0,0 +1,243 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/packaging" + "github.com/grafana/grafana/pkg/infra/fs" + "github.com/urfave/cli/v2" +) + +func writeAptlyConf(dbDir, repoDir string) error { + aptlyConf := fmt.Sprintf(`{ + "rootDir": "%s", + "downloadConcurrency": 4, + "downloadSpeedLimit": 0, + "architectures": [], + "dependencyFollowSuggests": false, + "dependencyFollowRecommends": false, + "dependencyFollowAllVariants": false, + "dependencyFollowSource": false, + "dependencyVerboseResolve": false, + "gpgDisableSign": false, + "gpgDisableVerify": false, + "gpgProvider": "gpg2", + "downloadSourcePackages": false, + "skipLegacyPool": true, + "ppaDistributorID": "ubuntu", + "ppaCodename": "", + "skipContentsPublishing": false, + "FileSystemPublishEndpoints": { + "repo": { + "rootDir": "%s", + "linkMethod": "copy" + } + }, + "S3PublishEndpoints": {}, + "SwiftPublishEndpoints": {} +} +`, dbDir, repoDir) + home, err := os.UserHomeDir() + if err != nil { + return err + } + return os.WriteFile(filepath.Join(home, ".aptly.conf"), []byte(aptlyConf), 0600) +} + +// downloadDebs downloads Deb packages. +func downloadDebs(cfg PublishConfig, workDir string) error { + if cfg.Bucket == "" { + panic("cfg.Bucket has to be set") + } + if !strings.HasSuffix(workDir, string(filepath.Separator)) { + workDir += string(filepath.Separator) + } + + var version string + if cfg.ReleaseMode.Mode == config.TagMode { + if cfg.ReleaseMode.IsBeta { + version = strings.ReplaceAll(cfg.Version, "-", "~") + } else { + version = cfg.Version + } + } + if version == "" { + panic(fmt.Sprintf("Unrecognized version mode %s", cfg.ReleaseMode.Mode)) + } + + var sfx string + switch cfg.Edition { + case config.EditionOSS: + case config.EditionEnterprise: + sfx = EnterpriseSfx + default: + return fmt.Errorf("unrecognized edition %q", cfg.Edition) + } + + u := fmt.Sprintf("gs://%s/%s/%s/grafana%s_%s_*.deb*", cfg.Bucket, + strings.ToLower(string(cfg.Edition)), ReleaseFolder, sfx, version) + log.Printf("Downloading Deb packages %q...\n", u) + args := []string{ + "-m", + "cp", + u, + workDir, + } + //nolint:gosec + cmd := exec.Command("gsutil", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to download Deb packages %q: %w\n%s", u, err, output) + } + + return nil +} + +// updateDebRepo updates the Debian repository with the new release. +func updateDebRepo(cfg PublishConfig, workDir string) error { + if cfg.ReleaseMode.Mode != config.TagMode { + panic(fmt.Sprintf("Unsupported version mode: %s", cfg.ReleaseMode.Mode)) + } + + if cfg.ReleaseMode.IsTest { + if cfg.Config.DebDBBucket == packaging.DefaultDebDBBucket { + return fmt.Errorf("in test-release mode, the default Deb DB bucket shouldn't be used") + } + if cfg.Config.DebRepoBucket == packaging.DefaultDebRepoBucket { + return fmt.Errorf("in test-release mode, the default Deb repo bucket shouldn't be used") + } + } + + if err := downloadDebs(cfg, workDir); err != nil { + return err + } + + repoName := "grafana" + if cfg.ReleaseMode.IsBeta { + repoName = "beta" + } + + repoRoot := path.Join(os.TempDir(), "deb-repo") + defer func() { + if err := os.RemoveAll(repoRoot); err != nil { + log.Printf("Failed to remove temporary directory %q: %s\n", repoRoot, err.Error()) + } + }() + + dbDir := filepath.Join(repoRoot, "db") + repoDir := filepath.Join(repoRoot, "repo") + tmpDir := filepath.Join(repoRoot, "tmp") + for _, dpath := range []string{dbDir, repoDir, tmpDir} { + if err := os.MkdirAll(dpath, 0750); err != nil { + return err + } + } + + if err := writeAptlyConf(dbDir, repoDir); err != nil { + return err + } + + // Download the Debian repo database + u := fmt.Sprintf("gs://%s/%s", cfg.DebDBBucket, strings.ToLower(string(cfg.Edition))) + log.Printf("Downloading Debian repo database from %s...\n", u) + //nolint:gosec + cmd := exec.Command("gsutil", "-m", "rsync", "-r", "-d", u, dbDir) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to download Debian repo database: %w\n%s", err, output) + } + + if err := addPkgsToRepo(cfg, workDir, tmpDir, repoName); err != nil { + return err + } + + log.Println("Updating local Debian package repository...") + // Update published local repository. This assumes that there exists already a local, published repo. + for _, tp := range []string{"stable", "beta"} { + passArg := fmt.Sprintf("-passphrase-file=%s", cfg.GPGPassPath) + //nolint:gosec + cmd := exec.Command("aptly", "publish", "update", "-batch", passArg, "-force-overwrite", tp, + "filesystem:repo:grafana") + if output, err := cmd.CombinedOutput(); err != nil { + return cli.NewExitError(fmt.Sprintf("failed to update Debian %q repository: %s", tp, output), 1) + } + } + + // Update database in GCS + u = fmt.Sprintf("gs://%s/%s", cfg.DebDBBucket, strings.ToLower(string(cfg.Edition))) + if cfg.DryRun { + log.Printf("Simulating upload of Debian repo database to GCS (%s)\n", u) + } else { + log.Printf("Uploading Debian repo database to GCS (%s)...\n", u) + //nolint:gosec + cmd = exec.Command("gsutil", "-m", "rsync", "-r", "-d", dbDir, u) + if output, err := cmd.CombinedOutput(); err != nil { + return cli.NewExitError(fmt.Sprintf("failed to upload Debian repo database to GCS: %s", output), 1) + } + } + + // Update metadata and binaries in repository bucket + u = fmt.Sprintf("gs://%s/%s/deb", cfg.DebRepoBucket, strings.ToLower(string(cfg.Edition))) + grafDir := filepath.Join(repoDir, "grafana") + if cfg.DryRun { + log.Printf("Simulating upload of Debian repo resources to GCS (%s)\n", u) + } else { + log.Printf("Uploading Debian repo resources to GCS (%s)...\n", u) + //nolint:gosec + cmd = exec.Command("gsutil", "-m", "rsync", "-r", "-d", grafDir, u) + if output, err := cmd.CombinedOutput(); err != nil { + return cli.NewExitError(fmt.Sprintf("failed to upload Debian repo resources to GCS: %s", output), 1) + } + allRepoResources := fmt.Sprintf("%s/**/*", u) + log.Printf("Setting cache ttl for Debian repo resources on GCS (%s)...\n", allRepoResources) + //nolint:gosec + cmd = exec.Command("gsutil", "-m", "setmeta", "-h", CacheSettings+cfg.TTL, allRepoResources) + if output, err := cmd.CombinedOutput(); err != nil { + return cli.NewExitError(fmt.Sprintf("failed to set cache ttl for Debian repo resources on GCS: %s", output), 1) + } + } + + return nil +} + +func addPkgsToRepo(cfg PublishConfig, workDir, tmpDir, repoName string) error { + var sfx string + switch cfg.Edition { + case config.EditionOSS: + case config.EditionEnterprise: + sfx = EnterpriseSfx + default: + return fmt.Errorf("unsupported edition %q", cfg.Edition) + } + + log.Printf("Adding packages to Debian %q repo...\n", repoName) + // TODO: Be more specific about filename pattern + debs, err := filepath.Glob(filepath.Join(workDir, fmt.Sprintf("grafana%s*.deb", sfx))) + if err != nil { + return err + } + for _, deb := range debs { + basename := filepath.Base(deb) + if strings.Contains(basename, "latest") { + continue + } + + tgt := filepath.Join(tmpDir, basename) + if err := fs.CopyFile(deb, tgt); err != nil { + return err + } + } + // XXX: Adds too many packages in enterprise (Arve: What does this mean exactly?) + //nolint:gosec + cmd := exec.Command("aptly", "repo", "add", "-force-replace", repoName, tmpDir) + if output, err := cmd.CombinedOutput(); err != nil { + return cli.NewExitError(fmt.Sprintf("failed to add packages to local Debian repository: %s", output), 1) + } + + return nil +} diff --git a/pkg/build/cmd/flags.go b/pkg/build/cmd/flags.go index 3ae24027d76..7aa6d98d026 100644 --- a/pkg/build/cmd/flags.go +++ b/pkg/build/cmd/flags.go @@ -37,4 +37,13 @@ var ( Name: "sign", Usage: "Enable plug-in signing (you must set GRAFANA_API_KEY)", } + dryRunFlag = cli.BoolFlag{ + Name: "dry-run", + Usage: "Only simulate actions", + } + gcpKeyFlag = cli.StringFlag{ + Name: "gcp-key", + Usage: "Google Cloud Platform key file", + Required: true, + } ) diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index ccf894fc8f0..66c5e801d07 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/build/docker" + "github.com/grafana/grafana/pkg/build/packaging" "github.com/urfave/cli/v2" ) @@ -169,6 +170,54 @@ func main() { }, }, }, + { + Name: "publish", + Usage: "Publish packages to Grafana com and repositories", + Subcommands: cli.Commands{ + { + Name: "packages", + Usage: "publish Grafana packages", + ArgsUsage: "[version]", + Action: PublishPackages, + Flags: []cli.Flag{ + &jobsFlag, + &editionFlag, + &buildIDFlag, + &dryRunFlag, + &gcpKeyFlag, + &cli.StringFlag{ + Name: "packages-bucket", + Value: "grafana-downloads", + Usage: "Google Cloud Storage Debian database bucket", + }, + &cli.StringFlag{ + Name: "deb-db-bucket", + Value: packaging.DefaultDebDBBucket, + Usage: "Google Cloud Storage Debian database bucket", + }, + &cli.StringFlag{ + Name: "deb-repo-bucket", + Value: packaging.DefaultDebRepoBucket, + Usage: "Google Cloud Storage Debian repo bucket", + }, + &cli.StringFlag{ + Name: "rpm-repo-bucket", + Value: packaging.DefaultRPMRepoBucket, + Usage: "Google Cloud Storage RPM repo bucket", + }, + &cli.StringFlag{ + Name: "ttl", + Value: packaging.DefaultTTLSeconds, + Usage: "Cache time to live for uploaded packages", + }, + &cli.BoolFlag{ + Name: "simulate-release", + Usage: "Only simulate creating release at grafana.com", + }, + }, + }, + }, + }, } if err := app.Run(os.Args); err != nil { diff --git a/pkg/build/cmd/publishpackages.go b/pkg/build/cmd/publishpackages.go new file mode 100644 index 00000000000..b640688f261 --- /dev/null +++ b/pkg/build/cmd/publishpackages.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/gcloud" + "github.com/grafana/grafana/pkg/build/gpg" + "github.com/urfave/cli/v2" +) + +// PublishPackages implements the sub-command "publish-packages". +func PublishPackages(c *cli.Context) error { + if err := gcloud.ActivateServiceAccount(); err != nil { + return fmt.Errorf("couldn't activate service account, err: %w", err) + } + + metadata, err := GenerateMetadata(c) + if err != nil { + return err + } + + releaseMode, err := metadata.GetReleaseMode() + if err != nil { + return err + } + + 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.NewExitError("the environment variable GRAFANA_COM_API_KEY must be set", 1) + } + + edition := config.Edition(c.String("edition")) + + // TODO: Verify config values + cfg := PublishConfig{ + Config: config.Config{ + Version: metadata.GrafanaVersion, + Bucket: c.String("packages-bucket"), + DebDBBucket: c.String("deb-db-bucket"), + DebRepoBucket: c.String("deb-repo-bucket"), + RPMRepoBucket: c.String("rpm-repo-bucket"), + }, + Edition: edition, + ReleaseMode: releaseMode, + GrafanaAPIKey: grafanaAPIKey, + DryRun: dryRun, + TTL: c.String("ttl"), + SimulateRelease: simulateRelease, + } + if err := gpg.LoadGPGKeys(&cfg.Config); err != nil { + return err + } + defer gpg.RemoveGPGFiles(cfg.Config) + + // Only update package manager repos for releases. + // In test release mode, the operator should configure different GCS buckets for the package repos, + // so should be safe. + if cfg.ReleaseMode.Mode == config.TagMode { + workDir := os.TempDir() + defer func() { + if err := os.RemoveAll(workDir); err != nil { + log.Printf("Failed to remove temporary directory %q: %s\n", workDir, err.Error()) + } + }() + if err := updatePkgRepos(cfg, workDir); err != nil { + return err + } + } + + log.Println("Successfully published packages!") + return nil +} + +// updatePkgRepos updates package manager repositories. +func updatePkgRepos(cfg PublishConfig, workDir string) error { + if err := gpg.Import(cfg.Config); err != nil { + return err + } + + // If updating the Deb repo fails, still continue with the RPM repo, so we don't have to retry + // both by hand + debErr := updateDebRepo(cfg, workDir) + if debErr != nil { + log.Printf("Updating Deb repo failed: %s\n", debErr) + } + rpmErr := updateRPMRepo(cfg, workDir) + if rpmErr != nil { + log.Printf("Updating RPM repo failed: %s\n", rpmErr) + } + + if debErr != nil { + return debErr + } + if rpmErr != nil { + return rpmErr + } + + log.Println("Updated Deb and RPM repos successfully!") + + return nil +} diff --git a/pkg/build/cmd/rpm.go b/pkg/build/cmd/rpm.go new file mode 100644 index 00000000000..9072c50750f --- /dev/null +++ b/pkg/build/cmd/rpm.go @@ -0,0 +1,365 @@ +package main + +import ( + "bytes" + "crypto" + "fmt" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/packaging" + "github.com/grafana/grafana/pkg/infra/fs" + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/packet" +) + +// updateRPMRepo updates the RPM repository with the new release. +func updateRPMRepo(cfg PublishConfig, workDir string) error { + if cfg.ReleaseMode.Mode != config.TagMode { + panic(fmt.Sprintf("Unsupported version mode %s", cfg.ReleaseMode.Mode)) + } + + if cfg.ReleaseMode.IsTest && cfg.Config.RPMRepoBucket == packaging.DefaultRPMRepoBucket { + return fmt.Errorf("in test-release mode, the default RPM repo bucket shouldn't be used") + } + + if err := downloadRPMs(cfg, workDir); err != nil { + return err + } + + repoRoot := path.Join(os.TempDir(), "rpm-repo") + defer func() { + if err := os.RemoveAll(repoRoot); err != nil { + log.Printf("Failed to remove %q: %s\n", repoRoot, err.Error()) + } + }() + + repoName := "rpm" + if cfg.ReleaseMode.IsBeta { + repoName = "rpm-beta" + } + folderURI := fmt.Sprintf("gs://%s/%s/%s", cfg.RPMRepoBucket, strings.ToLower(string(cfg.Edition)), repoName) + + // Download the RPM database + log.Printf("Downloading RPM database from GCS (%s)...\n", folderURI) + //nolint:gosec + cmd := exec.Command("gsutil", "-m", "rsync", "-r", "-d", folderURI, repoRoot) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to download RPM database from GCS: %w\n%s", err, output) + } + + // Add the new release to the repo + var sfx string + switch cfg.Edition { + case config.EditionOSS: + case config.EditionEnterprise: + sfx = EnterpriseSfx + default: + return fmt.Errorf("unsupported edition %q", cfg.Edition) + } + allRPMs, err := filepath.Glob(filepath.Join(workDir, fmt.Sprintf("grafana%s-*.rpm", sfx))) + if err != nil { + return fmt.Errorf("failed to list RPMs in %q: %w", workDir, err) + } + rpms := []string{} + for _, rpm := range allRPMs { + if strings.Contains(rpm, "-latest") { + continue + } + + rpms = append(rpms, rpm) + } + // XXX: What does the following comment mean? + // adds to many files for enterprise + for _, rpm := range rpms { + if err := fs.CopyFile(rpm, filepath.Join(repoRoot, filepath.Base(rpm))); err != nil { + return err + } + } + + //nolint:gosec + cmd = exec.Command("createrepo", repoRoot) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create repo at %q: %w\n%s", repoRoot, err, output) + } + + if err := signRPMRepo(repoRoot, cfg); err != nil { + return err + } + + // Update the repo in GCS + + // Sync packages first to avoid cache misses + if cfg.DryRun { + log.Printf("Simulating upload of RPMs to GCS (%s)\n", folderURI) + } else { + log.Printf("Uploading RPMs to GCS (%s)...\n", folderURI) + args := []string{"-m", "cp"} + args = append(args, rpms...) + args = append(args, folderURI) + //nolint:gosec + cmd = exec.Command("gsutil", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to upload RPMs to GCS: %w\n%s", err, output) + } + } + + if cfg.DryRun { + log.Printf("Simulating upload of RPM repo metadata to GCS (%s)\n", folderURI) + } else { + log.Printf("Uploading RPM repo metadata to GCS (%s)...\n", folderURI) + //nolint:gosec + cmd = exec.Command("gsutil", "-m", "rsync", "-r", "-d", repoRoot, folderURI) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to upload RPM repo metadata to GCS: %w\n%s", err, output) + } + allRepoResources := fmt.Sprintf("%s/**/*", folderURI) + log.Printf("Setting cache ttl for RPM repo resources on GCS (%s)...\n", allRepoResources) + //nolint:gosec + cmd = exec.Command("gsutil", "-m", "setmeta", "-h", CacheSettings+cfg.TTL, allRepoResources) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to set cache ttl for RPM repo resources on GCS: %w\n%s", err, output) + } + } + + return nil +} + +// downloadRPMs downloads RPM packages. +func downloadRPMs(cfg PublishConfig, workDir string) error { + if !strings.HasSuffix(workDir, string(filepath.Separator)) { + workDir += string(filepath.Separator) + } + var version string + if cfg.ReleaseMode.Mode == config.TagMode { + if cfg.ReleaseMode.IsBeta { + version = strings.ReplaceAll(cfg.Version, "-", "~") + } else { + version = cfg.Version + } + } + if version == "" { + panic(fmt.Sprintf("Unrecognized version mode %s", cfg.ReleaseMode.Mode)) + } + + var sfx string + switch cfg.Edition { + case config.EditionOSS: + case config.EditionEnterprise: + sfx = EnterpriseSfx + default: + return fmt.Errorf("unrecognized edition %q", cfg.Edition) + } + + u := fmt.Sprintf("gs://%s/%s/%s/grafana%s-%s-*.*.rpm*", cfg.Bucket, + strings.ToLower(string(cfg.Edition)), ReleaseFolder, sfx, version) + log.Printf("Downloading RPM packages %q...\n", u) + args := []string{ + "-m", + "cp", + u, + workDir, + } + //nolint:gosec + cmd := exec.Command("gsutil", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to download RPM packages %q: %w\n%s", u, err, output) + } + + return nil +} + +func getPublicKey(cfg PublishConfig) (*packet.PublicKey, error) { + f, err := os.Open(cfg.GPGPublicKey) + if err != nil { + return nil, fmt.Errorf("failed to open %q: %w", cfg.GPGPublicKey, err) + } + defer func(f *os.File) { + err := f.Close() + if err != nil { + return + } + }(f) + + block, err := armor.Decode(f) + if err != nil { + return nil, err + } + + if block.Type != openpgp.PublicKeyType { + return nil, fmt.Errorf("invalid public key block type: %q", block.Type) + } + + packetReader := packet.NewReader(block.Body) + pkt, err := packetReader.Next() + if err != nil { + return nil, err + } + + key, ok := pkt.(*packet.PublicKey) + if !ok { + return nil, fmt.Errorf("got non-public key from packet reader: %T", pkt) + } + + return key, nil +} + +func getPrivateKey(cfg PublishConfig) (*packet.PrivateKey, error) { + f, err := os.Open(cfg.GPGPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to open %q: %w", cfg.GPGPrivateKey, err) + } + defer func(f *os.File) { + err := f.Close() + if err != nil { + return + } + }(f) + + passphraseB, err := os.ReadFile(cfg.GPGPassPath) + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", cfg.GPGPrivateKey, err) + } + passphraseB = bytes.TrimSuffix(passphraseB, []byte("\n")) + + block, err := armor.Decode(f) + if err != nil { + return nil, err + } + + if block.Type != openpgp.PrivateKeyType { + return nil, fmt.Errorf("invalid private key block type: %q", block.Type) + } + + packetReader := packet.NewReader(block.Body) + pkt, err := packetReader.Next() + if err != nil { + return nil, err + } + + key, ok := pkt.(*packet.PrivateKey) + if !ok { + return nil, fmt.Errorf("got non-private key from packet reader: %T", pkt) + } + + if err := key.Decrypt(passphraseB); err != nil { + return nil, fmt.Errorf("failed to decrypt private key: %w", err) + } + return key, nil +} + +// signRPMRepo signs an RPM repository using PGP. +// The signature gets written to the file repodata/repomd.xml.asc. +func signRPMRepo(repoRoot string, cfg PublishConfig) error { + if cfg.GPGPublicKey == "" || cfg.GPGPrivateKey == "" { + return fmt.Errorf("private or public key is empty") + } + + log.Printf("Signing RPM repo") + + pubKey, err := getPublicKey(cfg) + if err != nil { + return err + } + + privKey, err := getPrivateKey(cfg) + if err != nil { + return err + } + + pcfg := packet.Config{ + DefaultHash: crypto.SHA256, + DefaultCipher: packet.CipherAES256, + DefaultCompressionAlgo: packet.CompressionZLIB, + CompressionConfig: &packet.CompressionConfig{ + Level: 9, + }, + RSABits: 4096, + } + currentTime := pcfg.Now() + uid := packet.NewUserId("", "", "") + + isPrimaryID := false + keyLifetimeSecs := uint32(86400 * 365) + signer := openpgp.Entity{ + PrimaryKey: pubKey, + PrivateKey: privKey, + Identities: map[string]*openpgp.Identity{ + uid.Id: &openpgp.Identity{ + Name: uid.Name, + UserId: uid, + SelfSignature: &packet.Signature{ + CreationTime: currentTime, + SigType: packet.SigTypePositiveCert, + PubKeyAlgo: packet.PubKeyAlgoRSA, + Hash: pcfg.Hash(), + IsPrimaryId: &isPrimaryID, + FlagsValid: true, + FlagSign: true, + FlagCertify: true, + IssuerKeyId: &pubKey.KeyId, + }, + }, + }, + Subkeys: []openpgp.Subkey{ + { + PublicKey: pubKey, + PrivateKey: privKey, + Sig: &packet.Signature{ + CreationTime: currentTime, + SigType: packet.SigTypeSubkeyBinding, + PubKeyAlgo: packet.PubKeyAlgoRSA, + Hash: pcfg.Hash(), + PreferredHash: []uint8{8}, // SHA-256 + FlagsValid: true, + FlagEncryptStorage: true, + FlagEncryptCommunications: true, + IssuerKeyId: &pubKey.KeyId, + KeyLifetimeSecs: &keyLifetimeSecs, + }, + }, + }, + } + + // Ignore gosec G304 as this function is only used in the build process. + //nolint:gosec + freader, err := os.Open(filepath.Join(repoRoot, "repodata", "repomd.xml")) + if err != nil { + return err + } + defer func(freader *os.File) { + err := freader.Close() + if err != nil { + return + } + }(freader) + + // Ignore gosec G304 as this function is only used in the build process. + //nolint:gosec + sigwriter, err := os.Create(filepath.Join(repoRoot, "repodata", "repomd.xml.asc")) + if err != nil { + return err + } + defer func(sigwriter *os.File) { + err := sigwriter.Close() + if err != nil { + return + } + }(sigwriter) + + if err := openpgp.ArmoredDetachSignText(sigwriter, &signer, freader, nil); err != nil { + return fmt.Errorf("failed to write PGP signature: %w", err) + } + + if err := sigwriter.Close(); err != nil { + return fmt.Errorf("failed to write PGP signature: %w", err) + } + + return nil +} diff --git a/pkg/build/cmd/rpm_test.go b/pkg/build/cmd/rpm_test.go new file mode 100644 index 00000000000..60d8ac5f50a --- /dev/null +++ b/pkg/build/cmd/rpm_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/stretchr/testify/require" +) + +const pubKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: OpenPGP.js v4.10.10 +Comment: https://openpgpjs.org + +xsBNBGM1b9wBCADZM49X7vwOS93KbgA6yhpwrYf8ZlzksGcDaYgp1IzvqHbs +xeU1mmBYVH/bSKRDG0tt3Qdky4Nvl4Oqd+g0e2ZGjmlEy9zUiPTTK/BtXT+5 +s8oqih2NIAkyF91BNZABAgvh/vJdYImhYeUQBDqMJgqZ/Y/Ha31N7rSW+jUt +LHspbN0ztJYjuEd/bg2NKH7Gs/AyNvX9IQTC4k7iRRafx7q/PBCVtsk+NCwz +BEkL93xpAdcdYiMNrRP2eIHQjBmNZ/oUCkcDsLCBvcSq6P2lGpNnpPzVoTJf +v2qrWkVn5txJJsOkmBGpEDbECPunVilrWO6RPomP0yYkr6NE4XeCJ3QhABEB +AAHNGWR1bW15IDxkdW1teUBob3RtYWlsLmNvbT7CwI0EEAEIACAFAmM1b9wG +CwkHCAMCBBUICgIEFgIBAAIZAQIbAwIeAQAhCRAoJ1i5w6kkAxYhBCQv+iwt +IFn7vj9PLygnWLnDqSQDPxkH/0Ju2Cah+bOxl09uv2Ft2BVlQh0u+wJyRVgs +KxTxldAXFZwMrN4wK/GUoGWDiy2tzNtoVE6GpxWUj+LvSGFaVLNVjW+Le77I +BP/sl1wKHJbQhseKc7Mz5Zj3i0F1FPM+rLik7tNk6kiEBqYVyyXahyT98Hu1 +1OKEV+8NiRG47iNgd/dpgEdVSS4DN/dL6m5q+CVy9YnlR+wXxF/2xcMmWBzR +V2cPVw0JzunpUV8lDDQ/n1sPw61D3oL1aH0bkn8aA8pEceKOVIYOaja7LkLX +uSlROlALA/M2fuubradW9I3FcrJNn+/xA52el2l/Hn/Syf9GQV/Ll/R+qKIo +Z57xWd7OwE0EYzVv3AEIAJl/PNYOF2prNKY58BfZ74XurDb9mNlZ1wsIqrOu +J/euzHEnzkCAjMUuXV7wcugjQlmpcZn6Y0QmQ2uX7SwPCMovDvngbXeAfbdd +6FUKecQ0sG54Plm8HSMNdjetdUVl7ACxjJO8Rdc/Asx7ua7gMm42CVfqMj4L +qN5foUBlaKJ1iGKUpQ+673UQWMYeOBuu9G8awbSzGaphN97CIX7xEMGzGeff +yHLHK+MsfX935uDgDwJQzxJKEugIJDMKgWOLgVz1jRCsJKHlywHTWpVuMiKY +Wnuq4tDNLBUQtaRL7uclG7Wejw/XNN0uD/zNHPgF5rmlYHVhrtDbBCP2XqTn +WU8AEQEAAcLAdgQYAQgACQUCYzVv3AIbDAAhCRAoJ1i5w6kkAxYhBCQv+iwt +IFn7vj9PLygnWLnDqSQDFqYH/AkdNaPUQlE7RQBigNRGOFBuqjhbLsV/rZf+ +/4K6wDHojM606lgLm57T4NUXnk53VIF3KO8+v8N11mCtPb+zBngfvVU14COC +HEDNdOK19TlR+tH25cftfUiF+OJsgMQysErGuFEtwLE6TNzpQIcnw7SbjxMr +EGacF9xCBKexB6MlR3GwJ2LBUJm3Lq/fvqImztoTlKDsrpk4JOH5FfYG+G2f +1zU73fVsCCElX4qA/49rRQf0RNfhjRjmHULP8hSvCXUEhfiBggEgxof/vKlC +qauHC55luuIeabju8HaXTjpz019cq+3IUgewX/ky0PhQXEW9SoODKabPY2yS +yUbHFm4= +=OCSx +-----END PGP PUBLIC KEY BLOCK----- +` + +const privKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: OpenPGP.js v4.10.10 +Comment: https://openpgpjs.org + +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 +RscWbg== +=KJNy +-----END PGP PRIVATE KEY BLOCK----- +` + +// Dummy GPG keys, used only for testing +// nolint:gosec +const passPhrase = `MkDgjkrgdGxt` + +func TestSignRPMRepo(t *testing.T) { + repoDir := t.TempDir() + workDir := t.TempDir() + pubKeyPath := filepath.Join(workDir, "pub.key") + err := os.WriteFile(pubKeyPath, []byte(pubKey), 0600) + require.NoError(t, err) + privKeyPath := filepath.Join(workDir, "priv.key") + err = os.WriteFile(privKeyPath, []byte(privKey), 0600) + require.NoError(t, err) + passPhrasePath := filepath.Join(workDir, "passphrase") + err = os.WriteFile(passPhrasePath, []byte(passPhrase), 0600) + require.NoError(t, err) + err = os.Mkdir(filepath.Join(repoDir, "repodata"), 0700) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(repoDir, "repodata", "repomd.xml"), []byte(""), 0600) + require.NoError(t, err) + + cfg := PublishConfig{ + Config: config.Config{ + GPGPrivateKey: privKeyPath, + GPGPublicKey: pubKeyPath, + GPGPassPath: passPhrasePath, + }, + } + + err = signRPMRepo(repoDir, cfg) + require.NoError(t, err) +}