diff --git a/.dockerignore b/.dockerignore index 0cace7a..829c073 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,6 @@ dumpcerts.sh acme.json acme-backup.json traefik-certs-dumper +manifest.json +*.Dockerfile +internal/ diff --git a/.gitignore b/.gitignore index 0cace7a..6681f45 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dumpcerts.sh acme.json acme-backup.json traefik-certs-dumper +manifest.json +/*.Dockerfile diff --git a/.travis.yml b/.travis.yml index b46a85b..28d6ca0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,10 @@ language: go go: - - 1.11.x + - 1.12.x - 1.x -sudo: false +dist: xenial env: - GO111MODULE=on diff --git a/Makefile b/Makefile index 00be093..de1bedc 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,12 @@ clean: build: clean @echo Version: $(VERSION) $(BUILD_DATE) - go build -v -ldflags '-X "github.com/ldez/traefik-certs-dumper/cmd.version=${VERSION}" -X "github.com/ldez/traefik-certs-dumper/cmd.commit=${SHA}" -X "github.com/ldez/traefik-certs-dumper/cmd.date=${BUILD_DATE}"' + go build -v -ldflags '-X "github.com/ldez/traefik-certs-dumper/cmd.version=${VERSION}" -X "github.com/ldez/traefik-certs-dumper/cmd.commit=${SHA}" -X "github.com/ldez/traefik-certs-dumper/cmd.date=${BUILD_DATE}"' -o traefik-certs-dumper checks: golangci-lint run + +publish-images: + go run ./internal/multiarch.go --version="$(TAG_NAME)" --dry-run=false + go run ./internal/multiarch.go --version="latest" --dry-run=false + rm -f *.Dockerfile diff --git a/go.mod b/go.mod index 6f7e14a..b2d06e2 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect github.com/cpuguy83/go-md2man v1.0.10 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect + github.com/docker/distribution v2.7.1+incompatible github.com/fsnotify/fsnotify v1.4.7 github.com/go-acme/lego v2.5.0+incompatible github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect @@ -25,6 +26,8 @@ require ( github.com/jonboulle/clockwork v0.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-testing-interface v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0-rc1 // indirect + github.com/opencontainers/image-spec v1.0.1 // indirect github.com/pascaldekloe/goe v0.1.0 // indirect github.com/pkg/errors v0.8.1 // indirect github.com/prometheus/client_golang v0.9.2 // indirect diff --git a/go.sum b/go.sum index b2b4fa5..136d0a3 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwc github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -79,6 +81,10 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= diff --git a/internal/build-options.json b/internal/build-options.json new file mode 100644 index 0000000..b9170dc --- /dev/null +++ b/internal/build-options.json @@ -0,0 +1,33 @@ +{ + "386": { + "os": "linux", + "go_arch": "386" + }, + "amd64": { + "os": "linux", + "go_arch": "amd64" + }, + "arm.v5": { + "os": "linux", + "go_arch": "arm", + "go_arm": "5", + "variant": "v5" + }, + "arm.v6": { + "os": "linux", + "go_arch": "arm", + "go_arm": "6", + "variant": "v6" + }, + "arm.v7": { + "os": "linux", + "go_arch": "arm", + "go_arm": "7", + "variant": "v7" + }, + "arm.v8": { + "os": "linux", + "go_arch": "arm64", + "variant": "v8" + } +} diff --git a/internal/multiarch.go b/internal/multiarch.go new file mode 100644 index 0000000..a40b5b2 --- /dev/null +++ b/internal/multiarch.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "log" + "os" +) + +type buildOption struct { + OS string `json:"os"` + GoARCH string `json:"go_arch"` + GoARM string `json:"go_arm,omitempty"` + Variant string `json:"variant,omitempty"` +} + +func main() { + log.SetFlags(log.Lshortfile) + + imageName := flag.String("image-name", "ldez/traefik-certs-dumper", "") + version := flag.String("version", "", "") + baseImageName := flag.String("base-image-name", "alpine:3.9", "") + dryRun := flag.Bool("dry-run", true, "") + + flag.Parse() + + require("image-name", imageName) + require("version", version) + require("base-image-name", baseImageName) + + _, travisTag := os.LookupEnv("TRAVIS_TAG") + if !travisTag { + log.Println("Skipping deploy") + os.Exit(0) + } + + targets := []string{"arm.v6", "arm.v7", "arm.v8", "amd64", "386"} + + publisher, err := newPublisher(*imageName, *version, *baseImageName, targets) + if err != nil { + log.Fatal(err) + } + + err = publisher.execute(*dryRun) + if err != nil { + log.Fatal(err) + } +} + +func require(fieldName string, field *string) { + if field == nil || *field == "" { + log.Fatalf("%s is required", fieldName) + } +} diff --git a/internal/publisher.go b/internal/publisher.go new file mode 100644 index 0000000..2832521 --- /dev/null +++ b/internal/publisher.go @@ -0,0 +1,223 @@ +package main + +import ( + "encoding/json" + "fmt" + "html/template" + "io/ioutil" + "log" + "os" + "os/exec" + "strings" + + "github.com/docker/distribution/manifest/manifestlist" +) + +// Publisher Publish multi-arch image. +type Publisher struct { + Builds [][]string + Push [][]string + ManifestAnnotate [][]string + ManifestCreate []string + ManifestPush []string +} + +func newPublisher(imageName, version, baseImageName string, targets []string) (Publisher, error) { + manifest, err := getManifest(baseImageName) + if err != nil { + return Publisher{}, err + } + + buildOptions, err := getBuildOptions("./internal/build-options.json") + if err != nil { + return Publisher{}, err + } + + publisher := Publisher{} + + for _, target := range targets { + option := buildOptions[target] + + descriptor, err := findManifestDescriptor(option, manifest.Manifests) + if err != nil { + log.Fatal(err) + } + + dockerfile := fmt.Sprintf("%s-%s-%s.Dockerfile", option.OS, option.GoARCH, option.GoARM) + + publisher.Builds = append(publisher.Builds, []string{ + "build", + "-t", fmt.Sprintf("%s:%s-%s", imageName, version, target), + "-f", dockerfile, + ".", + }) + + err = createDockerfile(dockerfile, option, descriptor, baseImageName) + if err != nil { + log.Fatal(err) + } + + publisher.Push = append(publisher.Push, []string{"push", fmt.Sprintf(`%s:%s-%s`, imageName, version, target)}) + + ma := []string{ + "manifest", "annotate", + fmt.Sprintf("%s:%s", imageName, version), + fmt.Sprintf("%s:%s-%s", imageName, version, target), + fmt.Sprintf("--os=%s", option.OS), + fmt.Sprintf("--arch=%s", option.GoARCH), + } + if option.Variant != "" { + ma = append(ma, fmt.Sprintf("--variant=%s", option.Variant)) + } + publisher.ManifestAnnotate = append(publisher.ManifestAnnotate, ma) + } + + publisher.ManifestCreate = []string{ + "manifest", "create", "--amend", fmt.Sprintf("%s:%s", imageName, version), + } + + for _, target := range targets { + publisher.ManifestCreate = append(publisher.ManifestCreate, fmt.Sprintf("%s:%s-%s", imageName, version, target)) + } + + publisher.ManifestPush = []string{ + "manifest", "push", fmt.Sprintf("%s:%s", imageName, version), + } + + return publisher, nil +} + +func (b Publisher) execute(dryRun bool) error { + for _, args := range b.Builds { + if err := execDocker(args, dryRun); err != nil { + return fmt.Errorf("failed to build: %v: %v", args, err) + } + } + + for _, args := range b.Push { + if err := execDocker(args, dryRun); err != nil { + return fmt.Errorf("failed to push: %v: %v", args, err) + } + } + + if err := execDocker(b.ManifestCreate, dryRun); err != nil { + return fmt.Errorf("failed to create manifest: %v: %v", b.ManifestCreate, err) + } + + for _, args := range b.ManifestAnnotate { + if err := execDocker(args, dryRun); err != nil { + return fmt.Errorf("failed to annotate manifest: %v: %v", args, err) + } + } + + if err := execDocker(b.ManifestPush, dryRun); err != nil { + return fmt.Errorf("failed to push manifest: %v: %v", b.ManifestPush, err) + } + + return nil +} + +func getBuildOptions(source string) (map[string]buildOption, error) { + file, err := os.Open(source) + if err != nil { + return nil, err + } + + buildOptions := make(map[string]buildOption) + + err = json.NewDecoder(file).Decode(&buildOptions) + if err != nil { + return nil, err + } + + return buildOptions, nil +} + +func createDockerfile(dockerfile string, buildOption buildOption, descriptor manifestlist.ManifestDescriptor, baseImageName string) error { + base := template.New("tmpl.Dockerfile") + parse, err := base.ParseFiles("./internal/tmpl.Dockerfile") + if err != nil { + return err + } + + data := map[string]interface{}{ + "GoOS": buildOption.OS, + "GoARCH": buildOption.GoARCH, + "GoARM": buildOption.GoARM, + "RuntimeImage": fmt.Sprintf("%s@%s", baseImageName, descriptor.Digest), + } + + file, err := os.Create(dockerfile) + if err != nil { + return err + } + + return parse.Execute(file, data) +} + +func getManifest(baseImageName string) (*manifestlist.ManifestList, error) { + manifestPath := "./manifest.json" + + if _, errExist := os.Stat(manifestPath); os.IsNotExist(errExist) { + cmd := exec.Command("docker", "manifest", "inspect", baseImageName) + cmd.Env = append(cmd.Env, "DOCKER_CLI_EXPERIMENTAL=enabled") + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + + err = ioutil.WriteFile(manifestPath, output, 0666) + if err != nil { + return nil, err + } + } else if errExist != nil { + return nil, errExist + } + + bytes, err := ioutil.ReadFile(manifestPath) + if err != nil { + return nil, err + } + + manifest := &manifestlist.ManifestList{} + + err = json.Unmarshal(bytes, manifest) + if err != nil { + return nil, err + } + + return manifest, nil +} + +func findManifestDescriptor(criterion buildOption, descriptors []manifestlist.ManifestDescriptor) (manifestlist.ManifestDescriptor, error) { + for _, descriptor := range descriptors { + if descriptor.Platform.OS == criterion.OS && + descriptor.Platform.Architecture == criterion.GoARCH && + descriptor.Platform.Variant == criterion.Variant { + return descriptor, nil + } + } + return manifestlist.ManifestDescriptor{}, fmt.Errorf("not supported: %v", criterion) +} + +func execDocker(args []string, dryRun bool) error { + if dryRun { + fmt.Println("docker", strings.Join(args, " ")) + return nil + } + + cmd := exec.Command("docker", args...) + cmd.Env = append(cmd.Env, "DOCKER_CLI_EXPERIMENTAL=enabled") + + output, err := cmd.CombinedOutput() + + if len(output) != 0 { + log.Println(string(output)) + } + + if err != nil { + return err + } + return nil +} diff --git a/internal/tmpl.Dockerfile b/internal/tmpl.Dockerfile new file mode 100644 index 0000000..f616f51 --- /dev/null +++ b/internal/tmpl.Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1-alpine as builder + +RUN apk --update upgrade \ + && apk --no-cache --no-progress add git make gcc musl-dev ca-certificates tzdata + +WORKDIR /go/src/github.com/ldez/traefik-certs-dumper + +ENV GO111MODULE on +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN GOARCH={{ .GoARCH }} GOARM={{ .GoARM }} make build + +FROM {{ .RuntimeImage }} + +# Not supported for multi-arch without Buildkit or QEMU +#RUN apk --update upgrade \ +# && apk --no-cache --no-progress add ca-certificates + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /go/src/github.com/ldez/traefik-certs-dumper/traefik-certs-dumper /usr/bin/traefik-certs-dumper + +ENTRYPOINT ["/usr/bin/traefik-certs-dumper"]