473 lines
13 KiB
Go
473 lines
13 KiB
Go
package recipe
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"coopcloud.tech/abra/catalogue"
|
|
"coopcloud.tech/abra/cli/formatter"
|
|
"coopcloud.tech/abra/cli/internal"
|
|
"coopcloud.tech/abra/client"
|
|
"coopcloud.tech/abra/client/stack"
|
|
"coopcloud.tech/abra/config"
|
|
"coopcloud.tech/tagcmp"
|
|
|
|
loader "coopcloud.tech/abra/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 := c.Args().First()
|
|
if recipe == "" {
|
|
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
|
}
|
|
|
|
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 := c.Args().First()
|
|
if recipe == "" {
|
|
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
|
}
|
|
|
|
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 := c.Args().First()
|
|
if recipe == "" {
|
|
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
|
}
|
|
|
|
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 := c.Args().First()
|
|
if recipe == "" {
|
|
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
|
}
|
|
|
|
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
|
|
var RecipeCommand = &cli.Command{
|
|
Name: "recipe",
|
|
Usage: "Manage recipes",
|
|
ArgsUsage: "<recipe>",
|
|
Aliases: []string{"r"},
|
|
Description: `
|
|
A recipe is a blueprint for an app. It is a bunch of configuration files which
|
|
describe how to deploy and maintain an app. Recipes are maintained by the Co-op
|
|
Cloud community and you can use Abra to read them and create apps for you.
|
|
`,
|
|
Subcommands: []*cli.Command{
|
|
recipeListCommand,
|
|
recipeVersionCommand,
|
|
recipeCreateCommand,
|
|
recipeUpgradeCommand,
|
|
recipeSyncCommand,
|
|
recipeLintCommand,
|
|
},
|
|
}
|