Compare commits

..

1 Commits

Author SHA1 Message Date
536c912113 Fixed typo in abra ac bash output
Some checks failed
continuous-integration/drone/pr Build is failing
2021-12-22 21:45:55 +00:00
93 changed files with 2157 additions and 3447 deletions

View File

@ -1,4 +0,0 @@
GANDI_TOKEN=...
HCLOUD_TOKEN=...
REGISTRY_PASSWORD=...
REGISTRY_USERNAME=...

9
.gitignore vendored
View File

@ -1,7 +1,6 @@
*fmtcoverage.html
.e2e.env
.envrc
.vscode/
abra abra
dist/ .vscode/
vendor/ vendor/
.envrc
dist/
*fmtcoverage.html

View File

@ -5,7 +5,7 @@ LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
DIST_LDFLAGS := $(LDFLAGS)" -s -w" DIST_LDFLAGS := $(LDFLAGS)" -s -w"
export GOPRIVATE=coopcloud.tech export GOPRIVATE=coopcloud.tech
all: format check static build test all: run test install build clean format check static
run: run:
@go run -ldflags=$(LDFLAGS) $(ABRA) @go run -ldflags=$(LDFLAGS) $(ABRA)
@ -43,15 +43,3 @@ loc-author:
sort -f | \ sort -f | \
uniq -ic | \ uniq -ic | \
sort -n sort -n
int-core:
@docker run \
-v $$(pwd):/src \
--env-file .e2e.env \
debian:bullseye-slim \
sh -c "\
apt update && apt install -y wget curl git; echo ""; echo ""; \
git config --global user.email 'e2e@coopcloud.tech'; \
git config --global user.name 'e2e'; \
cd /src/tests/integration && bash core.sh -- --dev \
"

View File

@ -7,7 +7,7 @@ import (
// AppCommand defines the `abra app` command and ets subcommands // AppCommand defines the `abra app` command and ets subcommands
var AppCommand = &cli.Command{ var AppCommand = &cli.Command{
Name: "app", Name: "app",
Usage: "Manage apps", Usage: "Manage deployed apps",
Aliases: []string{"a"}, Aliases: []string{"a"},
ArgsUsage: "<app>", ArgsUsage: "<app>",
Description: ` Description: `

View File

@ -38,10 +38,10 @@ var appBackupCommand = &cli.Command{
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<service>' and '--all' together")) internal.ShowSubcommandHelpAndError(c, errors.New("cannot use '<service>' and '--all' together"))
} }
abraSh := path.Join(config.RECIPES_DIR, app.Type, "abra.sh") abraSh := path.Join(config.ABRA_DIR, "apps", app.Type, "abra.sh")
if _, err := os.Stat(abraSh); err != nil { if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
logrus.Fatalf("%s does not exist?", abraSh) logrus.Fatalf("'%s' does not exist?", abraSh)
} }
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -62,7 +62,7 @@ var appBackupCommand = &cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
if !strings.Contains(string(bytes), execCmd) { if !strings.Contains(string(bytes), execCmd) {
logrus.Fatalf("%s doesn't have a %s function", app.Type, execCmd) logrus.Fatalf("%s doesn't have a '%s' function", app.Type, execCmd)
} }
sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd) sourceAndExec := fmt.Sprintf("%s; %s", sourceCmd, execCmd)

View File

@ -20,10 +20,10 @@ var appCheckCommand = &cli.Command{
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
envSamplePath := path.Join(config.RECIPES_DIR, app.Type, ".env.sample") envSamplePath := path.Join(config.ABRA_DIR, "apps", app.Type, ".env.sample")
if _, err := os.Stat(envSamplePath); err != nil { if _, err := os.Stat(envSamplePath); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
logrus.Fatalf("%s does not exist?", envSamplePath) logrus.Fatalf("'%s' does not exist?", envSamplePath)
} }
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -45,7 +45,7 @@ var appCheckCommand = &cli.Command{
logrus.Fatalf("%s is missing %s", app.Path, missingEnvVars) logrus.Fatalf("%s is missing %s", app.Path, missingEnvVars)
} }
logrus.Infof("all necessary environment variables defined for %s", app.Name) logrus.Infof("all necessary environment variables defined for '%s'", app.Name)
return nil return nil
}, },

View File

@ -31,7 +31,7 @@ var appConfigCommand = &cli.Command{
appFile, exists := files[appName] appFile, exists := files[appName]
if !exists { if !exists {
logrus.Fatalf("cannot find app with name %s", appName) logrus.Fatalf("cannot find app with name '%s'", appName)
} }
ed, ok := os.LookupEnv("EDITOR") ed, ok := os.LookupEnv("EDITOR")

View File

@ -1,19 +1,11 @@
package app package app
import ( import (
"fmt"
"os" "os"
"strings" "strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/formatter"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -83,7 +75,7 @@ And if you want to copy that file back to your current working directory locally
logrus.Fatalf("%s does not exist locally?", dstPath) logrus.Fatalf("%s does not exist locally?", dstPath)
} }
} }
err := configureAndCp(c, app, srcPath, dstPath, service, isToContainer) err := internal.ConfigureAndCp(c, app, srcPath, dstPath, service, isToContainer)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -92,64 +84,3 @@ And if you want to copy that file back to your current working directory locally
}, },
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
} }
func configureAndCp(
c *cli.Context,
app config.App,
srcPath string,
dstPath string,
service string,
isToContainer bool) error {
appFiles, err := config.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
}
appEnv, err := config.GetApp(appFiles, app.Name)
if err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
container, err := container.GetContainer(c.Context, cl, filters, true)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
if isToContainer {
if _, err := os.Stat(srcPath); err != nil {
logrus.Fatalf("%s does not exist?", srcPath)
}
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
content, err := archive.TarWithOptions(srcPath, toTarOpts)
if err != nil {
logrus.Fatal(err)
}
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(c.Context, container.ID, dstPath, content, copyOpts); err != nil {
logrus.Fatal(err)
}
} else {
content, _, err := cl.CopyFromContainer(c.Context, container.ID, srcPath)
if err != nil {
logrus.Fatal(err)
}
defer content.Close()
fromTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
if err := archive.Untar(content, dstPath, fromTarOpts); err != nil {
logrus.Fatal(err)
}
}
return nil
}

View File

@ -14,12 +14,11 @@ var appDeployCommand = &cli.Command{
internal.ForceFlag, internal.ForceFlag,
internal.ChaosFlag, internal.ChaosFlag,
internal.NoDomainChecksFlag, internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
}, },
Description: ` Description: `
This command deploys an app. It does not support incrementing the version of a This command deploys a new instance of an app. It does not support changing the
deployed app, for this you need to look at the "abra app upgrade <app>" version of an existing deployed app, for this you need to look at the "abra app
command. upgrade <app>" command.
You may pass "--force" to re-deploy the same version again. This can be useful You may pass "--force" to re-deploy the same version again. This can be useful
if the container runtime has gotten into a weird state. if the container runtime has gotten into a weird state.

View File

@ -1,20 +1,7 @@
package app package app
import ( import (
"strconv"
"strings"
"time"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -22,115 +9,19 @@ var appErrorsCommand = &cli.Command{
Name: "errors", Name: "errors",
Usage: "List errors for a deployed app", Usage: "List errors for a deployed app",
Description: ` Description: `
This command lists errors for a deployed app. This command will list errors for a deployed app. This is a best-effort
implementation and an attempt to gather a number of tips & tricks for finding
This is a best-effort implementation and an attempt to gather a number of tips errors together into one convenient command. When an app is failing to deploy
& tricks for finding errors together into one convenient command. When an app or having issues, it could be a lot of things. This command is best accompanied
is failing to deploy or having issues, it could be a lot of things. by "abra app logs <app>".
This command currently takes into account:
Is the service deployed?
Is the service killed by an OOM error?
Is the service reporting an error (like in "ps --no-trunc" output)
Is the service healthcheck failing? what are the healthcheck logs?
Got any more ideas? Please let us know:
https://git.coopcloud.tech/coop-cloud/organising/issues/new/choose
This command is best accompanied by "abra app logs <app>" which may reveal
further information which can help you debug the cause of an app failure via
the logs.
`, `,
Aliases: []string{"e"}, Aliases: []string{"e"},
Flags: []cli.Flag{internal.WatchFlag}, Flags: []cli.Flag{},
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) // TODO: entrypoint error
// TODO: ps --no-trunc errors
cl, err := client.New(app.Server) // TODO: failing healthcheck
if err != nil {
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
if !internal.Watch {
if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err)
}
return nil
}
for {
if err := checkErrors(c, cl, app); err != nil {
logrus.Fatal(err)
}
time.Sleep(2 * time.Second)
}
return nil return nil
}, },
} }
func checkErrors(c *cli.Context, cl *dockerClient.Client, app config.App) error {
recipe, err := recipe.Get(app.Type)
if err != nil {
return err
}
for _, service := range recipe.Config.Services {
filters := filters.NewArgs()
filters.Add("name", service.Name)
containers, err := cl.ContainerList(c.Context, types.ContainerListOptions{Filters: filters})
if err != nil {
return err
}
if len(containers) == 0 {
logrus.Warnf("%s is not up, something seems wrong", service.Name)
continue
}
container := containers[0]
containerState, err := cl.ContainerInspect(c.Context, container.ID)
if err != nil {
logrus.Fatal(err)
}
if containerState.State.OOMKilled {
logrus.Warnf("%s has been killed due to an out of memory error", service.Name)
}
if containerState.State.Error != "" {
logrus.Warnf("%s reports this error: %s", service.Name, containerState.State.Error)
}
if containerState.State.Health != nil {
if containerState.State.Health.Status != "healthy" {
logrus.Warnf("%s healthcheck status is %s", service.Name, containerState.State.Health.Status)
logrus.Warnf("%s healthcheck has failed %s times", service.Name, strconv.Itoa(containerState.State.Health.FailingStreak))
for _, log := range containerState.State.Health.Log {
logrus.Warnf("%s healthcheck logs: %s", service.Name, strings.TrimSpace(log.Output))
}
}
}
}
return nil
}
func getServiceName(names []string) string {
containerName := strings.Join(names, " ")
trimmed := strings.TrimPrefix(containerName, "/")
return strings.Split(trimmed, ".")[0]
}

View File

@ -5,10 +5,10 @@ import (
"sort" "sort"
"strings" "strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/ssh" "coopcloud.tech/abra/pkg/ssh"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -92,7 +92,7 @@ can take some time.
sort.Sort(config.ByServerAndType(apps)) sort.Sort(config.ByServerAndType(apps))
statuses := make(map[string]map[string]string) statuses := make(map[string]map[string]string)
var catl recipe.RecipeCatalogue var catl catalogue.RecipeCatalogue
if status { if status {
alreadySeen := make(map[string]bool) alreadySeen := make(map[string]bool)
for _, app := range apps { for _, app := range apps {
@ -110,7 +110,7 @@ can take some time.
} }
var err error var err error
catl, err = recipe.ReadRecipeCatalogue() catl, err = catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -124,26 +124,19 @@ can take some time.
var ok bool var ok bool
if stats, ok = allStats[app.Server]; !ok { if stats, ok = allStats[app.Server]; !ok {
stats = serverStatus{} stats = serverStatus{}
if appType == "" { totalServersCount++
// count server, no filtering
totalServersCount++
}
} }
if app.Type == appType || appType == "" { if app.Type == appType || appType == "" {
if appType != "" {
// only count server if matches filter
totalServersCount++
}
appStats := appStatus{} appStats := appStatus{}
stats.appCount++ stats.appCount++
totalAppsCount++ totalAppsCount++
if status { if status {
stackName := app.StackName()
status := "unknown" status := "unknown"
version := "unknown" version := "unknown"
if statusMeta, ok := statuses[app.StackName()]; ok { if statusMeta, ok := statuses[stackName]; ok {
if currentVersion, exists := statusMeta["version"]; exists { if currentVersion, exists := statusMeta["version"]; exists {
version = currentVersion version = currentVersion
} }
@ -160,7 +153,7 @@ can take some time.
var newUpdates []string var newUpdates []string
if version != "unknown" { if version != "unknown" {
updates, err := recipe.GetRecipeCatalogueVersions(app.Type, catl) updates, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -190,7 +183,10 @@ can take some time.
stats.latestCount++ stats.latestCount++
} }
} else { } else {
newUpdates = internal.ReverseStringList(newUpdates) // FIXME: jeezus golang why do you not have a list reverse function
for i, j := 0, len(newUpdates)-1; i < j; i, j = i+1, j-1 {
newUpdates[i], newUpdates[j] = newUpdates[j], newUpdates[i]
}
appStats.upgrade = strings.Join(newUpdates, "\n") appStats.upgrade = strings.Join(newUpdates, "\n")
stats.upgradeCount++ stats.upgradeCount++
} }
@ -198,7 +194,7 @@ can take some time.
appStats.server = app.Server appStats.server = app.Server
appStats.recipe = app.Type appStats.recipe = app.Type
appStats.appName = app.Name appStats.appName = app.StackName()
appStats.domain = app.Domain appStats.domain = app.Domain
stats.apps = append(stats.apps, appStats) stats.apps = append(stats.apps, appStats)
@ -207,52 +203,41 @@ can take some time.
allStats[app.Server] = stats allStats[app.Server] = stats
} }
alreadySeen := make(map[string]bool) for serverName, serverStat := range allStats {
for _, app := range apps { tableCol := []string{"recipe", "app name", "domain"}
if _, ok := alreadySeen[app.Server]; ok {
continue
}
serverStat := allStats[app.Server]
tableCol := []string{"recipe", "domain", "app name"}
if status { if status {
tableCol = append(tableCol, []string{"status", "version", "upgrade"}...) tableCol = append(tableCol, []string{"status", "version", "upgrade"}...)
} }
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
for _, appStat := range serverStat.apps { for _, appStat := range serverStat.apps {
tableRow := []string{appStat.recipe, appStat.domain, appStat.appName} tableRow := []string{appStat.recipe, appStat.appName, appStat.domain}
if status { if status {
tableRow = append(tableRow, []string{appStat.status, appStat.version, appStat.upgrade}...) tableRow = append(tableRow, []string{appStat.status, appStat.version, appStat.upgrade}...)
} }
table.Append(tableRow) table.Append(tableRow)
} }
if table.NumLines() > 0 { table.Render()
table.Render()
if status { if status {
fmt.Println(fmt.Sprintf( fmt.Println(fmt.Sprintf(
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v", "server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
app.Server, serverName,
serverStat.appCount, serverStat.appCount,
serverStat.versionCount, serverStat.versionCount,
serverStat.unversionedCount, serverStat.unversionedCount,
serverStat.latestCount, serverStat.latestCount,
serverStat.upgradeCount, serverStat.upgradeCount,
)) ))
} else { } else {
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.appCount)) fmt.Println(fmt.Sprintf("server: %s | total apps: %v", serverName, serverStat.appCount))
}
} }
if len(allStats) > 1 && table.NumLines() > 0 { if len(allStats) > 1 {
fmt.Println() // newline separator for multiple servers fmt.Println() // newline separator for multiple servers
} }
alreadySeen[app.Server] = true
} }
if len(allStats) > 1 { if len(allStats) > 1 {

View File

@ -10,7 +10,6 @@ import (
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/service"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client" dockerClient "github.com/docker/docker/client"
@ -49,6 +48,7 @@ func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
// defer after err check as any err returns a nil io.ReadCloser
defer logs.Close() defer logs.Close()
_, err = io.Copy(os.Stdout, logs) _, err = io.Copy(os.Stdout, logs)
@ -57,9 +57,7 @@ func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
} }
}(service.ID) }(service.ID)
} }
wg.Wait() wg.Wait()
os.Exit(0) os.Exit(0)
} }
@ -96,21 +94,27 @@ var appLogsCommand = &cli.Command{
} }
func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, serviceName string) error { func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, serviceName string) error {
service := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", app.StackName(), serviceName)) filters.Add("name", service)
chosenService, err := service.GetService(c.Context, cl, filters, internal.NoInput) serviceOpts := types.ServiceListOptions{Filters: filters}
services, err := cl.ServiceList(c.Context, serviceOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if len(services) != 1 {
logrus.Fatalf("expected 1 service but got %v", len(services))
}
if internal.StdErrOnly { if internal.StdErrOnly {
logOpts.ShowStdout = false logOpts.ShowStdout = false
} }
logs, err := cl.ServiceLogs(c.Context, chosenService.ID, logOpts) logs, err := cl.ServiceLogs(c.Context, services[0].ID, logOpts)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
// defer after err check as any err returns a nil io.ReadCloser
defer logs.Close() defer logs.Close()
_, err = io.Copy(os.Stdout, logs) _, err = io.Copy(os.Stdout, logs)

View File

@ -4,65 +4,58 @@ import (
"strings" "strings"
"time" "time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "github.com/docker/cli/cli/command/formatter"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/service"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/buger/goterm"
dockerFormatter "github.com/docker/cli/cli/command/formatter"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
var watch bool
var watchFlag = &cli.BoolFlag{
Name: "watch",
Aliases: []string{"w"},
Value: false,
Usage: "Watch status by polling repeatedly",
Destination: &watch,
}
var appPsCommand = &cli.Command{ var appPsCommand = &cli.Command{
Name: "ps", Name: "ps",
Usage: "Check app status", Usage: "Check app status",
Description: "This command shows a more detailed status output of a specific deployed app.", Description: "This command shows a more detailed status output of a specific deployed app.",
Aliases: []string{"p"}, Aliases: []string{"p"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.WatchFlag, watchFlag,
}, },
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) if !watch {
showPSOutput(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name)
}
if !internal.Watch {
showPSOutput(c, app, cl)
return nil return nil
} }
goterm.Clear() // TODO: how do we make this update in-place in an x-platform way?
for { for {
goterm.MoveCursor(1, 1) showPSOutput(c)
showPSOutput(c, app, cl)
goterm.Flush()
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }
}, },
} }
// showPSOutput renders ps output. // showPSOutput renders ps output.
func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) { func showPSOutput(c *cli.Context) {
app := internal.ValidateApp(c)
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", app.StackName()) filters.Add("name", app.StackName())
@ -71,8 +64,8 @@ func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
logrus.Fatal(err) logrus.Fatal(err)
} }
tableCol := []string{"service name", "image", "created", "status", "state", "ports"} tableCol := []string{"image", "created", "status", "ports"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
for _, container := range containers { for _, container := range containers {
var containerNames []string var containerNames []string
@ -82,12 +75,10 @@ func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
} }
tableRow := []string{ tableRow := []string{
service.ContainerToServiceName(container.Names, app.StackName()), abraFormatter.RemoveSha(container.Image),
formatter.RemoveSha(container.Image), abraFormatter.HumanDuration(container.Created),
formatter.HumanDuration(container.Created),
container.Status, container.Status,
container.State, formatter.DisplayablePorts(container.Ports),
dockerFormatter.DisplayablePorts(container.Ports),
} }
table.Append(tableRow) table.Append(tableRow)
} }

View File

@ -60,7 +60,7 @@ var appRemoveCommand = &cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
if isDeployed { if isDeployed {
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s \" or pass --force", app.Name, app.Name) logrus.Fatalf("'%s' is still deployed. Run \"abra app undeploy %s \" or pass --force", app.Name, app.Name)
} }
} }

View File

@ -3,28 +3,28 @@ package app
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
upstream "coopcloud.tech/abra/pkg/upstream/service" containerPkg "coopcloud.tech/abra/pkg/container"
stack "coopcloud.tech/abra/pkg/upstream/stack" "github.com/docker/docker/api/types/filters"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
var appRestartCommand = &cli.Command{ var appRestartCommand = &cli.Command{
Name: "restart", Name: "restart",
Usage: "Restart an app", Usage: "Restart an app",
Aliases: []string{"re"}, Aliases: []string{"R"},
ArgsUsage: "<service>", ArgsUsage: "<service>",
Description: `This command restarts a service within a deployed app.`, Description: `This command restarts a service within a deployed app.`,
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
serviceNameShort := c.Args().Get(1) serviceName := c.Args().Get(1)
if serviceNameShort == "" { if serviceName == "" {
err := errors.New("missing service?") err := errors.New("missing service?")
internal.ShowSubcommandHelpAndError(c, err) internal.ShowSubcommandHelpAndError(c, err)
} }
@ -34,32 +34,25 @@ var appRestartCommand = &cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
serviceName := fmt.Sprintf("%s_%s", app.StackName(), serviceNameShort) serviceFilter := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
filters := filters.NewArgs()
filters.Add("name", serviceFilter)
logrus.Debugf("attempting to scale %s to 0 (restart logic)", serviceName) targetContainer, err := containerPkg.GetContainer(c.Context, cl, filters, true)
if err := upstream.RunServiceScale(c.Context, cl, serviceName, 0); err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := stack.WaitOnService(c.Context, cl, serviceName, app.Name); err != nil { logrus.Debugf("attempting to restart %s", serviceFilter)
timeout := 30 * time.Second
if err := cl.ContainerRestart(c.Context, targetContainer.ID, &timeout); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("%s has been scaled to 0 (restart logic)", serviceName) logrus.Infof("%s service restarted", serviceFilter)
logrus.Debugf("attempting to scale %s to 1 (restart logic)", serviceName)
if err := upstream.RunServiceScale(c.Context, cl, serviceName, 1); err != nil {
logrus.Fatal(err)
}
if err := stack.WaitOnService(c.Context, cl, serviceName, app.Name); err != nil {
logrus.Fatal(err)
}
logrus.Debugf("%s has been scaled to 1 (restart logic)", serviceName)
logrus.Infof("%s service successfully restarted", serviceNameShort)
return nil return nil
}, },
BashComplete: autocomplete.AppNameComplete,
} }

View File

@ -27,7 +27,7 @@ var restoreAllServicesFlag = &cli.BoolFlag{
var appRestoreCommand = &cli.Command{ var appRestoreCommand = &cli.Command{
Name: "restore", Name: "restore",
Usage: "Restore an app from a backup", Usage: "Restore an app from a backup",
Aliases: []string{"rs"}, Aliases: []string{"r"},
Flags: []cli.Flag{restoreAllServicesFlag}, Flags: []cli.Flag{restoreAllServicesFlag},
ArgsUsage: "<service> [<backup file>]", ArgsUsage: "<service> [<backup file>]",
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
@ -37,10 +37,10 @@ var appRestoreCommand = &cli.Command{
internal.ShowSubcommandHelpAndError(c, errors.New("cannot use <service>/<backup file> and '--all' together")) internal.ShowSubcommandHelpAndError(c, errors.New("cannot use <service>/<backup file> and '--all' together"))
} }
abraSh := path.Join(config.RECIPES_DIR, app.Type, "abra.sh") abraSh := path.Join(config.ABRA_DIR, "apps", app.Type, "abra.sh")
if _, err := os.Stat(abraSh); err != nil { if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
logrus.Fatalf("%s does not exist?", abraSh) logrus.Fatalf("'%s' does not exist?", abraSh)
} }
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -60,7 +60,7 @@ var appRestoreCommand = &cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
if !strings.Contains(string(bytes), execCmd) { if !strings.Contains(string(bytes), execCmd) {
logrus.Fatalf("%s doesn't have a %s function", app.Type, execCmd) logrus.Fatalf("%s doesn't have a '%s' function", app.Type, execCmd)
} }
backupFile := c.Args().Get(2) backupFile := c.Args().Get(2)

View File

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
@ -20,12 +20,11 @@ import (
var appRollbackCommand = &cli.Command{ var appRollbackCommand = &cli.Command{
Name: "rollback", Name: "rollback",
Usage: "Roll an app back to a previous version", Usage: "Roll an app back to a previous version",
Aliases: []string{"rl"}, Aliases: []string{"r", "downgrade"},
ArgsUsage: "<app>", ArgsUsage: "<app>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.ForceFlag, internal.ForceFlag,
internal.ChaosFlag, internal.ChaosFlag,
internal.DontWaitConvergeFlag,
}, },
Description: ` Description: `
This command rolls an app back to a previous version if one exists. This command rolls an app back to a previous version if one exists.
@ -45,25 +44,12 @@ recipes.
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
if err := recipe.EnsureUpToDate(app.Type); err != nil {
logrus.Fatal(err)
}
r, err := recipe.Get(app.Type)
if err != nil {
logrus.Fatal(err)
}
if err := lint.LintForErrors(r); err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("checking whether %s is already deployed", stackName) logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName) isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil { if err != nil {
@ -71,27 +57,24 @@ recipes.
} }
if !isDeployed { if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("'%s' is not deployed?", app.Name)
} }
catl, err := recipe.ReadRecipeCatalogue() catl, err := catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl) versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if len(versions) == 0 && !internal.Chaos {
logrus.Fatalf("no published releases for %s in the recipe catalogue?", app.Type)
}
var availableDowngrades []string var availableDowngrades []string
if deployedVersion == "unknown" { if deployedVersion == "" {
deployedVersion = "unknown"
availableDowngrades = versions availableDowngrades = versions
logrus.Warnf("failed to determine version of deployed %s", app.Name) logrus.Warnf("failed to determine version of deployed '%s'", app.Name)
} }
if deployedVersion != "unknown" && !internal.Chaos { if deployedVersion != "unknown" && !internal.Chaos {
@ -115,16 +98,19 @@ recipes.
} }
} }
availableDowngrades = internal.ReverseStringList(availableDowngrades) // FIXME: jeezus golang why do you not have a list reverse function
for i, j := 0, len(availableDowngrades)-1; i < j; i, j = i+1, j-1 {
availableDowngrades[i], availableDowngrades[j] = availableDowngrades[j], availableDowngrades[i]
}
var chosenDowngrade string var chosenDowngrade string
if !internal.Chaos { if !internal.Chaos {
if internal.Force { if internal.Force {
chosenDowngrade = availableDowngrades[0] chosenDowngrade = availableDowngrades[0]
logrus.Debugf("choosing %s as version to downgrade to (--force)", chosenDowngrade) logrus.Debugf("choosing '%s' as version to downgrade to (--force)", chosenDowngrade)
} else { } else {
prompt := &survey.Select{ prompt := &survey.Select{
Message: fmt.Sprintf("Please select a downgrade (current version: %s):", deployedVersion), Message: fmt.Sprintf("Please select a downgrade (current version: '%s'):", deployedVersion),
Options: availableDowngrades, Options: availableDowngrades,
} }
if err := survey.AskOne(prompt, &chosenDowngrade); err != nil { if err := survey.AskOne(prompt, &chosenDowngrade); err != nil {
@ -148,7 +134,7 @@ recipes.
} }
} }
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Type, "abra.sh") abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath) abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -173,12 +159,12 @@ recipes.
} }
if !internal.Force { if !internal.Force {
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil { if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }
if err := stack.RunDeploy(cl, deployOpts, compose, app.StackName(), internal.DontWaitConverge); err != nil { if err := stack.RunDeploy(cl, deployOpts, compose, app.Type); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container" containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/upstream/container" "coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
@ -36,10 +36,9 @@ var appRunCommand = &cli.Command{
noTTYFlag, noTTYFlag,
userFlag, userFlag,
}, },
Aliases: []string{"r"}, Aliases: []string{"r"},
ArgsUsage: "<service> <args>...", ArgsUsage: "<service> <args>...",
Usage: "Run a command in a service container", Usage: "Run a command in a service container",
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
@ -83,7 +82,11 @@ var appRunCommand = &cli.Command{
execCreateOpts.Tty = false execCreateOpts.Tty = false
} }
// FIXME: avoid instantiating a new CLI // FIXME: an absolutely monumental hack to instantiate another command-line
// client withing our command-line client so that we pass something down
// the tubes that satisfies the necessary interface requirements. We should
// refactor our vendored container code to not require all this cruft. For
// now, It Works.
dcli, err := command.NewDockerCli() dcli, err := command.NewDockerCli()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -95,4 +98,25 @@ var appRunCommand = &cli.Command{
return nil return nil
}, },
BashComplete: func(c *cli.Context) {
switch c.NArg() {
case 0:
appNames, err := config.GetAppNames()
if err != nil {
logrus.Warn(err)
}
for _, a := range appNames {
fmt.Println(a)
}
case 1:
appName := c.Args().First()
serviceNames, err := config.GetAppServiceNames(appName)
if err != nil {
logrus.Warn(err)
}
for _, s := range serviceNames {
fmt.Println(s)
}
}
},
} }

View File

@ -6,10 +6,10 @@ import (
"os" "os"
"strconv" "strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/secret" "coopcloud.tech/abra/pkg/secret"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
@ -60,7 +60,7 @@ var appSecretGenerateCommand = &cli.Command{
} }
} }
if !matches { if !matches {
logrus.Fatalf("%s doesn't exist in the env config?", secretName) logrus.Fatalf("'%s' doesn't exist in the env config?", secretName)
} }
} }
@ -83,7 +83,7 @@ var appSecretGenerateCommand = &cli.Command{
} }
tableCol := []string{"name", "value"} tableCol := []string{"name", "value"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
for name, val := range secretVals { for name, val := range secretVals {
table.Append([]string{name, val}) table.Append([]string{name, val})
} }
@ -215,7 +215,7 @@ var appSecretLsCommand = &cli.Command{
secrets := secret.ReadSecretEnvVars(app.Env) secrets := secret.ReadSecretEnvVars(app.Env)
tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"} tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
@ -249,12 +249,7 @@ var appSecretLsCommand = &cli.Command{
table.Append(tableRow) table.Append(tableRow)
} }
if table.NumLines() > 0 { table.Render()
table.Render()
} else {
logrus.Warnf("no secrets stored for %s", app.Name)
}
return nil return nil
}, },
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,

View File

@ -11,7 +11,7 @@ import (
var appUndeployCommand = &cli.Command{ var appUndeployCommand = &cli.Command{
Name: "undeploy", Name: "undeploy",
Aliases: []string{"un"}, Aliases: []string{"u"},
Usage: "Undeploy an app", Usage: "Undeploy an app",
Description: ` Description: `
This does not destroy any of the application data. However, you should remain This does not destroy any of the application data. However, you should remain
@ -27,7 +27,7 @@ volumes as eligiblef or pruning once undeployed.
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("checking whether %s is already deployed", stackName) logrus.Debugf("checking whether '%s' is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName) isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil { if err != nil {
@ -35,7 +35,7 @@ volumes as eligiblef or pruning once undeployed.
} }
if !isDeployed { if !isDeployed {
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("'%s' is not deployed?", stackName)
} }
if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil { if err := internal.DeployOverview(app, deployedVersion, "continue with undeploy?"); err != nil {

View File

@ -5,9 +5,9 @@ import (
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
@ -18,7 +18,7 @@ import (
var appUpgradeCommand = &cli.Command{ var appUpgradeCommand = &cli.Command{
Name: "upgrade", Name: "upgrade",
Aliases: []string{"up"}, Aliases: []string{"u"},
Usage: "Upgrade an app", Usage: "Upgrade an app",
ArgsUsage: "<app>", ArgsUsage: "<app>",
Flags: []cli.Flag{ Flags: []cli.Flag{
@ -30,7 +30,7 @@ var appUpgradeCommand = &cli.Command{
This command supports upgrading an app. You can use it to choose and roll out a This command supports upgrading an app. You can use it to choose and roll out a
new upgrade to an existing app. new upgrade to an existing app.
This command specifically supports incrementing the version of running apps, as This command specifically supports changing the version of running apps, as
opposed to "abra app deploy <app>" which will not change the version of a opposed to "abra app deploy <app>" which will not change the version of a
deployed app. deployed app.
@ -48,19 +48,6 @@ recipes.
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
if err := recipe.EnsureUpToDate(app.Type); err != nil {
logrus.Fatal(err)
}
r, err := recipe.Get(app.Type)
if err != nil {
logrus.Fatal(err)
}
if err := lint.LintForErrors(r); err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -77,12 +64,12 @@ recipes.
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("%s is not deployed?", app.Name)
} }
catl, err := recipe.ReadRecipeCatalogue() catl, err := catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl) versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -92,7 +79,8 @@ recipes.
} }
var availableUpgrades []string var availableUpgrades []string
if deployedVersion == "uknown" { if deployedVersion == "" {
deployedVersion = "unknown"
availableUpgrades = versions availableUpgrades = versions
logrus.Warnf("failed to determine version of deployed %s", app.Name) logrus.Warnf("failed to determine version of deployed %s", app.Name)
} }
@ -118,8 +106,6 @@ recipes.
} }
} }
availableUpgrades = internal.ReverseStringList(availableUpgrades)
var chosenUpgrade string var chosenUpgrade string
if len(availableUpgrades) > 0 && !internal.Chaos { if len(availableUpgrades) > 0 && !internal.Chaos {
if internal.Force { if internal.Force {
@ -136,14 +122,6 @@ recipes.
} }
} }
// if release notes written after git tag published, read them before we
// check out the tag and then they'll appear to be missing. this covers
// when we obviously will forget to write release notes before publishing
releaseNotes, err := internal.GetReleaseNotes(app.Type, chosenUpgrade)
if err != nil {
return err
}
if !internal.Chaos { if !internal.Chaos {
if err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil { if err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -159,7 +137,7 @@ recipes.
} }
} }
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Type, "abra.sh") abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath) abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -183,11 +161,11 @@ recipes.
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade, releaseNotes); err != nil { if err := internal.NewVersionOverview(app, deployedVersion, chosenUpgrade); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := stack.RunDeploy(cl, deployOpts, compose, app.StackName(), internal.DontWaitConverge); err != nil { if err := stack.RunDeploy(cl, deployOpts, compose, app.Type); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }

View File

@ -3,11 +3,11 @@ package app
import ( import (
"strings" "strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -56,7 +56,7 @@ Cloud recipe version.
logrus.Fatal(err) logrus.Fatal(err)
} }
if deployedVersion == "unknown" { if deployedVersion == "" {
logrus.Fatalf("failed to determine version of deployed %s", app.Name) logrus.Fatalf("failed to determine version of deployed %s", app.Name)
} }
@ -64,12 +64,12 @@ Cloud recipe version.
logrus.Fatalf("%s is not deployed?", app.Name) logrus.Fatalf("%s is not deployed?", app.Name)
} }
recipeMeta, err := recipe.GetRecipeMeta(app.Type) recipeMeta, err := catalogue.GetRecipeMeta(app.Type)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
versionsMeta := make(map[string]recipe.ServiceMeta) versionsMeta := make(map[string]catalogue.ServiceMeta)
for _, recipeVersion := range recipeMeta.Versions { for _, recipeVersion := range recipeMeta.Versions {
if currentVersion, exists := recipeVersion[deployedVersion]; exists { if currentVersion, exists := recipeVersion[deployedVersion]; exists {
versionsMeta = currentVersion versionsMeta = currentVersion
@ -81,7 +81,7 @@ Cloud recipe version.
} }
tableCol := []string{"version", "service", "image", "digest"} tableCol := []string{"version", "service", "image", "digest"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
table.SetAutoMergeCellsByColumnIndex([]int{0}) table.SetAutoMergeCellsByColumnIndex([]int{0})
for serviceName, versionMeta := range versionsMeta { for serviceName, versionMeta := range versionsMeta {

View File

@ -1,10 +1,10 @@
package app package app
import ( import (
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -23,7 +23,7 @@ var appVolumeListCommand = &cli.Command{
logrus.Fatal(err) logrus.Fatal(err)
} }
table := formatter.CreateTable([]string{"driver", "volume name"}) table := abraFormatter.CreateTable([]string{"driver", "volume name"})
var volTable [][]string var volTable [][]string
for _, volume := range volumeList { for _, volume := range volumeList {
volRow := []string{ volRow := []string{
@ -34,12 +34,7 @@ var appVolumeListCommand = &cli.Command{
} }
table.AppendBulk(volTable) table.AppendBulk(volTable)
table.Render()
if table.NumLines() > 0 {
table.Render()
} else {
logrus.Warnf("no volumes created for %s", app.Name)
}
return nil return nil
}, },
@ -59,8 +54,7 @@ interface.
Passing "--force" will select all volumes for removal. Be careful. Passing "--force" will select all volumes for removal. Be careful.
`, `,
ArgsUsage: "<app>", Aliases: []string{"rm"},
Aliases: []string{"rm"},
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.ForceFlag, internal.ForceFlag,
}, },

97
cli/autocomplete.go Normal file
View File

@ -0,0 +1,97 @@
package cli
import (
"errors"
"fmt"
"os"
"path"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/web"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// AutoCompleteCommand helps people set up auto-complete in their shells
var AutoCompleteCommand = &cli.Command{
Name: "autocomplete",
Usage: "Help set up shell autocompletion",
Aliases: []string{"ac"},
Description: `
This command helps set up autocompletion in your shell by downloading the
relevant autocompletion files and laying out what additional information must
be loaded.
Example:
abra autocomplete bash
Supported shells are as follows:
fizsh
zsh
bash
`,
ArgsUsage: "<shell>",
Action: func(c *cli.Context) error {
shellType := c.Args().First()
if shellType == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
}
supportedShells := map[string]bool{
"bash": true,
"zsh": true,
"fizsh": true,
}
if _, ok := supportedShells[shellType]; !ok {
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
}
if shellType == "fizsh" {
shellType = "zsh" // handled the same on the autocompletion side
}
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
if err := os.Mkdir(autocompletionDir, 0764); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
}
logrus.Debugf("%s already created", autocompletionDir)
}
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
logrus.Infof("fetching %s", url)
if err := web.GetFile(autocompletionFile, url); err != nil {
logrus.Fatal(err)
}
}
switch shellType {
case "bash":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/bash_completion.d/
sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
`, autocompletionFile))
case "zsh":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/zsh/completion.d/
sudo cp %s /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
`, autocompletionFile))
}
return nil
},
}

View File

@ -4,16 +4,17 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os"
"path" "path"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git" gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/limit" "coopcloud.tech/abra/pkg/limit"
"coopcloud.tech/abra/pkg/recipe" "github.com/AlecAivazis/survey/v2"
"github.com/go-git/go-git/v5"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -58,13 +59,12 @@ var CatalogueSkipList = map[string]bool{
var catalogueGenerateCommand = &cli.Command{ var catalogueGenerateCommand = &cli.Command{
Name: "generate", Name: "generate",
Aliases: []string{"g"}, Aliases: []string{"g"},
Usage: "Generate the recipe catalogue", Usage: "Generate a new copy of the catalogue",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.PublishFlag, internal.PushFlag,
internal.CommitFlag,
internal.CommitMessageFlag,
internal.DryFlag, internal.DryFlag,
internal.SkipUpdatesFlag,
internal.RegistryUsernameFlag,
internal.RegistryPasswordFlag,
}, },
Description: ` Description: `
This command generates a new copy of the recipe catalogue which can be found on: This command generates a new copy of the recipe catalogue which can be found on:
@ -72,19 +72,17 @@ This command generates a new copy of the recipe catalogue which can be found on:
https://recipes.coopcloud.tech https://recipes.coopcloud.tech
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README.md and git tags of those repositories to produce recipe listing, parses README and tags to produce recipe metadata and produces a
metadata and produces a recipes JSON file. apps.json file which is placed in your ~/.abra/catalogue/recipes.json.
It is possible to generate new metadata for a single recipe by passing It is possible to generate new metadata for a single recipe by passing
<recipe>. The existing local catalogue will be updated, not overwritten. <recipe>. The existing local catalogue will be updated, not overwritten.
It is quite easy to get rate limited by Docker Hub when running this command. A new catalogue copy can be published to the recipes repository by passing the
If you have a Hub account you can have Abra log you in to avoid this. Pass "--commit" and "--push" flags. The recipes repository is available here:
"--user" and "--pass".
https://git.coopcloud.tech/coop-cloud/recipes
Push your new release git.coopcloud.tech with "-p/--publish". This requires
that you have permission to git push to these repositories and have your SSH
keys configured on your account.
`, `,
ArgsUsage: "[<recipe>]", ArgsUsage: "[<recipe>]",
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
@ -99,29 +97,68 @@ keys configured on your account.
return err return err
} }
repos, err := recipe.ReadReposMetadata() repos, err := catalogue.ReadReposMetadata()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("ensuring '%v' recipe(s) are locally present and up-to-date", len(repos))
var barLength int var barLength int
var logMsg string
if recipeName != "" { if recipeName != "" {
barLength = 1 barLength = 1
logMsg = fmt.Sprintf("ensuring %v recipe is cloned & up-to-date", barLength)
} else { } else {
barLength = len(repos) barLength = len(repos)
logMsg = fmt.Sprintf("ensuring %v recipes are cloned & up-to-date, this could take some time...", barLength)
} }
if !internal.SkipUpdates { cloneLimiter := limit.New(10)
logrus.Warn(logMsg) retrieveBar := formatter.CreateProgressbar(len(repos), "retrieving recipes from recipes.coopcloud.tech...")
if err := updateRepositories(repos, recipeName); err != nil { ch := make(chan string, barLength)
logrus.Fatal(err) for _, repoMeta := range repos {
} go func(rm catalogue.RepoMeta) {
cloneLimiter.Begin()
defer cloneLimiter.End()
if recipeName != "" && recipeName != rm.Name {
ch <- rm.Name
retrieveBar.Add(1)
return
}
if _, exists := CatalogueSkipList[rm.Name]; exists {
ch <- rm.Name
retrieveBar.Add(1)
return
}
recipeDir := path.Join(config.ABRA_DIR, "apps", rm.Name)
if err := gitPkg.Clone(recipeDir, rm.SSHURL); err != nil {
logrus.Fatal(err)
}
isClean, err := gitPkg.IsClean(rm.Name)
if err != nil {
logrus.Fatal(err)
}
if !isClean {
logrus.Fatalf("'%s' has locally unstaged changes", rm.Name)
}
if err := gitPkg.EnsureUpToDate(recipeDir); err != nil {
logrus.Fatal(err)
}
ch <- rm.Name
retrieveBar.Add(1)
}(repoMeta)
} }
catl := make(recipe.RecipeCatalogue) for range repos {
<-ch // wait for everything
}
catl := make(catalogue.RecipeCatalogue)
catlBar := formatter.CreateProgressbar(barLength, "generating catalogue metadata...") catlBar := formatter.CreateProgressbar(barLength, "generating catalogue metadata...")
for _, recipeMeta := range repos { for _, recipeMeta := range repos {
if recipeName != "" && recipeName != recipeMeta.Name { if recipeName != "" && recipeName != recipeMeta.Name {
@ -134,24 +171,19 @@ keys configured on your account.
continue continue
} }
versions, err := recipe.GetRecipeVersions( versions, err := catalogue.GetRecipeVersions(recipeMeta.Name)
recipeMeta.Name,
internal.RegistryUsername,
internal.RegistryPassword,
)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name) features, category, err := catalogue.GetRecipeFeaturesAndCategory(recipeMeta.Name)
if err != nil { if err != nil {
logrus.Warn(err) logrus.Warn(err)
} }
catl[recipeMeta.Name] = recipe.RecipeMeta{ catl[recipeMeta.Name] = catalogue.RecipeMeta{
Name: recipeMeta.Name, Name: recipeMeta.Name,
Repository: recipeMeta.CloneURL, Repository: recipeMeta.CloneURL,
SSHURL: recipeMeta.SSHURL,
Icon: recipeMeta.AvatarURL, Icon: recipeMeta.AvatarURL,
DefaultBranch: recipeMeta.DefaultBranch, DefaultBranch: recipeMeta.DefaultBranch,
Description: recipeMeta.Description, Description: recipeMeta.Description,
@ -160,7 +192,6 @@ keys configured on your account.
Category: category, Category: category,
Features: features, Features: features,
} }
catlBar.Add(1) catlBar.Add(1)
} }
@ -169,81 +200,45 @@ keys configured on your account.
logrus.Fatal(err) logrus.Fatal(err)
} }
if recipeName == "" { if _, err := os.Stat(config.APPS_JSON); err != nil && os.IsNotExist(err) {
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil { if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0764); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} else { } else {
catlFS, err := recipe.ReadRecipeCatalogue() if recipeName != "" {
if err != nil { catlFS, err := catalogue.ReadRecipeCatalogue()
logrus.Fatal(err) if err != nil {
} logrus.Fatal(err)
}
catlFS[recipeName] = catl[recipeName]
catlFS[recipeName] = catl[recipeName] updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
if err != nil {
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ") logrus.Fatal(err)
if err != nil { }
logrus.Fatal(err) if err := ioutil.WriteFile(config.APPS_JSON, updatedRecipesJSON, 0764); err != nil {
} logrus.Fatal(err)
}
if err := ioutil.WriteFile(config.RECIPES_JSON, updatedRecipesJSON, 0764); err != nil {
logrus.Fatal(err)
} }
} }
logrus.Infof("generated new recipe catalogue in %s", config.RECIPES_JSON) logrus.Infof("generated new recipe catalogue in %s", config.APPS_JSON)
cataloguePath := path.Join(config.ABRA_DIR, "catalogue") if internal.Commit {
if internal.Publish { if internal.CommitMessage == "" && !internal.NoInput {
prompt := &survey.Input{
isClean, err := gitPkg.IsClean(cataloguePath) Message: "commit message",
if err != nil { Default: fmt.Sprintf("chore: publish catalogue changes"),
logrus.Fatal(err) }
} if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
logrus.Fatal(err)
if isClean {
if !internal.Dry {
logrus.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
} }
} }
msg := "chore: publish new catalogue release changes" cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
if err := gitPkg.Commit(cataloguePath, "**.json", msg, internal.Dry); err != nil { if err := gitPkg.Commit(cataloguePath, "**.json", internal.CommitMessage, internal.Dry, internal.Push); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
repo, err := git.PlainOpen(cataloguePath)
if err != nil {
logrus.Fatal(err)
}
sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, "recipes")
if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil {
logrus.Fatal(err)
}
if err := gitPkg.Push(cataloguePath, "origin-ssh", false, internal.Dry); err != nil {
logrus.Fatal(err)
}
}
repo, err := git.PlainOpen(cataloguePath)
if err != nil {
logrus.Fatal(err)
}
head, err := repo.Head()
if err != nil {
logrus.Fatal(err)
}
if !internal.Dry && internal.Publish {
url := fmt.Sprintf("%s/recipes/commit/%s", config.REPOS_BASE_URL, head.Hash())
logrus.Infof("new changes published: %s", url)
}
if internal.Dry {
logrus.Info("dry run: no changes published")
} }
return nil return nil
@ -262,62 +257,3 @@ var CatalogueCommand = &cli.Command{
catalogueGenerateCommand, catalogueGenerateCommand,
}, },
} }
func updateRepositories(repos recipe.RepoCatalogue, recipeName string) error {
var barLength int
if recipeName != "" {
barLength = 1
} else {
barLength = len(repos)
}
cloneLimiter := limit.New(10)
retrieveBar := formatter.CreateProgressbar(barLength, "ensuring recipes are cloned & up-to-date...")
ch := make(chan string, barLength)
for _, repoMeta := range repos {
go func(rm recipe.RepoMeta) {
cloneLimiter.Begin()
defer cloneLimiter.End()
if recipeName != "" && recipeName != rm.Name {
ch <- rm.Name
retrieveBar.Add(1)
return
}
if _, exists := CatalogueSkipList[rm.Name]; exists {
ch <- rm.Name
retrieveBar.Add(1)
return
}
recipeDir := path.Join(config.RECIPES_DIR, rm.Name)
if err := gitPkg.Clone(recipeDir, rm.CloneURL); err != nil {
logrus.Fatal(err)
}
isClean, err := gitPkg.IsClean(recipeDir)
if err != nil {
logrus.Fatal(err)
}
if !isClean {
logrus.Fatalf("%s has locally unstaged changes", rm.Name)
}
if err := recipe.EnsureUpToDate(rm.Name); err != nil {
logrus.Fatal(err)
}
ch <- rm.Name
retrieveBar.Add(1)
}(repoMeta)
}
for range repos {
<-ch // wait for everything
}
return nil
}

View File

@ -2,10 +2,8 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path" "path"
"coopcloud.tech/abra/cli/app" "coopcloud.tech/abra/cli/app"
@ -15,129 +13,11 @@ import (
"coopcloud.tech/abra/cli/record" "coopcloud.tech/abra/cli/record"
"coopcloud.tech/abra/cli/server" "coopcloud.tech/abra/cli/server"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/web"
logrusStack "github.com/Gurpartap/logrus-stack" logrusStack "github.com/Gurpartap/logrus-stack"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
// AutoCompleteCommand helps people set up auto-complete in their shells
var AutoCompleteCommand = &cli.Command{
Name: "autocomplete",
Usage: "Configure shell autocompletion (recommended)",
Aliases: []string{"ac"},
Description: `
This command helps set up autocompletion in your shell by downloading the
relevant autocompletion files and laying out what additional information must
be loaded.
Example:
abra autocomplete bash
Supported shells are as follows:
fizsh
zsh
bash
`,
ArgsUsage: "<shell>",
Action: func(c *cli.Context) error {
shellType := c.Args().First()
if shellType == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no shell provided"))
}
supportedShells := map[string]bool{
"bash": true,
"zsh": true,
"fizsh": true,
}
if _, ok := supportedShells[shellType]; !ok {
logrus.Fatalf("%s is not a supported shell right now, sorry", shellType)
}
if shellType == "fizsh" {
shellType = "zsh" // handled the same on the autocompletion side
}
autocompletionDir := path.Join(config.ABRA_DIR, "autocompletion")
if err := os.Mkdir(autocompletionDir, 0764); err != nil {
if !os.IsExist(err) {
logrus.Fatal(err)
}
logrus.Debugf("%s already created", autocompletionDir)
}
autocompletionFile := path.Join(config.ABRA_DIR, "autocompletion", shellType)
if _, err := os.Stat(autocompletionFile); err != nil && os.IsNotExist(err) {
url := fmt.Sprintf("https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/autocomplete/%s", shellType)
logrus.Infof("fetching %s", url)
if err := web.GetFile(autocompletionFile, url); err != nil {
logrus.Fatal(err)
}
}
switch shellType {
case "bash":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/bash_completion.d/
sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
`, autocompletionFile))
case "zsh":
fmt.Println(fmt.Sprintf(`
# Run the following commands to install autocompletion
sudo mkdir /etc/zsh/completion.d/
sudo cp %s /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
# And finally run "abra app ps <hit tab key>" to test things are working, you should see app names listed!
`, autocompletionFile))
}
return nil
},
}
// UpgradeCommand upgrades abra in-place.
var UpgradeCommand = &cli.Command{
Name: "upgrade",
Usage: "Upgrade Abra itself",
Aliases: []string{"u"},
Description: `
This command allows you to upgrade Abra in-place with the latest stable or
release candidate.
If you would like to install the latest release candidate, please pass the
"--rc" option. Please bear in mind that the latest release candidate may have
some catastrophic bugs contained in it. In any case, thank you very much for
the testing efforts!
`,
Flags: []cli.Flag{internal.RCFlag},
Action: func(c *cli.Context) error {
mainURL := "https://install.abra.coopcloud.tech"
cmd := exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash", mainURL))
if internal.RC {
releaseCandidateURL := "https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
cmd = exec.Command("bash", "-c", fmt.Sprintf("wget -q -O- %s | bash -s -- --rc", releaseCandidateURL))
}
logrus.Debugf("attempting to run %s", cmd)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
}
return nil
},
}
func newAbraApp(version, commit string) *cli.App { func newAbraApp(version, commit string) *cli.App {
app := &cli.App{ app := &cli.App{
Name: "abra", Name: "abra",
@ -148,7 +28,10 @@ func newAbraApp(version, commit string) *cli.App {
| |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| | | |__| (_) |_____| (_) | |_) | | |___| | (_) | |_| | (_| |
\____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_| \____\___/ \___/| .__/ \____|_|\___/ \__,_|\__,_|
|_| |_|
`,
If you haven't already done so, consider setting up autocompletion for a more
convenient command-line experience. See "abra autocomplete -h" for more.
`,
Version: fmt.Sprintf("%s-%s", version, commit[:7]), Version: fmt.Sprintf("%s-%s", version, commit[:7]),
Commands: []*cli.Command{ Commands: []*cli.Command{
app.AppCommand, app.AppCommand,
@ -160,6 +43,7 @@ func newAbraApp(version, commit string) *cli.App {
AutoCompleteCommand, AutoCompleteCommand,
}, },
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.VerboseFlag,
internal.DebugFlag, internal.DebugFlag,
internal.NoInputFlag, internal.NoInputFlag,
}, },
@ -169,7 +53,6 @@ func newAbraApp(version, commit string) *cli.App {
// some love // some love
{Name: "3wordchant"}, {Name: "3wordchant"},
{Name: "decentral1se"}, {Name: "decentral1se"},
{Name: "kawaiipunk"},
{Name: "knoflook"}, {Name: "knoflook"},
{Name: "roxxers"}, {Name: "roxxers"},
}, },
@ -187,9 +70,9 @@ func newAbraApp(version, commit string) *cli.App {
paths := []string{ paths := []string{
config.ABRA_DIR, config.ABRA_DIR,
path.Join(config.SERVERS_DIR), path.Join(config.ABRA_DIR, "servers"),
path.Join(config.RECIPES_DIR), path.Join(config.ABRA_DIR, "apps"),
path.Join(config.VENDOR_DIR), path.Join(config.ABRA_DIR, "vendor"),
} }
for _, path := range paths { for _, path := range paths {
@ -201,7 +84,7 @@ func newAbraApp(version, commit string) *cli.App {
} }
} }
logrus.Debugf("abra version %s, commit %s", version, commit) logrus.Debugf("abra version '%s', commit '%s'", version, commit)
return nil return nil
} }

View File

@ -1,10 +1,12 @@
package formatter package formatter
import ( import (
"fmt"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/docker/cli/cli/command/formatter"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter"
"github.com/schollz/progressbar/v3" "github.com/schollz/progressbar/v3"
@ -14,6 +16,10 @@ func ShortenID(str string) string {
return str[:12] return str[:12]
} }
func Truncate(str string) string {
return fmt.Sprintf(`"%s"`, formatter.Ellipsis(str, 19))
}
func SmallSHA(hash string) string { func SmallSHA(hash string) string {
return hash[:8] return hash[:8]
} }
@ -33,7 +39,6 @@ func HumanDuration(timestamp int64) string {
// CreateTable prepares a table layout for output. // CreateTable prepares a table layout for output.
func CreateTable(columns []string) *tablewriter.Table { func CreateTable(columns []string) *tablewriter.Table {
table := tablewriter.NewWriter(os.Stdout) table := tablewriter.NewWriter(os.Stdout)
table.SetAutoWrapText(false)
table.SetHeader(columns) table.SetHeader(columns)
return table return table
} }

View File

@ -10,7 +10,7 @@ var Secrets bool
// SecretsFlag turns on/off automatically generating secrets // SecretsFlag turns on/off automatically generating secrets
var SecretsFlag = &cli.BoolFlag{ var SecretsFlag = &cli.BoolFlag{
Name: "secrets", Name: "secrets",
Aliases: []string{"ss"}, Aliases: []string{"S"},
Value: false, Value: false,
Usage: "Automatically generate secrets", Usage: "Automatically generate secrets",
Destination: &Secrets, Destination: &Secrets,
@ -22,7 +22,7 @@ var Pass bool
// PassFlag turns on/off storing generated secrets in pass // PassFlag turns on/off storing generated secrets in pass
var PassFlag = &cli.BoolFlag{ var PassFlag = &cli.BoolFlag{
Name: "pass", Name: "pass",
Aliases: []string{"p"}, Aliases: []string{"P"},
Value: false, Value: false,
Usage: "Store the generated secrets in a local pass store", Usage: "Store the generated secrets in a local pass store",
Destination: &Pass, Destination: &Pass,
@ -47,7 +47,6 @@ var ForceFlag = &cli.BoolFlag{
Name: "force", Name: "force",
Value: false, Value: false,
Aliases: []string{"f"}, Aliases: []string{"f"},
Usage: "Perform action without further prompt. Use with care!",
Destination: &Force, Destination: &Force,
} }
@ -114,12 +113,13 @@ var DNSValueFlag = &cli.StringFlag{
Destination: &DNSValue, Destination: &DNSValue,
} }
var DNSTTL string var DNSTTL int
var DNSTTLFlag = &cli.StringFlag{
var DNSTTLFlag = &cli.IntFlag{
Name: "ttl", Name: "ttl",
Value: "600s", Value: 86400,
Aliases: []string{"T"}, Aliases: []string{"T"},
Usage: "Domain name TTL value (seconds)", Usage: "Domain name TTL value)",
Destination: &DNSTTL, Destination: &DNSTTL,
} }
@ -272,6 +272,18 @@ var DebugFlag = &cli.BoolFlag{
Usage: "Show DEBUG messages", Usage: "Show DEBUG messages",
} }
// Verbose stores the variable from VerboseFlag.
var Verbose bool
// VerboseFlag turns on/off verbose logging down to the INFO level.
var VerboseFlag = &cli.BoolFlag{
Name: "verbose",
Aliases: []string{"V"},
Value: false,
Destination: &Verbose,
Usage: "Show INFO messages",
}
// RC signifies the latest release candidate // RC signifies the latest release candidate
var RC bool var RC bool
@ -306,7 +318,7 @@ var PatchFlag = &cli.BoolFlag{
Name: "patch", Name: "patch",
Usage: "Increase the patch part of the version", Usage: "Increase the patch part of the version",
Value: false, Value: false,
Aliases: []string{"pa", "z"}, Aliases: []string{"p", "z"},
Destination: &Patch, Destination: &Patch,
} }
@ -319,13 +331,38 @@ var DryFlag = &cli.BoolFlag{
Destination: &Dry, Destination: &Dry,
} }
var Publish bool var Push bool
var PublishFlag = &cli.BoolFlag{ var PushFlag = &cli.BoolFlag{
Name: "publish", Name: "push",
Usage: "Publish changes to git.coopcloud.tech", Usage: "Git push changes",
Value: false, Value: false,
Aliases: []string{"p"}, Aliases: []string{"P"},
Destination: &Publish, Destination: &Push,
}
var CommitMessage string
var CommitMessageFlag = &cli.StringFlag{
Name: "commit-message",
Usage: "Commit message (implies --commit)",
Aliases: []string{"cm"},
Destination: &CommitMessage,
}
var Commit bool
var CommitFlag = &cli.BoolFlag{
Name: "commit",
Usage: "Commit new changes",
Value: false,
Aliases: []string{"c"},
Destination: &Commit,
}
var TagMessage string
var TagMessageFlag = &cli.StringFlag{
Name: "tag-comment",
Usage: "Description for release tag",
Aliases: []string{"t", "tm"},
Destination: &TagMessage,
} }
var Domain string var Domain string
@ -379,63 +416,7 @@ var AutoDNSRecordFlag = &cli.BoolFlag{
Aliases: []string{"a"}, Aliases: []string{"a"},
Value: false, Value: false,
Usage: "Automatically configure DNS records", Usage: "Automatically configure DNS records",
Destination: &AutoDNSRecord, Destination: &StdErrOnly,
}
var DontWaitConverge bool
var DontWaitConvergeFlag = &cli.BoolFlag{
Name: "no-converge-checks",
Aliases: []string{"nc"},
Value: false,
Usage: "Don't wait for converge logic checks",
Destination: &DontWaitConverge,
}
var Watch bool
var WatchFlag = &cli.BoolFlag{
Name: "watch",
Aliases: []string{"w"},
Value: false,
Usage: "Watch status by polling repeatedly",
Destination: &Watch,
}
var OnlyErrors bool
var OnlyErrorFlag = &cli.BoolFlag{
Name: "errors",
Aliases: []string{"e"},
Value: false,
Usage: "Only show errors",
Destination: &OnlyErrors,
}
var SkipUpdates bool
var SkipUpdatesFlag = &cli.BoolFlag{
Name: "skip-updates",
Aliases: []string{"s"},
Value: false,
Usage: "Skip updating recipe repositories",
Destination: &SkipUpdates,
}
var RegistryUsername string
var RegistryUsernameFlag = &cli.StringFlag{
Name: "username",
Aliases: []string{"user"},
Value: "",
Usage: "Registry username",
EnvVars: []string{"REGISTRY_USERNAME"},
Destination: &RegistryUsername,
}
var RegistryPassword string
var RegistryPasswordFlag = &cli.StringFlag{
Name: "password",
Aliases: []string{"pass"},
Value: "",
Usage: "Registry password",
EnvVars: []string{"REGISTRY_PASSWORD"},
Destination: &RegistryUsername,
} }
// SSHFailMsg is a hopefully helpful SSH failure message // SSHFailMsg is a hopefully helpful SSH failure message

71
cli/internal/copy.go Normal file
View File

@ -0,0 +1,71 @@
package internal
import (
"fmt"
"os"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/container"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/pkg/archive"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func ConfigureAndCp(c *cli.Context, app config.App, srcPath string, dstPath string, service string, isToContainer bool) error {
appFiles, err := config.LoadAppFiles("")
if err != nil {
logrus.Fatal(err)
}
appEnv, err := config.GetApp(appFiles, app.Name)
if err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
logrus.Fatal(err)
}
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", appEnv.StackName(), service))
container, err := container.GetContainer(c.Context, cl, filters, true)
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)
if isToContainer {
if _, err := os.Stat(srcPath); err != nil {
logrus.Fatalf("%s does not exist?", srcPath)
}
toTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
content, err := archive.TarWithOptions(srcPath, toTarOpts)
if err != nil {
logrus.Fatal(err)
}
copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
if err := cl.CopyToContainer(c.Context, container.ID, dstPath, content, copyOpts); err != nil {
logrus.Fatal(err)
}
} else {
content, _, err := cl.CopyFromContainer(c.Context, container.ID, srcPath)
if err != nil {
logrus.Fatal(err)
}
defer content.Close()
fromTarOpts := &archive.TarOptions{NoOverwriteDirNonDir: true, Compression: archive.Gzip}
if err := archive.Untar(content, dstPath, fromTarOpts); err != nil {
logrus.Fatal(err)
}
}
return nil
}

View File

@ -2,17 +2,13 @@ package internal
import ( import (
"fmt" "fmt"
"io/ioutil"
"os"
"path"
"strings" "strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/dns" "coopcloud.tech/abra/pkg/dns"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
@ -23,47 +19,35 @@ import (
// DeployAction is the main command-line action for this package // DeployAction is the main command-line action for this package
func DeployAction(c *cli.Context) error { func DeployAction(c *cli.Context) error {
app := ValidateApp(c) app := ValidateApp(c)
stackName := app.StackName()
if err := recipe.EnsureUpToDate(app.Type); err != nil {
logrus.Fatal(err)
}
r, err := recipe.Get(app.Type)
if err != nil {
logrus.Fatal(err)
}
if err := lint.LintForErrors(r); err != nil {
logrus.Fatal(err)
}
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("checking whether %s is already deployed", app.StackName()) logrus.Debugf("checking whether %s is already deployed", stackName)
isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, app.StackName()) isDeployed, deployedVersion, err := stack.IsDeployed(c.Context, cl, stackName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if isDeployed { if isDeployed {
if Force || Chaos { if Force || Chaos {
logrus.Warnf("%s is already deployed but continuing (--force/--chaos)", app.Name) logrus.Warnf("%s is already deployed but continuing (--force/--chaos)", stackName)
} else { } else {
logrus.Fatalf("%s is already deployed", app.Name) logrus.Fatalf("%s is already deployed", stackName)
} }
} }
version := deployedVersion version := deployedVersion
if version == "" && !Chaos { if version == "" && !Chaos {
catl, err := recipe.ReadRecipeCatalogue() catl, err := catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl) versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -74,11 +58,7 @@ func DeployAction(c *cli.Context) error {
logrus.Fatal(err) logrus.Fatal(err)
} }
} else { } else {
head, err := git.GetRecipeHead(app.Type) version = "latest commit"
if err != nil {
logrus.Fatal(err)
}
version = formatter.SmallSHA(head.String())
logrus.Warn("no versions detected, using latest commit") logrus.Warn("no versions detected, using latest commit")
if err := recipe.EnsureLatest(app.Type); err != nil { if err := recipe.EnsureLatest(app.Type); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -108,7 +88,7 @@ func DeployAction(c *cli.Context) error {
} }
} }
abraShPath := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, app.Type, "abra.sh") abraShPath := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, app.Type, "abra.sh")
abraShEnv, err := config.ReadAbraShEnvVars(abraShPath) abraShEnv, err := config.ReadAbraShEnvVars(abraShPath)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
@ -123,7 +103,7 @@ func DeployAction(c *cli.Context) error {
} }
deployOpts := stack.Deploy{ deployOpts := stack.Deploy{
Composefiles: composeFiles, Composefiles: composeFiles,
Namespace: app.StackName(), Namespace: stackName,
Prune: false, Prune: false,
ResolveImage: stack.ResolveImageAlways, ResolveImage: stack.ResolveImageAlways,
} }
@ -150,7 +130,7 @@ func DeployAction(c *cli.Context) error {
logrus.Warn("skipping domain checks as requested") logrus.Warn("skipping domain checks as requested")
} }
if err := stack.RunDeploy(cl, deployOpts, compose, app.Name, DontWaitConverge); err != nil { if err := stack.RunDeploy(cl, deployOpts, compose, app.Env["TYPE"]); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -159,8 +139,8 @@ func DeployAction(c *cli.Context) error {
// DeployOverview shows a deployment overview // DeployOverview shows a deployment overview
func DeployOverview(app config.App, version, message string) error { func DeployOverview(app config.App, version, message string) error {
tableCol := []string{"server", "compose", "domain", "app name", "version"} tableCol := []string{"server", "compose", "domain", "stack", "version"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml" deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok { if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
@ -172,7 +152,7 @@ func DeployOverview(app config.App, version, message string) error {
server = "local" server = "local"
} }
table.Append([]string{server, deployConfig, app.Domain, app.Name, version}) table.Append([]string{server, deployConfig, app.Domain, app.StackName(), version})
table.Render() table.Render()
if NoInput { if NoInput {
@ -196,9 +176,9 @@ func DeployOverview(app config.App, version, message string) error {
} }
// NewVersionOverview shows an upgrade or downgrade overview // NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error { func NewVersionOverview(app config.App, currentVersion, newVersion string) error {
tableCol := []string{"server", "compose", "domain", "app name", "current version", "to be deployed"} tableCol := []string{"server", "compose", "domain", "stack", "current version", "to be deployed"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml" deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok { if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
@ -210,24 +190,9 @@ func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes
server = "local" server = "local"
} }
table.Append([]string{server, deployConfig, app.Domain, app.Name, currentVersion, newVersion}) table.Append([]string{server, deployConfig, app.Domain, app.StackName(), currentVersion, newVersion})
table.Render() table.Render()
if releaseNotes == "" {
var err error
releaseNotes, err = GetReleaseNotes(app.Type, newVersion)
if err != nil {
return err
}
}
if releaseNotes != "" {
fmt.Println()
fmt.Println(fmt.Sprintf("%s release notes:\n\n%s", newVersion, releaseNotes))
} else {
logrus.Warnf("no release notes available for %s", newVersion)
}
if NoInput { if NoInput {
return nil return nil
} }
@ -247,18 +212,3 @@ func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes
return nil return nil
} }
// GetReleaseNotes prints release notes for a recipe version
func GetReleaseNotes(recipeName, version string) (string, error) {
fpath := path.Join(config.RECIPES_DIR, recipeName, "release", version)
if _, err := os.Stat(fpath); !os.IsNotExist(err) {
releaseNotes, err := ioutil.ReadFile(fpath)
if err != nil {
return "", err
}
return string(releaseNotes), nil
}
return "", nil
}

View File

@ -1,10 +0,0 @@
package internal
// ReverseStringList reverses a list of a strings. Roll on Go generics.
func ReverseStringList(strings []string) []string {
for i, j := 0, len(strings)-1; i < j; i, j = i+1, j-1 {
strings[i], strings[j] = strings[j], strings[i]
}
return strings
}

View File

@ -4,10 +4,9 @@ import (
"fmt" "fmt"
"path" "path"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret" "coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/abra/pkg/ssh" "coopcloud.tech/abra/pkg/ssh"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
@ -94,7 +93,7 @@ func ensureAppNameFlag() error {
if NewAppName == "" && !NoInput { if NewAppName == "" && !NoInput {
prompt := &survey.Input{ prompt := &survey.Input{
Message: "Specify app name:", Message: "Specify app name:",
Default: Domain, Default: config.SanitiseAppName(Domain),
} }
if err := survey.AskOne(prompt, &NewAppName); err != nil { if err := survey.AskOne(prompt, &NewAppName); err != nil {
return err return err
@ -112,7 +111,7 @@ func ensureAppNameFlag() error {
func NewAction(c *cli.Context) error { func NewAction(c *cli.Context) error {
recipe := ValidateRecipeWithPrompt(c) recipe := ValidateRecipeWithPrompt(c)
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil { if err := config.EnsureAbraDirExists(); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -149,7 +148,7 @@ func NewAction(c *cli.Context) error {
} }
secretCols := []string{"Name", "Value"} secretCols := []string{"Name", "Value"}
secretTable := formatter.CreateTable(secretCols) secretTable := abraFormatter.CreateTable(secretCols)
for secret := range secrets { for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]}) secretTable.Append([]string{secret, secrets[secret]})
} }
@ -164,7 +163,7 @@ func NewAction(c *cli.Context) error {
} }
tableCol := []string{"Name", "Domain", "Type", "Server"} tableCol := []string{"Name", "Domain", "Type", "Server"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
table.Append([]string{sanitisedAppName, Domain, recipe.Name, NewAppServer}) table.Append([]string{sanitisedAppName, Domain, recipe.Name, NewAppServer})
fmt.Println("") fmt.Println("")

View File

@ -6,34 +6,16 @@ import (
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// PromptBumpType prompts for version bump type // PromptBumpType prompts for version bump type
func PromptBumpType(tagString string) error { func PromptBumpType(tagString string) error {
if (!Major && !Minor && !Patch) && tagString == "" { if (!Major && !Minor && !Patch) && tagString == "" {
fmt.Printf(` fmt.Printf(`semver cheat sheet (more via semver.org):
You need to make a decision about what kind of an update this new recipe major: new features/bug fixes, backwards incompatible
version is. If someone else performs this upgrade, do they have to do some minor: new features/bug fixes, backwards compatible
migration work or take care of some breaking changes? This can be signaled in patch: bug fixes, backwards compatible
the version you specify on the recipe deploy label and is called a semantic
version.
Here is a semver cheat sheet (more on https://semver.org):
major: new features/bug fixes, backwards incompatible (e.g 1.0.0 -> 2.0.0).
the upgrade won't work without some preparation work and others need
to take care when performing it. "it could go wrong".
minor: new features/bug fixes, backwards compatible (e.g. 0.1.0 -> 0.2.0).
the upgrade should Just Work and there are no breaking changes in
the app and the recipe config. "it should go fine".
patch: bug fixes, backwards compatible (e.g. 0.0.1 -> 0.0.2). this upgrade
should also Just Work and is mostly to do with minor bug fixes
and/or security patches. "nothing to worry about".
`) `)
var chosenBumpType string var chosenBumpType string
@ -82,29 +64,20 @@ func SetBumpType(bumpType string) {
} }
} }
// GetMainAppImage retrieves the main 'app' image name // GetMainApp retrieves the main 'app' image name
func GetMainAppImage(recipe recipe.Recipe) (string, error) { func GetMainApp(recipe recipe.Recipe) string {
var path string var app string
for _, service := range recipe.Config.Services { for _, service := range recipe.Config.Services {
if service.Name == "app" { name := service.Name
img, err := reference.ParseNormalizedNamed(service.Image) if name == "app" {
if err != nil { app = strings.Split(service.Image, ":")[0]
return "", err
}
path = reference.Path(img)
if strings.Contains(path, "library") {
path = strings.Split(path, "/")[1]
}
return path, nil
} }
} }
if path == "" { if app == "" {
return path, fmt.Errorf("%s has no main 'app' service?", recipe.Name) logrus.Fatalf("%s has no main 'app' service?", recipe.Name)
} }
return path, nil return app
} }

106
cli/internal/record.go Normal file
View File

@ -0,0 +1,106 @@
package internal
import (
"errors"
"fmt"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v2"
)
// EnsureDNSProvider ensures a DNS provider is chosen.
func EnsureDNSProvider() error {
if DNSProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select DNS provider",
Options: []string{"gandi"},
}
if err := survey.AskOne(prompt, &DNSProvider); err != nil {
return err
}
}
if DNSProvider == "" {
return fmt.Errorf("missing DNS provider?")
}
return nil
}
// EnsureDNSTypeFlag ensures a DNS type flag is present.
func EnsureDNSTypeFlag(c *cli.Context) error {
if DNSType == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record type",
Default: "A",
}
if err := survey.AskOne(prompt, &DNSType); err != nil {
return err
}
}
if DNSType == "" {
ShowSubcommandHelpAndError(c, errors.New("no record type provided"))
}
return nil
}
// EnsureDNSNameFlag ensures a DNS name flag is present.
func EnsureDNSNameFlag(c *cli.Context) error {
if DNSName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record name",
Default: "mysubdomain",
}
if err := survey.AskOne(prompt, &DNSName); err != nil {
return err
}
}
if DNSName == "" {
ShowSubcommandHelpAndError(c, errors.New("no record name provided"))
}
return nil
}
// EnsureDNSValueFlag ensures a DNS value flag is present.
func EnsureDNSValueFlag(c *cli.Context) error {
if DNSValue == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record value",
Default: "192.168.1.2",
}
if err := survey.AskOne(prompt, &DNSValue); err != nil {
return err
}
}
if DNSName == "" {
ShowSubcommandHelpAndError(c, errors.New("no record value provided"))
}
return nil
}
// EnsureZoneArgument ensures a zone argument is present.
func EnsureZoneArgument(c *cli.Context) (string, error) {
var zone string
if c.Args().First() == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify a domain name zone",
Default: "example.com",
}
if err := survey.AskOne(prompt, &zone); err != nil {
return zone, err
}
}
if zone == "" {
ShowSubcommandHelpAndError(c, errors.New("no zone value provided"))
}
return zone, nil
}

208
cli/internal/server.go Normal file
View File

@ -0,0 +1,208 @@
package internal
import (
"fmt"
"os"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v2"
)
// EnsureServerProvider ensures a 3rd party server provider is chosen.
func EnsureServerProvider() error {
if ServerProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select server provider",
Options: []string{"capsul", "hetzner-cloud"},
}
if err := survey.AskOne(prompt, &ServerProvider); err != nil {
return err
}
}
if ServerProvider == "" {
return fmt.Errorf("missing server provider?")
}
return nil
}
// EnsureNewCapsulVPSFlags ensure all flags are present.
func EnsureNewCapsulVPSFlags(c *cli.Context) error {
if CapsulName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify capsul name",
}
if err := survey.AskOne(prompt, &CapsulName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul instance URL",
Default: CapsulInstanceURL,
}
if err := survey.AskOne(prompt, &CapsulInstanceURL); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul type",
Default: CapsulType,
}
if err := survey.AskOne(prompt, &CapsulType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul image",
Default: CapsulImage,
}
if err := survey.AskOne(prompt, &CapsulImage); err != nil {
return err
}
}
if len(CapsulSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify capsul SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil {
return err
}
CapsulSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if CapsulAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("CAPSUL_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify capsul API token",
}
if err := survey.AskOne(prompt, &CapsulAPIToken); err != nil {
return err
}
} else {
CapsulAPIToken = token
}
}
if CapsulName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul name?"))
}
if CapsulInstanceURL == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul instance url?"))
}
if CapsulType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul type?"))
}
if CapsulImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul image?"))
}
if len(CapsulSSHKeys.Value()) == 0 {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul ssh keys?"))
}
if CapsulAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul API token?"))
}
return nil
}
// EnsureNewHetznerCloudVPSFlags ensure all flags are present.
func EnsureNewHetznerCloudVPSFlags(c *cli.Context) error {
if HetznerCloudName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS name",
}
if err := survey.AskOne(prompt, &HetznerCloudName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS type",
Default: HetznerCloudType,
}
if err := survey.AskOne(prompt, &HetznerCloudType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS image",
Default: HetznerCloudImage,
}
if err := survey.AskOne(prompt, &HetznerCloudImage); err != nil {
return err
}
}
if len(HetznerCloudSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify hetzner cloud SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &sshKeys); err != nil {
return err
}
HetznerCloudSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS location",
Default: HetznerCloudLocation,
}
if err := survey.AskOne(prompt, &HetznerCloudLocation); err != nil {
return err
}
}
if HetznerCloudAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("HCLOUD_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify hetzner cloud API token",
}
if err := survey.AskOne(prompt, &HetznerCloudAPIToken); err != nil {
return err
}
} else {
HetznerCloudAPIToken = token
}
}
if HetznerCloudName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS name?"))
}
if HetznerCloudType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS type?"))
}
if HetznerCloudImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud image?"))
}
if len(HetznerCloudSSHKeys.Value()) == 0 {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud ssh keys?"))
}
if HetznerCloudLocation == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS location?"))
}
if HetznerCloudAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud API token?"))
}
return nil
}

View File

@ -2,11 +2,10 @@ package internal
import ( import (
"errors" "errors"
"fmt"
"os"
"strings" "strings"
"coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/ssh" "coopcloud.tech/abra/pkg/ssh"
@ -23,10 +22,10 @@ func ValidateRecipe(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First() recipeName := c.Args().First()
if recipeName == "" { if recipeName == "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided")) ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
} }
chosenRecipe, err := recipe.Get(recipeName) recipe, err := recipe.Get(recipeName)
if err != nil { if err != nil {
if c.Command.Name == "generate" { if c.Command.Name == "generate" {
if strings.Contains(err.Error(), "missing a compose") { if strings.Contains(err.Error(), "missing a compose") {
@ -38,9 +37,9 @@ func ValidateRecipe(c *cli.Context) recipe.Recipe {
} }
} }
logrus.Debugf("validated %s as recipe argument", recipeName) logrus.Debugf("validated '%s' as recipe argument", recipeName)
return chosenRecipe return recipe
} }
// ValidateRecipeWithPrompt ensures a recipe argument is present before // ValidateRecipeWithPrompt ensures a recipe argument is present before
@ -51,7 +50,7 @@ func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
if recipeName == "" && !NoInput { if recipeName == "" && !NoInput {
var recipes []string var recipes []string
catl, err := recipe.ReadRecipeCatalogue() catl, err := catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -91,17 +90,17 @@ func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
} }
if recipeName == "" { if recipeName == "" {
ShowSubcommandHelpAndError(c, errors.New("no recipe name provided")) ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
} }
chosenRecipe, err := recipe.Get(recipeName) recipe, err := recipe.Get(recipeName)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("validated %s as recipe argument", recipeName) logrus.Debugf("validated %s as recipe argument", recipeName)
return chosenRecipe return recipe
} }
// ValidateApp ensures the app name arg is valid. // ValidateApp ensures the app name arg is valid.
@ -199,297 +198,3 @@ func ValidateServer(c *cli.Context) (string, error) {
return serverName, nil return serverName, nil
} }
// EnsureDNSProvider ensures a DNS provider is chosen.
func EnsureDNSProvider() error {
if DNSProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select DNS provider",
Options: []string{"gandi"},
}
if err := survey.AskOne(prompt, &DNSProvider); err != nil {
return err
}
}
if DNSProvider == "" {
return fmt.Errorf("missing DNS provider?")
}
return nil
}
// EnsureDNSTypeFlag ensures a DNS type flag is present.
func EnsureDNSTypeFlag(c *cli.Context) error {
if DNSType == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record type",
Default: "A",
}
if err := survey.AskOne(prompt, &DNSType); err != nil {
return err
}
}
if DNSType == "" {
ShowSubcommandHelpAndError(c, errors.New("no record type provided"))
}
return nil
}
// EnsureDNSNameFlag ensures a DNS name flag is present.
func EnsureDNSNameFlag(c *cli.Context) error {
if DNSName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record name",
Default: "mysubdomain",
}
if err := survey.AskOne(prompt, &DNSName); err != nil {
return err
}
}
if DNSName == "" {
ShowSubcommandHelpAndError(c, errors.New("no record name provided"))
}
return nil
}
// EnsureDNSValueFlag ensures a DNS value flag is present.
func EnsureDNSValueFlag(c *cli.Context) error {
if DNSValue == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify DNS record value",
Default: "192.168.1.2",
}
if err := survey.AskOne(prompt, &DNSValue); err != nil {
return err
}
}
if DNSValue == "" {
ShowSubcommandHelpAndError(c, errors.New("no record value provided"))
}
return nil
}
// EnsureZoneArgument ensures a zone argument is present.
func EnsureZoneArgument(c *cli.Context) (string, error) {
zone := c.Args().First()
if zone == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify a domain name zone",
Default: "example.com",
}
if err := survey.AskOne(prompt, &zone); err != nil {
return zone, err
}
}
if zone == "" {
ShowSubcommandHelpAndError(c, errors.New("no zone value provided"))
}
return zone, nil
}
// EnsureServerProvider ensures a 3rd party server provider is chosen.
func EnsureServerProvider() error {
if ServerProvider == "" && !NoInput {
prompt := &survey.Select{
Message: "Select server provider",
Options: []string{"capsul", "hetzner-cloud"},
}
if err := survey.AskOne(prompt, &ServerProvider); err != nil {
return err
}
}
if ServerProvider == "" {
return fmt.Errorf("missing server provider?")
}
return nil
}
// EnsureNewCapsulVPSFlags ensure all flags are present.
func EnsureNewCapsulVPSFlags(c *cli.Context) error {
if CapsulName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify capsul name",
}
if err := survey.AskOne(prompt, &CapsulName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul instance URL",
Default: CapsulInstanceURL,
}
if err := survey.AskOne(prompt, &CapsulInstanceURL); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul type",
Default: CapsulType,
}
if err := survey.AskOne(prompt, &CapsulType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify capsul image",
Default: CapsulImage,
}
if err := survey.AskOne(prompt, &CapsulImage); err != nil {
return err
}
}
if len(CapsulSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify capsul SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &CapsulSSHKeys); err != nil {
return err
}
CapsulSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if CapsulAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("CAPSUL_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify capsul API token",
}
if err := survey.AskOne(prompt, &CapsulAPIToken); err != nil {
return err
}
} else {
CapsulAPIToken = token
}
}
if CapsulName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul name?"))
}
if CapsulInstanceURL == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul instance url?"))
}
if CapsulType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul type?"))
}
if CapsulImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul image?"))
}
if len(CapsulSSHKeys.Value()) == 0 {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul ssh keys?"))
}
if CapsulAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing capsul API token?"))
}
return nil
}
// EnsureNewHetznerCloudVPSFlags ensure all flags are present.
func EnsureNewHetznerCloudVPSFlags(c *cli.Context) error {
if HetznerCloudName == "" && !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS name",
}
if err := survey.AskOne(prompt, &HetznerCloudName); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS type",
Default: HetznerCloudType,
}
if err := survey.AskOne(prompt, &HetznerCloudType); err != nil {
return err
}
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS image",
Default: HetznerCloudImage,
}
if err := survey.AskOne(prompt, &HetznerCloudImage); err != nil {
return err
}
}
if len(HetznerCloudSSHKeys.Value()) == 0 && !NoInput {
var sshKeys string
prompt := &survey.Input{
Message: "specify hetzner cloud SSH keys (e.g. me@foo.com)",
Default: "",
}
if err := survey.AskOne(prompt, &sshKeys); err != nil {
return err
}
HetznerCloudSSHKeys = *cli.NewStringSlice(strings.Split(sshKeys, ",")...)
}
if !NoInput {
prompt := &survey.Input{
Message: "specify hetzner cloud VPS location",
Default: HetznerCloudLocation,
}
if err := survey.AskOne(prompt, &HetznerCloudLocation); err != nil {
return err
}
}
if HetznerCloudAPIToken == "" && !NoInput {
token, ok := os.LookupEnv("HCLOUD_TOKEN")
if !ok {
prompt := &survey.Input{
Message: "specify hetzner cloud API token",
}
if err := survey.AskOne(prompt, &HetznerCloudAPIToken); err != nil {
return err
}
} else {
HetznerCloudAPIToken = token
}
}
if HetznerCloudName == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS name?"))
}
if HetznerCloudType == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS type?"))
}
if HetznerCloudImage == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud image?"))
}
if HetznerCloudLocation == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud VPS location?"))
}
if HetznerCloudAPIToken == "" {
ShowSubcommandHelpAndError(c, fmt.Errorf("missing hetzner cloud API token?"))
}
return nil
}

View File

@ -2,74 +2,101 @@ package recipe
import ( import (
"fmt" "fmt"
"os"
"strconv"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/tagcmp"
recipePkg "coopcloud.tech/abra/pkg/recipe" "github.com/docker/distribution/reference"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
var recipeLintCommand = &cli.Command{ var recipeLintCommand = &cli.Command{
Name: "lint", Name: "lint",
Usage: "Lint a recipe", Usage: "Lint a recipe",
Aliases: []string{"l"}, Aliases: []string{"l"},
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
Flags: []cli.Flag{internal.OnlyErrorFlag},
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil { expectedVersion := false
if recipe.Config.Version == "3.8" {
expectedVersion = true
}
envSampleProvided := false
envSample := fmt.Sprintf("%s/%s/.env.sample", config.APPS_DIR, recipe.Name)
if _, err := os.Stat(envSample); !os.IsNotExist(err) {
envSampleProvided = true
} else if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
tableCol := []string{"ref", "rule", "satisfied", "severity", "resolve"} serviceNamedApp := false
table := formatter.CreateTable(tableCol) traefikEnabled := false
healthChecksForAllServices := true
allImagesTagged := true
noUnstableTags := true
semverLikeTags := true
for _, service := range recipe.Config.Services {
if service.Name == "app" {
serviceNamedApp = true
}
hasError := false for label := range service.Deploy.Labels {
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...") if label == "traefik.enable" {
for level := range lint.LintRules { if service.Deploy.Labels[label] == "true" {
for _, rule := range lint.LintRules[level] { traefikEnabled = true
ok, err := rule.Function(recipe)
if err != nil {
logrus.Warn(err)
}
if !ok && rule.Level == "error" {
hasError = true
}
var result string
if ok {
result = "yes"
} else {
result = "NO"
}
if internal.OnlyErrors {
if !ok && rule.Level == "error" {
table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
bar.Add(1)
} }
} else {
table.Append([]string{rule.Ref, rule.Description, result, rule.Level, rule.HowToResolve})
bar.Add(1)
} }
} }
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
logrus.Fatal(err)
}
if reference.IsNameOnly(img) {
allImagesTagged = false
}
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
noUnstableTags = false
}
if tag == "latest" {
noUnstableTags = false
}
if !tagcmp.IsParsable(tag) {
semverLikeTags = false
}
if service.HealthCheck == nil {
healthChecksForAllServices = false
}
} }
if table.NumLines() > 0 { tableCol := []string{"rule", "satisfied"}
fmt.Println() table := formatter.CreateTable(tableCol)
table.Render() 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)})
if hasError { table.Append([]string{"traefik routing enabled on at least one service", strconv.FormatBool(traefikEnabled)})
logrus.Warn("watch out, some critical errors are present in your recipe config") 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 return nil
}, },
BashComplete: autocomplete.RecipeNameComplete,
} }

View File

@ -2,82 +2,37 @@ package recipe
import ( import (
"fmt" "fmt"
"path"
"sort" "sort"
"strconv"
"strings"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/catalogue"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
var pattern string
var patternFlag = &cli.StringFlag{
Name: "pattern",
Value: "",
Aliases: []string{"p"},
Usage: "Simple string to filter recipes",
Destination: &pattern,
}
var recipeListCommand = &cli.Command{ var recipeListCommand = &cli.Command{
Name: "list", Name: "list",
Usage: "List available recipes", Usage: "List available recipes",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
Flags: []cli.Flag{
patternFlag,
},
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
catalogueDir := path.Join(config.ABRA_DIR, "catalogue") catl, err := catalogue.ReadRecipeCatalogue()
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
}
catl, err := recipe.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err.Error()) logrus.Fatal(err.Error())
} }
recipes := catl.Flatten() recipes := catl.Flatten()
sort.Sort(recipe.ByRecipeName(recipes)) sort.Sort(catalogue.ByRecipeName(recipes))
tableCol := []string{"name", "category", "status", "healthcheck", "backups", "email", "tests", "SSO"} tableCol := []string{"name", "category", "status"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
len := 0
for _, recipe := range recipes { for _, recipe := range recipes {
tableRow := []string{ status := fmt.Sprintf("%v", recipe.Features.Status)
recipe.Name, tableRow := []string{recipe.Name, recipe.Category, status}
recipe.Category, table.Append(tableRow)
strconv.Itoa(recipe.Features.Status),
recipe.Features.Healthcheck,
recipe.Features.Backups,
recipe.Features.Email,
recipe.Features.Tests,
recipe.Features.SSO,
}
if pattern != "" {
if strings.Contains(recipe.Name, pattern) {
table.Append(tableRow)
len++
}
} else {
table.Append(tableRow)
len++
}
} }
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len)) table.Render()
if table.NumLines() > 0 {
table.Render()
}
return nil return nil
}, },

View File

@ -1,10 +1,8 @@
package recipe package recipe
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path" "path"
"text/template" "text/template"
@ -16,20 +14,6 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
// recipeMetadata is the recipe metadata for the README.md
type recipeMetadata struct {
Name string
Description string
Category string
Status string
Image string
Healthcheck string
Backups string
Email string
Tests string
SSO string
}
var recipeNewCommand = &cli.Command{ var recipeNewCommand = &cli.Command{
Name: "new", Name: "new",
Usage: "Create a new recipe", Usage: "Create a new recipe",
@ -45,15 +29,17 @@ Abra uses our built-in example repository which is available here:
Files within the example repository make use of the Golang templating system Files within the example repository make use of the Golang templating system
which Abra uses to inject values into the generated recipe folder (e.g. name of which Abra uses to inject values into the generated recipe folder (e.g. name of
recipe and domain in the sample environment config). recipe and domain in the sample environment config).
The new example repository is cloned to ~/.abra/apps/<recipe>.
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipeName := c.Args().First() recipeName := c.Args().First()
if recipeName == "" { if recipeName == "" {
internal.ShowSubcommandHelpAndError(c, errors.New("no recipe name provided")) internal.ShowSubcommandHelpAndError(c, errors.New("no recipe provided"))
} }
directory := path.Join(config.RECIPES_DIR, recipeName) directory := path.Join(config.APPS_DIR, recipeName)
if _, err := os.Stat(directory); !os.IsNotExist(err) { if _, err := os.Stat(directory); !os.IsNotExist(err) {
logrus.Fatalf("%s recipe directory already exists?", directory) logrus.Fatalf("%s recipe directory already exists?", directory)
} }
@ -63,73 +49,49 @@ recipe and domain in the sample environment config).
logrus.Fatal(err) logrus.Fatal(err)
} }
gitRepo := path.Join(config.RECIPES_DIR, recipeName, ".git") gitRepo := path.Join(config.APPS_DIR, recipeName, ".git")
if err := os.RemoveAll(gitRepo); err != nil { if err := os.RemoveAll(gitRepo); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
logrus.Debugf("removed example git repo in %s", gitRepo) logrus.Debugf("removed git repo in %s", gitRepo)
meta := newRecipeMeta(recipeName)
toParse := []string{ toParse := []string{
path.Join(config.RECIPES_DIR, recipeName, "README.md"), path.Join(config.APPS_DIR, recipeName, "README.md"),
path.Join(config.RECIPES_DIR, recipeName, ".env.sample"), path.Join(config.APPS_DIR, recipeName, ".env.sample"),
path.Join(config.APPS_DIR, recipeName, ".drone.yml"),
} }
for _, path := range toParse { for _, path := range toParse {
file, err := os.OpenFile(path, os.O_RDWR, 0664)
if err != nil {
logrus.Fatal(err)
}
tpl, err := template.ParseFiles(path) tpl, err := template.ParseFiles(path)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
var templated bytes.Buffer // TODO: ask for description and probably other things so that the
if err := tpl.Execute(&templated, meta); err != nil { // 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
}{recipeName, "TODO"}); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := ioutil.WriteFile(path, templated.Bytes(), 0644); err != nil {
logrus.Fatal(err)
}
} }
newGitRepo := path.Join(config.RECIPES_DIR, recipeName) newGitRepo := path.Join(config.APPS_DIR, recipeName)
if err := git.Init(newGitRepo, true); err != nil { if err := git.Init(newGitRepo, true); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
fmt.Print(fmt.Sprintf(` logrus.Infof(
Your new %s recipe has been created in %s. "new recipe %s created in %s, happy hacking!\n",
recipeName, path.Join(config.APPS_DIR, recipeName),
In order to share your recipe, you can upload it the git repository to: )
https://git.coopcloud.tech/coop-cloud/%s
If you're not sure how to do that, come chat with us:
https://docs.coopcloud.tech/contact
See "abra recipe -h" for additional recipe maintainer commands.
Happy Hacking!
`, recipeName, path.Join(config.RECIPES_DIR, recipeName), recipeName))
return nil return nil
}, },
} }
// newRecipeMeta creates a new recipeMetadata instance with defaults
func newRecipeMeta(recipeName string) recipeMetadata {
return recipeMetadata{
Name: recipeName,
Description: "> One line description of the recipe",
Category: "Apps",
Status: "0",
Image: fmt.Sprintf("[`%s`](https://hub.docker.com/r/%s), 4, upstream", recipeName, recipeName),
Healthcheck: "No",
Backups: "No",
Email: "No",
Tests: "No",
SSO: "No",
}
}

View File

@ -6,10 +6,10 @@ import (
"strconv" "strconv"
"strings" "strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git" gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe"
@ -17,6 +17,7 @@ import (
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
configPkg "github.com/go-git/go-git/v5/config"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -27,9 +28,11 @@ var recipeReleaseCommand = &cli.Command{
Aliases: []string{"rl"}, Aliases: []string{"rl"},
ArgsUsage: "<recipe> [<version>]", ArgsUsage: "<recipe> [<version>]",
Description: ` Description: `
This command is used to specify a new version of a recipe. These versions are This command is used to specify a new tag for a recipe. These tags are used to
then published on the Co-op Cloud recipe catalogue. These versions take the identify different versions of the recipe and are published on the Co-op Cloud
following form: recipe catalogue.
These tags take the following form:
a.b.c+x.y.z a.b.c+x.y.z
@ -38,21 +41,27 @@ the "x.y.z" part is the image tag of the recipe "app" service (the main
container which contains the software to be used). container which contains the software to be used).
We maintain a semantic versioning scheme ("a.b.c") alongside the libre app We maintain a semantic versioning scheme ("a.b.c") alongside the libre app
versioning scheme ("x.y.z") in order to maximise the chances that the nature of versioning scheme in order to maximise the chances that the nature of recipe
recipe updates are properly communicated. I.e. developers of an app might updates are properly communicated.
publish a minor version but that might lead to changes in the recipe which are
major and therefore require intervention while doing the upgrade work. Abra does its best to read the "a.b.c" version scheme and communicate what
action needs to be taken when performing different operations such as an update
or a rollback of an app.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe release gitea
Publish your new release to git.coopcloud.tech with "-p/--publish". This
requires that you have permission to git push to these repositories and have
your SSH keys configured on your account.
`, `,
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DryFlag, internal.DryFlag,
internal.MajorFlag, internal.MajorFlag,
internal.MinorFlag, internal.MinorFlag,
internal.PatchFlag, internal.PatchFlag,
internal.PublishFlag, internal.PushFlag,
internal.CommitFlag,
internal.CommitMessageFlag,
internal.TagMessageFlag,
}, },
BashComplete: autocomplete.RecipeNameComplete, BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
@ -63,11 +72,7 @@ your SSH keys configured on your account.
logrus.Fatal(err) logrus.Fatal(err)
} }
mainApp, err := internal.GetMainAppImage(recipe) mainApp := internal.GetMainApp(recipe)
if err != nil {
logrus.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp] mainAppVersion := imagesTmp[mainApp]
if mainAppVersion == "" { if mainAppVersion == "" {
logrus.Fatalf("main app service version for %s is empty?", recipe.Name) logrus.Fatalf("main app service version for %s is empty?", recipe.Name)
@ -92,15 +97,7 @@ your SSH keys configured on your account.
tags, err := recipe.Tags() tags, err := recipe.Tags()
if err != nil { if err != nil {
logrus.Fatal(err) return err
}
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
var err error
tagString, err = getLabelVersion(recipe, false)
if err != nil {
logrus.Fatal(err)
}
} }
if len(tags) > 0 { if len(tags) > 0 {
@ -111,8 +108,28 @@ your SSH keys configured on your account.
} else { } else {
logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name) logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil { initTag, err := recipePkg.GetVersionLabelLocal(recipe)
if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil { if err != nil {
logrus.Fatal(err)
}
logrus.Warnf("discovered %s as currently synced recipe label", initTag)
prompt := &survey.Confirm{
Message: fmt.Sprintf("use %s as the initial release?", initTag),
}
var response bool
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatalf("please fix your synced label for %s and re-run this command", recipe.Name)
}
if err := createReleaseFromTag(recipe, initTag, mainAppVersion); err != nil {
if cleanUpErr := cleanUpTag(initTag, recipe.Name); err != nil {
logrus.Fatal(cleanUpErr) logrus.Fatal(cleanUpErr)
} }
logrus.Fatal(err) logrus.Fatal(err)
@ -160,7 +177,7 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error { func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
var err error var err error
directory := path.Join(config.RECIPES_DIR, recipe.Name) directory := path.Join(config.APPS_DIR, recipe.Name)
repo, err := git.PlainOpen(directory) repo, err := git.PlainOpen(directory)
if err != nil { if err != nil {
return err return err
@ -181,19 +198,19 @@ func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string
tag.MissingPatch = false tag.MissingPatch = false
} }
if tagString == "" { if err := commitRelease(recipe); err != nil {
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion) logrus.Fatal(err)
} }
if err := commitRelease(recipe, tagString); err != nil { if tagString == "" {
logrus.Fatal(err) tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
} }
if err := tagRelease(tagString, repo); err != nil { if err := tagRelease(tagString, repo); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := pushRelease(recipe, tagString); err != nil { if err := pushRelease(tagString, repo); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -210,32 +227,50 @@ func btoi(b bool) int {
} }
// getTagCreateOptions constructs git tag create options // getTagCreateOptions constructs git tag create options
func getTagCreateOptions(tag string) (git.CreateTagOptions, error) { func getTagCreateOptions() (git.CreateTagOptions, error) {
msg := fmt.Sprintf("chore: publish %s release", tag) if internal.TagMessage == "" && !internal.NoInput {
return git.CreateTagOptions{Message: msg}, nil prompt := &survey.Input{
} Message: "git tag message",
Default: "chore: publish new release",
}
func commitRelease(recipe recipe.Recipe, tag string) error { if err := survey.AskOne(prompt, &internal.TagMessage); err != nil {
if internal.Dry { return git.CreateTagOptions{}, err
logrus.Debugf("dry run: no changes committed")
return nil
}
isClean, err := gitPkg.IsClean(recipe.Dir())
if err != nil {
return err
}
if isClean {
if !internal.Dry {
return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir())
} }
} }
if internal.Publish { return git.CreateTagOptions{Message: internal.TagMessage}, nil
msg := fmt.Sprintf("chore: publish %s release", tag) }
repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
if err := gitPkg.Commit(repoPath, "compose.**yml", msg, internal.Dry); err != nil { func commitRelease(recipe recipe.Recipe) error {
if internal.Dry {
logrus.Info("dry run: no changed committed")
return nil
}
if !internal.Commit && !internal.NoInput {
prompt := &survey.Confirm{
Message: "git commit changes?",
}
if err := survey.AskOne(prompt, &internal.Commit); err != nil {
return err
}
}
if internal.CommitMessage == "" && !internal.NoInput && internal.Commit {
prompt := &survey.Input{
Message: "commit message",
Default: "chore: publish new version",
}
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
return err
}
}
if internal.Commit {
repoPath := path.Join(config.APPS_DIR, recipe.Name)
if err := gitPkg.Commit(repoPath, "compose.**yml", internal.CommitMessage, internal.Dry, false); err != nil {
return err return err
} }
} }
@ -245,7 +280,7 @@ func commitRelease(recipe recipe.Recipe, tag string) error {
func tagRelease(tagString string, repo *git.Repository) error { func tagRelease(tagString string, repo *git.Repository) error {
if internal.Dry { if internal.Dry {
logrus.Debugf("dry run: no git tag created (%s)", tagString) logrus.Infof("dry run: no git tag created (%s)", tagString)
return nil return nil
} }
@ -254,7 +289,7 @@ func tagRelease(tagString string, repo *git.Repository) error {
return err return err
} }
createTagOptions, err := getTagCreateOptions(tagString) createTagOptions, err := getTagCreateOptions()
if err != nil { if err != nil {
return err return err
} }
@ -264,46 +299,46 @@ func tagRelease(tagString string, repo *git.Repository) error {
return err return err
} }
hash := formatter.SmallSHA(head.Hash().String()) hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash)) logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
return nil return nil
} }
func pushRelease(recipe recipe.Recipe, tagString string) error { func pushRelease(tagString string, repo *git.Repository) error {
if internal.Dry { if internal.Dry {
logrus.Info("dry run: no changes published") logrus.Info("dry run: no changes pushed")
return nil return nil
} }
if !internal.Publish && !internal.NoInput { if !internal.Push && !internal.NoInput {
prompt := &survey.Confirm{ prompt := &survey.Confirm{
Message: "publish new release?", Message: "git push changes?",
} }
if err := survey.AskOne(prompt, &internal.Publish); err != nil { if err := survey.AskOne(prompt, &internal.Push); err != nil {
return err return err
} }
} }
if internal.Publish { if internal.Push {
if err := recipe.Push(internal.Dry); err != nil { tagRef := fmt.Sprintf("+refs/tags/%s:refs/tags/%s", tagString, tagString)
pushOpts := &git.PushOptions{
RefSpecs: []configPkg.RefSpec{
configPkg.RefSpec(tagRef),
},
}
if err := repo.Push(pushOpts); err != nil {
return err return err
} }
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
if !internal.Dry {
url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
logrus.Infof("new release published: %s", url)
} else {
logrus.Info("dry run: no changes published")
}
} }
return nil return nil
} }
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error { func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
directory := path.Join(config.RECIPES_DIR, recipe.Name) directory := path.Join(config.APPS_DIR, recipe.Name)
repo, err := git.PlainOpen(directory) repo, err := git.PlainOpen(directory)
if err != nil { if err != nil {
return err return err
@ -316,13 +351,11 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
} }
} }
var lastGitTag tagcmp.Tag if err := internal.PromptBumpType(tagString); err != nil {
if tagString == "" { return err
if err := internal.PromptBumpType(tagString); err != nil {
return err
}
} }
var lastGitTag tagcmp.Tag
for _, tag := range tags { for _, tag := range tags {
parsed, err := tagcmp.Parse(tag) parsed, err := tagcmp.Parse(tag)
if err != nil { if err != nil {
@ -363,39 +396,18 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
newTag.Major = strconv.Itoa(now + 1) newTag.Major = strconv.Itoa(now + 1)
} }
if internal.Major || internal.Minor || internal.Patch { if err := commitRelease(recipe); err != nil {
newTag.Metadata = mainAppVersion
tagString = newTag.String()
}
if lastGitTag.String() == tagString {
logrus.Fatalf("latest git tag (%s) and synced lable (%s) are the same?", lastGitTag, tagString)
}
if !internal.NoInput {
prompt := &survey.Confirm{
Message: fmt.Sprintf("current: %s, new: %s, correct?", lastGitTag, tagString),
}
var ok bool
if err := survey.AskOne(prompt, &ok); err != nil {
logrus.Fatal(err)
}
if !ok {
logrus.Fatal("exiting as requested")
}
}
if err := commitRelease(recipe, tagString); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := tagRelease(tagString, repo); err != nil { newTag.Metadata = mainAppVersion
newTagString := newTag.String()
if err := tagRelease(newTagString, repo); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
if err := pushRelease(recipe, tagString); err != nil { if err := pushRelease(newTagString, repo); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
@ -404,46 +416,17 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
// cleanUpTag removes a freshly created tag // cleanUpTag removes a freshly created tag
func cleanUpTag(tag, recipeName string) error { func cleanUpTag(tag, recipeName string) error {
directory := path.Join(config.RECIPES_DIR, recipeName) directory := path.Join(config.APPS_DIR, recipeName)
repo, err := git.PlainOpen(directory) repo, err := git.PlainOpen(directory)
if err != nil { if err != nil {
return err return err
} }
if err := repo.DeleteTag(tag); err != nil { if err := repo.DeleteTag(tag); err != nil {
if !strings.Contains(err.Error(), "not found") { return err
return err
}
} }
logrus.Debugf("removed freshly created tag %s", tag) logrus.Warn("removed freshly created tag %s")
return nil return nil
} }
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
initTag, err := recipePkg.GetVersionLabelLocal(recipe)
if err != nil {
return "", err
}
if initTag == "" {
logrus.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
}
logrus.Warnf("discovered %s as currently synced recipe label", initTag)
if prompt && !internal.NoInput {
var response bool
prompt := &survey.Confirm{Message: fmt.Sprintf("use %s as the new version?", initTag)}
if err := survey.AskOne(prompt, &response); err != nil {
return "", err
}
if !response {
return "", fmt.Errorf("please fix your synced label for %s and re-run this command", recipe.Name)
}
}
return initTag, nil
}

View File

@ -18,7 +18,7 @@ import (
var recipeSyncCommand = &cli.Command{ var recipeSyncCommand = &cli.Command{
Name: "sync", Name: "sync",
Usage: "Sync recipe version label", Usage: "Ensure recipe version labels are up-to-date",
Aliases: []string{"s"}, Aliases: []string{"s"},
ArgsUsage: "<recipe> [<version>]", ArgsUsage: "<recipe> [<version>]",
Flags: []cli.Flag{ Flags: []cli.Flag{
@ -29,21 +29,23 @@ var recipeSyncCommand = &cli.Command{
}, },
Description: ` Description: `
This command will generate labels for the main recipe service (i.e. by This command will generate labels for the main recipe service (i.e. by
convention, the service named 'app') which corresponds to the following format: convention, the service named "app") which corresponds to the following format:
coop-cloud.${STACK_NAME}.version=<version> coop-cloud.${STACK_NAME}.version=<version>
Where <version> can be specifed on the command-line or Abra can attempt to The <version> is determined by the recipe maintainer and is specified on the
auto-generate it for you. The <recipe> configuration will be updated on the command-line. The <recipe> configuration will be updated on the local file
local file system. system.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe sync
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c) recipe := internal.ValidateRecipeWithPrompt(c)
mainApp, err := internal.GetMainAppImage(recipe) mainApp := internal.GetMainApp(recipe)
if err != nil {
logrus.Fatal(err)
}
imagesTmp, err := getImageVersions(recipe) imagesTmp, err := getImageVersions(recipe)
if err != nil { if err != nil {
@ -61,21 +63,16 @@ local file system.
if len(tags) == 0 && nextTag == "" { if len(tags) == 0 && nextTag == "" {
logrus.Warnf("no git tags found for %s", recipe.Name) logrus.Warnf("no git tags found for %s", recipe.Name)
fmt.Println(fmt.Sprintf(` fmt.Println(fmt.Sprintf(`
The following options are two types of initial semantic version that you can The following options are two types of initial version that you can pick for
pick for %s that will be published in the recipe catalogue. This follows the the first published version of %s that will be in the recipe catalogue. This
semver convention (more on https://semver.org), here is a short cheatsheet follows the semver convention (more on semver.org), here is a short cheatsheet
0.1.0: development release, still hacking. when you make a major upgrade 0.1.0 -> development release, still hacking
you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to 1.0.0 -> public release, assumed to be working
using the "x" part when things are stable.
1.0.0: public release, assumed to be working. you already have a stable In other words, if you want people to be able alpha test your current config
and reliable deployment of this app and feel relatively confident for %s but don't think it is quite ready and reliable, go with 0.1.0 and people
about it. will know that things are likely to change.
If you want people to be able alpha test your current config for %s but don't
think it is quite reliable, go with 0.1.0 and people will know that things are
likely to change.
`, recipe.Name, recipe.Name)) `, recipe.Name, recipe.Name))
var chosenVersion string var chosenVersion string
@ -98,7 +95,7 @@ likely to change.
} }
if nextTag == "" { if nextTag == "" {
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name) recipeDir := path.Join(config.APPS_DIR, recipe.Name)
repo, err := git.PlainOpen(recipeDir) repo, err := git.PlainOpen(recipeDir)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)

View File

@ -9,10 +9,9 @@ import (
"strings" "strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp" "coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference" "github.com/docker/distribution/reference"
@ -38,17 +37,12 @@ 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 semver-like convention. In this case, all possible tags will be listed and it
is up to the end-user to decide. is up to the end-user to decide.
The command is interactive and will show a select input which allows you to
make a seclection. Use the "?" key to see more help on navigating this
interface.
You may invoke this command in "wizard" mode and be prompted for input: You may invoke this command in "wizard" mode and be prompted for input:
abra recipe upgrade abra recipe upgrade
`, `,
BashComplete: autocomplete.RecipeNameComplete, ArgsUsage: "<recipe>",
ArgsUsage: "<recipe>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.PatchFlag, internal.PatchFlag,
internal.MinorFlag, internal.MinorFlag,
@ -67,7 +61,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
// check for versions file and load pinned versions // check for versions file and load pinned versions
versionsPresent := false versionsPresent := false
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name) recipeDir := path.Join(config.ABRA_DIR, "apps", recipe.Name)
versionsPath := path.Join(recipeDir, "versions") versionsPath := path.Join(recipeDir, "versions")
var servicePins = make(map[string]imgPin) var servicePins = make(map[string]imgPin)
if _, err := os.Stat(versionsPath); err == nil { if _, err := os.Stat(versionsPath); err == nil {
@ -153,12 +147,12 @@ You may invoke this command in "wizard" mode and be prompted for input:
continue // skip on to the next tag and don't update any compose files continue // skip on to the next tag and don't update any compose files
} }
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name) catlVersions, err := catalogue.VersionsOfService(recipe.Name, service.Name)
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
compatibleStrings := []string{"skip"} var compatibleStrings []string
for _, compat := range compatible { for _, compat := range compatible {
skip := false skip := false
for _, catlVersion := range catlVersions { for _, catlVersion := range catlVersions {
@ -215,7 +209,7 @@ You may invoke this command in "wizard" mode and be prompted for input:
continue continue
} }
} else { } else {
msg := fmt.Sprintf("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag) msg := fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) { if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
tag := img.(reference.NamedTagged).Tag() tag := img.(reference.NamedTagged).Tag()
logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag)) logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
@ -228,8 +222,6 @@ You may invoke this command in "wizard" mode and be prompted for input:
prompt := &survey.Select{ prompt := &survey.Select{
Message: msg, Message: msg,
Help: "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled",
VimMode: true,
Options: compatibleStrings, Options: compatibleStrings,
} }
if err := survey.AskOne(prompt, &upgradeTag); err != nil { if err := survey.AskOne(prompt, &upgradeTag); err != nil {
@ -237,14 +229,10 @@ You may invoke this command in "wizard" mode and be prompted for input:
} }
} }
} }
if upgradeTag != "skip" { if err := recipe.UpdateTag(image, upgradeTag); err != nil {
if err := recipe.UpdateTag(image, upgradeTag); err != nil { logrus.Fatal(err)
logrus.Fatal(err)
}
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
} else {
logrus.Warnf("not upgrading %s, skipping as requested", image)
} }
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
} }
return nil return nil

View File

@ -1,49 +1,36 @@
package recipe package recipe
import ( import (
"fmt" "coopcloud.tech/abra/cli/formatter"
"path"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
var recipeVersionCommand = &cli.Command{ var recipeVersionCommand = &cli.Command{
Name: "versions", Name: "versions",
Usage: "List recipe versions", Usage: "List recipe versions",
Aliases: []string{"v"}, Aliases: []string{"v"},
ArgsUsage: "<recipe>", ArgsUsage: "<recipe>",
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
catalogueDir := path.Join(config.ABRA_DIR, "catalogue") catalogue, err := catalogue.ReadRecipeCatalogue()
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "recipes")
if err := gitPkg.Clone(catalogueDir, url); err != nil {
return err
}
catalogue, err := recipePkg.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
recipeMeta, ok := catalogue[recipe.Name] recipeMeta, ok := catalogue[recipe.Name]
if !ok { if !ok {
logrus.Fatalf("%s recipe doesn't exist?", recipe.Name) logrus.Fatalf("'%s' recipe doesn't exist?", recipe.Name)
} }
tableCol := []string{"Version", "Service", "Image", "Tag", "Digest"} tableCol := []string{"Version", "Service", "Image", "Tag", "Digest"}
table := formatter.CreateTable(tableCol) table := formatter.CreateTable(tableCol)
for i := len(recipeMeta.Versions) - 1; i >= 0; i-- { for _, serviceVersion := range recipeMeta.Versions {
for tag, meta := range recipeMeta.Versions[i] { for tag, meta := range serviceVersion {
for service, serviceMeta := range meta { for service, serviceMeta := range meta {
table.Append([]string{tag, service, serviceMeta.Image, serviceMeta.Tag, serviceMeta.Digest}) table.Append([]string{tag, service, serviceMeta.Image, serviceMeta.Tag, serviceMeta.Digest})
} }
@ -51,12 +38,7 @@ var recipeVersionCommand = &cli.Command{
} }
table.SetAutoMergeCells(true) table.SetAutoMergeCells(true)
table.Render()
if table.NumLines() > 0 {
table.Render()
} else {
logrus.Fatalf("%s has no published versions?", recipe.Name)
}
return nil return nil
}, },

View File

@ -4,9 +4,9 @@ import (
"fmt" "fmt"
"strconv" "strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi" gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"coopcloud.tech/abra/pkg/formatter"
"github.com/libdns/gandi" "github.com/libdns/gandi"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -46,7 +46,7 @@ are listed. This zone must already be created on your provider account.
logrus.Fatal(err) logrus.Fatal(err)
} }
default: default:
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider) logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
} }
records, err := provider.GetRecords(c.Context, zone) records, err := provider.GetRecords(c.Context, zone)
@ -55,7 +55,7 @@ are listed. This zone must already be created on your provider account.
} }
tableCol := []string{"type", "name", "value", "TTL", "priority"} tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
for _, record := range records { for _, record := range records {
value := record.Value value := record.Value

View File

@ -3,11 +3,12 @@ package record
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/dns" "coopcloud.tech/abra/pkg/dns"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi" gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"coopcloud.tech/abra/pkg/formatter"
"github.com/libdns/gandi" "github.com/libdns/gandi"
"github.com/libdns/libdns" "github.com/libdns/libdns"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -43,12 +44,11 @@ Typically, you need two records, an A record which points at the zone (@.) and
a wildcard record for your apps (*.). Pass "--auto" to have Abra automatically a wildcard record for your apps (*.). Pass "--auto" to have Abra automatically
set this up. set this up.
abra record new --auto foo.com -p gandi -v 192.168.178.44 abra record new --auto
You may also invoke this command in "wizard" mode and be prompted for input You may also invoke this command in "wizard" mode and be prompted for input
abra record new abra record new
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
zone, err := internal.EnsureZoneArgument(c) zone, err := internal.EnsureZoneArgument(c)
@ -68,25 +68,14 @@ You may also invoke this command in "wizard" mode and be prompted for input
logrus.Fatal(err) logrus.Fatal(err)
} }
default: default:
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider) logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
} }
if internal.AutoDNSRecord { if internal.AutoDNSRecord {
ipv4, err := dns.EnsureIPv4(zone) logrus.Infof("automatically configuring @./*. A records for %s (--auto)", zone)
if err != nil { if err := autoConfigure(c, &provider, zone); err != nil {
logrus.Debugf("no ipv4 associated with %s, prompting for input", zone)
if err := internal.EnsureDNSValueFlag(c); err != nil {
logrus.Fatal(err)
}
ipv4 = internal.DNSValue
}
logrus.Infof("automatically configuring @./*. A records for %s for %s (--auto)", zone, ipv4)
if err := autoConfigure(c, &provider, zone, ipv4); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
return nil return nil
} }
@ -102,16 +91,11 @@ You may also invoke this command in "wizard" mode and be prompted for input
logrus.Fatal(err) logrus.Fatal(err)
} }
ttl, err := dns.GetTTL(internal.DNSTTL)
if err != nil {
return err
}
record := libdns.Record{ record := libdns.Record{
Type: internal.DNSType, Type: internal.DNSType,
Name: internal.DNSName, Name: internal.DNSName,
Value: internal.DNSValue, Value: internal.DNSValue,
TTL: ttl, TTL: time.Duration(internal.DNSTTL),
} }
if internal.DNSType == "MX" || internal.DNSType == "SRV" || internal.DNSType == "URI" { if internal.DNSType == "MX" || internal.DNSType == "SRV" || internal.DNSType == "URI" {
@ -147,7 +131,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
createdRecord := createdRecords[0] createdRecord := createdRecords[0]
tableCol := []string{"type", "name", "value", "TTL", "priority"} tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
value := createdRecord.Value value := createdRecord.Value
if len(createdRecord.Value) > 30 { if len(createdRecord.Value) > 30 {
@ -170,8 +154,8 @@ You may also invoke this command in "wizard" mode and be prompted for input
}, },
} }
func autoConfigure(c *cli.Context, provider *gandi.Provider, zone, ipv4 string) error { func autoConfigure(c *cli.Context, provider *gandi.Provider, zone string) error {
ttl, err := dns.GetTTL(internal.DNSTTL) ipv4, err := dns.EnsureIPv4(zone)
if err != nil { if err != nil {
return err return err
} }
@ -180,20 +164,20 @@ func autoConfigure(c *cli.Context, provider *gandi.Provider, zone, ipv4 string)
Type: "A", Type: "A",
Name: "@", Name: "@",
Value: ipv4, Value: ipv4,
TTL: ttl, TTL: time.Duration(internal.DNSTTL),
} }
wildcardRecord := libdns.Record{ wildcardRecord := libdns.Record{
Type: "A", Type: "A",
Name: "*", Name: "*",
Value: ipv4, Value: ipv4,
TTL: ttl, TTL: time.Duration(internal.DNSTTL),
} }
records := []libdns.Record{atRecord, wildcardRecord} records := []libdns.Record{atRecord, wildcardRecord}
tableCol := []string{"type", "name", "value", "TTL", "priority"} tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
for _, record := range records { for _, record := range records {
existingRecords, err := provider.GetRecords(c.Context, zone) existingRecords, err := provider.GetRecords(c.Context, zone)
@ -201,20 +185,15 @@ func autoConfigure(c *cli.Context, provider *gandi.Provider, zone, ipv4 string)
return err return err
} }
discovered := false
for _, existingRecord := range existingRecords { for _, existingRecord := range existingRecords {
if existingRecord.Type == record.Type && if existingRecord.Type == record.Type &&
existingRecord.Name == record.Name && existingRecord.Name == record.Name &&
existingRecord.Value == record.Value { existingRecord.Value == record.Value {
logrus.Warnf("%s record: %s %s for %s already exists?", record.Type, record.Name, record.Value, zone) logrus.Warnf("%s record for %s already exists?", record.Type, zone)
discovered = true continue
} }
} }
if discovered {
continue
}
createdRecords, err := provider.SetRecords( createdRecords, err := provider.SetRecords(
c.Context, c.Context,
zone, zone,

View File

@ -7,7 +7,7 @@ import (
// RecordCommand supports managing DNS entries. // RecordCommand supports managing DNS entries.
var RecordCommand = &cli.Command{ var RecordCommand = &cli.Command{
Name: "record", Name: "record",
Usage: "Manage domain name records", Usage: "Manage domain name records via 3rd party providers",
Aliases: []string{"rc"}, Aliases: []string{"rc"},
ArgsUsage: "<record>", ArgsUsage: "<record>",
Description: ` Description: `

View File

@ -4,9 +4,9 @@ import (
"fmt" "fmt"
"strconv" "strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi" gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"coopcloud.tech/abra/pkg/formatter"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/libdns/gandi" "github.com/libdns/gandi"
"github.com/libdns/libdns" "github.com/libdns/libdns"
@ -59,7 +59,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
logrus.Fatal(err) logrus.Fatal(err)
} }
default: default:
logrus.Fatalf("%s is not a supported DNS provider", internal.DNSProvider) logrus.Fatalf("'%s' is not a supported DNS provider", internal.DNSProvider)
} }
if err := internal.EnsureDNSTypeFlag(c); err != nil { if err := internal.EnsureDNSTypeFlag(c); err != nil {
@ -88,7 +88,7 @@ You may also invoke this command in "wizard" mode and be prompted for input
} }
tableCol := []string{"type", "name", "value", "TTL", "priority"} tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol) table := abraFormatter.CreateTable(tableCol)
value := toDelete.Value value := toDelete.Value
if len(toDelete.Value) > 30 { if len(toDelete.Value) > 30 {
@ -105,19 +105,17 @@ You may also invoke this command in "wizard" mode and be prompted for input
table.Render() table.Render()
if !internal.NoInput { response := false
response := false prompt := &survey.Confirm{
prompt := &survey.Confirm{ Message: "continue with record deletion?",
Message: "continue with record deletion?", }
}
if err := survey.AskOne(prompt, &response); err != nil { if err := survey.AskOne(prompt, &response); err != nil {
return err return err
} }
if !response { if !response {
logrus.Fatal("exiting as requested") logrus.Fatal("exiting as requested")
}
} }
_, err = provider.DeleteRecords(c.Context, zone, []libdns.Record{toDelete}) _, err = provider.DeleteRecords(c.Context, zone, []libdns.Record{toDelete})

View File

@ -97,7 +97,7 @@ func cleanUp(domainName string) {
} }
logrus.Warnf("cleaning up server directory for %s", domainName) logrus.Warnf("cleaning up server directory for %s", domainName)
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, domainName)); err != nil { if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, domainName)); err != nil {
logrus.Fatal(err) logrus.Fatal(err)
} }
} }

View File

@ -3,9 +3,9 @@ package server
import ( import (
"strings" "strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/context" "coopcloud.tech/abra/pkg/context"
"coopcloud.tech/abra/pkg/formatter"
"github.com/docker/cli/cli/connhelper/ssh" "github.com/docker/cli/cli/connhelper/ssh"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"

View File

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/libcapsul" "coopcloud.tech/libcapsul"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud" "github.com/hetznercloud/hcloud-go/hcloud"
@ -43,18 +43,13 @@ func newHetznerCloudVPS(c *cli.Context) error {
Location: &hcloud.Location{Name: internal.HetznerCloudLocation}, Location: &hcloud.Location{Name: internal.HetznerCloudLocation},
} }
sshKeyIDs := strings.Join(sshKeysRaw, "\n")
if sshKeyIDs == "" {
sshKeyIDs = "N/A (password auth)"
}
tableColumns := []string{"name", "type", "image", "ssh-keys", "location"} tableColumns := []string{"name", "type", "image", "ssh-keys", "location"}
table := formatter.CreateTable(tableColumns) table := formatter.CreateTable(tableColumns)
table.Append([]string{ table.Append([]string{
internal.HetznerCloudName, internal.HetznerCloudName,
internal.HetznerCloudType, internal.HetznerCloudType,
internal.HetznerCloudImage, internal.HetznerCloudImage,
sshKeyIDs, strings.Join(sshKeysRaw, "\n"),
internal.HetznerCloudLocation, internal.HetznerCloudLocation,
}) })
table.Render() table.Render()
@ -222,6 +217,7 @@ API tokens are read from the environment if specified, e.g.
Where "$provider_TOKEN" is the expected env var format. Where "$provider_TOKEN" is the expected env var format.
`, `,
ArgsUsage: "<provider>",
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.ServerProviderFlag, internal.ServerProviderFlag,

View File

@ -5,10 +5,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud" "github.com/hetznercloud/hcloud-go/hcloud"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -102,7 +102,7 @@ destroyed.
var serverRemoveCommand = &cli.Command{ var serverRemoveCommand = &cli.Command{
Name: "remove", Name: "remove",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
ArgsUsage: "[<server>]", ArgsUsage: "<server>",
Usage: "Remove a managed server", Usage: "Remove a managed server",
Description: ` Description: `
This command removes a server from Abra management. This command removes a server from Abra management.
@ -117,20 +117,15 @@ like tears in rain.
`, `,
Flags: []cli.Flag{ Flags: []cli.Flag{
rmServerFlag, rmServerFlag,
internal.ServerProviderFlag,
// Hetzner // Hetzner
internal.HetznerCloudNameFlag, internal.HetznerCloudNameFlag,
internal.HetznerCloudAPITokenFlag, internal.HetznerCloudAPITokenFlag,
}, },
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
serverName := c.Args().Get(1) serverName, err := internal.ValidateServer(c)
if serverName != "" { if err != nil {
var err error logrus.Fatal(err)
serverName, err = internal.ValidateServer(c)
if err != nil {
logrus.Fatal(err)
}
} }
if !rmServer { if !rmServer {
@ -165,18 +160,16 @@ like tears in rain.
} }
if serverName != "" { if err := client.DeleteContext(serverName); err != nil {
if err := client.DeleteContext(serverName); err != nil { logrus.Fatal(err)
logrus.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(config.SERVERS_DIR, serverName)); err != nil {
logrus.Fatal(err)
}
logrus.Infof("server at %s has been lost in time, like tears in rain", serverName)
} }
if err := os.RemoveAll(filepath.Join(config.ABRA_SERVER_FOLDER, serverName)); err != nil {
logrus.Fatal(err)
}
logrus.Infof("server at '%s' has been lost in time, like tears in rain", serverName)
return nil return nil
}, },
} }

View File

@ -8,7 +8,7 @@ import (
var ServerCommand = &cli.Command{ var ServerCommand = &cli.Command{
Name: "server", Name: "server",
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "Manage servers", Usage: "Manage servers via 3rd party providers",
Description: ` Description: `
These commands support creating, managing and removing servers using 3rd party These commands support creating, managing and removing servers using 3rd party
integrations. integrations.

44
cli/upgrade.go Normal file
View File

@ -0,0 +1,44 @@
package cli
import (
"fmt"
"os/exec"
"coopcloud.tech/abra/cli/internal"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var mainURL = "https://install.abra.coopcloud.tech"
var releaseCandidateURL = "https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
// UpgradeCommand upgrades abra in-place.
var UpgradeCommand = &cli.Command{
Name: "upgrade",
Usage: "Upgrade Abra",
Description: `
This command allows you to upgrade Abra in-place with the latest stable or
release candidate.
If you would like to install the latest release candidate, please pass the
"--rc" option. Please bear in mind that the latest release candidate may have
some catastrophic bugs contained in it. In any case, thank you very much for
testing efforts!
`,
Flags: []cli.Flag{internal.RCFlag},
Action: func(c *cli.Context) error {
cmd := exec.Command("bash", "-c", fmt.Sprintf("curl -s %s | bash", mainURL))
if internal.RC {
cmd = exec.Command("bash", "-c", fmt.Sprintf("curl -s %s | bash -s -- --rc", releaseCandidateURL))
}
logrus.Debugf("attempting to run '%s'", cmd)
if err := internal.RunCmd(cmd); err != nil {
logrus.Fatal(err)
}
return nil
},
}

View File

@ -5,10 +5,10 @@ import (
"coopcloud.tech/abra/cli" "coopcloud.tech/abra/cli"
) )
// Version is the current version of Abra // Version is the current version of abra.
var Version string var Version string
// Commit is the current git commit of Abra // Commit is the current commit of abra.
var Commit string var Commit string
func main() { func main() {

1
go.mod
View File

@ -27,7 +27,6 @@ require (
require ( require (
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e
github.com/Microsoft/hcsshim v0.8.21 // indirect github.com/Microsoft/hcsshim v0.8.21 // indirect
github.com/buger/goterm v1.0.3
github.com/containerd/containerd v1.5.5 // indirect github.com/containerd/containerd v1.5.5 // indirect
github.com/docker/docker-credential-helpers v0.6.4 // indirect github.com/docker/docker-credential-helpers v0.6.4 // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect

3
go.sum
View File

@ -110,8 +110,6 @@ github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
github.com/buger/goterm v1.0.3 h1:7V/HeAQHrzPk/U4BvyH2g9u+xbUW9nr4yRPyG59W4fM=
github.com/buger/goterm v1.0.3/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0 h1:s7+5BfS4WFJoVF9pnB8kBk03S7pZXRdKamnV0FOl5Sc= github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0 h1:s7+5BfS4WFJoVF9pnB8kBk03S7pZXRdKamnV0FOl5Sc=
@ -978,7 +976,6 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -23,7 +23,7 @@ func Get(appName string) (config.App, error) {
return config.App{}, err return config.App{}, err
} }
logrus.Debugf("retrieved %s for %s", app, appName) logrus.Debugf("retrieved '%s' for '%s'", app, appName)
return app, nil return app, nil
} }

View File

@ -3,8 +3,8 @@ package autocomplete
import ( import (
"fmt" "fmt"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@ -27,7 +27,7 @@ func AppNameComplete(c *cli.Context) {
// RecipeNameComplete completes recipe names // RecipeNameComplete completes recipe names
func RecipeNameComplete(c *cli.Context) { func RecipeNameComplete(c *cli.Context) {
catl, err := recipe.ReadRecipeCatalogue() catl, err := catalogue.ReadRecipeCatalogue()
if err != nil { if err != nil {
logrus.Warn(err) logrus.Warn(err)
} }

642
pkg/catalogue/catalogue.go Normal file
View File

@ -0,0 +1,642 @@
// Package catalogue provides ways of interacting with recipe catalogues which
// are JSON data structures which contain meta information about recipes (e.g.
// what versions of the Nextcloud recipe are available?).
package catalogue
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"strings"
"time"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/web"
"github.com/docker/distribution/reference"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus"
)
// RecipeCatalogueURL is the only current recipe catalogue available.
const RecipeCatalogueURL = "https://apps.coopcloud.tech"
// ReposMetadataURL is the recipe repository metadata
const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos"
// image represents a recipe container image.
type image struct {
Image string `json:"image"`
Rating string `json:"rating"`
Source string `json:"source"`
URL string `json:"url"`
}
// features represent what top-level features a recipe supports (e.g. does this
// recipe support backups?).
type features struct {
Backups string `json:"backups"`
Email string `json:"email"`
Healthcheck string `json:"healthcheck"`
Image image `json:"image"`
Status int `json:"status"`
Tests string `json:"tests"`
SSO string `json:"sso"`
}
// tag represents a git tag.
type tag = string
// service represents a service within a recipe.
type service = string
// ServiceMeta represents meta info associated with a service.
type ServiceMeta struct {
Digest string `json:"digest"`
Image string `json:"image"`
Tag string `json:"tag"`
}
// RecipeVersions are the versions associated with a recipe.
type RecipeVersions []map[tag]map[service]ServiceMeta
// RecipeMeta represents metadata for a recipe in the abra catalogue.
type RecipeMeta struct {
Category string `json:"category"`
DefaultBranch string `json:"default_branch"`
Description string `json:"description"`
Features features `json:"features"`
Icon string `json:"icon"`
Name string `json:"name"`
Repository string `json:"repository"`
Versions RecipeVersions `json:"versions"`
Website string `json:"website"`
}
// LatestVersion returns the latest version of a recipe.
func (r RecipeMeta) LatestVersion() string {
var version string
// apps.json versions are sorted so the last key is latest
latest := r.Versions[len(r.Versions)-1]
for tag := range latest {
version = tag
}
logrus.Debugf("choosing %s as latest version of %s", version, r.Name)
return version
}
// Name represents a recipe name.
type Name = string
// RecipeCatalogue represents the entire recipe catalogue.
type RecipeCatalogue map[Name]RecipeMeta
// Flatten converts AppCatalogue to slice
func (r RecipeCatalogue) Flatten() []RecipeMeta {
recipes := make([]RecipeMeta, 0, len(r))
for name := range r {
recipes = append(recipes, r[name])
}
return recipes
}
// ByRecipeName sorts recipes by name.
type ByRecipeName []RecipeMeta
func (r ByRecipeName) Len() int { return len(r) }
func (r ByRecipeName) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
func (r ByRecipeName) Less(i, j int) bool {
return strings.ToLower(r[i].Name) < strings.ToLower(r[j].Name)
}
// recipeCatalogueFSIsLatest checks whether the recipe catalogue stored locally
// is up to date.
func recipeCatalogueFSIsLatest() (bool, error) {
httpClient := web.NewHTTPRetryClient()
res, err := httpClient.Head(RecipeCatalogueURL)
if err != nil {
return false, err
}
lastModified := res.Header["Last-Modified"][0]
parsed, err := time.Parse(time.RFC1123, lastModified)
if err != nil {
return false, err
}
info, err := os.Stat(config.APPS_JSON)
if err != nil {
if os.IsNotExist(err) {
logrus.Debugf("no recipe catalogue found in file system cache")
return false, nil
}
return false, err
}
localModifiedTime := info.ModTime().Unix()
remoteModifiedTime := parsed.Unix()
if localModifiedTime < remoteModifiedTime {
logrus.Debug("file system cached recipe catalogue is out-of-date")
return false, nil
}
logrus.Debug("file system cached recipe catalogue is now up-to-date")
return true, nil
}
// ReadRecipeCatalogue reads the recipe catalogue.
func ReadRecipeCatalogue() (RecipeCatalogue, error) {
recipes := make(RecipeCatalogue)
recipeFSIsLatest, err := recipeCatalogueFSIsLatest()
if err != nil {
return nil, err
}
if !recipeFSIsLatest {
logrus.Debugf("reading recipe catalogue from web to get latest")
if err := readRecipeCatalogueWeb(&recipes); err != nil {
return nil, err
}
return recipes, nil
}
logrus.Debugf("reading recipe catalogue from file system cache to get latest")
if err := readRecipeCatalogueFS(&recipes); err != nil {
return nil, err
}
return recipes, nil
}
// readRecipeCatalogueFS reads the catalogue from the file system.
func readRecipeCatalogueFS(target interface{}) error {
recipesJSONFS, err := ioutil.ReadFile(config.APPS_JSON)
if err != nil {
return err
}
if err := json.Unmarshal(recipesJSONFS, &target); err != nil {
return err
}
logrus.Debugf("read recipe catalogue from file system cache in %s", config.APPS_JSON)
return nil
}
// readRecipeCatalogueWeb reads the catalogue from the web.
func readRecipeCatalogueWeb(target interface{}) error {
if err := web.ReadJSON(RecipeCatalogueURL, &target); err != nil {
return err
}
recipesJSON, err := json.MarshalIndent(target, "", " ")
if err != nil {
return err
}
if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0764); err != nil {
return err
}
logrus.Debugf("read recipe catalogue from web at %s", RecipeCatalogueURL)
return nil
}
// VersionsOfService lists the version of a service.
func VersionsOfService(recipe, serviceName string) ([]string, error) {
var versions []string
catalogue, err := ReadRecipeCatalogue()
if err != nil {
return nil, err
}
rec, ok := catalogue[recipe]
if !ok {
return versions, nil
}
alreadySeen := make(map[string]bool)
for _, serviceVersion := range rec.Versions {
for tag := range serviceVersion {
if _, ok := alreadySeen[tag]; !ok {
alreadySeen[tag] = true
versions = append(versions, tag)
}
}
}
logrus.Debugf("detected versions %s for %s", strings.Join(versions, ", "), recipe)
return versions, nil
}
// GetRecipeMeta retrieves the recipe metadata from the recipe catalogue.
func GetRecipeMeta(recipeName string) (RecipeMeta, error) {
catl, err := ReadRecipeCatalogue()
if err != nil {
return RecipeMeta{}, err
}
recipeMeta, ok := catl[recipeName]
if !ok {
err := fmt.Errorf("recipe %s does not exist?", recipeName)
return RecipeMeta{}, err
}
if err := recipe.EnsureExists(recipeName); err != nil {
return RecipeMeta{}, err
}
logrus.Debugf("recipe metadata retrieved for %s", recipeName)
return recipeMeta, nil
}
// RepoMeta is a single recipe repo metadata.
type RepoMeta struct {
ID int `json:"id"`
Owner Owner
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Empty bool `json:"empty"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Template bool `json:"template"`
Parent interface{} `json:"parent"`
Mirror bool `json:"mirror"`
Size int `json:"size"`
HTMLURL string `json:"html_url"`
SSHURL string `json:"ssh_url"`
CloneURL string `json:"clone_url"`
OriginalURL string `json:"original_url"`
Website string `json:"website"`
StarsCount int `json:"stars_count"`
ForksCount int `json:"forks_count"`
WatchersCount int `json:"watchers_count"`
OpenIssuesCount int `json:"open_issues_count"`
OpenPRCount int `json:"open_pr_counter"`
ReleaseCounter int `json:"release_counter"`
DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Permissions Permissions
HasIssues bool `json:"has_issues"`
InternalTracker InternalTracker
HasWiki bool `json:"has_wiki"`
HasPullRequests bool `json:"has_pull_requests"`
HasProjects bool `json:"has_projects"`
IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"`
AllowMergeCommits bool `json:"allow_merge_commits"`
AllowRebase bool `json:"allow_rebase"`
AllowRebaseExplicit bool `json:"allow_rebase_explicit"`
AllowSquashMerge bool `json:"allow_squash_merge"`
AvatarURL string `json:"avatar_url"`
Internal bool `json:"internal"`
MirrorInterval string `json:"mirror_interval"`
}
// Owner is the repo organisation owner metadata.
type Owner struct {
ID int `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Language string `json:"language"`
IsAdmin bool `json:"is_admin"`
LastLogin string `json:"last_login"`
Created string `json:"created"`
Restricted bool `json:"restricted"`
Username string `json:"username"`
}
// Permissions is perms metadata for a repo.
type Permissions struct {
Admin bool `json:"admin"`
Push bool `json:"push"`
Pull bool `json:"pull"`
}
// InternalTracker is issue tracker metadata for a repo.
type InternalTracker struct {
EnableTimeTracker bool `json:"enable_time_tracker"`
AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"`
EnableIssuesDependencies bool `json:"enable_issue_dependencies"`
}
// RepoCatalogue represents all the recipe repo metadata.
type RepoCatalogue map[string]RepoMeta
// ReadReposMetadata retrieves coop-cloud/... repo metadata from Gitea.
func ReadReposMetadata() (RepoCatalogue, error) {
reposMeta := make(RepoCatalogue)
pageIdx := 1
bar := formatter.CreateProgressbar(-1, "retrieving recipe repos list from git.coopcloud.tech...")
for {
var reposList []RepoMeta
pagedURL := fmt.Sprintf("%s?page=%v", ReposMetadataURL, pageIdx)
logrus.Debugf("fetching repo metadata from %s", pagedURL)
if err := web.ReadJSON(pagedURL, &reposList); err != nil {
return reposMeta, err
}
if len(reposList) == 0 {
bar.Add(1)
break
}
for idx, repo := range reposList {
reposMeta[repo.Name] = reposList[idx]
}
pageIdx++
bar.Add(1)
}
return reposMeta, nil
}
func GetStringInBetween(str, start, end string) (result string, err error) {
// GetStringInBetween returns empty string if no start or end string found
s := strings.Index(str, start)
if s == -1 {
return "", fmt.Errorf("marker string '%s' not found", start)
}
s += len(start)
e := strings.Index(str[s:], end)
if e == -1 {
return "", fmt.Errorf("end marker '%s' not found", end)
}
return str[s : s+e], nil
}
func GetImageMetadata(imageRowString, recipeName string) (image, error) {
img := image{}
imgFields := strings.Split(imageRowString, ",")
for i, elem := range imgFields {
imgFields[i] = strings.TrimSpace(elem)
}
if len(imgFields) < 3 {
if imageRowString != "" {
logrus.Warnf("%s image meta has incorrect format: %s", recipeName, imageRowString)
} else {
logrus.Warnf("%s image meta is empty?", recipeName)
}
return img, nil
}
img.Rating = imgFields[1]
img.Source = imgFields[2]
imgString := imgFields[0]
imageName, err := GetStringInBetween(imgString, "[", "]")
if err != nil {
logrus.Fatal(err)
}
img.Image = strings.ReplaceAll(imageName, "`", "")
imageURL, err := GetStringInBetween(imgString, "(", ")")
if err != nil {
logrus.Fatal(err)
}
img.URL = imageURL
return img, nil
}
func GetRecipeFeaturesAndCategory(recipeName string) (features, string, error) {
feat := features{}
var category string
readmePath := path.Join(config.ABRA_DIR, "apps", recipeName, "README.md")
logrus.Debugf("attempting to open %s for recipe metadata parsing", readmePath)
readmeFS, err := ioutil.ReadFile(readmePath)
if err != nil {
return feat, category, err
}
readmeMetadata, err := GetStringInBetween( // Find text between delimiters
string(readmeFS),
"<!-- metadata -->", "<!-- endmetadata -->",
)
if err != nil {
return feat, category, err
}
readmeLines := strings.Split( // Array item from lines
strings.ReplaceAll( // Remove \t tabs
readmeMetadata, "\t", "",
),
"\n")
for _, val := range readmeLines {
if strings.Contains(val, "**Category**") {
category = strings.TrimSpace(
strings.TrimPrefix(val, "* **Category**:"),
)
}
if strings.Contains(val, "**Backups**") {
feat.Backups = strings.TrimSpace(
strings.TrimPrefix(val, "* **Backups**:"),
)
}
if strings.Contains(val, "**Email**") {
feat.Email = strings.TrimSpace(
strings.TrimPrefix(val, "* **Email**:"),
)
}
if strings.Contains(val, "**SSO**") {
feat.SSO = strings.TrimSpace(
strings.TrimPrefix(val, "* **SSO**:"),
)
}
if strings.Contains(val, "**Healthcheck**") {
feat.Healthcheck = strings.TrimSpace(
strings.TrimPrefix(val, "* **Healthcheck**:"),
)
}
if strings.Contains(val, "**Tests**") {
feat.Tests = strings.TrimSpace(
strings.TrimPrefix(val, "* **Tests**:"),
)
}
if strings.Contains(val, "**Image**") {
imageMetadata, err := GetImageMetadata(strings.TrimSpace(
strings.TrimPrefix(val, "* **Image**:"),
), recipeName)
if err != nil {
continue
}
feat.Image = imageMetadata
}
}
return feat, category, nil
}
// GetRecipeVersions retrieves all recipe versions.
func GetRecipeVersions(recipeName string) (RecipeVersions, error) {
versions := RecipeVersions{}
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
logrus.Debugf("attempting to open git repository in %s", recipeDir)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return versions, err
}
worktree, err := repo.Worktree()
if err != nil {
logrus.Fatal(err)
}
gitTags, err := repo.Tags()
if err != nil {
return versions, err
}
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
logrus.Debugf("processing %s for %s", tag, recipeName)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(ref.Name()),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out %s in %s", tag, recipeDir)
return err
}
logrus.Debugf("successfully checked out %s in %s", ref.Name(), recipeDir)
recipe, err := recipe.Get(recipeName)
if err != nil {
return err
}
cl, err := client.New("default") // only required for docker.io registry calls
if err != nil {
logrus.Fatal(err)
}
versionMeta := make(map[string]ServiceMeta)
for _, service := range recipe.Config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return err
}
path := reference.Path(img)
if strings.Contains(path, "library") {
path = strings.Split(path, "/")[1]
}
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
logrus.Warnf("%s service is missing image tag?", path)
continue
}
logrus.Debugf("looking up image: %s from %s", img, path)
digest, err := client.GetTagDigest(cl, img)
if err != nil {
logrus.Warn(err)
continue
}
versionMeta[service.Name] = ServiceMeta{
Digest: digest,
Image: path,
Tag: img.(reference.NamedTagged).Tag(),
}
logrus.Debugf("collecting digest: %s, image: %s, tag: %s", digest, path, tag)
}
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
return nil
}); err != nil {
return versions, err
}
branch := "master"
if _, err := repo.Branch("master"); err != nil {
if _, err := repo.Branch("main"); err != nil {
logrus.Debugf("failed to select branch in %s", recipeDir)
logrus.Fatal(err)
}
branch = "main"
}
refName := fmt.Sprintf("refs/heads/%s", branch)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out %s in %s", branch, recipeDir)
logrus.Fatal(err)
}
logrus.Debugf("switched back to %s in %s", branch, recipeDir)
logrus.Debugf("collected %s for %s", versions, recipeName)
return versions, nil
}
// GetRecipeCatalogueVersions list the recipe versions listed in the recipe catalogue.
func GetRecipeCatalogueVersions(recipeName string, catl RecipeCatalogue) ([]string, error) {
var versions []string
if recipeMeta, exists := catl[recipeName]; exists {
for _, versionMeta := range recipeMeta.Versions {
for tag := range versionMeta {
versions = append(versions, tag)
}
}
}
return versions, nil
}

View File

@ -55,7 +55,7 @@ func New(contextName string) (*client.Client, error) {
return nil, err return nil, err
} }
logrus.Debugf("created client for %s", contextName) logrus.Debugf("created client for '%s'", contextName)
return cl, nil return cl, nil
} }

View File

@ -26,7 +26,7 @@ func CreateContext(contextName string, user string, port string) error {
if err := createContext(contextName, host); err != nil { if err := createContext(contextName, host); err != nil {
return err return err
} }
logrus.Debugf("created the %s context", contextName) logrus.Debugf("created the '%s' context", contextName)
return nil return nil
} }
@ -72,6 +72,8 @@ func DeleteContext(name string) error {
return err return err
} }
// remove any context that might be loaded
// TODO: Check if the context we are removing is the active one rather than doing it all the time
cfg := dConfig.LoadDefaultConfigFile(nil) cfg := dConfig.LoadDefaultConfigFile(nil)
cfg.CurrentContext = "" cfg.CurrentContext = ""
if err := cfg.Save(); err != nil { if err := cfg.Save(); err != nil {

View File

@ -1,11 +1,11 @@
package client package client
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os"
"strings" "strings"
"coopcloud.tech/abra/pkg/web" "coopcloud.tech/abra/pkg/web"
@ -35,29 +35,25 @@ func GetRegistryTags(image string) (RawTags, error) {
return tags, nil return tags, nil
} }
func basicAuth(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}
// getRegv2Token retrieves a registry v2 authentication token. // getRegv2Token retrieves a registry v2 authentication token.
func getRegv2Token(cl *client.Client, image reference.Named, registryUsername, registryPassword string) (string, error) { func getRegv2Token(cl *client.Client, image reference.Named) (string, error) {
img := reference.Path(image) img := reference.Path(image)
tokenURL := "https://auth.docker.io/token" tokenURL := "https://auth.docker.io/token"
values := fmt.Sprintf("service=registry.docker.io&scope=repository:%s:pull", img) values := fmt.Sprintf("service=registry.docker.io&scope=repository:%s:pull", img)
username, userOk := os.LookupEnv("DOCKER_USERNAME")
password, passOk := os.LookupEnv("DOCKER_PASSWORD")
if userOk && passOk {
logrus.Debugf("using docker log in credentials for registry token request")
values = fmt.Sprintf("%s&grant_type=password&client_id=coopcloud.tech&username=%s&password=%s", values, username, password)
}
fullURL := fmt.Sprintf("%s?%s", tokenURL, values) fullURL := fmt.Sprintf("%s?%s", tokenURL, values)
req, err := retryablehttp.NewRequest("GET", fullURL, nil) req, err := retryablehttp.NewRequest("GET", fullURL, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
if registryUsername != "" && registryPassword != "" {
logrus.Debugf("using registry log in credentials for token request")
auth := basicAuth(registryUsername, registryPassword)
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", auth))
}
client := web.NewHTTPRetryClient() client := web.NewHTTPRetryClient()
res, err := client.Do(req) res, err := client.Do(req)
if err != nil { if err != nil {
@ -92,7 +88,7 @@ func getRegv2Token(cl *client.Client, image reference.Named, registryUsername, r
} }
// GetTagDigest retrieves an image digest from a v2 registry // GetTagDigest retrieves an image digest from a v2 registry
func GetTagDigest(cl *client.Client, image reference.Named, registryUsername, registryPassword string) (string, error) { func GetTagDigest(cl *client.Client, image reference.Named) (string, error) {
img := reference.Path(image) img := reference.Path(image)
tag := image.(reference.NamedTagged).Tag() tag := image.(reference.NamedTagged).Tag()
manifestURL := fmt.Sprintf("https://index.docker.io/v2/%s/manifests/%s", img, tag) manifestURL := fmt.Sprintf("https://index.docker.io/v2/%s/manifests/%s", img, tag)
@ -102,7 +98,7 @@ func GetTagDigest(cl *client.Client, image reference.Named, registryUsername, re
return "", err return "", err
} }
token, err := getRegv2Token(cl, image, registryUsername, registryPassword) token, err := getRegv2Token(cl, image)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -186,7 +182,7 @@ func GetTagDigest(cl *client.Client, image reference.Named, registryUsername, re
} }
if digest == "" { if digest == "" {
return "", fmt.Errorf("Unable to retrieve amd64 digest for %s", image) return "", fmt.Errorf("Unable to retrieve amd64 digest for '%s'", image)
} }
return digest, nil return digest, nil

View File

@ -22,12 +22,12 @@ func UpdateTag(pattern, image, tag, recipeName string) error {
return err return err
} }
logrus.Debugf("considering %s config(s) for tag update", strings.Join(composeFiles, ", ")) logrus.Debugf("considering '%s' config(s) for tag update", strings.Join(composeFiles, ", "))
for _, composeFile := range composeFiles { for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}} opts := stack.Deploy{Composefiles: []string{composeFile}}
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample") envSamplePath := path.Join(config.ABRA_DIR, "apps", recipeName, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath) sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil { if err != nil {
return err return err
@ -57,7 +57,7 @@ func UpdateTag(pattern, image, tag, recipeName string) error {
} }
composeTag := img.(reference.NamedTagged).Tag() composeTag := img.(reference.NamedTagged).Tag()
logrus.Debugf("parsed %s from %s", composeTag, service.Image) logrus.Debugf("parsed '%s' from '%s'", composeTag, service.Image)
if image == composeImage { if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile) bytes, err := ioutil.ReadFile(composeFile)
@ -69,7 +69,7 @@ func UpdateTag(pattern, image, tag, recipeName string) error {
new := fmt.Sprintf("%s:%s", composeImage, tag) new := fmt.Sprintf("%s:%s", composeImage, tag)
replacedBytes := strings.Replace(string(bytes), old, new, -1) replacedBytes := strings.Replace(string(bytes), old, new, -1)
logrus.Debugf("updating %s to %s in %s", old, new, compose.Filename) logrus.Debugf("updating '%s' to '%s' in '%s'", old, new, compose.Filename)
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil { if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0764); err != nil {
return err return err
@ -93,7 +93,7 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
for _, composeFile := range composeFiles { for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}} opts := stack.Deploy{Composefiles: []string{composeFile}}
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample") envSamplePath := path.Join(config.ABRA_DIR, "apps", recipeName, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath) sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil { if err != nil {
return err return err
@ -146,8 +146,8 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
} }
if !discovered { if !discovered {
logrus.Warn("no existing label found, automagic insertion not supported yet") logrus.Warn("no existing label found, cannot continue...")
logrus.Fatalf("add '- \"%s\"' manually to the 'app' service in %s", label, composeFile) logrus.Fatalf("add \"%s\" manually, automagic insertion not supported yet", label)
} }
} }

View File

@ -2,13 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"html/template"
"io/ioutil" "io/ioutil"
"os" "os"
"path" "path"
"strings" "strings"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/upstream/convert" "coopcloud.tech/abra/pkg/upstream/convert"
loader "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
@ -43,17 +42,13 @@ type App struct {
Path string Path string
} }
// StackName gets what the docker safe stack name is for the app. This should // StackName gets what the docker safe stack name is for the app
// not not shown to the user, use a.Name for that. Give the output of this
// command to Docker only.
func (a App) StackName() string { func (a App) StackName() string {
if _, exists := a.Env["STACK_NAME"]; exists { if _, exists := a.Env["STACK_NAME"]; exists {
return a.Env["STACK_NAME"] return a.Env["STACK_NAME"]
} }
stackName := SanitiseAppName(a.Name) stackName := SanitiseAppName(a.Name)
a.Env["STACK_NAME"] = stackName a.Env["STACK_NAME"] = stackName
return stackName return stackName
} }
@ -101,14 +96,14 @@ func (a ByName) Less(i, j int) bool {
func readAppEnvFile(appFile AppFile, name AppName) (App, error) { func readAppEnvFile(appFile AppFile, name AppName) (App, error) {
env, err := ReadEnv(appFile.Path) env, err := ReadEnv(appFile.Path)
if err != nil { if err != nil {
return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error()) return App{}, fmt.Errorf("env file for '%s' couldn't be read: %s", name, err.Error())
} }
logrus.Debugf("read env %s from %s", env, appFile.Path) logrus.Debugf("read env '%s' from '%s'", env, appFile.Path)
app, err := newApp(env, name, appFile) app, err := newApp(env, name, appFile)
if err != nil { if err != nil {
return App{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error()) return App{}, fmt.Errorf("env file for '%s' has issues: %s", name, err.Error())
} }
return app, nil return app, nil
@ -140,7 +135,7 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
if servers[0] == "" { if servers[0] == "" {
// Empty servers flag, one string will always be passed // Empty servers flag, one string will always be passed
var err error var err error
servers, err = GetAllFoldersInDirectory(SERVERS_DIR) servers, err = GetAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -150,14 +145,14 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
logrus.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", ")) logrus.Debugf("collecting metadata from %v servers: %s", len(servers), strings.Join(servers, ", "))
for _, server := range servers { for _, server := range servers {
serverDir := path.Join(SERVERS_DIR, server) serverDir := path.Join(ABRA_SERVER_FOLDER, server)
files, err := getAllFilesInDirectory(serverDir) files, err := getAllFilesInDirectory(serverDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, file := range files { for _, file := range files {
appName := strings.TrimSuffix(file.Name(), ".env") appName := strings.TrimSuffix(file.Name(), ".env")
appFilePath := path.Join(SERVERS_DIR, server, file.Name()) appFilePath := path.Join(ABRA_SERVER_FOLDER, server, file.Name())
appFiles[appName] = AppFile{ appFiles[appName] = AppFile{
Path: appFilePath, Path: appFilePath,
Server: server, Server: server,
@ -253,8 +248,8 @@ func GetAppNames() ([]string, error) {
} }
// TemplateAppEnvSample copies the example env file for the app into the users env files // TemplateAppEnvSample copies the example env file for the app into the users env files
func TemplateAppEnvSample(recipeName, appName, server, domain string) error { func TemplateAppEnvSample(recipe, appName, server, domain string) error {
envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample") envSamplePath := path.Join(ABRA_DIR, "apps", recipe, ".env.sample")
envSample, err := ioutil.ReadFile(envSamplePath) envSample, err := ioutil.ReadFile(envSamplePath)
if err != nil { if err != nil {
return err return err
@ -265,27 +260,15 @@ func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
return fmt.Errorf("%s already exists?", appEnvPath) return fmt.Errorf("%s already exists?", appEnvPath)
} }
envSample = []byte(strings.Replace(string(envSample), fmt.Sprintf("%s.example.com", recipe), domain, -1))
envSample = []byte(strings.Replace(string(envSample), "example.com", domain, -1))
err = ioutil.WriteFile(appEnvPath, envSample, 0664) err = ioutil.WriteFile(appEnvPath, envSample, 0664)
if err != nil { if err != nil {
return err return err
} }
file, err := os.OpenFile(appEnvPath, os.O_RDWR, 0664) logrus.Debugf("copied %s to %s", envSamplePath, appEnvPath)
if err != nil {
return err
}
defer file.Close()
tpl, err := template.ParseFiles(appEnvPath)
if err != nil {
return err
}
if err := tpl.Execute(file, struct{ Name string }{recipeName}); err != nil {
return err
}
logrus.Debugf("copied & templated %s to %s", envSamplePath, appEnvPath)
return nil return nil
} }
@ -331,6 +314,9 @@ func GetAppStatuses(appFiles AppFiles) (map[string]map[string]string, error) {
if version, ok := service.Spec.Labels[labelKey]; ok { if version, ok := service.Spec.Labels[labelKey]; ok {
result["version"] = version result["version"] = version
} else { } else {
//FIXME: we only need to check containers with the version label not
// every single container and then skip when we see no label perf gains
// to be had here
continue continue
} }
@ -350,7 +336,7 @@ func GetAppComposeFiles(recipe string, appEnv AppEnv) ([]string, error) {
if _, ok := appEnv["COMPOSE_FILE"]; !ok { if _, ok := appEnv["COMPOSE_FILE"]; !ok {
logrus.Debug("no COMPOSE_FILE detected, loading compose.yml") logrus.Debug("no COMPOSE_FILE detected, loading compose.yml")
path := fmt.Sprintf("%s/%s/compose.yml", RECIPES_DIR, recipe) path := fmt.Sprintf("%s/%s/compose.yml", APPS_DIR, recipe)
composeFiles = append(composeFiles, path) composeFiles = append(composeFiles, path)
return composeFiles, nil return composeFiles, nil
} }
@ -359,7 +345,7 @@ func GetAppComposeFiles(recipe string, appEnv AppEnv) ([]string, error) {
envVars := strings.Split(composeFileEnvVar, ":") envVars := strings.Split(composeFileEnvVar, ":")
logrus.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", ")) logrus.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", "))
for _, file := range strings.Split(composeFileEnvVar, ":") { for _, file := range strings.Split(composeFileEnvVar, ":") {
path := fmt.Sprintf("%s/%s/%s", RECIPES_DIR, recipe, file) path := fmt.Sprintf("%s/%s/%s", APPS_DIR, recipe, file)
composeFiles = append(composeFiles, path) composeFiles = append(composeFiles, path)
} }

View File

@ -26,6 +26,7 @@ func TestReadAppEnvFile(t *testing.T) {
} }
func TestGetApp(t *testing.T) { func TestGetApp(t *testing.T) {
// TODO: Test failures as well as successes
app, err := GetApp(expectedAppFiles, appName) app, err := GetApp(expectedAppFiles, appName)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@ -15,18 +15,16 @@ import (
) )
var ABRA_DIR = os.ExpandEnv("$HOME/.abra") var ABRA_DIR = os.ExpandEnv("$HOME/.abra")
var SERVERS_DIR = path.Join(ABRA_DIR, "servers") var ABRA_SERVER_FOLDER = path.Join(ABRA_DIR, "servers")
var RECIPES_DIR = path.Join(ABRA_DIR, "apps") var APPS_JSON = path.Join(ABRA_DIR, "catalogue", "recipes.json")
var VENDOR_DIR = path.Join(ABRA_DIR, "vendor") var APPS_DIR = path.Join(ABRA_DIR, "apps")
var RECIPES_JSON = path.Join(ABRA_DIR, "catalogue", "recipes.json")
var REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud" var REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud"
var SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
// GetServers retrieves all servers. // GetServers retrieves all servers.
func GetServers() ([]string, error) { func GetServers() ([]string, error) {
var servers []string var servers []string
servers, err := GetAllFoldersInDirectory(SERVERS_DIR) servers, err := GetAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil { if err != nil {
return servers, err return servers, err
} }
@ -52,13 +50,13 @@ func ReadEnv(filePath string) (AppEnv, error) {
// ReadServerNames retrieves all server names. // ReadServerNames retrieves all server names.
func ReadServerNames() ([]string, error) { func ReadServerNames() ([]string, error) {
serverNames, err := GetAllFoldersInDirectory(SERVERS_DIR) serverNames, err := GetAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil { if err != nil {
return nil, err return nil, err
} }
logrus.Debugf("read %s from %s", strings.Join(serverNames, ","), SERVERS_DIR) logrus.Debugf("read %s from %s", strings.Join(serverNames, ","), ABRA_SERVER_FOLDER)
return serverNames, nil return serverNames, nil
} }
@ -126,6 +124,17 @@ func GetAllFoldersInDirectory(directory string) ([]string, error) {
return folders, nil return folders, nil
} }
// EnsureAbraDirExists checks for the abra config folder and throws error if not
func EnsureAbraDirExists() error {
if _, err := os.Stat(ABRA_DIR); os.IsNotExist(err) {
logrus.Debugf("%s does not exist, creating it", ABRA_DIR)
if err := os.Mkdir(ABRA_DIR, 0764); err != nil {
return err
}
}
return nil
}
// ReadAbraShEnvVars reads env vars from an abra.sh recipe file. // ReadAbraShEnvVars reads env vars from an abra.sh recipe file.
func ReadAbraShEnvVars(abraSh string) (map[string]string, error) { func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
envVars := make(map[string]string) envVars := make(map[string]string)

View File

@ -44,7 +44,7 @@ var expectedAppFiles = map[string]AppFile{
// var expectedServerNames = []string{"evil.corp"} // var expectedServerNames = []string{"evil.corp"}
func TestGetAllFoldersInDirectory(t *testing.T) { func TestGetAllFoldersInDirectory(t *testing.T) {
folders, err := GetAllFoldersInDirectory(testFolder) folders, err := getAllFoldersInDirectory(testFolder)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"coopcloud.tech/abra/pkg/formatter" abraFormatter "coopcloud.tech/abra/cli/formatter"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
@ -14,8 +14,8 @@ import (
) )
// GetContainer retrieves a container. If prompt is true and the retrievd count // GetContainer retrieves a container. If prompt is true and the retrievd count
// of containers does not match 1, then a prompt is presented to let the user // of containers does not match expectedN, then a prompt is presented to let
// choose. A count of 0 is handled gracefully. // the user choose.
func GetContainer(c context.Context, cl *client.Client, filters filters.Args, prompt bool) (types.Container, error) { func GetContainer(c context.Context, cl *client.Client, filters filters.Args, prompt bool) (types.Container, error) {
containerOpts := types.ContainerListOptions{Filters: filters} containerOpts := types.ContainerListOptions{Filters: filters}
containers, err := cl.ContainerList(c, containerOpts) containers, err := cl.ContainerList(c, containerOpts)
@ -33,7 +33,7 @@ func GetContainer(c context.Context, cl *client.Client, filters filters.Args, pr
for _, container := range containers { for _, container := range containers {
containerName := strings.Join(container.Names, " ") containerName := strings.Join(container.Names, " ")
trimmed := strings.TrimPrefix(containerName, "/") trimmed := strings.TrimPrefix(containerName, "/")
created := formatter.HumanDuration(container.Created) created := abraFormatter.HumanDuration(container.Created)
containersRaw = append(containersRaw, fmt.Sprintf("%s (created %v)", trimmed, created)) containersRaw = append(containersRaw, fmt.Sprintf("%s (created %v)", trimmed, created))
} }

View File

@ -47,7 +47,7 @@ func EnsureIPv4(domainName string) (string, error) {
}, },
} }
logrus.Debugf("created DNS resolver via %s", freifunkDNS) logrus.Debugf("created DNS resolver via '%s'", freifunkDNS)
ctx := context.Background() ctx := context.Background()
ips, err := resolver.LookupIPAddr(ctx, domainName) ips, err := resolver.LookupIPAddr(ctx, domainName)
@ -94,12 +94,3 @@ func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) {
return ipv4, nil return ipv4, nil
} }
// GetTTL parses a ttl string into a duration
func GetTTL(ttl string) (time.Duration, error) {
val, err := time.ParseDuration(ttl)
if err != nil {
return val, err
}
return val, nil
}

View File

@ -1,35 +0,0 @@
package git
import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// GetCurrentBranch retrieves the current branch of a repository
func GetCurrentBranch(repository *git.Repository) (string, error) {
branchRefs, err := repository.Branches()
if err != nil {
return "", err
}
headRef, err := repository.Head()
if err != nil {
return "", err
}
var currentBranchName string
err = branchRefs.ForEach(func(branchRef *plumbing.Reference) error {
if branchRef.Hash() == headRef.Hash() {
currentBranchName = branchRef.Name().String()
return nil
}
return nil
})
if err != nil {
return "", err
}
return currentBranchName, nil
}

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -14,10 +15,10 @@ import (
// Clone runs a git clone which accounts for different default branches. // Clone runs a git clone which accounts for different default branches.
func Clone(dir, url string) error { func Clone(dir, url string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) { if _, err := os.Stat(dir); os.IsNotExist(err) {
logrus.Debugf("%s does not exist, attempting to git clone from %s", dir, url) logrus.Debugf("'%s' does not exist, attempting to git clone from '%s'", dir, url)
_, err := git.PlainClone(dir, false, &git.CloneOptions{URL: url, Tags: git.AllTags}) _, err := git.PlainClone(dir, false, &git.CloneOptions{URL: url, Tags: git.AllTags})
if err != nil { if err != nil {
logrus.Debugf("cloning %s default branch failed, attempting from main branch", url) logrus.Debugf("cloning '%s' default branch failed, attempting from main branch", url)
_, err := git.PlainClone(dir, false, &git.CloneOptions{ _, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: url, URL: url,
Tags: git.AllTags, Tags: git.AllTags,
@ -31,10 +32,77 @@ func Clone(dir, url string) error {
return err return err
} }
} }
logrus.Debugf("%s has been git cloned successfully", dir) logrus.Debugf("'%s' has been git cloned successfully", dir)
} else { } else {
logrus.Debugf("%s already exists", dir) logrus.Debugf("'%s' already exists, doing nothing", dir)
} }
return nil return nil
} }
// EnsureUpToDate ensures that a git repo on disk has the latest changes (git-fetch).
func EnsureUpToDate(dir string) error {
repo, err := git.PlainOpen(dir)
if err != nil {
return err
}
recipeName := filepath.Base(dir)
isClean, err := IsClean(recipeName)
if err != nil {
return err
}
if !isClean {
return fmt.Errorf("'%s' has locally unstaged changes", recipeName)
}
branch := "master"
if _, err := repo.Branch("master"); err != nil {
if _, err := repo.Branch("main"); err != nil {
logrus.Debugf("failed to select branch in '%s'", dir)
return err
}
branch = "main"
}
logrus.Debugf("choosing '%s' as main git branch in '%s'", branch, dir)
worktree, err := repo.Worktree()
if err != nil {
return err
}
refName := fmt.Sprintf("refs/heads/%s", branch)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out '%s' in '%s'", refName, dir)
return err
}
logrus.Debugf("successfully checked out '%s' in '%s'", branch, dir)
remote, err := repo.Remote("origin")
if err != nil {
return err
}
fetchOpts := &git.FetchOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{"refs/heads/*:refs/remotes/origin/*"},
Force: true,
}
if err := remote.Fetch(fetchOpts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return err
}
}
logrus.Debugf("successfully fetched all changes in '%s'", dir)
return nil
}

View File

@ -8,7 +8,7 @@ import (
) )
// Commit runs a git commit // Commit runs a git commit
func Commit(repoPath, glob, commitMessage string, dryRun bool) error { func Commit(repoPath, glob, commitMessage string, dryRun, push bool) error {
if commitMessage == "" { if commitMessage == "" {
return fmt.Errorf("no commit message specified?") return fmt.Errorf("no commit message specified?")
} }
@ -47,9 +47,18 @@ func Commit(repoPath, glob, commitMessage string, dryRun bool) error {
if err != nil { if err != nil {
return err return err
} }
logrus.Debug("git changes commited") logrus.Info("changes commited")
} else { } else {
logrus.Debug("dry run: no changes commited") logrus.Info("dry run: no changes commited")
}
if !dryRun && push {
if err := commitRepo.Push(&git.PushOptions{}); err != nil {
return err
}
logrus.Info("changes pushed")
} else {
logrus.Info("dry run: no changes pushed")
} }
return nil return nil

View File

@ -1,43 +0,0 @@
package git
import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/sirupsen/logrus"
)
// Push pushes the latest changes & optionally tags to the default remote
func Push(repoDir string, remote string, tags bool, dryRun bool) error {
if dryRun {
logrus.Debugf("dry run: no git changes pushed in %s", repoDir)
return nil
}
commitRepo, err := git.PlainOpen(repoDir)
if err != nil {
return err
}
opts := &git.PushOptions{}
if remote != "" {
opts.RemoteName = remote
}
if err := commitRepo.Push(opts); err != nil {
return err
}
logrus.Debugf("git changes pushed")
if tags {
opts.RefSpecs = append(opts.RefSpecs, config.RefSpec("+refs/tags/*:refs/tags/*"))
if err := commitRepo.Push(opts); err != nil {
return err
}
logrus.Debugf("git tags pushed")
}
return nil
}

View File

@ -18,7 +18,7 @@ import (
// GetRecipeHead retrieves latest HEAD metadata. // GetRecipeHead retrieves latest HEAD metadata.
func GetRecipeHead(recipeName string) (*plumbing.Reference, error) { func GetRecipeHead(recipeName string) (*plumbing.Reference, error) {
recipeDir := path.Join(config.RECIPES_DIR, recipeName) recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
repo, err := git.PlainOpen(recipeDir) repo, err := git.PlainOpen(recipeDir)
if err != nil { if err != nil {
@ -34,8 +34,10 @@ func GetRecipeHead(recipeName string) (*plumbing.Reference, error) {
} }
// IsClean checks if a repo has unstaged changes // IsClean checks if a repo has unstaged changes
func IsClean(repoPath string) (bool, error) { func IsClean(recipeName string) (bool, error) {
repo, err := git.PlainOpen(repoPath) recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
repo, err := git.PlainOpen(recipeDir)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -60,9 +62,9 @@ func IsClean(repoPath string) (bool, error) {
} }
if status.String() != "" { if status.String() != "" {
logrus.Debugf("discovered git status in %s: %s", repoPath, status.String()) logrus.Debugf("discovered git status for %s repository: %s", recipeName, status.String())
} else { } else {
logrus.Debugf("discovered clean git status in %s", repoPath) logrus.Debugf("discovered clean git status for %s repository", recipeName)
} }
return status.IsClean(), nil return status.IsClean(), nil

View File

@ -1,28 +0,0 @@
package git
import (
"strings"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/sirupsen/logrus"
)
// CreateRemote creates a new git remote in a repository
func CreateRemote(repo *git.Repository, name, url string, dryRun bool) error {
if dryRun {
logrus.Debugf("dry run: remote %s (%s) not created", name, url)
return nil
}
if _, err := repo.CreateRemote(&config.RemoteConfig{
Name: name,
URLs: []string{url},
}); err != nil {
if !strings.Contains(err.Error(), "remote already exists") {
return err
}
}
return nil
}

View File

@ -1,12 +0,0 @@
package integration
import (
"os"
"testing"
)
func skipIfNotIntegration(t *testing.T) {
if os.Getenv("ABRA_INTEGRATION") == "" {
t.Skip("missing 'ABRA_INTEGRATION', not running integration tests")
}
}

View File

@ -1,338 +0,0 @@
package lint
import (
"fmt"
"net/http"
"os"
"path"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
)
var Warn = "warn"
var Critical = "critical"
type LintFunction func(recipe.Recipe) (bool, error)
type LintRule struct {
Ref string
Level string
Description string
HowToResolve string
Function LintFunction
}
var LintRules = map[string][]LintRule{
"warn": {
{
Ref: "R001",
Level: "warn",
Description: "compose config has expected version",
HowToResolve: "ensure 'version: \"3.8\"' in compose configs",
Function: LintComposeVersion,
},
{
Ref: "R002",
Level: "warn",
Description: "healthcheck enabled for all services",
HowToResolve: "wire up healthchecks",
Function: LintHealthchecks,
},
{
Ref: "R003",
Level: "warn",
Description: "all images use a tag",
HowToResolve: "use a tag for all images",
Function: LintAllImagesTagged,
},
{
Ref: "R004",
Level: "warn",
Description: "no unstable tags",
HowToResolve: "tag all images with stable tags",
Function: LintNoUnstableTags,
},
{
Ref: "R005",
Level: "warn",
Description: "tags use semver-like format",
HowToResolve: "use semver-like tags",
Function: LintSemverLikeTags,
},
{
Ref: "R006",
Level: "warn",
Description: "has published catalogue version",
HowToResolve: "publish a recipe version to the catalogue",
Function: LintHasPublishedVersion,
},
{
Ref: "R007",
Level: "warn",
Description: "README.md metadata filled in",
HowToResolve: "fill out all the metadata",
Function: LintMetadataFilledIn,
},
},
"error": {
{
Ref: "R008",
Level: "error",
Description: ".env.sample provided",
HowToResolve: "create an example .env.sample",
Function: LintEnvConfigPresent,
},
{
Ref: "R009",
Level: "error",
Description: "one service named 'app'",
HowToResolve: "name a servce 'app'",
Function: LintAppService,
},
{
Ref: "R010",
Level: "error",
Description: "traefik routing enabled",
HowToResolve: "include \"traefik.enable=true\" deploy label",
Function: LintTraefikEnabled,
},
{
Ref: "R011",
Level: "error",
Description: "all services have images",
HowToResolve: "ensure \"image: ...\" set on all services",
Function: LintImagePresent,
},
{
Ref: "R012",
Level: "error",
Description: "config version are vendored",
HowToResolve: "vendor config versions in an abra.sh",
Function: LintAbraShVendors,
},
{
Ref: "R013",
Level: "error",
Description: "git.coopcloud.tech repo exists",
HowToResolve: "upload your recipe to git.coopcloud.tech/coop-cloud/...",
Function: LintHasRecipeRepo,
},
},
}
func LintForErrors(recipe recipe.Recipe) error {
logrus.Debugf("linting for critical errors in %s configs", recipe.Name)
for level := range LintRules {
if level != "error" {
continue
}
for _, rule := range LintRules[level] {
ok, err := rule.Function(recipe)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("lint error in %s configs: \"%s\" failed lint checks (%s)", recipe.Name, rule.Description, rule.Ref)
}
}
}
logrus.Debugf("linting successful, %s is well configured", recipe.Name)
return nil
}
func LintComposeVersion(recipe recipe.Recipe) (bool, error) {
if recipe.Config.Version == "3.8" {
return true, nil
}
return true, nil
}
func LintEnvConfigPresent(recipe recipe.Recipe) (bool, error) {
envSample := fmt.Sprintf("%s/%s/.env.sample", config.RECIPES_DIR, recipe.Name)
if _, err := os.Stat(envSample); !os.IsNotExist(err) {
return true, nil
}
return false, nil
}
func LintAppService(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
if service.Name == "app" {
return true, nil
}
}
return false, nil
}
func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
for label := range service.Deploy.Labels {
if label == "traefik.enable" {
if service.Deploy.Labels[label] == "true" {
return true, nil
}
}
}
}
return false, nil
}
func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
if service.HealthCheck == nil {
return false, nil
}
}
return true, nil
}
func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
if reference.IsNameOnly(img) {
return false, nil
}
}
return true, nil
}
func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
return false, nil
}
if tag == "latest" {
return false, nil
}
}
return true, nil
}
func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
return false, nil
}
if !tagcmp.IsParsable(tag) {
return false, nil
}
}
return true, nil
}
func LintImagePresent(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
if service.Image == "" {
return false, nil
}
}
return true, nil
}
func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) {
catl, err := recipePkg.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
if err != nil {
logrus.Fatal(err)
}
if len(versions) == 0 {
return false, nil
}
return true, nil
}
func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
features, category, err := recipe.GetRecipeFeaturesAndCategory(r.Name)
if err != nil {
return false, err
}
if category == "" {
return false, nil
}
if features.Backups == "" ||
features.Email == "" ||
features.Healthcheck == "" ||
features.Image.Image == "" ||
features.SSO == "" {
return false, nil
}
return true, nil
}
func LintAbraShVendors(recipe recipe.Recipe) (bool, error) {
for _, service := range recipe.Config.Services {
if len(service.Configs) > 0 {
abraSh := path.Join(config.RECIPES_DIR, recipe.Name, "abra.sh")
if _, err := os.Stat(abraSh); err != nil {
if os.IsNotExist(err) {
return false, err
}
return false, err
}
}
}
return true, nil
}
func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) {
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipe.Name)
res, err := http.Get(url)
if err != nil {
return false, err
}
if res.StatusCode != 200 {
return false, err
}
return true, nil
}

View File

@ -1,161 +1,32 @@
package recipe package recipe
import ( import (
"encoding/json"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/compose" "coopcloud.tech/abra/pkg/compose"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git" gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/upstream/stack" "coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack" loader "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/abra/pkg/web"
composetypes "github.com/docker/cli/cli/compose/types" composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/distribution/reference"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// RecipeCatalogueURL is the only current recipe catalogue available.
const RecipeCatalogueURL = "https://apps.coopcloud.tech"
// ReposMetadataURL is the recipe repository metadata
const ReposMetadataURL = "https://git.coopcloud.tech/api/v1/orgs/coop-cloud/repos"
// tag represents a git tag.
type tag = string
// service represents a service within a recipe.
type service = string
// ServiceMeta represents meta info associated with a service.
type ServiceMeta struct {
Digest string `json:"digest"`
Image string `json:"image"`
Tag string `json:"tag"`
}
// RecipeVersions are the versions associated with a recipe.
type RecipeVersions []map[tag]map[service]ServiceMeta
// RecipeMeta represents metadata for a recipe in the abra catalogue.
type RecipeMeta struct {
Category string `json:"category"`
DefaultBranch string `json:"default_branch"`
Description string `json:"description"`
Features Features `json:"features"`
Icon string `json:"icon"`
Name string `json:"name"`
Repository string `json:"repository"`
SSHURL string `json:"ssh_url"`
Versions RecipeVersions `json:"versions"`
Website string `json:"website"`
}
// LatestVersion returns the latest version of a recipe.
func (r RecipeMeta) LatestVersion() string {
var version string
// apps.json versions are sorted so the last key is latest
latest := r.Versions[len(r.Versions)-1]
for tag := range latest {
version = tag
}
logrus.Debugf("choosing %s as latest version of %s", version, r.Name)
return version
}
// Name represents a recipe name.
type Name = string
// RecipeCatalogue represents the entire recipe catalogue.
type RecipeCatalogue map[Name]RecipeMeta
// Flatten converts AppCatalogue to slice
func (r RecipeCatalogue) Flatten() []RecipeMeta {
recipes := make([]RecipeMeta, 0, len(r))
for name := range r {
recipes = append(recipes, r[name])
}
return recipes
}
// ByRecipeName sorts recipes by name.
type ByRecipeName []RecipeMeta
func (r ByRecipeName) Len() int { return len(r) }
func (r ByRecipeName) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
func (r ByRecipeName) Less(i, j int) bool {
return strings.ToLower(r[i].Name) < strings.ToLower(r[j].Name)
}
// Image represents a recipe container image.
type Image struct {
Image string `json:"image"`
Rating string `json:"rating"`
Source string `json:"source"`
URL string `json:"url"`
}
// Features represent what top-level features a recipe supports (e.g. does this recipe support backups?).
type Features struct {
Backups string `json:"backups"`
Email string `json:"email"`
Healthcheck string `json:"healthcheck"`
Image Image `json:"image"`
Status int `json:"status"`
Tests string `json:"tests"`
SSO string `json:"sso"`
}
// Recipe represents a recipe. // Recipe represents a recipe.
type Recipe struct { type Recipe struct {
Name string Name string
Config *composetypes.Config Config *composetypes.Config
Meta RecipeMeta
}
// Push pushes the latest changes to a SSH URL remote. You need to have your
// local SSH configuration for git.coopcloud.tech working for this to work
func (r Recipe) Push(dryRun bool) error {
repo, err := git.PlainOpen(r.Dir())
if err != nil {
return err
}
if err := gitPkg.CreateRemote(repo, "origin-ssh", r.Meta.SSHURL, dryRun); err != nil {
return err
}
if err := gitPkg.Push(r.Dir(), "origin-ssh", true, dryRun); err != nil {
return err
}
return nil
}
// Dir retrieves the recipe repository path
func (r Recipe) Dir() string {
return path.Join(config.RECIPES_DIR, r.Name)
} }
// UpdateLabel updates a recipe label // UpdateLabel updates a recipe label
func (r Recipe) UpdateLabel(pattern, serviceName, label string) error { func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
fullPattern := fmt.Sprintf("%s/%s/%s", config.RECIPES_DIR, r.Name, pattern) fullPattern := fmt.Sprintf("%s/%s/%s", config.APPS_DIR, r.Name, pattern)
if err := compose.UpdateLabel(fullPattern, serviceName, label, r.Name); err != nil { if err := compose.UpdateLabel(fullPattern, serviceName, label, r.Name); err != nil {
return err return err
} }
@ -164,7 +35,7 @@ func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
// UpdateTag updates a recipe tag // UpdateTag updates a recipe tag
func (r Recipe) UpdateTag(image, tag string) error { func (r Recipe) UpdateTag(image, tag string) error {
pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, r.Name) pattern := fmt.Sprintf("%s/%s/compose**yml", config.APPS_DIR, r.Name)
if err := compose.UpdateTag(pattern, image, tag, r.Name); err != nil { if err := compose.UpdateTag(pattern, image, tag, r.Name); err != nil {
return err return err
} }
@ -175,7 +46,8 @@ func (r Recipe) UpdateTag(image, tag string) error {
func (r Recipe) Tags() ([]string, error) { func (r Recipe) Tags() ([]string, error) {
var tags []string var tags []string
repo, err := git.PlainOpen(r.Dir()) recipeDir := path.Join(config.ABRA_DIR, "apps", r.Name)
repo, err := git.PlainOpen(recipeDir)
if err != nil { if err != nil {
return tags, err return tags, err
} }
@ -192,7 +64,7 @@ func (r Recipe) Tags() ([]string, error) {
return tags, err return tags, err
} }
logrus.Debugf("detected %s as tags for recipe %s", strings.Join(tags, ", "), r.Name) logrus.Debugf("detected '%s' as tags for recipe '%s'", strings.Join(tags, ", "), r.Name)
return tags, nil return tags, nil
} }
@ -203,7 +75,7 @@ func Get(recipeName string) (Recipe, error) {
return Recipe{}, err return Recipe{}, err
} }
pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, recipeName) pattern := fmt.Sprintf("%s/%s/compose**yml", config.APPS_DIR, recipeName)
composeFiles, err := filepath.Glob(pattern) composeFiles, err := filepath.Glob(pattern)
if err != nil { if err != nil {
return Recipe{}, err return Recipe{}, err
@ -213,7 +85,7 @@ func Get(recipeName string) (Recipe, error) {
return Recipe{}, fmt.Errorf("%s is missing a compose.yml or compose.*.yml file?", recipeName) return Recipe{}, fmt.Errorf("%s is missing a compose.yml or compose.*.yml file?", recipeName)
} }
envSamplePath := path.Join(config.RECIPES_DIR, recipeName, ".env.sample") envSamplePath := path.Join(config.ABRA_DIR, "apps", recipeName, ".env.sample")
sampleEnv, err := config.ReadEnv(envSamplePath) sampleEnv, err := config.ReadEnv(envSamplePath)
if err != nil { if err != nil {
return Recipe{}, err return Recipe{}, err
@ -225,25 +97,16 @@ func Get(recipeName string) (Recipe, error) {
return Recipe{}, err return Recipe{}, err
} }
meta, err := GetRecipeMeta(recipeName) return Recipe{Name: recipeName, Config: config}, nil
if err != nil {
return Recipe{}, err
}
return Recipe{
Name: recipeName,
Config: config,
Meta: meta,
}, nil
} }
// EnsureExists ensures that a recipe is locally cloned // EnsureExists ensures that a recipe is locally cloned
func EnsureExists(recipeName string) error { func EnsureExists(recipe string) error {
recipeDir := path.Join(config.RECIPES_DIR, recipeName) recipeDir := path.Join(config.ABRA_DIR, "apps", strings.ToLower(recipe))
if _, err := os.Stat(recipeDir); os.IsNotExist(err) { if _, err := os.Stat(recipeDir); os.IsNotExist(err) {
logrus.Debugf("%s does not exist, attemmpting to clone", recipeDir) logrus.Debugf("%s does not exist, attemmpting to clone", recipeDir)
url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipeName) url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, recipe)
if err := gitPkg.Clone(recipeDir, url); err != nil { if err := gitPkg.Clone(recipeDir, url); err != nil {
return err return err
} }
@ -258,15 +121,15 @@ func EnsureExists(recipeName string) error {
// EnsureVersion checks whether a specific version exists for a recipe. // EnsureVersion checks whether a specific version exists for a recipe.
func EnsureVersion(recipeName, version string) error { func EnsureVersion(recipeName, version string) error {
recipeDir := path.Join(config.RECIPES_DIR, recipeName) recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
isClean, err := gitPkg.IsClean(recipeDir) isClean, err := gitPkg.IsClean(recipeName)
if err != nil { if err != nil {
return err return err
} }
if !isClean { if !isClean {
return fmt.Errorf("%s has locally unstaged changes", recipeName) return fmt.Errorf("'%s' has locally unstaged changes", recipeName)
} }
if err := gitPkg.EnsureGitRepo(recipeDir); err != nil { if err := gitPkg.EnsureGitRepo(recipeDir); err != nil {
@ -298,7 +161,7 @@ func EnsureVersion(recipeName, version string) error {
logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName) logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName)
if tagRef.String() == "" { if tagRef.String() == "" {
logrus.Warnf("no published release discovered for %s", recipeName) logrus.Warnf("%s recipe has no local tag: %s? this recipe version is not released?", recipeName, version)
return nil return nil
} }
@ -321,11 +184,11 @@ func EnsureVersion(recipeName, version string) error {
return nil return nil
} }
// EnsureLatest makes sure the latest commit is checked out for a local recipe repository // EnsureLatest makes sure the latest commit is checkout on for a local recipe repository.
func EnsureLatest(recipeName string) error { func EnsureLatest(recipeName string) error {
recipeDir := path.Join(config.RECIPES_DIR, recipeName) recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
isClean, err := gitPkg.IsClean(recipeDir) isClean, err := gitPkg.IsClean(recipeName)
if err != nil { if err != nil {
return err return err
} }
@ -350,15 +213,20 @@ func EnsureLatest(recipeName string) error {
return err return err
} }
branch, err := gitPkg.GetCurrentBranch(repo) branch := "master"
if err != nil { if _, err := repo.Branch("master"); err != nil {
return err if _, err := repo.Branch("main"); err != nil {
logrus.Debugf("failed to select branch in %s", path.Join(config.APPS_DIR, recipeName))
return err
}
branch = "main"
} }
refName := fmt.Sprintf("refs/heads/%s", branch)
checkOutOpts := &git.CheckoutOptions{ checkOutOpts := &git.CheckoutOptions{
Create: false, Create: false,
Force: true, Force: true,
Branch: plumbing.ReferenceName(branch), Branch: plumbing.ReferenceName(refName),
} }
if err := worktree.Checkout(checkOutOpts); err != nil { if err := worktree.Checkout(checkOutOpts); err != nil {
@ -378,10 +246,9 @@ func ChaosVersion(recipeName string) (string, error) {
return version, err return version, err
} }
version = formatter.SmallSHA(head.String()) version = head.String()[:8]
recipeDir := path.Join(config.RECIPES_DIR, recipeName) isClean, err := gitPkg.IsClean(recipeName)
isClean, err := gitPkg.IsClean(recipeDir)
if err != nil { if err != nil {
return version, err return version, err
} }
@ -397,7 +264,7 @@ func ChaosVersion(recipeName string) (string, error) {
func GetRecipesLocal() ([]string, error) { func GetRecipesLocal() ([]string, error) {
var recipes []string var recipes []string
recipes, err := config.GetAllFoldersInDirectory(config.RECIPES_DIR) recipes, err := config.GetAllFoldersInDirectory(config.APPS_DIR)
if err != nil { if err != nil {
return recipes, err return recipes, err
} }
@ -418,626 +285,8 @@ func GetVersionLabelLocal(recipe Recipe) (string, error) {
} }
if label == "" { if label == "" {
return label, fmt.Errorf("%s has no version label? try running \"abra recipe sync %s\" first?", recipe.Name, recipe.Name) return label, fmt.Errorf("unable to retrieve synced version label for %s", recipe.Name)
} }
return label, nil return label, nil
} }
func GetRecipeFeaturesAndCategory(recipeName string) (Features, string, error) {
feat := Features{}
var category string
readmePath := path.Join(config.RECIPES_DIR, recipeName, "README.md")
logrus.Debugf("attempting to open %s for recipe metadata parsing", readmePath)
readmeFS, err := ioutil.ReadFile(readmePath)
if err != nil {
return feat, category, err
}
readmeMetadata, err := GetStringInBetween( // Find text between delimiters
recipeName,
string(readmeFS),
"<!-- metadata -->", "<!-- endmetadata -->",
)
if err != nil {
return feat, category, err
}
readmeLines := strings.Split( // Array item from lines
strings.ReplaceAll( // Remove \t tabs
readmeMetadata, "\t", "",
),
"\n")
for _, val := range readmeLines {
if strings.Contains(val, "**Category**") {
category = strings.TrimSpace(
strings.TrimPrefix(val, "* **Category**:"),
)
}
if strings.Contains(val, "**Backups**") {
feat.Backups = strings.TrimSpace(
strings.TrimPrefix(val, "* **Backups**:"),
)
}
if strings.Contains(val, "**Email**") {
feat.Email = strings.TrimSpace(
strings.TrimPrefix(val, "* **Email**:"),
)
}
if strings.Contains(val, "**SSO**") {
feat.SSO = strings.TrimSpace(
strings.TrimPrefix(val, "* **SSO**:"),
)
}
if strings.Contains(val, "**Healthcheck**") {
feat.Healthcheck = strings.TrimSpace(
strings.TrimPrefix(val, "* **Healthcheck**:"),
)
}
if strings.Contains(val, "**Tests**") {
feat.Tests = strings.TrimSpace(
strings.TrimPrefix(val, "* **Tests**:"),
)
}
if strings.Contains(val, "**Image**") {
imageMetadata, err := GetImageMetadata(strings.TrimSpace(
strings.TrimPrefix(val, "* **Image**:"),
), recipeName)
if err != nil {
continue
}
feat.Image = imageMetadata
}
}
return feat, category, nil
}
func GetImageMetadata(imageRowString, recipeName string) (Image, error) {
img := Image{}
imgFields := strings.Split(imageRowString, ",")
for i, elem := range imgFields {
imgFields[i] = strings.TrimSpace(elem)
}
if len(imgFields) < 3 {
if imageRowString != "" {
logrus.Warnf("%s image meta has incorrect format: %s", recipeName, imageRowString)
} else {
logrus.Warnf("%s image meta is empty?", recipeName)
}
return img, nil
}
img.Rating = imgFields[1]
img.Source = imgFields[2]
imgString := imgFields[0]
imageName, err := GetStringInBetween(recipeName, imgString, "[", "]")
if err != nil {
logrus.Fatal(err)
}
img.Image = strings.ReplaceAll(imageName, "`", "")
imageURL, err := GetStringInBetween(recipeName, imgString, "(", ")")
if err != nil {
logrus.Fatal(err)
}
img.URL = imageURL
return img, nil
}
// GetStringInBetween returns empty string if no start or end string found
func GetStringInBetween(recipeName, str, start, end string) (result string, err error) {
s := strings.Index(str, start)
if s == -1 {
return "", fmt.Errorf("%s: marker string %s not found", recipeName, start)
}
s += len(start)
e := strings.Index(str[s:], end)
if e == -1 {
return "", fmt.Errorf("%s: end marker %s not found", recipeName, end)
}
return str[s : s+e], nil
}
// EnsureUpToDate ensures that the local repo is synced to the remote
func EnsureUpToDate(recipeName string) error {
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
isClean, err := gitPkg.IsClean(recipeDir)
if err != nil {
return err
}
if !isClean {
return fmt.Errorf("%s has locally unstaged changes", recipeName)
}
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return err
}
remotes, err := repo.Remotes()
if err != nil {
return err
}
if len(remotes) == 0 {
logrus.Debugf("cannot ensure %s is up-to-date, no git remotes configured", recipeName)
return nil
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
branch, err := CheckoutDefaultBranch(repo, recipeName)
if err != nil {
return err
}
opts := &git.PullOptions{
Force: true,
ReferenceName: branch,
}
if err := worktree.Pull(opts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return err
}
}
logrus.Debugf("fetched latest git changes for %s", recipeName)
return nil
}
func GetDefaultBranch(repo *git.Repository, recipeName string) (plumbing.ReferenceName, error) {
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
branch := "master"
if _, err := repo.Branch("master"); err != nil {
if _, err := repo.Branch("main"); err != nil {
logrus.Debugf("failed to select branch in %s", recipeDir)
return "", err
}
branch = "main"
}
return plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)), nil
}
func CheckoutDefaultBranch(repo *git.Repository, recipeName string) (plumbing.ReferenceName, error) {
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
branch, err := GetDefaultBranch(repo, recipeName)
if err != nil {
return plumbing.ReferenceName(""), err
}
worktree, err := repo.Worktree()
if err != nil {
return plumbing.ReferenceName(""), err
}
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: branch,
}
if err := worktree.Checkout(checkOutOpts); err != nil {
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
logrus.Debugf("failed to check out %s in %s", branch, recipeDir)
return branch, err
}
logrus.Debugf("successfully checked out %v in %s", branch, recipeDir)
return branch, nil
}
// recipeCatalogueFSIsLatest checks whether the recipe catalogue stored locally
// is up to date.
func recipeCatalogueFSIsLatest() (bool, error) {
httpClient := web.NewHTTPRetryClient()
res, err := httpClient.Head(RecipeCatalogueURL)
if err != nil {
return false, err
}
lastModified := res.Header["Last-Modified"][0]
parsed, err := time.Parse(time.RFC1123, lastModified)
if err != nil {
return false, err
}
info, err := os.Stat(config.RECIPES_JSON)
if err != nil {
if os.IsNotExist(err) {
logrus.Debugf("no recipe catalogue found in file system cache")
return false, nil
}
return false, err
}
localModifiedTime := info.ModTime().Unix()
remoteModifiedTime := parsed.Unix()
if localModifiedTime < remoteModifiedTime {
logrus.Debug("file system cached recipe catalogue is out-of-date")
return false, nil
}
logrus.Debug("file system cached recipe catalogue is now up-to-date")
return true, nil
}
// ReadRecipeCatalogue reads the recipe catalogue.
func ReadRecipeCatalogue() (RecipeCatalogue, error) {
recipes := make(RecipeCatalogue)
recipeFSIsLatest, err := recipeCatalogueFSIsLatest()
if err != nil {
return nil, err
}
if !recipeFSIsLatest {
logrus.Debugf("reading recipe catalogue from web to get latest")
if err := readRecipeCatalogueWeb(&recipes); err != nil {
return nil, err
}
return recipes, nil
}
logrus.Debugf("reading recipe catalogue from file system cache to get latest")
if err := readRecipeCatalogueFS(&recipes); err != nil {
return nil, err
}
return recipes, nil
}
// readRecipeCatalogueFS reads the catalogue from the file system.
func readRecipeCatalogueFS(target interface{}) error {
recipesJSONFS, err := ioutil.ReadFile(config.RECIPES_JSON)
if err != nil {
return err
}
if err := json.Unmarshal(recipesJSONFS, &target); err != nil {
return err
}
logrus.Debugf("read recipe catalogue from file system cache in %s", config.RECIPES_JSON)
return nil
}
// readRecipeCatalogueWeb reads the catalogue from the web.
func readRecipeCatalogueWeb(target interface{}) error {
if err := web.ReadJSON(RecipeCatalogueURL, &target); err != nil {
return err
}
recipesJSON, err := json.MarshalIndent(target, "", " ")
if err != nil {
return err
}
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil {
return err
}
logrus.Debugf("read recipe catalogue from web at %s", RecipeCatalogueURL)
return nil
}
// VersionsOfService lists the version of a service.
func VersionsOfService(recipe, serviceName string) ([]string, error) {
var versions []string
catalogue, err := ReadRecipeCatalogue()
if err != nil {
return nil, err
}
rec, ok := catalogue[recipe]
if !ok {
return versions, nil
}
alreadySeen := make(map[string]bool)
for _, serviceVersion := range rec.Versions {
for tag := range serviceVersion {
if _, ok := alreadySeen[tag]; !ok {
alreadySeen[tag] = true
versions = append(versions, tag)
}
}
}
logrus.Debugf("detected versions %s for %s", strings.Join(versions, ", "), recipe)
return versions, nil
}
// GetRecipeMeta retrieves the recipe metadata from the recipe catalogue.
func GetRecipeMeta(recipeName string) (RecipeMeta, error) {
catl, err := ReadRecipeCatalogue()
if err != nil {
return RecipeMeta{}, err
}
recipeMeta, ok := catl[recipeName]
if !ok {
err := fmt.Errorf("recipe %s does not exist?", recipeName)
return RecipeMeta{}, err
}
if err := EnsureExists(recipeName); err != nil {
return RecipeMeta{}, err
}
logrus.Debugf("recipe metadata retrieved for %s", recipeName)
return recipeMeta, nil
}
// RepoMeta is a single recipe repo metadata.
type RepoMeta struct {
ID int `json:"id"`
Owner Owner
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Empty bool `json:"empty"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Template bool `json:"template"`
Parent interface{} `json:"parent"`
Mirror bool `json:"mirror"`
Size int `json:"size"`
HTMLURL string `json:"html_url"`
SSHURL string `json:"ssh_url"`
CloneURL string `json:"clone_url"`
OriginalURL string `json:"original_url"`
Website string `json:"website"`
StarsCount int `json:"stars_count"`
ForksCount int `json:"forks_count"`
WatchersCount int `json:"watchers_count"`
OpenIssuesCount int `json:"open_issues_count"`
OpenPRCount int `json:"open_pr_counter"`
ReleaseCounter int `json:"release_counter"`
DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Permissions Permissions
HasIssues bool `json:"has_issues"`
InternalTracker InternalTracker
HasWiki bool `json:"has_wiki"`
HasPullRequests bool `json:"has_pull_requests"`
HasProjects bool `json:"has_projects"`
IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"`
AllowMergeCommits bool `json:"allow_merge_commits"`
AllowRebase bool `json:"allow_rebase"`
AllowRebaseExplicit bool `json:"allow_rebase_explicit"`
AllowSquashMerge bool `json:"allow_squash_merge"`
AvatarURL string `json:"avatar_url"`
Internal bool `json:"internal"`
MirrorInterval string `json:"mirror_interval"`
}
// Owner is the repo organisation owner metadata.
type Owner struct {
ID int `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Language string `json:"language"`
IsAdmin bool `json:"is_admin"`
LastLogin string `json:"last_login"`
Created string `json:"created"`
Restricted bool `json:"restricted"`
Username string `json:"username"`
}
// Permissions is perms metadata for a repo.
type Permissions struct {
Admin bool `json:"admin"`
Push bool `json:"push"`
Pull bool `json:"pull"`
}
// InternalTracker is issue tracker metadata for a repo.
type InternalTracker struct {
EnableTimeTracker bool `json:"enable_time_tracker"`
AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"`
EnableIssuesDependencies bool `json:"enable_issue_dependencies"`
}
// RepoCatalogue represents all the recipe repo metadata.
type RepoCatalogue map[string]RepoMeta
// ReadReposMetadata retrieves coop-cloud/... repo metadata from Gitea.
func ReadReposMetadata() (RepoCatalogue, error) {
reposMeta := make(RepoCatalogue)
pageIdx := 1
bar := formatter.CreateProgressbar(-1, "retrieving recipe repos list from git.coopcloud.tech...")
for {
var reposList []RepoMeta
pagedURL := fmt.Sprintf("%s?page=%v", ReposMetadataURL, pageIdx)
logrus.Debugf("fetching repo metadata from %s", pagedURL)
if err := web.ReadJSON(pagedURL, &reposList); err != nil {
return reposMeta, err
}
if len(reposList) == 0 {
bar.Add(1)
break
}
for idx, repo := range reposList {
reposMeta[repo.Name] = reposList[idx]
}
pageIdx++
bar.Add(1)
}
fmt.Println() // newline for spinner
return reposMeta, nil
}
// GetRecipeVersions retrieves all recipe versions.
func GetRecipeVersions(recipeName, registryUsername, registryPassword string) (RecipeVersions, error) {
versions := RecipeVersions{}
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
logrus.Debugf("attempting to open git repository in %s", recipeDir)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return versions, err
}
worktree, err := repo.Worktree()
if err != nil {
logrus.Fatal(err)
}
gitTags, err := repo.Tags()
if err != nil {
return versions, err
}
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
logrus.Debugf("processing %s for %s", tag, recipeName)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(ref.Name()),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
logrus.Debugf("failed to check out %s in %s", tag, recipeDir)
return err
}
logrus.Debugf("successfully checked out %s in %s", ref.Name(), recipeDir)
recipe, err := Get(recipeName)
if err != nil {
return err
}
cl, err := client.New("default") // only required for docker.io registry calls
if err != nil {
logrus.Fatal(err)
}
queryCache := make(map[reference.Named]string)
versionMeta := make(map[string]ServiceMeta)
for _, service := range recipe.Config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return err
}
path := reference.Path(img)
if strings.Contains(path, "library") {
path = strings.Split(path, "/")[1]
}
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
logrus.Warnf("%s service is missing image tag?", path)
continue
}
var exists bool
var digest string
if digest, exists = queryCache[img]; !exists {
logrus.Debugf("looking up image: %s from %s", img, path)
var err error
digest, err = client.GetTagDigest(cl, img, registryUsername, registryPassword)
if err != nil {
logrus.Warn(err)
continue
}
logrus.Debugf("queried for image: %s, tag: %s, digest: %s", path, tag, digest)
queryCache[img] = digest
logrus.Debugf("cached image: %s, tag: %s, digest: %s", path, tag, digest)
} else {
logrus.Debugf("reading image: %s, tag: %s, digest: %s from cache", path, tag, digest)
}
versionMeta[service.Name] = ServiceMeta{
Digest: digest,
Image: path,
Tag: img.(reference.NamedTagged).Tag(),
}
}
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
return nil
}); err != nil {
return versions, err
}
_, err = CheckoutDefaultBranch(repo, recipeName)
if err != nil {
return versions, err
}
logrus.Debugf("collected %s for %s", versions, recipeName)
return versions, nil
}
// GetRecipeCatalogueVersions list the recipe versions listed in the recipe catalogue.
func GetRecipeCatalogueVersions(recipeName string, catl RecipeCatalogue) ([]string, error) {
var versions []string
if recipeMeta, exists := catl[recipeName]; exists {
for _, versionMeta := range recipeMeta.Versions {
for tag := range versionMeta {
versions = append(versions, tag)
}
}
}
return versions, nil
}

View File

@ -19,13 +19,13 @@ func PassInsertSecret(secretValue, secretName, appName, server string) error {
secretValue, server, appName, secretName, secretValue, server, appName, secretName,
) )
logrus.Debugf("attempting to run %s", cmd) logrus.Debugf("attempting to run '%s'", cmd)
if err := exec.Command("bash", "-c", cmd).Run(); err != nil { if err := exec.Command("bash", "-c", cmd).Run(); err != nil {
return err return err
} }
logrus.Infof("%s inserted into pass store", secretName) logrus.Infof("'%s' inserted into pass store", secretName)
return nil return nil
} }
@ -41,13 +41,13 @@ func PassRmSecret(secretName, appName, server string) error {
server, appName, secretName, server, appName, secretName,
) )
logrus.Debugf("attempting to run %s", cmd) logrus.Debugf("attempting to run '%s'", cmd)
if err := exec.Command("bash", "-c", cmd).Run(); err != nil { if err := exec.Command("bash", "-c", cmd).Run(); err != nil {
return err return err
} }
logrus.Infof("%s removed from pass store", secretName) logrus.Infof("'%s' removed from pass store", secretName)
return nil return nil
} }

View File

@ -34,7 +34,7 @@ func GeneratePasswords(count, length uint) ([]string, error) {
return nil, err return nil, err
} }
logrus.Debugf("generated %s", strings.Join(passwords, ", ")) logrus.Debugf("generated '%s'", strings.Join(passwords, ", "))
return passwords, nil return passwords, nil
} }
@ -53,7 +53,7 @@ func GeneratePassphrases(count uint) ([]string, error) {
return nil, err return nil, err
} }
logrus.Debugf("generated %s", strings.Join(passphrases, ", ")) logrus.Debugf("generated '%s'", strings.Join(passphrases, ", "))
return passphrases, nil return passphrases, nil
} }
@ -69,32 +69,35 @@ func ReadSecretEnvVars(appEnv config.AppEnv) map[string]string {
} }
} }
logrus.Debugf("read %s as secrets from %s", secretEnvVars, appEnv) logrus.Debugf("read '%s' as secrets from '%s'", secretEnvVars, appEnv)
return secretEnvVars return secretEnvVars
} }
// TODO: should probably go in the config/app package?
func ParseSecretEnvVarName(secretEnvVar string) string { func ParseSecretEnvVarName(secretEnvVar string) string {
withoutPrefix := strings.TrimPrefix(secretEnvVar, "SECRET_") withoutPrefix := strings.TrimPrefix(secretEnvVar, "SECRET_")
withoutSuffix := strings.TrimSuffix(withoutPrefix, "_VERSION") withoutSuffix := strings.TrimSuffix(withoutPrefix, "_VERSION")
name := strings.ToLower(withoutSuffix) name := strings.ToLower(withoutSuffix)
logrus.Debugf("parsed %s as name from %s", name, secretEnvVar) logrus.Debugf("parsed '%s' as name from '%s'", name, secretEnvVar)
return name return name
} }
// TODO: should probably go in the config/app package?
func ParseGeneratedSecretName(secret string, appEnv config.App) string { func ParseGeneratedSecretName(secret string, appEnv config.App) string {
name := fmt.Sprintf("%s_", appEnv.StackName()) name := fmt.Sprintf("%s_", appEnv.StackName())
withoutAppName := strings.TrimPrefix(secret, name) withoutAppName := strings.TrimPrefix(secret, name)
idx := strings.LastIndex(withoutAppName, "_") idx := strings.LastIndex(withoutAppName, "_")
parsed := withoutAppName[:idx] parsed := withoutAppName[:idx]
logrus.Debugf("parsed %s as name from %s", parsed, secret) logrus.Debugf("parsed '%s' as name from '%s'", parsed, secret)
return parsed return parsed
} }
// TODO: should probably go in the config/app package?
func ParseSecretEnvVarValue(secret string) (secretValue, error) { func ParseSecretEnvVarValue(secret string) (secretValue, error) {
values := strings.Split(secret, "#") values := strings.Split(secret, "#")
if len(values) == 0 { if len(values) == 0 {
return secretValue{}, fmt.Errorf("unable to parse %s", secret) return secretValue{}, fmt.Errorf("unable to parse '%s'", secret)
} }
if len(values) == 1 { if len(values) == 1 {
@ -110,7 +113,7 @@ func ParseSecretEnvVarValue(secret string) (secretValue, error) {
} }
version := strings.ReplaceAll(values[0], " ", "") version := strings.ReplaceAll(values[0], " ", "")
logrus.Debugf("parsed version %s and length '%v' from %s", version, length, secret) logrus.Debugf("parsed version '%s' and length '%v' from '%s'", version, length, secret)
return secretValue{Version: version, Length: length}, nil return secretValue{Version: version, Length: length}, nil
} }
@ -129,7 +132,7 @@ func GenerateSecrets(secretEnvVars map[string]string, appName, server string) (m
return return
} }
secretRemoteName := fmt.Sprintf("%s_%s_%s", appName, secretName, secretValue.Version) secretRemoteName := fmt.Sprintf("%s_%s_%s", appName, secretName, secretValue.Version)
logrus.Debugf("attempting to generate and store %s on %s", secretRemoteName, server) logrus.Debugf("attempting to generate and store '%s' on '%s'", secretRemoteName, server)
if secretValue.Length > 0 { if secretValue.Length > 0 {
passwords, err := GeneratePasswords(1, uint(secretValue.Length)) passwords, err := GeneratePasswords(1, uint(secretValue.Length))
if err != nil { if err != nil {
@ -174,7 +177,7 @@ func GenerateSecrets(secretEnvVars map[string]string, appName, server string) (m
} }
} }
logrus.Debugf("generated and stored %s on %s", secrets, server) logrus.Debugf("generated and stored '%s' on '%s'", secrets, server)
return secrets, nil return secrets, nil
} }

View File

@ -1,78 +0,0 @@
package service
import (
"context"
"fmt"
"strings"
"coopcloud.tech/abra/pkg/formatter"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// GetService retrieves a service container. If prompt is true and the retrievd
// count of service containers does not match 1, then a prompt is presented to
// let the user choose. A count of 0 is handled gracefully.
func GetService(c context.Context, cl *client.Client, filters filters.Args, prompt bool) (swarm.Service, error) {
serviceOpts := types.ServiceListOptions{Filters: filters}
services, err := cl.ServiceList(c, serviceOpts)
if err != nil {
return swarm.Service{}, err
}
if len(services) == 0 {
filter := filters.Get("name")[0]
return swarm.Service{}, fmt.Errorf("no services matching the %v filter found?", filter)
}
if len(services) != 1 {
var servicesRaw []string
for _, service := range services {
serviceName := service.Spec.Name
created := formatter.HumanDuration(service.CreatedAt.Unix())
servicesRaw = append(servicesRaw, fmt.Sprintf("%s (created %v)", serviceName, created))
}
if !prompt {
err := fmt.Errorf("expected 1 service but found %v: %s", len(services), strings.Join(servicesRaw, " "))
return swarm.Service{}, err
}
logrus.Warnf("ambiguous service list received, prompting for input")
var response string
prompt := &survey.Select{
Message: "which service are you looking for?",
Options: servicesRaw,
}
if err := survey.AskOne(prompt, &response); err != nil {
return swarm.Service{}, err
}
chosenService := strings.TrimSpace(strings.Split(response, " ")[0])
for _, service := range services {
serviceName := strings.ToLower(service.Spec.Name)
if serviceName == chosenService {
return service, nil
}
}
logrus.Panic("failed to match chosen service")
}
return services[0], nil
}
// ContainerToServiceName converts a container name to a service name.
func ContainerToServiceName(containerNames []string, stackName string) string {
containerName := strings.Join(containerNames, "")
trimmed := strings.TrimPrefix(containerName, "/")
stackNameServiceName := strings.Split(trimmed, ".")[0]
splitter := fmt.Sprintf("%s_", stackName)
return strings.Split(stackNameServiceName, splitter)[1]
}

View File

@ -475,10 +475,11 @@ func GetContextConnDetails(serverName string) (*dockerSSHPkg.Spec, error) {
func GetHostConfig(hostname, username, port string) (HostConfig, error) { func GetHostConfig(hostname, username, port string) (HostConfig, error) {
var hostConfig HostConfig var hostConfig HostConfig
if hostname == "" { var host, idf string
if hostname = ssh_config.Get(hostname, "Hostname"); hostname == "" {
logrus.Debugf("no hostname found in SSH config, assuming %s", hostname) if host = ssh_config.Get(hostname, "Hostname"); host == "" {
} logrus.Debugf("no hostname found in SSH config, assuming %s", hostname)
host = hostname
} }
if username == "" { if username == "" {
@ -499,19 +500,17 @@ func GetHostConfig(hostname, username, port string) (HostConfig, error) {
} }
} }
if idf := ssh_config.Get(hostname, "IdentityFile"); idf != "" && idf != "~/.ssh/identity" { idf = ssh_config.Get(hostname, "IdentityFile")
if idf != "" {
var err error var err error
idf, err = identityFileAbsPath(idf) idf, err = identityFileAbsPath(idf)
if err != nil { if err != nil {
return hostConfig, err return hostConfig, err
} }
hostConfig.IdentityFile = idf hostConfig.IdentityFile = idf
} else {
logrus.Debugf("no identity file found in SSH config for %s", hostname)
hostConfig.IdentityFile = ""
} }
hostConfig.Host = hostname hostConfig.Host = host
hostConfig.Port = port hostConfig.Port = port
hostConfig.User = username hostConfig.User = username

View File

@ -188,14 +188,14 @@ func ignorableCloseError(err error) bool {
func (c *commandConn) CloseRead() error { func (c *commandConn) CloseRead() error {
// NOTE: maybe already closed here // NOTE: maybe already closed here
if err := c.stdout.Close(); err != nil && !ignorableCloseError(err) { if err := c.stdout.Close(); err != nil && !ignorableCloseError(err) {
// muted because https://github.com/docker/compose/issues/8544 // TODO: muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseRead: %v", err) // logrus.Warnf("commandConn.CloseRead: %v", err)
} }
c.stdioClosedMu.Lock() c.stdioClosedMu.Lock()
c.stdoutClosed = true c.stdoutClosed = true
c.stdioClosedMu.Unlock() c.stdioClosedMu.Unlock()
if err := c.killIfStdioClosed(); err != nil { if err := c.killIfStdioClosed(); err != nil {
// muted because https://github.com/docker/compose/issues/8544 // TODO: muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseRead: %v", err) // logrus.Warnf("commandConn.CloseRead: %v", err)
} }
return nil return nil
@ -212,14 +212,14 @@ func (c *commandConn) Read(p []byte) (int, error) {
func (c *commandConn) CloseWrite() error { func (c *commandConn) CloseWrite() error {
// NOTE: maybe already closed here // NOTE: maybe already closed here
if err := c.stdin.Close(); err != nil && !ignorableCloseError(err) { if err := c.stdin.Close(); err != nil && !ignorableCloseError(err) {
// muted because https://github.com/docker/compose/issues/8544 // TODO: muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseWrite: %v", err) // logrus.Warnf("commandConn.CloseWrite: %v", err)
} }
c.stdioClosedMu.Lock() c.stdioClosedMu.Lock()
c.stdinClosed = true c.stdinClosed = true
c.stdioClosedMu.Unlock() c.stdioClosedMu.Unlock()
if err := c.killIfStdioClosed(); err != nil { if err := c.killIfStdioClosed(); err != nil {
// muted because https://github.com/docker/compose/issues/8544 // TODO: muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.CloseWrite: %v", err) // logrus.Warnf("commandConn.CloseWrite: %v", err)
} }
return nil return nil
@ -239,7 +239,7 @@ func (c *commandConn) Close() error {
logrus.Warnf("commandConn.Close: CloseRead: %v", err) logrus.Warnf("commandConn.Close: CloseRead: %v", err)
} }
if err = c.CloseWrite(); err != nil { if err = c.CloseWrite(); err != nil {
// muted because https://github.com/docker/compose/issues/8544 // TODO: muted because https://github.com/docker/compose/issues/8544
// logrus.Warnf("commandConn.Close: CloseWrite: %v", err) // logrus.Warnf("commandConn.Close: CloseWrite: %v", err)
} }
return err return err

View File

@ -16,6 +16,7 @@ import (
) )
// The default escape key sequence: ctrl-p, ctrl-q // The default escape key sequence: ctrl-p, ctrl-q
// TODO: This could be moved to `pkg/term`.
var defaultEscapeKeys = []byte{16, 17} var defaultEscapeKeys = []byte{16, 17}
// A hijackedIOStreamer handles copying input to and output from streams to the // A hijackedIOStreamer handles copying input to and output from streams to the

View File

@ -399,6 +399,7 @@ func convertServiceNetworks(
return nets, nil return nets, nil
} }
// TODO: fix secrets API so that SecretAPIClient is not required here
func convertServiceSecrets( func convertServiceSecrets(
client client.SecretAPIClient, client client.SecretAPIClient,
namespace Namespace, namespace Namespace,
@ -441,6 +442,8 @@ func convertServiceSecrets(
// required by the serivce. Unlike convertServiceSecrets, this takes the whole // required by the serivce. Unlike convertServiceSecrets, this takes the whole
// ServiceConfig, because some Configs may be needed as a result of other // ServiceConfig, because some Configs may be needed as a result of other
// fields (like CredentialSpecs). // fields (like CredentialSpecs).
//
// TODO: fix configs API so that ConfigsAPIClient is not required here
func convertServiceConfigObjs( func convertServiceConfigObjs(
client client.ConfigAPIClient, client client.ConfigAPIClient,
namespace Namespace, namespace Namespace,
@ -623,6 +626,7 @@ func convertHealthcheck(healthcheck *composetypes.HealthCheckConfig) (*container
} }
func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*swarm.RestartPolicy, error) { func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*swarm.RestartPolicy, error) {
// TODO: log if restart is being ignored
if source == nil { if source == nil {
policy, err := opts.ParseRestartPolicy(restart) policy, err := opts.ParseRestartPolicy(restart)
if err != nil { if err != nil {

View File

@ -1,44 +0,0 @@
package upstream
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
// RunServiceScale scales a service (useful for restart action)
func RunServiceScale(ctx context.Context, cl *client.Client, serviceID string, scale uint64) error {
service, _, err := cl.ServiceInspectWithRaw(ctx, serviceID, types.ServiceInspectOptions{})
if err != nil {
return err
}
serviceMode := &service.Spec.Mode
if serviceMode.Replicated != nil {
serviceMode.Replicated.Replicas = &scale
} else if serviceMode.ReplicatedJob != nil {
serviceMode.ReplicatedJob.TotalCompletions = &scale
} else {
return fmt.Errorf("scale can only be used with replicated or replicated-job mode")
}
response, err := cl.ServiceUpdate(
ctx,
service.ID,
service.Version,
service.Spec,
types.ServiceUpdateOptions{},
)
if err != nil {
return err
}
for _, warning := range response.Warnings {
logrus.Warn(warning)
}
return nil
}

View File

@ -112,7 +112,7 @@ func GetDeployedServicesByName(ctx context.Context, cl *dockerclient.Client, sta
// IsDeployed chekcks whether an appp is deployed or not. // IsDeployed chekcks whether an appp is deployed or not.
func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string) (bool, string, error) { func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string) (bool, string, error) {
version := "unknown" version := ""
isDeployed := false isDeployed := false
filter := filters.NewArgs() filter := filters.NewArgs()
@ -132,12 +132,12 @@ func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string)
} }
} }
logrus.Debugf("%s has been detected as deployed with version %s", stackName, version) logrus.Debugf("'%s' has been detected as deployed with version '%s'", stackName, version)
return true, version, nil return true, version, nil
} }
logrus.Debugf("%s has been detected as not deployed", stackName) logrus.Debugf("'%s' has been detected as not deployed", stackName)
return isDeployed, version, nil return isDeployed, version, nil
} }
@ -158,7 +158,7 @@ func pruneServices(ctx context.Context, cl *dockerclient.Client, namespace conve
} }
// RunDeploy is the swarm implementation of docker stack deploy // RunDeploy is the swarm implementation of docker stack deploy
func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config, appName string, dontWait bool) error { func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config, recipeName string) error {
ctx := context.Background() ctx := context.Background()
if err := validateResolveImageFlag(&opts); err != nil { if err := validateResolveImageFlag(&opts); err != nil {
@ -170,7 +170,7 @@ func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config, a
opts.ResolveImage = ResolveImageNever opts.ResolveImage = ResolveImageNever
} }
return deployCompose(ctx, cl, opts, cfg, appName, dontWait) return deployCompose(ctx, cl, opts, cfg, recipeName)
} }
// validateResolveImageFlag validates the opts.resolveImage command line option // validateResolveImageFlag validates the opts.resolveImage command line option
@ -183,7 +183,7 @@ func validateResolveImageFlag(opts *Deploy) error {
} }
} }
func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, config *composetypes.Config, appName string, dontWait bool) error { func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, config *composetypes.Config, recipeName string) error {
namespace := convert.NewNamespace(opts.Namespace) namespace := convert.NewNamespace(opts.Namespace)
if opts.Prune { if opts.Prune {
@ -224,7 +224,7 @@ func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, co
return err return err
} }
return deployServices(ctx, cl, services, namespace, opts.SendRegistryAuth, opts.ResolveImage, appName, dontWait) return deployServices(ctx, cl, services, namespace, opts.SendRegistryAuth, opts.ResolveImage, recipeName)
} }
func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} { func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} {
@ -340,8 +340,7 @@ func deployServices(
namespace convert.Namespace, namespace convert.Namespace,
sendAuth bool, sendAuth bool,
resolveImage string, resolveImage string,
appName string, recipeName string) error {
dontWait bool) error {
existingServices, err := GetStackServices(ctx, cl, namespace.Name()) existingServices, err := GetStackServices(ctx, cl, namespace.Name())
if err != nil { if err != nil {
return err return err
@ -360,6 +359,18 @@ func deployServices(
encodedAuth string encodedAuth string
) )
// FIXME: disable for now as not sure how to avoid having a `dockerCli`
// instance here and would rather not copy/pasta that entire module in
// right now for something that we don't even support right now. Will skip
// this for now.
if sendAuth {
// Retrieve encoded auth token from the image reference
// encodedAuth, err = command.RetrieveAuthTokenFromImage(ctx, dockerCli, image)
// if err != nil {
// return err
// }
}
if service, exists := existingServiceMap[name]; exists { if service, exists := existingServiceMap[name]; exists {
logrus.Infof("Updating service %s (id: %s)\n", name, service.ID) logrus.Infof("Updating service %s (id: %s)\n", name, service.ID)
@ -392,6 +403,7 @@ func deployServices(
// Stack deploy does not have a `--force` option. Preserve existing // Stack deploy does not have a `--force` option. Preserve existing
// ForceUpdate value so that tasks are not re-deployed if not updated. // ForceUpdate value so that tasks are not re-deployed if not updated.
// TODO move this to API client?
serviceSpec.TaskTemplate.ForceUpdate = service.Spec.TaskTemplate.ForceUpdate serviceSpec.TaskTemplate.ForceUpdate = service.Spec.TaskTemplate.ForceUpdate
response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts) response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts)
@ -427,19 +439,14 @@ func deployServices(
for _, serviceName := range serviceIDs { for _, serviceName := range serviceIDs {
serviceNames = append(serviceNames, serviceName) serviceNames = append(serviceNames, serviceName)
} }
if dontWait {
logrus.Warn("skipping converge logic checks")
return nil
}
logrus.Infof("waiting for services to converge: %s", strings.Join(serviceNames, ", ")) logrus.Infof("waiting for services to converge: %s", strings.Join(serviceNames, ", "))
ch := make(chan error, len(serviceIDs)) ch := make(chan error, len(serviceIDs))
for serviceID, serviceName := range serviceIDs { for serviceID, serviceName := range serviceIDs {
logrus.Debugf("waiting on %s to converge", serviceName) logrus.Debugf("waiting on %s to converge", serviceName)
go func(sID, sName, aName string) { go func(sID, sName, rName string) {
ch <- WaitOnService(ctx, cl, sID, aName) ch <- waitOnService(ctx, cl, sID, sName, rName)
}(serviceID, serviceName, appName) }(serviceID, serviceName, recipeName)
} }
for _, serviceID := range serviceIDs { for _, serviceID := range serviceIDs {
@ -469,7 +476,7 @@ func getStackConfigs(ctx context.Context, dockerclient client.APIClient, namespa
// https://github.com/docker/cli/blob/master/cli/command/service/helpers.go // https://github.com/docker/cli/blob/master/cli/command/service/helpers.go
// https://github.com/docker/cli/blob/master/cli/command/service/progress/progress.go // https://github.com/docker/cli/blob/master/cli/command/service/progress/progress.go
func WaitOnService(ctx context.Context, cl *dockerclient.Client, serviceID, appName string) error { func waitOnService(ctx context.Context, cl *dockerclient.Client, serviceID, serviceName, recipeName string) error {
errChan := make(chan error, 1) errChan := make(chan error, 1)
pipeReader, pipeWriter := io.Pipe() pipeReader, pipeWriter := io.Pipe()
@ -479,7 +486,7 @@ func WaitOnService(ctx context.Context, cl *dockerclient.Client, serviceID, appN
go io.Copy(ioutil.Discard, pipeReader) go io.Copy(ioutil.Discard, pipeReader)
timeout := 30 * time.Second timeout := 60 * time.Second
select { select {
case err := <-errChan: case err := <-errChan:
@ -498,8 +505,8 @@ If a service is failing to even start (run "abra app ps %s" to see what
services are running) there could be a few things. The follow command will services are running) there could be a few things. The follow command will
try to smoke those out for you: try to smoke those out for you:
abra app errors --watch %s abra app errors %s
`, appName, timeout, appName, appName, appName)) `, recipeName, timeout, recipeName, recipeName, recipeName))
} }
} }

View File

@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
ABRA_VERSION="0.3.0-alpha" ABRA_VERSION="0.4.0-alpha"
ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$ABRA_VERSION" ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$ABRA_VERSION"
RC_VERSION="0.4.0-alpha-rc1" RC_VERSION="0.4.0-alpha"
RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$RC_VERSION" RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$RC_VERSION"
for arg in "$@"; do for arg in "$@"; do
@ -37,28 +37,29 @@ function print_checksum_error {
function install_abra_release { function install_abra_release {
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
if ! type "wget" > /dev/null 2>&1; then if ! type "curl" > /dev/null 2>&1; then
echo "'wget' is not installed, cannot proceed..." echo "'curl' is not installed, cannot proceed..."
echo "perhaps try installing manually via the releases URL?" echo "perhaps try installing manually via the releases URL?"
echo "https://git.coopcloud.tech/coop-cloud/abra/releases" echo "https://git.coopcloud.tech/coop-cloud/abra/releases"
exit 1 exit 1
fi fi
# FIXME: support different architectures
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m) PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m)
FILENAME="abra_"$ABRA_VERSION"_"$PLATFORM"" FILENAME="abra_"$ABRA_VERSION"_"$PLATFORM""
sed_command_rel='s/.*"assets":\[\{[^]]*"name":"'$FILENAME'"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p' sed_command_rel='s/.*"assets":\[\{[^]]*"name":"'$FILENAME'"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p'
sed_command_checksums='s/.*"assets":\[\{[^\]*"name":"checksums.txt"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p' sed_command_checksums='s/.*"assets":\[\{[^\]*"name":"checksums.txt"[^}]*"browser_download_url":"([^"]*)".*\].*/\1/p'
json=$(wget -q -O- $ABRA_RELEASE_URL) json=$(curl -s $ABRA_RELEASE_URL)
release_url=$(echo $json | sed -En $sed_command_rel) release_url=$(echo $json | sed -En $sed_command_rel)
checksums_url=$(echo $json | sed -En $sed_command_checksums) checksums_url=$(echo $json | sed -En $sed_command_checksums)
checksums=$(wget -q -O- $checksums_url) checksums=$(curl -s $checksums_url)
checksum=$(echo "$checksums" | grep "$FILENAME" - | sed -En 's/([0-9a-f]{64})\s+'"$FILENAME"'.*/\1/p') checksum=$(echo "$checksums" | grep "$FILENAME" - | sed -En 's/([0-9a-f]{64})\s+'"$FILENAME"'.*/\1/p')
echo "downloading $ABRA_VERSION $PLATFORM binary release for abra..." echo "downloading $ABRA_VERSION $PLATFORM binary release for abra..."
wget -q --show-progress "$release_url" -O "$HOME/.local/bin/.abra-download" curl --progress-bar "$release_url" --output "$HOME/.local/bin/.abra-download"
localsum=$(sha256sum $HOME/.local/bin/.abra-download | sed -En 's/([0-9a-f]{64})\s+.*/\1/p') localsum=$(sha256sum $HOME/.local/bin/.abra-download | sed -En 's/([0-9a-f]{64})\s+.*/\1/p')
echo "checking if checksums match..." echo "checking if checksums match..."
if [[ "$localsum" != "$checksum" ]]; then if [[ "$localsum" != "$checksum" ]]; then

View File

@ -1,101 +0,0 @@
#!/bin/bash
# the goal of this script is to ensure basic core functionality is working
# before we make new releases. we try to make a balance between manual testing
# and automated testing, i.e. we don't invest too much time in a fragile
# automation that costs us more time to maintain and instead just do the test
# manually (see `../manual/manual.md` for more). it is a balance which we
# figure out together.
set -ex
ABRA="$HOME/.local/bin/abra -d"
INSTALLER_URL="https://install.abra.coopcloud.tech"
for arg in "$@"; do
if [ "$arg" == "--dev" ]; then
ABRA="/src/abra -d"
INSTALLER_URL="https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/installer/installer"
fi
done
export PATH=$PATH:$HOME/.local/bin
# ========================================================================
# choosing abra executable for test run
# ========================================================================
echo "choosing $ABRA as abra executable"
echo "choosing $INSTALLER_URL as abra installer url"
# ========================================================================
# latest stable release
# ========================================================================
wget -O- https://install.abra.autonomic.zone | bash
~/.local/bin/abra -v
# ========================================================================
# latest rc release
# ========================================================================
wget -O- https://install.abra.autonomic.zone | bash -s -- --rc
~/.local/bin/abra -v
# ========================================================================
# upgrade to stable in-place
# ========================================================================
$ABRA upgrade
~/.local/bin/abra -v
# ========================================================================
# upgrade to rc in-place
# ========================================================================
$ABRA upgrade --rc
~/.local/bin/abra -v
# ========================================================================
# autocomplete
# ========================================================================
$ABRA autocomplete bash
$ABRA autocomplete fizsh
$ABRA autocomplete zsh
# ========================================================================
# record command
# ========================================================================
$ABRA record new -p gandi -t A -n e2e -v 192.157.2.21 coopcloud.tech
$ABRA record list -p gandi coopcloud.tech | grep -q e2e
$ABRA -n record rm -p gandi -t A -n e2e coopcloud.tech
# ========================================================================
# catalogue command
# ========================================================================
$ABRA catalogue generate
$ABRA catalogue generate -s gitea
# ========================================================================
# recipe command
# ========================================================================
$ABRA recipe new testrecipe
$ABRA recipe list
$ABRA recipe list -p cloud
$ABRA recipe versions peertube
$ABRA recipe lint gitea
# ========================================================================
# server command
# ========================================================================
$ABRA -n server new -p hetzner-cloud --hn e2e
$ABRA server ls | grep -q e2e
$ABRA -n server rm -s -p hetzner-cloud --hn e2e
# ========================================================================
# app command
# ========================================================================
$ABRA app ls
$ABRA app ls -S

View File

@ -1 +0,0 @@
TYPE=works

View File

@ -1,84 +0,0 @@
---
# The goal of this compose file is to have a testing ground for understanding
# what cases we need to handle to get stable deployments. For that, we need to
# work with healthchecks and deploy configurations quite closely. If you run
# the `make symlink` target then this will be loaded into a "fake" app on your
# local machine which you can deploy with `abra`.
version: "3.8"
services:
r1_should_work:
image: redis:alpine
deploy:
update_config:
failure_action: rollback
order: start-first
rollback_config:
order: start-first
restart_policy:
max_attempts: 1
healthcheck:
test: redis-cli ping
interval: 2s
retries: 3
start_period: 1s
timeout: 3s
r2_broken_health_check:
image: redis:alpine
deploy:
update_config:
failure_action: rollback
order: start-first
rollback_config:
order: start-first
restart_policy:
max_attempts: 3
healthcheck:
test: foobar
interval: 2s
retries: 3
start_period: 1s
timeout: 3s
r3_no_health_check:
image: redis:alpine
deploy:
update_config:
failure_action: rollback
order: start-first
rollback_config:
order: start-first
restart_policy:
max_attempts: 3
r4_disabled_health_check:
image: redis:alpine
deploy:
update_config:
failure_action: rollback
order: start-first
rollback_config:
order: start-first
restart_policy:
max_attempts: 3
healthcheck:
disable: true
r5_should_also_work:
image: redis:alpine
deploy:
update_config:
failure_action: rollback
order: start-first
rollback_config:
order: start-first
restart_policy:
max_attempts: 1
healthcheck:
test: redis-cli ping
interval: 2s
retries: 3
start_period: 1s
timeout: 3s

View File

@ -1 +0,0 @@
TYPE=works

View File

@ -1,53 +0,0 @@
# manual test plan
Best served after running `make int-core` which assures most core functionality
is still working. These manual tests are for testing things that are hard to
wire up for testing in an automated way.
## recipe publish
- `abra recipe upgrade <recipe>`
- `abra recipe sync <recipe>`
- `abra recipe release --publish <recipe>`
## automagic traefik deploy
- `abra server add -p -t <server>`
## deploy, upgrade, rollback
- `abra app deploy <app>`
- `abra app upgrade <app>`
- `abra app rollback <app>`
## backup & restore
- `abra app deploy <app>`
- `abra app backup <app>`
- `abra app undeploy <app>`
- `abra app volume remove --force <app>`
- `abra app deploy <app>`
- `abra app restore <app>`
## app day-to-day ops
### easy mode
- `abra app config <app>`
- `abra app check <app>`
- `abra app ps <app>`
- `abra app logs <app>`
- `abra app cp <app>`
- `abra app run <app>`
- `abra app secret ls <app>`
- `abra app volume ls <app>`
### hard mode
- `abra app restart <app>`
- `abra app remove <app>`
- `abra app secret insert <app> foo v1 bar`
- `abra app secret remove <app> foo`
- `abra app secret generate --all`
- `abra app volume remove --force <app>`
- `abra app errors -w <app>`