package recipe import ( "errors" "fmt" "os" "path" "sort" "strconv" "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", 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 { var compatible []tagcmp.Tag 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) } tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag()) if err != nil { logrus.Fatal(err) } for _, regVersion := range regVersions { other, err := tagcmp.Parse(regVersion.Name) if err != nil { continue } if tag.IsCompatible(other) && tag.IsLessThan(other) && !tag.Equals(other) { compatible = append(compatible, other) } } sort.Sort(tagcmp.ByTag(compatible)) if len(compatible) == 0 { logrus.Info(fmt.Sprintf("No new versions available for '%s', '%s' is the latest", image, tag)) break } 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()) } } var upgradeTag string msg := fmt.Sprintf("Which tag would you like to upgrade to? (service: %s, tag: %s)", service.Name, tag) 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 and publish tags", 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 return nil }, } var recipeReleaseCommand = &cli.Command{ Name: "release", Usage: "Release a new recipe version", ArgsUsage: "", Action: func(c *cli.Context) error { recipe := c.Args().First() if recipe == "" { internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided")) } // TODO: part 3 of https://git.coopcloud.tech/coop-cloud/go-abra/issues/39#issuecomment-8066 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 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 } if img.(reference.NamedTagged).Tag() == "latest" { noUnstableTags = 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.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, recipeReleaseCommand, recipeLintCommand, }, }