abra/cli/recipe/recipe.go

384 lines
10 KiB
Go
Raw Normal View History

package recipe
2021-07-21 08:58:13 +00:00
import (
"errors"
2021-07-21 08:58:13 +00:00
"fmt"
"os"
2021-07-24 22:07:35 +00:00
"path"
2021-07-21 08:58:13 +00:00
"sort"
2021-08-03 17:25:32 +00:00
"strconv"
2021-08-10 05:53:05 +00:00
"strings"
2021-07-25 17:28:29 +00:00
"text/template"
2021-07-21 08:58:13 +00:00
"coopcloud.tech/abra/catalogue"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
2021-08-09 14:17:40 +00:00
"coopcloud.tech/abra/client"
"coopcloud.tech/abra/config"
2021-08-09 14:17:40 +00:00
"coopcloud.tech/abra/tagcmp"
2021-08-09 14:17:40 +00:00
"github.com/AlecAivazis/survey/v2"
2021-08-03 17:25:32 +00:00
"github.com/docker/distribution/reference"
2021-07-24 22:07:35 +00:00
"github.com/go-git/go-git/v5"
2021-07-21 08:58:13 +00:00
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var recipeListCommand = &cli.Command{
Name: "list",
2021-07-28 11:56:18 +00:00
Usage: "List all available recipes",
2021-07-21 08:58:13 +00:00
Aliases: []string{"ls"},
Action: func(c *cli.Context) error {
catl, err := catalogue.ReadAppsCatalogue()
2021-07-21 08:58:13 +00:00
if err != nil {
logrus.Fatal(err.Error())
}
apps := catl.Flatten()
sort.Sort(catalogue.ByAppName(apps))
2021-07-21 08:58:13 +00:00
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}
2021-07-21 08:58:13 +00:00
table.Append(tableRow)
}
table.Render()
return nil
},
}
2021-07-24 21:18:23 +00:00
var recipeVersionCommand = &cli.Command{
Name: "versions",
Usage: "List available versions for <recipe>",
ArgsUsage: "<recipe>",
Action: func(c *cli.Context) error {
recipe := c.Args().First()
if recipe == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
2021-07-24 21:18:23 +00:00
}
catalogue, err := catalogue.ReadAppsCatalogue()
2021-07-24 21:18:23 +00:00
if err != nil {
logrus.Fatal(err)
2021-07-24 21:30:42 +00:00
return nil
2021-07-24 21:18:23 +00:00
}
if app, ok := catalogue[recipe]; ok {
2021-07-24 21:18:23 +00:00
tableCol := []string{"Version", "Service", "Image", "Digest"}
table := formatter.CreateTable(tableCol)
2021-07-24 21:18:23 +00:00
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
},
}
2021-07-24 22:07:35 +00:00
var recipeCreateCommand = &cli.Command{
Name: "create",
Usage: "Create a new recipe",
ArgsUsage: "<recipe>",
Action: func(c *cli.Context) error {
recipe := c.Args().First()
if recipe == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
2021-07-24 22:07:35 +00:00
}
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})
2021-07-24 22:07:35 +00:00
if err != nil {
logrus.Fatal(err)
return nil
}
2021-07-25 17:28:29 +00:00
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"),
2021-07-24 22:07:35 +00:00
path.Join(config.APPS_DIR, recipe, ".drone.yml"),
}
2021-07-25 17:28:29 +00:00
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 {
2021-07-24 22:07:35 +00:00
logrus.Fatal(err)
return nil
}
}
2021-07-25 17:28:29 +00:00
fmt.Printf(
2021-08-06 08:37:15 +00:00
"New recipe '%s' created in %s, happy hacking!\n",
2021-07-25 17:28:29 +00:00
recipe, path.Join(config.APPS_DIR, recipe),
)
2021-07-24 22:07:35 +00:00
return nil
},
}
var recipeUpgradeCommand = &cli.Command{
Name: "upgrade",
Usage: "Upgrade recipe image tags",
ArgsUsage: "<recipe>",
Action: func(c *cli.Context) error {
recipe := c.Args().First()
if recipe == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
}
2021-08-09 14:17:40 +00:00
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)
}
2021-08-09 14:17:40 +00:00
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)
}
2021-08-10 05:53:05 +00:00
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]
}
2021-08-09 14:17:40 +00:00
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))
2021-08-10 05:53:05 +00:00
continue // skip on to the next tag and don't update any compose files
2021-08-09 14:17:40 +00:00
}
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())
}
2021-08-09 14:17:40 +00:00
}
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)
2021-08-09 14:17:40 +00:00
}
return nil
},
}
var recipeSyncCommand = &cli.Command{
Name: "sync",
Usage: "Generate recipe labels and publish tags",
ArgsUsage: "<recipe>",
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: "<recipe>",
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
},
}
2021-08-03 17:25:32 +00:00
var recipeLintCommand = &cli.Command{
Name: "lint",
Usage: "Recipe configuration linter",
ArgsUsage: "<recipe>",
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)
2021-08-03 17:25:32 +00:00
if err != nil {
logrus.Fatal(err)
}
expectedVersion := false
2021-08-03 22:07:23 +00:00
if compose.Version == "3.8" {
2021-08-03 17:25:32 +00:00
expectedVersion = true
}
2021-08-03 22:07:23 +00:00
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)
}
2021-08-03 17:25:32 +00:00
serviceNamedApp := false
traefikEnabled := false
healthChecksForAllServices := true
allImagesTagged := true
noUnstableTags := true
2021-08-10 05:57:23 +00:00
semverLikeTags := true
2021-08-03 22:07:23 +00:00
for _, service := range compose.Services {
2021-08-03 17:25:32 +00:00
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
}
2021-08-10 05:57:23 +00:00
tag := img.(reference.NamedTagged).Tag()
if tag == "latest" {
2021-08-03 17:25:32 +00:00
noUnstableTags = false
}
2021-08-10 05:57:23 +00:00
if !tagcmp.IsParsable(tag) {
semverLikeTags = false
}
2021-08-03 17:25:32 +00:00
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)})
2021-08-03 22:07:23 +00:00
table.Append([]string{"Environment configuration is provided", strconv.FormatBool(envSampleProvided)})
2021-08-03 17:25:32 +00:00
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)})
2021-08-10 05:57:23 +00:00
table.Append([]string{"All tags are using a semver-like format", strconv.FormatBool(semverLikeTags)})
2021-08-03 17:25:32 +00:00
table.Render()
return nil
},
}
// RecipeCommand defines the `abra recipe` command and ets subcommands
2021-07-21 08:58:13 +00:00
var RecipeCommand = &cli.Command{
Name: "recipe",
Usage: "Manage app recipes",
2021-07-28 11:56:18 +00:00
Description: `
2021-07-28 20:13:05 +00:00
A recipe is a blueprint for an app. It is made up of two things:
2021-07-28 11:56:18 +00:00
- 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.
`,
2021-07-21 08:58:13 +00:00
Subcommands: []*cli.Command{
recipeListCommand,
2021-07-24 21:18:23 +00:00
recipeVersionCommand,
2021-07-24 22:07:35 +00:00
recipeCreateCommand,
recipeUpgradeCommand,
recipeSyncCommand,
recipeReleaseCommand,
2021-08-03 17:25:32 +00:00
recipeLintCommand,
2021-07-21 08:58:13 +00:00
},
}