2021-08-02 01:10:41 +00:00
|
|
|
package recipe
|
2021-07-21 08:58:13 +00:00
|
|
|
|
|
|
|
import (
|
2021-08-03 11:57:12 +00:00
|
|
|
"errors"
|
2021-07-21 08:58:13 +00:00
|
|
|
"fmt"
|
2021-07-21 16:21:45 +00:00
|
|
|
"os"
|
2021-07-24 22:07:35 +00:00
|
|
|
"path"
|
2021-08-03 17:25:32 +00:00
|
|
|
"path/filepath"
|
2021-07-21 08:58:13 +00:00
|
|
|
"sort"
|
2021-08-03 17:25:32 +00:00
|
|
|
"strconv"
|
2021-07-25 17:28:29 +00:00
|
|
|
"text/template"
|
2021-07-21 08:58:13 +00:00
|
|
|
|
2021-07-28 20:10:13 +00:00
|
|
|
"coopcloud.tech/abra/catalogue"
|
2021-08-02 01:10:41 +00:00
|
|
|
"coopcloud.tech/abra/cli/formatter"
|
2021-08-03 11:57:12 +00:00
|
|
|
"coopcloud.tech/abra/cli/internal"
|
2021-08-03 17:25:32 +00:00
|
|
|
loader "coopcloud.tech/abra/client/stack"
|
2021-07-21 16:21:45 +00:00
|
|
|
"coopcloud.tech/abra/config"
|
|
|
|
|
2021-08-03 17:25:32 +00:00
|
|
|
"github.com/docker/cli/cli/command/stack/options"
|
|
|
|
"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 {
|
2021-07-28 20:10:13 +00:00
|
|
|
catl, err := catalogue.ReadAppsCatalogue()
|
2021-07-21 08:58:13 +00:00
|
|
|
if err != nil {
|
|
|
|
logrus.Fatal(err.Error())
|
|
|
|
}
|
2021-07-28 20:10:13 +00:00
|
|
|
apps := catl.Flatten()
|
|
|
|
sort.Sort(catalogue.ByAppName(apps))
|
2021-07-21 08:58:13 +00:00
|
|
|
tableCol := []string{"Name", "Category", "Status"}
|
2021-08-02 01:10:41 +00:00
|
|
|
table := formatter.CreateTable(tableCol)
|
2021-07-26 15:25:08 +00:00
|
|
|
for _, app := range apps {
|
2021-07-22 12:53:08 +00:00
|
|
|
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 == "" {
|
2021-08-03 11:57:12 +00:00
|
|
|
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
|
2021-07-24 21:18:23 +00:00
|
|
|
}
|
|
|
|
|
2021-07-28 20:10:13 +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
|
|
|
}
|
|
|
|
|
2021-07-28 20:10:13 +00:00
|
|
|
if app, ok := catalogue[recipe]; ok {
|
2021-07-24 21:18:23 +00:00
|
|
|
tableCol := []string{"Version", "Service", "Image", "Digest"}
|
2021-08-02 01:10:41 +00:00
|
|
|
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 == "" {
|
2021-08-03 11:57:12 +00:00
|
|
|
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)
|
2021-07-30 11:16:28 +00:00
|
|
|
_, 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(
|
|
|
|
"New recipe '%s' created in %s, happy hacking!",
|
|
|
|
recipe, path.Join(config.APPS_DIR, recipe),
|
|
|
|
)
|
2021-07-24 22:07:35 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2021-08-04 21:52:34 +00:00
|
|
|
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: read all local tags and read all upstream tags
|
|
|
|
// and then ask which you'd like to upgrade to
|
|
|
|
// ensure that this release isn't in local or upstream already
|
|
|
|
// after each choice, make change and show diff
|
|
|
|
|
|
|
|
//TODO: read the apps catalogue and read the latest version of the recipe
|
|
|
|
// read the latest local tag of the recipe
|
|
|
|
// if there are no new changes, and upstream/local point to same commit, there is nothing to update, bail
|
|
|
|
// if there are changes and the commit they both point to is different, then this is a new release
|
|
|
|
// figure out the new version
|
|
|
|
// if the catalogue latest and the local latest are the same, N+1 release
|
|
|
|
// otherwise, use the local latest tag as the new version
|
|
|
|
// apply that version to all labels and show diff
|
|
|
|
|
|
|
|
//TODO: offer to commit all the things
|
|
|
|
// offer to make a git tag for that with the new version
|
|
|
|
// offer to git push that upstream
|
|
|
|
|
|
|
|
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"))
|
|
|
|
}
|
|
|
|
|
|
|
|
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}
|
2021-08-03 22:07:23 +00:00
|
|
|
compose, err := loader.LoadComposefile(opts)
|
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-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
|
|
|
|
}
|
|
|
|
|
|
|
|
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)})
|
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)})
|
|
|
|
table.Render()
|
|
|
|
|
|
|
|
return nil
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2021-08-02 06:36:35 +00:00
|
|
|
// RecipeCommand defines the `abra recipe` command and ets subcommands
|
2021-07-21 08:58:13 +00:00
|
|
|
var RecipeCommand = &cli.Command{
|
2021-07-26 21:58:34 +00:00
|
|
|
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,
|
2021-08-04 21:52:34 +00:00
|
|
|
recipeReleaseCommand,
|
2021-08-03 17:25:32 +00:00
|
|
|
recipeLintCommand,
|
2021-07-21 08:58:13 +00:00
|
|
|
},
|
|
|
|
}
|