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 2243 additions and 3586 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
dist/
.vscode/
vendor/
.envrc
dist/
*fmtcoverage.html

View File

@ -5,7 +5,7 @@ LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
export GOPRIVATE=coopcloud.tech
all: format check static build test
all: run test install build clean format check static
run:
@go run -ldflags=$(LDFLAGS) $(ABRA)
@ -43,15 +43,3 @@ loc-author:
sort -f | \
uniq -ic | \
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
var AppCommand = &cli.Command{
Name: "app",
Usage: "Manage apps",
Usage: "Manage deployed apps",
Aliases: []string{"a"},
ArgsUsage: "<app>",
Description: `

View File

@ -38,10 +38,10 @@ var appBackupCommand = &cli.Command{
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 os.IsNotExist(err) {
logrus.Fatalf("%s does not exist?", abraSh)
logrus.Fatalf("'%s' does not exist?", abraSh)
}
logrus.Fatal(err)
}
@ -62,7 +62,7 @@ var appBackupCommand = &cli.Command{
logrus.Fatal(err)
}
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)

View File

@ -20,10 +20,10 @@ var appCheckCommand = &cli.Command{
Action: func(c *cli.Context) error {
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 os.IsNotExist(err) {
logrus.Fatalf("%s does not exist?", envSamplePath)
logrus.Fatalf("'%s' does not exist?", envSamplePath)
}
logrus.Fatal(err)
}
@ -45,7 +45,7 @@ var appCheckCommand = &cli.Command{
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
},

View File

@ -31,7 +31,7 @@ var appConfigCommand = &cli.Command{
appFile, exists := files[appName]
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")

View File

@ -1,19 +1,11 @@
package app
import (
"fmt"
"os"
"strings"
"coopcloud.tech/abra/cli/internal"
"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/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)
}
}
err := configureAndCp(c, app, srcPath, dstPath, service, isToContainer)
err := internal.ConfigureAndCp(c, app, srcPath, dstPath, service, isToContainer)
if err != nil {
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,
}
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.ChaosFlag,
internal.NoDomainChecksFlag,
internal.DontWaitConvergeFlag,
},
Description: `
This command deploys an app. It does not support incrementing the version of a
deployed app, for this you need to look at the "abra app upgrade <app>"
command.
This command deploys a new instance of an app. It does not support changing the
version of an existing deployed app, for this you need to look at the "abra app
upgrade <app>" command.
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.

View File

@ -1,20 +1,7 @@
package app
import (
"strconv"
"strings"
"time"
"coopcloud.tech/abra/cli/internal"
"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"
)
@ -22,115 +9,19 @@ var appErrorsCommand = &cli.Command{
Name: "errors",
Usage: "List errors for a deployed app",
Description: `
This command lists errors for a deployed app.
This is a best-effort implementation and an attempt to gather a number of tips
& tricks for finding errors together into one convenient command. When an app
is failing to deploy or having issues, it could be a lot of things.
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.
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
errors together into one convenient command. When an app is failing to deploy
or having issues, it could be a lot of things. This command is best accompanied
by "abra app logs <app>".
`,
Aliases: []string{"e"},
Flags: []cli.Flag{internal.WatchFlag},
Flags: []cli.Flag{},
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(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 {
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)
}
// TODO: entrypoint error
// TODO: ps --no-trunc errors
// TODO: failing healthcheck
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"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/ssh"
"coopcloud.tech/tagcmp"
"github.com/sirupsen/logrus"
@ -92,7 +92,7 @@ can take some time.
sort.Sort(config.ByServerAndType(apps))
statuses := make(map[string]map[string]string)
var catl recipe.RecipeCatalogue
var catl catalogue.RecipeCatalogue
if status {
alreadySeen := make(map[string]bool)
for _, app := range apps {
@ -110,7 +110,7 @@ can take some time.
}
var err error
catl, err = recipe.ReadRecipeCatalogue()
catl, err = catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
@ -124,26 +124,19 @@ can take some time.
var ok bool
if stats, ok = allStats[app.Server]; !ok {
stats = serverStatus{}
if appType == "" {
// count server, no filtering
totalServersCount++
}
totalServersCount++
}
if app.Type == appType || appType == "" {
if appType != "" {
// only count server if matches filter
totalServersCount++
}
appStats := appStatus{}
stats.appCount++
totalAppsCount++
if status {
stackName := app.StackName()
status := "unknown"
version := "unknown"
if statusMeta, ok := statuses[app.StackName()]; ok {
if statusMeta, ok := statuses[stackName]; ok {
if currentVersion, exists := statusMeta["version"]; exists {
version = currentVersion
}
@ -160,7 +153,7 @@ can take some time.
var newUpdates []string
if version != "unknown" {
updates, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
updates, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil {
logrus.Fatal(err)
}
@ -190,7 +183,10 @@ can take some time.
stats.latestCount++
}
} 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")
stats.upgradeCount++
}
@ -198,7 +194,7 @@ can take some time.
appStats.server = app.Server
appStats.recipe = app.Type
appStats.appName = app.Name
appStats.appName = app.StackName()
appStats.domain = app.Domain
stats.apps = append(stats.apps, appStats)
@ -207,52 +203,41 @@ can take some time.
allStats[app.Server] = stats
}
alreadySeen := make(map[string]bool)
for _, app := range apps {
if _, ok := alreadySeen[app.Server]; ok {
continue
}
serverStat := allStats[app.Server]
tableCol := []string{"recipe", "domain", "app name"}
for serverName, serverStat := range allStats {
tableCol := []string{"recipe", "app name", "domain"}
if status {
tableCol = append(tableCol, []string{"status", "version", "upgrade"}...)
}
table := formatter.CreateTable(tableCol)
table := abraFormatter.CreateTable(tableCol)
for _, appStat := range serverStat.apps {
tableRow := []string{appStat.recipe, appStat.domain, appStat.appName}
tableRow := []string{appStat.recipe, appStat.appName, appStat.domain}
if status {
tableRow = append(tableRow, []string{appStat.status, appStat.version, appStat.upgrade}...)
}
table.Append(tableRow)
}
if table.NumLines() > 0 {
table.Render()
table.Render()
if status {
fmt.Println(fmt.Sprintf(
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
app.Server,
serverStat.appCount,
serverStat.versionCount,
serverStat.unversionedCount,
serverStat.latestCount,
serverStat.upgradeCount,
))
} else {
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.appCount))
}
if status {
fmt.Println(fmt.Sprintf(
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v",
serverName,
serverStat.appCount,
serverStat.versionCount,
serverStat.unversionedCount,
serverStat.latestCount,
serverStat.upgradeCount,
))
} else {
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
}
alreadySeen[app.Server] = true
}
if len(allStats) > 1 {

View File

@ -10,7 +10,6 @@ import (
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/service"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
@ -49,6 +48,7 @@ func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
if err != nil {
logrus.Fatal(err)
}
// defer after err check as any err returns a nil io.ReadCloser
defer logs.Close()
_, err = io.Copy(os.Stdout, logs)
@ -57,9 +57,7 @@ func stackLogs(c *cli.Context, stackName string, client *dockerClient.Client) {
}
}(service.ID)
}
wg.Wait()
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 {
service := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("%s_%s", app.StackName(), serviceName))
chosenService, err := service.GetService(c.Context, cl, filters, internal.NoInput)
filters.Add("name", service)
serviceOpts := types.ServiceListOptions{Filters: filters}
services, err := cl.ServiceList(c.Context, serviceOpts)
if err != nil {
logrus.Fatal(err)
}
if len(services) != 1 {
logrus.Fatalf("expected 1 service but got %v", len(services))
}
if internal.StdErrOnly {
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 {
logrus.Fatal(err)
}
// defer after err check as any err returns a nil io.ReadCloser
defer logs.Close()
_, err = io.Copy(os.Stdout, logs)

View File

@ -4,65 +4,58 @@ import (
"strings"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"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/cli/cli/command/formatter"
"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"
)
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{
Name: "ps",
Usage: "Check app status",
Description: "This command shows a more detailed status output of a specific deployed app.",
Aliases: []string{"p"},
Flags: []cli.Flag{
internal.WatchFlag,
watchFlag,
},
BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error {
app := internal.ValidateApp(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)
if !watch {
showPSOutput(c)
return nil
}
goterm.Clear()
// TODO: how do we make this update in-place in an x-platform way?
for {
goterm.MoveCursor(1, 1)
showPSOutput(c, app, cl)
goterm.Flush()
showPSOutput(c)
time.Sleep(2 * time.Second)
}
},
}
// 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.Add("name", app.StackName())
@ -71,8 +64,8 @@ func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
logrus.Fatal(err)
}
tableCol := []string{"service name", "image", "created", "status", "state", "ports"}
table := formatter.CreateTable(tableCol)
tableCol := []string{"image", "created", "status", "ports"}
table := abraFormatter.CreateTable(tableCol)
for _, container := range containers {
var containerNames []string
@ -82,12 +75,10 @@ func showPSOutput(c *cli.Context, app config.App, cl *dockerClient.Client) {
}
tableRow := []string{
service.ContainerToServiceName(container.Names, app.StackName()),
formatter.RemoveSha(container.Image),
formatter.HumanDuration(container.Created),
abraFormatter.RemoveSha(container.Image),
abraFormatter.HumanDuration(container.Created),
container.Status,
container.State,
dockerFormatter.DisplayablePorts(container.Ports),
formatter.DisplayablePorts(container.Ports),
}
table.Append(tableRow)
}

View File

@ -39,13 +39,13 @@ var appRemoveCommand = &cli.Command{
if !internal.Force {
response := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("about to remove %s, are you sure?", app.Name),
Message: fmt.Sprintf("about to delete %s, are you sure?", app.Name),
}
if err := survey.AskOne(prompt, &response); err != nil {
logrus.Fatal(err)
}
if !response {
logrus.Fatal("aborting as requested")
logrus.Fatal("user aborted app removal")
}
}
@ -54,16 +54,18 @@ var appRemoveCommand = &cli.Command{
logrus.Fatal(err)
}
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if isDeployed {
logrus.Fatalf("%s is still deployed. Run \"abra app undeploy %s \" or pass --force", app.Name, app.Name)
if !internal.Force {
isDeployed, _, err := stack.IsDeployed(c.Context, cl, app.StackName())
if err != nil {
logrus.Fatal(err)
}
if isDeployed {
logrus.Fatalf("'%s' is still deployed. Run \"abra app undeploy %s \" or pass --force", app.Name, app.Name)
}
}
fs := filters.NewArgs()
fs.Add("name", app.StackName())
fs.Add("name", app.Name)
secretList, err := cl.SecretList(c.Context, types.SecretListOptions{Filters: fs})
if err != nil {
logrus.Fatal(err)
@ -79,7 +81,6 @@ var appRemoveCommand = &cli.Command{
if len(secrets) > 0 {
var secretNamesToRemove []string
if !internal.Force {
secretsPrompt := &survey.MultiSelect{
Message: "which secrets do you want to remove?",
@ -141,9 +142,7 @@ var appRemoveCommand = &cli.Command{
logrus.Info("no volumes were removed")
}
} else {
if Volumes {
logrus.Info("no volumes to remove")
}
logrus.Info("no volumes to remove")
}
err = os.Remove(app.Path)

View File

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

View File

@ -27,7 +27,7 @@ var restoreAllServicesFlag = &cli.BoolFlag{
var appRestoreCommand = &cli.Command{
Name: "restore",
Usage: "Restore an app from a backup",
Aliases: []string{"rs"},
Aliases: []string{"r"},
Flags: []cli.Flag{restoreAllServicesFlag},
ArgsUsage: "<service> [<backup file>]",
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"))
}
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 os.IsNotExist(err) {
logrus.Fatalf("%s does not exist?", abraSh)
logrus.Fatalf("'%s' does not exist?", abraSh)
}
logrus.Fatal(err)
}
@ -60,7 +60,7 @@ var appRestoreCommand = &cli.Command{
logrus.Fatal(err)
}
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)

View File

@ -4,8 +4,8 @@ import (
"fmt"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
@ -20,12 +20,11 @@ import (
var appRollbackCommand = &cli.Command{
Name: "rollback",
Usage: "Roll an app back to a previous version",
Aliases: []string{"rl"},
Aliases: []string{"r", "downgrade"},
ArgsUsage: "<app>",
Flags: []cli.Flag{
internal.ForceFlag,
internal.ChaosFlag,
internal.DontWaitConvergeFlag,
},
Description: `
This command rolls an app back to a previous version if one exists.
@ -45,25 +44,12 @@ recipes.
app := internal.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)
if err != nil {
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)
if err != nil {
@ -71,27 +57,24 @@ recipes.
}
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 {
logrus.Fatal(err)
}
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil {
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
if deployedVersion == "unknown" {
if deployedVersion == "" {
deployedVersion = "unknown"
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 {
@ -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
if !internal.Chaos {
if internal.Force {
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 {
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,
}
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)
if err != nil {
logrus.Fatal(err)
@ -173,12 +159,12 @@ recipes.
}
if !internal.Force {
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade, ""); err != nil {
if err := internal.NewVersionOverview(app, deployedVersion, chosenDowngrade); err != nil {
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)
}

View File

@ -5,8 +5,8 @@ import (
"fmt"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/upstream/container"
"github.com/docker/cli/cli/command"
@ -36,10 +36,9 @@ var appRunCommand = &cli.Command{
noTTYFlag,
userFlag,
},
Aliases: []string{"r"},
ArgsUsage: "<service> <args>...",
Usage: "Run a command in a service container",
BashComplete: autocomplete.AppNameComplete,
Aliases: []string{"r"},
ArgsUsage: "<service> <args>...",
Usage: "Run a command in a service container",
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
@ -83,7 +82,11 @@ var appRunCommand = &cli.Command{
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()
if err != nil {
logrus.Fatal(err)
@ -95,4 +98,25 @@ var appRunCommand = &cli.Command{
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"
"strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/secret"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
@ -20,19 +20,18 @@ import (
var allSecrets bool
var allSecretsFlag = &cli.BoolFlag{
Name: "all",
Aliases: []string{"a"},
Aliases: []string{"A"},
Value: false,
Destination: &allSecrets,
Usage: "Generate all secrets",
}
var appSecretGenerateCommand = &cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate secrets",
ArgsUsage: "<secret> <version>",
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
BashComplete: autocomplete.AppNameComplete,
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate secrets",
ArgsUsage: "<secret> <version>",
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
Action: func(c *cli.Context) error {
app := internal.ValidateApp(c)
@ -61,7 +60,7 @@ var appSecretGenerateCommand = &cli.Command{
}
}
if !matches {
logrus.Fatalf("%s doesn't exist in the env config?", secretName)
logrus.Fatalf("'%s' doesn't exist in the env config?", secretName)
}
}
@ -84,7 +83,7 @@ var appSecretGenerateCommand = &cli.Command{
}
tableCol := []string{"name", "value"}
table := formatter.CreateTable(tableCol)
table := abraFormatter.CreateTable(tableCol)
for name, val := range secretVals {
table.Append([]string{name, val})
}
@ -96,12 +95,11 @@ var appSecretGenerateCommand = &cli.Command{
}
var appSecretInsertCommand = &cli.Command{
Name: "insert",
Aliases: []string{"i"},
Usage: "Insert secret",
Flags: []cli.Flag{internal.PassFlag},
ArgsUsage: "<app> <secret-name> <version> <data>",
BashComplete: autocomplete.AppNameComplete,
Name: "insert",
Aliases: []string{"i"},
Usage: "Insert secret",
Flags: []cli.Flag{internal.PassFlag},
ArgsUsage: "<app> <secret-name> <version> <data>",
Description: `
This command inserts a secret into an app environment.
@ -141,12 +139,11 @@ Example:
}
var appSecretRmCommand = &cli.Command{
Name: "remove",
Usage: "Remove a secret",
Aliases: []string{"rm"},
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
ArgsUsage: "<app> <secret-name>",
BashComplete: autocomplete.AppNameComplete,
Name: "remove",
Usage: "Remove a secret",
Aliases: []string{"rm"},
Flags: []cli.Flag{allSecretsFlag, internal.PassFlag},
ArgsUsage: "<app> <secret-name>",
Description: `
This command removes a secret from an app environment.
@ -218,7 +215,7 @@ var appSecretLsCommand = &cli.Command{
secrets := secret.ReadSecretEnvVars(app.Env)
tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"}
table := formatter.CreateTable(tableCol)
table := abraFormatter.CreateTable(tableCol)
cl, err := client.New(app.Server)
if err != nil {
@ -252,12 +249,7 @@ var appSecretLsCommand = &cli.Command{
table.Append(tableRow)
}
if table.NumLines() > 0 {
table.Render()
} else {
logrus.Warnf("no secrets stored for %s", app.Name)
}
table.Render()
return nil
},
BashComplete: autocomplete.AppNameComplete,

View File

@ -11,7 +11,7 @@ import (
var appUndeployCommand = &cli.Command{
Name: "undeploy",
Aliases: []string{"un"},
Aliases: []string{"u"},
Usage: "Undeploy an app",
Description: `
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.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)
if err != nil {
@ -35,7 +35,7 @@ volumes as eligiblef or pruning once undeployed.
}
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 {

View File

@ -5,9 +5,9 @@ import (
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/recipe"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"coopcloud.tech/tagcmp"
@ -18,7 +18,7 @@ import (
var appUpgradeCommand = &cli.Command{
Name: "upgrade",
Aliases: []string{"up"},
Aliases: []string{"u"},
Usage: "Upgrade an app",
ArgsUsage: "<app>",
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
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
deployed app.
@ -48,19 +48,6 @@ recipes.
app := internal.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)
if err != nil {
logrus.Fatal(err)
@ -77,12 +64,12 @@ recipes.
logrus.Fatalf("%s is not deployed?", app.Name)
}
catl, err := recipe.ReadRecipeCatalogue()
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil {
logrus.Fatal(err)
}
@ -92,7 +79,8 @@ recipes.
}
var availableUpgrades []string
if deployedVersion == "uknown" {
if deployedVersion == "" {
deployedVersion = "unknown"
availableUpgrades = versions
logrus.Warnf("failed to determine version of deployed %s", app.Name)
}
@ -113,13 +101,11 @@ recipes.
}
if len(availableUpgrades) == 0 && !internal.Force {
logrus.Infof("no available upgrades, you're on latest (%s) ✌️", deployedVersion)
logrus.Info("no available upgrades, you're on latest ✌️")
return nil
}
}
availableUpgrades = internal.ReverseStringList(availableUpgrades)
var chosenUpgrade string
if len(availableUpgrades) > 0 && !internal.Chaos {
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 err := recipe.EnsureVersion(app.Type, chosenUpgrade); err != nil {
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)
if err != nil {
logrus.Fatal(err)
@ -183,11 +161,11 @@ recipes.
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)
}
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)
}

View File

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

View File

@ -1,10 +1,10 @@
package app
import (
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/formatter"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
@ -23,7 +23,7 @@ var appVolumeListCommand = &cli.Command{
logrus.Fatal(err)
}
table := formatter.CreateTable([]string{"driver", "volume name"})
table := abraFormatter.CreateTable([]string{"driver", "volume name"})
var volTable [][]string
for _, volume := range volumeList {
volRow := []string{
@ -34,12 +34,7 @@ var appVolumeListCommand = &cli.Command{
}
table.AppendBulk(volTable)
if table.NumLines() > 0 {
table.Render()
} else {
logrus.Warnf("no volumes created for %s", app.Name)
}
table.Render()
return nil
},
@ -59,8 +54,7 @@ interface.
Passing "--force" will select all volumes for removal. Be careful.
`,
ArgsUsage: "<app>",
Aliases: []string{"rm"},
Aliases: []string{"rm"},
Flags: []cli.Flag{
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"
"fmt"
"io/ioutil"
"os"
"path"
"coopcloud.tech/abra/cli/formatter"
"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"
"coopcloud.tech/abra/pkg/limit"
"coopcloud.tech/abra/pkg/recipe"
"github.com/go-git/go-git/v5"
"github.com/AlecAivazis/survey/v2"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -58,13 +59,12 @@ var CatalogueSkipList = map[string]bool{
var catalogueGenerateCommand = &cli.Command{
Name: "generate",
Aliases: []string{"g"},
Usage: "Generate the recipe catalogue",
Usage: "Generate a new copy of the catalogue",
Flags: []cli.Flag{
internal.PublishFlag,
internal.PushFlag,
internal.CommitFlag,
internal.CommitMessageFlag,
internal.DryFlag,
internal.SkipUpdatesFlag,
internal.RegistryUsernameFlag,
internal.RegistryPasswordFlag,
},
Description: `
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
It polls the entire git.coopcloud.tech/coop-cloud/... recipe repository
listing, parses README.md and git tags of those repositories to produce recipe
metadata and produces a recipes JSON file.
listing, parses README and tags to produce recipe metadata and produces a
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
<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.
If you have a Hub account you can have Abra log you in to avoid this. Pass
"--user" and "--pass".
A new catalogue copy can be published to the recipes repository by passing the
"--commit" and "--push" flags. The recipes repository is available here:
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>]",
Action: func(c *cli.Context) error {
@ -99,29 +97,68 @@ keys configured on your account.
return err
}
repos, err := recipe.ReadReposMetadata()
repos, err := catalogue.ReadReposMetadata()
if err != nil {
logrus.Fatal(err)
}
logrus.Debugf("ensuring '%v' recipe(s) are locally present and up-to-date", len(repos))
var barLength int
var logMsg string
if recipeName != "" {
barLength = 1
logMsg = fmt.Sprintf("ensuring %v recipe is cloned & up-to-date", barLength)
} else {
barLength = len(repos)
logMsg = fmt.Sprintf("ensuring %v recipes are cloned & up-to-date, this could take some time...", barLength)
}
if !internal.SkipUpdates {
logrus.Warn(logMsg)
if err := updateRepositories(repos, recipeName); err != nil {
logrus.Fatal(err)
}
cloneLimiter := limit.New(10)
retrieveBar := formatter.CreateProgressbar(len(repos), "retrieving recipes from recipes.coopcloud.tech...")
ch := make(chan string, barLength)
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...")
for _, recipeMeta := range repos {
if recipeName != "" && recipeName != recipeMeta.Name {
@ -134,24 +171,19 @@ keys configured on your account.
continue
}
versions, err := recipe.GetRecipeVersions(
recipeMeta.Name,
internal.RegistryUsername,
internal.RegistryPassword,
)
versions, err := catalogue.GetRecipeVersions(recipeMeta.Name)
if err != nil {
logrus.Fatal(err)
}
features, category, err := recipe.GetRecipeFeaturesAndCategory(recipeMeta.Name)
features, category, err := catalogue.GetRecipeFeaturesAndCategory(recipeMeta.Name)
if err != nil {
logrus.Warn(err)
}
catl[recipeMeta.Name] = recipe.RecipeMeta{
catl[recipeMeta.Name] = catalogue.RecipeMeta{
Name: recipeMeta.Name,
Repository: recipeMeta.CloneURL,
SSHURL: recipeMeta.SSHURL,
Icon: recipeMeta.AvatarURL,
DefaultBranch: recipeMeta.DefaultBranch,
Description: recipeMeta.Description,
@ -160,7 +192,6 @@ keys configured on your account.
Category: category,
Features: features,
}
catlBar.Add(1)
}
@ -169,81 +200,45 @@ keys configured on your account.
logrus.Fatal(err)
}
if recipeName == "" {
if err := ioutil.WriteFile(config.RECIPES_JSON, recipesJSON, 0764); err != nil {
if _, err := os.Stat(config.APPS_JSON); err != nil && os.IsNotExist(err) {
if err := ioutil.WriteFile(config.APPS_JSON, recipesJSON, 0764); err != nil {
logrus.Fatal(err)
}
} else {
catlFS, err := recipe.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
if recipeName != "" {
catlFS, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
catlFS[recipeName] = catl[recipeName]
catlFS[recipeName] = catl[recipeName]
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
if err != nil {
logrus.Fatal(err)
}
if err := ioutil.WriteFile(config.RECIPES_JSON, updatedRecipesJSON, 0764); err != nil {
logrus.Fatal(err)
updatedRecipesJSON, err := json.MarshalIndent(catlFS, "", " ")
if err != nil {
logrus.Fatal(err)
}
if err := ioutil.WriteFile(config.APPS_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.Publish {
isClean, err := gitPkg.IsClean(cataloguePath)
if err != nil {
logrus.Fatal(err)
}
if isClean {
if !internal.Dry {
logrus.Fatalf("no changes discovered in %s, nothing to publish?", cataloguePath)
if internal.Commit {
if internal.CommitMessage == "" && !internal.NoInput {
prompt := &survey.Input{
Message: "commit message",
Default: fmt.Sprintf("chore: publish catalogue changes"),
}
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
logrus.Fatal(err)
}
}
msg := "chore: publish new catalogue release changes"
if err := gitPkg.Commit(cataloguePath, "**.json", msg, internal.Dry); err != nil {
cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
if err := gitPkg.Commit(cataloguePath, "**.json", internal.CommitMessage, internal.Dry, internal.Push); err != nil {
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
@ -262,62 +257,3 @@ var CatalogueCommand = &cli.Command{
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
import (
"errors"
"fmt"
"os"
"os/exec"
"path"
"coopcloud.tech/abra/cli/app"
@ -15,129 +13,11 @@ import (
"coopcloud.tech/abra/cli/record"
"coopcloud.tech/abra/cli/server"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/web"
logrusStack "github.com/Gurpartap/logrus-stack"
"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: "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 {
app := &cli.App{
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]),
Commands: []*cli.Command{
app.AppCommand,
@ -160,6 +43,7 @@ func newAbraApp(version, commit string) *cli.App {
AutoCompleteCommand,
},
Flags: []cli.Flag{
internal.VerboseFlag,
internal.DebugFlag,
internal.NoInputFlag,
},
@ -169,7 +53,6 @@ func newAbraApp(version, commit string) *cli.App {
// some love
{Name: "3wordchant"},
{Name: "decentral1se"},
{Name: "kawaiipunk"},
{Name: "knoflook"},
{Name: "roxxers"},
},
@ -187,9 +70,9 @@ func newAbraApp(version, commit string) *cli.App {
paths := []string{
config.ABRA_DIR,
path.Join(config.SERVERS_DIR),
path.Join(config.RECIPES_DIR),
path.Join(config.VENDOR_DIR),
path.Join(config.ABRA_DIR, "servers"),
path.Join(config.ABRA_DIR, "apps"),
path.Join(config.ABRA_DIR, "vendor"),
}
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
}

View File

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

View File

@ -10,7 +10,7 @@ var Secrets bool
// SecretsFlag turns on/off automatically generating secrets
var SecretsFlag = &cli.BoolFlag{
Name: "secrets",
Aliases: []string{"ss"},
Aliases: []string{"S"},
Value: false,
Usage: "Automatically generate secrets",
Destination: &Secrets,
@ -22,7 +22,7 @@ var Pass bool
// PassFlag turns on/off storing generated secrets in pass
var PassFlag = &cli.BoolFlag{
Name: "pass",
Aliases: []string{"p"},
Aliases: []string{"P"},
Value: false,
Usage: "Store the generated secrets in a local pass store",
Destination: &Pass,
@ -47,7 +47,6 @@ var ForceFlag = &cli.BoolFlag{
Name: "force",
Value: false,
Aliases: []string{"f"},
Usage: "Perform action without further prompt. Use with care!",
Destination: &Force,
}
@ -114,12 +113,13 @@ var DNSValueFlag = &cli.StringFlag{
Destination: &DNSValue,
}
var DNSTTL string
var DNSTTLFlag = &cli.StringFlag{
var DNSTTL int
var DNSTTLFlag = &cli.IntFlag{
Name: "ttl",
Value: "600s",
Value: 86400,
Aliases: []string{"T"},
Usage: "Domain name TTL value (seconds)",
Usage: "Domain name TTL value)",
Destination: &DNSTTL,
}
@ -272,6 +272,18 @@ var DebugFlag = &cli.BoolFlag{
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
var RC bool
@ -306,7 +318,7 @@ var PatchFlag = &cli.BoolFlag{
Name: "patch",
Usage: "Increase the patch part of the version",
Value: false,
Aliases: []string{"pa", "z"},
Aliases: []string{"p", "z"},
Destination: &Patch,
}
@ -319,13 +331,38 @@ var DryFlag = &cli.BoolFlag{
Destination: &Dry,
}
var Publish bool
var PublishFlag = &cli.BoolFlag{
Name: "publish",
Usage: "Publish changes to git.coopcloud.tech",
var Push bool
var PushFlag = &cli.BoolFlag{
Name: "push",
Usage: "Git push changes",
Value: false,
Aliases: []string{"p"},
Destination: &Publish,
Aliases: []string{"P"},
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
@ -379,63 +416,7 @@ var AutoDNSRecordFlag = &cli.BoolFlag{
Aliases: []string{"a"},
Value: false,
Usage: "Automatically configure DNS records",
Destination: &AutoDNSRecord,
}
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,
Destination: &StdErrOnly,
}
// 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 (
"fmt"
"io/ioutil"
"os"
"path"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"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/upstream/stack"
"github.com/AlecAivazis/survey/v2"
@ -23,49 +19,35 @@ import (
// DeployAction is the main command-line action for this package
func DeployAction(c *cli.Context) error {
app := ValidateApp(c)
if !Chaos {
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)
}
stackName := app.StackName()
cl, err := client.New(app.Server)
if err != nil {
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 {
logrus.Fatal(err)
}
if isDeployed {
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 {
logrus.Fatalf("%s is already deployed", app.Name)
logrus.Fatalf("%s is already deployed", stackName)
}
}
version := deployedVersion
if version == "unknown" && !Chaos {
catl, err := recipe.ReadRecipeCatalogue()
if version == "" && !Chaos {
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
versions, err := recipe.GetRecipeCatalogueVersions(app.Type, catl)
versions, err := catalogue.GetRecipeCatalogueVersions(app.Type, catl)
if err != nil {
logrus.Fatal(err)
}
@ -76,11 +58,7 @@ func DeployAction(c *cli.Context) error {
logrus.Fatal(err)
}
} else {
head, err := git.GetRecipeHead(app.Type)
if err != nil {
logrus.Fatal(err)
}
version = formatter.SmallSHA(head.String())
version = "latest commit"
logrus.Warn("no versions detected, using latest commit")
if err := recipe.EnsureLatest(app.Type); err != nil {
logrus.Fatal(err)
@ -88,14 +66,14 @@ func DeployAction(c *cli.Context) error {
}
}
if version == "unknown" && !Chaos {
if version == "" && !Chaos {
logrus.Debugf("choosing %s as version to deploy", version)
if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err)
}
}
if version != "unknown" && !Chaos {
if version != "" && !Chaos {
if err := recipe.EnsureVersion(app.Type, version); err != nil {
logrus.Fatal(err)
}
@ -110,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)
if err != nil {
logrus.Fatal(err)
@ -125,7 +103,7 @@ func DeployAction(c *cli.Context) error {
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: app.StackName(),
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
@ -152,7 +130,7 @@ func DeployAction(c *cli.Context) error {
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)
}
@ -161,8 +139,8 @@ func DeployAction(c *cli.Context) error {
// DeployOverview shows a deployment overview
func DeployOverview(app config.App, version, message string) error {
tableCol := []string{"server", "compose", "domain", "app name", "version"}
table := formatter.CreateTable(tableCol)
tableCol := []string{"server", "compose", "domain", "stack", "version"}
table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
@ -174,7 +152,7 @@ func DeployOverview(app config.App, version, message string) error {
server = "local"
}
table.Append([]string{server, deployConfig, app.Domain, app.Name, version})
table.Append([]string{server, deployConfig, app.Domain, app.StackName(), version})
table.Render()
if NoInput {
@ -198,9 +176,9 @@ func DeployOverview(app config.App, version, message string) error {
}
// NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes string) error {
tableCol := []string{"server", "compose", "domain", "app name", "current version", "to be deployed"}
table := formatter.CreateTable(tableCol)
func NewVersionOverview(app config.App, currentVersion, newVersion string) error {
tableCol := []string{"server", "compose", "domain", "stack", "current version", "to be deployed"}
table := abraFormatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
@ -212,24 +190,9 @@ func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes
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()
if releaseNotes == "" {
var err error
releaseNotes, err = GetReleaseNotes(app.Type, newVersion)
if err != nil {
return err
}
}
if releaseNotes != "" && newVersion != "" {
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 {
return nil
}
@ -249,22 +212,3 @@ func NewVersionOverview(app config.App, currentVersion, newVersion, releaseNotes
return nil
}
// GetReleaseNotes prints release notes for a recipe version
func GetReleaseNotes(recipeName, version string) (string, error) {
if version == "" {
return "", nil
}
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"
"path"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/abra/pkg/ssh"
"github.com/AlecAivazis/survey/v2"
@ -94,7 +93,7 @@ func ensureAppNameFlag() error {
if NewAppName == "" && !NoInput {
prompt := &survey.Input{
Message: "Specify app name:",
Default: Domain,
Default: config.SanitiseAppName(Domain),
}
if err := survey.AskOne(prompt, &NewAppName); err != nil {
return err
@ -112,7 +111,7 @@ func ensureAppNameFlag() error {
func NewAction(c *cli.Context) error {
recipe := ValidateRecipeWithPrompt(c)
if err := recipePkg.EnsureUpToDate(recipe.Name); err != nil {
if err := config.EnsureAbraDirExists(); err != nil {
logrus.Fatal(err)
}
@ -149,7 +148,7 @@ func NewAction(c *cli.Context) error {
}
secretCols := []string{"Name", "Value"}
secretTable := formatter.CreateTable(secretCols)
secretTable := abraFormatter.CreateTable(secretCols)
for secret := range secrets {
secretTable.Append([]string{secret, secrets[secret]})
}
@ -163,9 +162,9 @@ func NewAction(c *cli.Context) error {
NewAppServer = "local"
}
tableCol := []string{"server", "type", "domain", "app name"}
table := formatter.CreateTable(tableCol)
table.Append([]string{NewAppServer, recipe.Name, Domain, NewAppName})
tableCol := []string{"Name", "Domain", "Type", "Server"}
table := abraFormatter.CreateTable(tableCol)
table.Append([]string{sanitisedAppName, Domain, recipe.Name, NewAppServer})
fmt.Println("")
fmt.Println(fmt.Sprintf("A new %s app has been created! Here is an overview:", recipe.Name))
@ -173,10 +172,10 @@ func NewAction(c *cli.Context) error {
table.Render()
fmt.Println("")
fmt.Println("You can configure this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app config %s", NewAppName))
fmt.Println(fmt.Sprintf("\n abra app config %s", sanitisedAppName))
fmt.Println("")
fmt.Println("You can deploy this app by running the following:")
fmt.Println(fmt.Sprintf("\n abra app deploy %s", NewAppName))
fmt.Println(fmt.Sprintf("\n abra app deploy %s", sanitisedAppName))
fmt.Println("")
return nil

View File

@ -2,38 +2,20 @@ package internal
import (
"fmt"
"strings"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
)
// PromptBumpType prompts for version bump type
func PromptBumpType(tagString string) error {
if (!Major && !Minor && !Patch) && tagString == "" {
fmt.Printf(`
You need to make a decision about what kind of an update this new recipe
version is. If someone else performs this upgrade, do they have to do some
migration work or take care of some breaking changes? This can be signaled in
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".
fmt.Printf(`semver cheat sheet (more via semver.org):
major: new features/bug fixes, backwards incompatible
minor: new features/bug fixes, backwards compatible
patch: bug fixes, backwards compatible
`)
var chosenBumpType string
@ -82,27 +64,20 @@ func SetBumpType(bumpType string) {
}
}
// GetMainAppImage retrieves the main 'app' image name
func GetMainAppImage(recipe recipe.Recipe) (string, error) {
var path string
// GetMainApp retrieves the main 'app' image name
func GetMainApp(recipe recipe.Recipe) string {
var app string
for _, service := range recipe.Config.Services {
if service.Name == "app" {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return "", err
}
path = reference.Path(img)
path = recipePkg.StripTagMeta(path)
return path, nil
name := service.Name
if name == "app" {
app = strings.Split(service.Image, ":")[0]
}
}
if path == "" {
return path, fmt.Errorf("%s has no main 'app' service?", recipe.Name)
if app == "" {
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 (
"errors"
"fmt"
"os"
"strings"
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/ssh"
@ -23,10 +22,10 @@ func ValidateRecipe(c *cli.Context) recipe.Recipe {
recipeName := c.Args().First()
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 c.Command.Name == "generate" {
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
@ -51,7 +50,7 @@ func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
if recipeName == "" && !NoInput {
var recipes []string
catl, err := recipe.ReadRecipeCatalogue()
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err)
}
@ -91,17 +90,17 @@ func ValidateRecipeWithPrompt(c *cli.Context) recipe.Recipe {
}
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 {
logrus.Fatal(err)
}
logrus.Debugf("validated %s as recipe argument", recipeName)
return chosenRecipe
return recipe
}
// ValidateApp ensures the app name arg is valid.
@ -199,297 +198,3 @@ func ValidateServer(c *cli.Context) (string, error) {
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 (
"fmt"
"os"
"strconv"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/lint"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/tagcmp"
"github.com/docker/distribution/reference"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var recipeLintCommand = &cli.Command{
Name: "lint",
Usage: "Lint a recipe",
Aliases: []string{"l"},
ArgsUsage: "<recipe>",
Flags: []cli.Flag{internal.OnlyErrorFlag},
BashComplete: autocomplete.RecipeNameComplete,
Name: "lint",
Usage: "Lint a recipe",
Aliases: []string{"l"},
ArgsUsage: "<recipe>",
Action: func(c *cli.Context) error {
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)
}
tableCol := []string{"ref", "rule", "satisfied", "severity", "resolve"}
table := formatter.CreateTable(tableCol)
serviceNamedApp := false
traefikEnabled := false
healthChecksForAllServices := true
allImagesTagged := true
noUnstableTags := true
semverLikeTags := true
for _, service := range recipe.Config.Services {
if service.Name == "app" {
serviceNamedApp = true
}
hasError := false
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...")
for level := range lint.LintRules {
for _, rule := range lint.LintRules[level] {
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)
for label := range service.Deploy.Labels {
if label == "traefik.enable" {
if service.Deploy.Labels[label] == "true" {
traefikEnabled = true
}
} 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 {
fmt.Println()
table.Render()
}
if hasError {
logrus.Warn("watch out, some critical errors are present in your recipe config")
}
tableCol := []string{"rule", "satisfied"}
table := formatter.CreateTable(tableCol)
table.Append([]string{"compose files have the expected version", strconv.FormatBool(expectedVersion)})
table.Append([]string{"environment configuration is provided", strconv.FormatBool(envSampleProvided)})
table.Append([]string{"recipe contains a service named 'app'", strconv.FormatBool(serviceNamedApp)})
table.Append([]string{"traefik routing enabled on at least one service", strconv.FormatBool(traefikEnabled)})
table.Append([]string{"all services have a healthcheck enabled", strconv.FormatBool(healthChecksForAllServices)})
table.Append([]string{"all images are using a tag", strconv.FormatBool(allImagesTagged)})
table.Append([]string{"no usage of unstable 'latest' tags", strconv.FormatBool(noUnstableTags)})
table.Append([]string{"all tags are using a semver-like format", strconv.FormatBool(semverLikeTags)})
table.Render()
return nil
},
BashComplete: autocomplete.RecipeNameComplete,
}

View File

@ -2,82 +2,37 @@ package recipe
import (
"fmt"
"path"
"sort"
"strconv"
"strings"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/catalogue"
"github.com/sirupsen/logrus"
"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{
Name: "list",
Usage: "List available recipes",
Aliases: []string{"ls"},
Flags: []cli.Flag{
patternFlag,
},
Action: func(c *cli.Context) error {
catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
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()
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
logrus.Fatal(err.Error())
}
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)
len := 0
for _, recipe := range recipes {
tableRow := []string{
recipe.Name,
recipe.Category,
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++
}
status := fmt.Sprintf("%v", recipe.Features.Status)
tableRow := []string{recipe.Name, recipe.Category, status}
table.Append(tableRow)
}
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len))
if table.NumLines() > 0 {
table.Render()
}
table.Render()
return nil
},

View File

@ -1,10 +1,8 @@
package recipe
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"
"path"
"text/template"
@ -16,20 +14,6 @@ import (
"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{
Name: "new",
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
which Abra uses to inject values into the generated recipe folder (e.g. name of
recipe and domain in the sample environment config).
The new example repository is cloned to ~/.abra/apps/<recipe>.
`,
Action: func(c *cli.Context) error {
recipeName := c.Args().First()
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) {
logrus.Fatalf("%s recipe directory already exists?", directory)
}
@ -63,73 +49,49 @@ recipe and domain in the sample environment config).
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 {
logrus.Fatal(err)
}
logrus.Debugf("removed example git repo in %s", gitRepo)
meta := newRecipeMeta(recipeName)
logrus.Debugf("removed git repo in %s", gitRepo)
toParse := []string{
path.Join(config.RECIPES_DIR, recipeName, "README.md"),
path.Join(config.RECIPES_DIR, recipeName, ".env.sample"),
path.Join(config.APPS_DIR, recipeName, "README.md"),
path.Join(config.APPS_DIR, recipeName, ".env.sample"),
path.Join(config.APPS_DIR, recipeName, ".drone.yml"),
}
for _, path := range toParse {
file, err := os.OpenFile(path, os.O_RDWR, 0664)
if err != nil {
logrus.Fatal(err)
}
tpl, err := template.ParseFiles(path)
if err != nil {
logrus.Fatal(err)
}
var templated bytes.Buffer
if err := tpl.Execute(&templated, meta); err != nil {
// TODO: ask for description and probably other things so that the
// template repository is more "ready" to go than the current best-guess
// mode of templating
if err := tpl.Execute(file, struct {
Name string
Description string
}{recipeName, "TODO"}); err != nil {
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 {
logrus.Fatal(err)
}
fmt.Print(fmt.Sprintf(`
Your new %s recipe has been created in %s.
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))
logrus.Infof(
"new recipe %s created in %s, happy hacking!\n",
recipeName, path.Join(config.APPS_DIR, recipeName),
)
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"
"strings"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
@ -17,6 +17,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/docker/distribution/reference"
"github.com/go-git/go-git/v5"
configPkg "github.com/go-git/go-git/v5/config"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -27,9 +28,11 @@ var recipeReleaseCommand = &cli.Command{
Aliases: []string{"rl"},
ArgsUsage: "<recipe> [<version>]",
Description: `
This command is used to specify a new version of a recipe. These versions are
then published on the Co-op Cloud recipe catalogue. These versions take the
following form:
This command is used to specify a new tag for a recipe. These tags are used to
identify different versions of the recipe and are published on the Co-op Cloud
recipe catalogue.
These tags take the following form:
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).
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
recipe updates are properly communicated. I.e. developers of an app might
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.
versioning scheme in order to maximise the chances that the nature of recipe
updates are properly communicated.
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{
internal.DryFlag,
internal.MajorFlag,
internal.MinorFlag,
internal.PatchFlag,
internal.PublishFlag,
internal.PushFlag,
internal.CommitFlag,
internal.CommitMessageFlag,
internal.TagMessageFlag,
},
BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error {
@ -63,11 +72,7 @@ your SSH keys configured on your account.
logrus.Fatal(err)
}
mainApp, err := internal.GetMainAppImage(recipe)
if err != nil {
logrus.Fatal(err)
}
mainApp := internal.GetMainApp(recipe)
mainAppVersion := imagesTmp[mainApp]
if mainAppVersion == "" {
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()
if err != nil {
logrus.Fatal(err)
}
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
var err error
tagString, err = getLabelVersion(recipe, false)
if err != nil {
logrus.Fatal(err)
}
return err
}
if len(tags) > 0 {
@ -111,8 +108,28 @@ your SSH keys configured on your account.
} else {
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 {
if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil {
initTag, err := recipePkg.GetVersionLabelLocal(recipe)
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(err)
@ -127,7 +144,6 @@ your SSH keys configured on your account.
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
var services = make(map[string]string)
missingTag := false
for _, service := range recipe.Config.Services {
if service.Image == "" {
continue
@ -139,27 +155,21 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
}
path := reference.Path(img)
path = recipePkg.StripTagMeta(path)
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:
if service.Name == "app" {
missingTag = true
}
continue
return services, fmt.Errorf("%s service is missing image tag?", path)
}
services[path] = tag
}
if missingTag {
return services, fmt.Errorf("app service is missing image tag?")
}
return services, nil
}
@ -167,7 +177,7 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) 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)
if err != nil {
return err
@ -188,19 +198,19 @@ func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string
tag.MissingPatch = false
}
if tagString == "" {
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
if err := commitRelease(recipe); err != nil {
logrus.Fatal(err)
}
if err := commitRelease(recipe, tagString); err != nil {
logrus.Fatal(err)
if tagString == "" {
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
}
if err := tagRelease(tagString, repo); err != nil {
logrus.Fatal(err)
}
if err := pushRelease(recipe, tagString); err != nil {
if err := pushRelease(tagString, repo); err != nil {
logrus.Fatal(err)
}
@ -217,32 +227,50 @@ func btoi(b bool) int {
}
// getTagCreateOptions constructs git tag create options
func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
msg := fmt.Sprintf("chore: publish %s release", tag)
return git.CreateTagOptions{Message: msg}, nil
}
func getTagCreateOptions() (git.CreateTagOptions, error) {
if internal.TagMessage == "" && !internal.NoInput {
prompt := &survey.Input{
Message: "git tag message",
Default: "chore: publish new release",
}
func commitRelease(recipe recipe.Recipe, tag string) error {
if internal.Dry {
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 err := survey.AskOne(prompt, &internal.TagMessage); err != nil {
return git.CreateTagOptions{}, err
}
}
if internal.Publish {
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 {
return git.CreateTagOptions{Message: internal.TagMessage}, 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
}
}
@ -252,7 +280,7 @@ func commitRelease(recipe recipe.Recipe, tag string) error {
func tagRelease(tagString string, repo *git.Repository) error {
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
}
@ -261,7 +289,7 @@ func tagRelease(tagString string, repo *git.Repository) error {
return err
}
createTagOptions, err := getTagCreateOptions(tagString)
createTagOptions, err := getTagCreateOptions()
if err != nil {
return err
}
@ -271,46 +299,46 @@ func tagRelease(tagString string, repo *git.Repository) error {
return err
}
hash := formatter.SmallSHA(head.Hash().String())
logrus.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
hash := abraFormatter.SmallSHA(head.Hash().String())
logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
return nil
}
func pushRelease(recipe recipe.Recipe, tagString string) error {
func pushRelease(tagString string, repo *git.Repository) error {
if internal.Dry {
logrus.Info("dry run: no changes published")
logrus.Info("dry run: no changes pushed")
return nil
}
if !internal.Publish && !internal.NoInput {
if !internal.Push && !internal.NoInput {
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
}
}
if internal.Publish {
if err := recipe.Push(internal.Dry); err != nil {
if internal.Push {
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
}
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")
}
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
}
return nil
}
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)
if err != nil {
return err
@ -323,13 +351,11 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
}
}
var lastGitTag tagcmp.Tag
if tagString == "" {
if err := internal.PromptBumpType(tagString); err != nil {
return err
}
if err := internal.PromptBumpType(tagString); err != nil {
return err
}
var lastGitTag tagcmp.Tag
for _, tag := range tags {
parsed, err := tagcmp.Parse(tag)
if err != nil {
@ -370,39 +396,18 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
newTag.Major = strconv.Itoa(now + 1)
}
if internal.Major || internal.Minor || internal.Patch {
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 {
if err := commitRelease(recipe); err != nil {
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)
}
if err := pushRelease(recipe, tagString); err != nil {
if err := pushRelease(newTagString, repo); err != nil {
logrus.Fatal(err)
}
@ -411,46 +416,17 @@ func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recip
// cleanUpTag removes a freshly created tag
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)
if err != nil {
return err
}
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
}
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{
Name: "sync",
Usage: "Sync recipe version label",
Usage: "Ensure recipe version labels are up-to-date",
Aliases: []string{"s"},
ArgsUsage: "<recipe> [<version>]",
Flags: []cli.Flag{
@ -29,21 +29,23 @@ var recipeSyncCommand = &cli.Command{
},
Description: `
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>
Where <version> can be specifed on the command-line or Abra can attempt to
auto-generate it for you. The <recipe> configuration will be updated on the
local file system.
The <version> is determined by the recipe maintainer and is specified on the
command-line. The <recipe> configuration will be updated on the local file
system.
You may invoke this command in "wizard" mode and be prompted for input:
abra recipe sync
`,
Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipeWithPrompt(c)
mainApp, err := internal.GetMainAppImage(recipe)
if err != nil {
logrus.Fatal(err)
}
mainApp := internal.GetMainApp(recipe)
imagesTmp, err := getImageVersions(recipe)
if err != nil {
@ -61,21 +63,16 @@ local file system.
if len(tags) == 0 && nextTag == "" {
logrus.Warnf("no git tags found for %s", recipe.Name)
fmt.Println(fmt.Sprintf(`
The following options are two types of initial semantic version that you can
pick for %s that will be published in the recipe catalogue. This follows the
semver convention (more on https://semver.org), here is a short cheatsheet
The following options are two types of initial version that you can pick for
the first published version of %s that will be in the recipe catalogue. This
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
you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
using the "x" part when things are stable.
0.1.0 -> development release, still hacking
1.0.0 -> public release, assumed to be working
1.0.0: public release, assumed to be working. you already have a stable
and reliable deployment of this app and feel relatively confident
about it.
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.
In other words, if you want people to be able alpha test your current config
for %s but don't think it is quite ready and reliable, go with 0.1.0 and people
will know that things are likely to change.
`, recipe.Name, recipe.Name))
var chosenVersion string
@ -98,7 +95,7 @@ likely to change.
}
if nextTag == "" {
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
recipeDir := path.Join(config.APPS_DIR, recipe.Name)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
logrus.Fatal(err)

View File

@ -9,10 +9,9 @@ import (
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"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
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:
abra recipe upgrade
`,
BashComplete: autocomplete.RecipeNameComplete,
ArgsUsage: "<recipe>",
ArgsUsage: "<recipe>",
Flags: []cli.Flag{
internal.PatchFlag,
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
versionsPresent := false
recipeDir := path.Join(config.RECIPES_DIR, recipe.Name)
recipeDir := path.Join(config.ABRA_DIR, "apps", recipe.Name)
versionsPath := path.Join(recipeDir, "versions")
var servicePins = make(map[string]imgPin)
if _, err := os.Stat(versionsPath); err == nil {
@ -115,26 +109,23 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image)
image = recipePkg.StripTagMeta(image)
switch img.(type) {
case reference.NamedTagged:
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
logrus.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
}
default:
logrus.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name)
continue
if strings.Contains(image, "library") {
// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
// postgres:<tag>, i.e. images which do not have a username in the
// first position of the string
image = strings.Split(image, "/")[1]
}
semverLikeTag := true
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) {
logrus.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag())
semverLikeTag = false
}
tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag())
if err != nil {
logrus.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name)
continue
if err != nil && semverLikeTag {
logrus.Fatal(err)
}
logrus.Debugf("parsed %s for %s", tag, service.Name)
var compatible []tagcmp.Tag
for _, regVersion := range regVersions {
other, err := tagcmp.Parse(regVersion.Name)
@ -151,17 +142,17 @@ You may invoke this command in "wizard" mode and be prompted for input:
sort.Sort(tagcmp.ByTagDesc(compatible))
if len(compatible) == 0 {
if len(compatible) == 0 && semverLikeTag {
logrus.Info(fmt.Sprintf("no new versions available for %s, %s is the latest", image, tag))
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 {
logrus.Fatal(err)
}
compatibleStrings := []string{"skip"}
var compatibleStrings []string
for _, compat := range compatible {
skip := false
for _, catlVersion := range catlVersions {
@ -191,13 +182,13 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
if contains {
logrus.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
logrus.Infof("Upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString)
} else {
logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString)
continue
}
} else {
logrus.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
logrus.Fatalf("Service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String())
continue
}
} else {
@ -214,16 +205,16 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
if upgradeTag == "" {
logrus.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image)
logrus.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants.", tag.String(), compatible[0].String(), image)
continue
}
} 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()) {
tag := img.(reference.NamedTagged).Tag()
logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag))
msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
compatibleStrings = []string{"skip"}
compatibleStrings = []string{}
for _, regVersion := range regVersions {
compatibleStrings = append(compatibleStrings, regVersion.Name)
}
@ -231,8 +222,6 @@ You may invoke this command in "wizard" mode and be prompted for input:
prompt := &survey.Select{
Message: msg,
Help: "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled",
VimMode: true,
Options: compatibleStrings,
}
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
@ -240,17 +229,10 @@ You may invoke this command in "wizard" mode and be prompted for input:
}
}
}
if upgradeTag != "skip" {
ok, err := recipe.UpdateTag(image, upgradeTag)
if err != nil {
logrus.Fatal(err)
}
if ok {
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
}
} else {
logrus.Warnf("not upgrading %s, skipping as requested", image)
if err := recipe.UpdateTag(image, upgradeTag); err != nil {
logrus.Fatal(err)
}
logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)
}
return nil

View File

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

View File

@ -4,9 +4,9 @@ import (
"fmt"
"strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"coopcloud.tech/abra/pkg/formatter"
"github.com/libdns/gandi"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
@ -46,7 +46,7 @@ are listed. This zone must already be created on your provider account.
logrus.Fatal(err)
}
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)
@ -55,7 +55,7 @@ are listed. This zone must already be created on your provider account.
}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol)
table := abraFormatter.CreateTable(tableCol)
for _, record := range records {
value := record.Value

View File

@ -3,11 +3,12 @@ package record
import (
"fmt"
"strconv"
"time"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/dns"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"coopcloud.tech/abra/pkg/formatter"
"github.com/libdns/gandi"
"github.com/libdns/libdns"
"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
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
abra record new
`,
Action: func(c *cli.Context) error {
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)
}
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 {
ipv4, err := dns.EnsureIPv4(zone)
if 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.Infof("automatically configuring @./*. A records for %s (--auto)", zone)
if err := autoConfigure(c, &provider, zone); err != nil {
logrus.Fatal(err)
}
return nil
}
@ -102,16 +91,11 @@ You may also invoke this command in "wizard" mode and be prompted for input
logrus.Fatal(err)
}
ttl, err := dns.GetTTL(internal.DNSTTL)
if err != nil {
return err
}
record := libdns.Record{
Type: internal.DNSType,
Name: internal.DNSName,
Value: internal.DNSValue,
TTL: ttl,
TTL: time.Duration(internal.DNSTTL),
}
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]
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol)
table := abraFormatter.CreateTable(tableCol)
value := createdRecord.Value
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 {
ttl, err := dns.GetTTL(internal.DNSTTL)
func autoConfigure(c *cli.Context, provider *gandi.Provider, zone string) error {
ipv4, err := dns.EnsureIPv4(zone)
if err != nil {
return err
}
@ -180,20 +164,20 @@ func autoConfigure(c *cli.Context, provider *gandi.Provider, zone, ipv4 string)
Type: "A",
Name: "@",
Value: ipv4,
TTL: ttl,
TTL: time.Duration(internal.DNSTTL),
}
wildcardRecord := libdns.Record{
Type: "A",
Name: "*",
Value: ipv4,
TTL: ttl,
TTL: time.Duration(internal.DNSTTL),
}
records := []libdns.Record{atRecord, wildcardRecord}
tableCol := []string{"type", "name", "value", "TTL", "priority"}
table := formatter.CreateTable(tableCol)
table := abraFormatter.CreateTable(tableCol)
for _, record := range records {
existingRecords, err := provider.GetRecords(c.Context, zone)
@ -201,20 +185,15 @@ func autoConfigure(c *cli.Context, provider *gandi.Provider, zone, ipv4 string)
return err
}
discovered := false
for _, existingRecord := range existingRecords {
if existingRecord.Type == record.Type &&
existingRecord.Name == record.Name &&
existingRecord.Value == record.Value {
logrus.Warnf("%s record: %s %s for %s already exists?", record.Type, record.Name, record.Value, zone)
discovered = true
logrus.Warnf("%s record for %s already exists?", record.Type, zone)
continue
}
}
if discovered {
continue
}
createdRecords, err := provider.SetRecords(
c.Context,
zone,

View File

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

View File

@ -4,9 +4,9 @@ import (
"fmt"
"strconv"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
gandiPkg "coopcloud.tech/abra/pkg/dns/gandi"
"coopcloud.tech/abra/pkg/formatter"
"github.com/AlecAivazis/survey/v2"
"github.com/libdns/gandi"
"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)
}
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 {
@ -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"}
table := formatter.CreateTable(tableCol)
table := abraFormatter.CreateTable(tableCol)
value := toDelete.Value
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()
if !internal.NoInput {
response := false
prompt := &survey.Confirm{
Message: "continue with record deletion?",
}
response := false
prompt := &survey.Confirm{
Message: "continue with record deletion?",
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
logrus.Fatal("exiting as requested")
}
if !response {
logrus.Fatal("exiting as requested")
}
_, 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)
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)
}
}

View File

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

View File

@ -4,8 +4,8 @@ import (
"fmt"
"strings"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/libcapsul"
"github.com/AlecAivazis/survey/v2"
"github.com/hetznercloud/hcloud-go/hcloud"
@ -43,18 +43,13 @@ func newHetznerCloudVPS(c *cli.Context) error {
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"}
table := formatter.CreateTable(tableColumns)
table.Append([]string{
internal.HetznerCloudName,
internal.HetznerCloudType,
internal.HetznerCloudImage,
sshKeyIDs,
strings.Join(sshKeysRaw, "\n"),
internal.HetznerCloudLocation,
})
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.
`,
ArgsUsage: "<provider>",
Flags: []cli.Flag{
internal.ServerProviderFlag,

View File

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

View File

@ -8,7 +8,7 @@ import (
var ServerCommand = &cli.Command{
Name: "server",
Aliases: []string{"s"},
Usage: "Manage servers",
Usage: "Manage servers via 3rd party providers",
Description: `
These commands support creating, managing and removing servers using 3rd party
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"
)
// Version is the current version of Abra
// Version is the current version of abra.
var Version string
// Commit is the current git commit of Abra
// Commit is the current commit of abra.
var Commit string
func main() {

1
go.mod
View File

@ -27,7 +27,6 @@ require (
require (
coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e
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/docker/docker-credential-helpers v0.6.4 // 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/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/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/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=
@ -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-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-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-20210426230700-d19ff857e887/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
}
logrus.Debugf("retrieved %s for %s", app, appName)
logrus.Debugf("retrieved '%s' for '%s'", app, appName)
return app, nil
}

View File

@ -3,8 +3,8 @@ package autocomplete
import (
"fmt"
"coopcloud.tech/abra/pkg/catalogue"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/recipe"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
@ -27,7 +27,7 @@ func AppNameComplete(c *cli.Context) {
// RecipeNameComplete completes recipe names
func RecipeNameComplete(c *cli.Context) {
catl, err := recipe.ReadRecipeCatalogue()
catl, err := catalogue.ReadRecipeCatalogue()
if err != nil {
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
}
logrus.Debugf("created client for %s", contextName)
logrus.Debugf("created client for '%s'", contextName)
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 {
return err
}
logrus.Debugf("created the %s context", contextName)
logrus.Debugf("created the '%s' context", contextName)
return nil
}
@ -72,6 +72,8 @@ func DeleteContext(name string) error {
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.CurrentContext = ""
if err := cfg.Save(); err != nil {

View File

@ -1,11 +1,11 @@
package client
import (
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"strings"
"coopcloud.tech/abra/pkg/web"
@ -35,29 +35,25 @@ func GetRegistryTags(image string) (RawTags, error) {
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.
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)
tokenURL := "https://auth.docker.io/token"
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)
req, err := retryablehttp.NewRequest("GET", fullURL, nil)
if err != nil {
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()
res, err := client.Do(req)
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
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)
tag := image.(reference.NamedTagged).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
}
token, err := getRegv2Token(cl, image, registryUsername, registryPassword)
token, err := getRegv2Token(cl, image)
if err != nil {
return "", err
}
@ -186,7 +182,7 @@ func GetTagDigest(cl *client.Client, image reference.Named, registryUsername, re
}
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

View File

@ -16,26 +16,26 @@ import (
)
// UpdateTag updates an image tag in-place on file system local compose files.
func UpdateTag(pattern, image, tag, recipeName string) (bool, error) {
func UpdateTag(pattern, image, tag, recipeName string) error {
composeFiles, err := filepath.Glob(pattern)
if err != nil {
return false, 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 {
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)
if err != nil {
return false, err
return err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return false, err
return err
}
for _, service := range compose.Services {
@ -45,42 +45,40 @@ func UpdateTag(pattern, image, tag, recipeName string) (bool, error) {
img, _ := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
var composeTag string
switch img.(type) {
case reference.NamedTagged:
composeTag = img.(reference.NamedTagged).Tag()
default:
// unable to parse, typically image missing tag
return false, nil
return err
}
composeImage := reference.Path(img)
if strings.Contains(composeImage, "library") {
// ParseNormalizedNamed prepends 'library' to images like nginx:<tag>,
// postgres:<tag>, i.e. images which do not have a username in the
// first position of the string
composeImage = strings.Split(composeImage, "/")[1]
}
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 {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
return err
}
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
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 {
return true, err
return err
}
}
}
}
return false, nil
return nil
}
// UpdateLabel updates a label in-place on file system local compose files.
@ -95,7 +93,7 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
for _, composeFile := range composeFiles {
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)
if err != nil {
return err
@ -148,8 +146,8 @@ func UpdateLabel(pattern, serviceName, label, recipeName string) error {
}
if !discovered {
logrus.Warn("no existing label found, automagic insertion not supported yet")
logrus.Fatalf("add '- \"%s\"' manually to the 'app' service in %s", label, composeFile)
logrus.Warn("no existing label found, cannot continue...")
logrus.Fatalf("add \"%s\" manually, automagic insertion not supported yet", label)
}
}

View File

@ -2,13 +2,12 @@ package config
import (
"fmt"
"html/template"
"io/ioutil"
"os"
"path"
"strings"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/cli/formatter"
"coopcloud.tech/abra/pkg/upstream/convert"
loader "coopcloud.tech/abra/pkg/upstream/stack"
stack "coopcloud.tech/abra/pkg/upstream/stack"
@ -43,17 +42,13 @@ type App struct {
Path string
}
// StackName gets what the docker safe stack name is for the app. This should
// not not shown to the user, use a.Name for that. Give the output of this
// command to Docker only.
// StackName gets what the docker safe stack name is for the app
func (a App) StackName() string {
if _, exists := a.Env["STACK_NAME"]; exists {
return a.Env["STACK_NAME"]
}
stackName := SanitiseAppName(a.Name)
a.Env["STACK_NAME"] = stackName
return stackName
}
@ -101,14 +96,14 @@ func (a ByName) Less(i, j int) bool {
func readAppEnvFile(appFile AppFile, name AppName) (App, error) {
env, err := ReadEnv(appFile.Path)
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)
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
@ -140,7 +135,7 @@ func LoadAppFiles(servers ...string) (AppFiles, error) {
if servers[0] == "" {
// Empty servers flag, one string will always be passed
var err error
servers, err = GetAllFoldersInDirectory(SERVERS_DIR)
servers, err = GetAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil {
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, ", "))
for _, server := range servers {
serverDir := path.Join(SERVERS_DIR, server)
serverDir := path.Join(ABRA_SERVER_FOLDER, server)
files, err := getAllFilesInDirectory(serverDir)
if err != nil {
return nil, err
}
for _, file := range files {
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{
Path: appFilePath,
Server: server,
@ -253,8 +248,8 @@ func GetAppNames() ([]string, error) {
}
// TemplateAppEnvSample copies the example env file for the app into the users env files
func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
envSamplePath := path.Join(RECIPES_DIR, recipeName, ".env.sample")
func TemplateAppEnvSample(recipe, appName, server, domain string) error {
envSamplePath := path.Join(ABRA_DIR, "apps", recipe, ".env.sample")
envSample, err := ioutil.ReadFile(envSamplePath)
if err != nil {
return err
@ -265,27 +260,15 @@ func TemplateAppEnvSample(recipeName, appName, server, domain string) error {
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)
if err != nil {
return err
}
file, err := os.OpenFile(appEnvPath, os.O_RDWR, 0664)
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)
logrus.Debugf("copied %s to %s", envSamplePath, appEnvPath)
return nil
}
@ -331,6 +314,9 @@ func GetAppStatuses(appFiles AppFiles) (map[string]map[string]string, error) {
if version, ok := service.Spec.Labels[labelKey]; ok {
result["version"] = version
} 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
}
@ -350,7 +336,7 @@ func GetAppComposeFiles(recipe string, appEnv AppEnv) ([]string, error) {
if _, ok := appEnv["COMPOSE_FILE"]; !ok {
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)
return composeFiles, nil
}
@ -359,7 +345,7 @@ func GetAppComposeFiles(recipe string, appEnv AppEnv) ([]string, error) {
envVars := strings.Split(composeFileEnvVar, ":")
logrus.Debugf("COMPOSE_FILE detected (%s), loading %s", composeFileEnvVar, strings.Join(envVars, ", "))
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)
}

View File

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

View File

@ -15,18 +15,16 @@ import (
)
var ABRA_DIR = os.ExpandEnv("$HOME/.abra")
var SERVERS_DIR = path.Join(ABRA_DIR, "servers")
var RECIPES_DIR = path.Join(ABRA_DIR, "apps")
var VENDOR_DIR = path.Join(ABRA_DIR, "vendor")
var RECIPES_JSON = path.Join(ABRA_DIR, "catalogue", "recipes.json")
var ABRA_SERVER_FOLDER = path.Join(ABRA_DIR, "servers")
var APPS_JSON = path.Join(ABRA_DIR, "catalogue", "recipes.json")
var APPS_DIR = path.Join(ABRA_DIR, "apps")
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.
func GetServers() ([]string, error) {
var servers []string
servers, err := GetAllFoldersInDirectory(SERVERS_DIR)
servers, err := GetAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil {
return servers, err
}
@ -52,13 +50,13 @@ func ReadEnv(filePath string) (AppEnv, error) {
// ReadServerNames retrieves all server names.
func ReadServerNames() ([]string, error) {
serverNames, err := GetAllFoldersInDirectory(SERVERS_DIR)
serverNames, err := GetAllFoldersInDirectory(ABRA_SERVER_FOLDER)
if err != nil {
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
}
@ -126,6 +124,17 @@ func GetAllFoldersInDirectory(directory string) ([]string, error) {
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.
func ReadAbraShEnvVars(abraSh string) (map[string]string, error) {
envVars := make(map[string]string)

View File

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

View File

@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"coopcloud.tech/abra/pkg/formatter"
abraFormatter "coopcloud.tech/abra/cli/formatter"
"github.com/AlecAivazis/survey/v2"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
@ -14,8 +14,8 @@ import (
)
// 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
// choose. A count of 0 is handled gracefully.
// of containers does not match expectedN, then a prompt is presented to let
// the user choose.
func GetContainer(c context.Context, cl *client.Client, filters filters.Args, prompt bool) (types.Container, error) {
containerOpts := types.ContainerListOptions{Filters: filters}
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 {
containerName := strings.Join(container.Names, " ")
trimmed := strings.TrimPrefix(containerName, "/")
created := formatter.HumanDuration(container.Created)
created := abraFormatter.HumanDuration(container.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()
ips, err := resolver.LookupIPAddr(ctx, domainName)
@ -94,12 +94,3 @@ func EnsureDomainsResolveSameIPv4(domainName, server string) (string, error) {
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"
"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/sirupsen/logrus"
)
@ -14,10 +15,10 @@ import (
// Clone runs a git clone which accounts for different default branches.
func Clone(dir, url string) error {
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})
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{
URL: url,
Tags: git.AllTags,
@ -31,10 +32,77 @@ func Clone(dir, url string) error {
return err
}
}
logrus.Debugf("%s has been git cloned successfully", dir)
logrus.Debugf("'%s' has been git cloned successfully", dir)
} else {
logrus.Debugf("%s already exists", dir)
logrus.Debugf("'%s' already exists, doing nothing", dir)
}
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
func Commit(repoPath, glob, commitMessage string, dryRun bool) error {
func Commit(repoPath, glob, commitMessage string, dryRun, push bool) error {
if commitMessage == "" {
return fmt.Errorf("no commit message specified?")
}
@ -47,9 +47,18 @@ func Commit(repoPath, glob, commitMessage string, dryRun bool) error {
if err != nil {
return err
}
logrus.Debug("git changes commited")
logrus.Info("changes commited")
} 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

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.
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)
if err != nil {
@ -34,8 +34,10 @@ func GetRecipeHead(recipeName string) (*plumbing.Reference, error) {
}
// IsClean checks if a repo has unstaged changes
func IsClean(repoPath string) (bool, error) {
repo, err := git.PlainOpen(repoPath)
func IsClean(recipeName string) (bool, error) {
recipeDir := path.Join(config.ABRA_DIR, "apps", recipeName)
repo, err := git.PlainOpen(recipeDir)
if err != nil {
return false, err
}
@ -60,9 +62,9 @@ func IsClean(repoPath string) (bool, error) {
}
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 {
logrus.Debugf("discovered clean git status in %s", repoPath)
logrus.Debugf("discovered clean git status for %s repository", recipeName)
}
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
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"strings"
"time"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/compose"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"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"
"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"
// 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.
type Recipe struct {
Name string
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
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 {
return err
}
@ -163,24 +34,20 @@ func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
}
// UpdateTag updates a recipe tag
func (r Recipe) UpdateTag(image, tag string) (bool, error) {
pattern := fmt.Sprintf("%s/%s/compose**yml", config.RECIPES_DIR, r.Name)
image = StripTagMeta(image)
ok, err := compose.UpdateTag(pattern, image, tag, r.Name)
if err != nil {
return false, err
func (r Recipe) UpdateTag(image, tag string) error {
pattern := fmt.Sprintf("%s/%s/compose**yml", config.APPS_DIR, r.Name)
if err := compose.UpdateTag(pattern, image, tag, r.Name); err != nil {
return err
}
return ok, nil
return nil
}
// Tags list the recipe tags
func (r Recipe) Tags() ([]string, error) {
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 {
return tags, err
}
@ -197,7 +64,7 @@ func (r Recipe) Tags() ([]string, error) {
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
}
@ -208,7 +75,7 @@ func Get(recipeName string) (Recipe, error) {
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)
if err != nil {
return Recipe{}, err
@ -218,7 +85,7 @@ func Get(recipeName string) (Recipe, error) {
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)
if err != nil {
return Recipe{}, err
@ -230,25 +97,16 @@ func Get(recipeName string) (Recipe, error) {
return Recipe{}, err
}
meta, err := GetRecipeMeta(recipeName)
if err != nil {
return Recipe{}, err
}
return Recipe{
Name: recipeName,
Config: config,
Meta: meta,
}, nil
return Recipe{Name: recipeName, Config: config}, nil
}
// EnsureExists ensures that a recipe is locally cloned
func EnsureExists(recipeName string) error {
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
func EnsureExists(recipe string) error {
recipeDir := path.Join(config.ABRA_DIR, "apps", strings.ToLower(recipe))
if _, err := os.Stat(recipeDir); os.IsNotExist(err) {
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 {
return err
}
@ -263,15 +121,15 @@ func EnsureExists(recipeName string) error {
// EnsureVersion checks whether a specific version exists for a recipe.
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 {
return err
}
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 {
@ -303,7 +161,7 @@ func EnsureVersion(recipeName, version string) error {
logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName)
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
}
@ -326,11 +184,11 @@ func EnsureVersion(recipeName, version string) error {
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 {
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 {
return err
}
@ -355,15 +213,20 @@ func EnsureLatest(recipeName string) error {
return err
}
branch, err := gitPkg.GetCurrentBranch(repo)
if err != nil {
return 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", path.Join(config.APPS_DIR, recipeName))
return err
}
branch = "main"
}
refName := fmt.Sprintf("refs/heads/%s", branch)
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(branch),
Branch: plumbing.ReferenceName(refName),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
@ -383,10 +246,9 @@ func ChaosVersion(recipeName string) (string, error) {
return version, err
}
version = formatter.SmallSHA(head.String())
version = head.String()[:8]
recipeDir := path.Join(config.RECIPES_DIR, recipeName)
isClean, err := gitPkg.IsClean(recipeDir)
isClean, err := gitPkg.IsClean(recipeName)
if err != nil {
return version, err
}
@ -402,7 +264,7 @@ func ChaosVersion(recipeName string) (string, error) {
func GetRecipesLocal() ([]string, error) {
var recipes []string
recipes, err := config.GetAllFoldersInDirectory(config.RECIPES_DIR)
recipes, err := config.GetAllFoldersInDirectory(config.APPS_DIR)
if err != nil {
return recipes, err
}
@ -423,644 +285,8 @@ func GetVersionLabelLocal(recipe Recipe) (string, error) {
}
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
}
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)
path = StripTagMeta(path)
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
}
// StripTagMeta strips front-matter image tag data that we don't need for parsing.
func StripTagMeta(image string) string {
originalImage := image
if strings.Contains(image, "docker.io") {
image = strings.Split(image, "/")[1]
}
if strings.Contains(image, "library") {
image = strings.Split(image, "/")[1]
}
if originalImage != image {
logrus.Debugf("stripped %s to %s for parsing", originalImage, image)
}
return image
}

View File

@ -19,13 +19,13 @@ func PassInsertSecret(secretValue, secretName, appName, server string) error {
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 {
return err
}
logrus.Infof("%s inserted into pass store", secretName)
logrus.Infof("'%s' inserted into pass store", secretName)
return nil
}
@ -41,13 +41,13 @@ func PassRmSecret(secretName, appName, server string) error {
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 {
return err
}
logrus.Infof("%s removed from pass store", secretName)
logrus.Infof("'%s' removed from pass store", secretName)
return nil
}

View File

@ -34,7 +34,7 @@ func GeneratePasswords(count, length uint) ([]string, error) {
return nil, err
}
logrus.Debugf("generated %s", strings.Join(passwords, ", "))
logrus.Debugf("generated '%s'", strings.Join(passwords, ", "))
return passwords, nil
}
@ -53,7 +53,7 @@ func GeneratePassphrases(count uint) ([]string, error) {
return nil, err
}
logrus.Debugf("generated %s", strings.Join(passphrases, ", "))
logrus.Debugf("generated '%s'", strings.Join(passphrases, ", "))
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
}
// TODO: should probably go in the config/app package?
func ParseSecretEnvVarName(secretEnvVar string) string {
withoutPrefix := strings.TrimPrefix(secretEnvVar, "SECRET_")
withoutSuffix := strings.TrimSuffix(withoutPrefix, "_VERSION")
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
}
// TODO: should probably go in the config/app package?
func ParseGeneratedSecretName(secret string, appEnv config.App) string {
name := fmt.Sprintf("%s_", appEnv.StackName())
withoutAppName := strings.TrimPrefix(secret, name)
idx := strings.LastIndex(withoutAppName, "_")
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
}
// TODO: should probably go in the config/app package?
func ParseSecretEnvVarValue(secret string) (secretValue, error) {
values := strings.Split(secret, "#")
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 {
@ -110,7 +113,7 @@ func ParseSecretEnvVarValue(secret string) (secretValue, error) {
}
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
}
@ -129,7 +132,7 @@ func GenerateSecrets(secretEnvVars map[string]string, appName, server string) (m
return
}
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 {
passwords, err := GeneratePasswords(1, uint(secretValue.Length))
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
}

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) {
var hostConfig HostConfig
if hostname == "" {
if hostname = ssh_config.Get(hostname, "Hostname"); hostname == "" {
logrus.Debugf("no hostname found in SSH config, assuming %s", hostname)
}
var host, idf string
if host = ssh_config.Get(hostname, "Hostname"); host == "" {
logrus.Debugf("no hostname found in SSH config, assuming %s", hostname)
host = hostname
}
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
idf, err = identityFileAbsPath(idf)
if err != nil {
return hostConfig, err
}
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.User = username

View File

@ -188,14 +188,14 @@ func ignorableCloseError(err error) bool {
func (c *commandConn) CloseRead() error {
// NOTE: maybe already closed here
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)
}
c.stdioClosedMu.Lock()
c.stdoutClosed = true
c.stdioClosedMu.Unlock()
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)
}
return nil
@ -212,14 +212,14 @@ func (c *commandConn) Read(p []byte) (int, error) {
func (c *commandConn) CloseWrite() error {
// NOTE: maybe already closed here
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)
}
c.stdioClosedMu.Lock()
c.stdinClosed = true
c.stdioClosedMu.Unlock()
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)
}
return nil
@ -239,7 +239,7 @@ func (c *commandConn) Close() error {
logrus.Warnf("commandConn.Close: CloseRead: %v", err)
}
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)
}
return err

View File

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

View File

@ -399,6 +399,7 @@ func convertServiceNetworks(
return nets, nil
}
// TODO: fix secrets API so that SecretAPIClient is not required here
func convertServiceSecrets(
client client.SecretAPIClient,
namespace Namespace,
@ -441,6 +442,8 @@ func convertServiceSecrets(
// required by the serivce. Unlike convertServiceSecrets, this takes the whole
// ServiceConfig, because some Configs may be needed as a result of other
// fields (like CredentialSpecs).
//
// TODO: fix configs API so that ConfigsAPIClient is not required here
func convertServiceConfigObjs(
client client.ConfigAPIClient,
namespace Namespace,
@ -623,6 +626,7 @@ func convertHealthcheck(healthcheck *composetypes.HealthCheckConfig) (*container
}
func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*swarm.RestartPolicy, error) {
// TODO: log if restart is being ignored
if source == nil {
policy, err := opts.ParseRestartPolicy(restart)
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.
func IsDeployed(ctx context.Context, cl *dockerclient.Client, stackName string) (bool, string, error) {
version := "unknown"
version := ""
isDeployed := false
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
}
logrus.Debugf("%s has been detected as not deployed", stackName)
logrus.Debugf("'%s' has been detected as not deployed", stackName)
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
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()
if err := validateResolveImageFlag(&opts); err != nil {
@ -170,7 +170,7 @@ func RunDeploy(cl *dockerclient.Client, opts Deploy, cfg *composetypes.Config, a
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
@ -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)
if opts.Prune {
@ -224,7 +224,7 @@ func deployCompose(ctx context.Context, cl *dockerclient.Client, opts Deploy, co
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{} {
@ -340,8 +340,7 @@ func deployServices(
namespace convert.Namespace,
sendAuth bool,
resolveImage string,
appName string,
dontWait bool) error {
recipeName string) error {
existingServices, err := GetStackServices(ctx, cl, namespace.Name())
if err != nil {
return err
@ -360,6 +359,18 @@ func deployServices(
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 {
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
// 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
response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts)
@ -427,19 +439,14 @@ func deployServices(
for _, serviceName := range serviceIDs {
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, ", "))
ch := make(chan error, len(serviceIDs))
for serviceID, serviceName := range serviceIDs {
logrus.Debugf("waiting on %s to converge", serviceName)
go func(sID, sName, aName string) {
ch <- WaitOnService(ctx, cl, sID, aName)
}(serviceID, serviceName, appName)
go func(sID, sName, rName string) {
ch <- waitOnService(ctx, cl, sID, sName, rName)
}(serviceID, serviceName, recipeName)
}
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/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)
pipeReader, pipeWriter := io.Pipe()
@ -479,31 +486,27 @@ func WaitOnService(ctx context.Context, cl *dockerclient.Client, serviceID, appN
go io.Copy(ioutil.Discard, pipeReader)
timeout := 50 * time.Second
timeout := 60 * time.Second
select {
case err := <-errChan:
return err
case <-time.After(timeout):
return fmt.Errorf(fmt.Sprintf(`
%s has not converged (%s second timeout reached).
%s has still not converged (%s second timeout reached)
This does not necessarily mean your deployment has failed, it may just be that
the app is taking longer to deploy based on your server resources or network
latency.
You can track latest deployment status with:
abra app ps --watch %s
And inspect the logs with:
latency. Please run the following the inspect the logs of your deployed app:
abra app logs %s
If a service is failing to even start, try smoke out the error with:
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
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
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"
RC_VERSION="0.4.0-alpha-rc4"
RC_VERSION="0.4.0-alpha"
RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$RC_VERSION"
for arg in "$@"; do
@ -37,28 +37,29 @@ function print_checksum_error {
function install_abra_release {
mkdir -p "$HOME/.local/bin"
if ! type "wget" > /dev/null 2>&1; then
echo "'wget' is not installed, cannot proceed..."
if ! type "curl" > /dev/null 2>&1; then
echo "'curl' is not installed, cannot proceed..."
echo "perhaps try installing manually via the releases URL?"
echo "https://git.coopcloud.tech/coop-cloud/abra/releases"
exit 1
fi
# FIXME: support different architectures
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m)
FILENAME="abra_"$ABRA_VERSION"_"$PLATFORM""
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'
json=$(wget -q -O- $ABRA_RELEASE_URL)
json=$(curl -s $ABRA_RELEASE_URL)
release_url=$(echo $json | sed -En $sed_command_rel)
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')
echo "downloading $ABRA_VERSION $PLATFORM binary release for abra..."
wget -q "$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')
echo "checking if checksums match..."
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 int-core -v 192.157.2.21 coopcloud.tech
$ABRA record list -p gandi coopcloud.tech | grep -q int-core
$ABRA -n record rm -p gandi -t A -n int-core 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 int-core
$ABRA server ls | grep -q int-core
$ABRA -n server rm -s -p hetzner-cloud --hn int-core
# ========================================================================
# 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,59 +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 deploy --force <app>`
- `abra app deploy --chaos <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 ls -t <recipe>`
- `abra app ls -s <server>`
- `abra app ls -s <server> -t <recipe>`
- `abra app ls -s <server> -t <recipe> -S`
- `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>`