From 4203e944d769bf4196f9dac680c304dd52f4b3a8 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Fri, 5 Apr 2024 09:04:35 +0200 Subject: [PATCH 01/10] implement azoras cmd --- cmd/azoras/README.md | 44 ++++++++++ cmd/azoras/azoras.go | 30 +++++++ cmd/azoras/deprecate.go | 133 ++++++++++++++++++++++++++++++ cmd/azoras/install.go | 178 ++++++++++++++++++++++++++++++++++++++++ cmd/azoras/login.go | 47 +++++++++++ go.mod | 6 +- go.sum | 5 ++ internal/archive/tar.go | 35 ++++++++ internal/archive/zip.go | 29 +++++++ 9 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 cmd/azoras/README.md create mode 100644 cmd/azoras/azoras.go create mode 100644 cmd/azoras/deprecate.go create mode 100644 cmd/azoras/install.go create mode 100644 cmd/azoras/login.go create mode 100644 internal/archive/tar.go create mode 100644 internal/archive/zip.go diff --git a/cmd/azoras/README.md b/cmd/azoras/README.md new file mode 100644 index 00000000..c476bff4 --- /dev/null +++ b/cmd/azoras/README.md @@ -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. + +azoras provides a helper subcommand to login to login the ORAS CLI to your Azure ACR registry using the Azure CLI. +You first need to login to the Azure CLI with `az login` and then run: + +```shell +azoras login +``` + +## 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 +``` diff --git a/cmd/azoras/azoras.go b/cmd/azoras/azoras.go new file mode 100644 index 00000000..57fa2e2f --- /dev/null +++ b/cmd/azoras/azoras.go @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +package main + +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" + +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) + } +} diff --git a/cmd/azoras/deprecate.go b/cmd/azoras/deprecate.go new file mode 100644 index 00000000..e2ed03d2 --- /dev/null +++ b/cmd/azoras/deprecate.go @@ -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.") + 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) + 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 +} diff --git a/cmd/azoras/install.go b/cmd/azoras/install.go new file mode 100644 index 00000000..18504e9c --- /dev/null +++ b/cmd/azoras/install.go @@ -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", +} + +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. + 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" + } + err := os.MkdirAll(dir, 0o755) + return dir, err +} + +func orasBinName() string { + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + return "oras" + ext +} diff --git a/cmd/azoras/login.go b/cmd/azoras/login.go new file mode 100644 index 00000000..a0eba751 --- /dev/null +++ b/cmd/azoras/login.go @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package main + +import ( + "flag" + "os" + "os/exec" + + "github.com/microsoft/go-infra/subcmd" +) + +func init() { + subcommands = append(subcommands, subcmd.Option{ + Name: "login", + Summary: "Log in to the Azure Container Registry using the Azure CLI, which needs to be installed.", + Description: "", + Handle: handleLogin, + TakeArgsReason: "The name of the Azure Container Registry to log in to.", + }) +} + +func handleLogin(p subcmd.ParseFunc) error { + if err := p(); err != nil { + return err + } + if _, err := exec.LookPath("oras"); err != nil { + return err + } + acr := flag.Arg(0) + cmdAz := exec.Command("az", "acr", "login", "--name", acr, "--expose-token", "--output", "tsv", "--query", "accessToken") + cmdOras := exec.Command("oras", "login", "--password-stdin", acr) + var err error + cmdOras.Stdin, err = cmdAz.StdoutPipe() + if err != nil { + return err + } + cmdOras.Stdout = os.Stdout + if err := cmdOras.Start(); err != nil { + return err + } + if err := cmdAz.Run(); err != nil { + return err + } + return cmdOras.Wait() +} diff --git a/go.mod b/go.mod index a94f9b03..5575fe5d 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,16 @@ module github.com/microsoft/go-infra -go 1.18 +go 1.21 + +toolchain go1.21.2 require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 github.com/go-test/deep v1.1.0 github.com/google/go-github v17.0.0+incompatible github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 + github.com/opencontainers/image-spec v1.1.0 golang.org/x/mod v0.16.0 golang.org/x/oauth2 v0.18.0 ) @@ -20,6 +23,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect golang.org/x/net v0.22.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.33.0 // indirect diff --git a/go.sum b/go.sum index e6f7c3ee..2eaa4d3d 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,7 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -22,6 +23,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 h1:YH424zrwLTlyHSH/GzLMJeu5zhYVZSx5RQxGKm1h96s= github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= diff --git a/internal/archive/tar.go b/internal/archive/tar.go new file mode 100644 index 00000000..d27fedbc --- /dev/null +++ b/internal/archive/tar.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +package archive + +import ( + "archive/tar" + "compress/gzip" + "io" +) + +// UntarOneFile extracts a single file from a tar archive. +// It returns the contents of the file, or nil if the file is not found. +func UntarOneFile(name string, r io.Reader, isGziped bool) ([]byte, error) { + if isGziped { + var err error + r, err = gzip.NewReader(r) + if err != nil { + return nil, err + } + } + tr := tar.NewReader(r) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if header.Name == name { + return io.ReadAll(tr) + } + } + return nil, nil +} diff --git a/internal/archive/zip.go b/internal/archive/zip.go new file mode 100644 index 00000000..8ea447cb --- /dev/null +++ b/internal/archive/zip.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package archive + +import ( + "archive/zip" + "io" +) + +// UnzipOneFile extracts a single file from a zip archive. +// It returns the contents of the file, or nil if the file is not found. +func UnzipOneFile(name string, r io.ReaderAt, size int64) ([]byte, error) { + z, err := zip.NewReader(r, size) + if err != nil { + return nil, err + } + for _, zf := range z.File { + if zf.Name == name { + rc, err := zf.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + } + } + return nil, nil +} From a325472b616167641e63926e834a97ee69d2bf6e Mon Sep 17 00:00:00 2001 From: qmuntal Date: Fri, 5 Apr 2024 09:11:04 +0200 Subject: [PATCH 02/10] revert go1.21 bump in go.mod --- go.mod | 4 +--- go.sum | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 5575fe5d..47f40d58 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,7 @@ module github.com/microsoft/go-infra -go 1.21 - -toolchain go1.21.2 +go 1.18 require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 diff --git a/go.sum b/go.sum index 2eaa4d3d..09c591ae 100644 --- a/go.sum +++ b/go.sum @@ -11,7 +11,6 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= From 3f6fcc41ba0ae73d4f855ac6cd9e4dd5f1c8cff4 Mon Sep 17 00:00:00 2001 From: Quim Muntal Date: Mon, 8 Apr 2024 08:39:48 +0200 Subject: [PATCH 03/10] Apply suggestions from code review Co-authored-by: Davis Goodin --- cmd/azoras/README.md | 17 +++++++++++------ cmd/azoras/azoras.go | 1 + cmd/azoras/deprecate.go | 6 +++++- internal/archive/tar.go | 5 +++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/cmd/azoras/README.md b/cmd/azoras/README.md index c476bff4..6fefa8c4 100644 --- a/cmd/azoras/README.md +++ b/cmd/azoras/README.md @@ -20,12 +20,17 @@ azoras install 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. -azoras provides a helper subcommand to login to login the ORAS CLI to your Azure ACR registry using the Azure CLI. -You first need to login to the Azure CLI with `az login` and then run: - -```shell -azoras login -``` +azoras provides a helper subcommand to login the ORAS CLI to your Azure ACR registry using the Azure CLI. + +1. Log into the Azure CLI: + ```shell + az login + ``` +2. Use the utility command to log into your ACR: + ```shell + azoras login + ``` + An ACR name is `golangimages`, *not* its login server URL. ## Subcommands diff --git a/cmd/azoras/azoras.go b/cmd/azoras/azoras.go index 57fa2e2f..99d0199c 100644 --- a/cmd/azoras/azoras.go +++ b/cmd/azoras/azoras.go @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. + package main import ( diff --git a/cmd/azoras/deprecate.go b/cmd/azoras/deprecate.go index e2ed03d2..a26901c2 100644 --- a/cmd/azoras/deprecate.go +++ b/cmd/azoras/deprecate.go @@ -98,7 +98,11 @@ func deprecate(image string, eol time.Time) error { return nil } } - cmdOras := exec.Command("oras", "attach", "--artifact-type", artifactTypeLifecycle, "--annotation", annotationNameEoL+"="+eol.Format(time.RFC3339), image) + cmdOras := exec.Command( + "oras", "attach", + "--artifact-type", artifactTypeLifecycle, + "--annotation", annotationNameEoL+"="+eol.Format(time.RFC3339), + image) err = executil.Run(cmdOras) if err != nil { return err diff --git a/internal/archive/tar.go b/internal/archive/tar.go index d27fedbc..781d8cc9 100644 --- a/internal/archive/tar.go +++ b/internal/archive/tar.go @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. + package archive import ( @@ -10,8 +11,8 @@ import ( // UntarOneFile extracts a single file from a tar archive. // It returns the contents of the file, or nil if the file is not found. -func UntarOneFile(name string, r io.Reader, isGziped bool) ([]byte, error) { - if isGziped { +func UntarOneFile(name string, r io.Reader, isGzipped bool) ([]byte, error) { + if isGzipped { var err error r, err = gzip.NewReader(r) if err != nil { From 99c1d7b315fceff2fb4fca82be28b2e7e338fd12 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 8 Apr 2024 08:50:54 +0200 Subject: [PATCH 04/10] improve oras auth section --- cmd/azoras/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/azoras/README.md b/cmd/azoras/README.md index 6fefa8c4..78992bf4 100644 --- a/cmd/azoras/README.md +++ b/cmd/azoras/README.md @@ -17,9 +17,6 @@ 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. - azoras provides a helper subcommand to login the ORAS CLI to your Azure ACR registry using the Azure CLI. 1. Log into the Azure CLI: @@ -32,6 +29,9 @@ azoras provides a helper subcommand to login the ORAS CLI to your Azure ACR regi ``` An ACR name is `golangimages`, *not* its login server URL. +It is also possible to login to the ORAS CLI manually using the `oras login` command. +See the [ORAS CLI Authentication](https://oras.land/docs/how_to_guides/authentication/) documentation for more information. + ## Subcommands ### `azoras deprecate` From 017f4d9f83f24dc6abe2ca679466c1e3343952b2 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 8 Apr 2024 08:51:50 +0200 Subject: [PATCH 05/10] add azoras version subcommand --- cmd/azoras/version.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 cmd/azoras/version.go diff --git a/cmd/azoras/version.go b/cmd/azoras/version.go new file mode 100644 index 00000000..73219786 --- /dev/null +++ b/cmd/azoras/version.go @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package main + +import ( + "bytes" + "fmt" + "os/exec" + + "github.com/microsoft/go-infra/subcmd" +) + +func init() { + subcommands = append(subcommands, subcmd.Option{ + Name: "version", + Summary: "Print the version of the azoras tool and the ORAS CLI.", + Description: "", + Handle: handleVersion, + }) +} + +func handleVersion(p subcmd.ParseFunc) error { + if err := p(); err != nil { + return err + } + fmt.Printf("azoras version: %s\n", version) + if _, err := exec.LookPath("oras"); err != nil { + fmt.Printf("oras not found\n") + return nil + } + orasVersion, err := exec.Command("oras", "version").CombinedOutput() + if err != nil { + fmt.Printf("oras version: error: %v\n", err) + return nil + } + orasVersion = bytes.ReplaceAll(orasVersion, []byte("\n"), []byte("\n\t")) + fmt.Printf("ORAS CLI version:\n\t%s\n", orasVersion) + return nil +} From 573984702ea87410d935e3e930962b7dbc15a1b1 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 8 Apr 2024 09:00:06 +0200 Subject: [PATCH 06/10] embed unmodified oras checksums file --- cmd/azoras/checksums/oras_1.1.0_checksums.txt | 7 +++++ cmd/azoras/install.go | 30 ++++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 cmd/azoras/checksums/oras_1.1.0_checksums.txt diff --git a/cmd/azoras/checksums/oras_1.1.0_checksums.txt b/cmd/azoras/checksums/oras_1.1.0_checksums.txt new file mode 100644 index 00000000..175f5048 --- /dev/null +++ b/cmd/azoras/checksums/oras_1.1.0_checksums.txt @@ -0,0 +1,7 @@ +067600d61d5d7c23f7bd184cff168ad558d48bed99f6735615bce0e1068b1d77 oras_1.1.0_linux_s390x.tar.gz +2ac83631181d888445e50784a5f760f7f9d97fba3c089e79b68580c496fe68cf oras_1.1.0_windows_amd64.zip +d52d3140b0bb9f7d7e31dcbf2a513f971413769c11f7d7a5599e76cc98e45007 oras_1.1.0_darwin_arm64.tar.gz +def86e7f787f8deee50bb57d1c155201099f36aa0c6700d3b525e69ddf8ae49b oras_1.1.0_linux_armv7.tar.gz +e09e85323b24ccc8209a1506f142e3d481e6e809018537c6b3db979c891e6ad7 oras_1.1.0_linux_amd64.tar.gz +e450b081f67f6fda2f16b7046075c67c9a53f3fda92fd20ecc59873b10477ab4 oras_1.1.0_linux_arm64.tar.gz +f8ac5dea53dd9331cf080f1025f0612e7b07c5af864a4fd609f97d8946508e45 oras_1.1.0_darwin_amd64.tar.gz diff --git a/cmd/azoras/install.go b/cmd/azoras/install.go index 18504e9c..362eb6fa 100644 --- a/cmd/azoras/install.go +++ b/cmd/azoras/install.go @@ -6,6 +6,7 @@ package main import ( "bytes" "crypto/sha256" + _ "embed" "fmt" "io" "log" @@ -23,16 +24,10 @@ 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", -} +// checksums1_1_0 is copied from https://github.com/oras-project/oras/releases/download/v1.1.0/oras_1.1.0_checksums.txt. +// +//go:embed checksums/oras_1.1.0_checksums.txt +var checksums1_1_0 []byte func init() { subcommands = append(subcommands, subcmd.Option{ @@ -97,6 +92,7 @@ func install(dir string, content []byte, format string) error { // download fetches the ORAS CLI from the GitHub release page. func download(name string) ([]byte, error) { + checksums := parseChecksums(checksums1_1_0) expectedChecksum, ok := checksums[name] if !ok { return nil, fmt.Errorf("unable to find checksum for %s", name) @@ -176,3 +172,17 @@ func orasBinName() string { } return "oras" + ext } + +func parseChecksums(data []byte) map[string]string { + checksums := make(map[string]string) + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + lines := bytes.Split(data, []byte("\n")) + for _, line := range lines { + parts := bytes.Fields(line) + if len(parts) != 2 { + continue + } + checksums[string(parts[1])] = string(parts[0]) + } + return checksums +} From cd68b86883e008b8e2b6d9f1f7afd577907b6e6f Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 8 Apr 2024 09:03:42 +0200 Subject: [PATCH 07/10] error out if UntarOneFile detects a duplicated file --- internal/archive/tar.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/archive/tar.go b/internal/archive/tar.go index 781d8cc9..51dd74be 100644 --- a/internal/archive/tar.go +++ b/internal/archive/tar.go @@ -6,6 +6,7 @@ package archive import ( "archive/tar" "compress/gzip" + "errors" "io" ) @@ -20,6 +21,7 @@ func UntarOneFile(name string, r io.Reader, isGzipped bool) ([]byte, error) { } } tr := tar.NewReader(r) + var data []byte for { header, err := tr.Next() if err == io.EOF { @@ -29,8 +31,14 @@ func UntarOneFile(name string, r io.Reader, isGzipped bool) ([]byte, error) { return nil, err } if header.Name == name { - return io.ReadAll(tr) + if data != nil { + return nil, errors.New("multiple files found with the same name") + } + data, err = io.ReadAll(tr) + if err != nil { + return nil, err + } } } - return nil, nil + return data, nil } From 9d6a8e6763de87ed127849b062290e17445ffdb6 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 8 Apr 2024 09:08:33 +0200 Subject: [PATCH 08/10] install oras in custom location --- cmd/azoras/install.go | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/cmd/azoras/install.go b/cmd/azoras/install.go index 362eb6fa..15c4965c 100644 --- a/cmd/azoras/install.go +++ b/cmd/azoras/install.go @@ -50,11 +50,7 @@ func handleInit(p subcmd.ParseFunc) error { if err != nil { return err } - if err := downloadAndInstallOras(dir); err != nil { - os.RemoveAll(dir) // Best effort to clean up the directory. - return err - } - return nil + return downloadAndInstallOras(dir) } // downloadOras downloads the ORAS CLI and installs it in the recommended location. @@ -84,9 +80,7 @@ func install(dir string, content []byte, format string) error { 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) - } + log.Printf("Add %#q to your PATH environment variable so that oras can be found.\n", dir) return nil } @@ -154,14 +148,15 @@ func orasGitHubFileName() string { } func installDir() (string, error) { - var dir string - switch runtime.GOOS { - case "windows": - dir = filepath.Join(os.Getenv("USERPROFILE"), "bin") - default: - dir = "/usr/local/bin" + userDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(userDir, ".msft-go-infra", "oras") + err = os.MkdirAll(dir, 0o755) + if err != nil { + return "", err } - err := os.MkdirAll(dir, 0o755) return dir, err } From c8105ba5323646f43ed87706d7f1d9ffa2404504 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 8 Apr 2024 09:19:15 +0200 Subject: [PATCH 09/10] append bulk images to positional arg images --- cmd/azoras/README.md | 2 +- cmd/azoras/deprecate.go | 32 +++++++++++++++----------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/cmd/azoras/README.md b/cmd/azoras/README.md index 78992bf4..5e7a9ba1 100644 --- a/cmd/azoras/README.md +++ b/cmd/azoras/README.md @@ -45,5 +45,5 @@ 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 +azoras deprecate -bulk images.txt ``` diff --git a/cmd/azoras/deprecate.go b/cmd/azoras/deprecate.go index a26901c2..be6e3389 100644 --- a/cmd/azoras/deprecate.go +++ b/cmd/azoras/deprecate.go @@ -4,6 +4,7 @@ package main import ( + "bytes" "encoding/json" "flag" "log" @@ -30,16 +31,16 @@ func init() { 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 + go run ./cmd/azoras deprecate -bulk images.txt -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.", + TakeArgsReason: "A list of fully-qualified image to deprecate", }) } 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.") + bulk := flag.String("bulk", "", "A file containing a line-separated list of images to deprecate in bulk.") if err := p(); err != nil { return err } @@ -55,24 +56,21 @@ func handleDeprecate(p subcmd.ParseFunc) error { 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 := append([]string{}, flag.Args()...) + if *bulk != "" { + data, err := os.ReadFile(ref) + if err != nil { + return err + } + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + images = append(images, strings.Split(string(data), "\n")...) } - 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 image == "" { + continue + } if err1 := deprecate(image, eol); err1 != nil { log.Printf("Failed to deprecate image %s: %v\n", image, err1) if err == nil { From 48cba95f8afed0294c4d30d1dc75f8d939fc4267 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 15 Apr 2024 16:55:43 +0200 Subject: [PATCH 10/10] fix bulk argument parsing --- cmd/azoras/deprecate.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/azoras/deprecate.go b/cmd/azoras/deprecate.go index be6e3389..4867f80b 100644 --- a/cmd/azoras/deprecate.go +++ b/cmd/azoras/deprecate.go @@ -47,7 +47,7 @@ func handleDeprecate(p subcmd.ParseFunc) error { if _, err := exec.LookPath("oras"); err != nil { return err } - ref := flag.Arg(0) + eol := time.Now() if *eolStr != "" { var err error @@ -58,7 +58,7 @@ func handleDeprecate(p subcmd.ParseFunc) error { } images := append([]string{}, flag.Args()...) if *bulk != "" { - data, err := os.ReadFile(ref) + data, err := os.ReadFile(*bulk) if err != nil { return err }