From 0d4ea37ea1ea57a450bfb08662e3b66360a970da Mon Sep 17 00:00:00 2001 From: Anthony Juckel Date: Thu, 20 Jun 2024 17:32:48 -0500 Subject: [PATCH] feat: add initial implementation --- .gitignore | 3 +- DOCS.md | 101 +++++++- Dockerfile | 25 ++ Makefile | 271 ++++++++++++++++++++ cmd/vela-manifest-tool/command.go | 49 ++++ cmd/vela-manifest-tool/command_test.go | 53 ++++ cmd/vela-manifest-tool/main.go | 191 ++++++++++++++ cmd/vela-manifest-tool/main_test.go | 22 ++ cmd/vela-manifest-tool/manifestspec.go | 158 ++++++++++++ cmd/vela-manifest-tool/manifestspec_test.go | 196 ++++++++++++++ cmd/vela-manifest-tool/plugin.go | 145 +++++++++++ cmd/vela-manifest-tool/plugin_test.go | 89 +++++++ cmd/vela-manifest-tool/registry.go | 98 +++++++ cmd/vela-manifest-tool/registry_test.go | 135 ++++++++++ cmd/vela-manifest-tool/repo.go | 53 ++++ cmd/vela-manifest-tool/repo_test.go | 84 ++++++ go.mod | 26 ++ go.sum | 54 ++++ version/version.go | 64 +++++ 19 files changed, 1814 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/vela-manifest-tool/command.go create mode 100644 cmd/vela-manifest-tool/command_test.go create mode 100644 cmd/vela-manifest-tool/main.go create mode 100644 cmd/vela-manifest-tool/main_test.go create mode 100644 cmd/vela-manifest-tool/manifestspec.go create mode 100644 cmd/vela-manifest-tool/manifestspec_test.go create mode 100644 cmd/vela-manifest-tool/plugin.go create mode 100644 cmd/vela-manifest-tool/plugin_test.go create mode 100644 cmd/vela-manifest-tool/registry.go create mode 100644 cmd/vela-manifest-tool/registry_test.go create mode 100644 cmd/vela-manifest-tool/repo.go create mode 100644 cmd/vela-manifest-tool/repo_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 version/version.go diff --git a/.gitignore b/.gitignore index 7b57fe7..0414e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ release/ # Local testing files -.secrets.env \ No newline at end of file +.secrets.env +*~ diff --git a/DOCS.md b/DOCS.md index cedd8bf..d7af212 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,5 +1,102 @@ ## Description -TODO: FILL ME +This plugin enables you to build and publish [Docker Manifest List](https://www.docker.com/) +or [OCI Image Index](https://github.com/opencontainers/image-spec/blob/main/image-index.md) +in a Vela pipeline. + +Source Code: https://github.com/go-vela/vela-manifest-tool + +Registry: https://hub.docker.com/r/target/vela-manifest-tool + +## Usage + +> **NOTE:** +> +> Users should refrain from using latest as the tag for the Docker image. +> +> It is recommended to use a semantically versioned tag instead. + +Sample of building and publishing an image: + +```yaml +steps: + - name: publish_hello-world + image: target/vela-manifest-tool:latest + pull: always + parameters: + registry: index.docker.io + repo: /octocat/hello-world + tags: [ "latest" ] + platforms: + - linux/amd64 + - linux/arm64/v8 + component_template: /octocat/hello-world:latest-{{ .Os }}-{{ .Arch }}{{ if .Variant }}-{{ .Variant }}{{ end }} +``` + +NOTE: For vela-manifest-tool, unlike for vela-kaniko, the `repo` argument excludes the `registry` value. Said another +way, rather than using: + +```yaml +parameters: + registry: index.docker.io + repo: index.docker.io/octocat/hello-world + ... + component_template: index.docker.io/octocat/hello-world:latest-{{ .Os }}-{{ .Arch }}{{ if .Variant }}-{{ .Variant }}{{ end }} +``` + +You must instead use: + +```yaml +parameters: + registry: index.docker.io + repo: /octocat/hello-world + ... + component_template: /octocat/hello-world:latest-{{ .Os }}-{{ .Arch }}{{ if .Variant }}-{{ .Variant }}{{ end }} +``` + +This is because manifest tool requires that all image repos referenced exist within the same registry. Resulting tags will +all be the concatenation of the registry with the repo. + +Sample of building an image without publishing: + +```yaml +steps: + - name: publish_hello-world + image: target/vela-manifest-tool:latest + pull: always + parameters: ++ dry_run: true + registry: index.docker.io + repo: /octocat/hello-world + tags: [ "latest" ] + platforms: + - linux/amd64 + - linux/arm64/v8 + component_template: /octocat/hello-world:latest-{{ .Os }}-{{ .Arch }}{{ if .Variant }}-{{ .Variant }}{{ end }} +``` + +For every element of `tags:`, one spec file will be generated and (unless `dry_run: true`) pushed to the `registry:`. +For each manifest-tool spec file, the tag for the manifest list/image index will be `$registry$repo:$tag`. Then there will +be one element in the `manifests:` list of the spec file for each element of the `platform:` argument. Platform is assumed +to be in `os/architecture/variant` format. Within the `component_template`, you can use Os, Arch, Variant (from the platform), +or Tag (from the top level `tags:`). + +Note: The default component_template of `"{{.Repo}}:{{.Tag}}-{{.Os}}-{{.Arch}}{{if .Variant}}-{{.Variant}}{{end}}"` might +be sufficient for most needs if you follow that tagging convention. For example, if the builds for /octocat/hello-world created +the architecture specific image + +- index.docker.io/octocat/hello-world:latest-linux-amd64 +- index.docker.io/octocat/hello-world:latest-linux-arm64-v8 + +Then the following configuration would be sufficient due to defaults for tags, platforms, and component_template: + +```yaml +steps: + - name: publish_hello-world + image: target/vela-manifest-tool:latest + pull: always + parameters: + registry: index.docker.io + repo: /octocat/hello-world +``` -see: https://github.com/go-vela/vela-kaniko/blob/main/DOCS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5467076 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: Apache-2.0 + +################################################################################ +## docker build --no-cache --target certs -t vela-manifest-tool:certs . ## +################################################################################ + +FROM alpine:3.19.1@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b as certs + +RUN apk add --update --no-cache ca-certificates + +################################################################# +## docker build --no-cache -t vela-manifest-tool:local . ## +################################################################# + +FROM mplatform/manifest-tool:alpine-v2.1.6@sha256:96db9e944c50a5f7514394af4e44f764725645cfd2efef92d87095b0016a55ae + +COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt + +WORKDIR /workspace + +RUN mkdir /root/.docker + +COPY release/vela-manifest-tool /bin/vela-manifest-tool + +ENTRYPOINT [ "/bin/vela-manifest-tool" ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d6ef1d8 --- /dev/null +++ b/Makefile @@ -0,0 +1,271 @@ +# SPDX-License-Identifier: Apache-2.0 + +# capture the current date we build the application from +BUILD_DATE = $(shell date +%Y-%m-%dT%H:%M:%SZ) + +# check if a git commit sha is already set +ifndef GITHUB_SHA + # capture the current git commit sha we build the application from + GITHUB_SHA = $(shell git rev-parse HEAD) +endif + +# check if a git tag is already set +ifndef GITHUB_TAG + # capture the current git tag we build the application from + GITHUB_TAG = $(shell git describe --tag --abbrev=0) +endif + +# check if a go version is already set +ifndef GOLANG_VERSION + # capture the current go version we build the application from + GOLANG_VERSION = $(shell go version | awk '{ print $$3 }') +endif + +# create a list of linker flags for building the golang application +LD_FLAGS = -X github.com/go-vela/vela-manifest-tool/version.Commit=${GITHUB_SHA} -X github.com/go-vela/vela-manifest-tool/version.Date=${BUILD_DATE} -X github.com/go-vela/vela-manifest-tool/version.Go=${GOLANG_VERSION} -X github.com/go-vela/vela-manifest-tool/version.Tag=${GITHUB_TAG} + +# The `clean` target is intended to clean the workspace +# and prepare the local changes for submission. +# +# Usage: `make clean` +.PHONY: clean +clean: tidy vet fmt fix + +# The `run` target is intended to build and +# execute the Docker image for the plugin. +# +# Usage: `make run` +.PHONY: run +run: build docker-build docker-run + +# The `tidy` target is intended to clean up +# the Go module files (go.mod & go.sum). +# +# Usage: `make tidy` +.PHONY: tidy +tidy: + @echo + @echo "### Tidying Go module" + @go mod tidy + +# The `vet` target is intended to inspect the +# Go source code for potential issues. +# +# Usage: `make vet` +.PHONY: vet +vet: + @echo + @echo "### Vetting Go code" + @go vet ./... + +# The `fmt` target is intended to format the +# Go source code to meet the language standards. +# +# Usage: `make fmt` +.PHONY: fmt +fmt: + @echo + @echo "### Formatting Go Code" + @go fmt ./... + +# The `fix` target is intended to rewrite the +# Go source code using old APIs. +# +# Usage: `make fix` +.PHONY: fix +fix: + @echo + @echo "### Fixing Go Code" + @go fix ./... + +# The `test` target is intended to run +# the tests for the Go source code. +# +# Usage: `make test` +.PHONY: test +test: + @echo + @echo "### Testing Go Code" + @go test -race ./... + +# The `test-cover` target is intended to run +# the tests for the Go source code and then +# open the test coverage report. +# +# Usage: `make test-cover` +.PHONY: test-cover +test-cover: + @echo + @echo "### Creating test coverage report" + @go test -race -covermode=atomic -coverprofile=coverage.out ./... + @echo + @echo "### Opening test coverage report" + @go tool cover -html=coverage.out + +# The `build` target is intended to compile +# the Go source code into a binary. +# +# Usage: `make build` +.PHONY: build +build: + @echo + @echo "### Building release/vela-manifest-tool binary" + GOOS=linux CGO_ENABLED=0 \ + go build -a \ + -ldflags '${LD_FLAGS}' \ + -o release/vela-manifest-tool \ + github.com/go-vela/vela-manifest-tool/cmd/vela-manifest-tool + +# The `build-static` target is intended to compile +# the Go source code into a statically linked binary. +# +# Usage: `make build-static` +.PHONY: build-static +build-static: + @echo + @echo "### Building static release/vela-manifest-tool binary" + GOOS=linux CGO_ENABLED=0 \ + go build -a \ + -ldflags '-s -w -extldflags "-static" ${LD_FLAGS}' \ + -o release/vela-manifest-tool \ + github.com/go-vela/vela-manifest-tool/cmd/vela-manifest-tool + +.PHONY: build-static-amd64 +build-static-amd64: + @echo + @echo "### Building static release/vela-manifest-tool binary" + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ + go build -a \ + -ldflags '-s -w -extldflags "-static" ${LD_FLAGS}' \ + -o release/vela-manifest-tool \ + github.com/go-vela/vela-manifest-tool/cmd/vela-manifest-tool + +# The `build-static-ci` target is intended to compile +# the Go source code into a statically linked binary +# when used within a CI environment. +# +# Usage: `make build-static-ci` +.PHONY: build-static-ci +build-static-ci: + @echo + @echo "### Building CI static release/vela-manifest-tool binary" + @go build -a \ + -ldflags '-s -w -extldflags "-static" ${LD_FLAGS}' \ + -o release/vela-manifest-tool \ + github.com/go-vela/vela-manifest-tool/cmd/vela-manifest-tool + +# The `check` target is intended to output all +# dependencies from the Go module that need updates. +# +# Usage: `make check` +.PHONY: check +check: check-install + @echo + @echo "### Checking dependencies for updates" + @go list -u -m -json all | go-mod-outdated -update + +# The `check-direct` target is intended to output direct +# dependencies from the Go module that need updates. +# +# Usage: `make check-direct` +.PHONY: check-direct +check-direct: check-install + @echo + @echo "### Checking direct dependencies for updates" + @go list -u -m -json all | go-mod-outdated -direct + +# The `check-full` target is intended to output +# all dependencies from the Go module. +# +# Usage: `make check-full` +.PHONY: check-full +check-full: check-install + @echo + @echo "### Checking all dependencies for updates" + @go list -u -m -json all | go-mod-outdated + +# The `check-install` target is intended to download +# the tool used to check dependencies from the Go module. +# +# Usage: `make check-install` +.PHONY: check-install +check-install: + @echo + @echo "### Installing psampaz/go-mod-outdated" + @go get -u github.com/psampaz/go-mod-outdated + +# The `bump-deps` target is intended to upgrade +# non-test dependencies for the Go module. +# +# Usage: `make bump-deps` +.PHONY: bump-deps +bump-deps: check + @echo + @echo "### Upgrading dependencies" + @go get -u ./... + +# The `bump-deps-full` target is intended to upgrade +# all dependencies for the Go module. +# +# Usage: `make bump-deps-full` +.PHONY: bump-deps-full +bump-deps-full: check + @echo + @echo "### Upgrading all dependencies" + @go get -t -u ./... + +# The `docker-build` target is intended to build +# the Docker image for the plugin. +# +# Usage: `make docker-build` +.PHONY: docker-build +docker-build: + @echo + @echo "### Building vela-manifest-tool:local image" + @docker build --no-cache -t vela-manifest-tool:local . + +# The `docker-test` target is intended to execute +# the Docker image for the plugin with test variables. +# +# Usage: `make docker-test` +.PHONY: docker-test +docker-test: + @echo + @echo "### Testing vela-manifest-tool:local image" + @docker run --rm \ + -e PARAMETER_CONTEXT=/workspace/ \ + -e PARAMETER_DOCKERFILE=Dockerfile.example \ + -e PARAMETER_DRY_RUN=true \ + -e PARAMETER_REGISTRY=index.docker.io \ + -e PARAMETER_REPO=index.docker.io/target/vela-manifest-tool \ + -e PARAMETER_TAGS=latest \ + -e VELA_BUILD_COMMIT=123abcdefg \ + -e VELA_BUILD_EVENT=push \ + -v $(shell pwd):/workspace \ + vela-manifest-tool:local + +# The `docker-run` target is intended to execute +# the Docker image for the plugin. +# +# Usage: `make docker-run` +.PHONY: docker-run +docker-run: + @echo + @echo "### Executing vela-manifest-tool:local image" + @echo "PARAMETER_REGISTRY: ${PARAMETER_REGISTRY}" + @docker run --rm \ + -e DOCKER_USERNAME \ + -e DOCKER_PASSWORD \ + -e PARAMETER_DRY_RUN \ + -e PARAMETER_REGISTRY \ + -e PARAMETER_REPO \ + -e PARAMETER_TAGS \ + -e VELA_BUILD_AUTHOR_EMAIL \ + -e VELA_BUILD_COMMIT \ + -e VELA_BUILD_EVENT \ + -e VELA_BUILD_NUMBER \ + -e VELA_BUILD_TAG \ + -e VELA_REPO_FULL_NAME \ + -e VELA_REPO_LINK \ + -v $(shell pwd):/workspace \ + vela-manifest-tool:local diff --git a/cmd/vela-manifest-tool/command.go b/cmd/vela-manifest-tool/command.go new file mode 100644 index 0000000..d384f6b --- /dev/null +++ b/cmd/vela-manifest-tool/command.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/sirupsen/logrus" +) + +const manifestToolBin = "/manifest-tool" + +// private variables just for test mocking +var stdout io.Writer = os.Stdout +var stderr io.Writer = os.Stderr + +// execCmd is a helper function to +// run the provided command. +func execCmd(e *exec.Cmd) error { + logrus.Tracef("executing cmd %s", strings.Join(e.Args, " ")) + + // set command stdout to OS stdout + e.Stdout = stdout + // set command stderr to OS stderr + e.Stderr = stderr + + // output "trace" string for command + fmt.Println("$", strings.Join(e.Args, " ")) + + return e.Run() +} + +// versionCmd is a helper function to output +// the client version information. +func versionCmd() *exec.Cmd { + logrus.Trace("creating manifest-tool version command") + + // variable to store flags for command + var flags []string + + // add flag to print version of manifest-tool command + flags = append(flags, "--version") + + return exec.Command(manifestToolBin, flags...) +} diff --git a/cmd/vela-manifest-tool/command_test.go b/cmd/vela-manifest-tool/command_test.go new file mode 100644 index 0000000..79e3a04 --- /dev/null +++ b/cmd/vela-manifest-tool/command_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +func TestVersion(t *testing.T) { + cmd := versionCmd() + cases := []struct { + arg, expected string + }{ + {cmd.Args[0], "manifest-tool"}, + {cmd.Args[1], "--version"}, + } + for _, tc := range cases { + if !strings.Contains(tc.arg, tc.expected) { + t.Errorf(`Expected %v to contain %q`, tc.arg, tc.expected) + } + } +} + +// Feels like execCmd should be written/tested in shared lib +func TestExecution(t *testing.T) { + cases := []struct { + args []string + expout, experr string + }{ + {[]string{"echo", "-n", "foo"}, "foo", ""}, + } + oldStdout := stdout + defer func() { stdout = oldStdout }() + oldStderr := stderr + defer func() { stderr = oldStderr }() + for _, tc := range cases { + var outbuf, errbuf bytes.Buffer + + stdout, stderr = &outbuf, &errbuf + cmd := exec.Command(tc.args[0], tc.args[1:]...) + err := execCmd(cmd) + if err != nil { + t.Errorf("Expected no error when creating command: %v", err) + } + if tc.expout != outbuf.String() { + t.Errorf("Expected %q to be equal to %q", outbuf.String(), tc.expout) + } + if tc.experr != errbuf.String() { + t.Errorf("Expected %q to be equal to %q", errbuf.String(), tc.experr) + } + } +} diff --git a/cmd/vela-manifest-tool/main.go b/cmd/vela-manifest-tool/main.go new file mode 100644 index 0000000..acf1fd9 --- /dev/null +++ b/cmd/vela-manifest-tool/main.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/go-vela/vela-manifest-tool/version" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" + + _ "github.com/joho/godotenv/autoload" +) + +//nolint:funlen // ignore function length due to comments and flags +func main() { + v := version.New() + + // serialize the version information as pretty JSON + bytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + logrus.Fatal(err) + } + + // output the version information to stdout + fmt.Fprintf(os.Stdout, "%s\n", string(bytes)) + + // create new CLI application + app := cli.NewApp() + + // Plugin Information + + app.Name = "vela-manifest-tool" + app.HelpName = "vela-manifest-tool" + app.Usage = "Vela Manifest Tool plugin for building and publishing manifest lists/image indices" + app.Copyright = "Copyright 2024 Target Brands, Inc. All rights reserved." + app.Authors = []*cli.Author{ + { + Name: "Vela Admins", + Email: "vela@target.com", + }, + } + + // Plugin Metadata + + app.Action = run + app.Compiled = time.Now() + app.Version = v.Semantic() + + // Plugin Flags + + app.Flags = []cli.Flag{ + + &cli.StringFlag{ + EnvVars: []string{"PARAMETER_LOG_LEVEL", "MANIFEST_TOOL_LOG_LEVEL"}, + FilePath: "/vela/parameters/manifest_tool/log_level,/vela/secrets/manifest_tool/log_level", + Name: "log.level", + Usage: "set log level - options: (trace|debug|info|warn|error|fatal|panic)", + Value: "info", + }, + + // Registry Flags + &cli.BoolFlag{ + EnvVars: []string{"PARAMETER_DRY_RUN", "MANIFEST_TOOL_DRY_RUN"}, + FilePath: "/vela/parameters/manifest_tool/dry_run,/vela/secrets/manifest_tool/dry_run", + Name: "registry.dry_run", + Usage: "enables building images without publishing to the registry", + }, + &cli.StringFlag{ + EnvVars: []string{"PARAMETER_REGISTRY", "MANIFEST_TOOL_REGISTRY"}, + FilePath: "/vela/parameters/manifest_tool/registry,/vela/secrets/manifest_tool/registry", + Name: "registry.name", + Usage: "Docker registry name to communicate with", + Value: "index.docker.io", + }, + &cli.StringFlag{ + EnvVars: []string{"PARAMETER_USERNAME", "MANIFEST_TOOL_USERNAME", "DOCKER_USERNAME"}, + FilePath: "/vela/parameters/manifest_tool/username,/vela/secrets/manifest_tool/username,/vela/secrets/managed-auth/username", + Name: "registry.username", + Usage: "user name for communication with the registry", + }, + &cli.StringFlag{ + EnvVars: []string{"PARAMETER_PASSWORD", "MANIFEST_TOOL_PASSWORD", "DOCKER_PASSWORD"}, + FilePath: "/vela/parameters/manifest_tool/password,/vela/secrets/manifest_tool/password,/vela/secrets/managed-auth/password", + Name: "registry.password", + Usage: "password for communication with the registry", + }, + &cli.IntFlag{ + EnvVars: []string{"PARAMETER_PUSH_RETRY", "MANIFEST_TOOL_PUSH_RETRY"}, + FilePath: "/vela/parameters/manifest_tool/push_retry,/vela/secrets/manifest_tool/push_retry", + Name: "registry.push_retry", + Usage: "number of retries for pushing an image to a remote destination", + }, + + // Repo Flags + &cli.StringFlag{ + EnvVars: []string{"PARAMETER_REPO", "MANIFEST_TOOL_REPO"}, + FilePath: "/vela/parameters/manifest_tool/repo,/vela/secrets/manifest_tool/repo", + Name: "repo.name", + Usage: "repository name for the image", + }, + &cli.StringSliceFlag{ + EnvVars: []string{"PARAMETER_TAGS", "MANIFEST_TOOL_TAGS"}, + FilePath: "/vela/parameters/manifest_tool/tags,/vela/secrets/manifest_tool/tags", + Name: "repo.tags", + Usage: "repository tags of the manifest list/image index", + Value: cli.NewStringSlice("latest"), + }, + &cli.StringSliceFlag{ + EnvVars: []string{"PARAMETER_PLATFORMS", "MANIFEST_TOOL_PLATFORMS"}, + FilePath: "/vela/parameters/manifest_tool/tags,/vela/secrets/manifest_tool/platforms", + Name: "repo.platforms", + Usage: "docker platforms to include in the manifest list/image index", + Value: cli.NewStringSlice("linux/amd64", "linux/arm64/v8"), + }, + &cli.StringFlag{ + EnvVars: []string{"PARAMETER_COMPONENT_TEMPLATE", "MANIFEST_TOOL_COMPONENT_TEMPLATE"}, + FilePath: "/vela/parameters/manifest_tool/component_template,/vela/secrets/manifest_tool/component_template", + Name: "repo.component_template", + Usage: "template used to render each component image", + Value: "{{.Repo}}:{{.Tag}}-{{.Os}}-{{.Arch}}{{if .Variant}}-{{.Variant}}{{end}}", + }, + } + + err = app.Run(os.Args) + if err != nil { + logrus.Fatal(err) + } +} + +// run executes the plugin based off the configuration provided. +func run(c *cli.Context) error { + // set the log level for the plugin + switch c.String("log.level") { + case "t", "trace", "Trace", "TRACE": + logrus.SetLevel(logrus.TraceLevel) + case "d", "debug", "Debug", "DEBUG": + logrus.SetLevel(logrus.DebugLevel) + case "w", "warn", "Warn", "WARN": + logrus.SetLevel(logrus.WarnLevel) + case "e", "error", "Error", "ERROR": + logrus.SetLevel(logrus.ErrorLevel) + case "f", "fatal", "Fatal", "FATAL": + logrus.SetLevel(logrus.FatalLevel) + case "p", "panic", "Panic", "PANIC": + logrus.SetLevel(logrus.PanicLevel) + case "i", "info", "Info", "INFO": + fallthrough + default: + logrus.SetLevel(logrus.InfoLevel) + } + + logrus.WithFields(logrus.Fields{ + "code": "https://github.com/go-vela/vela-manifest-tool", + "docs": "https://go-vela.github.io/docs/plugins/registry/pipeline/manifest-tool", + "registry": "https://hub.docker.com/r/target/vela-manifest-tool", + }).Info("Vela Manifest Tool Plugin") + + // create the plugin + p := &Plugin{ + // build configuration + // registry configuration + Registry: &Registry{ + DryRun: c.Bool("registry.dry_run"), + Name: c.String("registry.name"), + Username: c.String("registry.username"), + Password: c.String("registry.password"), + PushRetry: c.Int("registry.push_retry"), + }, + // repo configuration + Repo: &Repo{ + Name: c.String("repo.name"), + Tags: c.StringSlice("repo.tags"), + Platforms: c.StringSlice("repo.platforms"), + ComponentTemplate: c.String("repo.component_template"), + }, + } + + // validate the plugin + err := p.Validate() + if err != nil { + return err + } + + // execute the plugin + return p.Exec() +} diff --git a/cmd/vela-manifest-tool/main_test.go b/cmd/vela-manifest-tool/main_test.go new file mode 100644 index 0000000..05f2541 --- /dev/null +++ b/cmd/vela-manifest-tool/main_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + + "github.com/go-vela/vela-manifest-tool/version" +) + +func TestVersionCompatible(t *testing.T) { + v := version.New() + if v == nil { + t.Error("version.New should return a value") + } +} + +func TestVersionSemver(t *testing.T) { + version.Tag = "abcd" + v := version.New() + if v != nil { + t.Errorf("version.New should return nil if a non-semver Tag (%q) is provided", version.Tag) + } +} diff --git a/cmd/vela-manifest-tool/manifestspec.go b/cmd/vela-manifest-tool/manifestspec.go new file mode 100644 index 0000000..b15cede --- /dev/null +++ b/cmd/vela-manifest-tool/manifestspec.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/template" + + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +var allowedPlatforms = map[string]bool{ + "linux/amd64": true, + "linux/arm64": true, + "linux/arm64/v8": true, + "linux/arm": true, + "linux/arm/v7": true, +} + +type Manifest struct { + Spec ManifestSpec + Context ComponentContext + Template *template.Template +} + +// ManifestSpec represents the structure of the manifest-tool yaml spec file +type ManifestSpec struct { + Image string // name of the image index including tag + Manifests []ManifestComponent // list of component images to include in index +} + +type ManifestPlatform struct { + Os string + Architecture string + Variant string `yaml:",omitempty"` +} + +type ManifestComponent struct { + Image string // name of the component image to be referenced by the index + Platform ManifestPlatform // The platform specification for the component image +} + +type ComponentContext struct { + Repo string + Tag string + Os string + Arch string + Variant string +} + +func NewManifestSpec(reg *Registry, repo *Repo) ([]*ManifestSpec, error) { + specs := []*ManifestSpec{} + tmpl, err := template.New("component_template").Parse(repo.ComponentTemplate) + if err != nil { + return specs, err + } + + if len(reg.Name) == 0 { + return specs, fmt.Errorf("no registry name provided") + } + if len(repo.Name) == 0 { + return specs, fmt.Errorf("no repository name provided") + } + for _, tag := range repo.Tags { + ms := ManifestSpec{ + Image: reg.Name + repo.Name + ":" + tag, + Manifests: []ManifestComponent{}, + } + for _, platform := range repo.Platforms { + platformComp := strings.Split(platform, "/") + if len(platformComp) < 2 { + return nil, fmt.Errorf("malformed platform %s", platform) + } else if len(platformComp) == 2 { + // probably a better way to do this, just not sure how + // else to make the variant below clean + platformComp = append(platformComp, "") + } + ctx := ComponentContext{ + Repo: repo.Name, + Tag: tag, + Os: platformComp[0], + Arch: platformComp[1], + Variant: platformComp[2], + } + var compImgBuf bytes.Buffer + err = tmpl.Execute(&compImgBuf, ctx) + if err != nil { + return specs, err + } + compImg := compImgBuf.String() + comp := ManifestComponent{ + Image: fmt.Sprintf("%s%s", reg.Name, compImg), + Platform: ManifestPlatform{ + Os: ctx.Os, + Architecture: ctx.Arch, + Variant: ctx.Variant, + }, + } + ms.Manifests = append(ms.Manifests, comp) + } + specs = append(specs, &ms) + } + return specs, nil +} + +func (ms *ManifestSpec) Validate() error { + logrus.Trace("validating manifest spec plugin configuration") + + // verify repo is provided + if len(ms.Image) == 0 { + return fmt.Errorf("no top-level image provided") + } + + err := validateTagOfImage(ms.Image) + if err != nil { + return err + } + + // check if tags are provided + if len(ms.Manifests) > 0 { + // check each tag value for valid docker tag syntax + for _, compManifest := range ms.Manifests { + err = validateTagOfImage(compManifest.Image) + if err != nil { + return err + } + } + } else { + return fmt.Errorf("no component images provided") + } + + return nil +} + +func (ms *ManifestSpec) Render(wr io.Writer) error { + yamlData, err := yaml.Marshal(ms) + if err != nil { + return err + } + _, err = wr.Write(yamlData) + return err +} + +func validateTagOfImage(fullImage string) error { + topLevelImgParts := strings.Split(fullImage, ":") + + if len(topLevelImgParts) != 2 { + return fmt.Errorf("%s not in image:tag format", fullImage) + } + if !tagRegexp.MatchString(topLevelImgParts[1]) { + return fmt.Errorf(errTagValidation, topLevelImgParts[1]) + } + return nil +} diff --git a/cmd/vela-manifest-tool/manifestspec_test.go b/cmd/vela-manifest-tool/manifestspec_test.go new file mode 100644 index 0000000..1fde173 --- /dev/null +++ b/cmd/vela-manifest-tool/manifestspec_test.go @@ -0,0 +1,196 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/sirupsen/logrus" +) + +func init() { + logrus.SetFormatter(&logrus.TextFormatter{}) +} + +func TestManifestSpec_New_Validate(t *testing.T) { + man := defaultFixture(t) + + assertImageMatch(t, "index.docker.io/octocat/hello-world:latest", man.Image) + assertImageMatch(t, "index.docker.io/octocat/hello-world:latest-linux-amd64", + man.Manifests[0].Image) + assertImageMatch(t, "index.docker.io/octocat/hello-world:latest-linux-arm64-v8", + man.Manifests[1].Image) + var data bytes.Buffer + err := man.Render(&data) + if err != nil { + t.Errorf("Error encountered during render: %v", err) + } + expected := "image: index.docker.io/octocat/hello-world:latest\n" + + "manifests:\n" + + "- image: index.docker.io/octocat/hello-world:latest-linux-amd64\n" + + " platform:\n" + + " os: linux\n" + + " architecture: amd64\n" + + "- image: index.docker.io/octocat/hello-world:latest-linux-arm64-v8\n" + + " platform:\n" + + " os: linux\n" + + " architecture: arm64\n" + + " variant: v8\n" + if data.String() != expected { + t.Errorf("failed yaml rendering.\nexpected:\n%sactual:\n%s", expected, data.String()) + } +} + +func TestManifestSpec_Validations(t *testing.T) { + testCases := []struct { + name string + valid bool + avail bool + ms *ManifestSpec + }{ + { + name: "missing image", + valid: false, + avail: true, + ms: trMS(t, func(ms *ManifestSpec) *ManifestSpec { ms.Image = ""; return ms }), + }, + { + name: "missing repo", + valid: false, + avail: true, + ms: trMS(t, func(ms *ManifestSpec) *ManifestSpec { ms.Image = ""; return ms }), + }, + { + name: "invalid image", + valid: false, + avail: false, + ms: firstMS(NewManifestSpec(trReg(func(r *Registry) *Registry { + r.Name = "" + return r + }), defaultRepo())), + }, + { + name: "invalid reg", + valid: false, + avail: false, + ms: firstMS(NewManifestSpec(defaultRegistry(), trRep(func(r *Repo) *Repo { + r.Name = "" + return r + }))), + }, + { + name: "invalid platform", + valid: false, + avail: false, + ms: firstMS(NewManifestSpec(defaultRegistry(), trRep(func(r *Repo) *Repo { + r.Platforms = []string{"linux"} + return r + }))), + }, + { + name: "no platforms", + valid: false, + avail: true, + ms: firstMS(NewManifestSpec(defaultRegistry(), trRep(func(r *Repo) *Repo { + r.Platforms = []string{} + return r + }))), + }, + { + name: "no tags", + valid: false, + avail: false, + ms: firstMS(NewManifestSpec(defaultRegistry(), trRep(func(r *Repo) *Repo { + r.Tags = []string{} + return r + }))), + }, + { + name: "incomplete template", + valid: false, + avail: true, + ms: firstMS(NewManifestSpec(defaultRegistry(), trRep(func(r *Repo) *Repo { + r.ComponentTemplate = "{{.Repo}}" + return r + }))), + }, + { + name: "invalid tag", + valid: false, + avail: true, + ms: firstMS(NewManifestSpec(defaultRegistry(), trRep(func(r *Repo) *Repo { + r.Tags = []string{"invalid|tag"} + return r + }))), + }, + } + for _, tc := range testCases { + if tc.avail && tc.ms == nil { + t.Errorf("%s: expected manifest spec to be available, but none returned", tc.name) + } else if !tc.avail && tc.ms != nil { + t.Errorf("%s: expected no manifest specs, but at least one returned", tc.name) + } else if tc.avail && tc.ms != nil { + err := tc.ms.Validate() + if err != nil && tc.valid { + t.Errorf("%s: expected valid ManifestSpec, but got %v", tc.name, err) + } else if err == nil && !tc.valid { + t.Errorf("%s: expected invalid ManifestSpec, but got nil", tc.name) + } + } + } +} + +func firstMS(ms []*ManifestSpec, _ error) *ManifestSpec { + if len(ms) > 0 { + return ms[0] + } + return nil +} + +func defaultRegistry() *Registry { + return &Registry{ + Name: "index.docker.io", + Username: "test", + Password: "pass", + PushRetry: 1, + DryRun: true, + } +} + +func defaultRepo() *Repo { + return &Repo{ + Name: "/octocat/hello-world", + Tags: []string{"latest"}, + Platforms: []string{"linux/amd64", "linux/arm64/v8"}, + ComponentTemplate: "{{.Repo}}:{{.Tag}}-{{.Os}}-{{.Arch}}{{if .Variant}}-{{.Variant}}{{end}}", + } +} + +func defaultFixture(t *testing.T) *ManifestSpec { + ms, err := NewManifestSpec(defaultRegistry(), defaultRepo()) + if err != nil { + t.Fatalf("error encountered: %v", err) + } + if len(ms) != 1 { + t.Fatalf("should only have returned a single manifest spec") + } + return ms[0] +} + +// Translate ManifestSpec +func trMS(t *testing.T, f func(*ManifestSpec) *ManifestSpec) *ManifestSpec { + return f(defaultFixture(t)) +} + +func trReg(f func(r *Registry) *Registry) *Registry { + return f(defaultRegistry()) +} + +func trRep(f func(r *Repo) *Repo) *Repo { + return f(defaultRepo()) +} + +func assertImageMatch(t *testing.T, expected, actual string) { + if expected != actual { + t.Errorf("image mismatch\nexpected: %s !=\nactual: %s", expected, actual) + } +} diff --git a/cmd/vela-manifest-tool/plugin.go b/cmd/vela-manifest-tool/plugin.go new file mode 100644 index 0000000..d28cbcb --- /dev/null +++ b/cmd/vela-manifest-tool/plugin.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "regexp" + + "github.com/spf13/afero" + + "github.com/sirupsen/logrus" +) + +var ( + appFS = afero.NewOsFs() + + // regular expression to validate docker tags + // refs: + // - https://docs.docker.com/engine/reference/commandline/tag/#extended-description + // - https://github.com/distribution/distribution/blob/01f589cf8726565aa3c5c053be12873bafedbedc/reference/regexp.go#L41 + tagRegexp = regexp.MustCompile(`^[\w][\w.-]{0,127}$`) +) + +// errTagValidation defines the error message +// when the provided tag is not allowed. +const errTagValidation = "tag '%s' not allowed - see https://docs.docker.com/engine/reference/commandline/tag/#extended-description" + +// Plugin represents the configuration loaded for the plugin. +type Plugin struct { + Registry *Registry // registry arguments loaded for the plugin + Repo *Repo // repo arguments loaded for the plugin + manifestSpecs []*ManifestSpec // Parsed specs, populated as side effect of validate +} + +// Command formats and outputs the command necessary for +// manifest-tool to build and publish a Docker Manifest List or +// OCI Image Index +func (p *Plugin) Command(specFile string) *exec.Cmd { + logrus.Debug("creating manifest-tool command from plugin configuration") + + // variable to store flags for command + flags := []string{ + "push", + "from-spec", + specFile, + } + + return exec.Command(manifestToolBin, flags...) +} + +// Exec formats and runs the commands for building and publishing a Docker image. +func (p *Plugin) Exec() error { + logrus.Debug("running plugin with provided configuration") + + if len(p.manifestSpecs) == 0 { + return errors.New("no manifest specs") + } + + // create registry file for authentication + err := p.Registry.Write() + if err != nil { + return err + } + + // output the manifest-tool version for troubleshooting + err = execCmd(versionCmd()) + if err != nil { + return err + } + + manifestSpecs, err := NewManifestSpec(p.Registry, p.Repo) + if err != nil { + return err + } + a := &afero.Afero{ + Fs: appFS, + } + err = a.Mkdir("/root/specs", 0755) + if err != nil { + return err + } + + for i, spec := range manifestSpecs { + fmt.Printf("Processing manifest list/image index %s\n", spec.Image) + var data bytes.Buffer + err = spec.Render(&data) + if err != nil { + return err + } + + fmt.Printf("Rendered spec file:\n%s\n", data.String()) + specFilename := fmt.Sprintf("/root/specs/spec_%d.yml", i) + a.WriteFile(specFilename, data.Bytes(), 0644) + cmd := p.Command(specFilename) + // If a dry run, return without executing the cmd + if p.Registry.DryRun { + fmt.Println("Not pushing manifest list/image index as dry_run is true") + } else { + // run manifest-tool command from plugin configuration + err = execCmd(cmd) + if err != nil { + return err + } + } + } + + return nil +} + +// Validate verifies the Plugin is properly configured. +func (p *Plugin) Validate() error { + logrus.Debug("validating plugin configuration") + + var err error + + // validate registry configuration + err = p.Registry.Validate() + if err != nil { + return err + } + + // validate repo configuration + err = p.Repo.Validate() + if err != nil { + return err + } + + manifestSpecs, err := NewManifestSpec(p.Registry, p.Repo) + for _, ms := range manifestSpecs { + err = ms.Validate() + if err != nil { + return err + } + } + + if err != nil { + return err + } + p.manifestSpecs = manifestSpecs + + return nil +} diff --git a/cmd/vela-manifest-tool/plugin_test.go b/cmd/vela-manifest-tool/plugin_test.go new file mode 100644 index 0000000..e2116b8 --- /dev/null +++ b/cmd/vela-manifest-tool/plugin_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "testing" +) + +func TestPluginScenarios(t *testing.T) { + testCases := []struct { + name string + valid bool + p *Plugin + }{ + { + name: "all populated", + valid: true, + p: makeDefaultPlugin(), + }, + { + name: "invalid template", + valid: false, + p: trP(func(p *Plugin) *Plugin { p.Repo.ComponentTemplate = "{{"; return p }), + }, + { + name: "only one platform component", + valid: false, + p: trP(func(p *Plugin) *Plugin { p.Repo.Platforms = []string{"linux"}; return p }), + }, + { + name: "no image provided", + valid: false, + p: trP(func(p *Plugin) *Plugin { p.Repo.Name = ""; return p }), + }, + { + name: "no tags provided", + valid: false, + p: trP(func(p *Plugin) *Plugin { p.Repo.Tags = []string{}; return p }), + }, + { + name: "no registry name", + valid: false, + p: trP(func(p *Plugin) *Plugin { p.Registry.Name = ""; return p }), + }, + { + name: "invalid template variable", + valid: false, + p: trP(func(p *Plugin) *Plugin { + p.Repo.ComponentTemplate = "{{.Res}}" + return p + }), + }, + } + for _, tc := range testCases { + err := tc.p.Validate() + if err != nil && tc.valid { + t.Errorf("%s: expected valid plugin, but error was %v", tc.name, err) + } else if err == nil && !tc.valid { + t.Errorf("%s: expected invalid plugin, but error was nil", tc.name) + } else { + fmt.Printf("%s: Completed successfully: %v\n", tc.name, err) + } + } +} + +func makeDefaultPlugin() *Plugin { + return &Plugin{ + // build configuration + // registry configuration + Registry: &Registry{ + DryRun: true, + Name: "registry.example.com", + Username: "docker_user", + Password: "docker_pass", + PushRetry: 1, + }, + // repo configuration + Repo: &Repo{ + Name: "project/image", + Tags: []string{"latest", "v0.0.0"}, + Platforms: []string{"linux/amd64", "linux/arm64/v8"}, + ComponentTemplate: "{{.Repo}}:{{.Tag}}-{{.Os}}-{{.Arch}}{{if .Variant}}-{{.Variant}}{{end}}", + }, + } +} + +// Translate Plugin +func trP(t func(*Plugin) *Plugin) *Plugin { + return t(makeDefaultPlugin()) +} diff --git a/cmd/vela-manifest-tool/registry.go b/cmd/vela-manifest-tool/registry.go new file mode 100644 index 0000000..325eb76 --- /dev/null +++ b/cmd/vela-manifest-tool/registry.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/base64" + "fmt" + + "github.com/spf13/afero" + + "github.com/sirupsen/logrus" +) + +const ( + credentials = `%s:%s` + + registryFile = `{ + "auths": { + "%s": { + "auth": "%s" + } + } +}` +) + +// Registry represents the plugin configuration for registry information. +// +// https://docs.docker.com/registry/ +type Registry struct { + // name of the registry to publish the image to + Name string + // user name for communication with the registry + Username string + // password for communication with the registry + Password string + // enable building the image without publishing + PushRetry int + // enable pulling from any insecure registry + DryRun bool +} + +// Write creates a Docker config.json file for building and publishing the image. +func (r *Registry) Write() error { + logrus.Trace("writing registry configuration file") + + // use custom filesystem which enables us to test + a := &afero.Afero{ + Fs: appFS, + } + + // check if name, username and password are provided + if len(r.Name) == 0 || len(r.Username) == 0 || len(r.Password) == 0 { + return nil + } + + // create basic authentication string for config.json file + basicAuth := base64.StdEncoding.EncodeToString( + []byte(fmt.Sprintf(credentials, r.Username, r.Password)), + ) + + // create output string for config.json file + out := fmt.Sprintf( + registryFile, + r.Name, + basicAuth, + ) + + // create full path for config.json file + path := "/root/.docker/config.json" + + //nolint: gomnd // ignore magic number + return a.WriteFile(path, []byte(out), 0644) +} + +// Validate verifies the Registry is properly configured. +func (r *Registry) Validate() error { + logrus.Trace("validating registry plugin configuration") + + // verify registry is provided + if len(r.Name) == 0 { + return fmt.Errorf("no registry name provided") + } + + // check if dry run is disabled + if !r.DryRun { + // check if username is provided + if len(r.Username) == 0 { + return fmt.Errorf("no registry username provided") + } + + // check if password is provided + if len(r.Password) == 0 { + return fmt.Errorf("no registry password provided") + } + } + + return nil +} diff --git a/cmd/vela-manifest-tool/registry_test.go b/cmd/vela-manifest-tool/registry_test.go new file mode 100644 index 0000000..94733f9 --- /dev/null +++ b/cmd/vela-manifest-tool/registry_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "github.com/spf13/afero" +) + +func TestDocker_Registry_Validate(t *testing.T) { + // setup types + r := &Registry{ + Name: "index.docker.io", + Username: "octocat", + Password: "superSecretPassword", + DryRun: false, + } + + err := r.Validate() + if err != nil { + t.Errorf("Validate returned err: %v", err) + } +} + +func TestDocker_Registry_Validate_NoName(t *testing.T) { + // setup types + r := &Registry{ + Username: "octocat", + Password: "superSecretPassword", + DryRun: false, + } + + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned err") + } +} + +func TestDocker_Registry_Validate_NoUsername(t *testing.T) { + // setup types + r := &Registry{ + Name: "index.docker.io", + Password: "superSecretPassword", + DryRun: false, + } + + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned err") + } +} + +func TestDocker_Registry_Validate_NoPassword(t *testing.T) { + // setup types + r := &Registry{ + Name: "index.docker.io", + Username: "octocat", + DryRun: false, + } + + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned err") + } +} + +func TestDocker_Registry_Write(t *testing.T) { + // setup filesystem + appFS = afero.NewMemMapFs() + + // setup types + r := &Registry{ + Name: "index.docker.io", + Username: "octocat", + Password: "superSecretPassword", + DryRun: false, + } + + err := r.Write() + if err != nil { + t.Errorf("Write returned err: %v", err) + } +} + +func TestDocker_Registry_Write_NoName(t *testing.T) { + // setup filesystem + appFS = afero.NewMemMapFs() + + // setup types + r := &Registry{ + Username: "octocat", + Password: "superSecretPassword", + DryRun: false, + } + + err := r.Write() + if err != nil { + t.Errorf("Write returned err: %v", err) + } +} + +func TestDocker_Registry_Write_NoUsername(t *testing.T) { + // setup filesystem + appFS = afero.NewMemMapFs() + + // setup types + r := &Registry{ + Name: "index.docker.io", + Username: "octocat", + DryRun: false, + } + + err := r.Write() + if err != nil { + t.Errorf("Write returned err: %v", err) + } +} + +func TestDocker_Registry_Write_NoPassword(t *testing.T) { + // setup filesystem + appFS = afero.NewMemMapFs() + + // setup types + r := &Registry{ + Name: "index.docker.io", + Username: "octocat", + DryRun: false, + } + + err := r.Write() + if err != nil { + t.Errorf("Write returned err: %v", err) + } +} diff --git a/cmd/vela-manifest-tool/repo.go b/cmd/vela-manifest-tool/repo.go new file mode 100644 index 0000000..ce2a4d5 --- /dev/null +++ b/cmd/vela-manifest-tool/repo.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + + "github.com/sirupsen/logrus" +) + +type ( + // Repo represents the plugin configuration for repo information + Repo struct { + Name string // name of the repository for the image + Tags []string // tags of the image for the repository + Platforms []string // platforms which should be included in the manifest + ComponentTemplate string // Template used to render each component image + } +) + +// Validate verifies the Repo is properly configured. +func (r *Repo) Validate() error { + logrus.Trace("validating repo plugin configuration") + + // verify repo is provided + if len(r.Name) == 0 { + return fmt.Errorf("no repo name provided") + } + + // check if tags are provided + if len(r.Tags) > 0 { + // check each tag value for valid docker tag syntax + for _, tag := range r.Tags { + if !tagRegexp.MatchString(tag) { + return fmt.Errorf(errTagValidation, tag) + } + } + } else { + return fmt.Errorf("no tags provided") + } + + if len(r.Platforms) > 0 { + for _, platform := range r.Platforms { + if _, ok := allowedPlatforms[platform]; !ok { + return fmt.Errorf("unsupported platform %s requested", platform) + } + } + } else { + return fmt.Errorf("no platforms provided") + } + + return nil +} diff --git a/cmd/vela-manifest-tool/repo_test.go b/cmd/vela-manifest-tool/repo_test.go new file mode 100644 index 0000000..7b4241c --- /dev/null +++ b/cmd/vela-manifest-tool/repo_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "testing" + +func TestDocker_Repo_Validate(t *testing.T) { + // setup types + r := &Repo{ + Name: "/target/vela-manifest-tool", + Tags: []string{"latest"}, + Platforms: []string{"linux/amd64", "linux/arm64/v8"}, + } + + err := r.Validate() + if err != nil { + t.Errorf("Validate returned err: %v", err) + } +} + +func TestDocker_Repo_Validate_NoName(t *testing.T) { + // setup types + r := &Repo{ + Name: "", + Tags: []string{"latest"}, + Platforms: []string{"linux/amd64", "linux/arm64/v8"}, + } + + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned err") + } +} + +func TestDocker_Repo_Validate_InvalidTags(t *testing.T) { + // setup types + r := &Repo{ + Name: "/target/vela-manifest-tool", + Tags: []string{"!@#$%^&*()"}, + Platforms: []string{"linux/amd64", "linux/arm64/v8"}, + } + + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned err") + } +} + +func TestDocker_Repo_Validate_NoTags(t *testing.T) { + // setup types + r := &Repo{ + Name: "/target/vela-manifest-tool", + Tags: []string{}, + Platforms: []string{"linux/amd64", "linux/arm64/v8"}, + } + + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned err") + } +} + +func TestDocker_Repo_Validate_NoPlatforms(t *testing.T) { + r := &Repo{ + Name: "/target/vela-manifest-tool", + Tags: []string{"latest"}, + } + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned err") + } +} + +func TestDocker_Repo_InvalidPlatform(t *testing.T) { + r := &Repo{ + Name: "/target/vela-manifest-tool", + Tags: []string{"latest"}, + Platforms: []string{"windows/riscv64"}, + } + err := r.Validate() + if err == nil { + t.Errorf("Validate should have returned an err") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2ac3031 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/go-vela/vela-manifest-tool + +go 1.21 + +require ( + github.com/Masterminds/semver/v3 v3.2.1 + github.com/go-vela/types v0.23.0 + github.com/joho/godotenv v1.5.1 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/afero v1.11.0 + github.com/urfave/cli/v2 v2.27.1 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/psampaz/go-mod-outdated v0.9.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aafc04c --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-vela/types v0.23.0 h1:CWICreHO4V9KqbE+AINkRJVwCZmggxOLIZh+e1n/XXA= +github.com/go-vela/types v0.23.0/go.mod h1:AAqgxIw1aRBgPkE/5juGuiwh/JZuOtL8fcPaEkjFWwQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/psampaz/go-mod-outdated v0.9.0 h1:P3f6z6NrAgG1kq1W4xcsa/lL8SM2SEZxAlRvG1AMFBs= +github.com/psampaz/go-mod-outdated v0.9.0/go.mod h1:FcfE/igcl0GuLxemNXSL7r+rconnPmFP8kmO/Th3/To= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..44633a5 --- /dev/null +++ b/version/version.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package version + +import ( + "fmt" + "runtime" + + "github.com/go-vela/types/version" + + "github.com/Masterminds/semver/v3" + + "github.com/sirupsen/logrus" +) + +var ( + // Arch represents the architecture information for the package. + Arch = runtime.GOARCH + // Commit represents the git commit information for the package. + Commit string + // Compiler represents the compiler information for the package. + Compiler = runtime.Compiler + // Date represents the build date information for the package. + Date string + // Go represents the golang version information for the package. + Go string + // OS represents the operating system information for the package. + OS = runtime.GOOS + // Tag represents the git tag information for the package. + Tag string +) + +// New creates a new version object for Vela that is used throughout the application. +func New() *version.Version { + // check if a semantic tag was provided + if len(Tag) == 0 { + logrus.Warning("no semantic tag provided - defaulting to v0.0.0") + + // set a fallback default for the tag + Tag = "v0.0.0" + } + + v, err := semver.NewVersion(Tag) + if err != nil { + fmt.Println(fmt.Errorf("unable to parse semantic version for %s: %w", Tag, err)) + return nil + } + + return &version.Version{ + Canonical: Tag, + Major: v.Major(), + Minor: v.Minor(), + Patch: v.Patch(), + PreRelease: v.Prerelease(), + Metadata: version.Metadata{ + Architecture: Arch, + BuildDate: Date, + Compiler: Compiler, + GitCommit: Commit, + GoVersion: Go, + OperatingSystem: OS, + }, + } +}