Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement ORAS helper command #114

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions cmd/azoras/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# azoras

azoras is a tool that helps work with the ORAS CLI and Azure ACR.
The subcommands implement common workflows for image annotations and maintenance.

## Installation

```shell
go install github.com/go-infra/cmd/azoras@latest
```

Optionally, azoras can install the ORAS CLI for you running:

```shell
azoras install
```

## Authentication

azoras doesn't handle authentication, you need to login to your Azure ACR registry with the ORAS CLI.
See https://oras.land/docs/how_to_guides/authentication for more information.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

azoras doesn't handle authentication seems to conflict with azoras provides a helper subcommand to login. I think I get what this is trying to say (it won't use any ambient auth if you go directly to run the azoras deprecate command) but that doesn't seem all that important vs. listing the relatively painless commands that you actually need to run to get it set up.

I think this could be moved into a note after the instructions, and I don't think it needs to be worded like azoras is lacking a major feature, just pointing out what's going on behind the scenes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I'll rephrase this section.


azoras provides a helper subcommand to login to login the ORAS CLI to your Azure ACR registry using the Azure CLI.
qmuntal marked this conversation as resolved.
Show resolved Hide resolved
You first need to login to the Azure CLI with `az login` and then run:

```shell
azoras login
```
qmuntal marked this conversation as resolved.
Show resolved Hide resolved

## Subcommands

### `azoras deprecate`

Deprecate an image in an Azure ACR registry.

```shell
azoras deprecate myregistry.azurecr.io/myimage:sha256:foo
```

This subcommand can also deprecated multiple images at once using a file with a line-separated list of images.

```shell
azoras deprecate images.txt -bulk
```
30 changes: 30 additions & 0 deletions cmd/azoras/azoras.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
package main
qmuntal marked this conversation as resolved.
Show resolved Hide resolved

import (
"log"

"github.com/microsoft/go-infra/subcmd"
)

// version is the semver of this tool. Compared against the value in the config file (if any) to
// ensure that all users of the tool contributing to a given repo have a new enough version of the
// tool to support all patching features used in that repo.
//
// When adding a new feature to the azoras tool, make sure it is backward compatible and
// increment the patch number here.
const version = "v1.0.0"
qmuntal marked this conversation as resolved.
Show resolved Hide resolved

const description = `
azoras is a tool that helps work with the ORAS CLI and Azure ACR.
The subcommands implement common workflows for image annotations and maintenance.
`

var subcommands []subcmd.Option

func main() {
if err := subcmd.Run("azoras", description, subcommands); err != nil {
log.Fatal(err)
}
}
133 changes: 133 additions & 0 deletions cmd/azoras/deprecate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package main

import (
"encoding/json"
"flag"
"log"
"os"
"os/exec"
"strings"
"time"

"github.com/microsoft/go-infra/executil"
"github.com/microsoft/go-infra/subcmd"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

const (
artifactTypeLifecycle = "application/vnd.microsoft.artifact.lifecycle"
annotationNameEoL = "vnd.microsoft.artifact.lifecycle.end-of-life.date"
)

func init() {
subcommands = append(subcommands, subcmd.Option{
Name: "deprecate",
Summary: "Deprecate the given image by annotating it with an end-of-life date.",
Description: `
Examples:

go run ./cmd/azoras deprecate myregistry.azurecr.io/myimage:sha256:foo
go run ./cmd/azoras deprecate images.txt -bulk -eol 2022-12-31T23:59:59Z
`,
Handle: handleDeprecate,
TakeArgsReason: "The fully qualified image to deprecate or a file containing a newline-separated list of images to deprecate if -bulk is set.",
})
}

func handleDeprecate(p subcmd.ParseFunc) error {
eolStr := flag.String("eol", "", "The end-of-life date for the image in RFC3339 format. Defaults to the current time.")
bulk := flag.Bool("bulk", false, "Deprecate multiple images.")
qmuntal marked this conversation as resolved.
Show resolved Hide resolved
if err := p(); err != nil {
return err
}
if _, err := exec.LookPath("oras"); err != nil {
return err
}
ref := flag.Arg(0)
eol := time.Now()
if *eolStr != "" {
var err error
eol, err = time.Parse(time.RFC3339, *eolStr)
if err != nil {
return err
}
}
if !*bulk {
// Deprecate a single image.
return deprecate(ref, eol)
}

// Deprecate multiple images.
data, err := os.ReadFile(ref)
if err != nil {
return err
}
images := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
return deprecateBulk(images, eol)
}

// deprecateBulk deprecates multiple images in bulk.
func deprecateBulk(images []string, eol time.Time) error {
var err error
for _, image := range images {
if err1 := deprecate(image, eol); err1 != nil {
log.Printf("Failed to deprecate image %s: %v\n", image, err1)
if err == nil {
err = err1
}
}
}
return err
}

// deprecate annotates the given image with an end-of-life date.
func deprecate(image string, eol time.Time) error {
prevs, err := getAnnotation(image, artifactTypeLifecycle, annotationNameEoL)
if err != nil {
// Log the error and continue with the deprecation, as this is a best-effort operation.
log.Printf("Failed to get the EoL date for image %s: %v\n", image, err)
}
for _, prev := range prevs {
t, err := time.Parse(time.RFC3339, prev)
if err == nil && t.Before(eol) {
// The image is already deprecated.
log.Printf("Image %s is already past its EoL date of %s\n", image, prev)
return nil
}
}
cmdOras := exec.Command("oras", "attach", "--artifact-type", artifactTypeLifecycle, "--annotation", annotationNameEoL+"="+eol.Format(time.RFC3339), image)
qmuntal marked this conversation as resolved.
Show resolved Hide resolved
err = executil.Run(cmdOras)
if err != nil {
return err
}
log.Printf("Image %s deprecated with an EoL date of %s\n", image, eol.Format(time.RFC3339))
return nil
}

// getAnnotation returns the list of values for the given annotation name on the given image.
func getAnnotation(image, artifactType, name string) ([]string, error) {
cmd := exec.Command("oras", "discover", "-o", "json", image)
out, err := executil.CombinedOutput(cmd)
if err != nil {
return nil, err
}
var index ocispec.Index
if err := json.Unmarshal([]byte(out), &index); err != nil {
return nil, err
}
var vals []string
for _, manifest := range index.Manifests {
if manifest.ArtifactType != artifactType {
continue
}
for key, val := range manifest.Annotations {
if key == name {
vals = append(vals, val)
}
}
}
return vals, nil
}
178 changes: 178 additions & 0 deletions cmd/azoras/install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package main

import (
"bytes"
"crypto/sha256"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"

"github.com/microsoft/go-infra/internal/archive"
"github.com/microsoft/go-infra/subcmd"
)

const (
orasVersion = "1.1.0"
)

// checksums is copied from https://github.com/oras-project/oras/releases/download/v1.1.0/oras_1.1.0_checksums.txt.
var checksums = map[string]string{
"oras_1.1.0_linux_s390x.tar.gz": "067600d61d5d7c23f7bd184cff168ad558d48bed99f6735615bce0e1068b1d77",
"oras_1.1.0_windows_amd64.zip": "2ac83631181d888445e50784a5f760f7f9d97fba3c089e79b68580c496fe68cf",
"oras_1.1.0_darwin_arm64.tar.gz": "d52d3140b0bb9f7d7e31dcbf2a513f971413769c11f7d7a5599e76cc98e45007",
"oras_1.1.0_linux_armv7.tar.gz": "def86e7f787f8deee50bb57d1c155201099f36aa0c6700d3b525e69ddf8ae49b",
"oras_1.1.0_linux_amd64.tar.gz": "e09e85323b24ccc8209a1506f142e3d481e6e809018537c6b3db979c891e6ad7",
"oras_1.1.0_linux_arm64.tar.gz": "e450b081f67f6fda2f16b7046075c67c9a53f3fda92fd20ecc59873b10477ab4",
"oras_1.1.0_darwin_amd64.tar.gz": "f8ac5dea53dd9331cf080f1025f0612e7b07c5af864a4fd609f97d8946508e45",
qmuntal marked this conversation as resolved.
Show resolved Hide resolved
}

func init() {
subcommands = append(subcommands, subcmd.Option{
Name: "install",
Summary: "Install the ORAS CLI.",
Description: "",
Handle: handleInit,
})
}

func handleInit(p subcmd.ParseFunc) error {
if err := p(); err != nil {
return err
}
if path, err := exec.LookPath("oras"); err == nil {
log.Printf("ORAS CLI is already installed at %s\n", path)
return nil
}
dir, err := installDir()
if err != nil {
return err
}
if err := downloadAndInstallOras(dir); err != nil {
os.RemoveAll(dir) // Best effort to clean up the directory.
qmuntal marked this conversation as resolved.
Show resolved Hide resolved
qmuntal marked this conversation as resolved.
Show resolved Hide resolved
return err
}
return nil
}

// downloadOras downloads the ORAS CLI and installs it in the recommended location.
// See https://oras.land/docs/installation/ for more information.
func downloadAndInstallOras(dir string) error {
targetFile := orasGitHubFileName()
content, err := download(targetFile)
if err != nil {
return err
}
return install(dir, content, filepath.Ext(targetFile))
}

// install installs the ORAS CLI in the given directory.
func install(dir string, content []byte, format string) error {
log.Println("Extacting ORAS CLI...")
name := orasBinName()
content, err := extract(format, name, content)
if err != nil {
return err
}
log.Println("Extracted ORAS CLI")

bin := filepath.Join(dir, name)
log.Printf("Installing ORAS CLI to %#q...\n", bin)
if err := os.WriteFile(bin, content, 0o755); err != nil {
return err
}
log.Println("Installed ORAS CLI")
if runtime.GOOS == "windows" {
log.Printf("Add %#q to your PATH environment variable so that oras.exe can be found.\n", dir)
}
return nil
}

// download fetches the ORAS CLI from the GitHub release page.
func download(name string) ([]byte, error) {
expectedChecksum, ok := checksums[name]
if !ok {
return nil, fmt.Errorf("unable to find checksum for %s", name)
}
target := fmt.Sprintf("https://github.com/oras-project/oras/releases/download/v%s/%s", orasVersion, name)
log.Printf("Downloading ORAS CLI from %s\n", target)
resp, err := http.Get(target)
if err != nil {
return nil, fmt.Errorf("unable to download ORAS CLI: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unable to download ORAS CLI: %s", resp.Status)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("unable to read response body: %v", err)
}

// Validate the checksum
sum := fmt.Sprintf("%x", sha256.Sum256(content))
if sum != expectedChecksum {
return nil, fmt.Errorf("SHA256 mismatch.\n Expected: %v\n Downloaded: %v", expectedChecksum, sum)
}
log.Println("Downloaded ORAS CLI")
return content, nil
}

// extract extracts the ORAS CLI from the archive.
func extract(format string, name string, data []byte) (content []byte, err error) {
switch format {
case ".zip":
content, err = archive.UnzipOneFile(name, bytes.NewReader(data), int64(len(data)))
case ".tar.gz":
content, err = archive.UntarOneFile(name, bytes.NewReader(data), true)
default:
return nil, fmt.Errorf("unsupported archive format: %s", format)
}
if err != nil {
return nil, fmt.Errorf("unable to extract ORAS CLI: %v", err)
}
if content == nil {
return nil, fmt.Errorf("unable to find %s in the archive", name)
}
return content, nil
}

func orasGitHubFileName() string {
arch := runtime.GOARCH
if arch == "arm" {
// ORAS uses "armv7" instead of "arm" in the URL.
arch = "armv7"
}
ext := "tar.gz"
if runtime.GOOS == "windows" {
ext = "zip"
}
return fmt.Sprintf("oras_%s_%s_%s.%s", orasVersion, runtime.GOOS, arch, ext)
}

func installDir() (string, error) {
var dir string
switch runtime.GOOS {
case "windows":
dir = filepath.Join(os.Getenv("USERPROFILE"), "bin")
default:
dir = "/usr/local/bin"
qmuntal marked this conversation as resolved.
Show resolved Hide resolved
}
err := os.MkdirAll(dir, 0o755)
return dir, err
}

func orasBinName() string {
ext := ""
if runtime.GOOS == "windows" {
ext = ".exe"
}
return "oras" + ext
}
Loading