diff --git a/cli/app/deploy.go b/cli/app/deploy.go index 22aa7e5a..c9e4e9a3 100644 --- a/cli/app/deploy.go +++ b/cli/app/deploy.go @@ -206,10 +206,15 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`, log.Fatal(err) } + toDeployChaosVersionLabel := toDeployChaosVersion + if app.Recipe.Dirty { + toDeployChaosVersionLabel = fmt.Sprintf("%s%s", toDeployChaosVersion, config.DIRTY_DEFAULT) + } + appPkg.ExposeAllEnv(stackName, compose, app.Env) appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name) appPkg.SetChaosLabel(compose, stackName, internal.Chaos) - appPkg.SetChaosVersionLabel(compose, stackName, toDeployChaosVersion) + appPkg.SetChaosVersionLabel(compose, stackName, toDeployChaosVersionLabel) appPkg.SetUpdateLabel(compose, stackName, app.Env) envVars, err := appPkg.CheckEnv(app) @@ -275,7 +280,6 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`, if toDeployChaosVersion != config.CHAOS_DEFAULT { app.Recipe.Version = toDeployChaosVersion } - log.Debugf("choosing %s as version to save to env file", app.Recipe.Version) if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil { log.Fatalf("writing new recipe version in env file: %s", err) } diff --git a/cli/app/labels.go b/cli/app/labels.go new file mode 100644 index 00000000..e452b73d --- /dev/null +++ b/cli/app/labels.go @@ -0,0 +1,139 @@ +package app + +import ( + "context" + "fmt" + "sort" + + "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/autocomplete" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/formatter" + "coopcloud.tech/abra/pkg/log" + "coopcloud.tech/abra/pkg/upstream/convert" + composetypes "github.com/docker/cli/cli/compose/types" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + dockerClient "github.com/docker/docker/client" + "github.com/spf13/cobra" +) + +var AppLabelsCommand = &cobra.Command{ + Use: "labels [flags]", + Aliases: []string{"lb"}, + Short: "Show deployment labels", + Long: "Both local recipe and live deployment labels are shown.", + Example: " abra app labels 1312.net", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func( + cmd *cobra.Command, + args []string, + toComplete string) ([]string, cobra.ShellCompDirective) { + return autocomplete.AppNameComplete() + }, + Run: func(cmd *cobra.Command, args []string) { + app := internal.ValidateApp(args) + + if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil { + log.Fatal(err) + } + + cl, err := client.New(app.Server) + if err != nil { + log.Fatal(err) + } + + remoteLabels, err := getLabels(cl, app.StackName()) + if err != nil { + log.Fatal(err) + } + + rows := [][]string{ + {"DEPLOYED LABELS", "---"}, + } + + remoteLabelKeys := make([]string, 0, len(remoteLabels)) + for k := range remoteLabels { + remoteLabelKeys = append(remoteLabelKeys, k) + } + + sort.Strings(remoteLabelKeys) + + for _, k := range remoteLabelKeys { + rows = append(rows, []string{ + k, + remoteLabels[k], + }) + } + + if len(remoteLabelKeys) == 0 { + rows = append(rows, []string{"unknown"}) + } + + rows = append(rows, []string{"RECIPE LABELS", "---"}) + + config, err := app.Recipe.GetComposeConfig(app.Env) + if err != nil { + log.Fatal(err) + } + + var localLabelKeys []string + var appServiceConfig composetypes.ServiceConfig + for _, service := range config.Services { + if service.Name == "app" { + appServiceConfig = service + + for k := range service.Deploy.Labels { + localLabelKeys = append(localLabelKeys, k) + } + } + } + + sort.Strings(localLabelKeys) + + for _, k := range localLabelKeys { + rows = append(rows, []string{ + k, + appServiceConfig.Deploy.Labels[k], + }) + } + + overview := formatter.CreateOverview("LABELS OVERVIEW", rows) + fmt.Println(overview) + }, +} + +// getLabels reads docker labels from running services in the format of "coop-cloud.${STACK_NAME}.${LABEL}". +func getLabels(cl *dockerClient.Client, stackName string) (map[string]string, error) { + labels := make(map[string]string) + + filter := filters.NewArgs() + filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName)) + + services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter}) + if err != nil { + return labels, err + } + + for _, service := range services { + if service.Spec.Name != fmt.Sprintf("%s_app", stackName) { + continue + } + + for k, v := range service.Spec.Labels { + labels[k] = v + } + } + + return labels, nil +} + +func init() { + AppLabelsCommand.Flags().BoolVarP( + &internal.Chaos, + "chaos", + "C", + false, + "ignore uncommitted recipes changes", + ) +} diff --git a/cli/app/new.go b/cli/app/new.go index a9c63945..ceadc32c 100644 --- a/cli/app/new.go +++ b/cli/app/new.go @@ -201,8 +201,8 @@ var AppNewCommand = &cobra.Command{ log.Warnf( "secrets are %s shown again, please save them %s", - formatter.BoldStyle.Render("NOT"), - formatter.BoldStyle.Render("NOW"), + formatter.BoldUnderlineStyle.Render("NOT"), + formatter.BoldUnderlineStyle.Render("NOW"), ) } @@ -211,7 +211,6 @@ var AppNewCommand = &cobra.Command{ log.Fatal(err) } - log.Debugf("choosing %s as version to save to env file", recipeVersion) if err := app.WriteRecipeVersion(recipeVersion, false); err != nil { log.Fatalf("writing new recipe version in env file: %s", err) } diff --git a/cli/app/ps.go b/cli/app/ps.go index a1ff5f33..06816518 100644 --- a/cli/app/ps.go +++ b/cli/app/ps.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "coopcloud.tech/abra/cli/internal" appPkg "coopcloud.tech/abra/pkg/app" @@ -24,7 +25,7 @@ import ( var AppPsCommand = &cobra.Command{ Use: "ps [flags]", Aliases: []string{"p"}, - Short: "Check app status", + Short: "Check app deployment status", Args: cobra.ExactArgs(1), ValidArgsFunction: func( cmd *cobra.Command, @@ -57,9 +58,11 @@ var AppPsCommand = &cobra.Command{ statuses, err := appPkg.GetAppStatuses([]appPkg.App{app}, true) if statusMeta, ok := statuses[app.StackName()]; ok { if isChaos, exists := statusMeta["chaos"]; exists && isChaos == "true" { - chaosVersion, err = app.Recipe.ChaosVersion() - if err != nil { - log.Fatal(err) + if cVersion, exists := statusMeta["chaosVersion"]; exists { + chaosVersion = cVersion + if strings.HasSuffix(chaosVersion, config.DIRTY_DEFAULT) { + chaosVersion = formatter.BoldDirtyDefault(chaosVersion) + } } } } diff --git a/cli/app/rollback.go b/cli/app/rollback.go index 09ca206d..2b223610 100644 --- a/cli/app/rollback.go +++ b/cli/app/rollback.go @@ -235,7 +235,6 @@ beforehand.`, } app.Recipe.Version = chosenDowngrade - log.Debugf("choosing %s as version to save to env file", app.Recipe.Version) if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil { log.Fatalf("writing new recipe version in env file: %s", err) } diff --git a/cli/app/undeploy.go b/cli/app/undeploy.go index 3d1a99a1..f476cfa8 100644 --- a/cli/app/undeploy.go +++ b/cli/app/undeploy.go @@ -80,7 +80,6 @@ Passing "--prune/-p" does not remove those volumes.`, } } - log.Debugf("choosing %s as version to save to env file", deployMeta.Version) if err := app.WriteRecipeVersion(deployMeta.Version, false); err != nil { log.Fatalf("writing undeployed recipe version in env file: %s", err) } diff --git a/cli/app/upgrade.go b/cli/app/upgrade.go index d1f84ac4..617c8830 100644 --- a/cli/app/upgrade.go +++ b/cli/app/upgrade.go @@ -182,7 +182,7 @@ beforehand.`, if err != nil { log.Fatal(err) } - for _, version := range versions { + for _, version := range internal.SortVersionsDesc(versions) { parsedVersion, err := tagcmp.Parse(version) if err != nil { log.Fatal(err) @@ -289,7 +289,6 @@ beforehand.`, } app.Recipe.Version = chosenUpgrade - log.Debugf("choosing %s as version to save to env file", app.Recipe.Version) if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil { log.Fatalf("writing new recipe version in env file: %s", err) } diff --git a/cli/internal/deploy.go b/cli/internal/deploy.go index 4ea87bda..151c7b48 100644 --- a/cli/internal/deploy.go +++ b/cli/internal/deploy.go @@ -61,18 +61,22 @@ func NewVersionOverview( domain = config.NO_DOMAIN_DEFAULT } + upperKind := strings.ToUpper(kind) + rows := [][]string{ {"APP", domain}, {"RECIPE", app.Recipe.Name}, {"SERVER", server}, - {"DEPLOYED", deployedVersion}, - {"CURRENT CHAOS ", deployedChaosVersion}, - {fmt.Sprintf("TO %s", strings.ToUpper(kind)), toDeployVersion}, {"CONFIG", deployConfig}, + {"CURRENT DEPLOYMENT", "---"}, + {"VERSION", deployedVersion}, + {"CHAOS ", deployedChaosVersion}, + {upperKind, "---"}, + {"VERSION", toDeployVersion}, } overview := formatter.CreateOverview( - fmt.Sprintf("%s OVERVIEW", strings.ToUpper(kind)), + fmt.Sprintf("%s OVERVIEW", upperKind), rows, ) @@ -131,15 +135,25 @@ func DeployOverview( domain = config.NO_DOMAIN_DEFAULT } + deployedChaosVersion = formatter.BoldDirtyDefault(deployedChaosVersion) + + if app.Recipe.Dirty { + toDeployChaosVersion = formatter.BoldDirtyDefault(toDeployChaosVersion) + } + rows := [][]string{ {"APP", domain}, {"RECIPE", app.Recipe.Name}, {"SERVER", server}, - {"DEPLOYED", deployedVersion}, - {"CURRENT CHAOS ", deployedChaosVersion}, - {"TO DEPLOY", toDeployVersion}, - {"NEW CHAOS", toDeployChaosVersion}, {"CONFIG", deployConfig}, + + {"CURRENT DEPLOYMENT", "---"}, + {"VERSION", deployedVersion}, + {"CHAOS", deployedChaosVersion}, + + {"NEW DEPLOYMENT", "---"}, + {"VERSION", toDeployVersion}, + {"CHAOS", toDeployChaosVersion}, } overview := formatter.CreateOverview("DEPLOY OVERVIEW", rows) @@ -187,13 +201,18 @@ func UndeployOverview( domain = config.NO_DOMAIN_DEFAULT } + if app.Recipe.Dirty { + chaosVersion = formatter.BoldDirtyDefault(chaosVersion) + } + rows := [][]string{ {"APP", domain}, {"RECIPE", app.Recipe.Name}, {"SERVER", server}, + {"CONFIG", deployConfig}, + {"CURRENT DEPLOYMENT", "---"}, {"DEPLOYED", version}, {"CHAOS", chaosVersion}, - {"CONFIG", deployConfig}, } overview := formatter.CreateOverview("UNDEPLOY OVERVIEW", rows) diff --git a/cli/internal/deploy_test.go b/cli/internal/deploy_test.go new file mode 100644 index 00000000..ed260068 --- /dev/null +++ b/cli/internal/deploy_test.go @@ -0,0 +1,17 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSortVersionsDesc(t *testing.T) { + versions := SortVersionsDesc([]string{ + "0.2.3+1.2.2", + "1.0.0+2.2.2", + }) + + assert.Equal(t, "1.0.0+2.2.2", versions[0]) + assert.Equal(t, "0.2.3+1.2.2", versions[1]) +} diff --git a/cli/run.go b/cli/run.go index e6b31448..bb0797c2 100644 --- a/cli/run.go +++ b/cli/run.go @@ -187,6 +187,7 @@ func Run(version, commit string) { app.AppUndeployCommand, app.AppUpgradeCommand, app.AppVolumeCommand, + app.AppLabelsCommand, ) if err := rootCmd.Execute(); err != nil { diff --git a/pkg/app/app.go b/pkg/app/app.go index 992ac7a9..86c3a0c6 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -91,6 +91,17 @@ type App struct { Path string } +// String outputs a human-friendly string representation. +func (a App) String() string { + out := fmt.Sprintf("{name: %s, ", a.Name) + out += fmt.Sprintf("recipe: %s, ", a.Recipe) + out += fmt.Sprintf("domain: %s, ", a.Domain) + out += fmt.Sprintf("env %s, ", a.Env) + out += fmt.Sprintf("server %s, ", a.Server) + out += fmt.Sprintf("path %s}", a.Path) + return out +} + // Type aliases to make code hints easier to understand // AppName is AppName @@ -492,13 +503,13 @@ func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv envfile.AppEnv func ExposeAllEnv(stackName string, compose *composetypes.Config, appEnv envfile.AppEnv) { for _, service := range compose.Services { if service.Name == "app" { - log.Debugf("add the following environment to the app service config of %s:", stackName) + log.Debugf("adding env vars to %s service config", stackName) for k, v := range appEnv { _, exists := service.Environment[k] if !exists { value := v service.Environment[k] = &value - log.Debugf("add env var: %s value: %s to %s", k, value, stackName) + log.Debugf("%s: %s: %s", stackName, k, value) } } } @@ -567,16 +578,19 @@ func ReadAbraShCmdNames(abraSh string) ([]string, error) { return cmdNames, nil } -func (a App) WriteRecipeVersion(version string, dryRun bool) error { +// Wipe removes the version from the app .env file. +func (a App) WipeRecipeVersion() error { file, err := os.Open(a.Path) if err != nil { return err } defer file.Close() - skipped := false - scanner := bufio.NewScanner(file) - lines := []string{} + var ( + lines []string + scanner = bufio.NewScanner(file) + ) + for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") { @@ -589,13 +603,71 @@ func (a App) WriteRecipeVersion(version string, dryRun bool) error { continue } - if strings.Contains(line, version) { + splitted := strings.Split(line, ":") + lines = append(lines, splitted[0]) + } + + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + + if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil { + log.Fatal(err) + } + + log.Debugf("version wiped from %s.env", a.Domain) + + return nil +} + +// WriteRecipeVersion writes the recipe version to the app .env file. +func (a App) WriteRecipeVersion(version string, dryRun bool) error { + file, err := os.Open(a.Path) + if err != nil { + return err + } + defer file.Close() + + var ( + dirtyVersion string + skipped bool + lines []string + scanner = bufio.NewScanner(file) + ) + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") { + lines = append(lines, line) + continue + } + + if strings.HasPrefix(line, "#") { + lines = append(lines, line) + continue + } + + if strings.Contains(line, version) && !a.Recipe.Dirty && !strings.HasSuffix(line, config.DIRTY_DEFAULT) { skipped = true lines = append(lines, line) continue } splitted := strings.Split(line, ":") + + if a.Recipe.Dirty { + dirtyVersion = fmt.Sprintf("%s%s", version, config.DIRTY_DEFAULT) + if strings.Contains(line, dirtyVersion) { + skipped = true + lines = append(lines, line) + continue + } + + line = fmt.Sprintf("%s:%s", splitted[0], dirtyVersion) + lines = append(lines, line) + continue + } + line = fmt.Sprintf("%s:%s", splitted[0], version) lines = append(lines, line) } @@ -604,6 +676,10 @@ func (a App) WriteRecipeVersion(version string, dryRun bool) error { log.Fatal(err) } + if a.Recipe.Dirty && dirtyVersion != "" { + version = dirtyVersion + } + if !dryRun { if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil { log.Fatal(err) diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 3eb2d7dd..0a0f377a 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -198,3 +198,41 @@ func compareFilter(t *testing.T, f1 filters.Args, f2 map[string]map[string]bool) t.Errorf("filters mismatch (-want +got):\n%s", diff) } } + +func TestWriteRecipeVersionOverwrite(t *testing.T) { + app, err := appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName) + if err != nil { + t.Fatal(err) + } + + defer t.Cleanup(func() { + if err := app.WipeRecipeVersion(); err != nil { + t.Fatal(err) + } + }) + + assert.Equal(t, "", app.Recipe.Version) + + if err := app.WriteRecipeVersion("foo", false); err != nil { + t.Fatal(err) + } + + app, err = appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "foo", app.Recipe.Version) + + app.Recipe.Dirty = true + if err := app.WriteRecipeVersion("foo+U", false); err != nil { + t.Fatal(err) + } + + app, err = appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "foo+U", app.Recipe.Version) +} diff --git a/pkg/config/abra.go b/pkg/config/abra.go index 8be06fea..55358115 100644 --- a/pkg/config/abra.go +++ b/pkg/config/abra.go @@ -114,6 +114,8 @@ var ( // complained yet! CHAOS_DEFAULT = "false" + DIRTY_DEFAULT = "+U" + NO_DOMAIN_DEFAULT = "N/A" NO_VERSION_DEFAULT = "N/A" ) diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index a6279452..a5a8ab1e 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -13,11 +13,15 @@ import ( "github.com/docker/go-units" "golang.org/x/term" + "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/log" "github.com/schollz/progressbar/v3" ) var BoldStyle = lipgloss.NewStyle(). + Bold(true) + +var BoldUnderlineStyle = lipgloss.NewStyle(). Bold(true). Underline(true) @@ -102,7 +106,6 @@ func CreateOverview(header string, rows [][]string) string { var borderStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.ThickBorder()). Padding(0, 1, 0, 1). - MaxWidth(79). BorderForeground(lipgloss.Color("63")) var headerStyle = lipgloss.NewStyle(). @@ -110,9 +113,7 @@ func CreateOverview(header string, rows [][]string) string { Bold(true). PaddingBottom(1) - var leftStyle = lipgloss.NewStyle(). - Bold(true) - + var leftStyle = lipgloss.NewStyle() var rightStyle = lipgloss.NewStyle() var longest int @@ -138,10 +139,20 @@ func CreateOverview(header string, rows [][]string) string { offset = offset + " " } - renderedRows = append( - renderedRows, - horizontal(leftStyle.Render(row[0]), offset, rightStyle.Render(row[1])), - ) + rendered := horizontal(leftStyle.Render(row[0]), offset, rightStyle.Render(row[1])) + if row[1] == "---" { + rendered = horizontal( + leftStyle. + Bold(true). + Underline(true). + PaddingTop(1). + Render(row[0]), + offset, + rightStyle.Render(""), + ) + } + + renderedRows = append(renderedRows, rendered) } body := strings.Builder{} @@ -242,3 +253,13 @@ func ByteCountSI(b uint64) string { return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp]) } + +// BoldDirtyDefault ensures a dirty modifier is rendered in bold. +func BoldDirtyDefault(v string) string { + if strings.HasSuffix(v, config.DIRTY_DEFAULT) { + vBold := BoldStyle.Render(config.DIRTY_DEFAULT) + v = strings.Replace(v, config.DIRTY_DEFAULT, vBold, 1) + } + + return v +} diff --git a/pkg/formatter/formatter_test.go b/pkg/formatter/formatter_test.go new file mode 100644 index 00000000..a3fe8a9d --- /dev/null +++ b/pkg/formatter/formatter_test.go @@ -0,0 +1,11 @@ +package formatter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBoldDirtyDefault(t *testing.T) { + assert.Equal(t, "foo", BoldDirtyDefault("foo")) +} diff --git a/pkg/git/read.go b/pkg/git/read.go index eaa5d319..b2991357 100644 --- a/pkg/git/read.go +++ b/pkg/git/read.go @@ -1,6 +1,8 @@ package git import ( + "errors" + "fmt" "io/ioutil" "os" "os/user" @@ -17,12 +19,16 @@ import ( func IsClean(repoPath string) (bool, error) { repo, err := git.PlainOpen(repoPath) if err != nil { - return false, err + if errors.Is(err, git.ErrRepositoryNotExists) { + return false, git.ErrRepositoryNotExists + } + + return false, fmt.Errorf("unable to open %s: %s", repoPath, err) } worktree, err := repo.Worktree() if err != nil { - return false, err + return false, fmt.Errorf("unable to open worktree of %s: %s", repoPath, err) } patterns, err := GetExcludesFiles() @@ -36,14 +42,14 @@ func IsClean(repoPath string) (bool, error) { status, err := worktree.Status() if err != nil { - return false, err + return false, fmt.Errorf("unable to query status of %s: %s", repoPath, err) } if status.String() != "" { noNewline := strings.TrimSuffix(status.String(), "\n") - log.Debugf("discovered git status in %s: %s", repoPath, noNewline) + log.Debugf("git status: %s: %s", repoPath, noNewline) } else { - log.Debugf("discovered clean git status in %s", repoPath) + log.Debugf("git status: %s: clean", repoPath) } return status.IsClean(), nil diff --git a/pkg/git/read_test.go b/pkg/git/read_test.go new file mode 100644 index 00000000..5ad07cfe --- /dev/null +++ b/pkg/git/read_test.go @@ -0,0 +1,15 @@ +package git + +import ( + "errors" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" +) + +func TestIsClean(t *testing.T) { + isClean, err := IsClean("/tmp") + assert.Equal(t, isClean, false) + assert.True(t, errors.Is(err, git.ErrRepositoryNotExists)) +} diff --git a/pkg/recipe/git.go b/pkg/recipe/git.go index 8da23e8b..a304495d 100644 --- a/pkg/recipe/git.go +++ b/pkg/recipe/git.go @@ -137,8 +137,7 @@ func (r Recipe) EnsureIsClean() error { } if !isClean { - msg := "%s (%s) has locally unstaged changes? please commit/remove your changes before proceeding" - return fmt.Errorf(msg, r.Name, r.Dir) + return fmt.Errorf("%s (%s) has locally unstaged changes?", r.Name, r.Dir) } return nil @@ -230,8 +229,23 @@ func (r Recipe) EnsureUpToDate() error { return nil } +// IsDirty checks whether a recipe is dirty or not. N.B., if you call IsDirty +// from another Recipe method, you should propagate the pointer reference (*). +func (r *Recipe) IsDirty() error { + isClean, err := gitPkg.IsClean(r.Dir) + if err != nil { + return err + } + + if !isClean { + r.Dirty = true + } + + return nil +} + // ChaosVersion constructs a chaos mode recipe version. -func (r Recipe) ChaosVersion() (string, error) { +func (r *Recipe) ChaosVersion() (string, error) { var version string head, err := r.Head() @@ -241,15 +255,10 @@ func (r Recipe) ChaosVersion() (string, error) { version = formatter.SmallSHA(head.String()) - isClean, err := gitPkg.IsClean(r.Dir) - if err != nil { + if err := r.IsDirty(); err != nil { return version, err } - if !isClean { - version = fmt.Sprintf("%s+U", version) - } - return version, nil } diff --git a/pkg/recipe/git_test.go b/pkg/recipe/git_test.go new file mode 100644 index 00000000..4753c0ec --- /dev/null +++ b/pkg/recipe/git_test.go @@ -0,0 +1,39 @@ +package recipe + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsDirty(t *testing.T) { + r := Get("abra-test-recipe") + + if err := r.EnsureExists(); err != nil { + t.Fatal(err) + } + + if err := r.IsDirty(); err != nil { + t.Fatal(err) + } + + assert.False(t, r.Dirty) + + fpath := filepath.Join(r.Dir, "foo.txt") + f, err := os.Create(fpath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + defer t.Cleanup(func() { + os.Remove(fpath) + }) + + if err := r.IsDirty(); err != nil { + t.Fatal(err) + } + + assert.True(t, r.Dirty) +} diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index e00662ee..84b54624 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -2,6 +2,7 @@ package recipe import ( "encoding/json" + "errors" "fmt" "io/ioutil" "net/url" @@ -20,6 +21,7 @@ import ( "coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/web" "coopcloud.tech/tagcmp" + "github.com/go-git/go-git/v5" ) // RecipeCatalogueURL is the only current recipe catalogue available. @@ -131,7 +133,12 @@ func Get(name string) Recipe { log.Fatalf("version seems invalid: %s", name) } name = split[0] + version = split[1] + if strings.HasSuffix(version, config.DIRTY_DEFAULT) { + version = strings.Replace(split[1], config.DIRTY_DEFAULT, "", 1) + log.Debugf("removed dirty suffix from .env version: %s -> %s", split[1], version) + } } gitURL := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, name) @@ -151,7 +158,7 @@ func Get(name string) Recipe { dir := path.Join(config.RECIPES_DIR, escapeRecipeName(name)) - return Recipe{ + r := Recipe{ Name: name, Version: version, Dir: dir, @@ -163,11 +170,18 @@ func Get(name string) Recipe { SampleEnvPath: path.Join(dir, ".env.sample"), AbraShPath: path.Join(dir, "abra.sh"), } + + if err := r.IsDirty(); err != nil && !errors.Is(err, git.ErrRepositoryNotExists) { + log.Fatalf("failed to check git status of %s: %s", r.Name, err) + } + + return r } type Recipe struct { Name string Version string + Dirty bool // NOTE(d1): git terminology for unstaged changes Dir string GitURL string SSHURL string @@ -178,6 +192,21 @@ type Recipe struct { AbraShPath string } +// String outputs a human-friendly string representation. +func (r Recipe) String() string { + out := fmt.Sprintf("{name: %s, ", r.Name) + out += fmt.Sprintf("version : %s, ", r.Version) + out += fmt.Sprintf("dirty: %v, ", r.Dirty) + out += fmt.Sprintf("dir: %s, ", r.Dir) + out += fmt.Sprintf("git url: %s, ", r.GitURL) + out += fmt.Sprintf("ssh url: %s, ", r.SSHURL) + out += fmt.Sprintf("compose: %s, ", r.ComposePath) + out += fmt.Sprintf("readme: %s, ", r.ReadmePath) + out += fmt.Sprintf("sample env: %s, ", r.SampleEnvPath) + out += fmt.Sprintf("abra.sh: %s}", r.AbraShPath) + return out +} + func escapeRecipeName(recipeName string) string { recipeName = strings.ReplaceAll(recipeName, "/", "_") recipeName = strings.ReplaceAll(recipeName, ".", "_") diff --git a/pkg/recipe/recipe_test.go b/pkg/recipe/recipe_test.go index 5ba61e02..a2abcf11 100644 --- a/pkg/recipe/recipe_test.go +++ b/pkg/recipe/recipe_test.go @@ -105,3 +105,8 @@ func TestGetVersionLabelLocalDoesNotUseTimeoutLabel(t *testing.T) { assert.NotEqual(t, label, defaultTimeoutLabel) } } + +func TestDirtyMarkerRemoved(t *testing.T) { + r := Get("abra-test-recipe:1e83340e+U") + assert.Equal(t, "1e83340e", r.Version) +} diff --git a/pkg/upstream/stack/stack.go b/pkg/upstream/stack/stack.go index f38f5e39..418f1dd2 100644 --- a/pkg/upstream/stack/stack.go +++ b/pkg/upstream/stack/stack.go @@ -13,6 +13,7 @@ import ( stdlibErr "errors" + "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/upstream/convert" "github.com/docker/cli/cli/command/service/progress" @@ -112,7 +113,7 @@ func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string) IsDeployed: false, Version: "unknown", IsChaos: false, - ChaosVersion: "false", // NOTE(d1): match string type used on label + ChaosVersion: config.CHAOS_DEFAULT, } filter := filters.NewArgs() diff --git a/tests/integration/app_deploy.bats b/tests/integration/app_deploy.bats index 41e85423..cdd413b0 100644 --- a/tests/integration/app_deploy.bats +++ b/tests/integration/app_deploy.bats @@ -65,7 +65,6 @@ teardown(){ run $ABRA app deploy "$TEST_APP_DOMAIN" \ --chaos --no-input --no-converge-checks assert_success - assert_output --partial 'NEW CHAOS' } # bats test_tags=slow @@ -128,7 +127,6 @@ teardown(){ --no-input --no-converge-checks --chaos assert_success assert_output --partial "${wantHash:0:8}" - assert_output --partial 'NEW CHAOS' } # bats test_tags=slow @@ -347,3 +345,18 @@ teardown(){ run $ABRA app secret rm "$TEST_APP_DOMAIN" --all assert_success } + +# bats test_tags=slow +@test "chaos version label includes dirty marker" { + run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo" + assert_success + assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" + + run $ABRA app deploy "$TEST_APP_DOMAIN" \ + --no-input --no-converge-checks --chaos + assert_success + + run $ABRA app labels "$TEST_APP_DOMAIN" --chaos + assert_success + assert_output --regexp 'chaos-version.*+U' +} diff --git a/tests/integration/app_labels.bats b/tests/integration/app_labels.bats new file mode 100644 index 00000000..fb6d26c8 --- /dev/null +++ b/tests/integration/app_labels.bats @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +setup_file(){ + load "$PWD/tests/integration/helpers/common" + _common_setup + _add_server + _new_app +} + +teardown_file(){ + _rm_app + _rm_server + _reset_recipe +} + +setup(){ + load "$PWD/tests/integration/helpers/common" + _common_setup + _ensure_catalogue +} + +teardown(){ + _reset_recipe + _reset_app + _undeploy_app + _reset_tags + + run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo" + assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" +} + +@test "validate app argument" { + run $ABRA app labels + assert_failure + + run $ABRA app labels DOESNTEXIST + assert_failure +} + +@test "bail if unstaged changes and no --chaos" { + run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo" + assert_success + assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" status + assert_success + assert_output --partial 'foo' + + run $ABRA app labels "$TEST_APP_DOMAIN" --no-input + assert_failure +} + +@test "do not bail if unstaged changes and --chaos" { + run bash -c 'echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo"' + assert_success + assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" status + assert_success + assert_output --partial 'foo' + + run $ABRA app labels "$TEST_APP_DOMAIN" --chaos + assert_success +} + +@test "ensure recipe up to date if no --offline" { + wantHash=$(_get_n_hash 3) + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" reset --hard HEAD~3 + assert_success + + assert_equal $(_get_current_hash) "$wantHash" + + run $ABRA app labels "$TEST_APP_DOMAIN" + assert_success + + assert_equal $(_get_head_hash) $(_get_current_hash) +} + +@test "ensure recipe not up to date if --offline" { + _ensure_env_version "0.1.0+1.20.0" + latestRelease=$(_latest_release) + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -d "$latestRelease" + assert_success + + run $ABRA app labels "$TEST_APP_DOMAIN" --offline + assert_success + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l + refute_output --partial "$latestRelease" +} + +@test "show unknown if no deloyment" { + run $ABRA app labels "$TEST_APP_DOMAIN" + assert_success + assert_output --partial 'unknown' +} + +# bats test_tags=slow +@test "show deploy labels when deployed" { + run $ABRA app deploy "$TEST_APP_DOMAIN" \ + --no-input --no-converge-checks + assert_success + + run $ABRA app labels "$TEST_APP_DOMAIN" + assert_success + assert_output --partial 'com.docker.stack.image' +} diff --git a/tests/integration/app_ps.bats b/tests/integration/app_ps.bats index 41da4753..62d53de0 100644 --- a/tests/integration/app_ps.bats +++ b/tests/integration/app_ps.bats @@ -137,3 +137,16 @@ teardown(){ assert_output --partial "$latestRelease" assert_output --partial "${headHash:0:8}" # is a chaos deploy } + +# bats test_tags=slow +@test "ensure live chaos commit is shown" { + headHash=$(_get_head_hash) + + run $ABRA app deploy "$TEST_APP_DOMAIN" "0f5a0570" --no-input + assert_success + + run $ABRA app ps "$TEST_APP_DOMAIN" + assert_success + assert_output --partial "0f5a0570" # is not latest HEAD + refute_output --partial "${headHash:0:8}" +} diff --git a/tests/resources/valid_abra_config/servers/evil.corp/ecloud.env b/tests/resources/valid_abra_config/servers/evil.corp/ecloud.env index 8c6b5068..db05f8ca 100644 --- a/tests/resources/valid_abra_config/servers/evil.corp/ecloud.env +++ b/tests/resources/valid_abra_config/servers/evil.corp/ecloud.env @@ -1,3 +1,3 @@ RECIPE=ecloud DOMAIN=ecloud.evil.corp -SMTP_AUTHTYPE=login +SMTP_AUTHTYPE=login \ No newline at end of file