From 83671f42a27b8b9a07d00f3ddd532d2bf77c5559 Mon Sep 17 00:00:00 2001 From: decentral1se Date: Tue, 10 Aug 2021 12:21:42 +0200 Subject: [PATCH] feat: recipe sync --- TODO.md | 2 +- cli/recipe/recipe.go | 50 ++++++++++++++++- client/registry.go | 130 +++++++++++++++++++++++++++++++++++++++++-- config/app.go | 46 +++++++++++++++ 4 files changed, 220 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 0e9f535f..7e76ee0b 100644 --- a/TODO.md +++ b/TODO.md @@ -40,7 +40,7 @@ - [x] `ls` - [x] `create` - [x] `upgrade` - - [ ] `sync` (in-progress `@decentral1se`) + - [x] `sync` - [x] `versions` - [x] `lint` - [ ] `upgrade` diff --git a/cli/recipe/recipe.go b/cli/recipe/recipe.go index 5a9cd540..36191ea7 100644 --- a/cli/recipe/recipe.go +++ b/cli/recipe/recipe.go @@ -271,15 +271,59 @@ This is step 1 of upgrading a recipe. Step 2 is running "abra recipe sync } var recipeSyncCommand = &cli.Command{ - Name: "sync", - Usage: "Generate recipe labels and publish tags", + Name: "sync", + Usage: "Generate recipe labels", + Description: ` +This command will generate labels for each service which correspond to the +following format: + + coop-cloud.${STACK_NAME}.${SERVICE_NAME}.version=${IMAGE_TAG}-${IMAGE_DIGEST} + +The configuration will be updated on the local file system. These +labels are consumed by abra in other command invocations and used to determine +the versioning metadata of up-and-running containers are. +`, ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } - // TODO: part 2 of https://git.coopcloud.tech/coop-cloud/go-abra/issues/39#issuecomment-8066 + + compose, err := config.GetAppComposeFiles(recipe) + if err != nil { + logrus.Fatal(err) + } + + hasAppService := false + for _, service := range compose.Services { + if service.Name == "app" { + hasAppService = true + } + } + + if !hasAppService { + logrus.Fatal(fmt.Sprintf("No 'app' service defined in '%s', cannot proceed", recipe)) + } + + for _, service := range compose.Services { + img, _ := reference.ParseNormalizedNamed(service.Image) + if err != nil { + logrus.Fatal(err) + } + + digest, err := client.GetTagDigest(img) + if err != nil { + logrus.Fatal(err) + } + + tag := img.(reference.NamedTagged).Tag() + label := fmt.Sprintf("coop-cloud.${STACK_NAME}.%s.version=%s-%s", service.Name, tag, digest) + if err := config.UpdateAppComposeLabel(recipe, service.Name, label); err != nil { + logrus.Fatal(err) + } + } + return nil }, } diff --git a/client/registry.go b/client/registry.go index e6c27b6e..89d6d796 100644 --- a/client/registry.go +++ b/client/registry.go @@ -1,22 +1,28 @@ package client import ( + "encoding/json" "fmt" + "io/ioutil" + "net/http" + "strings" + "time" "coopcloud.tech/abra/web" + "github.com/docker/distribution/reference" ) -type Tag struct { +type RawTag struct { Layer string Name string } -type Tags []Tag +type RawTags []RawTag var registryURL = "https://registry.hub.docker.com/v1/repositories/%s/tags" -func GetRegistryTags(image string) (Tags, error) { - var tags Tags +func GetRegistryTags(image string) (RawTags, error) { + var tags RawTags tagsUrl := fmt.Sprintf(registryURL, image) if err := web.ReadJSON(tagsUrl, &tags); err != nil { @@ -25,3 +31,119 @@ func GetRegistryTags(image string) (Tags, error) { return tags, nil } + +// getRegv2Token retrieves a registry v2 authentication token. +func getRegv2Token(image reference.Named) (string, error) { + img := reference.Path(image.(reference.Named)) + authTokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", img) + req, err := http.NewRequest("GET", authTokenURL, nil) + if err != nil { + return "", err + } + + client := &http.Client{Timeout: 10 * time.Second} + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + _, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", nil + } + + tokenRes := struct { + Token string + Expiry string + Issued string + }{} + + if err := json.Unmarshal(body, &tokenRes); err != nil { + return "", err + } + + return tokenRes.Token, nil +} + +// GetTagDigest retrieves an image digest from a v2 registry +func GetTagDigest(image reference.Named) (string, error) { + img := reference.Path(image.(reference.Named)) + tag := image.(reference.NamedTagged).Tag() + manifestURL := fmt.Sprintf("https://index.docker.io/v2/%s/manifests/%s", img, tag) + + req, err := http.NewRequest("GET", manifestURL, nil) + if err != nil { + return "", err + } + + token, err := getRegv2Token(image) + if err != nil { + return "", err + } + + req.Header = http.Header{ + "Accept": []string{ + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + }, + "Authorization": []string{fmt.Sprintf("Bearer %s", token)}, + } + + client := &http.Client{Timeout: 10 * time.Second} + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + _, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + + registryRes := struct { + SchemaVersion int + MediaType string + Manifests []struct { + MediaType string + Size int + Digest string + Platform struct { + Architecture string + Os string + } + } + }{} + + if err := json.Unmarshal(body, ®istryRes); err != nil { + return "", err + } + + var digest string + for _, manifest := range registryRes.Manifests { + if string(manifest.Platform.Architecture) == "amd64" { + digest = strings.Split(manifest.Digest, ":")[1][:7] + } + } + + if digest == "" { + return "", fmt.Errorf("Unable to retrieve amd64 digest for '%s'", image) + } + + return digest, nil +} diff --git a/config/app.go b/config/app.go index 0801729f..bab2913e 100644 --- a/config/app.go +++ b/config/app.go @@ -296,3 +296,49 @@ func UpdateAppComposeTag(recipe, image, tag string) error { return nil } + +func UpdateAppComposeLabel(recipe, serviceName, newLabel string) error { + pattern := fmt.Sprintf("%s/%s/compose**yml", APPS_DIR, recipe) + composeFiles, err := filepath.Glob(pattern) + if err != nil { + return err + } + + for _, composeFile := range composeFiles { + opts := options.Deploy{Composefiles: []string{composeFile}} + compose, err := loader.LoadComposefile(opts) + if err != nil { + return err + } + + serviceExists := false + var service composetypes.ServiceConfig + for _, s := range compose.Services { + if s.Name == serviceName { + service = s + serviceExists = true + } + } + + if !serviceExists { + continue + } + + for oldLabel, value := range service.Deploy.Labels { + if strings.HasPrefix(oldLabel, "coop-cloud") { + bytes, err := ioutil.ReadFile(composeFile) + if err != nil { + return err + } + + old := fmt.Sprintf("coop-cloud.${STACK_NAME}.%s.version=%s", service.Name, value) + replacedBytes := strings.Replace(string(bytes), old, newLabel, -1) + if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0644); err != nil { + return err + } + } + } + } + + return nil +}