package recipe import ( "errors" "fmt" "os" "path" "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/config" "coopcloud.tech/abra/tagcmp" "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 all available recipes", Aliases: []string{"ls"}, Action: func(c *cli.Context) error { catl, err := catalogue.ReadAppsCatalogue() if err != nil { logrus.Fatal(err.Error()) } apps := catl.Flatten() sort.Sort(catalogue.ByAppName(apps)) tableCol := []string{"Name", "Category", "Status"} table := formatter.CreateTable(tableCol) for _, app := range apps { status := fmt.Sprintf("%v", app.Features.Status) tableRow := []string{app.Name, app.Category, status} table.Append(tableRow) } table.Render() return nil }, } var recipeVersionCommand = &cli.Command{ Name: "versions", Usage: "List available versions for ", ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } catalogue, err := catalogue.ReadAppsCatalogue() if err != nil { logrus.Fatal(err) return nil } if app, ok := catalogue[recipe]; ok { tableCol := []string{"Version", "Service", "Image", "Digest"} table := formatter.CreateTable(tableCol) for version := range app.Versions { for service := range app.Versions[version] { meta := app.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", ArgsUsage: "", 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 } } fmt.Printf( "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", Description: ` This command reads and attempts to parse all image tags within the given 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 ". `, ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } compose, err := config.GetAppComposeFiles(recipe) 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.(reference.Named)) regVersions, err := client.GetRegistryTags(image) if err != nil { logrus.Fatal(err) } if strings.Contains(image, "library") { // ParseNormalizedNamed prepends 'library' to images like nginx:, // postgres:, 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) } config.UpdateAppComposeTag(recipe, image, upgradeTag) } return nil }, } var recipeSyncCommand = &cli.Command{ 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")) } 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 }, } var recipeLintCommand = &cli.Command{ Name: "lint", Usage: "Recipe configuration linter", ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } compose, err := config.GetAppComposeFiles(recipe) 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 app recipes", Description: ` A recipe is a blueprint for an app. It is made up of two things: - A libre software app (e.g. Nextcloud, Wordpress, Mastodon) - A package configuration which describes how to deploy and maintain it Recipes are developed, maintained and extended by the Co-op Cloud volunteer-run community. Each recipe has a "level" which is intended as a way to quickly show how reliable this app is to deploy and maintain in its current state. `, Subcommands: []*cli.Command{ recipeListCommand, recipeVersionCommand, recipeCreateCommand, recipeUpgradeCommand, recipeSyncCommand, recipeLintCommand, }, }