Skip to content

Commit

Permalink
Add restore-release utility (#381)
Browse files Browse the repository at this point in the history
Add a restore-release utility which can automate restoring the container
registry to the same images and tags as a previous release. This can be
used to avoid unnecessary plugin installations and revision bumps for
plugins whose Dockerfile or metadata didn't change since the last
release (in this case, due to broken automation with a
tj-actions/changed-files action).
  • Loading branch information
pkwarren authored Feb 21, 2023
1 parent f3dd513 commit 75dc7a4
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 88 deletions.
43 changes: 43 additions & 0 deletions .github/workflows/restore-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Restore Release

on:
workflow_dispatch:
inputs:
release_tag:
description: "The release tag that should be restored in the container registry"
required: true
arguments:
description: "Arguments to the restore release command"
required: false
default: '-dry-run'

permissions:
contents: read
packages: write

jobs:
release:
environment: production
if: github.repository == 'bufbuild/plugins'
runs-on: ubuntu-22.04
steps:
- name: Checkout repository code
uses: actions/checkout@v3
- name: Login to GitHub Container Registry
if: github.repository == 'bufbuild/plugins'
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: 1.20.x
check-latest: true
cache: true
- name: Restore Release
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
go run ./cmd/restore-release ${{ inputs.arguments }} ${{ inputs.release_tag }}
174 changes: 174 additions & 0 deletions cmd/restore-release/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

// restore-release takes a GitHub release name (e.g. yyyyMMdd.N)
// and restores the container registry to the state of the release.
// This can be used in case images are pushed by accident, to avoid
// unnecessary installation and revision bumps for images which
// haven't changed.
//
// It takes a -dry-run argument to do all tasks except the final
// docker push command, so it is safe to run prior to making changes.

import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/exec"
"strings"

"github.com/bufbuild/plugins/internal/release"
)

func main() {
dryRun := flag.Bool("dry-run", false, "perform a dry-run (no GitHub modifications)")
flag.Parse()

if len(flag.Args()) != 1 {
_, _ = fmt.Fprintln(flag.CommandLine.Output(), "usage: restore-release <releaseName>")
flag.PrintDefaults()
os.Exit(2)
}
releaseName := flag.Args()[0]
cmd := &command{
dryRun: *dryRun,
release: releaseName,
}
if err := cmd.run(); err != nil {
log.Fatalln(err.Error())
}
}

type command struct {
dryRun bool
release string
}

func (c *command) run() error {
ctx := context.Background()
client := release.NewClient(ctx)
githubRelease, err := client.GetReleaseByTag(ctx, release.GithubOwnerBufbuild, release.GithubRepoPlugins, c.release)
if err != nil {
return fmt.Errorf("failed to retrieve release %s: %w", c.release, err)
}
pluginReleasesBytes, _, err := client.DownloadAsset(ctx, githubRelease, release.PluginReleasesFile)
if err != nil {
return fmt.Errorf("failed to download plugin-releases.json: %w", err)
}
var pluginReleases release.PluginReleases
if err := json.Unmarshal(pluginReleasesBytes, &pluginReleases); err != nil {
return fmt.Errorf("invalid plugin-releases.json format: %w", err)
}
for _, pluginRelease := range pluginReleases.Releases {
image, err := fetchRegistryImage(pluginRelease)
if err != nil {
return err
}
if image == pluginRelease.RegistryImage {
continue
}
// The current registry image doesn't match the release's plugin-releases.json.
taggedImage, _, found := strings.Cut(image, "@")
if !found {
return fmt.Errorf("invalid image format: %s", image)
}
taggedImage += ":" + pluginRelease.PluginVersion
log.Printf("updating image tag %q to point from %q to %q", taggedImage, image, pluginRelease.RegistryImage)
if err := pullImage(pluginRelease.RegistryImage); err != nil {
return fmt.Errorf("failed to pull %q: %w", pluginRelease.RegistryImage, err)
}
if err := tagImage(pluginRelease.RegistryImage, taggedImage); err != nil {
return fmt.Errorf("failed to tag %q: %w", taggedImage, err)
}
if !c.dryRun {
if err := pushImage(taggedImage); err != nil {
return fmt.Errorf("failed to push %q: %w", taggedImage, err)
}
}
}
return nil
}

func pullImage(name string) error {
cmd, err := dockerCmd("pull", name)
if err != nil {
return err
}
log.Printf("pulling image: %s", name)
return cmd.Run()
}

func tagImage(previousName, newName string) error {
cmd, err := dockerCmd("tag", previousName, newName)
if err != nil {
return err
}
log.Printf("tagging image: %s => %s", previousName, newName)
return cmd.Run()
}

func pushImage(name string) error {
cmd, err := dockerCmd("push", name)
if err != nil {
return err
}
log.Printf("pushing image: %s", name)
return cmd.Run()
}

func dockerCmd(command string, args ...string) (*exec.Cmd, error) {
dockerPath, err := exec.LookPath("docker")
if err != nil {
return nil, err
}
cmd := &exec.Cmd{
Path: dockerPath,
Args: append([]string{
dockerPath,
command,
}, args...),
Stdout: os.Stdout,
Stderr: os.Stderr,
}
return cmd, nil
}

func fetchRegistryImage(pluginRelease release.PluginRelease) (string, error) {
owner, pluginName, found := strings.Cut(pluginRelease.PluginName, "/")
if !found {
return "", fmt.Errorf("invalid plugin name: %q", pluginRelease.PluginName)
}
imageName := fmt.Sprintf("ghcr.io/%s/plugins-%s-%s", release.GithubOwnerBufbuild, owner, pluginName)
cmd, err := dockerCmd("manifest", "inspect", "--verbose", imageName+":"+pluginRelease.PluginVersion)
if err != nil {
return "", err
}
var bb bytes.Buffer
cmd.Stdout = &bb
if err := cmd.Run(); err != nil {
return "", err
}
type manifestJSON struct {
Descriptor struct {
Digest string `json:"digest"`
} `json:"Descriptor"` //nolint:tagliatelle
SchemaV2Manifest struct {
Config struct {
Digest string `json:"digest"`
} `json:"config"`
} `json:"SchemaV2Manifest"` //nolint:tagliatelle
}
var result manifestJSON
if err := json.Unmarshal(bb.Bytes(), &result); err != nil {
return "", fmt.Errorf("unable to parse docker manifest inspect output: %w", err)
}
descriptorDigest := result.Descriptor.Digest
if descriptorDigest == "" {
return "", errors.New("unable to parse descriptor digest from docker manifest inspect output")
}
return fmt.Sprintf("%s@%s", imageName, descriptorDigest), nil
}
10 changes: 6 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.19

require (
aead.dev/minisign v0.2.0
github.com/bufbuild/buf v1.10.1-0.20221214040745-bc1fb9464910
github.com/bufbuild/buf v1.14.0
github.com/google/go-github/v48 v48.2.0
github.com/hashicorp/go-retryablehttp v0.7.2
github.com/sethvargo/go-envconfig v0.9.0
Expand All @@ -17,15 +17,17 @@ require (

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/otel v1.12.0 // indirect
go.opentelemetry.io/otel/trace v1.12.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
Expand Down
Loading

0 comments on commit 75dc7a4

Please sign in to comment.