forked from toolshed/abra
		
	refactor: break up recipe cli package
This commit is contained in:
		| @ -1,444 +1,10 @@ | ||||
| package recipe | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"path/filepath" | ||||
| 	"sort" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"text/template" | ||||
|  | ||||
| 	"coopcloud.tech/abra/cli/formatter" | ||||
| 	"coopcloud.tech/abra/cli/internal" | ||||
| 	"coopcloud.tech/abra/pkg/catalogue" | ||||
| 	"coopcloud.tech/abra/pkg/client" | ||||
| 	"coopcloud.tech/abra/pkg/client/stack" | ||||
| 	"coopcloud.tech/abra/pkg/config" | ||||
| 	"coopcloud.tech/tagcmp" | ||||
|  | ||||
| 	loader "coopcloud.tech/abra/pkg/client/stack" | ||||
| 	"github.com/AlecAivazis/survey/v2" | ||||
| 	"github.com/docker/distribution/reference" | ||||
| 	"github.com/go-git/go-git/v5" | ||||
| 	"github.com/sirupsen/logrus" | ||||
| 	"github.com/urfave/cli/v2" | ||||
| ) | ||||
|  | ||||
| var recipeListCommand = &cli.Command{ | ||||
| 	Name:    "list", | ||||
| 	Usage:   "List available recipes", | ||||
| 	Aliases: []string{"ls"}, | ||||
| 	Action: func(c *cli.Context) error { | ||||
| 		catl, err := catalogue.ReadRecipeCatalogue() | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err.Error()) | ||||
| 		} | ||||
| 		recipes := catl.Flatten() | ||||
| 		sort.Sort(catalogue.ByRecipeName(recipes)) | ||||
| 		tableCol := []string{"Name", "Category", "Status"} | ||||
| 		table := formatter.CreateTable(tableCol) | ||||
| 		for _, recipe := range recipes { | ||||
| 			status := fmt.Sprintf("%v", recipe.Features.Status) | ||||
| 			tableRow := []string{recipe.Name, recipe.Category, status} | ||||
| 			table.Append(tableRow) | ||||
| 		} | ||||
| 		table.Render() | ||||
| 		return nil | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| var recipeVersionCommand = &cli.Command{ | ||||
| 	Name:      "versions", | ||||
| 	Usage:     "List recipe versions", | ||||
| 	Aliases:   []string{"v"}, | ||||
| 	ArgsUsage: "<recipe>", | ||||
| 	Action: func(c *cli.Context) error { | ||||
| 		recipe := internal.ValidateRecipeArg(c) | ||||
|  | ||||
| 		catalogue, err := catalogue.ReadRecipeCatalogue() | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		if recipe, ok := catalogue[recipe]; ok { | ||||
| 			tableCol := []string{"Version", "Service", "Image", "Digest"} | ||||
| 			table := formatter.CreateTable(tableCol) | ||||
| 			for version := range recipe.Versions { | ||||
| 				for service := range recipe.Versions[version] { | ||||
| 					meta := recipe.Versions[version][service] | ||||
| 					table.Append([]string{version, service, meta.Image, meta.Digest}) | ||||
| 				} | ||||
| 			} | ||||
| 			table.SetAutoMergeCells(true) | ||||
| 			table.Render() | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		logrus.Fatalf("'%s' recipe doesn't exist?", recipe) | ||||
| 		return nil | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| var recipeCreateCommand = &cli.Command{ | ||||
| 	Name:      "create", | ||||
| 	Usage:     "Create a new recipe", | ||||
| 	Aliases:   []string{"c"}, | ||||
| 	ArgsUsage: "<recipe>", | ||||
| 	Action: func(c *cli.Context) error { | ||||
| 		recipe := c.Args().First() | ||||
| 		if recipe == "" { | ||||
| 			internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) | ||||
| 		} | ||||
|  | ||||
| 		directory := path.Join(config.APPS_DIR, recipe) | ||||
| 		if _, err := os.Stat(directory); !os.IsNotExist(err) { | ||||
| 			logrus.Fatalf("'%s' recipe directory already exists?", directory) | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		url := fmt.Sprintf("%s/example.git", config.REPOS_BASE_URL) | ||||
| 		_, err := git.PlainClone(directory, false, &git.CloneOptions{URL: url, Tags: git.AllTags}) | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		gitRepo := path.Join(config.APPS_DIR, recipe, ".git") | ||||
| 		if err := os.RemoveAll(gitRepo); err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		toParse := []string{ | ||||
| 			path.Join(config.APPS_DIR, recipe, "README.md"), | ||||
| 			path.Join(config.APPS_DIR, recipe, ".env.sample"), | ||||
| 			path.Join(config.APPS_DIR, recipe, ".drone.yml"), | ||||
| 		} | ||||
| 		for _, path := range toParse { | ||||
| 			file, err := os.OpenFile(path, os.O_RDWR, 0755) | ||||
| 			if err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 				return nil | ||||
| 			} | ||||
|  | ||||
| 			tpl, err := template.ParseFiles(path) | ||||
| 			if err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 				return nil | ||||
| 			} | ||||
|  | ||||
| 			// TODO: ask for description and probably other things so that the | ||||
| 			// template repository is more "ready" to go than the current best-guess | ||||
| 			// mode of templating | ||||
| 			if err := tpl.Execute(file, struct { | ||||
| 				Name        string | ||||
| 				Description string | ||||
| 			}{recipe, "TODO"}); err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 				return nil | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		logrus.Infof( | ||||
| 			"New recipe '%s' created in %s, happy hacking!\n", | ||||
| 			recipe, path.Join(config.APPS_DIR, recipe), | ||||
| 		) | ||||
|  | ||||
| 		return nil | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| var recipeUpgradeCommand = &cli.Command{ | ||||
| 	Name:    "upgrade", | ||||
| 	Usage:   "Upgrade recipe image tags", | ||||
| 	Aliases: []string{"u"}, | ||||
| 	Description: ` | ||||
| This command reads and attempts to parse all image tags within the given | ||||
| <recipe> configuration and prompt with more recent tags to upgrade to. It will | ||||
| update the relevant compose file tags on the local file system. | ||||
|  | ||||
| Some image tags cannot be parsed because they do not follow some sort of | ||||
| semver-like convention. In this case, all possible tags will be listed and it | ||||
| is up to the end-user to decide. | ||||
|  | ||||
| This is step 1 of upgrading a recipe. Step 2 is running "abra recipe sync | ||||
| <recipe>". | ||||
| `, | ||||
| 	ArgsUsage: "<recipe>", | ||||
| 	Action: func(c *cli.Context) error { | ||||
| 		recipe := internal.ValidateRecipeArg(c) | ||||
|  | ||||
| 		appFiles, err := config.LoadAppFiles("") | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
|  | ||||
| 		appEnv, err := config.GetApp(appFiles, recipe) | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
|  | ||||
| 		compose, err := config.GetAppComposeConfig(recipe, stack.Deploy{}, appEnv.Env) | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
|  | ||||
| 		for _, service := range compose.Services { | ||||
| 			catlVersions, err := catalogue.VersionsOfService(recipe, service.Name) | ||||
| 			if err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
|  | ||||
| 			img, err := reference.ParseNormalizedNamed(service.Image) | ||||
| 			if err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
|  | ||||
| 			image := reference.Path(img) | ||||
| 			regVersions, err := client.GetRegistryTags(image) | ||||
| 			if err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
|  | ||||
| 			if strings.Contains(image, "library") { | ||||
| 				// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>, | ||||
| 				// postgres:<tag>, i.e. images which do not have a username in the | ||||
| 				// first position of the string | ||||
| 				image = strings.Split(image, "/")[1] | ||||
| 			} | ||||
|  | ||||
| 			semverLikeTag := true | ||||
| 			if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) { | ||||
| 				semverLikeTag = false | ||||
| 			} | ||||
|  | ||||
| 			tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag()) | ||||
| 			if err != nil && semverLikeTag { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
|  | ||||
| 			var compatible []tagcmp.Tag | ||||
| 			for _, regVersion := range regVersions { | ||||
| 				other, err := tagcmp.Parse(regVersion.Name) | ||||
| 				if err != nil { | ||||
| 					continue // skip tags that cannot be parsed | ||||
| 				} | ||||
|  | ||||
| 				if tag.IsCompatible(other) && tag.IsLessThan(other) && !tag.Equals(other) { | ||||
| 					compatible = append(compatible, other) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			sort.Sort(tagcmp.ByTag(compatible)) | ||||
|  | ||||
| 			if len(compatible) == 0 && semverLikeTag { | ||||
| 				logrus.Info(fmt.Sprintf("No new versions available for '%s', '%s' is the latest", image, tag)) | ||||
| 				continue // skip on to the next tag and don't update any compose files | ||||
| 			} | ||||
|  | ||||
| 			var compatibleStrings []string | ||||
| 			for _, compat := range compatible { | ||||
| 				skip := false | ||||
| 				for _, catlVersion := range catlVersions { | ||||
| 					if compat.String() == catlVersion { | ||||
| 						skip = true | ||||
| 					} | ||||
| 				} | ||||
| 				if !skip { | ||||
| 					compatibleStrings = append(compatibleStrings, compat.String()) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			msg := fmt.Sprintf("Which tag would you like to upgrade to? (service: %s, tag: %s)", service.Name, tag) | ||||
| 			if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) { | ||||
| 				tag := img.(reference.NamedTagged).Tag() | ||||
| 				logrus.Warning(fmt.Sprintf("Unable to determine versioning semantics of '%s', listing all tags...", tag)) | ||||
| 				msg = fmt.Sprintf("Which tag would you like to upgrade to? (service: %s, tag: %s)", service.Name, tag) | ||||
| 				compatibleStrings = []string{} | ||||
| 				for _, regVersion := range regVersions { | ||||
| 					compatibleStrings = append(compatibleStrings, regVersion.Name) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			var upgradeTag string | ||||
| 			prompt := &survey.Select{ | ||||
| 				Message: msg, | ||||
| 				Options: compatibleStrings, | ||||
| 			} | ||||
| 			if err := survey.AskOne(prompt, &upgradeTag); err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
|  | ||||
| 			if err := config.UpdateAppComposeTag(recipe, image, upgradeTag, appEnv.Env); err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return nil | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| var recipeSyncCommand = &cli.Command{ | ||||
| 	Name:    "sync", | ||||
| 	Usage:   "Generate new recipe labels", | ||||
| 	Aliases: []string{"s"}, | ||||
| 	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 <recipe> 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: "<recipe>", | ||||
| 	Action: func(c *cli.Context) error { | ||||
| 		recipe := internal.ValidateRecipeArg(c) | ||||
|  | ||||
| 		appFiles, err := config.LoadAppFiles("") | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
|  | ||||
| 		appEnv, err := config.GetApp(appFiles, recipe) | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
|  | ||||
| 		compose, err := config.GetAppComposeConfig(recipe, stack.Deploy{}, appEnv.Env) | ||||
| 		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, appEnv.Env); err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return nil | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| var recipeLintCommand = &cli.Command{ | ||||
| 	Name:      "lint", | ||||
| 	Usage:     "Lint a recipe", | ||||
| 	Aliases:   []string{"l"}, | ||||
| 	ArgsUsage: "<recipe>", | ||||
| 	Action: func(c *cli.Context) error { | ||||
| 		recipe := internal.ValidateRecipeArg(c) | ||||
|  | ||||
| 		pattern := fmt.Sprintf("%s/%s/compose**yml", config.APPS_DIR, recipe) | ||||
| 		composeFiles, err := filepath.Glob(pattern) | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
| 		opts := stack.Deploy{Composefiles: composeFiles} | ||||
| 		compose, err := loader.LoadComposefile(opts, make(map[string]string)) | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
|  | ||||
| 		expectedVersion := false | ||||
| 		if compose.Version == "3.8" { | ||||
| 			expectedVersion = true | ||||
| 		} | ||||
|  | ||||
| 		envSampleProvided := false | ||||
| 		envSample := fmt.Sprintf("%s/%s/.env.sample", config.APPS_DIR, recipe) | ||||
| 		if _, err := os.Stat(envSample); !os.IsNotExist(err) { | ||||
| 			envSampleProvided = true | ||||
| 		} | ||||
| 		if err != nil { | ||||
| 			logrus.Fatal(err) | ||||
| 		} | ||||
|  | ||||
| 		serviceNamedApp := false | ||||
| 		traefikEnabled := false | ||||
| 		healthChecksForAllServices := true | ||||
| 		allImagesTagged := true | ||||
| 		noUnstableTags := true | ||||
| 		semverLikeTags := true | ||||
| 		for _, service := range compose.Services { | ||||
| 			if service.Name == "app" { | ||||
| 				serviceNamedApp = true | ||||
| 			} | ||||
|  | ||||
| 			for label := range service.Deploy.Labels { | ||||
| 				if label == "traefik.enable" { | ||||
| 					if service.Deploy.Labels[label] == "true" { | ||||
| 						traefikEnabled = true | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			img, err := reference.ParseNormalizedNamed(service.Image) | ||||
| 			if err != nil { | ||||
| 				logrus.Fatal(err) | ||||
| 			} | ||||
| 			if reference.IsNameOnly(img) { | ||||
| 				allImagesTagged = false | ||||
| 			} | ||||
|  | ||||
| 			tag := img.(reference.NamedTagged).Tag() | ||||
| 			if tag == "latest" { | ||||
| 				noUnstableTags = false | ||||
| 			} | ||||
|  | ||||
| 			if !tagcmp.IsParsable(tag) { | ||||
| 				semverLikeTags = false | ||||
| 			} | ||||
|  | ||||
| 			if service.HealthCheck == nil { | ||||
| 				healthChecksForAllServices = false | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		tableCol := []string{"Rule", "Satisfied"} | ||||
| 		table := formatter.CreateTable(tableCol) | ||||
| 		table.Append([]string{"Compose files have the expected version", strconv.FormatBool(expectedVersion)}) | ||||
| 		table.Append([]string{"Environment configuration is provided", strconv.FormatBool(envSampleProvided)}) | ||||
| 		table.Append([]string{"Recipe contains a service named 'app'", strconv.FormatBool(serviceNamedApp)}) | ||||
| 		table.Append([]string{"Traefik routing enabled on at least one service", strconv.FormatBool(traefikEnabled)}) | ||||
| 		table.Append([]string{"All services have a healthcheck enabled", strconv.FormatBool(healthChecksForAllServices)}) | ||||
| 		table.Append([]string{"All images are using a tag", strconv.FormatBool(allImagesTagged)}) | ||||
| 		table.Append([]string{"No usage of unstable 'latest' tags", strconv.FormatBool(noUnstableTags)}) | ||||
| 		table.Append([]string{"All tags are using a semver-like format", strconv.FormatBool(semverLikeTags)}) | ||||
| 		table.Render() | ||||
|  | ||||
| 		return nil | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| // RecipeCommand defines the `abra recipe` command and ets subcommands | ||||
| // RecipeCommand defines all recipe related sub-commands. | ||||
| var RecipeCommand = &cli.Command{ | ||||
| 	Name:      "recipe", | ||||
| 	Usage:     "Manage recipes", | ||||
|  | ||||
		Reference in New Issue
	
	Block a user