package recipe import ( "errors" "fmt" "os" "path" "path/filepath" "sort" "strconv" "text/template" "coopcloud.tech/abra/catalogue" "coopcloud.tech/abra/cli/formatter" "coopcloud.tech/abra/cli/internal" loader "coopcloud.tech/abra/client/stack" "coopcloud.tech/abra/config" "github.com/docker/cli/cli/command/stack/options" "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!", recipe, path.Join(config.APPS_DIR, recipe), ) 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")) } pattern := fmt.Sprintf("%s/%s/compose**yml", config.APPS_DIR, recipe) composeFiles, err := filepath.Glob(pattern) if err != nil { logrus.Fatal(err) } opts := options.Deploy{Composefiles: composeFiles} config, err := loader.LoadComposefile(opts) if err != nil { logrus.Fatal(err) } expectedVersion := false if config.Version == "3.8" { expectedVersion = true } serviceNamedApp := false traefikEnabled := false healthChecksForAllServices := true allImagesTagged := true noUnstableTags := true for _, service := range config.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 } } // TODO // check for .env.sample tableCol := []string{"Rule", "Satisfied"} table := formatter.CreateTable(tableCol) table.Append([]string{"Compose files have the expected version", strconv.FormatBool(expectedVersion)}) 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, recipeLintCommand, }, }