Compare commits

...

44 Commits

Author SHA1 Message Date
Ammar Hussein
09da474219 Merge branch 'removeDomainCheck' 2024-11-27 11:42:40 -08:00
Ammar Hussein
7b54c2b5b9 remove whitespace 2024-11-27 11:38:49 -08:00
Ammar Hussein
8ee1947fe9 remove -D on server add 2024-11-25 17:23:00 -08:00
04aec8232f
chore: vendor 2024-08-04 11:06:58 +02:00
2a5985e44e
build: drop 2MB with GCFLAGS [ci skip] 2024-07-27 12:56:43 +02:00
c65be64e7d
fix: dont checkout version for abra app undeploy
See coop-cloud/organising#628
2024-07-24 16:09:27 +02:00
fd8652e26d
fix: --chaos/--offline for abra app ps
See coop-cloud/organising#628
See coop-cloud/organising#629
2024-07-24 16:09:03 +02:00
518c5795f4
fix: avoid overwriting non version env vars
See coop-cloud/organising#630
2024-07-24 16:07:08 +02:00
827edcb0da
test: full width for CI testing [ci skip]
Also clean up the .env.sample.
2024-07-18 11:03:02 +02:00
05489a129c
test: re-create serer for setup [ci skip] 2024-07-17 14:32:53 +02:00
c02e11eb0a
test: fix order of teardown [ci skip] 2024-07-17 14:15:03 +02:00
8b8e158664
test: int suite fixes 2024-07-17 14:05:46 +02:00
e5a6dea10c
fix: catch ctrl-c again; less cryptic logging 2024-07-17 10:09:09 +02:00
1132b09b5b
fix: error out for invalid env versions 2024-07-17 10:08:51 +02:00
b2436174b0
chore: more logging for env versions 2024-07-17 10:08:32 +02:00
ea10019068
fix: "secret insert" respects env version 2024-07-17 10:08:13 +02:00
9b0b3c2e4c
fix: override version from CLI
See coop-cloud/organising#541
2024-07-17 10:07:47 +02:00
8084bff104
test: env version tests
See coop-cloud/organising#541
2024-07-17 10:06:46 +02:00
d22e2c38ce
test: just build main 2024-07-17 08:29:58 +02:00
e945087f79
test: env version writing tests 2024-07-17 08:27:12 +02:00
7734dd555d
fix: spacer between multiple versions 2024-07-17 02:12:26 +02:00
aedf5e5ff7
fix: dont write commented out versions
See coop-cloud/organising#626
2024-07-17 01:56:28 +02:00
95c598d030
feat: "app new" supports writing env files
And, automagically, chaos commit hashes.
2024-07-17 01:45:19 +02:00
56068362e8
fix: write versions on deploy/upgrade/rollback
See coop-cloud/organising#625
2024-07-17 01:29:49 +02:00
cf14731b46
refactor: "false" -> CHAOS_DEFAULT 2024-07-17 01:23:12 +02:00
486cfa68d8
test: explode on failures
Closes coop-cloud/organising#623
2024-07-17 00:16:47 +02:00
1718903834
test: reset recipe before undeploying [ci skip] 2024-07-17 00:00:06 +02:00
eb9894e5bb
test: dont clone if exists [ci skip] 2024-07-16 23:51:28 +02:00
a2116774e8
test: ensure catalogue in place [ci skip] 2024-07-16 23:46:02 +02:00
d2efdf8bf5
test: adjust output checking [ci skip] 2024-07-16 23:39:10 +02:00
b15c05929c
test: adjust output checking [ci skip] 2024-07-16 23:32:12 +02:00
f167a91868
test: skip for now, it's flaking again [ci skip]
We need to solve coop-cloud/organising#541
2024-07-16 23:28:18 +02:00
8cded8752a
test: ensure correct server for diffing [ci skip] 2024-07-16 23:25:17 +02:00
d1876e2fae
test: do exact diff of JSON for integration
See coop-cloud/organising#627
2024-07-16 23:19:36 +02:00
e42a1bca29
fix: add chaos/deploy versiosn back to ps output
Fix to support alakazam parsing
2024-07-16 22:47:47 +02:00
b5493ba059
refactor: CreateTable2 -> CreateTable [ci skip] 2024-07-16 22:45:03 +02:00
a41a36b8fd
fix: dont lock existing version on rollback
Otherwise, we can't select previous versions.
2024-07-16 17:35:15 +02:00
de006782b6
refactor: tablewriter -> lipgloss
Also the jsontable impl. is dropped also. Output is unchanged.
2024-07-16 16:22:47 +02:00
f28cffe6d8
refactor: vertical deploy overview 2024-07-16 09:37:10 +02:00
d3ede0f0f6
refactor: logging with background/padding 2024-07-15 22:55:02 +02:00
ae4653f5e3
build: add full install target [ci skip] 2024-07-13 15:30:38 +02:00
f
7f0a74d3c3
fix: source autocompletion on the current terminal 2024-07-11 12:02:38 -03:00
f
e99114e695
fix: setup should be run once 2024-07-11 12:02:22 -03:00
f
b1208f9db5
fix: sometimes the completion directories already exist 2024-07-11 12:01:21 -03:00
3617 changed files with 982546 additions and 745 deletions

View File

@ -71,7 +71,6 @@ steps:
port: 22 port: 22
command_timeout: 60m command_timeout: 60m
script_stop: true script_stop: true
envs: [ DRONE_SOURCE_BRANCH ]
request_pty: true request_pty: true
script: script:
- | - |

View File

@ -1,7 +1,7 @@
go env -w GOPRIVATE=coopcloud.tech # integration test suite
# export PASSWORD_STORE_DIR=$(pwd)/../../autonomic/passwords/passwords/
# export ABRA_DIR="$HOME/.abra_test" # export ABRA_DIR="$HOME/.abra_test"
# export ABRA_TEST_DOMAIN=test.example.com # export ABRA_TEST_DOMAIN=test.example.com
# export ABRA_SKIP_TEARDOWN=1 # for faster feedback when developing tests # export ABRA_CI=1
# release automation
# export GITEA_TOKEN=

1
.gitignore vendored
View File

@ -6,4 +6,3 @@
abra abra
dist/ dist/
tests/integration/.bats tests/integration/.bats
vendor/

View File

@ -49,6 +49,8 @@ builds:
- 5 - 5
- 6 - 6
- 7 - 7
gcflags:
- "all=-l -B"
ldflags: ldflags:
- "-X 'main.Commit={{ .Commit }}'" - "-X 'main.Commit={{ .Commit }}'"
- "-X 'main.Version={{ .Version }}'" - "-X 'main.Version={{ .Version }}'"

View File

@ -5,6 +5,7 @@ GOPATH := $(shell go env GOPATH)
GOVERSION := 1.21 GOVERSION := 1.21
LDFLAGS := "-X 'main.Commit=$(COMMIT)'" LDFLAGS := "-X 'main.Commit=$(COMMIT)'"
DIST_LDFLAGS := $(LDFLAGS)" -s -w" DIST_LDFLAGS := $(LDFLAGS)" -s -w"
GCFLAGS := "all=-l -B"
export GOPRIVATE=coopcloud.tech export GOPRIVATE=coopcloud.tech
@ -12,22 +13,24 @@ export GOPRIVATE=coopcloud.tech
all: format check build-abra test all: format check build-abra test
run-abra: run-abra:
@go run -ldflags=$(LDFLAGS) $(ABRA) @go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
run-kadabra: run-kadabra:
@go run -ldflags=$(LDFLAGS) $(KADABRA) @go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(KADABRA)
install-abra: install-abra:
@go install -ldflags=$(LDFLAGS) $(ABRA) @go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
install-kadabra: install-kadabra:
@go install -ldflags=$(LDFLAGS) $(KADABRA) @go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(KADABRA)
install: install-abra install-kadabra
build-abra: build-abra:
@go build -v -ldflags=$(DIST_LDFLAGS) $(ABRA) @go build -v -gcflags=$(GCFLAGS) -ldflags=$(DIST_LDFLAGS) $(ABRA)
build-kadabra: build-kadabra:
@go build -v -ldflags=$(DIST_LDFLAGS) $(KADABRA) @go build -v -gcflags=$(GCFLAGS) -ldflags=$(DIST_LDFLAGS) $(KADABRA)
build: build-abra build-kadabra build: build-abra build-kadabra

View File

@ -1,11 +1,14 @@
package app package app
import ( import (
"fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
"github.com/charmbracelet/lipgloss"
"github.com/urfave/cli" "github.com/urfave/cli"
) )
@ -40,8 +43,21 @@ ${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
log.Fatal(err) log.Fatal(err)
} }
tableCol := []string{"recipe env sample", "app env"} table, err := formatter.CreateTable()
table := formatter.CreateTable(tableCol) if err != nil {
log.Fatal(err)
}
table.
Headers("RECIPE ENV SAMPLE", "APP ENV").
StyleFunc(func(row, col int) lipgloss.Style {
switch {
case col == 1:
return lipgloss.NewStyle().Padding(0, 1, 0, 1).Align(lipgloss.Center)
default:
return lipgloss.NewStyle().Padding(0, 1, 0, 1)
}
})
envVars, err := appPkg.CheckEnv(app) envVars, err := appPkg.CheckEnv(app)
if err != nil { if err != nil {
@ -50,13 +66,15 @@ ${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
for _, envVar := range envVars { for _, envVar := range envVars {
if envVar.Present { if envVar.Present {
table.Append([]string{envVar.Name, "✅"}) val := []string{envVar.Name, "✅"}
table.Row(val...)
} else { } else {
table.Append([]string{envVar.Name, "❌"}) val := []string{envVar.Name, "❌"}
table.Row(val...)
} }
} }
table.Render() fmt.Println(table)
return nil return nil
}, },

View File

@ -2,9 +2,11 @@ package app
import ( import (
"context" "context"
"fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile" "coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/secret" "coopcloud.tech/abra/pkg/secret"
@ -47,17 +49,26 @@ EXAMPLE:
abra app deploy foo.example.com 1e83340e`, abra app deploy foo.example.com 1e83340e`,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
var warnMessages []string
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
specificVersion := c.Args().Get(1) specificVersion := c.Args().Get(1)
if specificVersion == "" {
specificVersion = app.Recipe.Version
}
if specificVersion != "" && internal.Chaos { if specificVersion != "" && internal.Chaos {
log.Fatal("cannot use <version> and --chaos together") log.Fatal("cannot use <version> and --chaos together")
} }
if specificVersion != "" {
log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
app.Recipe.Version = specificVersion
}
if specificVersion == "" && app.Recipe.Version != "" && !internal.Chaos {
log.Debugf("retrieved %s as version from env file", app.Recipe.Version)
specificVersion = app.Recipe.Version
}
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil { if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -115,7 +126,7 @@ EXAMPLE:
if deployMeta.IsDeployed { if deployMeta.IsDeployed {
if internal.Force || internal.Chaos { if internal.Force || internal.Chaos {
log.Warnf("%s is already deployed but continuing (--force/--chaos)", app.Name) warnMessages = append(warnMessages, fmt.Sprintf("%s is already deployed", app.Name))
} else { } else {
log.Fatalf("%s is already deployed", app.Name) log.Fatalf("%s is already deployed", app.Name)
} }
@ -139,13 +150,13 @@ EXAMPLE:
log.Fatal(err) log.Fatal(err)
} }
version = formatter.SmallSHA(head.String()) version = formatter.SmallSHA(head.String())
log.Warn("no versions detected, using latest commit") warnMessages = append(warnMessages, fmt.Sprintf("no versions detected, using latest commit"))
} }
} }
chaosVersion := "false" chaosVersion := config.CHAOS_DEFAULT
if internal.Chaos { if internal.Chaos {
log.Warnf("chaos mode engaged") warnMessages = append(warnMessages, "chaos mode engaged")
if isChaosCommit { if isChaosCommit {
chaosVersion = specificVersion chaosVersion = specificVersion
@ -201,14 +212,12 @@ EXAMPLE:
for _, envVar := range envVars { for _, envVar := range envVars {
if !envVar.Present { if !envVar.Present {
log.Warnf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain) warnMessages = append(warnMessages,
fmt.Sprintf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain),
)
} }
} }
if err := internal.DeployOverview(app, version, chaosVersion, "continue with deployment?"); err != nil {
log.Fatal(err)
}
if !internal.NoDomainChecks { if !internal.NoDomainChecks {
domainName, ok := app.Env["DOMAIN"] domainName, ok := app.Env["DOMAIN"]
if ok { if ok {
@ -216,10 +225,14 @@ EXAMPLE:
log.Fatal(err) log.Fatal(err)
} }
} else { } else {
log.Warn("skipping domain checks as no DOMAIN=... configured for app") warnMessages = append(warnMessages, "skipping domain checks as no DOMAIN=... configured for app")
} }
} else { } else {
log.Warn("skipping domain checks as requested") warnMessages = append(warnMessages, "skipping domain checks as requested")
}
if err := internal.DeployOverview(app, warnMessages, version, chaosVersion); err != nil {
log.Fatal(err)
} }
stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName) stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
@ -240,12 +253,15 @@ EXAMPLE:
} }
} }
if app.Recipe.Version != "" && specificVersion != "" && specificVersion != app.Recipe.Version { app.Recipe.Version = version
err := app.WriteRecipeVersion(specificVersion) if chaosVersion != config.CHAOS_DEFAULT {
if err != nil { app.Recipe.Version = chaosVersion
log.Fatalf("writing new recipe version in env file: %s", err)
}
} }
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)
}
return nil return nil
}, },
} }

View File

@ -239,15 +239,27 @@ can take some time.`,
serverStat := allStats[app.Server] serverStat := allStats[app.Server]
tableCol := []string{"recipe", "domain"} headers := []string{"RECIPE", "DOMAIN"}
if status { if status {
tableCol = append(tableCol, []string{"status", "chaos", "version", "upgrade", "autoupdate"}...) headers = append(headers, []string{
"STATUS",
"CHAOS",
"VERSION",
"UPGRADE",
"AUTOUPDATE"}...,
)
} }
table := formatter.CreateTable(tableCol) table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
var rows [][]string
for _, appStat := range serverStat.Apps { for _, appStat := range serverStat.Apps {
tableRow := []string{appStat.Recipe, appStat.Domain} row := []string{appStat.Recipe, appStat.Domain}
if status { if status {
chaosStatus := appStat.Chaos chaosStatus := appStat.Chaos
if chaosStatus != "unknown" { if chaosStatus != "unknown" {
@ -259,17 +271,27 @@ can take some time.`,
chaosStatus = appStat.ChaosVersion chaosStatus = appStat.ChaosVersion
} }
} }
tableRow = append(tableRow, []string{appStat.Status, chaosStatus, appStat.Version, appStat.Upgrade, appStat.AutoUpdate}...)
row = append(row, []string{
appStat.Status,
chaosStatus,
appStat.Version,
appStat.Upgrade,
appStat.AutoUpdate}...,
)
} }
table.Append(tableRow)
rows = append(rows, row)
} }
if table.NumLines() > 0 { table.Rows(rows...)
table.Render()
if len(rows) > 0 {
fmt.Println(table)
if status { if status {
fmt.Println(fmt.Sprintf( fmt.Println(fmt.Sprintf(
"server: %s | total apps: %v | versioned: %v | unversioned: %v | latest: %v | upgrade: %v", "SERVER: %s | TOTAL APPS: %v | VERSIONED: %v | UNVERSIONED: %v | LATEST : %v | UPGRADE: %v",
app.Server, app.Server,
serverStat.AppCount, serverStat.AppCount,
serverStat.VersionCount, serverStat.VersionCount,
@ -278,19 +300,21 @@ can take some time.`,
serverStat.UpgradeCount, serverStat.UpgradeCount,
)) ))
} else { } else {
fmt.Println(fmt.Sprintf("server: %s | total apps: %v", app.Server, serverStat.AppCount)) log.Infof("SERVER: %s TOTAL APPS: %v", app.Server, serverStat.AppCount)
} }
}
if len(allStats) > 1 && table.NumLines() > 0 { if len(allStats) > 1 && len(rows) > 0 {
fmt.Println() // newline separator for multiple servers fmt.Println() // newline separator for multiple servers
}
} }
alreadySeen[app.Server] = true alreadySeen[app.Server] = true
} }
if len(allStats) > 1 { if len(allStats) > 1 {
fmt.Println(fmt.Sprintf("total servers: %v | total apps: %v ", totalServersCount, totalAppsCount)) totalServers := formatter.BoldStyle.Render("TOTAL SERVERS")
totalApps := formatter.BoldStyle.Render("TOTAL APPS")
log.Infof("%s: %v | %s: %v ", totalServers, totalServersCount, totalApps, totalAppsCount)
} }
return nil return nil

View File

@ -4,16 +4,17 @@ import (
"fmt" "fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/app"
appPkg "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/jsontable"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
recipePkg "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret" "coopcloud.tech/abra/pkg/secret"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/lipgloss/table"
dockerClient "github.com/docker/docker/client" dockerClient "github.com/docker/docker/client"
"github.com/urfave/cli" "github.com/urfave/cli"
) )
@ -28,6 +29,8 @@ deploy <domain>" to do so.
You can see what recipes are available (i.e. values for the <recipe> argument) You can see what recipes are available (i.e. values for the <recipe> argument)
by running "abra recipe ls". by running "abra recipe ls".
Recipe commit hashes are supported values for "[<version>]".
Passing the "--secrets/-S" flag will automatically generate secrets for your Passing the "--secrets/-S" flag will automatically generate secrets for your
app and store them encrypted at rest on the chosen target server. These app and store them encrypted at rest on the chosen target server. These
generated secrets are only visible at generation time, so please take care to generated secrets are only visible at generation time, so please take care to
@ -66,6 +69,7 @@ var appNewCommand = cli.Command{
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
var version string
if !internal.Chaos { if !internal.Chaos {
if err := recipe.EnsureIsClean(); err != nil { if err := recipe.EnsureIsClean(); err != nil {
log.Fatal(err) log.Fatal(err)
@ -75,16 +79,13 @@ var appNewCommand = cli.Command{
log.Fatal(err) log.Fatal(err)
} }
} }
if c.Args().Get(1) == "" {
var version string
if c.Args().Get(1) == "" {
recipeVersions, err := recipe.GetRecipeVersions() recipeVersions, err := recipe.GetRecipeVersions()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// NOTE(d1): determine whether recipe versions exist or not and check
// out the latest version or current HEAD
if len(recipeVersions) > 0 { if len(recipeVersions) > 0 {
latest := recipeVersions[len(recipeVersions)-1] latest := recipeVersions[len(recipeVersions)-1]
for tag := range latest { for tag := range latest {
@ -100,7 +101,8 @@ var appNewCommand = cli.Command{
} }
} }
} else { } else {
if _, err := recipe.EnsureVersion(c.Args().Get(1)); err != nil { version = c.Args().Get(1)
if _, err := recipe.EnsureVersion(version); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
@ -127,7 +129,7 @@ var appNewCommand = cli.Command{
} }
var secrets AppSecrets var secrets AppSecrets
var secretTable *jsontable.JSONTable var secretsTable *table.Table
if internal.Secrets { if internal.Secrets {
sampleEnv, err := recipe.SampleEnv() sampleEnv, err := recipe.SampleEnv()
if err != nil { if err != nil {
@ -158,10 +160,16 @@ var appNewCommand = cli.Command{
log.Fatal(err) log.Fatal(err)
} }
secretCols := []string{"Name", "Value"} secretsTable, err = formatter.CreateTable()
secretTable = formatter.CreateTable(secretCols) if err != nil {
log.Fatal(err)
}
headers := []string{"NAME", "VALUE"}
secretsTable.Headers(headers...)
for name, val := range secrets { for name, val := range secrets {
secretTable.Append([]string{name, val}) secretsTable.Row(name, val)
} }
} }
@ -169,14 +177,20 @@ var appNewCommand = cli.Command{
internal.NewAppServer = "local" internal.NewAppServer = "local"
} }
tableCol := []string{"server", "recipe", "domain"} table, err := formatter.CreateTable()
table := formatter.CreateTable(tableCol) if err != nil {
table.Append([]string{internal.NewAppServer, recipe.Name, internal.Domain}) log.Fatal(err)
}
headers := []string{"SERVER", "DOMAIN", "RECIPE", "VERSION"}
table.Headers(headers...)
table.Row(internal.NewAppServer, internal.Domain, recipe.Name, version)
log.Infof("new app '%s' created 🌞", recipe.Name) log.Infof("new app '%s' created 🌞", recipe.Name)
fmt.Println("") fmt.Println("")
table.Render() fmt.Println(table)
fmt.Println("") fmt.Println("")
fmt.Println("Configure this app:") fmt.Println("Configure this app:")
@ -190,8 +204,23 @@ var appNewCommand = cli.Command{
fmt.Println("") fmt.Println("")
fmt.Println("Generated secrets:") fmt.Println("Generated secrets:")
fmt.Println("") fmt.Println("")
secretTable.Render() fmt.Println(secretsTable)
log.Warn("generated secrets are not shown again, please take note of them NOW")
log.Warnf(
"generated secrets %s shown again, please take note of them %s",
formatter.BoldStyle.Render("NOT"),
formatter.BoldStyle.Render("NOW"),
)
}
app, err := app.Get(internal.Domain)
if err != nil {
log.Fatal(err)
}
log.Debugf("choosing %s as version to save to env file", version)
if err := app.WriteRecipeVersion(version, false); err != nil {
log.Fatalf("writing new recipe version in env file: %s", err)
} }
return nil return nil

View File

@ -9,6 +9,7 @@ import (
appPkg "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
abraService "coopcloud.tech/abra/pkg/service" abraService "coopcloud.tech/abra/pkg/service"
@ -29,12 +30,14 @@ var appPsCommand = cli.Command{
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.MachineReadableFlag, internal.MachineReadableFlag,
internal.DebugFlag, internal.DebugFlag,
internal.ChaosFlag,
internal.OfflineFlag,
}, },
Before: internal.SubCommandBefore, Before: internal.SubCommandBefore,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if err := app.Recipe.Ensure(false, false); err != nil { if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -52,15 +55,10 @@ var appPsCommand = cli.Command{
log.Fatalf("%s is not deployed?", app.Name) log.Fatalf("%s is not deployed?", app.Name)
} }
chaosVersion := "false" chaosVersion := config.CHAOS_DEFAULT
statuses, err := appPkg.GetAppStatuses([]appPkg.App{app}, true) statuses, err := appPkg.GetAppStatuses([]appPkg.App{app}, true)
if statusMeta, ok := statuses[app.StackName()]; ok { if statusMeta, ok := statuses[app.StackName()]; ok {
isChaos, exists := statusMeta["chaos"] if isChaos, exists := statusMeta["chaos"]; exists && isChaos == "true" {
if exists && isChaos == "false" {
if _, err := app.Recipe.EnsureVersion(deployMeta.Version); err != nil {
log.Fatal(err)
}
} else {
chaosVersion, err = app.Recipe.ChaosVersion() chaosVersion, err = app.Recipe.ChaosVersion()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -94,7 +92,7 @@ func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chao
return return
} }
var tablerows [][]string var rows [][]string
allContainerStats := make(map[string]map[string]string) allContainerStats := make(map[string]map[string]string)
for _, service := range compose.Services { for _, service := range compose.Services {
filters := filters.NewArgs() filters := filters.NewArgs()
@ -134,9 +132,7 @@ func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chao
allContainerStats[containerStats["service"]] = containerStats allContainerStats[containerStats["service"]] = containerStats
tablerow := []string{ row := []string{
deployedVersion,
chaosVersion,
containerStats["service"], containerStats["service"],
containerStats["image"], containerStats["image"],
containerStats["created"], containerStats["created"],
@ -145,25 +141,37 @@ func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chao
containerStats["ports"], containerStats["ports"],
} }
tablerows = append(tablerows, tablerow) rows = append(rows, row)
} }
if internal.MachineReadable { if internal.MachineReadable {
jsonstring, err := json.Marshal(allContainerStats) jsonstring, err := json.Marshal(allContainerStats)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal("unable to convert to JSON: %s", err)
} }
fmt.Println(string(jsonstring)) fmt.Println(string(jsonstring))
return return
} }
tableCol := []string{"version", "chaos", "service", "image", "created", "status", "state", "ports"} table, err := formatter.CreateTable()
table := formatter.CreateTable(tableCol) if err != nil {
for _, row := range tablerows { log.Fatal(err)
table.Append(row)
} }
table.SetAutoMergeCellsByColumnIndex([]int{0, 1})
table.Render() headers := []string{
"SERVICE",
"IMAGE",
"CREATED",
"STATUS",
"STATE",
"PORTS",
}
table.
Headers(headers...).
Rows(rows...)
fmt.Println(table)
log.Infof("VERSION: %s CHAOS: %s", deployedVersion, chaosVersion)
} }

View File

@ -49,12 +49,14 @@ flag.`,
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if !internal.Force && !internal.NoInput { if !internal.Force && !internal.NoInput {
log.Warnf("ALERTA ALERTA: this will completely remove %s data and config locally and remotely", app.Name)
response := false response := false
msg := "ALERTA ALERTA: this will completely remove %s data and configurations locally and remotely, are you sure?" prompt := &survey.Confirm{Message: "are you sure?"}
prompt := &survey.Confirm{Message: fmt.Sprintf(msg, app.Name)}
if err := survey.AskOne(prompt, &response); err != nil { if err := survey.AskOne(prompt, &response); err != nil {
log.Fatal(err) log.Fatal(err)
} }
if !response { if !response {
log.Fatal("aborting as requested") log.Fatal("aborting as requested")
} }

View File

@ -6,6 +6,7 @@ import (
appPkg "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile" "coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/abra/pkg/lint"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
@ -47,9 +48,17 @@ EXAMPLE:
abra app rollback foo.example.com 1.2.3+3.2.1`, abra app rollback foo.example.com 1.2.3+3.2.1`,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
var warnMessages []string
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
specificVersion := c.Args().Get(1)
if specificVersion != "" {
log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
app.Recipe.Version = specificVersion
}
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil { if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -82,13 +91,9 @@ EXAMPLE:
var availableDowngrades []string var availableDowngrades []string
if deployMeta.Version == "unknown" { if deployMeta.Version == "unknown" {
availableDowngrades = versions availableDowngrades = versions
log.Warnf("failed to determine deployed version of %s", app.Name) warnMessages = append(warnMessages, fmt.Sprintf("failed to determine deployed version of %s", app.Name))
} }
specificVersion := c.Args().Get(1)
if specificVersion == "" {
specificVersion = app.Recipe.Version
}
if specificVersion != "" { if specificVersion != "" {
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version) parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil { if err != nil {
@ -113,7 +118,7 @@ EXAMPLE:
if deployMeta.Version != "unknown" && specificVersion == "" { if deployMeta.Version != "unknown" && specificVersion == "" {
if deployMeta.IsChaos { if deployMeta.IsChaos {
log.Warn("attempting to rollback a chaos deployment") warnMessages = append(warnMessages, fmt.Sprintf("attempting to rollback a chaos deployment"))
} }
for _, version := range versions { for _, version := range versions {
@ -197,13 +202,20 @@ EXAMPLE:
appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade) appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
appPkg.SetUpdateLabel(compose, stackName, app.Env) appPkg.SetUpdateLabel(compose, stackName, app.Env)
chaosVersion := "false" chaosVersion := config.CHAOS_DEFAULT
if deployMeta.IsChaos { if deployMeta.IsChaos {
chaosVersion = deployMeta.ChaosVersion chaosVersion = deployMeta.ChaosVersion
} }
// NOTE(d1): no release notes implemeneted for rolling back // NOTE(d1): no release notes implemeneted for rolling back
if err := internal.NewVersionOverview(app, deployMeta.Version, chaosVersion, chosenDowngrade, ""); err != nil { if err := internal.NewVersionOverview(
app,
warnMessages,
"rollback",
deployMeta.Version,
chaosVersion,
chosenDowngrade,
""); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -211,11 +223,10 @@ EXAMPLE:
log.Fatal(err) log.Fatal(err)
} }
if app.Recipe.Version != "" { app.Recipe.Version = chosenDowngrade
err := app.WriteRecipeVersion(chosenDowngrade) log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
if err != nil { if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
log.Fatalf("writing new recipe version in env file: %s", err) log.Fatalf("writing new recipe version in env file: %s", err)
}
} }
return nil return nil

View File

@ -115,18 +115,37 @@ var appSecretGenerateCommand = cli.Command{
os.Exit(1) os.Exit(1)
} }
tableCol := []string{"name", "value"} headers := []string{"NAME", "VALUE"}
table := formatter.CreateTable(tableCol) table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
var rows [][]string
for name, val := range secretVals { for name, val := range secretVals {
table.Append([]string{name, val}) row := []string{name, val}
rows = append(rows, row)
table.Row(row...)
} }
if internal.MachineReadable { if internal.MachineReadable {
table.JSONRender() out, err := formatter.ToJSON(headers, rows)
} else { if err != nil {
table.Render() log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
return nil
} }
log.Warn("generated secrets are not shown again, please take note of them NOW")
fmt.Println(table)
log.Warnf(
"generated secrets %s shown again, please take note of them %s",
formatter.BoldStyle.Render("NOT"),
formatter.BoldStyle.Render("NOW"),
)
return nil return nil
}, },
@ -141,6 +160,7 @@ var appSecretInsertCommand = cli.Command{
internal.PassFlag, internal.PassFlag,
internal.FileFlag, internal.FileFlag,
internal.TrimFlag, internal.TrimFlag,
internal.ChaosFlag,
}, },
Before: internal.SubCommandBefore, Before: internal.SubCommandBefore,
ArgsUsage: "<domain> <secret-name> <version> <data>", ArgsUsage: "<domain> <secret-name> <version> <data>",
@ -159,6 +179,9 @@ Example:
`, `,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
if len(c.Args()) != 4 { if len(c.Args()) != 4 {
internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?")) internal.ShowSubcommandHelpAndError(c, errors.New("missing arguments?"))
@ -345,34 +368,48 @@ var appSecretLsCommand = cli.Command{
log.Fatal(err) log.Fatal(err)
} }
tableCol := []string{"Name", "Version", "Generated Name", "Created On Server"} headers := []string{"NAME", "VERSION", "GENERATED NAME", "CREATED ON SERVER"}
table := formatter.CreateTable(tableCol) table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
secStats, err := secret.PollSecretsStatus(cl, app) secStats, err := secret.PollSecretsStatus(cl, app)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
var rows [][]string
for _, secStat := range secStats { for _, secStat := range secStats {
tableRow := []string{ row := []string{
secStat.LocalName, secStat.LocalName,
secStat.Version, secStat.Version,
secStat.RemoteName, secStat.RemoteName,
strconv.FormatBool(secStat.CreatedOnRemote), strconv.FormatBool(secStat.CreatedOnRemote),
} }
table.Append(tableRow)
rows = append(rows, row)
table.Row(row...)
} }
if table.NumLines() > 0 { if len(rows) > 0 {
if internal.MachineReadable { if internal.MachineReadable {
table.JSONRender() out, err := formatter.ToJSON(headers, rows)
} else { if err != nil {
table.Render() log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
return nil
} }
} else {
log.Warnf("no secrets stored for %s", app.Name) fmt.Println(table)
return nil
} }
log.Warnf("no secrets stored for %s", app.Name)
return nil return nil
}, },
} }

View File

@ -56,9 +56,15 @@ var appServicesCommand = cli.Command{
log.Fatal(err) log.Fatal(err)
} }
tableCol := []string{"service name", "image"} table, err := formatter.CreateTable()
table := formatter.CreateTable(tableCol) if err != nil {
log.Fatal(err)
}
headers := []string{"SERVICE (SHORT)", "SERVICE (LONG)", "IMAGE"}
table.Headers(headers...)
var rows [][]string
for _, container := range containers { for _, container := range containers {
var containerNames []string var containerNames []string
for _, containerName := range container.Names { for _, containerName := range container.Names {
@ -69,14 +75,20 @@ var appServicesCommand = cli.Command{
serviceShortName := service.ContainerToServiceName(container.Names, app.StackName()) serviceShortName := service.ContainerToServiceName(container.Names, app.StackName())
serviceLongName := fmt.Sprintf("%s_%s", app.StackName(), serviceShortName) serviceLongName := fmt.Sprintf("%s_%s", app.StackName(), serviceShortName)
tableRow := []string{ row := []string{
serviceShortName,
serviceLongName, serviceLongName,
formatter.RemoveSha(container.Image), formatter.RemoveSha(container.Image),
} }
table.Append(tableRow)
rows = append(rows, row)
} }
table.Render() table.Rows(rows...)
if len(rows) > 0 {
fmt.Println(table)
}
return nil return nil
}, },

View File

@ -8,6 +8,7 @@ import (
appPkg "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
stack "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack"
@ -67,6 +68,7 @@ var appUndeployCommand = cli.Command{
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag, internal.DebugFlag,
internal.NoInputFlag, internal.NoInputFlag,
internal.OfflineFlag,
pruneFlag, pruneFlag,
}, },
Before: internal.SubCommandBefore, Before: internal.SubCommandBefore,
@ -81,9 +83,6 @@ any previously attached volumes as eligible for pruning once undeployed.
Passing "-p/--prune" does not remove those volumes.`, Passing "-p/--prune" does not remove those volumes.`,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err)
}
stackName := app.StackName() stackName := app.StackName()
cl, err := client.New(app.Server) cl, err := client.New(app.Server)
@ -102,12 +101,12 @@ Passing "-p/--prune" does not remove those volumes.`,
log.Fatalf("%s is not deployed?", app.Name) log.Fatalf("%s is not deployed?", app.Name)
} }
chaosVersion := "false" chaosVersion := config.CHAOS_DEFAULT
if deployMeta.IsChaos { if deployMeta.IsChaos {
chaosVersion = deployMeta.ChaosVersion chaosVersion = deployMeta.ChaosVersion
} }
if err := internal.DeployOverview(app, deployMeta.Version, chaosVersion, "continue with undeploy?"); err != nil { if err := internal.DeployOverview(app, []string{}, deployMeta.Version, chaosVersion); err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -8,6 +8,7 @@ import (
appPkg "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/envfile" "coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/lint" "coopcloud.tech/abra/pkg/lint"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
@ -47,9 +48,17 @@ EXAMPLE:
abra app upgrade foo.example.com 1.2.3+3.2.1`, abra app upgrade foo.example.com 1.2.3+3.2.1`,
BashComplete: autocomplete.AppNameComplete, BashComplete: autocomplete.AppNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
var warnMessages []string
app := internal.ValidateApp(c) app := internal.ValidateApp(c)
stackName := app.StackName() stackName := app.StackName()
specificVersion := c.Args().Get(1)
if specificVersion != "" {
log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
app.Recipe.Version = specificVersion
}
if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil { if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -82,10 +91,9 @@ EXAMPLE:
var availableUpgrades []string var availableUpgrades []string
if deployMeta.Version == "unknown" { if deployMeta.Version == "unknown" {
availableUpgrades = versions availableUpgrades = versions
log.Warnf("failed to determine deployed version of %s", app.Name) warnMessages = append(warnMessages, fmt.Sprintf("failed to determine deployed version of %s", app.Name))
} }
specificVersion := c.Args().Get(1)
if specificVersion != "" { if specificVersion != "" {
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version) parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil { if err != nil {
@ -114,7 +122,7 @@ EXAMPLE:
if deployMeta.Version != "unknown" && specificVersion == "" { if deployMeta.Version != "unknown" && specificVersion == "" {
if deployMeta.IsChaos { if deployMeta.IsChaos {
log.Warn("attempting to upgrade a chaos deployment") warnMessages = append(warnMessages, fmt.Sprintf("attempting to upgrade a chaos deployment"))
} }
for _, version := range versions { for _, version := range versions {
@ -156,7 +164,7 @@ EXAMPLE:
} }
if internal.Force && chosenUpgrade == "" { if internal.Force && chosenUpgrade == "" {
log.Warnf("%s is already upgraded to latest but continuing (--force)", app.Name) warnMessages = append(warnMessages, fmt.Sprintf("%s is already upgraded to latest", app.Name))
chosenUpgrade = deployMeta.Version chosenUpgrade = deployMeta.Version
} }
@ -230,7 +238,9 @@ EXAMPLE:
for _, envVar := range envVars { for _, envVar := range envVars {
if !envVar.Present { if !envVar.Present {
log.Warnf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain) warnMessages = append(warnMessages,
fmt.Sprintf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain),
)
} }
} }
@ -240,12 +250,19 @@ EXAMPLE:
return nil return nil
} }
chaosVersion := "false" chaosVersion := config.CHAOS_DEFAULT
if deployMeta.IsChaos { if deployMeta.IsChaos {
chaosVersion = deployMeta.ChaosVersion chaosVersion = deployMeta.ChaosVersion
} }
if err := internal.NewVersionOverview(app, deployMeta.Version, chaosVersion, chosenUpgrade, releaseNotes); err != nil { if err := internal.NewVersionOverview(
app,
warnMessages,
"upgrade",
deployMeta.Version,
chaosVersion,
chosenUpgrade,
releaseNotes); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -267,11 +284,10 @@ EXAMPLE:
} }
} }
if app.Recipe.Version != "" { app.Recipe.Version = chosenUpgrade
err := app.WriteRecipeVersion(chosenUpgrade) log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
if err != nil { if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
log.Fatalf("writing new recipe version in env file: %s", err) log.Fatalf("writing new recipe version in env file: %s", err)
}
} }
return nil return nil

View File

@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"fmt"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/autocomplete"
@ -37,26 +38,35 @@ var appVolumeListCommand = cli.Command{
log.Fatal(err) log.Fatal(err)
} }
volumeList, err := client.GetVolumes(cl, context.Background(), app.Server, filters) volumes, err := client.GetVolumes(cl, context.Background(), app.Server, filters)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
table := formatter.CreateTable([]string{"name", "created", "mounted"}) headers := []string{"name", "created", "mounted"}
var volTable [][]string
for _, volume := range volumeList { table, err := formatter.CreateTable()
volRow := []string{volume.Name, volume.CreatedAt, volume.Mountpoint} if err != nil {
volTable = append(volTable, volRow) log.Fatal(err)
} }
table.AppendBulk(volTable) table.Headers(headers...)
if table.NumLines() > 0 { var rows [][]string
table.Render() for _, volume := range volumes {
} else { row := []string{volume.Name, volume.CreatedAt, volume.Mountpoint}
log.Warnf("no volumes created for %s", app.Name) rows = append(rows, row)
} }
table.Rows(rows...)
if len(rows) > 0 {
fmt.Println(table)
return nil
}
log.Warnf("no volumes created for %s", app.Name)
return nil return nil
}, },
} }

View File

@ -80,26 +80,29 @@ EXAMPLE:
switch shellType { switch shellType {
case "bash": case "bash":
fmt.Println(fmt.Sprintf(` fmt.Println(fmt.Sprintf(`
# run the following commands to install auto-completion # run the following commands once to install auto-completion
sudo mkdir /etc/bash_completion.d/ sudo mkdir -p /etc/bash_completion.d/
sudo cp %s /etc/bash_completion.d/abra sudo cp %s /etc/bash_completion.d/abra
echo "source /etc/bash_completion.d/abra" >> ~/.bashrc echo "source /etc/bash_completion.d/abra" >> ~/.bashrc
source /etc/bash_completion.d/abra
# To test, run the following: "abra app <hit tab key>" - you should see command completion! # To test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile)) `, autocompletionFile))
case "zsh": case "zsh":
fmt.Println(fmt.Sprintf(` fmt.Println(fmt.Sprintf(`
# run the following commands to install auto-completion # run the following commands to once install auto-completion
sudo mkdir /etc/zsh/completion.d/ sudo mkdir -p /etc/zsh/completion.d/
sudo cp %s /etc/zsh/completion.d/abra sudo cp %s /etc/zsh/completion.d/abra
echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc echo "PROG=abra\n_CLI_ZSH_AUTOCOMPLETE_HACK=1\nsource /etc/zsh/completion.d/abra" >> ~/.zshrc
source /etc/zsh/completion.d/abra
# to test, run the following: "abra app <hit tab key>" - you should see command completion! # to test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile)) `, autocompletionFile))
case "fish": case "fish":
fmt.Println(fmt.Sprintf(` fmt.Println(fmt.Sprintf(`
# run the following commands to install auto-completion # run the following commands once to install auto-completion
sudo mkdir -p /etc/fish/completions sudo mkdir -p /etc/fish/completions
sudo cp %s /etc/fish/completions/abra sudo cp %s /etc/fish/completions/abra
echo "source /etc/fish/completions/abra" >> ~/.config/fish/config.fish echo "source /etc/fish/completions/abra" >> ~/.config/fish/config.fish
source /etc/fish/completions/abra
# to test, run the following: "abra app <hit tab key>" - you should see command completion! # to test, run the following: "abra app <hit tab key>" - you should see command completion!
`, autocompletionFile)) `, autocompletionFile))
} }
@ -187,7 +190,9 @@ func newAbraApp(version, commit string) *cli.App {
} }
} }
log.Logger.SetStyles(log.Styles())
charmLog.SetDefault(log.Logger) charmLog.SetDefault(log.Logger)
log.Debugf("abra version %s, commit %s", version, commit) log.Debugf("abra version %s, commit %s", version, commit)
return nil return nil

View File

@ -6,17 +6,41 @@ import (
"strings" "strings"
appPkg "coopcloud.tech/abra/pkg/app" appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/lipgloss"
dockerClient "github.com/docker/docker/client" dockerClient "github.com/docker/docker/client"
) )
// NewVersionOverview shows an upgrade or downgrade overview var borderStyle = lipgloss.NewStyle().
func NewVersionOverview(app appPkg.App, currentVersion, chaosVersion, newVersion, releaseNotes string) error { BorderStyle(lipgloss.ThickBorder()).
tableCol := []string{"server", "recipe", "config", "domain", "version", "chaos", "to deploy"} Padding(0, 1, 0, 1).
table := formatter.CreateTable(tableCol) MaxWidth(79).
BorderForeground(lipgloss.Color("63"))
var headerStyle = lipgloss.NewStyle().
Underline(true).
Bold(true)
var leftStyle = lipgloss.NewStyle().
Bold(true)
var rightStyle = lipgloss.NewStyle()
// horizontal is a JoinHorizontal helper function.
func horizontal(left, mid, right string) string {
return lipgloss.JoinHorizontal(lipgloss.Left, left, mid, right)
}
// NewVersionOverview shows an upgrade or downgrade overview
func NewVersionOverview(
app appPkg.App,
warnMessages []string,
kind,
currentVersion,
chaosVersion,
newVersion,
releaseNotes string) error {
deployConfig := "compose.yml" deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok { if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n") deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
@ -27,22 +51,36 @@ func NewVersionOverview(app appPkg.App, currentVersion, chaosVersion, newVersion
server = "local" server = "local"
} }
table.Append([]string{ body := strings.Builder{}
server, body.WriteString(
app.Recipe.Name, borderStyle.Render(
deployConfig, lipgloss.JoinVertical(
app.Domain, lipgloss.Center,
currentVersion, headerStyle.Render(fmt.Sprintf("%s OVERVIEW", strings.ToUpper(kind))),
chaosVersion, lipgloss.JoinVertical(
newVersion, lipgloss.Left,
}) horizontal(leftStyle.Render("SERVER"), " ", rightStyle.Render(server)),
table.Render() horizontal(leftStyle.Render("DOMAIN"), " ", rightStyle.Render(app.Domain)),
horizontal(leftStyle.Render("RECIPE"), " ", rightStyle.Render(app.Recipe.Name)),
horizontal(leftStyle.Render("CONFIG"), " ", rightStyle.Render(deployConfig)),
horizontal(leftStyle.Render("VERSION"), " ", rightStyle.Render(currentVersion)),
horizontal(leftStyle.Render("CHAOS"), " ", rightStyle.Render(chaosVersion)),
horizontal(leftStyle.Render("DEPLOY"), " ", rightStyle.Padding(0).Render(newVersion)),
),
),
),
)
fmt.Println(body.String())
if releaseNotes != "" && newVersion != "" { if releaseNotes != "" && newVersion != "" {
fmt.Println() fmt.Println()
fmt.Print(releaseNotes) fmt.Print(releaseNotes)
} else { } else {
log.Warnf("no release notes available for %s", newVersion) warnMessages = append(warnMessages, fmt.Sprintf("no release notes available for %s", newVersion))
}
for _, msg := range warnMessages {
log.Warn(msg)
} }
if NoInput { if NoInput {
@ -50,16 +88,66 @@ func NewVersionOverview(app appPkg.App, currentVersion, chaosVersion, newVersion
} }
response := false response := false
prompt := &survey.Confirm{ prompt := &survey.Confirm{Message: "proceed?"}
Message: "continue with deployment?",
}
if err := survey.AskOne(prompt, &response); err != nil { if err := survey.AskOne(prompt, &response); err != nil {
return err return err
} }
if !response { if !response {
log.Fatal("exiting as requested") log.Fatal("deployment cancelled")
}
return nil
}
// DeployOverview shows a deployment overview
func DeployOverview(app appPkg.App, warnMessages []string, version, chaosVersion string) error {
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
}
server := app.Server
if app.Server == "default" {
server = "local"
}
body := strings.Builder{}
body.WriteString(
borderStyle.Render(
lipgloss.JoinVertical(
lipgloss.Center,
headerStyle.Render("DEPLOY OVERVIEW"),
lipgloss.JoinVertical(
lipgloss.Left,
horizontal(leftStyle.Render("SERVER"), " ", rightStyle.Render(server)),
horizontal(leftStyle.Render("DOMAIN"), " ", rightStyle.Render(app.Domain)),
horizontal(leftStyle.Render("RECIPE"), " ", rightStyle.Render(app.Recipe.Name)),
horizontal(leftStyle.Render("CONFIG"), " ", rightStyle.Render(deployConfig)),
horizontal(leftStyle.Render("VERSION"), " ", rightStyle.Render(version)),
horizontal(leftStyle.Render("CHAOS"), " ", rightStyle.Padding(0).Render(chaosVersion)),
),
),
),
)
fmt.Println(body.String())
for _, msg := range warnMessages {
log.Warn(msg)
}
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{Message: "proceed?"}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
log.Fatal("deployment cancelled")
} }
return nil return nil
@ -118,48 +206,3 @@ func PostCmds(cl *dockerClient.Client, app appPkg.App, commands string) error {
} }
return nil return nil
} }
// DeployOverview shows a deployment overview
func DeployOverview(app appPkg.App, version, chaosVersion, message string) error {
tableCol := []string{"server", "recipe", "config", "domain", "version", "chaos"}
table := formatter.CreateTable(tableCol)
deployConfig := "compose.yml"
if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
}
server := app.Server
if app.Server == "default" {
server = "local"
}
table.Append([]string{
server,
app.Recipe.Name,
deployConfig,
app.Domain,
version,
chaosVersion,
})
table.Render()
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{
Message: message,
}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
log.Fatal("exiting as requested")
}
return nil
}

View File

@ -32,11 +32,25 @@ var recipeLintCommand = cli.Command{
log.Fatal(err) log.Fatal(err)
} }
tableCol := []string{"ref", "rule", "severity", "satisfied", "skipped", "resolve"} headers := []string{
table := formatter.CreateTable(tableCol) "ref",
"rule",
"severity",
"satisfied",
"skipped",
"resolve",
}
table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers(headers...)
hasError := false hasError := false
bar := formatter.CreateProgressbar(-1, "running recipe lint rules...") var rows [][]string
var warnMessages []string
for level := range lint.LintRules { for level := range lint.LintRules {
for _, rule := range lint.LintRules[level] { for _, rule := range lint.LintRules[level] {
if internal.OnlyErrors && rule.Level != "error" { if internal.OnlyErrors && rule.Level != "error" {
@ -58,7 +72,7 @@ var recipeLintCommand = cli.Command{
if !skipped { if !skipped {
ok, err := rule.Function(recipe) ok, err := rule.Function(recipe)
if err != nil { if err != nil {
log.Warn(err) warnMessages = append(warnMessages, err.Error())
} }
if !ok && rule.Level == "error" { if !ok && rule.Level == "error" {
@ -78,26 +92,30 @@ var recipeLintCommand = cli.Command{
} }
} }
table.Append([]string{ row := []string{
rule.Ref, rule.Ref,
rule.Description, rule.Description,
rule.Level, rule.Level,
satisfiedOutput, satisfiedOutput,
skippedOutput, skippedOutput,
rule.HowToResolve, rule.HowToResolve,
}) }
bar.Add(1) rows = append(rows, row)
table.Row(row...)
} }
} }
if table.NumLines() > 0 { if len(rows) > 0 {
fmt.Println() fmt.Println(table)
table.Render()
}
if hasError { for _, warnMsg := range warnMessages {
log.Warn("watch out, some critical errors are present in your recipe config") log.Warn(warnMsg)
}
if hasError {
log.Warnf("critical errors present in %s config", recipe.Name)
}
} }
return nil return nil

View File

@ -41,12 +41,27 @@ var recipeListCommand = cli.Command{
recipes := catl.Flatten() recipes := catl.Flatten()
sort.Sort(recipe.ByRecipeName(recipes)) sort.Sort(recipe.ByRecipeName(recipes))
tableCol := []string{"name", "category", "status", "healthcheck", "backups", "email", "tests", "SSO"} table, err := formatter.CreateTable()
table := formatter.CreateTable(tableCol) if err != nil {
log.Fatal(err)
}
len := 0 headers := []string{
"name",
"category",
"status",
"healthcheck",
"backups",
"email",
"tests",
"SSO",
}
table.Headers(headers...)
var rows [][]string
for _, recipe := range recipes { for _, recipe := range recipes {
tableRow := []string{ row := []string{
recipe.Name, recipe.Name,
recipe.Category, recipe.Category,
strconv.Itoa(recipe.Features.Status), strconv.Itoa(recipe.Features.Status),
@ -59,23 +74,27 @@ var recipeListCommand = cli.Command{
if pattern != "" { if pattern != "" {
if strings.Contains(recipe.Name, pattern) { if strings.Contains(recipe.Name, pattern) {
table.Append(tableRow) table.Row(row...)
len++ rows = append(rows, row)
} }
} else { } else {
table.Append(tableRow) table.Row(row...)
len++ rows = append(rows, row)
} }
} }
if table.NumLines() > 0 { if len(rows) > 0 {
if internal.MachineReadable { if internal.MachineReadable {
table.SetCaption(false, "") out, err := formatter.ToJSON(headers, rows)
table.JSONRender() if err != nil {
} else { log.Fatal("unable to render to JSON: %s", err)
table.SetCaption(true, fmt.Sprintf("total recipes: %v", len)) }
table.Render() fmt.Println(out)
return nil
} }
fmt.Println(table)
log.Infof("total recipes: %v", len(rows))
} }
return nil return nil

View File

@ -9,7 +9,6 @@ import (
"coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
recipePkg "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/olekukonko/tablewriter"
"github.com/urfave/cli" "github.com/urfave/cli"
) )
@ -37,6 +36,8 @@ var recipeVersionCommand = cli.Command{
Before: internal.SubCommandBefore, Before: internal.SubCommandBefore,
BashComplete: autocomplete.RecipeNameComplete, BashComplete: autocomplete.RecipeNameComplete,
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
var warnMessages []string
recipe := internal.ValidateRecipe(c) recipe := internal.ValidateRecipe(c)
catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline) catl, err := recipePkg.ReadRecipeCatalogue(internal.Offline)
@ -46,47 +47,65 @@ var recipeVersionCommand = cli.Command{
recipeMeta, ok := catl[recipe.Name] recipeMeta, ok := catl[recipe.Name]
if !ok { if !ok {
log.Warn("no published versions in catalogue, trying local recipe repository") warnMessages = append(warnMessages, "retrieved versions from local recipe repository")
recipeVersions, err := recipe.GetRecipeVersions() recipeVersions, err := recipe.GetRecipeVersions()
if err != nil { if err != nil {
log.Warn(err) warnMessages = append(warnMessages, err.Error())
} }
recipeMeta = recipePkg.RecipeMeta{Versions: recipeVersions} recipeMeta = recipePkg.RecipeMeta{Versions: recipeVersions}
} }
if len(recipeMeta.Versions) == 0 { if len(recipeMeta.Versions) == 0 {
log.Fatalf("%s has no catalogue published versions?", recipe.Name) log.Fatalf("%s has no published versions?", recipe.Name)
} }
tableCols := []string{"version", "service", "image", "tag"}
aggregated_table := formatter.CreateTable(tableCols)
for i := len(recipeMeta.Versions) - 1; i >= 0; i-- { for i := len(recipeMeta.Versions) - 1; i >= 0; i-- {
table := formatter.CreateTable(tableCols) table, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
table.Headers("SERVICE", "NAME", "TAG")
for version, meta := range recipeMeta.Versions[i] { for version, meta := range recipeMeta.Versions[i] {
var versions [][]string var allRows [][]string
var rows [][]string
for service, serviceMeta := range meta { for service, serviceMeta := range meta {
versions = append(versions, []string{version, service, serviceMeta.Image, serviceMeta.Tag}) rows = append(rows, []string{service, serviceMeta.Image, serviceMeta.Tag})
allRows = append(allRows, []string{version, service, serviceMeta.Image, serviceMeta.Tag})
} }
sort.Slice(versions, sortServiceByName(versions)) sort.Slice(rows, sortServiceByName(rows))
for _, version := range versions { table.Rows(rows...)
table.Append(version)
aggregated_table.Append(version)
}
if !internal.MachineReadable { if !internal.MachineReadable {
table.SetAutoMergeCellsByColumnIndex([]int{0}) fmt.Println(table)
table.SetAlignment(tablewriter.ALIGN_LEFT) log.Infof("VERSION: %s", version)
table.Render()
fmt.Println() fmt.Println()
continue
}
if internal.MachineReadable {
sort.Slice(allRows, sortServiceByName(allRows))
headers := []string{"VERSION", "SERVICE", "NAME", "TAG"}
out, err := formatter.ToJSON(headers, allRows)
if err != nil {
log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
continue
} }
} }
} }
if internal.MachineReadable {
aggregated_table.JSONRender() if !internal.MachineReadable {
for _, warnMsg := range warnMessages {
log.Warn(warnMsg)
}
} }
return nil return nil

View File

@ -115,17 +115,10 @@ You can then add a server like so:
If "--local" is passed, then Abra assumes that the current local server is If "--local" is passed, then Abra assumes that the current local server is
intended as the target server. This is useful when you want to have your entire intended as the target server. This is useful when you want to have your entire
Co-op Cloud config located on the server itself, and not on your local Co-op Cloud config located on the server itself, and not on your local
developer machine. The domain is then set to "default". developer machine. The domain is then set to "default".`,
You can also pass "--no-domain-checks/-D" flag to use any arbitrary name
instead of a real domain. The host will be resolved with the "Hostname" entry
of your ~/.ssh/config. Checks for a valid online domain will be skipped:
abra server add -D example`,
Flags: []cli.Flag{ Flags: []cli.Flag{
internal.DebugFlag, internal.DebugFlag,
internal.NoInputFlag, internal.NoInputFlag,
internal.NoDomainChecksFlag,
localFlag, localFlag,
}, },
Before: internal.SubCommandBefore, Before: internal.SubCommandBefore,
@ -170,10 +163,8 @@ of your ~/.ssh/config. Checks for a valid online domain will be skipped:
return nil return nil
} }
if !internal.NoDomainChecks { if _, err := dns.EnsureIPv4(name); err != nil {
if _, err := dns.EnsureIPv4(name); err != nil { log.Warn(err)
log.Fatal(err)
}
} }
_, err := createServerDir(name) _, err := createServerDir(name)

View File

@ -1,6 +1,7 @@
package server package server
import ( import (
"fmt"
"strings" "strings"
"coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/cli/internal"
@ -29,14 +30,20 @@ var serverListCommand = cli.Command{
log.Fatal(err) log.Fatal(err)
} }
tableColumns := []string{"name", "host"} table, err := formatter.CreateTable()
table := formatter.CreateTable(tableColumns) if err != nil {
log.Fatal(err)
}
headers := []string{"NAME", "HOST"}
table.Headers(headers...)
serverNames, err := config.ReadServerNames() serverNames, err := config.ReadServerNames()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
var rows [][]string
for _, serverName := range serverNames { for _, serverName := range serverNames {
var row []string var row []string
for _, ctx := range contexts { for _, ctx := range contexts {
@ -57,6 +64,7 @@ var serverListCommand = cli.Command{
} }
row = []string{serverName, sp.Host} row = []string{serverName, sp.Host}
rows = append(rows, row)
} }
} }
@ -66,17 +74,22 @@ var serverListCommand = cli.Command{
} else { } else {
row = []string{serverName, "unknown"} row = []string{serverName, "unknown"}
} }
rows = append(rows, row)
} }
table.Append(row) table.Row(row...)
} }
if internal.MachineReadable { if internal.MachineReadable {
table.JSONRender() out, err := formatter.ToJSON(headers, rows)
if err != nil {
log.Fatal("unable to render to JSON: %s", err)
}
fmt.Println(out)
return nil return nil
} }
table.Render() fmt.Println(table)
return nil return nil
}, },

View File

@ -487,8 +487,11 @@ func newAbraApp(version, commit string) *cli.App {
} }
app.Before = func(c *cli.Context) error { app.Before = func(c *cli.Context) error {
log.Logger.SetStyles(log.Styles())
charmLog.SetDefault(log.Logger) charmLog.SetDefault(log.Logger)
log.Debugf("kadabra version %s, commit %s", version, commit) log.Debugf("kadabra version %s, commit %s", version, commit)
return nil return nil
} }

7
go.mod
View File

@ -6,6 +6,7 @@ require (
coopcloud.tech/tagcmp v0.0.0-20230809071031-eb3e7758d4eb coopcloud.tech/tagcmp v0.0.0-20230809071031-eb3e7758d4eb
git.coopcloud.tech/coop-cloud/godotenv v1.5.2-0.20231130100509-01bff8284355 git.coopcloud.tech/coop-cloud/godotenv v1.5.2-0.20231130100509-01bff8284355
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/charmbracelet/lipgloss v0.11.1
github.com/charmbracelet/log v0.4.0 github.com/charmbracelet/log v0.4.0
github.com/distribution/reference v0.6.0 github.com/distribution/reference v0.6.0
github.com/docker/cli v27.0.3+incompatible github.com/docker/cli v27.0.3+incompatible
@ -15,9 +16,9 @@ require (
github.com/google/go-cmp v0.6.0 github.com/google/go-cmp v0.6.0
github.com/moby/sys/signal v0.7.0 github.com/moby/sys/signal v0.7.0
github.com/moby/term v0.5.0 github.com/moby/term v0.5.0
github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/schollz/progressbar/v3 v3.14.4 github.com/schollz/progressbar/v3 v3.14.4
golang.org/x/term v0.22.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.1 gotest.tools/v3 v3.5.1
) )
@ -32,8 +33,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/lipgloss v0.11.0 // indirect github.com/charmbracelet/x/ansi v0.1.3 // indirect
github.com/charmbracelet/x/ansi v0.1.2 // indirect
github.com/cloudflare/circl v1.3.9 // indirect github.com/cloudflare/circl v1.3.9 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
@ -105,7 +105,6 @@ require (
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.27.0 // indirect golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/term v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect

11
go.sum
View File

@ -135,12 +135,12 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= github.com/charmbracelet/lipgloss v0.11.1 h1:a8KgVPHa7kOoP95vm2tQQrjD2AKhbWmfr4uJ2RW6kNk=
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= github.com/charmbracelet/lipgloss v0.11.1/go.mod h1:beLlcmkF7MWA+5UrKKIRo/VJ21xGXr7YJ9miWfdMRIU=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= github.com/charmbracelet/x/ansi v0.1.3 h1:RBh/eleNWML5R524mjUF0yVRePTwqN9tPtV+DPgO5Lw=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/ansi v0.1.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw=
github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E=
@ -621,7 +621,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
@ -687,8 +686,6 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=

View File

@ -569,21 +569,34 @@ func ReadAbraShCmdNames(abraSh string) ([]string, error) {
return cmdNames, nil return cmdNames, nil
} }
func (a App) WriteRecipeVersion(version string) error { func (a App) WriteRecipeVersion(version string, dryRun bool) error {
file, err := os.Open(a.Path) file, err := os.Open(a.Path)
if err != nil { if err != nil {
return err return err
} }
defer file.Close() defer file.Close()
skipped := false
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
lines := []string{} lines := []string{}
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if !strings.Contains(line, "RECIPE=") && !strings.Contains(line, "TYPE") { if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
lines = append(lines, line) lines = append(lines, line)
continue continue
} }
if strings.HasPrefix(line, "#") {
lines = append(lines, line)
continue
}
if strings.Contains(line, version) {
skipped = true
lines = append(lines, line)
continue
}
splitted := strings.Split(line, ":") splitted := strings.Split(line, ":")
line = fmt.Sprintf("%s:%s", splitted[0], version) line = fmt.Sprintf("%s:%s", splitted[0], version)
lines = append(lines, line) lines = append(lines, line)
@ -593,5 +606,19 @@ func (a App) WriteRecipeVersion(version string) error {
log.Fatal(err) log.Fatal(err)
} }
return os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm) if !dryRun {
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
log.Fatal(err)
}
} else {
log.Debugf("skipping writing version %s because dry run", version)
}
if !skipped {
log.Infof("version %s saved to %s.env", version, a.Domain)
} else {
log.Debugf("skipping version %s write as already exists in %s.env", version, a.Domain)
}
return nil
} }

View File

@ -107,4 +107,5 @@ var (
REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud" REPOS_BASE_URL = "https://git.coopcloud.tech/coop-cloud"
CATALOGUE_JSON_REPO_NAME = "recipes-catalogue-json" CATALOGUE_JSON_REPO_NAME = "recipes-catalogue-json"
SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git" SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
CHAOS_DEFAULT = "false"
) )

View File

@ -11,6 +11,7 @@ import (
"coopcloud.tech/abra/pkg/envfile" "coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/recipe"
testPkg "coopcloud.tech/abra/pkg/test" testPkg "coopcloud.tech/abra/pkg/test"
"github.com/stretchr/testify/assert"
) )
func TestGetAllFoldersInDirectory(t *testing.T) { func TestGetAllFoldersInDirectory(t *testing.T) {
@ -43,13 +44,7 @@ func TestReadEnv(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if !reflect.DeepEqual(env, testPkg.ExpectedAppEnv) { if !reflect.DeepEqual(env, testPkg.ExpectedAppEnv) {
t.Fatalf( t.Fatal("did not get expected application settings")
"did not get expected application settings. Expected: DOMAIN=%s RECIPE=%s; Got: DOMAIN=%s RECIPE=%s",
testPkg.ExpectedAppEnv["DOMAIN"],
testPkg.ExpectedAppEnv["RECIPE"],
env["DOMAIN"],
env["RECIPE"],
)
} }
} }
@ -228,3 +223,21 @@ func TestEnvVarModifiersIncluded(t *testing.T) {
} }
} }
} }
func TestNoOverwriteNonVersionEnvVars(t *testing.T) {
app, err := appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
if err != nil {
t.Fatal(err)
}
if err := app.WriteRecipeVersion("1.3.12", true); err != nil {
t.Fatal(err)
}
app, err = appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
if err != nil {
t.Fatal(err)
}
assert.NotEqual(t, app.Env["SMTP_AUTHTYPE"], "login:1.3.12")
}

View File

@ -1,18 +1,26 @@
package formatter package formatter
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/docker/go-units" "github.com/docker/go-units"
// "github.com/olekukonko/tablewriter" "golang.org/x/term"
"coopcloud.tech/abra/pkg/jsontable"
"coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/log"
"github.com/schollz/progressbar/v3" "github.com/schollz/progressbar/v3"
) )
var BoldStyle = lipgloss.NewStyle().
Bold(true).
Underline(true)
func ShortenID(str string) string { func ShortenID(str string) string {
return str[:12] return str[:12]
} }
@ -34,11 +42,67 @@ func HumanDuration(timestamp int64) string {
} }
// CreateTable prepares a table layout for output. // CreateTable prepares a table layout for output.
func CreateTable(columns []string) *jsontable.JSONTable { func CreateTable() (*table.Table, error) {
table := jsontable.NewJSONTable(os.Stdout) table := table.New().
table.SetAutoWrapText(false) Border(lipgloss.ThickBorder()).
table.SetHeader(columns) BorderStyle(
return table lipgloss.NewStyle().
Foreground(lipgloss.Color("63")),
)
if isAbraCI, ok := os.LookupEnv("ABRA_CI"); ok && isAbraCI == "1" {
// NOTE(d1): no width limits for CI testing since we test against outputs
log.Debug("detected ABRA_CI=1")
return table, nil
}
width, _, err := term.GetSize(0)
if err != nil {
return nil, err
}
if width-10 < 79 {
// NOTE(d1): maintain standard minimum width
table.Width(79)
} else {
// NOTE(d1): tests show that this produces stable border drawing
table.Width(width - 10)
}
return table, nil
}
// ToJSON converts a lipgloss.Table to JSON representation. It's not a robust
// implementation and mainly caters for our current use case which is basically
// a bunch of strings. See https://github.com/charmbracelet/lipgloss/issues/335
// for the real thing (hopefully).
func ToJSON(headers []string, rows [][]string) (string, error) {
var buff bytes.Buffer
buff.Write([]byte("["))
for idx, row := range rows {
payload := make(map[string]string)
for idx, header := range headers {
payload[strings.ToLower(header)] = row[idx]
}
serialized, err := json.Marshal(payload)
if err != nil {
return "", err
}
buff.Write(serialized)
if idx < (len(rows) - 1) {
buff.Write([]byte(","))
}
}
buff.Write([]byte("]"))
return buff.String(), nil
} }
// CreateProgressbar generates a progress bar // CreateProgressbar generates a progress bar

View File

@ -1,211 +0,0 @@
package jsontable
import (
"fmt"
"io"
"strings"
"github.com/olekukonko/tablewriter"
)
// A quick-and-dirty proxy/emulator of tablewriter to enable more easy machine readable output
// - Does not strictly support types, just quoted or unquoted values
// - Does not support nested values.
// If a datalabel is set with SetDataLabel(true, "..."), that will be used as the key for teh data of the table,
// otherwise if the caption is set with SetCaption(true, "..."), the data label will be set to the default of
// "rows", otherwise the table will output as a JSON list.
//
// Proxys all actions through to the tablewriter except addrow and addbatch, which it does at render time
//
type JSONTable struct {
out io.Writer
colsize int
rows [][]string
keys []string
quoted []bool // hack to do output typing, quoted vs. unquoted
hasDataLabel bool
dataLabel string
hasCaption bool
caption string // the actual caption
hasCaptionLabel bool
captionLabel string // the key in the dictionary for the caption
tbl *tablewriter.Table
}
func writeChar(w io.Writer, c byte) {
w.Write([]byte{c})
}
func NewJSONTable(writer io.Writer) *JSONTable {
t := &JSONTable{
out: writer,
colsize: 0,
rows: [][]string{},
keys: []string{},
quoted: []bool{},
hasDataLabel: false,
dataLabel: "rows",
hasCaption: false,
caption: "",
hasCaptionLabel: false,
captionLabel: "caption",
tbl: tablewriter.NewWriter(writer),
}
return t
}
func (t *JSONTable) NumLines() int {
// JSON only but reflects a shared state.
return len(t.rows)
}
func (t *JSONTable) SetHeader(keys []string) {
// Set the keys value which will assign each column to the keys.
// Note that we'll ignore values that are beyond the length of the keys list
t.colsize = len(keys)
t.keys = []string{}
for _, k := range keys {
t.keys = append(t.keys, k)
t.quoted = append(t.quoted, true)
}
t.tbl.SetHeader(keys)
}
func (t *JSONTable) SetColumnQuoting(quoting []bool) {
// Specify which columns are quoted or unquoted in output
// JSON only
for i := 0; i < t.colsize; i++ {
t.quoted[i] = quoting[i]
}
}
func (t *JSONTable) Append(row []string) {
// We'll just append whatever to the rows list. If they fix the keys after appending rows, it'll work as
// expected.
// We should detect if the row is narrower than the key list tho.
// JSON only (but we use the rows later when rendering a regular table)
t.rows = append(t.rows, row)
}
func (t *JSONTable) Render() {
// Load the table with rows and render.
// Proxy only
for _, row := range t.rows {
t.tbl.Append(row)
}
t.tbl.Render()
}
func (t *JSONTable) _JSONRenderInner() {
// JSON only
// Render the list of dictionaries to the writer.
//// inner render loop
writeChar(t.out, '[')
for rowidx, row := range t.rows {
if rowidx != 0 {
writeChar(t.out, ',')
}
writeChar(t.out, '{')
for keyidx, key := range t.keys {
key := strings.ToLower(key)
key = strings.ReplaceAll(key, " ", "-")
value := "nil"
if keyidx < len(row) {
value = row[keyidx]
}
if keyidx != 0 {
writeChar(t.out, ',')
}
if t.quoted[keyidx] {
fmt.Fprintf(t.out, "\"%s\":\"%s\"", key, value)
} else {
fmt.Fprintf(t.out, "\"%s\":%s", key, value)
}
}
writeChar(t.out, '}')
}
writeChar(t.out, ']')
}
func (t *JSONTable) JSONRender() {
// write JSON table to output
// JSON only
if t.hasDataLabel || t.hasCaption {
// dict mode
writeChar(t.out, '{')
if t.hasCaption {
fmt.Fprintf(t.out, "\"%s\":\"%s\",", t.captionLabel, t.caption)
}
fmt.Fprintf(t.out, "\"%s\":", t.dataLabel)
}
// write list
t._JSONRenderInner()
if t.hasDataLabel || t.hasCaption {
// dict mode
writeChar(t.out, '}')
}
}
func (t *JSONTable) SetCaption(caption bool, captionText ...string) {
t.hasCaption = caption
if len(captionText) == 1 {
t.caption = captionText[0]
}
t.tbl.SetCaption(caption, captionText...)
}
func (t *JSONTable) SetCaptionLabel(captionLabel bool, captionLabelText ...string) {
// JSON only
t.hasCaptionLabel = captionLabel
if len(captionLabelText) == 1 {
t.captionLabel = captionLabelText[0]
}
}
func (t *JSONTable) SetDataLabel(dataLabel bool, dataLabelText ...string) {
// JSON only
t.hasDataLabel = dataLabel
if len(dataLabelText) == 1 {
t.dataLabel = dataLabelText[0]
}
}
func (t *JSONTable) AppendBulk(rows [][]string) {
// JSON only but reflects shared state
for _, row := range rows {
t.Append(row)
}
}
// Stuff we should implement but we just proxy for now.
func (t *JSONTable) SetAutoMergeCellsByColumnIndex(cols []int) {
// FIXME
t.tbl.SetAutoMergeCellsByColumnIndex(cols)
}
// Stuff we should implement but we just proxy for now.
func (t *JSONTable) SetAlignment(align int) {
// FIXME
t.tbl.SetAlignment(align)
}
func (t *JSONTable) SetAutoMergeCells(auto bool) {
// FIXME
t.tbl.SetAutoMergeCells(auto)
}
// Stub functions
func (t *JSONTable) SetAutoWrapText(auto bool) {
t.tbl.SetAutoWrapText(auto)
return
}

View File

@ -1,83 +0,0 @@
package jsontable
import (
"testing"
"bytes"
"encoding/json"
"github.com/olekukonko/tablewriter"
)
var TestLine = []string{"1", "2"}
var TestGroup = [][]string{{"1", "2", "3"}, {"a", "teohunteohu", "c", "d"}, {"☺", "☹"}}
var TestKeys = []string{"key0", "key1", "key2"}
// test creation
func TestNewTable(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
if tbl.NumLines() != 0 {
t.Fatalf("Something went weird when making table (should have 0 lines)")
}
}
// test adding things
func TestTableAdd(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
tbl.Append(TestLine)
if tbl.NumLines() != 1 {
t.Fatalf("Appending a line does not result in a length of 1.")
}
tbl.AppendBulk(TestGroup)
numlines := tbl.NumLines()
if numlines != (len(TestGroup) + 1) {
t.Fatalf("Appending two lines does not result in a length of 4 (length is %d).", numlines)
}
}
// test JSON output is parsable
func TestJsonParsable(t *testing.T) {
var b bytes.Buffer
tbl := NewJSONTable(&b)
tbl.AppendBulk(TestGroup)
tbl.SetHeader(TestKeys)
tbl.JSONRender()
var son []map[string]interface{}
err := json.Unmarshal(b.Bytes(), &son)
if err != nil {
t.Fatalf("Did not produce parsable JSON: %s", err.Error())
}
}
// test identical commands to a tablewriter and jsontable produce the same rendered output
func TestTableWriter(t *testing.T) {
var bjson bytes.Buffer
var btable bytes.Buffer
tbl := NewJSONTable(&bjson)
tbl.AppendBulk(TestGroup)
tbl.SetHeader(TestKeys)
tbl.Render()
wtbl := tablewriter.NewWriter(&btable)
wtbl.AppendBulk(TestGroup)
wtbl.SetHeader(TestKeys)
wtbl.Render()
if bytes.Compare(bjson.Bytes(), btable.Bytes()) != 0 {
t.Fatalf("JSON table and TableWriter produce non-identical outputs.\n%s\n%s", bjson.Bytes(), btable.Bytes())
}
}
/// FIXME test different output formats when captions etc. are added

View File

@ -3,7 +3,9 @@ package log
import ( import (
"os" "os"
"strings"
"github.com/charmbracelet/lipgloss"
charmLog "github.com/charmbracelet/log" charmLog "github.com/charmbracelet/log"
) )
@ -32,3 +34,42 @@ var SetLevel = Logger.SetLevel
var DebugLevel = charmLog.DebugLevel var DebugLevel = charmLog.DebugLevel
var SetOutput = charmLog.SetOutput var SetOutput = charmLog.SetOutput
var SetReportCaller = charmLog.SetReportCaller var SetReportCaller = charmLog.SetReportCaller
func Styles() *charmLog.Styles {
styles := charmLog.DefaultStyles()
styles.Levels = map[charmLog.Level]lipgloss.Style{
charmLog.DebugLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(DebugLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("63")).
Foreground(lipgloss.Color("15")),
charmLog.InfoLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.InfoLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("86")).
Foreground(lipgloss.Color("16")),
charmLog.WarnLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.WarnLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("192")).
Foreground(lipgloss.Color("16")),
charmLog.ErrorLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.ErrorLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("204")).
Foreground(lipgloss.Color("15")),
charmLog.FatalLevel: lipgloss.NewStyle().
SetString(strings.ToUpper(charmLog.FatalLevel.String())).
Bold(true).
Padding(0, 1, 0, 1).
Background(lipgloss.Color("134")).
Foreground(lipgloss.Color("15")),
}
return styles
}

View File

@ -26,20 +26,26 @@ func (r Recipe) Ensure(chaos bool, offline bool) error {
if err := r.EnsureIsClean(); err != nil { if err := r.EnsureIsClean(); err != nil {
return err return err
} }
if !offline { if !offline {
if err := r.EnsureUpToDate(); err != nil { if err := r.EnsureUpToDate(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
if r.Version != "" { if r.Version != "" {
log.Debugf("ensuring version %s", r.Version)
if _, err := r.EnsureVersion(r.Version); err != nil { if _, err := r.EnsureVersion(r.Version); err != nil {
return err return err
} }
} else {
if err := r.EnsureLatest(); err != nil { return nil
return err
}
} }
if err := r.EnsureLatest(); err != nil {
return err
}
return nil return nil
} }

View File

@ -127,6 +127,9 @@ func Get(name string) Recipe {
version := "" version := ""
if strings.Contains(name, ":") { if strings.Contains(name, ":") {
split := strings.Split(name, ":") split := strings.Split(name, ":")
if len(split) > 2 {
log.Fatalf("version seems invalid: %s", name)
}
name = split[0] name = split[0]
version = split[1] version = split[1]
} }

View File

@ -181,7 +181,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
if err := client.StoreSecret(cl, secret.RemoteName, passwords[0], server); err != nil { if err := client.StoreSecret(cl, secret.RemoteName, passwords[0], server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
log.Warnf("%s already exists, moving on...", secret.RemoteName) log.Warnf("%s already exists", secret.RemoteName)
ch <- nil ch <- nil
} else { } else {
ch <- err ch <- err
@ -201,7 +201,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
if err := client.StoreSecret(cl, secret.RemoteName, passphrases[0], server); err != nil { if err := client.StoreSecret(cl, secret.RemoteName, passphrases[0], server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
log.Warnf("%s already exists, moving on...", secret.RemoteName) log.Warnf("%s already exists", secret.RemoteName)
ch <- nil ch <- nil
} else { } else {
ch <- err ch <- err

View File

@ -27,8 +27,9 @@ var (
) )
var ExpectedAppEnv = envfile.AppEnv{ var ExpectedAppEnv = envfile.AppEnv{
"DOMAIN": "ecloud.evil.corp", "DOMAIN": "ecloud.evil.corp",
"RECIPE": "ecloud", "RECIPE": "ecloud",
"SMTP_AUTHTYPE": "login",
} }
var ExpectedApp = appPkg.App{ var ExpectedApp = appPkg.App{

View File

@ -175,6 +175,7 @@ func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace conve
pruneServices = append(pruneServices, service) pruneServices = append(pruneServices, service)
} }
} }
removeServices(ctx, cl, pruneServices) removeServices(ctx, cl, pruneServices)
} }
@ -255,10 +256,12 @@ func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, co
log.Infof("waiting for %s to deploy... please hold 🤚", appName) log.Infof("waiting for %s to deploy... please hold 🤚", appName)
if err := waitOnServices(ctx, cl, serviceIDs, appName); err == nil { if err := waitOnServices(ctx, cl, serviceIDs, appName); err != nil {
log.Infof("successfully deployed %s", appName) return err
} }
log.Infof("successfully deployed %s", appName)
return nil return nil
} }
@ -395,7 +398,7 @@ func deployServices(
) )
if service, exists := existingServiceMap[name]; exists { if service, exists := existingServiceMap[name]; exists {
log.Infof("updating service %s (id: %s)", name, service.ID) log.Infof("updating %s", name)
updateOpts := types.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth} updateOpts := types.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth}
@ -430,7 +433,7 @@ func deployServices(
response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts) response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "failed to update service %s", name) return nil, errors.Wrapf(err, "failed to update %s", name)
} }
for _, warning := range response.Warnings { for _, warning := range response.Warnings {
@ -439,7 +442,7 @@ func deployServices(
serviceIDs = append(serviceIDs, service.ID) serviceIDs = append(serviceIDs, service.ID)
} else { } else {
log.Infof("creating service %s", name) log.Infof("creating %s", name)
createOpts := types.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth} createOpts := types.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth}
@ -450,7 +453,7 @@ func deployServices(
serviceCreateResponse, err := cl.ServiceCreate(ctx, serviceSpec, createOpts) serviceCreateResponse, err := cl.ServiceCreate(ctx, serviceSpec, createOpts)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "failed to create service %s", name) return nil, errors.Wrapf(err, "failed to create %s", name)
} }
serviceIDs = append(serviceIDs, serviceCreateResponse.ID) serviceIDs = append(serviceIDs, serviceCreateResponse.ID)
@ -510,13 +513,14 @@ func WaitOnService(ctx context.Context, cl *dockerClient.Client, serviceID, appN
case err := <-errChan: case err := <-errChan:
return err return err
case <-sigintChannel: case <-sigintChannel:
return fmt.Errorf(fmt.Sprintf(` return fmt.Errorf(`
Not waiting for %s to deploy. The deployment is ongoing... Not waiting for %s to deploy. The deployment is ongoing...
If you want to stop the deployment, try: If you want to stop the deployment, try:
abra app undeploy %s`, appName, appName))
abra app undeploy %s`, appName, appName)
case <-time.After(timeout): case <-time.After(timeout):
return fmt.Errorf(fmt.Sprintf(` return fmt.Errorf(`
%s has not converged (%s second timeout reached). %s has not converged (%s second timeout reached).
This does not necessarily mean your deployment has failed, it may just be that This does not necessarily mean your deployment has failed, it may just be that
@ -530,7 +534,7 @@ You can track latest deployment status with:
And inspect the logs with: And inspect the logs with:
abra app logs %s abra app logs %s
`, appName, timeout, appName, appName)) `, appName, timeout, appName, appName)
} }
} }
@ -548,7 +552,7 @@ func GetStacks(cl *dockerClient.Client) ([]*formatter.Stack, error) {
labels := service.Spec.Labels labels := service.Spec.Labels
name, ok := labels[convert.LabelNamespace] name, ok := labels[convert.LabelNamespace]
if !ok { if !ok {
return nil, errors.Errorf("cannot get label %s for service %s", return nil, errors.Errorf("cannot get label %s for %s",
convert.LabelNamespace, service.ID) convert.LabelNamespace, service.ID)
} }
ztack, ok := m[name] ztack, ok := m[name]

View File

@ -87,7 +87,7 @@ function install_abra_release {
x=$(echo $PATH | grep $HOME/.local/bin) x=$(echo $PATH | grep $HOME/.local/bin)
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "$(tput setaf 3)WARNING: $HOME/.local/bin/ is not in \$PATH! If you want to run abra by just typing "abra" you should add it to your \$PATH! To do that run:$(tput sgr0)" echo "$(tput setaf 3)WARNING: $HOME/.local/bin/ is not in \$PATH! If you want to run abra by just typing "abra" you should add it to your \$PATH! To do that run this once and restart your terminal:$(tput sgr0)"
p=$HOME/.local/bin p=$HOME/.local/bin
com="echo PATH=\$PATH:$p" com="echo PATH=\$PATH:$p"
if [[ $SHELL =~ "bash" ]]; then if [[ $SHELL =~ "bash" ]]; then

View File

@ -7,10 +7,9 @@
# destroys resources on the swarm server you run it against. This is for # destroys resources on the swarm server you run it against. This is for
# setup/teardown for the integration test suite. # setup/teardown for the integration test suite.
# #
# export DRONE_SOURCE_BRANCH=<your-branch-name>
# ./run-ci-int # ./run-ci-int
set +e set -eu
echo "========================================================================" echo "========================================================================"
echo "WIPING DOCKER RESOURCES FOR A CLEAN SLATE" echo "WIPING DOCKER RESOURCES FOR A CLEAN SLATE"
@ -45,17 +44,7 @@ echo "========================================================================"
rm -rf abra rm -rf abra
git clone ssh://git@git.coopcloud.tech:2222/coop-cloud/abra.git git clone ssh://git@git.coopcloud.tech:2222/coop-cloud/abra.git
cd abra cd abra
echo "========================================================================" git checkout main
echo "========================================================================"
echo "FETCHING ABRA BRANCH FOR TESTING"
echo "========================================================================"
if [ -z "$DRONE_SOURCE_BRANCH" ]; then
DRONE_SOURCE_BRANCH="main"
fi
git fetch --all
git checkout $DRONE_SOURCE_BRANCH
echo "========================================================================" echo "========================================================================"
echo "========================================================================" echo "========================================================================"
@ -83,6 +72,7 @@ echo "========================================================================"
export ABRA_DIR="$HOME/.abra_test" export ABRA_DIR="$HOME/.abra_test"
export TERM=xterm export TERM=xterm
export TEST_SERVER=default export TEST_SERVER=default
export ABRA_CI=1
rm -rf "$ABRA_DIR" rm -rf "$ABRA_DIR"
bats -Tp tests/integration --filter-tags \!dns --print-output-on-failure bats -Tp tests/integration --filter-tags \!dns --print-output-on-failure

View File

@ -20,6 +20,7 @@ setup(){
teardown(){ teardown(){
_reset_recipe _reset_recipe
_reset_tags
} }
@test "validate app argument" { @test "validate app argument" {
@ -82,19 +83,17 @@ teardown(){
} }
@test "ensure recipe not up to date if --offline" { @test "ensure recipe not up to date if --offline" {
wantHash=$(_get_n_hash 1) _ensure_env_version "0.1.0+1.20.0"
latestRelease=$(_latest_release)
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" reset --hard HEAD~1 run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -d "$latestRelease"
assert_success assert_success
assert_equal $(_get_current_hash) "$wantHash" # NOTE(d1): don't assert success because it might flake
# NOTE(d1): we can't quite tell if this will fail or not in the future, so,
# since it isn't an important part of what we're testing here, we don't check
# it
run $ABRA app check "$TEST_APP_DOMAIN" --offline run $ABRA app check "$TEST_APP_DOMAIN" --offline
assert_equal $(_get_current_hash) "$wantHash" run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l
refute_output --partial "$latestRelease"
} }
@test "error if missing .env.sample" { @test "error if missing .env.sample" {
@ -118,3 +117,20 @@ teardown(){
assert_success assert_success
assert_output --partial '❌' assert_output --partial '❌'
} }
# bats test_tags=slow
@test "respects env version" {
tagHash=$(_get_tag_hash "0.1.0+1.20.0")
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks
assert_success
run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app check "$TEST_APP_DOMAIN"
assert_success
assert_equal $(_get_current_hash) "$tagHash"
}

View File

@ -19,8 +19,9 @@ setup(){
} }
teardown(){ teardown(){
_undeploy_app
_reset_recipe _reset_recipe
_reset_tags
_undeploy_app
} }
# bats test_tags=slow # bats test_tags=slow
@ -105,20 +106,18 @@ test_cmd_export"
} }
@test "ensure recipe not up to date if --offline" { @test "ensure recipe not up to date if --offline" {
wantHash=$(_get_n_hash 3) _ensure_env_version "0.1.0+1.20.0"
latestRelease=$(_latest_release)
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" reset --hard HEAD~3 run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -d "$latestRelease"
assert_success assert_success
assert_equal $(_get_current_hash) "$wantHash"
run $ABRA app cmd --local --offline "$TEST_APP_DOMAIN" test_cmd run $ABRA app cmd --local --offline "$TEST_APP_DOMAIN" test_cmd
assert_success assert_success
assert_output --partial 'baz' assert_output --partial 'baz'
assert_equal $(_get_current_hash) $wantHash run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l
refute_output --partial "$latestRelease"
_reset_recipe "$TEST_RECIPE"
} }
@test "error if missing arguments without passing --local" { @test "error if missing arguments without passing --local" {
@ -187,6 +186,24 @@ test_cmd_export"
assert_output --partial 'baz' assert_output --partial 'baz'
} }
# bats test_tags=slow
@test "respects env version" {
tagHash=$(_get_tag_hash "0.1.0+1.20.0")
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input
assert_success
run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app cmd "$TEST_APP_DOMAIN" app test_cmd
assert_success
assert_output --partial 'baz'
assert_equal $(_get_current_hash) "$tagHash"
}
# bats test_tags=slow # bats test_tags=slow
@test "error if missing service" { @test "error if missing service" {
_deploy_app _deploy_app

View File

@ -16,12 +16,13 @@ teardown_file(){
setup(){ setup(){
load "$PWD/tests/integration/helpers/common" load "$PWD/tests/integration/helpers/common"
_common_setup _common_setup
_ensure_catalogue
} }
teardown(){ teardown(){
_undeploy_app
_reset_recipe _reset_recipe
_reset_app _reset_app
_undeploy_app
_reset_tags _reset_tags
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo" run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
@ -86,19 +87,18 @@ teardown(){
# bats test_tags=slow # bats test_tags=slow
@test "ensure recipe not up to date if --offline" { @test "ensure recipe not up to date if --offline" {
wantHash=$(_get_n_hash 3) _ensure_env_version "0.1.0+1.20.0"
latestRelease=$(_latest_release)
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" reset --hard HEAD~3 run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -d "$latestRelease"
assert_success assert_success
assert_equal $(_get_current_hash) "$wantHash"
# NOTE(d1): need to use --chaos to force same commit
run $ABRA app deploy "$TEST_APP_DOMAIN" \ run $ABRA app deploy "$TEST_APP_DOMAIN" \
--no-input --no-converge-checks --chaos --offline --no-input --no-converge-checks --offline
assert_success assert_success
assert_equal $(_get_current_hash) "$wantHash" run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l
refute_output --partial "$latestRelease"
} }
# bats test_tags=slow # bats test_tags=slow
@ -106,6 +106,7 @@ teardown(){
latestCommit="$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-parse --short HEAD)" latestCommit="$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-parse --short HEAD)"
_remove_tags _remove_tags
_wipe_env_version
# NOTE(d1): need to pass --offline to stop tags being pulled again # NOTE(d1): need to pass --offline to stop tags being pulled again
run $ABRA app deploy "$TEST_APP_DOMAIN" \ run $ABRA app deploy "$TEST_APP_DOMAIN" \
@ -125,6 +126,8 @@ teardown(){
assert_equal $(_get_current_hash) "$wantHash" assert_equal $(_get_current_hash) "$wantHash"
_wipe_env_version
run $ABRA app deploy "$TEST_APP_DOMAIN" \ run $ABRA app deploy "$TEST_APP_DOMAIN" \
--no-input --no-converge-checks --chaos --no-input --no-converge-checks --chaos
assert_success assert_success
@ -171,14 +174,12 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" \ run $ABRA app deploy "$TEST_APP_DOMAIN" \
--no-input --no-converge-checks --force --no-input --no-converge-checks --force
assert_success assert_success
assert_output --partial 'already deployed but continuing' assert_output --partial 'already deployed'
assert_output --partial '--force'
run $ABRA app deploy "$TEST_APP_DOMAIN" \ run $ABRA app deploy "$TEST_APP_DOMAIN" \
--no-input --no-converge-checks --chaos --no-input --no-converge-checks --chaos
assert_success assert_success
assert_output --partial 'already deployed but continuing' assert_output --partial 'already deployed'
assert_output --partial '--chaos'
} }
# bats test_tags=slow # bats test_tags=slow
@ -351,39 +352,6 @@ teardown(){
_undeploy_app _undeploy_app
# TODO(d1): use of `--chaos` is a hack while the following is not fixed run $ABRA app secret rm "$TEST_APP_DOMAIN" --all
# https://git.coopcloud.tech/coop-cloud/organising/issues/620
run $ABRA app secret rm "$TEST_APP_DOMAIN" --all --chaos
assert_success assert_success
} }
# bats test_tags=slow
@test "deploy chaos commit" {
tagHash=$(_get_tag_hash "0.1.0+1.20.0")
run $ABRA app deploy "$TEST_APP_DOMAIN" "$tagHash" --no-input --no-converge-checks
assert_success
assert_output --partial 'chaos mode'
}
# bats test_tags=slow
@test "deploy remote recipe" {
run sed -i 's/TYPE=abra-test-recipe/RECIPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success
assert_output --partial "git.coopcloud.tech/coop-cloud/abra-test-recipe"
}
# bats test_tags=slow
@test "deploy remote recipe with version" {
run sed -i 's/TYPE=abra-test-recipe/RECIPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:0.2.0+1.21.0/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success
assert_output --partial '0.2.0+1.21.0'
}

View File

@ -0,0 +1,99 @@
#!/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
_undeploy_app
_reset_app
}
# bats test_tags=slow
@test "deploy version written to env" {
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks
assert_success
run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}
# bats test_tags=slow
@test "redeploy overwrites env version" {
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks
assert_success
run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.2.0+1.21.0" \
--no-input --no-converge-checks --force
assert_success
run grep -q "TYPE=$TEST_RECIPE:0.2.0+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}
# bats test_tags=slow
@test "chaos commit written to env" {
run $ABRA app deploy "$TEST_APP_DOMAIN" "1e83340e" --no-input --no-converge-checks
assert_success
run grep -q "TYPE=$TEST_RECIPE:1e83340e" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}
# bats test_tags=slow
@test "redeploy reads from env version" {
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks
assert_success
run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
_undeploy_app
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success
assert_output --partial '0.1.0+1.20.0'
}
# bats test_tags=slow
@test "specific version overrides env version" {
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks
assert_success
run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.2.0+1.21.0" \
--no-input --no-converge-checks --force --debug
assert_success
assert_output --partial "overriding env file version"
run grep -q "TYPE=$TEST_RECIPE:0.2.0+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}

View File

@ -0,0 +1,73 @@
#!/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
_undeploy_app
_reset_app
}
# bats test_tags=slow
@test "deploy remote recipe" {
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success
assert_output --partial "git.coopcloud.tech/coop-cloud/abra-test-recipe"
}
# bats test_tags=slow
@test "deploy remote recipe with version" {
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:0.2.0+1.21.0/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success
assert_output --partial '0.2.0+1.21.0'
}
# bats test_tags=slow
@test "deploy remote recipe with chaos commit" {
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:1e83340e/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success
assert_output --partial '1e83340e'
}
# bats test_tags=slow
@test "remote recipe version written to env" {
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success
run grep -q "TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:$(_latest_release)" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}

View File

@ -0,0 +1,44 @@
#!/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_app
}
@test "badly formatted env version bails out" {
run sed -i 's/TYPE=abra-test-recipe/TYPE=abra-test-recipe:0.2.0+1.21.0/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_failure
assert_output --partial 'seems invalid'
}
@test "invalid env version bails out" {
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe:DOESNTEXIST/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_failure
assert_output --partial 'not found'
}

View File

@ -62,8 +62,8 @@ teardown(){
run $ABRA app ls --server foo.com run $ABRA app ls --server foo.com
assert_success assert_success
refute_output --partial "server: $TEST_SERVER |" refute_output --partial "SERVER: $TEST_SERVER"
assert_output --partial "server: foo.com |" assert_output --partial "SERVER: foo.com"
run rm -rf "$ABRA_DIR/servers/foo.com" run rm -rf "$ABRA_DIR/servers/foo.com"
assert_success assert_success
@ -97,8 +97,8 @@ teardown(){
@test "server stats are correct" { @test "server stats are correct" {
run $ABRA app ls run $ABRA app ls
assert_success assert_success
assert_output --partial "server: $TEST_SERVER" assert_output --partial "SERVER: $TEST_SERVER"
assert_output --partial "total apps: 1" assert_output --partial "TOTAL APPS: 1"
run mkdir -p "$ABRA_DIR/servers/foo.com" run mkdir -p "$ABRA_DIR/servers/foo.com"
assert_success assert_success
@ -113,8 +113,8 @@ teardown(){
assert_success assert_success
assert_output --partial "$TEST_SERVER" assert_output --partial "$TEST_SERVER"
assert_output --partial "foo.com" assert_output --partial "foo.com"
assert_output --partial "total servers: 2" assert_output --partial "TOTAL SERVERS: 2"
assert_output --partial "total apps: 2" assert_output --partial "TOTAL APPS: 2"
run rm -rf "$ABRA_DIR/servers/foo.com" run rm -rf "$ABRA_DIR/servers/foo.com"
assert_success assert_success

View File

@ -39,17 +39,41 @@ teardown(){
_get_head_hash _get_head_hash
_get_current_hash _get_current_hash
assert_equal "$headHash" "$currentHash" assert_equal "$headHash" "$currentHash"
run grep -q "TYPE=$TEST_RECIPE:$(_latest_release)" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
} }
@test "create new app with version" { @test "create new app with version" {
run $ABRA app new "$TEST_RECIPE" 0.1.1+1.20.2 \ run $ABRA app new "$TEST_RECIPE" 0.3.0+1.21.0 \
--no-input \ --no-input \
--server "$TEST_SERVER" \ --server "$TEST_SERVER" \
--domain "$TEST_APP_DOMAIN" --domain "$TEST_APP_DOMAIN"
assert_success assert_success
assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_equal $(_get_tag_hash 0.1.1+1.20.2) $(_get_current_hash) assert_equal $(_get_tag_hash 0.3.0+1.21.0) $(_get_current_hash)
run grep -q "TYPE=$TEST_RECIPE:0.3.0+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}
@test "create new app with chaos commit" {
run $ABRA app new "$TEST_RECIPE" 1e83340e \
--no-input \
--server "$TEST_SERVER" \
--domain "$TEST_APP_DOMAIN"
assert_success
assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
currentHash=$(_get_current_hash)
assert_equal 1e83340e ${currentHash:0:8}
run grep -q "TYPE=$TEST_RECIPE:1e83340e" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
} }
@test "does not overwrite existing env files" { @test "does not overwrite existing env files" {

View File

@ -31,6 +31,84 @@ teardown(){
assert_output --partial 'cannot find app' assert_output --partial 'cannot find app'
} }
# bats test_tags=slow
@test "retrieve recipe if missing" {
_deploy_app
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE"
assert_success
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE"
run $ABRA app ps "$TEST_APP_DOMAIN"
assert_success
assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE"
}
# bats test_tags=slow
@test "bail if unstaged changes and no --chaos" {
_deploy_app
run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_success
assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
run $ABRA app ps "$TEST_APP_DOMAIN"
assert_failure
assert_output --partial 'locally unstaged changes'
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
}
# bats test_tags=slow
@test "do not bail if unstaged changes and --chaos" {
_deploy_app
run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_success
assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
run $ABRA app ps --chaos "$TEST_APP_DOMAIN"
assert_success
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
}
# bats test_tags=slow
@test "ensure recipe up to date if no --offline" {
_deploy_app
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 ps "$TEST_APP_DOMAIN"
assert_success
assert_equal $(_get_head_hash) $(_get_current_hash)
}
@test "ensure recipe not up to date if --offline" {
_deploy_app
_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 ps --offline "$TEST_APP_DOMAIN"
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l
refute_output --partial "$latestRelease"
}
@test "error if not deployed" { @test "error if not deployed" {
run $ABRA app ps "$TEST_APP_DOMAIN" run $ABRA app ps "$TEST_APP_DOMAIN"
assert_failure assert_failure
@ -41,13 +119,11 @@ teardown(){
@test "show ps report" { @test "show ps report" {
_deploy_app _deploy_app
latestRelease=$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | tail -n 1)
run $ABRA app ps "$TEST_APP_DOMAIN" run $ABRA app ps "$TEST_APP_DOMAIN"
assert_success assert_success
assert_output --partial 'app' assert_output --partial 'app'
assert_output --partial 'healthy' assert_output --partial 'healthy'
assert_output --partial "$latestRelease" assert_output --partial $(_latest_release)
assert_output --partial 'false' # not a chaos deploy assert_output --partial 'false' # not a chaos deploy
} }

View File

@ -58,18 +58,18 @@ teardown(){
} }
@test "ensure recipe not up to date if --offline" { @test "ensure recipe not up to date if --offline" {
wantHash=$(_get_n_hash 3) _ensure_env_version "0.1.0+1.20.0"
latestRelease=$(_latest_release)
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" reset --hard HEAD~3 run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -d "$latestRelease"
assert_success assert_success
assert_equal $(_get_current_hash) "$wantHash"
run $ABRA app rollback "$TEST_APP_DOMAIN" \ run $ABRA app rollback "$TEST_APP_DOMAIN" \
--no-input --no-converge-checks --offline --no-input --no-converge-checks --offline
assert_failure assert_failure
assert_equal $(_get_current_hash) $wantHash run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l
refute_output --partial "$latestRelease"
} }
@test "error if not already deployed" { @test "error if not already deployed" {

View File

@ -0,0 +1,44 @@
#!/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
}
teardown(){
_undeploy_app
_reset_recipe
}
@test "rollback writes version to env file" {
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.2.0+1.21.0" --no-input --no-converge-checks
assert_success
assert_output --partial "0.2.0+1.21.0"
run grep -q "TYPE=abra-test-recipe:0.2.0+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app rollback "$TEST_APP_DOMAIN" "0.1.0+1.20.0" \
--no-input --no-converge-checks --debug
assert_success
assert_output --partial "0.1.0+1.20.0"
assert_output --partial "overriding env file version"
run grep -q "TYPE=abra-test-recipe:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}

View File

@ -217,7 +217,8 @@ teardown(){
run bash -c "echo bar >> $ABRA_DIR/recipes/$TEST_RECIPE/foo" run bash -c "echo bar >> $ABRA_DIR/recipes/$TEST_RECIPE/foo"
run $ABRA app secret insert \ run $ABRA app secret insert \
--file "$TEST_APP_DOMAIN" test_pass_one v1 "$ABRA_DIR/recipes/$TEST_RECIPE/foo" --chaos \
--file "$TEST_APP_DOMAIN" test_pass_one v1 "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_success assert_success
assert_output --partial 'successfully stored on server' assert_output --partial 'successfully stored on server'
@ -317,9 +318,10 @@ teardown(){
run $ABRA app secret generate "$TEST_APP_DOMAIN" --all run $ABRA app secret generate "$TEST_APP_DOMAIN" --all
assert_success assert_success
run $ABRA app secret ls "$TEST_APP_DOMAIN" --machine run bash -c '$ABRA app secret ls "$TEST_APP_DOMAIN" --machine \
| jq -r ".[] | select(.name==\"test_pass_two\") | .version"'
assert_success assert_success
assert_output --partial '"created-on-server":"true"' assert_output --partial 'v1'
} }
@test "ls: bail if unstaged changes and no --chaos" { @test "ls: bail if unstaged changes and no --chaos" {

View File

@ -0,0 +1,93 @@
#!/usr/bin/env bash
setup_file(){
load "$PWD/tests/integration/helpers/common"
_common_setup
_add_server
# NOTE(d1): create new app without secrets
run $ABRA app new "$TEST_RECIPE" \
--no-input \
--server "$TEST_SERVER" \
--domain "$TEST_APP_DOMAIN"
assert_success
assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
}
teardown_file(){
_rm_app
_rm_server
_reset_recipe
}
setup(){
load "$PWD/tests/integration/helpers/common"
_common_setup
}
teardown(){
_reset_recipe
_reset_app
run $ABRA app secret rm "$TEST_APP_DOMAIN" --all --no-input
}
@test "generate: respect env version" {
tagHash=$(_get_tag_hash "0.2.0+1.21.0")
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe:0.2.0+1.21.0/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app secret generate "$TEST_APP_DOMAIN" --all
assert_success
assert_equal $(_get_current_hash) "$tagHash"
}
@test "insert: respect env version" {
tagHash=$(_get_tag_hash "0.2.0+1.21.0")
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe:0.2.0+1.21.0/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app secret insert "$TEST_APP_DOMAIN" test_pass_one v1 foo
assert_success
assert_output --partial 'successfully stored on server'
assert_equal $(_get_current_hash) "$tagHash"
}
@test "rm: respect env version" {
tagHash=$(_get_tag_hash "0.2.0+1.21.0")
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe:0.2.0+1.21.0/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app secret generate "$TEST_APP_DOMAIN" --all
assert_success
run $ABRA app secret rm "$TEST_APP_DOMAIN" --all
assert_success
assert_equal $(_get_current_hash) "$tagHash"
}
@test "ls: respect env version" {
tagHash=$(_get_tag_hash "0.2.0+1.21.0")
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe:0.2.0+1.21.0/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app secret generate "$TEST_APP_DOMAIN" --all
assert_success
run $ABRA app secret ls "$TEST_APP_DOMAIN"
assert_success
assert_output --partial 'true'
assert_equal $(_get_current_hash) "$tagHash"
}

View File

@ -18,6 +18,7 @@ setup(){
} }
teardown(){ teardown(){
_reset_recipe
_undeploy_app _undeploy_app
} }
@ -31,12 +32,61 @@ teardown(){
assert_output --partial 'cannot find app' assert_output --partial 'cannot find app'
} }
# bats test_tags=slow
@test "ensure recipe up to date if no --offline" {
_deploy_app
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 undeploy "$TEST_APP_DOMAIN" --no-input
assert_success
assert_equal $(_get_head_hash) $(_get_current_hash)
}
# bats test_tags=slow
@test "ensure recipe not up to date if --offline" {
_deploy_app
_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 undeploy "$TEST_APP_DOMAIN" --no-input --offline
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l
refute_output --partial "$latestRelease"
}
@test "error if not deployed" { @test "error if not deployed" {
run $ABRA app undeploy "$TEST_APP_DOMAIN" run $ABRA app undeploy "$TEST_APP_DOMAIN"
assert_failure assert_failure
assert_output --partial 'is not deployed' assert_output --partial 'is not deployed'
} }
# bats test_tags=slow
@test "do not bail if unstaged changes (only query runtime)" {
_deploy_app
run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_success
assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
run $ABRA app undeploy "$TEST_APP_DOMAIN" --no-input
assert_success
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
}
# bats test_tags=slow # bats test_tags=slow
@test "undeploy app" { @test "undeploy app" {
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input

View File

@ -18,8 +18,9 @@ setup(){
} }
teardown(){ teardown(){
_undeploy_app
_reset_recipe _reset_recipe
_reset_app
_undeploy_app
} }
@test "validate app argument" { @test "validate app argument" {
@ -123,11 +124,9 @@ teardown(){
assert_success assert_success
assert_output --partial '0.1.0+1.20.0' assert_output --partial '0.1.0+1.20.0'
latestRelease=$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | tail -n 1)
run $ABRA app upgrade "$TEST_APP_DOMAIN" --no-input --no-converge-checks run $ABRA app upgrade "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success assert_success
assert_output --partial "$latestRelease" assert_output --partial "$(_latest_release)"
} }
# bats test_tags=slow # bats test_tags=slow
@ -136,11 +135,9 @@ teardown(){
assert_success assert_success
assert_output --partial '0.1.1+1.20.2' assert_output --partial '0.1.1+1.20.2'
latestRelease=$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | tail -n 1)
run $ABRA app upgrade "$TEST_APP_DOMAIN" --no-input --no-converge-checks run $ABRA app upgrade "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success assert_success
assert_output --partial "$latestRelease" assert_output --partial "$(_latest_release)"
assert_output --partial 'release notes baz' # 0.2.0+1.21.0 assert_output --partial 'release notes baz' # 0.2.0+1.21.0
refute_output --partial 'release notes bar' # 0.1.1+1.20.2 refute_output --partial 'release notes bar' # 0.1.1+1.20.2
} }
@ -164,11 +161,9 @@ teardown(){
assert_success assert_success
assert_output --partial '0.1.0+1.20.0' assert_output --partial '0.1.0+1.20.0'
latestRelease=$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | tail -n 1)
run $ABRA app upgrade "$TEST_APP_DOMAIN" --no-input --no-converge-checks run $ABRA app upgrade "$TEST_APP_DOMAIN" --no-input --no-converge-checks
assert_success assert_success
assert_output --partial "$latestRelease" assert_output --partial "$(_latest_release)"
assert_output --partial 'release notes bar' # 0.1.1+1.20.2 assert_output --partial 'release notes bar' # 0.1.1+1.20.2
assert_output --partial 'release notes baz' # 0.2.0+1.21.0 assert_output --partial 'release notes baz' # 0.2.0+1.21.0
} }

View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
setup_file(){
load "$PWD/tests/integration/helpers/common"
_common_setup
_add_server
_new_app
}
teardown_file(){
_rm_app
_rm_server
}
setup(){
load "$PWD/tests/integration/helpers/common"
_common_setup
}
teardown(){
_undeploy_app
_reset_recipe
}
@test "upgrade writes version to env file" {
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks
assert_success
assert_output --partial '0.1.0+1.20.0'
run grep -q "TYPE=abra-test-recipe:0.1.0+1.20.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app upgrade "$TEST_APP_DOMAIN" "0.2.0+1.21.0" \
--no-input --no-converge-checks --debug
assert_success
assert_output --partial "0.2.0+1.21.0"
assert_output --partial "overriding env file version"
run grep -q "TYPE=abra-test-recipe:0.2.0+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}

View File

@ -1,5 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
_latest_release(){
echo $(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | tail -n 1)
}
_fetch_recipe() { _fetch_recipe() {
if [[ ! -d "$ABRA_DIR/recipes/$TEST_RECIPE" ]]; then if [[ ! -d "$ABRA_DIR/recipes/$TEST_RECIPE" ]]; then
run mkdir -p "$ABRA_DIR/recipes" run mkdir -p "$ABRA_DIR/recipes"
@ -19,10 +23,29 @@ _reset_recipe(){
} }
_ensure_latest_version(){ _ensure_latest_version(){
latestRelease=$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | tail -n 1) latestRelease=$(_latest_release)
if [ ! $latestRelease = "$1" ]; then if [ ! $latestRelease = "$1" ]; then
echo "expected latest recipe version of '$1', saw: $latestRelease" echo "expected latest recipe version of '$1', saw: $latestRelease"
return 1 return 1
fi fi
} }
_ensure_catalogue(){
if [[ ! -d "$ABRA_DIR/catalogue" ]]; then
run git clone https://git.coopcloud.tech/coop-cloud/recipes-catalogue-json.git $ABRA_DIR/catalogue
assert_success
fi
}
_ensure_env_version(){
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe:$1/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}
_wipe_env_version(){
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}

View File

@ -1,10 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
setup_file(){
load "$PWD/tests/integration/helpers/common"
_common_setup
_ensure_catalogue
}
setup() { setup() {
load "$PWD/tests/integration/helpers/common" load "$PWD/tests/integration/helpers/common"
_common_setup _common_setup
} }
@test "recipe versions" { @test "recipe versions" {
run $ABRA recipe versions gitea run $ABRA recipe versions gitea
assert_success assert_success
@ -12,11 +19,9 @@ setup() {
} }
@test "local tags used if no catalogue entry" { @test "local tags used if no catalogue entry" {
latestRelease=$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | tail -n 1)
run $ABRA recipe versions "$TEST_RECIPE" run $ABRA recipe versions "$TEST_RECIPE"
assert_success assert_success
assert_output --partial "$latestRelease" assert_output --partial "$(_latest_release)"
} }
@test "versions listed in correct order" { @test "versions listed in correct order" {

View File

@ -13,6 +13,7 @@ teardown_file(){
setup(){ setup(){
load "$PWD/tests/integration/helpers/common" load "$PWD/tests/integration/helpers/common"
_common_setup _common_setup
_add_server
} }
teardown(){ teardown(){
@ -61,12 +62,13 @@ teardown(){
} }
@test "machine readable output" { @test "machine readable output" {
run "$ABRA" server ls --machine if [ ! "$TEST_SERVER" = "default" ]; then
skip "can only diff output against 'default' server (local)"
fi
output=$("$ABRA" server ls --machine)
run diff \
<(jq -S "." <(echo "$output")) \
<(jq -S "." <(echo '[{"host":"local","name":"default"}]'))
assert_success assert_success
expectedOutput='[{"name":"'
expectedOutput+="$TEST_SERVER"
expectedOutput+='"'
assert_output --partial "$expectedOutput"
} }

View File

@ -1,2 +1,3 @@
RECIPE=ecloud RECIPE=ecloud
DOMAIN=ecloud.evil.corp DOMAIN=ecloud.evil.corp
SMTP_AUTHTYPE=login

18
vendor/coopcloud.tech/tagcmp/.drone.yml vendored Normal file
View File

@ -0,0 +1,18 @@
---
kind: pipeline
name: coopcloud.tech/tagcmp
steps:
- name: gofmt
image: golang:1.21
commands:
- test -z "$(gofmt -l .)"
- name: go build
image: golang:1.21
commands:
- go build -v .
- name: go test
image: golang:1.21
commands:
- go test . -cover

View File

@ -0,0 +1 @@
fmtcoverage.html

15
vendor/coopcloud.tech/tagcmp/LICENSE vendored Normal file
View File

@ -0,0 +1,15 @@
Tagcmp: comparison operations for image tags.
Copyright (C) 2023 Co-op Cloud <helo@coopcloud.tech>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

161
vendor/coopcloud.tech/tagcmp/README.md vendored Normal file
View File

@ -0,0 +1,161 @@
# tagcmp
[![Build Status](https://build.coopcloud.tech/api/badges/coop-cloud/tagcmp/status.svg?ref=refs/heads/main)](https://build.coopcloud.tech/coop-cloud/tagcmp)
[![Go Report Card](https://goreportcard.com/badge/git.coopcloud.tech/coop-cloud/tagcmp)](https://goreportcard.com/report/git.coopcloud.tech/coop-cloud/tagcmp)
[![Go Reference](https://pkg.go.dev/badge/coopcloud.tech/tagcmp.svg)](https://pkg.go.dev/coopcloud.tech/tagcmp)
Comparison operations for image tags. Because registries aren't doing this for
us 🙄
This library is helpful if you're aiming to use only "stable" and "semver-like"
tags and want to be able to do things like compare them, find which tags are
more recent, sort them and other types of comparisons. This is a best-effort
implementation which follows the wisdom of [Renovate].
> Docker doesn't really have versioning, instead it supports "tags" and these
> are usually used by Docker image authors as a form of versioning ... It's
> pretty "wild west" for tagging and not always compliant with SemVer.
The Renovate implementation allows image tags to be automatically upgraded, is
the only show in town, apparently. This library follows that implementation
quite closely.
[renovate]: https://docs.renovatebot.com/docker/
## Example
```golang
package main
import (
"fmt"
"sort"
"coopcloud.tech/tagcmp"
)
func main() {
rawTags := []string{
"1.7.1",
"1.9.4-linux-arm64",
"1.14.2-rootless",
"linux-arm64-rootless",
"1.14.1-rootless",
"1.12.4-linux-amd64",
"1.14.0-rootless",
}
tag, err := tagcmp.Parse("1.14.0-rootless")
if err != nil {
panic(err)
}
var compatible []tagcmp.Tag
for _, rawTag := range rawTags {
parsed, _ := tagcmp.Parse(rawTag) // skips unsupported tags
if tag.IsCompatible(parsed) {
compatible = append(compatible, parsed)
}
}
sort.Sort(tagcmp.ByTagAsc(compatible))
fmt.Println(compatible)
}
```
Output:
```golang
[1.14.0-rootless 1.14.1-rootless 1.14.2-rootless]
```
## Types of versions supported
```golang
// semver
"5",
"2.6",
"4.3.5",
// semver with 'v'
"v1",
"v2.3",
"v1.0.2",
// semver with suffix
"6-alpine",
"6.2-alpine",
"6.2.1-alpine",
// semver with sufix and 'v'
"v6-alpine",
"v6.2-alpine",
"v6.2.1-alpine",
"v6.2.1-alpine",
// semver with multiple suffix values
"6.2.1-alpine-foo",
// semver with multiple suffix values and 'v'
"v6.2.1-alpine-foo",
```
## Types of versions not supported
> Please note, we could support some of these versions if people really need
> them to be supported. Some tags are using a unique format which we could
> support by implementing a very specific parser for (e.g. `ParseMinioTag`,
> `ParseZncTag`). For now, this library tries to provide a `Parse` function
> which handles more general cases. Please open an issue, change sets are
> welcome.
```golang
// empty
"",
// patametrized
"${MAILU_VERSION:-master}",
"${PHP_VERSION}-fpm-alpine3.13",
// commit hash like
"0a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d",
// numeric
"20191109",
"e02267d",
// not semver
"3.0.6.0",
"r1295",
"version-r1070",
// prerelease
"3.7.0b1",
"3.8.0b1-alpine",
// multiple versions
"5.36-backdrop-php7.4",
"v1.0.5_3.4.0",
"v1.0.5_3.4.0_openid-sso",
// tz based
"RELEASE.2021-04-22T15-44-28Z",
// only text
"alpine",
"latest",
"master",
// multiple - delimters
"apache-debian-1.8-prod",
"version-znc-1.8.2",
```
## License
[GPLv3+](./LICENSE)
## Who's using it?
- [`abra`](https://git.coopcloud.tech/coop-cloud/abra)

View File

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

452
vendor/coopcloud.tech/tagcmp/tagcmp.go vendored Normal file
View File

@ -0,0 +1,452 @@
// Package tagcmp provides image tag comparison operations.
package tagcmp
import (
"fmt"
"regexp"
"strconv"
"strings"
)
type Tag struct {
Major string `json:",omitempty"` // major semver part
Minor string `json:",omitempty"` // minor semver part
MissingMinor bool // whether or not the minor semver part was left out
Patch string `json:",omitempty"` // patch semver part
MissingPatch bool // whether or not he patch semver part was left out
Suffix string // tag suffix (e.g. "-alpine") [would be release candidate in semver]
UsesV bool // whether or not the tag uses the "v" prefix
Metadata string // metadata: what's after + and after the first "-"
}
type TagDelta struct {
Major int // major semver difference
Minor int // minor semver difference
Patch int // patch semver difference
}
// ByTagAsc sorts tags in ascending order where the last element is the latest tag.
type ByTagAsc []Tag
func (t ByTagAsc) Len() int { return len(t) }
func (t ByTagAsc) Swap(i, j int) { t[i], t[j] = t[j], t[i] }
func (t ByTagAsc) Less(i, j int) bool {
return t[i].IsLessThan(t[j])
}
// ByTagDesc sorts tags in descending order where the first element is the latest tag.
type ByTagDesc []Tag
func (t ByTagDesc) Len() int { return len(t) }
func (t ByTagDesc) Swap(i, j int) { t[j], t[i] = t[i], t[j] }
func (t ByTagDesc) Less(i, j int) bool {
return t[j].IsLessThan(t[i])
}
// IsGreaterThan tests if a tag is greater than another. There are some
// tag-isms to take into account here, shorter is bigger (i.e. 2.1 > 2.1.1 ==
// true, 2 > 2.1 == true).
func (t Tag) IsGreaterThan(tag Tag) bool {
// shorter is bigger, i.e. 2.1 > 2.1.1
if t.MissingPatch && !tag.MissingPatch || t.MissingMinor && !tag.MissingMinor {
return true
}
if tag.MissingPatch && !t.MissingPatch || tag.MissingMinor && !t.MissingMinor {
return false
}
// ignore errors since Parse already handled
mj1, _ := strconv.Atoi(t.Major)
mj2, _ := strconv.Atoi(tag.Major)
if mj1 > mj2 {
return true
}
if mj2 > mj1 {
return false
}
mn1, _ := strconv.Atoi(t.Minor)
mn2, _ := strconv.Atoi(tag.Minor)
if mn1 > mn2 {
return true
}
if mn2 > mn1 {
return false
}
p1, _ := strconv.Atoi(t.Patch)
p2, _ := strconv.Atoi(tag.Patch)
if p1 > p2 {
return true
}
if p2 > p1 {
return false
}
return false
}
// IsLessThan tests if a tag is less than another. There are some tag-isms to
// take into account here, shorter is bigger (i.e. 2.1 < 2.1.1 == false, 2 <
// 2.1 == false).
func (t Tag) IsLessThan(tag Tag) bool {
return !t.IsGreaterThan(tag)
}
// Equals tests Tag equality
func (t Tag) Equals(tag Tag) bool {
if t.MissingPatch && !tag.MissingPatch || t.MissingMinor && !tag.MissingMinor {
return false
}
if tag.MissingPatch && !t.MissingPatch || tag.MissingMinor && !t.MissingMinor {
return false
}
if t.Metadata != tag.Metadata {
return false
}
// ignore errors since Parse already handled
mj1, _ := strconv.Atoi(t.Major)
mj2, _ := strconv.Atoi(tag.Major)
if mj1 != mj2 {
return false
}
mn1, _ := strconv.Atoi(t.Minor)
mn2, _ := strconv.Atoi(tag.Minor)
if mn1 != mn2 {
return false
}
p1, _ := strconv.Atoi(t.Patch)
p2, _ := strconv.Atoi(tag.Patch)
return p1 == p2
}
// String formats a Tag correctly in string representation
func (t Tag) String() string {
var repr string
if t.UsesV {
repr += "v"
}
repr += t.Major
if !t.MissingMinor {
repr += fmt.Sprintf(".%s", t.Minor)
}
if !t.MissingPatch {
repr += fmt.Sprintf(".%s", t.Patch)
}
if t.Suffix != "" {
repr += fmt.Sprintf("-%s", t.Suffix)
}
if t.Metadata != "" {
repr += fmt.Sprintf("+%s", t.Metadata)
}
return repr
}
func (t TagDelta) String() string {
var repr string
repr = fmt.Sprintf("%d.%d.%d", t.Major, t.Minor, t.Patch)
return repr
}
// IsCompatible determines if two tags can be compared together
func (t Tag) IsCompatible(tag Tag) bool {
if t.UsesV && !tag.UsesV || tag.UsesV && !t.UsesV {
return false
}
if t.Suffix != "" && tag.Suffix == "" || t.Suffix == "" && tag.Suffix != "" {
return false
}
if t.Suffix != "" && tag.Suffix != "" {
if t.Suffix != tag.Suffix {
return false
}
}
if t.MissingMinor && !tag.MissingMinor || tag.MissingMinor && !t.MissingMinor {
return false
}
if t.MissingPatch && !tag.MissingPatch || tag.MissingPatch && !t.MissingPatch {
return false
}
return true
}
// IsUpgradeCompatible chekcs if upTag is compatible with a pinned version tag.
// I.e. pinning to 22-fpm should return true if upTag is 22.2.0-fpm but not 22.2.0-alpine or 23.0.0-fpm
func (pin Tag) IsUpgradeCompatible(upTag Tag) bool {
if pin.Suffix != upTag.Suffix {
return false
}
if pin.Major != upTag.Major {
return false
}
if pin.MissingMinor {
return true
}
if pin.Minor != upTag.Minor {
return false
}
if pin.MissingPatch {
return true
}
if pin.Patch != upTag.Patch {
return false
}
return true
}
// UpgradeDelta returns a TagDelta object which is the difference between an old and new tag
// It can contain negative numbers if comparing with an older tag.
func (curTag Tag) UpgradeDelta(newTag Tag) (TagDelta, error) {
if !curTag.IsCompatible(newTag) {
return TagDelta{}, fmt.Errorf("%s and %s are not compatible with each other", curTag.String(), newTag.String())
}
diff := TagDelta{
Major: 0,
Minor: 0,
Patch: 0,
}
// assuming tags are correctly formatted
curMajor, _ := strconv.Atoi(curTag.Major)
newMajor, _ := strconv.Atoi(newTag.Major)
diff.Major = newMajor - curMajor
if !curTag.MissingMinor {
curMinor, _ := strconv.Atoi(curTag.Minor)
newMinor, _ := strconv.Atoi(newTag.Minor)
diff.Minor = newMinor - curMinor
}
if !curTag.MissingPatch {
curPatch, _ := strconv.Atoi(curTag.Patch)
newPatch, _ := strconv.Atoi(newTag.Patch)
diff.Patch = newPatch - curPatch
}
return diff, nil
}
// UpgradeType takes exit from UpgradeElemene and returns a numeric representation of upgrade or downgrade
// 1/-1: patch 2/-2: minor 4/-4: major 0: no change
func (d TagDelta) UpgradeType() int {
if d.Major > 0 {
return 4
}
if d.Major < 0 {
return -4
}
if d.Minor > 0 {
return 2
}
if d.Minor < 0 {
return -2
}
if d.Patch > 0 {
return 1
}
if d.Patch < 0 {
return -1
}
return 0
}
// CommitHashPattern matches commit-like hash tags
var CommitHashPattern = "^[a-f0-9]{7,40}$"
// DotPattern matches tags which contain multiple versions
var DotPattern = "([0-9]+)\\.([0-9]+)"
// EmptyPattern matches when tags are missing
var EmptyPattern = "^$"
// ParametrizedPattern matches when tags are parametrized
var ParametrizedPattern = "\\${.+}"
// StringPattern matches when tags are only made up of alphabetic characters
var StringPattern = "^[a-zA-Z]+$"
// patternMatches determines if a tag matches unsupported patterns
func patternMatches(tag string) error {
unsupported := []string{
CommitHashPattern,
EmptyPattern,
ParametrizedPattern,
StringPattern,
}
for _, pattern := range unsupported {
if match, _ := regexp.Match(pattern, []byte(tag)); match {
return fmt.Errorf("'%s' is not supported (%s)", tag, pattern)
}
}
return nil
}
// patternCounts determines if tags match unsupported patterns by counting occurences of matches
func patternCounts(tag string) error {
v := regexp.MustCompile(DotPattern)
tag = strings.Split(tag, "+")[0]
if m := v.FindAllStringIndex(tag, -1); len(m) > 1 {
return fmt.Errorf("'%s' is not supported (%s)", tag, DotPattern)
}
return nil
}
// parseVersionPart converts a semver version part to an integer
func parseVersionPart(part string) (int, error) {
p, err := strconv.Atoi(part)
if err != nil {
return 0, err
}
return p, nil
}
// ParseDelta converts a tag difference in the format of X, X.Y or X.Y.Z where
// X, Y, Z are positive or negative integers or 0
func ParseDelta(delta string) (TagDelta, error) {
tagDelta := TagDelta{
Major: 0,
Minor: 0,
Patch: 0,
}
splits := strings.Split(delta, ".")
if len(splits) > 3 {
return TagDelta{}, fmt.Errorf("'%s' has too much dots", delta)
}
major, err := strconv.Atoi(splits[0])
if err != nil {
return TagDelta{}, fmt.Errorf("Major part of '%s' is not an integer", delta)
}
tagDelta.Major = major
if len(splits) > 1 {
minor, err := strconv.Atoi(splits[1])
if err != nil {
return TagDelta{}, fmt.Errorf("Minor part of '%s' is not an integer", delta)
}
tagDelta.Minor = minor
}
if len(splits) > 2 {
patch, err := strconv.Atoi(splits[2])
if err != nil {
return TagDelta{}, fmt.Errorf("Minor part of '%s' is not an integer", delta)
}
tagDelta.Patch = patch
}
return tagDelta, nil
}
// Parse converts an image tag into a structured data format. It aims to to
// support the general case of tags which are "semver-like" and/or stable and
// parseable by heuristics. Image tags follow no formal specification and
// therefore this is a best-effort implementation. Examples of tags this
// function can parse are: "5", "5.2", "v4", "v5.3.6", "4-alpine",
// "v3.2.1-debian".
func Parse(tag string) (Tag, error) {
if err := patternMatches(tag); err != nil {
return Tag{}, err
}
if err := patternCounts(tag); err != nil {
return Tag{}, err
}
usesV := false
if string(tag[0]) == "v" {
tag = strings.TrimPrefix(tag, "v")
usesV = true
}
var metadata string
splits := strings.Split(tag, "+")
if len(splits) > 1 {
tag = splits[0]
metadata = splits[1]
}
var suffix string
splits = strings.SplitN(tag, "-", 2)
if len(splits) > 1 {
tag = splits[0]
suffix = splits[1]
}
var major, minor, patch string
var missingMinor, missingPatch bool
parts := strings.Split(tag, ".")
switch {
case len(parts) == 1:
if _, err := parseVersionPart(parts[0]); err != nil {
return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0])
}
major = parts[0]
missingMinor = true
missingPatch = true
case len(parts) == 2:
if _, err := parseVersionPart(parts[0]); err != nil {
return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0])
}
major = parts[0]
if _, err := parseVersionPart(parts[1]); err != nil {
return Tag{}, fmt.Errorf("couldn't parse minor part of '%s': '%s'", tag, parts[1])
}
minor = parts[1]
missingPatch = true
case len(parts) == 3:
if _, err := parseVersionPart(parts[0]); err != nil {
return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0])
}
major = parts[0]
if _, err := parseVersionPart(parts[1]); err != nil {
return Tag{}, fmt.Errorf("couldn't parse minor part of '%s': '%s'", tag, parts[1])
}
minor = parts[1]
if _, err := parseVersionPart(parts[2]); err != nil {
return Tag{}, fmt.Errorf("couldn't parse patch part of '%s': '%s'", tag, parts[2])
}
patch = parts[2]
default:
return Tag{}, fmt.Errorf("couldn't parse semver of '%s", tag)
}
parsedTag := Tag{
Major: major,
Minor: minor,
MissingMinor: missingMinor,
Patch: patch,
MissingPatch: missingPatch,
UsesV: usesV,
Suffix: suffix,
Metadata: metadata,
}
return parsedTag, nil
}
// IsParsable determines if a tag is supported by this library
func IsParsable(tag string) bool {
if _, err := Parse(tag); err != nil {
return false
}
return true
}

12
vendor/dario.cat/mergo/.deepsource.toml vendored Normal file
View File

@ -0,0 +1,12 @@
version = 1
test_patterns = [
"*_test.go"
]
[[analyzers]]
name = "go"
enabled = true
[analyzers.meta]
import_path = "dario.cat/mergo"

33
vendor/dario.cat/mergo/.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
#### joe made this: http://goel.io/joe
#### go ####
# Binaries for programs and plugins
*.exe
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/
#### vim ####
# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-v][a-z]
[._]sw[a-p]
# Session
Session.vim
# Temporary
.netrwhist
*~
# Auto-generated tag files
tags

12
vendor/dario.cat/mergo/.travis.yml vendored Normal file
View File

@ -0,0 +1,12 @@
language: go
arch:
- amd64
- ppc64le
install:
- go get -t
- go get golang.org/x/tools/cmd/cover
- go get github.com/mattn/goveralls
script:
- go test -race -v ./...
after_script:
- $HOME/gopath/bin/goveralls -service=travis-ci -repotoken $COVERALLS_TOKEN

View File

@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at i@dario.im. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

112
vendor/dario.cat/mergo/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1,112 @@
<!-- omit in toc -->
# Contributing to mergo
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
> - Star the project
> - Tweet about it
> - Refer this project in your project's readme
> - Mention the project at local meetups and tell your friends/colleagues
<!-- omit in toc -->
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [I Have a Question](#i-have-a-question)
- [I Want To Contribute](#i-want-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
## Code of Conduct
This project and everyone participating in it is governed by the
[mergo Code of Conduct](https://github.com/imdario/mergoblob/master/CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report unacceptable behavior
to <>.
## I Have a Question
> If you want to ask a question, we assume that you have read the available [Documentation](https://pkg.go.dev/github.com/imdario/mergo).
Before you ask a question, it is best to search for existing [Issues](https://github.com/imdario/mergo/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
If you then still feel the need to ask a question and need clarification, we recommend the following:
- Open an [Issue](https://github.com/imdario/mergo/issues/new).
- Provide as much context as you can about what you're running into.
- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
We will then take care of the issue as soon as possible.
## I Want To Contribute
> ### Legal Notice <!-- omit in toc -->
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
### Reporting Bugs
<!-- omit in toc -->
#### Before Submitting a Bug Report
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
- Make sure that you are using the latest version.
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](). If you are looking for support, you might want to check [this section](#i-have-a-question)).
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/imdario/mergoissues?q=label%3Abug).
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
- Collect information about the bug:
- Stack trace (Traceback)
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
- Possibly your input and the output
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
<!-- omit in toc -->
#### How Do I Submit a Good Bug Report?
> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to .
<!-- You may add a PGP key to allow the messages to be sent encrypted as well. -->
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
- Open an [Issue](https://github.com/imdario/mergo/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
- Explain the behavior you would expect and the actual behavior.
- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
- Provide the information you collected in the previous section.
Once it's filed:
- The project team will label the issue accordingly.
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be implemented by someone.
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion for mergo, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
<!-- omit in toc -->
#### Before Submitting an Enhancement
- Make sure that you are using the latest version.
- Read the [documentation]() carefully and find out if the functionality is already covered, maybe by an individual configuration.
- Perform a [search](https://github.com/imdario/mergo/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
<!-- omit in toc -->
#### How Do I Submit a Good Enhancement Suggestion?
Enhancement suggestions are tracked as [GitHub issues](https://github.com/imdario/mergo/issues).
- Use a **clear and descriptive title** for the issue to identify the suggestion.
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. <!-- this should only be included if the project has a GUI -->
- **Explain why this enhancement would be useful** to most mergo users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
<!-- omit in toc -->
## Attribution
This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)!

28
vendor/dario.cat/mergo/LICENSE vendored Normal file
View File

@ -0,0 +1,28 @@
Copyright (c) 2013 Dario Castañé. All rights reserved.
Copyright (c) 2012 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

248
vendor/dario.cat/mergo/README.md vendored Normal file
View File

@ -0,0 +1,248 @@
# Mergo
[![GitHub release][5]][6]
[![GoCard][7]][8]
[![Test status][1]][2]
[![OpenSSF Scorecard][21]][22]
[![OpenSSF Best Practices][19]][20]
[![Coverage status][9]][10]
[![Sourcegraph][11]][12]
[![FOSSA status][13]][14]
[![GoDoc][3]][4]
[![Become my sponsor][15]][16]
[![Tidelift][17]][18]
[1]: https://github.com/imdario/mergo/workflows/tests/badge.svg?branch=master
[2]: https://github.com/imdario/mergo/actions/workflows/tests.yml
[3]: https://godoc.org/github.com/imdario/mergo?status.svg
[4]: https://godoc.org/github.com/imdario/mergo
[5]: https://img.shields.io/github/release/imdario/mergo.svg
[6]: https://github.com/imdario/mergo/releases
[7]: https://goreportcard.com/badge/imdario/mergo
[8]: https://goreportcard.com/report/github.com/imdario/mergo
[9]: https://coveralls.io/repos/github/imdario/mergo/badge.svg?branch=master
[10]: https://coveralls.io/github/imdario/mergo?branch=master
[11]: https://sourcegraph.com/github.com/imdario/mergo/-/badge.svg
[12]: https://sourcegraph.com/github.com/imdario/mergo?badge
[13]: https://app.fossa.io/api/projects/git%2Bgithub.com%2Fimdario%2Fmergo.svg?type=shield
[14]: https://app.fossa.io/projects/git%2Bgithub.com%2Fimdario%2Fmergo?ref=badge_shield
[15]: https://img.shields.io/github/sponsors/imdario
[16]: https://github.com/sponsors/imdario
[17]: https://tidelift.com/badges/package/go/github.com%2Fimdario%2Fmergo
[18]: https://tidelift.com/subscription/pkg/go-github.com-imdario-mergo
[19]: https://bestpractices.coreinfrastructure.org/projects/7177/badge
[20]: https://bestpractices.coreinfrastructure.org/projects/7177
[21]: https://api.securityscorecards.dev/projects/github.com/imdario/mergo/badge
[22]: https://api.securityscorecards.dev/projects/github.com/imdario/mergo
A helper to merge structs and maps in Golang. Useful for configuration default values, avoiding messy if-statements.
Mergo merges same-type structs and maps by setting default values in zero-value fields. Mergo won't merge unexported (private) fields. It will do recursively any exported one. It also won't merge structs inside maps (because they are not addressable using Go reflection).
Also a lovely [comune](http://en.wikipedia.org/wiki/Mergo) (municipality) in the Province of Ancona in the Italian region of Marche.
## Status
It is ready for production use. [It is used in several projects by Docker, Google, The Linux Foundation, VMWare, Shopify, Microsoft, etc](https://github.com/imdario/mergo#mergo-in-the-wild).
### Important notes
#### 1.0.0
In [1.0.0](//github.com/imdario/mergo/releases/tag/1.0.0) Mergo moves to a vanity URL `dario.cat/mergo`.
#### 0.3.9
Please keep in mind that a problematic PR broke [0.3.9](//github.com/imdario/mergo/releases/tag/0.3.9). I reverted it in [0.3.10](//github.com/imdario/mergo/releases/tag/0.3.10), and I consider it stable but not bug-free. Also, this version adds support for go modules.
Keep in mind that in [0.3.2](//github.com/imdario/mergo/releases/tag/0.3.2), Mergo changed `Merge()`and `Map()` signatures to support [transformers](#transformers). I added an optional/variadic argument so that it won't break the existing code.
If you were using Mergo before April 6th, 2015, please check your project works as intended after updating your local copy with ```go get -u dario.cat/mergo```. I apologize for any issue caused by its previous behavior and any future bug that Mergo could cause in existing projects after the change (release 0.2.0).
### Donations
If Mergo is useful to you, consider buying me a coffee, a beer, or making a monthly donation to allow me to keep building great free software. :heart_eyes:
<a href='https://ko-fi.com/B0B58839' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://az743702.vo.msecnd.net/cdn/kofi1.png?v=0' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
<a href="https://liberapay.com/dario/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a>
<a href='https://github.com/sponsors/imdario' target='_blank'><img alt="Become my sponsor" src="https://img.shields.io/github/sponsors/imdario?style=for-the-badge" /></a>
### Mergo in the wild
- [moby/moby](https://github.com/moby/moby)
- [kubernetes/kubernetes](https://github.com/kubernetes/kubernetes)
- [vmware/dispatch](https://github.com/vmware/dispatch)
- [Shopify/themekit](https://github.com/Shopify/themekit)
- [imdario/zas](https://github.com/imdario/zas)
- [matcornic/hermes](https://github.com/matcornic/hermes)
- [OpenBazaar/openbazaar-go](https://github.com/OpenBazaar/openbazaar-go)
- [kataras/iris](https://github.com/kataras/iris)
- [michaelsauter/crane](https://github.com/michaelsauter/crane)
- [go-task/task](https://github.com/go-task/task)
- [sensu/uchiwa](https://github.com/sensu/uchiwa)
- [ory/hydra](https://github.com/ory/hydra)
- [sisatech/vcli](https://github.com/sisatech/vcli)
- [dairycart/dairycart](https://github.com/dairycart/dairycart)
- [projectcalico/felix](https://github.com/projectcalico/felix)
- [resin-os/balena](https://github.com/resin-os/balena)
- [go-kivik/kivik](https://github.com/go-kivik/kivik)
- [Telefonica/govice](https://github.com/Telefonica/govice)
- [supergiant/supergiant](supergiant/supergiant)
- [SergeyTsalkov/brooce](https://github.com/SergeyTsalkov/brooce)
- [soniah/dnsmadeeasy](https://github.com/soniah/dnsmadeeasy)
- [ohsu-comp-bio/funnel](https://github.com/ohsu-comp-bio/funnel)
- [EagerIO/Stout](https://github.com/EagerIO/Stout)
- [lynndylanhurley/defsynth-api](https://github.com/lynndylanhurley/defsynth-api)
- [russross/canvasassignments](https://github.com/russross/canvasassignments)
- [rdegges/cryptly-api](https://github.com/rdegges/cryptly-api)
- [casualjim/exeggutor](https://github.com/casualjim/exeggutor)
- [divshot/gitling](https://github.com/divshot/gitling)
- [RWJMurphy/gorl](https://github.com/RWJMurphy/gorl)
- [andrerocker/deploy42](https://github.com/andrerocker/deploy42)
- [elwinar/rambler](https://github.com/elwinar/rambler)
- [tmaiaroto/gopartman](https://github.com/tmaiaroto/gopartman)
- [jfbus/impressionist](https://github.com/jfbus/impressionist)
- [Jmeyering/zealot](https://github.com/Jmeyering/zealot)
- [godep-migrator/rigger-host](https://github.com/godep-migrator/rigger-host)
- [Dronevery/MultiwaySwitch-Go](https://github.com/Dronevery/MultiwaySwitch-Go)
- [thoas/picfit](https://github.com/thoas/picfit)
- [mantasmatelis/whooplist-server](https://github.com/mantasmatelis/whooplist-server)
- [jnuthong/item_search](https://github.com/jnuthong/item_search)
- [bukalapak/snowboard](https://github.com/bukalapak/snowboard)
- [containerssh/containerssh](https://github.com/containerssh/containerssh)
- [goreleaser/goreleaser](https://github.com/goreleaser/goreleaser)
- [tjpnz/structbot](https://github.com/tjpnz/structbot)
## Install
go get dario.cat/mergo
// use in your .go code
import (
"dario.cat/mergo"
)
## Usage
You can only merge same-type structs with exported fields initialized as zero value of their type and same-types maps. Mergo won't merge unexported (private) fields but will do recursively any exported one. It won't merge empty structs value as [they are zero values](https://golang.org/ref/spec#The_zero_value) too. Also, maps will be merged recursively except for structs inside maps (because they are not addressable using Go reflection).
```go
if err := mergo.Merge(&dst, src); err != nil {
// ...
}
```
Also, you can merge overwriting values using the transformer `WithOverride`.
```go
if err := mergo.Merge(&dst, src, mergo.WithOverride); err != nil {
// ...
}
```
Additionally, you can map a `map[string]interface{}` to a struct (and otherwise, from struct to map), following the same restrictions as in `Merge()`. Keys are capitalized to find each corresponding exported field.
```go
if err := mergo.Map(&dst, srcMap); err != nil {
// ...
}
```
Warning: if you map a struct to map, it won't do it recursively. Don't expect Mergo to map struct members of your struct as `map[string]interface{}`. They will be just assigned as values.
Here is a nice example:
```go
package main
import (
"fmt"
"dario.cat/mergo"
)
type Foo struct {
A string
B int64
}
func main() {
src := Foo{
A: "one",
B: 2,
}
dest := Foo{
A: "two",
}
mergo.Merge(&dest, src)
fmt.Println(dest)
// Will print
// {two 2}
}
```
Note: if test are failing due missing package, please execute:
go get gopkg.in/yaml.v3
### Transformers
Transformers allow to merge specific types differently than in the default behavior. In other words, now you can customize how some types are merged. For example, `time.Time` is a struct; it doesn't have zero value but IsZero can return true because it has fields with zero value. How can we merge a non-zero `time.Time`?
```go
package main
import (
"fmt"
"dario.cat/mergo"
"reflect"
"time"
)
type timeTransformer struct {
}
func (t timeTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error {
if typ == reflect.TypeOf(time.Time{}) {
return func(dst, src reflect.Value) error {
if dst.CanSet() {
isZero := dst.MethodByName("IsZero")
result := isZero.Call([]reflect.Value{})
if result[0].Bool() {
dst.Set(src)
}
}
return nil
}
}
return nil
}
type Snapshot struct {
Time time.Time
// ...
}
func main() {
src := Snapshot{time.Now()}
dest := Snapshot{}
mergo.Merge(&dest, src, mergo.WithTransformers(timeTransformer{}))
fmt.Println(dest)
// Will print
// { 2018-01-12 01:15:00 +0000 UTC m=+0.000000001 }
}
```
## Contact me
If I can help you, you have an idea or you are using Mergo in your projects, don't hesitate to drop me a line (or a pull request): [@im_dario](https://twitter.com/im_dario)
## About
Written by [Dario Castañé](http://dario.im).
## License
[BSD 3-Clause](http://opensource.org/licenses/BSD-3-Clause) license, as [Go language](http://golang.org/LICENSE).
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fimdario%2Fmergo.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fimdario%2Fmergo?ref=badge_large)

14
vendor/dario.cat/mergo/SECURITY.md vendored Normal file
View File

@ -0,0 +1,14 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 0.3.x | :white_check_mark: |
| < 0.3 | :x: |
## Security contact information
To report a security vulnerability, please use the
[Tidelift security contact](https://tidelift.com/security).
Tidelift will coordinate the fix and disclosure.

148
vendor/dario.cat/mergo/doc.go vendored Normal file
View File

@ -0,0 +1,148 @@
// Copyright 2013 Dario Castañé. All rights reserved.
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
/*
A helper to merge structs and maps in Golang. Useful for configuration default values, avoiding messy if-statements.
Mergo merges same-type structs and maps by setting default values in zero-value fields. Mergo won't merge unexported (private) fields. It will do recursively any exported one. It also won't merge structs inside maps (because they are not addressable using Go reflection).
# Status
It is ready for production use. It is used in several projects by Docker, Google, The Linux Foundation, VMWare, Shopify, etc.
# Important notes
1.0.0
In 1.0.0 Mergo moves to a vanity URL `dario.cat/mergo`.
0.3.9
Please keep in mind that a problematic PR broke 0.3.9. We reverted it in 0.3.10. We consider 0.3.10 as stable but not bug-free. . Also, this version adds suppot for go modules.
Keep in mind that in 0.3.2, Mergo changed Merge() and Map() signatures to support transformers. We added an optional/variadic argument so that it won't break the existing code.
If you were using Mergo before April 6th, 2015, please check your project works as intended after updating your local copy with go get -u dario.cat/mergo. I apologize for any issue caused by its previous behavior and any future bug that Mergo could cause in existing projects after the change (release 0.2.0).
# Install
Do your usual installation procedure:
go get dario.cat/mergo
// use in your .go code
import (
"dario.cat/mergo"
)
# Usage
You can only merge same-type structs with exported fields initialized as zero value of their type and same-types maps. Mergo won't merge unexported (private) fields but will do recursively any exported one. It won't merge empty structs value as they are zero values too. Also, maps will be merged recursively except for structs inside maps (because they are not addressable using Go reflection).
if err := mergo.Merge(&dst, src); err != nil {
// ...
}
Also, you can merge overwriting values using the transformer WithOverride.
if err := mergo.Merge(&dst, src, mergo.WithOverride); err != nil {
// ...
}
Additionally, you can map a map[string]interface{} to a struct (and otherwise, from struct to map), following the same restrictions as in Merge(). Keys are capitalized to find each corresponding exported field.
if err := mergo.Map(&dst, srcMap); err != nil {
// ...
}
Warning: if you map a struct to map, it won't do it recursively. Don't expect Mergo to map struct members of your struct as map[string]interface{}. They will be just assigned as values.
Here is a nice example:
package main
import (
"fmt"
"dario.cat/mergo"
)
type Foo struct {
A string
B int64
}
func main() {
src := Foo{
A: "one",
B: 2,
}
dest := Foo{
A: "two",
}
mergo.Merge(&dest, src)
fmt.Println(dest)
// Will print
// {two 2}
}
# Transformers
Transformers allow to merge specific types differently than in the default behavior. In other words, now you can customize how some types are merged. For example, time.Time is a struct; it doesn't have zero value but IsZero can return true because it has fields with zero value. How can we merge a non-zero time.Time?
package main
import (
"fmt"
"dario.cat/mergo"
"reflect"
"time"
)
type timeTransformer struct {
}
func (t timeTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error {
if typ == reflect.TypeOf(time.Time{}) {
return func(dst, src reflect.Value) error {
if dst.CanSet() {
isZero := dst.MethodByName("IsZero")
result := isZero.Call([]reflect.Value{})
if result[0].Bool() {
dst.Set(src)
}
}
return nil
}
}
return nil
}
type Snapshot struct {
Time time.Time
// ...
}
func main() {
src := Snapshot{time.Now()}
dest := Snapshot{}
mergo.Merge(&dest, src, mergo.WithTransformers(timeTransformer{}))
fmt.Println(dest)
// Will print
// { 2018-01-12 01:15:00 +0000 UTC m=+0.000000001 }
}
# Contact me
If I can help you, you have an idea or you are using Mergo in your projects, don't hesitate to drop me a line (or a pull request): https://twitter.com/im_dario
# About
Written by Dario Castañé: https://da.rio.hn
# License
BSD 3-Clause license, as Go language.
*/
package mergo

178
vendor/dario.cat/mergo/map.go vendored Normal file
View File

@ -0,0 +1,178 @@
// Copyright 2014 Dario Castañé. All rights reserved.
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Based on src/pkg/reflect/deepequal.go from official
// golang's stdlib.
package mergo
import (
"fmt"
"reflect"
"unicode"
"unicode/utf8"
)
func changeInitialCase(s string, mapper func(rune) rune) string {
if s == "" {
return s
}
r, n := utf8.DecodeRuneInString(s)
return string(mapper(r)) + s[n:]
}
func isExported(field reflect.StructField) bool {
r, _ := utf8.DecodeRuneInString(field.Name)
return r >= 'A' && r <= 'Z'
}
// Traverses recursively both values, assigning src's fields values to dst.
// The map argument tracks comparisons that have already been seen, which allows
// short circuiting on recursive types.
func deepMap(dst, src reflect.Value, visited map[uintptr]*visit, depth int, config *Config) (err error) {
overwrite := config.Overwrite
if dst.CanAddr() {
addr := dst.UnsafeAddr()
h := 17 * addr
seen := visited[h]
typ := dst.Type()
for p := seen; p != nil; p = p.next {
if p.ptr == addr && p.typ == typ {
return nil
}
}
// Remember, remember...
visited[h] = &visit{typ, seen, addr}
}
zeroValue := reflect.Value{}
switch dst.Kind() {
case reflect.Map:
dstMap := dst.Interface().(map[string]interface{})
for i, n := 0, src.NumField(); i < n; i++ {
srcType := src.Type()
field := srcType.Field(i)
if !isExported(field) {
continue
}
fieldName := field.Name
fieldName = changeInitialCase(fieldName, unicode.ToLower)
if v, ok := dstMap[fieldName]; !ok || (isEmptyValue(reflect.ValueOf(v), !config.ShouldNotDereference) || overwrite) {
dstMap[fieldName] = src.Field(i).Interface()
}
}
case reflect.Ptr:
if dst.IsNil() {
v := reflect.New(dst.Type().Elem())
dst.Set(v)
}
dst = dst.Elem()
fallthrough
case reflect.Struct:
srcMap := src.Interface().(map[string]interface{})
for key := range srcMap {
config.overwriteWithEmptyValue = true
srcValue := srcMap[key]
fieldName := changeInitialCase(key, unicode.ToUpper)
dstElement := dst.FieldByName(fieldName)
if dstElement == zeroValue {
// We discard it because the field doesn't exist.
continue
}
srcElement := reflect.ValueOf(srcValue)
dstKind := dstElement.Kind()
srcKind := srcElement.Kind()
if srcKind == reflect.Ptr && dstKind != reflect.Ptr {
srcElement = srcElement.Elem()
srcKind = reflect.TypeOf(srcElement.Interface()).Kind()
} else if dstKind == reflect.Ptr {
// Can this work? I guess it can't.
if srcKind != reflect.Ptr && srcElement.CanAddr() {
srcPtr := srcElement.Addr()
srcElement = reflect.ValueOf(srcPtr)
srcKind = reflect.Ptr
}
}
if !srcElement.IsValid() {
continue
}
if srcKind == dstKind {
if err = deepMerge(dstElement, srcElement, visited, depth+1, config); err != nil {
return
}
} else if dstKind == reflect.Interface && dstElement.Kind() == reflect.Interface {
if err = deepMerge(dstElement, srcElement, visited, depth+1, config); err != nil {
return
}
} else if srcKind == reflect.Map {
if err = deepMap(dstElement, srcElement, visited, depth+1, config); err != nil {
return
}
} else {
return fmt.Errorf("type mismatch on %s field: found %v, expected %v", fieldName, srcKind, dstKind)
}
}
}
return
}
// Map sets fields' values in dst from src.
// src can be a map with string keys or a struct. dst must be the opposite:
// if src is a map, dst must be a valid pointer to struct. If src is a struct,
// dst must be map[string]interface{}.
// It won't merge unexported (private) fields and will do recursively
// any exported field.
// If dst is a map, keys will be src fields' names in lower camel case.
// Missing key in src that doesn't match a field in dst will be skipped. This
// doesn't apply if dst is a map.
// This is separated method from Merge because it is cleaner and it keeps sane
// semantics: merging equal types, mapping different (restricted) types.
func Map(dst, src interface{}, opts ...func(*Config)) error {
return _map(dst, src, opts...)
}
// MapWithOverwrite will do the same as Map except that non-empty dst attributes will be overridden by
// non-empty src attribute values.
// Deprecated: Use Map(…) with WithOverride
func MapWithOverwrite(dst, src interface{}, opts ...func(*Config)) error {
return _map(dst, src, append(opts, WithOverride)...)
}
func _map(dst, src interface{}, opts ...func(*Config)) error {
if dst != nil && reflect.ValueOf(dst).Kind() != reflect.Ptr {
return ErrNonPointerArgument
}
var (
vDst, vSrc reflect.Value
err error
)
config := &Config{}
for _, opt := range opts {
opt(config)
}
if vDst, vSrc, err = resolveValues(dst, src); err != nil {
return err
}
// To be friction-less, we redirect equal-type arguments
// to deepMerge. Only because arguments can be anything.
if vSrc.Kind() == vDst.Kind() {
return deepMerge(vDst, vSrc, make(map[uintptr]*visit), 0, config)
}
switch vSrc.Kind() {
case reflect.Struct:
if vDst.Kind() != reflect.Map {
return ErrExpectedMapAsDestination
}
case reflect.Map:
if vDst.Kind() != reflect.Struct {
return ErrExpectedStructAsDestination
}
default:
return ErrNotSupported
}
return deepMap(vDst, vSrc, make(map[uintptr]*visit), 0, config)
}

409
vendor/dario.cat/mergo/merge.go vendored Normal file
View File

@ -0,0 +1,409 @@
// Copyright 2013 Dario Castañé. All rights reserved.
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Based on src/pkg/reflect/deepequal.go from official
// golang's stdlib.
package mergo
import (
"fmt"
"reflect"
)
func hasMergeableFields(dst reflect.Value) (exported bool) {
for i, n := 0, dst.NumField(); i < n; i++ {
field := dst.Type().Field(i)
if field.Anonymous && dst.Field(i).Kind() == reflect.Struct {
exported = exported || hasMergeableFields(dst.Field(i))
} else if isExportedComponent(&field) {
exported = exported || len(field.PkgPath) == 0
}
}
return
}
func isExportedComponent(field *reflect.StructField) bool {
pkgPath := field.PkgPath
if len(pkgPath) > 0 {
return false
}
c := field.Name[0]
if 'a' <= c && c <= 'z' || c == '_' {
return false
}
return true
}
type Config struct {
Transformers Transformers
Overwrite bool
ShouldNotDereference bool
AppendSlice bool
TypeCheck bool
overwriteWithEmptyValue bool
overwriteSliceWithEmptyValue bool
sliceDeepCopy bool
debug bool
}
type Transformers interface {
Transformer(reflect.Type) func(dst, src reflect.Value) error
}
// Traverses recursively both values, assigning src's fields values to dst.
// The map argument tracks comparisons that have already been seen, which allows
// short circuiting on recursive types.
func deepMerge(dst, src reflect.Value, visited map[uintptr]*visit, depth int, config *Config) (err error) {
overwrite := config.Overwrite
typeCheck := config.TypeCheck
overwriteWithEmptySrc := config.overwriteWithEmptyValue
overwriteSliceWithEmptySrc := config.overwriteSliceWithEmptyValue
sliceDeepCopy := config.sliceDeepCopy
if !src.IsValid() {
return
}
if dst.CanAddr() {
addr := dst.UnsafeAddr()
h := 17 * addr
seen := visited[h]
typ := dst.Type()
for p := seen; p != nil; p = p.next {
if p.ptr == addr && p.typ == typ {
return nil
}
}
// Remember, remember...
visited[h] = &visit{typ, seen, addr}
}
if config.Transformers != nil && !isReflectNil(dst) && dst.IsValid() {
if fn := config.Transformers.Transformer(dst.Type()); fn != nil {
err = fn(dst, src)
return
}
}
switch dst.Kind() {
case reflect.Struct:
if hasMergeableFields(dst) {
for i, n := 0, dst.NumField(); i < n; i++ {
if err = deepMerge(dst.Field(i), src.Field(i), visited, depth+1, config); err != nil {
return
}
}
} else {
if dst.CanSet() && (isReflectNil(dst) || overwrite) && (!isEmptyValue(src, !config.ShouldNotDereference) || overwriteWithEmptySrc) {
dst.Set(src)
}
}
case reflect.Map:
if dst.IsNil() && !src.IsNil() {
if dst.CanSet() {
dst.Set(reflect.MakeMap(dst.Type()))
} else {
dst = src
return
}
}
if src.Kind() != reflect.Map {
if overwrite && dst.CanSet() {
dst.Set(src)
}
return
}
for _, key := range src.MapKeys() {
srcElement := src.MapIndex(key)
if !srcElement.IsValid() {
continue
}
dstElement := dst.MapIndex(key)
switch srcElement.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Interface, reflect.Slice:
if srcElement.IsNil() {
if overwrite {
dst.SetMapIndex(key, srcElement)
}
continue
}
fallthrough
default:
if !srcElement.CanInterface() {
continue
}
switch reflect.TypeOf(srcElement.Interface()).Kind() {
case reflect.Struct:
fallthrough
case reflect.Ptr:
fallthrough
case reflect.Map:
srcMapElm := srcElement
dstMapElm := dstElement
if srcMapElm.CanInterface() {
srcMapElm = reflect.ValueOf(srcMapElm.Interface())
if dstMapElm.IsValid() {
dstMapElm = reflect.ValueOf(dstMapElm.Interface())
}
}
if err = deepMerge(dstMapElm, srcMapElm, visited, depth+1, config); err != nil {
return
}
case reflect.Slice:
srcSlice := reflect.ValueOf(srcElement.Interface())
var dstSlice reflect.Value
if !dstElement.IsValid() || dstElement.IsNil() {
dstSlice = reflect.MakeSlice(srcSlice.Type(), 0, srcSlice.Len())
} else {
dstSlice = reflect.ValueOf(dstElement.Interface())
}
if (!isEmptyValue(src, !config.ShouldNotDereference) || overwriteWithEmptySrc || overwriteSliceWithEmptySrc) && (overwrite || isEmptyValue(dst, !config.ShouldNotDereference)) && !config.AppendSlice && !sliceDeepCopy {
if typeCheck && srcSlice.Type() != dstSlice.Type() {
return fmt.Errorf("cannot override two slices with different type (%s, %s)", srcSlice.Type(), dstSlice.Type())
}
dstSlice = srcSlice
} else if config.AppendSlice {
if srcSlice.Type() != dstSlice.Type() {
return fmt.Errorf("cannot append two slices with different type (%s, %s)", srcSlice.Type(), dstSlice.Type())
}
dstSlice = reflect.AppendSlice(dstSlice, srcSlice)
} else if sliceDeepCopy {
i := 0
for ; i < srcSlice.Len() && i < dstSlice.Len(); i++ {
srcElement := srcSlice.Index(i)
dstElement := dstSlice.Index(i)
if srcElement.CanInterface() {
srcElement = reflect.ValueOf(srcElement.Interface())
}
if dstElement.CanInterface() {
dstElement = reflect.ValueOf(dstElement.Interface())
}
if err = deepMerge(dstElement, srcElement, visited, depth+1, config); err != nil {
return
}
}
}
dst.SetMapIndex(key, dstSlice)
}
}
if dstElement.IsValid() && !isEmptyValue(dstElement, !config.ShouldNotDereference) {
if reflect.TypeOf(srcElement.Interface()).Kind() == reflect.Slice {
continue
}
if reflect.TypeOf(srcElement.Interface()).Kind() == reflect.Map && reflect.TypeOf(dstElement.Interface()).Kind() == reflect.Map {
continue
}
}
if srcElement.IsValid() && ((srcElement.Kind() != reflect.Ptr && overwrite) || !dstElement.IsValid() || isEmptyValue(dstElement, !config.ShouldNotDereference)) {
if dst.IsNil() {
dst.Set(reflect.MakeMap(dst.Type()))
}
dst.SetMapIndex(key, srcElement)
}
}
// Ensure that all keys in dst are deleted if they are not in src.
if overwriteWithEmptySrc {
for _, key := range dst.MapKeys() {
srcElement := src.MapIndex(key)
if !srcElement.IsValid() {
dst.SetMapIndex(key, reflect.Value{})
}
}
}
case reflect.Slice:
if !dst.CanSet() {
break
}
if (!isEmptyValue(src, !config.ShouldNotDereference) || overwriteWithEmptySrc || overwriteSliceWithEmptySrc) && (overwrite || isEmptyValue(dst, !config.ShouldNotDereference)) && !config.AppendSlice && !sliceDeepCopy {
dst.Set(src)
} else if config.AppendSlice {
if src.Type() != dst.Type() {
return fmt.Errorf("cannot append two slice with different type (%s, %s)", src.Type(), dst.Type())
}
dst.Set(reflect.AppendSlice(dst, src))
} else if sliceDeepCopy {
for i := 0; i < src.Len() && i < dst.Len(); i++ {
srcElement := src.Index(i)
dstElement := dst.Index(i)
if srcElement.CanInterface() {
srcElement = reflect.ValueOf(srcElement.Interface())
}
if dstElement.CanInterface() {
dstElement = reflect.ValueOf(dstElement.Interface())
}
if err = deepMerge(dstElement, srcElement, visited, depth+1, config); err != nil {
return
}
}
}
case reflect.Ptr:
fallthrough
case reflect.Interface:
if isReflectNil(src) {
if overwriteWithEmptySrc && dst.CanSet() && src.Type().AssignableTo(dst.Type()) {
dst.Set(src)
}
break
}
if src.Kind() != reflect.Interface {
if dst.IsNil() || (src.Kind() != reflect.Ptr && overwrite) {
if dst.CanSet() && (overwrite || isEmptyValue(dst, !config.ShouldNotDereference)) {
dst.Set(src)
}
} else if src.Kind() == reflect.Ptr {
if !config.ShouldNotDereference {
if err = deepMerge(dst.Elem(), src.Elem(), visited, depth+1, config); err != nil {
return
}
} else {
if overwriteWithEmptySrc || (overwrite && !src.IsNil()) || dst.IsNil() {
dst.Set(src)
}
}
} else if dst.Elem().Type() == src.Type() {
if err = deepMerge(dst.Elem(), src, visited, depth+1, config); err != nil {
return
}
} else {
return ErrDifferentArgumentsTypes
}
break
}
if dst.IsNil() || overwrite {
if dst.CanSet() && (overwrite || isEmptyValue(dst, !config.ShouldNotDereference)) {
dst.Set(src)
}
break
}
if dst.Elem().Kind() == src.Elem().Kind() {
if err = deepMerge(dst.Elem(), src.Elem(), visited, depth+1, config); err != nil {
return
}
break
}
default:
mustSet := (isEmptyValue(dst, !config.ShouldNotDereference) || overwrite) && (!isEmptyValue(src, !config.ShouldNotDereference) || overwriteWithEmptySrc)
if mustSet {
if dst.CanSet() {
dst.Set(src)
} else {
dst = src
}
}
}
return
}
// Merge will fill any empty for value type attributes on the dst struct using corresponding
// src attributes if they themselves are not empty. dst and src must be valid same-type structs
// and dst must be a pointer to struct.
// It won't merge unexported (private) fields and will do recursively any exported field.
func Merge(dst, src interface{}, opts ...func(*Config)) error {
return merge(dst, src, opts...)
}
// MergeWithOverwrite will do the same as Merge except that non-empty dst attributes will be overridden by
// non-empty src attribute values.
// Deprecated: use Merge(…) with WithOverride
func MergeWithOverwrite(dst, src interface{}, opts ...func(*Config)) error {
return merge(dst, src, append(opts, WithOverride)...)
}
// WithTransformers adds transformers to merge, allowing to customize the merging of some types.
func WithTransformers(transformers Transformers) func(*Config) {
return func(config *Config) {
config.Transformers = transformers
}
}
// WithOverride will make merge override non-empty dst attributes with non-empty src attributes values.
func WithOverride(config *Config) {
config.Overwrite = true
}
// WithOverwriteWithEmptyValue will make merge override non empty dst attributes with empty src attributes values.
func WithOverwriteWithEmptyValue(config *Config) {
config.Overwrite = true
config.overwriteWithEmptyValue = true
}
// WithOverrideEmptySlice will make merge override empty dst slice with empty src slice.
func WithOverrideEmptySlice(config *Config) {
config.overwriteSliceWithEmptyValue = true
}
// WithoutDereference prevents dereferencing pointers when evaluating whether they are empty
// (i.e. a non-nil pointer is never considered empty).
func WithoutDereference(config *Config) {
config.ShouldNotDereference = true
}
// WithAppendSlice will make merge append slices instead of overwriting it.
func WithAppendSlice(config *Config) {
config.AppendSlice = true
}
// WithTypeCheck will make merge check types while overwriting it (must be used with WithOverride).
func WithTypeCheck(config *Config) {
config.TypeCheck = true
}
// WithSliceDeepCopy will merge slice element one by one with Overwrite flag.
func WithSliceDeepCopy(config *Config) {
config.sliceDeepCopy = true
config.Overwrite = true
}
func merge(dst, src interface{}, opts ...func(*Config)) error {
if dst != nil && reflect.ValueOf(dst).Kind() != reflect.Ptr {
return ErrNonPointerArgument
}
var (
vDst, vSrc reflect.Value
err error
)
config := &Config{}
for _, opt := range opts {
opt(config)
}
if vDst, vSrc, err = resolveValues(dst, src); err != nil {
return err
}
if vDst.Type() != vSrc.Type() {
return ErrDifferentArgumentsTypes
}
return deepMerge(vDst, vSrc, make(map[uintptr]*visit), 0, config)
}
// IsReflectNil is the reflect value provided nil
func isReflectNil(v reflect.Value) bool {
k := v.Kind()
switch k {
case reflect.Interface, reflect.Slice, reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr:
// Both interface and slice are nil if first word is 0.
// Both are always bigger than a word; assume flagIndir.
return v.IsNil()
default:
return false
}
}

81
vendor/dario.cat/mergo/mergo.go vendored Normal file
View File

@ -0,0 +1,81 @@
// Copyright 2013 Dario Castañé. All rights reserved.
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Based on src/pkg/reflect/deepequal.go from official
// golang's stdlib.
package mergo
import (
"errors"
"reflect"
)
// Errors reported by Mergo when it finds invalid arguments.
var (
ErrNilArguments = errors.New("src and dst must not be nil")
ErrDifferentArgumentsTypes = errors.New("src and dst must be of same type")
ErrNotSupported = errors.New("only structs, maps, and slices are supported")
ErrExpectedMapAsDestination = errors.New("dst was expected to be a map")
ErrExpectedStructAsDestination = errors.New("dst was expected to be a struct")
ErrNonPointerArgument = errors.New("dst must be a pointer")
)
// During deepMerge, must keep track of checks that are
// in progress. The comparison algorithm assumes that all
// checks in progress are true when it reencounters them.
// Visited are stored in a map indexed by 17 * a1 + a2;
type visit struct {
typ reflect.Type
next *visit
ptr uintptr
}
// From src/pkg/encoding/json/encode.go.
func isEmptyValue(v reflect.Value, shouldDereference bool) bool {
switch v.Kind() {
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
return v.Len() == 0
case reflect.Bool:
return !v.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Interface, reflect.Ptr:
if v.IsNil() {
return true
}
if shouldDereference {
return isEmptyValue(v.Elem(), shouldDereference)
}
return false
case reflect.Func:
return v.IsNil()
case reflect.Invalid:
return true
}
return false
}
func resolveValues(dst, src interface{}) (vDst, vSrc reflect.Value, err error) {
if dst == nil || src == nil {
err = ErrNilArguments
return
}
vDst = reflect.ValueOf(dst).Elem()
if vDst.Kind() != reflect.Struct && vDst.Kind() != reflect.Map && vDst.Kind() != reflect.Slice {
err = ErrNotSupported
return
}
vSrc = reflect.ValueOf(src)
// We check if vSrc is a pointer to dereference it.
if vSrc.Kind() == reflect.Ptr {
vSrc = vSrc.Elem()
}
return
}

View File

@ -0,0 +1 @@
.DS_Store

View File

@ -0,0 +1,23 @@
Copyright (c) 2013 John Barton
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,202 @@
# GoDotEnv ![CI](https://github.com/joho/godotenv/workflows/CI/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/joho/godotenv)](https://goreportcard.com/report/github.com/joho/godotenv)
A Go (golang) port of the Ruby [dotenv](https://github.com/bkeepers/dotenv) project (which loads env vars from a .env file).
From the original Library:
> Storing configuration in the environment is one of the tenets of a twelve-factor app. Anything that is likely to change between deployment environmentssuch as resource handles for databases or credentials for external servicesshould be extracted from the code into environment variables.
>
> But it is not always practical to set environment variables on development machines or continuous integration servers where multiple projects are run. Dotenv load variables from a .env file into ENV when the environment is bootstrapped.
It can be used as a library (for loading in env for your own daemons etc.) or as a bin command.
There is test coverage and CI for both linuxish and Windows environments, but I make no guarantees about the bin version working on Windows.
## Installation
As a library
```shell
go get github.com/joho/godotenv
```
or if you want to use it as a bin command
go >= 1.17
```shell
go install github.com/joho/godotenv/cmd/godotenv@latest
```
go < 1.17
```shell
go get github.com/joho/godotenv/cmd/godotenv
```
## Usage
Add your application configuration to your `.env` file in the root of your project:
```shell
S3_BUCKET=YOURS3BUCKET
SECRET_KEY=YOURSECRETKEYGOESHERE
```
Then in your Go app you can do something like
```go
package main
import (
"log"
"os"
"github.com/joho/godotenv"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
s3Bucket := os.Getenv("S3_BUCKET")
secretKey := os.Getenv("SECRET_KEY")
// now do something with s3 or whatever
}
```
If you're even lazier than that, you can just take advantage of the autoload package which will read in `.env` on import
```go
import _ "github.com/joho/godotenv/autoload"
```
While `.env` in the project root is the default, you don't have to be constrained, both examples below are 100% legit
```go
godotenv.Load("somerandomfile")
godotenv.Load("filenumberone.env", "filenumbertwo.env")
```
If you want to be really fancy with your env file you can do comments and exports (below is a valid env file)
```shell
# I am a comment and that is OK
SOME_VAR=someval
FOO=BAR # comments at line end are OK too
export BAR=BAZ
```
Or finally you can do YAML(ish) style
```yaml
FOO: bar
BAR: baz
```
as a final aside, if you don't want godotenv munging your env you can just get a map back instead
```go
var myEnv map[string]string
myEnv, err := godotenv.Read()
s3Bucket := myEnv["S3_BUCKET"]
```
... or from an `io.Reader` instead of a local file
```go
reader := getRemoteFile()
myEnv, err := godotenv.Parse(reader)
```
... or from a `string` if you so desire
```go
content := getRemoteFileContent()
myEnv, err := godotenv.Unmarshal(content)
```
### Precedence & Conventions
Existing envs take precedence of envs that are loaded later.
The [convention](https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use)
for managing multiple environments (i.e. development, test, production)
is to create an env named `{YOURAPP}_ENV` and load envs in this order:
```go
env := os.Getenv("FOO_ENV")
if "" == env {
env = "development"
}
godotenv.Load(".env." + env + ".local")
if "test" != env {
godotenv.Load(".env.local")
}
godotenv.Load(".env." + env)
godotenv.Load() // The Original .env
```
If you need to, you can also use `godotenv.Overload()` to defy this convention
and overwrite existing envs instead of only supplanting them. Use with caution.
### Command Mode
Assuming you've installed the command as above and you've got `$GOPATH/bin` in your `$PATH`
```
godotenv -f /some/path/to/.env some_command with some args
```
If you don't specify `-f` it will fall back on the default of loading `.env` in `PWD`
By default, it won't override existing environment variables; you can do that with the `-o` flag.
### Writing Env Files
Godotenv can also write a map representing the environment to a correctly-formatted and escaped file
```go
env, err := godotenv.Unmarshal("KEY=value")
err := godotenv.Write(env, "./.env")
```
... or to a string
```go
env, err := godotenv.Unmarshal("KEY=value")
content, err := godotenv.Marshal(env)
```
## Contributing
Contributions are welcome, but with some caveats.
This library has been declared feature complete (see [#182](https://github.com/joho/godotenv/issues/182) for background) and will not be accepting issues or pull requests adding new functionality or breaking the library API.
Contributions would be gladly accepted that:
* bring this library's parsing into closer compatibility with the mainline dotenv implementations, in particular [Ruby's dotenv](https://github.com/bkeepers/dotenv) and [Node.js' dotenv](https://github.com/motdotla/dotenv)
* keep the library up to date with the go ecosystem (ie CI bumps, documentation changes, changes in the core libraries)
* bug fixes for use cases that pertain to the library's purpose of easing development of codebases deployed into twelve factor environments
*code changes without tests and references to peer dotenv implementations will not be accepted*
1. Fork it
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Added some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request
## Releases
Releases should follow [Semver](http://semver.org/) though the first couple of releases are `v1` and `v1.1`.
Use [annotated tags for all releases](https://github.com/joho/godotenv/issues/30). Example `git tag -a v1.2.1`
## Who?
The original library [dotenv](https://github.com/bkeepers/dotenv) was written by [Brandon Keepers](http://opensoul.org/), and this port was done by [John Barton](https://johnbarton.co/) based off the tests/fixtures in the original library.

View File

@ -0,0 +1,234 @@
// Package godotenv is a go port of the ruby dotenv library (https://github.com/bkeepers/dotenv)
//
// Examples/readme can be found on the GitHub page at https://github.com/joho/godotenv
//
// The TL;DR is that you make a .env file that looks something like
//
// SOME_ENV_VAR=somevalue
//
// and then in your go code you can call
//
// godotenv.Load()
//
// and all the env vars declared in .env will be available through os.Getenv("SOME_ENV_VAR")
package godotenv
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strconv"
"strings"
)
const doubleQuoteSpecialChars = "\\\n\r\"!$`"
// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (map[string]string, map[string]map[string]string, error) {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
if err != nil {
return nil, nil, err
}
return UnmarshalBytes(buf.Bytes())
}
// Load will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main).
//
// If you call Load without any args it will default to loading .env in the current path.
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// godotenv.Load("fileone", "filetwo")
//
// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults.
func Load(filenames ...string) (err error) {
filenames = filenamesOrDefault(filenames)
for _, filename := range filenames {
err = loadFile(filename, false)
if err != nil {
return // return early on a spazout
}
}
return
}
// Overload will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main).
//
// If you call Overload without any args it will default to loading .env in the current path.
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// godotenv.Overload("fileone", "filetwo")
//
// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefully set all vars.
func Overload(filenames ...string) (err error) {
filenames = filenamesOrDefault(filenames)
for _, filename := range filenames {
err = loadFile(filename, true)
if err != nil {
return // return early on a spazout
}
}
return
}
// Read all env (with same file loading semantics as Load) but return values as
// a map rather than automatically writing values into env
func Read(filenames ...string) (envMap map[string]string, modMap map[string]map[string]string, err error) {
filenames = filenamesOrDefault(filenames)
envMap = make(map[string]string)
modMap = make(map[string]map[string]string)
for _, filename := range filenames {
individualEnvMap, individualModMap, individualErr := readFile(filename)
if individualErr != nil {
err = individualErr
return // return early on a spazout
}
for key, value := range individualEnvMap {
envMap[key] = value
}
for key, value := range individualModMap {
modMap[key] = value
}
}
return
}
// Unmarshal reads an env file from a string, returning a map of keys and values.
func Unmarshal(str string) (envMap map[string]string, modifierMap map[string]map[string]string, err error) {
return UnmarshalBytes([]byte(str))
}
// UnmarshalBytes parses env file from byte slice of chars, returning a map of keys and values.
func UnmarshalBytes(src []byte) (map[string]string, map[string]map[string]string, error) {
vars := make(map[string]string)
modifiers := make(map[string]map[string]string)
err := parseBytes(src, vars, modifiers)
return vars, modifiers, err
}
// Exec loads env vars from the specified filenames (empty map falls back to default)
// then executes the cmd specified.
//
// Simply hooks up os.Stdin/err/out to the command and calls Run().
//
// If you want more fine grained control over your command it's recommended
// that you use `Load()`, `Overload()` or `Read()` and the `os/exec` package yourself.
func Exec(filenames []string, cmd string, cmdArgs []string, overload bool) error {
op := Load
if overload {
op = Overload
}
if err := op(filenames...); err != nil {
return err
}
command := exec.Command(cmd, cmdArgs...)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = os.Stderr
return command.Run()
}
// Write serializes the given environment and writes it to a file.
func Write(envMap map[string]string, filename string) error {
content, err := Marshal(envMap)
if err != nil {
return err
}
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(content + "\n")
if err != nil {
return err
}
return file.Sync()
}
// Marshal outputs the given environment as a dotenv-formatted environment file.
// Each line is in the format: KEY="VALUE" where VALUE is backslash-escaped.
func Marshal(envMap map[string]string) (string, error) {
lines := make([]string, 0, len(envMap))
for k, v := range envMap {
if d, err := strconv.Atoi(v); err == nil {
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
} else {
lines = append(lines, fmt.Sprintf(`%s="%s"`, k, doubleQuoteEscape(v)))
}
}
sort.Strings(lines)
return strings.Join(lines, "\n"), nil
}
func filenamesOrDefault(filenames []string) []string {
if len(filenames) == 0 {
return []string{".env"}
}
return filenames
}
func loadFile(filename string, overload bool) error {
envMap, _, err := readFile(filename)
if err != nil {
return err
}
currentEnv := map[string]bool{}
rawEnv := os.Environ()
for _, rawEnvLine := range rawEnv {
key := strings.Split(rawEnvLine, "=")[0]
currentEnv[key] = true
}
for key, value := range envMap {
if !currentEnv[key] || overload {
_ = os.Setenv(key, value)
}
}
return nil
}
func readFile(filename string) (envMap map[string]string, modMap map[string]map[string]string, err error) {
file, err := os.Open(filename)
if err != nil {
return
}
defer file.Close()
return Parse(file)
}
func doubleQuoteEscape(line string) string {
for _, c := range doubleQuoteSpecialChars {
toReplace := "\\" + string(c)
if c == '\n' {
toReplace = `\n`
}
if c == '\r' {
toReplace = `\r`
}
line = strings.Replace(line, string(c), toReplace, -1)
}
return line
}

View File

@ -0,0 +1,293 @@
package godotenv
import (
"bytes"
"errors"
"fmt"
"regexp"
"strings"
"unicode"
)
const (
charComment = '#'
prefixSingleQuote = '\''
prefixDoubleQuote = '"'
exportPrefix = "export"
)
func parseBytes(src []byte, vars map[string]string, modifiers map[string]map[string]string) error {
src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1)
cutset := src
for {
cutset = getStatementStart(cutset)
if cutset == nil {
// reached end of file
break
}
key, left, err := locateKeyName(cutset)
if err != nil {
return err
}
value, mods, left, err := extractVarValue(left, vars)
if err != nil {
return err
}
vars[key] = value
modifiers[key] = mods
cutset = left
}
return nil
}
// getStatementPosition returns position of statement begin.
//
// It skips any comment line or non-whitespace character.
func getStatementStart(src []byte) []byte {
pos := indexOfNonSpaceChar(src)
if pos == -1 {
return nil
}
src = src[pos:]
if src[0] != charComment {
return src
}
// skip comment section
pos = bytes.IndexFunc(src, isCharFunc('\n'))
if pos == -1 {
return nil
}
return getStatementStart(src[pos:])
}
// locateKeyName locates and parses key name and returns rest of slice
func locateKeyName(src []byte) (key string, cutset []byte, err error) {
// trim "export" and space at beginning
src = bytes.TrimLeftFunc(src, isSpace)
if bytes.HasPrefix(src, []byte(exportPrefix)) {
trimmed := bytes.TrimPrefix(src, []byte(exportPrefix))
if bytes.IndexFunc(trimmed, isSpace) == 0 {
src = bytes.TrimLeftFunc(trimmed, isSpace)
}
}
// locate key name end and validate it in single loop
offset := 0
loop:
for i, char := range src {
rchar := rune(char)
if isSpace(rchar) {
continue
}
switch char {
case '=', ':':
// library also supports yaml-style value declaration
key = string(src[0:i])
offset = i + 1
break loop
case '_':
default:
// variable name should match [A-Za-z0-9_.]
if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' {
continue
}
return "", nil, fmt.Errorf(
`unexpected character %q in variable name near %q`,
string(char), string(src))
}
}
if len(src) == 0 {
return "", nil, errors.New("zero length string")
}
// trim whitespace
key = strings.TrimRightFunc(key, unicode.IsSpace)
cutset = bytes.TrimLeftFunc(src[offset:], isSpace)
return key, cutset, nil
}
// extractVarValue extracts variable value and returns rest of slice
func extractVarValue(src []byte, vars map[string]string) (value string, modifiers map[string]string, rest []byte, err error) {
quote, hasPrefix := hasQuotePrefix(src)
// unquoted value - read until end of line
endOfLine := bytes.IndexFunc(src, isLineEnd)
// Hit EOF without a trailing newline
if endOfLine == -1 {
endOfLine = len(src)
if endOfLine == 0 {
return "", nil, nil, nil
}
}
if !hasPrefix {
// Convert line to rune away to do accurate countback of runes
line := []rune(string(src[0:endOfLine]))
// Assume end of line is end of var
endOfVar := len(line)
if endOfVar == 0 {
return "", nil, src[endOfLine:], nil
}
comment := ""
// Work backwards to check if the line ends in whitespace then
// a comment (ie asdasd # some comment)
for i := endOfVar - 1; i >= 0; i-- {
if line[i] == charComment && i > 0 {
comment = string(line[i+1:])
if isSpace(line[i-1]) {
endOfVar = i
break
}
}
}
trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace)
return expandVariables(trimmed, vars), extractModifiers(comment), src[endOfLine:], nil
}
// lookup quoted string terminator
for i := 1; i < len(src); i++ {
if char := src[i]; char != quote {
continue
}
// skip escaped quote symbol (\" or \', depends on quote)
if prevChar := src[i-1]; prevChar == '\\' {
continue
}
// trim quotes
trimFunc := isCharFunc(rune(quote))
value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc))
if quote == prefixDoubleQuote {
// unescape newlines for double quote (this is compat feature)
// and expand environment variables
value = expandVariables(expandEscapes(value), vars)
}
var mods map[string]string
if endOfLine > i {
mods = extractModifiers(string(src[i+1 : endOfLine]))
}
return value, mods, src[i+1:], nil
}
// return formatted error if quoted string is not terminated
valEndIndex := bytes.IndexFunc(src, isCharFunc('\n'))
if valEndIndex == -1 {
valEndIndex = len(src)
}
return "", nil, nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex])
}
func extractModifiers(comment string) map[string]string {
if comment == "" {
return nil
}
comment = strings.TrimSpace(comment)
kvpairs := strings.Split(comment, " ")
mods := make(map[string]string)
for _, kv := range kvpairs {
kvsplit := strings.Split(kv, "=")
if len(kvsplit) != 2 {
continue
}
mods[kvsplit[0]] = kvsplit[1]
}
return mods
}
func expandEscapes(str string) string {
out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string {
c := strings.TrimPrefix(match, `\`)
switch c {
case "n":
return "\n"
case "r":
return "\r"
default:
return match
}
})
return unescapeCharsRegex.ReplaceAllString(out, "$1")
}
func indexOfNonSpaceChar(src []byte) int {
return bytes.IndexFunc(src, func(r rune) bool {
return !unicode.IsSpace(r)
})
}
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) {
if len(src) == 0 {
return 0, false
}
switch prefix := src[0]; prefix {
case prefixDoubleQuote, prefixSingleQuote:
return prefix, true
default:
return 0, false
}
}
func isCharFunc(char rune) func(rune) bool {
return func(v rune) bool {
return v == char
}
}
// isSpace reports whether the rune is a space character but not line break character
//
// this differs from unicode.IsSpace, which also applies line break as space
func isSpace(r rune) bool {
switch r {
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
return true
}
return false
}
func isLineEnd(r rune) bool {
if r == '\n' || r == '\r' {
return true
}
return false
}
var (
escapeRegex = regexp.MustCompile(`\\.`)
expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)
unescapeCharsRegex = regexp.MustCompile(`\\([^$])`)
)
func expandVariables(v string, m map[string]string) string {
return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string {
submatch := expandVarRegex.FindStringSubmatch(s)
if submatch == nil {
return s
}
if submatch[1] == "\\" || submatch[2] == "(" {
return submatch[0][1:]
} else if submatch[4] != "" {
return m[submatch[4]]
}
return s
})
}

View File

@ -0,0 +1,55 @@
# Contributing to Survey
🎉🎉 First off, thanks for the interest in contributing to `survey`! 🎉🎉
The following is a set of guidelines to follow when contributing to this package. These are not hard rules, please use common sense and feel free to propose changes to this document in a pull request.
## Code of Conduct
This project and its contibutors are expected to uphold the [Go Community Code of Conduct](https://golang.org/conduct). By participating, you are expected to follow these guidelines.
## Getting help
* [Open an issue](https://github.com/AlecAivazis/survey/issues/new/choose)
* Reach out to `@AlecAivazis` or `@mislav` in the Gophers slack (please use only when urgent)
## Submitting a contribution
When submitting a contribution,
- Try to make a series of smaller changes instead of one large change
- Provide a description of each change that you are proposing
- Reference the issue addressed by your pull request (if there is one)
- Document all new exported Go APIs
- Update the project's README when applicable
- Include unit tests if possible
- Contributions with visual ramifications or interaction changes should be accompanied with an integration test—see below for details.
## Writing and running tests
When submitting features, please add as many units tests as necessary to test both positive and negative cases.
Integration tests for survey uses [go-expect](https://github.com/Netflix/go-expect) to expect a match on stdout and respond on stdin. Since `os.Stdout` in a `go test` process is not a TTY, you need a way to interpret terminal / ANSI escape sequences for things like `CursorLocation`. The stdin/stdout handled by `go-expect` is also multiplexed to a [virtual terminal](https://github.com/hinshun/vt10x).
For example, you can extend the tests for Input by specifying the following test case:
```go
{
"Test Input prompt interaction", // Name of the test.
&Input{ // An implementation of the survey.Prompt interface.
Message: "What is your name?",
},
func(c *expect.Console) { // An expect procedure. You can expect strings / regexps and
c.ExpectString("What is your name?") // write back strings / bytes to its psuedoterminal for survey.
c.SendLine("Johnny Appleseed")
c.ExpectEOF() // Nothing is read from the tty without an expect, and once an
// expectation is met, no further bytes are read. End your
// procedure with `c.ExpectEOF()` to read until survey finishes.
},
"Johnny Appleseed", // The expected result.
}
```
If you want to write your own `go-expect` test from scratch, you'll need to instantiate a virtual terminal,
multiplex it into an `*expect.Console`, and hook up its tty with survey's optional stdio. Please see `go-expect`
[documentation](https://godoc.org/github.com/Netflix/go-expect) for more detail.

21
vendor/github.com/AlecAivazis/survey/v2/LICENSE generated vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Alec Aivazis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

510
vendor/github.com/AlecAivazis/survey/v2/README.md generated vendored Normal file
View File

@ -0,0 +1,510 @@
# Survey
[![GoDoc](http://img.shields.io/badge/godoc-reference-5272B4.svg)](https://pkg.go.dev/github.com/AlecAivazis/survey/v2)
A library for building interactive and accessible prompts on terminals supporting ANSI escape sequences.
<img width="550" src="https://thumbs.gfycat.com/VillainousGraciousKouprey-size_restricted.gif"/>
```go
package main
import (
"fmt"
"github.com/AlecAivazis/survey/v2"
)
// the questions to ask
var qs = []*survey.Question{
{
Name: "name",
Prompt: &survey.Input{Message: "What is your name?"},
Validate: survey.Required,
Transform: survey.Title,
},
{
Name: "color",
Prompt: &survey.Select{
Message: "Choose a color:",
Options: []string{"red", "blue", "green"},
Default: "red",
},
},
{
Name: "age",
Prompt: &survey.Input{Message: "How old are you?"},
},
}
func main() {
// the answers will be written to this struct
answers := struct {
Name string // survey will match the question and field names
FavoriteColor string `survey:"color"` // or you can tag fields to match a specific name
Age int // if the types don't match, survey will convert it
}{}
// perform the questions
err := survey.Ask(qs, &answers)
if err != nil {
fmt.Println(err.Error())
return
}
fmt.Printf("%s chose %s.", answers.Name, answers.FavoriteColor)
}
```
## Examples
Examples can be found in the `examples/` directory. Run them
to see basic behavior:
```bash
go run examples/simple.go
go run examples/validation.go
```
## Running the Prompts
There are two primary ways to execute prompts and start collecting information from your users: `Ask` and
`AskOne`. The primary difference is whether you are interested in collecting a single piece of information
or if you have a list of questions to ask whose answers should be collected in a single struct.
For most basic usecases, `Ask` should be enough. However, for surveys with complicated branching logic,
we recommend that you break out your questions into multiple calls to both of these functions to fit your needs.
### Configuring the Prompts
Most prompts take fine-grained configuration through fields on the structs you instantiate. It is also
possible to change survey's default behaviors by passing `AskOpts` to either `Ask` or `AskOne`. Examples
in this document will do both interchangeably:
```golang
prompt := &Select{
Message: "Choose a color:",
Options: []string{"red", "blue", "green"},
// can pass a validator directly
Validate: survey.Required,
}
// or define a default for the single call to `AskOne`
// the answer will get written to the color variable
survey.AskOne(prompt, &color, survey.WithValidator(survey.Required))
// or define a default for every entry in a list of questions
// the answer will get copied into the matching field of the struct as shown above
survey.Ask(questions, &answers, survey.WithValidator(survey.Required))
```
## Prompts
### Input
<img src="https://thumbs.gfycat.com/LankyBlindAmericanpainthorse-size_restricted.gif" width="400px"/>
```golang
name := ""
prompt := &survey.Input{
Message: "ping",
}
survey.AskOne(prompt, &name)
```
#### Suggestion Options
<img src="https://i.imgur.com/Q7POpA1.gif" width="800px"/>
```golang
file := ""
prompt := &survey.Input{
Message: "inform a file to save:",
Suggest: func (toComplete string) []string {
files, _ := filepath.Glob(toComplete + "*")
return files
},
}
}
survey.AskOne(prompt, &file)
```
### Multiline
<img src="https://thumbs.gfycat.com/ImperfectShimmeringBeagle-size_restricted.gif" width="400px"/>
```golang
text := ""
prompt := &survey.Multiline{
Message: "ping",
}
survey.AskOne(prompt, &text)
```
### Password
<img src="https://thumbs.gfycat.com/CompassionateSevereHypacrosaurus-size_restricted.gif" width="400px" />
```golang
password := ""
prompt := &survey.Password{
Message: "Please type your password",
}
survey.AskOne(prompt, &password)
```
### Confirm
<img src="https://thumbs.gfycat.com/UnkemptCarefulGermanpinscher-size_restricted.gif" width="400px"/>
```golang
name := false
prompt := &survey.Confirm{
Message: "Do you like pie?",
}
survey.AskOne(prompt, &name)
```
### Select
<img src="https://thumbs.gfycat.com/GrimFilthyAmazonparrot-size_restricted.gif" width="450px"/>
```golang
color := ""
prompt := &survey.Select{
Message: "Choose a color:",
Options: []string{"red", "blue", "green"},
}
survey.AskOne(prompt, &color)
```
Fields and values that come from a `Select` prompt can be one of two different things. If you pass an `int`
the field will have the value of the selected index. If you instead pass a string, the string value selected
will be written to the field.
The user can also press `esc` to toggle the ability cycle through the options with the j and k keys to do down and up respectively.
By default, the select prompt is limited to showing 7 options at a time
and will paginate lists of options longer than that. This can be changed a number of ways:
```golang
// as a field on a single select
prompt := &survey.MultiSelect{..., PageSize: 10}
// or as an option to Ask or AskOne
survey.AskOne(prompt, &days, survey.WithPageSize(10))
```
#### Select options description
The optional description text can be used to add extra information to each option listed in the select prompt:
```golang
color := ""
prompt := &survey.Select{
Message: "Choose a color:",
Options: []string{"red", "blue", "green"},
Description: func(value string, index int) string {
if value == "red" {
return "My favorite color"
}
return ""
},
}
survey.AskOne(prompt, &color)
// Assuming that the user chose "red - My favorite color":
fmt.Println(color) //=> "red"
```
### MultiSelect
![Example](img/multi-select-all-none.gif)
```golang
days := []string{}
prompt := &survey.MultiSelect{
Message: "What days do you prefer:",
Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
}
survey.AskOne(prompt, &days)
```
Fields and values that come from a `MultiSelect` prompt can be one of two different things. If you pass an `int`
the field will have a slice of the selected indices. If you instead pass a string, a slice of the string values
selected will be written to the field.
The user can also press `esc` to toggle the ability cycle through the options with the j and k keys to do down and up respectively.
By default, the MultiSelect prompt is limited to showing 7 options at a time
and will paginate lists of options longer than that. This can be changed a number of ways:
```golang
// as a field on a single select
prompt := &survey.MultiSelect{..., PageSize: 10}
// or as an option to Ask or AskOne
survey.AskOne(prompt, &days, survey.WithPageSize(10))
```
### Editor
Launches the user's preferred editor (defined by the \$VISUAL or \$EDITOR environment variables) on a
temporary file. Once the user exits their editor, the contents of the temporary file are read in as
the result. If neither of those are present, notepad (on Windows) or vim (Linux or Mac) is used.
You can also specify a [pattern](https://golang.org/pkg/io/ioutil/#TempFile) for the name of the temporary file. This
can be useful for ensuring syntax highlighting matches your usecase.
```golang
prompt := &survey.Editor{
Message: "Shell code snippet",
FileName: "*.sh",
}
survey.AskOne(prompt, &content)
```
## Filtering Options
By default, the user can filter for options in Select and MultiSelects by typing while the prompt
is active. This will filter out all options that don't contain the typed string anywhere in their name, ignoring case.
A custom filter function can also be provided to change this behavior:
```golang
func myFilter(filterValue string, optValue string, optIndex int) bool {
// only include the option if it includes the filter and has length greater than 5
return strings.Contains(optValue, filterValue) && len(optValue) >= 5
}
// configure it for a specific prompt
&Select{
Message: "Choose a color:",
Options: []string{"red", "blue", "green"},
Filter: myFilter,
}
// or define a default for all of the questions
survey.AskOne(prompt, &color, survey.WithFilter(myFilter))
```
## Keeping the filter active
By default the filter will disappear if the user selects one of the filtered elements. Once the user selects one element the filter setting is gone.
However the user can prevent this from happening and keep the filter active for multiple selections in a e.g. MultiSelect:
```golang
// configure it for a specific prompt
&Select{
Message: "Choose a color:",
Options: []string{"light-green", "green", "dark-green", "red"},
KeepFilter: true,
}
// or define a default for all of the questions
survey.AskOne(prompt, &color, survey.WithKeepFilter(true))
```
## Validation
Validating individual responses for a particular question can be done by defining a
`Validate` field on the `survey.Question` to be validated. This function takes an
`interface{}` type and returns an error to show to the user, prompting them for another
response. Like usual, validators can be provided directly to the prompt or with `survey.WithValidator`:
```golang
q := &survey.Question{
Prompt: &survey.Input{Message: "Hello world validation"},
Validate: func (val interface{}) error {
// since we are validating an Input, the assertion will always succeed
if str, ok := val.(string) ; !ok || len(str) > 10 {
return errors.New("This response cannot be longer than 10 characters.")
}
return nil
},
}
color := ""
prompt := &survey.Input{ Message: "Whats your name?" }
// you can pass multiple validators here and survey will make sure each one passes
survey.AskOne(prompt, &color, survey.WithValidator(survey.Required))
```
### Built-in Validators
`survey` comes prepackaged with a few validators to fit common situations. Currently these
validators include:
| name | valid types | description | notes |
| ------------ | -------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Required | any | Rejects zero values of the response type | Boolean values pass straight through since the zero value (false) is a valid response |
| MinLength(n) | string | Enforces that a response is at least the given length | |
| MaxLength(n) | string | Enforces that a response is no longer than the given length | |
| MaxItems(n) | []OptionAnswer | Enforces that a response has no more selections of the indicated | |
| MinItems(n) | []OptionAnswer | Enforces that a response has no less selections of the indicated | |
## Help Text
All of the prompts have a `Help` field which can be defined to provide more information to your users:
<img src="https://thumbs.gfycat.com/CloudyRemorsefulFossa-size_restricted.gif" width="400px" style="margin-top: 8px"/>
```golang
&survey.Input{
Message: "What is your phone number:",
Help: "Phone number should include the area code",
}
```
## Removing the "Select All" and "Select None" options
By default, users can select all of the multi-select options using the right arrow key. To prevent users from being able to do this (and remove the `<right> to all` message from the prompt), use the option `WithRemoveSelectAll`:
```golang
import (
"github.com/AlecAivazis/survey/v2"
)
number := ""
prompt := &survey.Input{
Message: "This question has the select all option removed",
}
survey.AskOne(prompt, &number, survey.WithRemoveSelectAll())
```
Also by default, users can use the left arrow key to unselect all of the options. To prevent users from being able to do this (and remove the `<left> to none` message from the prompt), use the option `WithRemoveSelectNone`:
```golang
import (
"github.com/AlecAivazis/survey/v2"
)
number := ""
prompt := &survey.Input{
Message: "This question has the select all option removed",
}
survey.AskOne(prompt, &number, survey.WithRemoveSelectNone())
```
### Changing the input rune
In some situations, `?` is a perfectly valid response. To handle this, you can change the rune that survey
looks for with `WithHelpInput`:
```golang
import (
"github.com/AlecAivazis/survey/v2"
)
number := ""
prompt := &survey.Input{
Message: "If you have this need, please give me a reasonable message.",
Help: "I couldn't come up with one.",
}
survey.AskOne(prompt, &number, survey.WithHelpInput('^'))
```
## Changing the Icons
Changing the icons and their color/format can be done by passing the `WithIcons` option. The format
follows the patterns outlined [here](https://github.com/mgutz/ansi#style-format). For example:
```golang
import (
"github.com/AlecAivazis/survey/v2"
)
number := ""
prompt := &survey.Input{
Message: "If you have this need, please give me a reasonable message.",
Help: "I couldn't come up with one.",
}
survey.AskOne(prompt, &number, survey.WithIcons(func(icons *survey.IconSet) {
// you can set any icons
icons.Question.Text = "⁇"
// for more information on formatting the icons, see here: https://github.com/mgutz/ansi#style-format
icons.Question.Format = "yellow+hb"
}))
```
The icons and their default text and format are summarized below:
| name | text | format | description |
| -------------- | ---- | ---------- | ------------------------------------------------------------- |
| Error | X | red | Before an error |
| Help | i | cyan | Before help text |
| Question | ? | green+hb | Before the message of a prompt |
| SelectFocus | > | green | Marks the current focus in `Select` and `MultiSelect` prompts |
| UnmarkedOption | [ ] | default+hb | Marks an unselected option in a `MultiSelect` prompt |
| MarkedOption | [x] | cyan+b | Marks a chosen selection in a `MultiSelect` prompt |
## Custom Types
survey will assign prompt answers to your custom types if they implement this interface:
```golang
type Settable interface {
WriteAnswer(field string, value interface{}) error
}
```
Here is an example how to use them:
```golang
type MyValue struct {
value string
}
func (my *MyValue) WriteAnswer(name string, value interface{}) error {
my.value = value.(string)
}
myval := MyValue{}
survey.AskOne(
&survey.Input{
Message: "Enter something:",
},
&myval
)
```
## Testing
You can test your program's interactive prompts using [go-expect](https://github.com/Netflix/go-expect). The library
can be used to expect a match on stdout and respond on stdin. Since `os.Stdout` in a `go test` process is not a TTY,
if you are manipulating the cursor or using `survey`, you will need a way to interpret terminal / ANSI escape sequences
for things like `CursorLocation`. `vt10x.NewVT10XConsole` will create a `go-expect` console that also multiplexes
stdio to an in-memory [virtual terminal](https://github.com/hinshun/vt10x).
For some examples, you can see any of the tests in this repo.
## FAQ
### What kinds of IO are supported by `survey`?
survey aims to support most terminal emulators; it expects support for ANSI escape sequences.
This means that reading from piped stdin or writing to piped stdout is **not supported**,
and likely to break your application in these situations. See [#337](https://github.com/AlecAivazis/survey/pull/337#issue-581351617)
### Why isn't Ctrl-C working?
Ordinarily, when you type Ctrl-C, the terminal recognizes this as the QUIT button and delivers a SIGINT signal to the process, which terminates it.
However, Survey temporarily configures the terminal to deliver control codes as ordinary input bytes.
When Survey reads a ^C byte (ASCII \x03, "end of text"), it interrupts the current survey and returns a
`github.com/AlecAivazis/survey/v2/terminal.InterruptErr` from `Ask` or `AskOne`.
If you want to stop the process, handle the returned error in your code:
```go
err := survey.AskOne(prompt, &myVar)
if err != nil {
if err == terminal.InterruptErr {
log.Fatal("interrupted")
}
...
}
```

154
vendor/github.com/AlecAivazis/survey/v2/confirm.go generated vendored Normal file
View File

@ -0,0 +1,154 @@
package survey
import (
"fmt"
"regexp"
)
// Confirm is a regular text input that accept yes/no answers. Response type is a bool.
type Confirm struct {
Renderer
Message string
Default bool
Help string
}
// data available to the templates when processing
type ConfirmTemplateData struct {
Confirm
Answer string
ShowHelp bool
Config *PromptConfig
}
// Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format
var ConfirmQuestionTemplate = `
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
{{- if .Answer}}
{{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}}
{{- else }}
{{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}}
{{- color "white"}}{{if .Default}}(Y/n) {{else}}(y/N) {{end}}{{color "reset"}}
{{- end}}`
// the regex for answers
var (
yesRx = regexp.MustCompile("^(?i:y(?:es)?)$")
noRx = regexp.MustCompile("^(?i:n(?:o)?)$")
)
func yesNo(t bool) string {
if t {
return "Yes"
}
return "No"
}
func (c *Confirm) getBool(showHelp bool, config *PromptConfig) (bool, error) {
cursor := c.NewCursor()
rr := c.NewRuneReader()
_ = rr.SetTermMode()
defer func() {
_ = rr.RestoreTermMode()
}()
// start waiting for input
for {
line, err := rr.ReadLine(0)
if err != nil {
return false, err
}
// move back up a line to compensate for the \n echoed from terminal
cursor.PreviousLine(1)
val := string(line)
// get the answer that matches the
var answer bool
switch {
case yesRx.Match([]byte(val)):
answer = true
case noRx.Match([]byte(val)):
answer = false
case val == "":
answer = c.Default
case val == config.HelpInput && c.Help != "":
err := c.Render(
ConfirmQuestionTemplate,
ConfirmTemplateData{
Confirm: *c,
ShowHelp: true,
Config: config,
},
)
if err != nil {
// use the default value and bubble up
return c.Default, err
}
showHelp = true
continue
default:
// we didnt get a valid answer, so print error and prompt again
//lint:ignore ST1005 it should be fine for this error message to have punctuation
if err := c.Error(config, fmt.Errorf("%q is not a valid answer, please try again.", val)); err != nil {
return c.Default, err
}
err := c.Render(
ConfirmQuestionTemplate,
ConfirmTemplateData{
Confirm: *c,
ShowHelp: showHelp,
Config: config,
},
)
if err != nil {
// use the default value and bubble up
return c.Default, err
}
continue
}
return answer, nil
}
}
/*
Prompt prompts the user with a simple text field and expects a reply followed
by a carriage return.
likesPie := false
prompt := &survey.Confirm{ Message: "What is your name?" }
survey.AskOne(prompt, &likesPie)
*/
func (c *Confirm) Prompt(config *PromptConfig) (interface{}, error) {
// render the question template
err := c.Render(
ConfirmQuestionTemplate,
ConfirmTemplateData{
Confirm: *c,
Config: config,
},
)
if err != nil {
return "", err
}
// get input and return
return c.getBool(false, config)
}
// Cleanup overwrite the line with the finalized formatted version
func (c *Confirm) Cleanup(config *PromptConfig, val interface{}) error {
// if the value was previously true
ans := yesNo(val.(bool))
// render the template
return c.Render(
ConfirmQuestionTemplate,
ConfirmTemplateData{
Confirm: *c,
Answer: ans,
Config: config,
},
)
}

View File

@ -0,0 +1,104 @@
package core
import (
"bytes"
"os"
"sync"
"text/template"
"github.com/mgutz/ansi"
)
// DisableColor can be used to make testing reliable
var DisableColor = false
var TemplateFuncsWithColor = map[string]interface{}{
// Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format
"color": ansi.ColorCode,
}
var TemplateFuncsNoColor = map[string]interface{}{
// Templates without Color formatting. For layout/ testing.
"color": func(color string) string {
return ""
},
}
// envColorDisabled returns if output colors are forbid by environment variables
func envColorDisabled() bool {
return os.Getenv("NO_COLOR") != "" || os.Getenv("CLICOLOR") == "0"
}
// envColorForced returns if output colors are forced from environment variables
func envColorForced() bool {
val, ok := os.LookupEnv("CLICOLOR_FORCE")
return ok && val != "0"
}
// RunTemplate returns two formatted strings given a template and
// the data it requires. The first string returned is generated for
// user-facing output and may or may not contain ANSI escape codes
// for colored output. The second string does not contain escape codes
// and can be used by the renderer for layout purposes.
func RunTemplate(tmpl string, data interface{}) (string, string, error) {
tPair, err := GetTemplatePair(tmpl)
if err != nil {
return "", "", err
}
userBuf := bytes.NewBufferString("")
err = tPair[0].Execute(userBuf, data)
if err != nil {
return "", "", err
}
layoutBuf := bytes.NewBufferString("")
err = tPair[1].Execute(layoutBuf, data)
if err != nil {
return userBuf.String(), "", err
}
return userBuf.String(), layoutBuf.String(), err
}
var (
memoizedGetTemplate = map[string][2]*template.Template{}
memoMutex = &sync.RWMutex{}
)
// GetTemplatePair returns a pair of compiled templates where the
// first template is generated for user-facing output and the
// second is generated for use by the renderer. The second
// template does not contain any color escape codes, whereas
// the first template may or may not depending on DisableColor.
func GetTemplatePair(tmpl string) ([2]*template.Template, error) {
memoMutex.RLock()
if t, ok := memoizedGetTemplate[tmpl]; ok {
memoMutex.RUnlock()
return t, nil
}
memoMutex.RUnlock()
templatePair := [2]*template.Template{nil, nil}
templateNoColor, err := template.New("prompt").Funcs(TemplateFuncsNoColor).Parse(tmpl)
if err != nil {
return [2]*template.Template{}, err
}
templatePair[1] = templateNoColor
envColorHide := envColorDisabled() && !envColorForced()
if DisableColor || envColorHide {
templatePair[0] = templatePair[1]
} else {
templateWithColor, err := template.New("prompt").Funcs(TemplateFuncsWithColor).Parse(tmpl)
templatePair[0] = templateWithColor
if err != nil {
return [2]*template.Template{}, err
}
}
memoMutex.Lock()
memoizedGetTemplate[tmpl] = templatePair
memoMutex.Unlock()
return templatePair, nil
}

376
vendor/github.com/AlecAivazis/survey/v2/core/write.go generated vendored Normal file
View File

@ -0,0 +1,376 @@
package core
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// the tag used to denote the name of the question
const tagName = "survey"
// Settable allow for configuration when assigning answers
type Settable interface {
WriteAnswer(field string, value interface{}) error
}
// OptionAnswer is the return type of Selects/MultiSelects that lets the appropriate information
// get copied to the user's struct
type OptionAnswer struct {
Value string
Index int
}
type reflectField struct {
value reflect.Value
fieldType reflect.StructField
}
func OptionAnswerList(incoming []string) []OptionAnswer {
list := []OptionAnswer{}
for i, opt := range incoming {
list = append(list, OptionAnswer{Value: opt, Index: i})
}
return list
}
func WriteAnswer(t interface{}, name string, v interface{}) (err error) {
// if the field is a custom type
if s, ok := t.(Settable); ok {
// use the interface method
return s.WriteAnswer(name, v)
}
// the target to write to
target := reflect.ValueOf(t)
// the value to write from
value := reflect.ValueOf(v)
// make sure we are writing to a pointer
if target.Kind() != reflect.Ptr {
return errors.New("you must pass a pointer as the target of a Write operation")
}
// the object "inside" of the target pointer
elem := target.Elem()
// handle the special types
switch elem.Kind() {
// if we are writing to a struct
case reflect.Struct:
// if we are writing to an option answer than we want to treat
// it like a single thing and not a place to deposit answers
if elem.Type().Name() == "OptionAnswer" {
// copy the value over to the normal struct
return copy(elem, value)
}
// get the name of the field that matches the string we were given
field, _, err := findField(elem, name)
// if something went wrong
if err != nil {
// bubble up
return err
}
// handle references to the Settable interface aswell
if s, ok := field.Interface().(Settable); ok {
// use the interface method
return s.WriteAnswer(name, v)
}
if field.CanAddr() {
if s, ok := field.Addr().Interface().(Settable); ok {
// use the interface method
return s.WriteAnswer(name, v)
}
}
// copy the value over to the normal struct
return copy(field, value)
case reflect.Map:
mapType := reflect.TypeOf(t).Elem()
if mapType.Key().Kind() != reflect.String {
return errors.New("answer maps key must be of type string")
}
// copy only string value/index value to map if,
// map is not of type interface and is 'OptionAnswer'
if value.Type().Name() == "OptionAnswer" {
if kval := mapType.Elem().Kind(); kval == reflect.String {
mt := *t.(*map[string]string)
mt[name] = value.FieldByName("Value").String()
return nil
} else if kval == reflect.Int {
mt := *t.(*map[string]int)
mt[name] = int(value.FieldByName("Index").Int())
return nil
}
}
if mapType.Elem().Kind() != reflect.Interface {
return errors.New("answer maps must be of type map[string]interface")
}
mt := *t.(*map[string]interface{})
mt[name] = value.Interface()
return nil
}
// otherwise just copy the value to the target
return copy(elem, value)
}
type errFieldNotMatch struct {
questionName string
}
func (err errFieldNotMatch) Error() string {
return fmt.Sprintf("could not find field matching %v", err.questionName)
}
func (err errFieldNotMatch) Is(target error) bool { // implements the dynamic errors.Is interface.
if target != nil {
if name, ok := IsFieldNotMatch(target); ok {
// if have a filled questionName then perform "deeper" comparison.
return name == "" || err.questionName == "" || name == err.questionName
}
}
return false
}
// IsFieldNotMatch reports whether an "err" is caused by a non matching field.
// It returns the Question.Name that couldn't be matched with a destination field.
//
// Usage:
//
// if err := survey.Ask(qs, &v); err != nil {
// if name, ok := core.IsFieldNotMatch(err); ok {
// // name is the question name that did not match a field
// }
// }
func IsFieldNotMatch(err error) (string, bool) {
if err != nil {
if v, ok := err.(errFieldNotMatch); ok {
return v.questionName, true
}
}
return "", false
}
// BUG(AlecAivazis): the current implementation might cause weird conflicts if there are
// two fields with same name that only differ by casing.
func findField(s reflect.Value, name string) (reflect.Value, reflect.StructField, error) {
fields := flattenFields(s)
// first look for matching tags so we can overwrite matching field names
for _, f := range fields {
// the value of the survey tag
tag := f.fieldType.Tag.Get(tagName)
// if the tag matches the name we are looking for
if tag != "" && tag == name {
// then we found our index
return f.value, f.fieldType, nil
}
}
// then look for matching names
for _, f := range fields {
// if the name of the field matches what we're looking for
if strings.EqualFold(f.fieldType.Name, name) {
return f.value, f.fieldType, nil
}
}
// we didn't find the field
return reflect.Value{}, reflect.StructField{}, errFieldNotMatch{name}
}
func flattenFields(s reflect.Value) []reflectField {
sType := s.Type()
numField := sType.NumField()
fields := make([]reflectField, 0, numField)
for i := 0; i < numField; i++ {
fieldType := sType.Field(i)
field := s.Field(i)
if field.Kind() == reflect.Struct && fieldType.Anonymous {
// field is a promoted structure
fields = append(fields, flattenFields(field)...)
continue
}
fields = append(fields, reflectField{field, fieldType})
}
return fields
}
// isList returns true if the element is something we can Len()
func isList(v reflect.Value) bool {
switch v.Type().Kind() {
case reflect.Array, reflect.Slice:
return true
default:
return false
}
}
// Write takes a value and copies it to the target
func copy(t reflect.Value, v reflect.Value) (err error) {
// if something ends up panicing we need to catch it in a deferred func
defer func() {
if r := recover(); r != nil {
// if we paniced with an error
if _, ok := r.(error); ok {
// cast the result to an error object
err = r.(error)
} else if _, ok := r.(string); ok {
// otherwise we could have paniced with a string so wrap it in an error
err = errors.New(r.(string))
}
}
}()
// if we are copying from a string result to something else
if v.Kind() == reflect.String && v.Type() != t.Type() {
var castVal interface{}
var casterr error
vString := v.Interface().(string)
switch t.Kind() {
case reflect.Bool:
castVal, casterr = strconv.ParseBool(vString)
case reflect.Int:
castVal, casterr = strconv.Atoi(vString)
case reflect.Int8:
var val64 int64
val64, casterr = strconv.ParseInt(vString, 10, 8)
if casterr == nil {
castVal = int8(val64)
}
case reflect.Int16:
var val64 int64
val64, casterr = strconv.ParseInt(vString, 10, 16)
if casterr == nil {
castVal = int16(val64)
}
case reflect.Int32:
var val64 int64
val64, casterr = strconv.ParseInt(vString, 10, 32)
if casterr == nil {
castVal = int32(val64)
}
case reflect.Int64:
if t.Type() == reflect.TypeOf(time.Duration(0)) {
castVal, casterr = time.ParseDuration(vString)
} else {
castVal, casterr = strconv.ParseInt(vString, 10, 64)
}
case reflect.Uint:
var val64 uint64
val64, casterr = strconv.ParseUint(vString, 10, 8)
if casterr == nil {
castVal = uint(val64)
}
case reflect.Uint8:
var val64 uint64
val64, casterr = strconv.ParseUint(vString, 10, 8)
if casterr == nil {
castVal = uint8(val64)
}
case reflect.Uint16:
var val64 uint64
val64, casterr = strconv.ParseUint(vString, 10, 16)
if casterr == nil {
castVal = uint16(val64)
}
case reflect.Uint32:
var val64 uint64
val64, casterr = strconv.ParseUint(vString, 10, 32)
if casterr == nil {
castVal = uint32(val64)
}
case reflect.Uint64:
castVal, casterr = strconv.ParseUint(vString, 10, 64)
case reflect.Float32:
var val64 float64
val64, casterr = strconv.ParseFloat(vString, 32)
if casterr == nil {
castVal = float32(val64)
}
case reflect.Float64:
castVal, casterr = strconv.ParseFloat(vString, 64)
default:
//lint:ignore ST1005 allow this error message to be capitalized
return fmt.Errorf("Unable to convert from string to type %s", t.Kind())
}
if casterr != nil {
return casterr
}
t.Set(reflect.ValueOf(castVal))
return
}
// if we are copying from an OptionAnswer to something
if v.Type().Name() == "OptionAnswer" {
// copying an option answer to a string
if t.Kind() == reflect.String {
// copies the Value field of the struct
t.Set(reflect.ValueOf(v.FieldByName("Value").Interface()))
return
}
// copying an option answer to an int
if t.Kind() == reflect.Int {
// copies the Index field of the struct
t.Set(reflect.ValueOf(v.FieldByName("Index").Interface()))
return
}
// copying an OptionAnswer to an OptionAnswer
if t.Type().Name() == "OptionAnswer" {
t.Set(v)
return
}
// we're copying an option answer to an incorrect type
//lint:ignore ST1005 allow this error message to be capitalized
return fmt.Errorf("Unable to convert from OptionAnswer to type %s", t.Kind())
}
// if we are copying from one slice or array to another
if isList(v) && isList(t) {
// loop over every item in the desired value
for i := 0; i < v.Len(); i++ {
// write to the target given its kind
switch t.Kind() {
// if its a slice
case reflect.Slice:
// an object of the correct type
obj := reflect.Indirect(reflect.New(t.Type().Elem()))
// write the appropriate value to the obj and catch any errors
if err := copy(obj, v.Index(i)); err != nil {
return err
}
// just append the value to the end
t.Set(reflect.Append(t, obj))
// otherwise it could be an array
case reflect.Array:
// set the index to the appropriate value
if err := copy(t.Slice(i, i+1).Index(0), v.Index(i)); err != nil {
return err
}
}
}
} else {
// set the value to the target
t.Set(v)
}
// we're done
return
}

226
vendor/github.com/AlecAivazis/survey/v2/editor.go generated vendored Normal file
View File

@ -0,0 +1,226 @@
package survey
import (
"bytes"
"io/ioutil"
"os"
"os/exec"
"runtime"
"github.com/AlecAivazis/survey/v2/terminal"
shellquote "github.com/kballard/go-shellquote"
)
/*
Editor launches an instance of the users preferred editor on a temporary file.
The editor to use is determined by reading the $VISUAL or $EDITOR environment
variables. If neither of those are present, notepad (on Windows) or vim
(others) is used.
The launch of the editor is triggered by the enter key. Since the response may
be long, it will not be echoed as Input does, instead, it print <Received>.
Response type is a string.
message := ""
prompt := &survey.Editor{ Message: "What is your commit message?" }
survey.AskOne(prompt, &message)
*/
type Editor struct {
Renderer
Message string
Default string
Help string
Editor string
HideDefault bool
AppendDefault bool
FileName string
}
// data available to the templates when processing
type EditorTemplateData struct {
Editor
Answer string
ShowAnswer bool
ShowHelp bool
Config *PromptConfig
}
// Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format
var EditorQuestionTemplate = `
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
{{- if .ShowAnswer}}
{{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}}
{{- else }}
{{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}}
{{- if and .Default (not .HideDefault)}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}}
{{- color "cyan"}}[Enter to launch editor] {{color "reset"}}
{{- end}}`
var (
bom = []byte{0xef, 0xbb, 0xbf}
editor = "vim"
)
func init() {
if runtime.GOOS == "windows" {
editor = "notepad"
}
if v := os.Getenv("VISUAL"); v != "" {
editor = v
} else if e := os.Getenv("EDITOR"); e != "" {
editor = e
}
}
func (e *Editor) PromptAgain(config *PromptConfig, invalid interface{}, err error) (interface{}, error) {
initialValue := invalid.(string)
return e.prompt(initialValue, config)
}
func (e *Editor) Prompt(config *PromptConfig) (interface{}, error) {
initialValue := ""
if e.Default != "" && e.AppendDefault {
initialValue = e.Default
}
return e.prompt(initialValue, config)
}
func (e *Editor) prompt(initialValue string, config *PromptConfig) (interface{}, error) {
// render the template
err := e.Render(
EditorQuestionTemplate,
EditorTemplateData{
Editor: *e,
Config: config,
},
)
if err != nil {
return "", err
}
// start reading runes from the standard in
rr := e.NewRuneReader()
_ = rr.SetTermMode()
defer func() {
_ = rr.RestoreTermMode()
}()
cursor := e.NewCursor()
cursor.Hide()
defer cursor.Show()
for {
r, _, err := rr.ReadRune()
if err != nil {
return "", err
}
if r == '\r' || r == '\n' {
break
}
if r == terminal.KeyInterrupt {
return "", terminal.InterruptErr
}
if r == terminal.KeyEndTransmission {
break
}
if string(r) == config.HelpInput && e.Help != "" {
err = e.Render(
EditorQuestionTemplate,
EditorTemplateData{
Editor: *e,
ShowHelp: true,
Config: config,
},
)
if err != nil {
return "", err
}
}
continue
}
// prepare the temp file
pattern := e.FileName
if pattern == "" {
pattern = "survey*.txt"
}
f, err := ioutil.TempFile("", pattern)
if err != nil {
return "", err
}
defer func() {
_ = os.Remove(f.Name())
}()
// write utf8 BOM header
// The reason why we do this is because notepad.exe on Windows determines the
// encoding of an "empty" text file by the locale, for example, GBK in China,
// while golang string only handles utf8 well. However, a text file with utf8
// BOM header is not considered "empty" on Windows, and the encoding will then
// be determined utf8 by notepad.exe, instead of GBK or other encodings.
if _, err := f.Write(bom); err != nil {
return "", err
}
// write initial value
if _, err := f.WriteString(initialValue); err != nil {
return "", err
}
// close the fd to prevent the editor unable to save file
if err := f.Close(); err != nil {
return "", err
}
// check is input editor exist
if e.Editor != "" {
editor = e.Editor
}
stdio := e.Stdio()
args, err := shellquote.Split(editor)
if err != nil {
return "", err
}
args = append(args, f.Name())
// open the editor
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = stdio.In
cmd.Stdout = stdio.Out
cmd.Stderr = stdio.Err
cursor.Show()
if err := cmd.Run(); err != nil {
return "", err
}
// raw is a BOM-unstripped UTF8 byte slice
raw, err := ioutil.ReadFile(f.Name())
if err != nil {
return "", err
}
// strip BOM header
text := string(bytes.TrimPrefix(raw, bom))
// check length, return default value on empty
if len(text) == 0 && !e.AppendDefault {
return e.Default, nil
}
return text, nil
}
func (e *Editor) Cleanup(config *PromptConfig, val interface{}) error {
return e.Render(
EditorQuestionTemplate,
EditorTemplateData{
Editor: *e,
Answer: "<Received>",
ShowAnswer: true,
Config: config,
},
)
}

1
vendor/github.com/AlecAivazis/survey/v2/filter.go generated vendored Normal file
View File

@ -0,0 +1 @@
package survey

219
vendor/github.com/AlecAivazis/survey/v2/input.go generated vendored Normal file
View File

@ -0,0 +1,219 @@
package survey
import (
"errors"
"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
)
/*
Input is a regular text input that prints each character the user types on the screen
and accepts the input with the enter key. Response type is a string.
name := ""
prompt := &survey.Input{ Message: "What is your name?" }
survey.AskOne(prompt, &name)
*/
type Input struct {
Renderer
Message string
Default string
Help string
Suggest func(toComplete string) []string
answer string
typedAnswer string
options []core.OptionAnswer
selectedIndex int
showingHelp bool
}
// data available to the templates when processing
type InputTemplateData struct {
Input
ShowAnswer bool
ShowHelp bool
Answer string
PageEntries []core.OptionAnswer
SelectedIndex int
Config *PromptConfig
}
// Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format
var InputQuestionTemplate = `
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
{{- if .ShowAnswer}}
{{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}}
{{- else if .PageEntries -}}
{{- .Answer}} [Use arrows to move, enter to select, type to continue]
{{- "\n"}}
{{- range $ix, $choice := .PageEntries}}
{{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
{{- $choice.Value}}
{{- color "reset"}}{{"\n"}}
{{- end}}
{{- else }}
{{- if or (and .Help (not .ShowHelp)) .Suggest }}{{color "cyan"}}[
{{- if and .Help (not .ShowHelp)}}{{ print .Config.HelpInput }} for help {{- if and .Suggest}}, {{end}}{{end -}}
{{- if and .Suggest }}{{color "cyan"}}{{ print .Config.SuggestInput }} for suggestions{{end -}}
]{{color "reset"}} {{end}}
{{- if .Default}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}}
{{- end}}`
func (i *Input) onRune(config *PromptConfig) terminal.OnRuneFn {
return terminal.OnRuneFn(func(key rune, line []rune) ([]rune, bool, error) {
if i.options != nil && (key == terminal.KeyEnter || key == '\n') {
return []rune(i.answer), true, nil
} else if i.options != nil && key == terminal.KeyEscape {
i.answer = i.typedAnswer
i.options = nil
} else if key == terminal.KeyArrowUp && len(i.options) > 0 {
if i.selectedIndex == 0 {
i.selectedIndex = len(i.options) - 1
} else {
i.selectedIndex--
}
i.answer = i.options[i.selectedIndex].Value
} else if (key == terminal.KeyArrowDown || key == terminal.KeyTab) && len(i.options) > 0 {
if i.selectedIndex == len(i.options)-1 {
i.selectedIndex = 0
} else {
i.selectedIndex++
}
i.answer = i.options[i.selectedIndex].Value
} else if key == terminal.KeyTab && i.Suggest != nil {
i.answer = string(line)
i.typedAnswer = i.answer
options := i.Suggest(i.answer)
i.selectedIndex = 0
if len(options) == 0 {
return line, false, nil
}
i.answer = options[0]
if len(options) == 1 {
i.typedAnswer = i.answer
i.options = nil
} else {
i.options = core.OptionAnswerList(options)
}
} else {
if i.options == nil {
return line, false, nil
}
if key >= terminal.KeySpace {
i.answer += string(key)
}
i.typedAnswer = i.answer
i.options = nil
}
pageSize := config.PageSize
opts, idx := paginate(pageSize, i.options, i.selectedIndex)
err := i.Render(
InputQuestionTemplate,
InputTemplateData{
Input: *i,
Answer: i.answer,
ShowHelp: i.showingHelp,
SelectedIndex: idx,
PageEntries: opts,
Config: config,
},
)
if err == nil {
err = errReadLineAgain
}
return []rune(i.typedAnswer), true, err
})
}
var errReadLineAgain = errors.New("read line again")
func (i *Input) Prompt(config *PromptConfig) (interface{}, error) {
// render the template
err := i.Render(
InputQuestionTemplate,
InputTemplateData{
Input: *i,
Config: config,
ShowHelp: i.showingHelp,
},
)
if err != nil {
return "", err
}
// start reading runes from the standard in
rr := i.NewRuneReader()
_ = rr.SetTermMode()
defer func() {
_ = rr.RestoreTermMode()
}()
cursor := i.NewCursor()
if !config.ShowCursor {
cursor.Hide() // hide the cursor
defer cursor.Show() // show the cursor when we're done
}
var line []rune
for {
if i.options != nil {
line = []rune{}
}
line, err = rr.ReadLineWithDefault(0, line, i.onRune(config))
if err == errReadLineAgain {
continue
}
if err != nil {
return "", err
}
break
}
i.answer = string(line)
// readline print an empty line, go up before we render the follow up
cursor.Up(1)
// if we ran into the help string
if i.answer == config.HelpInput && i.Help != "" {
// show the help and prompt again
i.showingHelp = true
return i.Prompt(config)
}
// if the line is empty
if len(i.answer) == 0 {
// use the default value
return i.Default, err
}
lineStr := i.answer
i.AppendRenderedText(lineStr)
// we're done
return lineStr, err
}
func (i *Input) Cleanup(config *PromptConfig, val interface{}) error {
return i.Render(
InputQuestionTemplate,
InputTemplateData{
Input: *i,
ShowAnswer: true,
Config: config,
Answer: val.(string),
},
)
}

112
vendor/github.com/AlecAivazis/survey/v2/multiline.go generated vendored Normal file
View File

@ -0,0 +1,112 @@
package survey
import (
"strings"
"github.com/AlecAivazis/survey/v2/terminal"
)
type Multiline struct {
Renderer
Message string
Default string
Help string
}
// data available to the templates when processing
type MultilineTemplateData struct {
Multiline
Answer string
ShowAnswer bool
ShowHelp bool
Config *PromptConfig
}
// Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format
var MultilineQuestionTemplate = `
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
{{- if .ShowAnswer}}
{{- "\n"}}{{color "cyan"}}{{.Answer}}{{color "reset"}}
{{- if .Answer }}{{ "\n" }}{{ end }}
{{- else }}
{{- if .Default}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}}
{{- color "cyan"}}[Enter 2 empty lines to finish]{{color "reset"}}
{{- end}}`
func (i *Multiline) Prompt(config *PromptConfig) (interface{}, error) {
// render the template
err := i.Render(
MultilineQuestionTemplate,
MultilineTemplateData{
Multiline: *i,
Config: config,
},
)
if err != nil {
return "", err
}
// start reading runes from the standard in
rr := i.NewRuneReader()
_ = rr.SetTermMode()
defer func() {
_ = rr.RestoreTermMode()
}()
cursor := i.NewCursor()
multiline := make([]string, 0)
emptyOnce := false
// get the next line
for {
var line []rune
line, err = rr.ReadLine(0)
if err != nil {
return string(line), err
}
if string(line) == "" {
if emptyOnce {
numLines := len(multiline) + 2
cursor.PreviousLine(numLines)
for j := 0; j < numLines; j++ {
terminal.EraseLine(i.Stdio().Out, terminal.ERASE_LINE_ALL)
cursor.NextLine(1)
}
cursor.PreviousLine(numLines)
break
}
emptyOnce = true
} else {
emptyOnce = false
}
multiline = append(multiline, string(line))
}
val := strings.Join(multiline, "\n")
val = strings.TrimSpace(val)
// if the line is empty
if len(val) == 0 {
// use the default value
return i.Default, err
}
i.AppendRenderedText(val)
return val, err
}
func (i *Multiline) Cleanup(config *PromptConfig, val interface{}) error {
return i.Render(
MultilineQuestionTemplate,
MultilineTemplateData{
Multiline: *i,
Answer: val.(string),
ShowAnswer: true,
Config: config,
},
)
}

360
vendor/github.com/AlecAivazis/survey/v2/multiselect.go generated vendored Normal file
View File

@ -0,0 +1,360 @@
package survey
import (
"errors"
"fmt"
"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
)
/*
MultiSelect is a prompt that presents a list of various options to the user
for them to select using the arrow keys and enter. Response type is a slice of strings.
days := []string{}
prompt := &survey.MultiSelect{
Message: "What days do you prefer:",
Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
}
survey.AskOne(prompt, &days)
*/
type MultiSelect struct {
Renderer
Message string
Options []string
Default interface{}
Help string
PageSize int
VimMode bool
FilterMessage string
Filter func(filter string, value string, index int) bool
Description func(value string, index int) string
filter string
selectedIndex int
checked map[int]bool
showingHelp bool
}
// data available to the templates when processing
type MultiSelectTemplateData struct {
MultiSelect
Answer string
ShowAnswer bool
Checked map[int]bool
SelectedIndex int
ShowHelp bool
Description func(value string, index int) string
PageEntries []core.OptionAnswer
Config *PromptConfig
// These fields are used when rendering an individual option
CurrentOpt core.OptionAnswer
CurrentIndex int
}
// IterateOption sets CurrentOpt and CurrentIndex appropriately so a multiselect option can be rendered individually
func (m MultiSelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} {
copy := m
copy.CurrentIndex = ix
copy.CurrentOpt = opt
return copy
}
func (m MultiSelectTemplateData) GetDescription(opt core.OptionAnswer) string {
if m.Description == nil {
return ""
}
return m.Description(opt.Value, opt.Index)
}
var MultiSelectQuestionTemplate = `
{{- define "option"}}
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
{{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}}
{{- color "reset"}}
{{- " "}}{{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{color "reset"}}{{end}}
{{end}}
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}}
{{- else }}
{{- " "}}{{- color "cyan"}}[Use arrows to move, space to select,{{- if not .Config.RemoveSelectAll }} <right> to all,{{end}}{{- if not .Config.RemoveSelectNone }} <left> to none,{{end}} type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
{{- "\n"}}
{{- range $ix, $option := .PageEntries}}
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end}}`
// OnChange is called on every keypress.
func (m *MultiSelect) OnChange(key rune, config *PromptConfig) {
options := m.filterOptions(config)
oldFilter := m.filter
if key == terminal.KeyArrowUp || (m.VimMode && key == 'k') {
// if we are at the top of the list
if m.selectedIndex == 0 {
// go to the bottom
m.selectedIndex = len(options) - 1
} else {
// decrement the selected index
m.selectedIndex--
}
} else if key == terminal.KeyTab || key == terminal.KeyArrowDown || (m.VimMode && key == 'j') {
// if we are at the bottom of the list
if m.selectedIndex == len(options)-1 {
// start at the top
m.selectedIndex = 0
} else {
// increment the selected index
m.selectedIndex++
}
// if the user pressed down and there is room to move
} else if key == terminal.KeySpace {
// the option they have selected
if m.selectedIndex < len(options) {
selectedOpt := options[m.selectedIndex]
// if we haven't seen this index before
if old, ok := m.checked[selectedOpt.Index]; !ok {
// set the value to true
m.checked[selectedOpt.Index] = true
} else {
// otherwise just invert the current value
m.checked[selectedOpt.Index] = !old
}
if !config.KeepFilter {
m.filter = ""
}
}
// only show the help message if we have one to show
} else if string(key) == config.HelpInput && m.Help != "" {
m.showingHelp = true
} else if key == terminal.KeyEscape {
m.VimMode = !m.VimMode
} else if key == terminal.KeyDeleteWord || key == terminal.KeyDeleteLine {
m.filter = ""
} else if key == terminal.KeyDelete || key == terminal.KeyBackspace {
if m.filter != "" {
runeFilter := []rune(m.filter)
m.filter = string(runeFilter[0 : len(runeFilter)-1])
}
} else if key >= terminal.KeySpace {
m.filter += string(key)
m.VimMode = false
} else if !config.RemoveSelectAll && key == terminal.KeyArrowRight {
for _, v := range options {
m.checked[v.Index] = true
}
if !config.KeepFilter {
m.filter = ""
}
} else if !config.RemoveSelectNone && key == terminal.KeyArrowLeft {
for _, v := range options {
m.checked[v.Index] = false
}
if !config.KeepFilter {
m.filter = ""
}
}
m.FilterMessage = ""
if m.filter != "" {
m.FilterMessage = " " + m.filter
}
if oldFilter != m.filter {
// filter changed
options = m.filterOptions(config)
if len(options) > 0 && len(options) <= m.selectedIndex {
m.selectedIndex = len(options) - 1
}
}
// paginate the options
// figure out the page size
pageSize := m.PageSize
// if we dont have a specific one
if pageSize == 0 {
// grab the global value
pageSize = config.PageSize
}
// TODO if we have started filtering and were looking at the end of a list
// and we have modified the filter then we should move the page back!
opts, idx := paginate(pageSize, options, m.selectedIndex)
tmplData := MultiSelectTemplateData{
MultiSelect: *m,
SelectedIndex: idx,
Checked: m.checked,
ShowHelp: m.showingHelp,
Description: m.Description,
PageEntries: opts,
Config: config,
}
// render the options
_ = m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx)
}
func (m *MultiSelect) filterOptions(config *PromptConfig) []core.OptionAnswer {
// the filtered list
answers := []core.OptionAnswer{}
// if there is no filter applied
if m.filter == "" {
// return all of the options
return core.OptionAnswerList(m.Options)
}
// the filter to apply
filter := m.Filter
if filter == nil {
filter = config.Filter
}
// apply the filter to each option
for i, opt := range m.Options {
// i the filter says to include the option
if filter(m.filter, opt, i) {
answers = append(answers, core.OptionAnswer{
Index: i,
Value: opt,
})
}
}
// we're done here
return answers
}
func (m *MultiSelect) Prompt(config *PromptConfig) (interface{}, error) {
// compute the default state
m.checked = make(map[int]bool)
// if there is a default
if m.Default != nil {
// if the default is string values
if defaultValues, ok := m.Default.([]string); ok {
for _, dflt := range defaultValues {
for i, opt := range m.Options {
// if the option corresponds to the default
if opt == dflt {
// we found our initial value
m.checked[i] = true
// stop looking
break
}
}
}
// if the default value is index values
} else if defaultIndices, ok := m.Default.([]int); ok {
// go over every index we need to enable by default
for _, idx := range defaultIndices {
// and enable it
m.checked[idx] = true
}
}
}
// if there are no options to render
if len(m.Options) == 0 {
// we failed
return "", errors.New("please provide options to select from")
}
// figure out the page size
pageSize := m.PageSize
// if we dont have a specific one
if pageSize == 0 {
// grab the global value
pageSize = config.PageSize
}
// paginate the options
// build up a list of option answers
opts, idx := paginate(pageSize, core.OptionAnswerList(m.Options), m.selectedIndex)
cursor := m.NewCursor()
cursor.Save() // for proper cursor placement during selection
cursor.Hide() // hide the cursor
defer cursor.Show() // show the cursor when we're done
defer cursor.Restore() // clear any accessibility offsetting on exit
tmplData := MultiSelectTemplateData{
MultiSelect: *m,
SelectedIndex: idx,
Description: m.Description,
Checked: m.checked,
PageEntries: opts,
Config: config,
}
// ask the question
err := m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx)
if err != nil {
return "", err
}
rr := m.NewRuneReader()
_ = rr.SetTermMode()
defer func() {
_ = rr.RestoreTermMode()
}()
// start waiting for input
for {
r, _, err := rr.ReadRune()
if err != nil {
return "", err
}
if r == '\r' || r == '\n' {
break
}
if r == terminal.KeyInterrupt {
return "", terminal.InterruptErr
}
if r == terminal.KeyEndTransmission {
break
}
m.OnChange(r, config)
}
m.filter = ""
m.FilterMessage = ""
answers := []core.OptionAnswer{}
for i, option := range m.Options {
if val, ok := m.checked[i]; ok && val {
answers = append(answers, core.OptionAnswer{Value: option, Index: i})
}
}
return answers, nil
}
// Cleanup removes the options section, and renders the ask like a normal question.
func (m *MultiSelect) Cleanup(config *PromptConfig, val interface{}) error {
// the answer to show
answer := ""
for _, ans := range val.([]core.OptionAnswer) {
answer = fmt.Sprintf("%s, %s", answer, ans.Value)
}
// if we answered anything
if len(answer) > 2 {
// remove the precending commas
answer = answer[2:]
}
// execute the output summary template with the answer
return m.Render(
MultiSelectQuestionTemplate,
MultiSelectTemplateData{
MultiSelect: *m,
SelectedIndex: m.selectedIndex,
Checked: m.checked,
Answer: answer,
ShowAnswer: true,
Description: m.Description,
Config: config,
},
)
}

106
vendor/github.com/AlecAivazis/survey/v2/password.go generated vendored Normal file
View File

@ -0,0 +1,106 @@
package survey
import (
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
)
/*
Password is like a normal Input but the text shows up as *'s and there is no default. Response
type is a string.
password := ""
prompt := &survey.Password{ Message: "Please type your password" }
survey.AskOne(prompt, &password)
*/
type Password struct {
Renderer
Message string
Help string
}
type PasswordTemplateData struct {
Password
ShowHelp bool
Config *PromptConfig
}
// PasswordQuestionTemplate is a template with color formatting. See Documentation: https://github.com/mgutz/ansi#style-format
var PasswordQuestionTemplate = `
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
{{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}}`
func (p *Password) Prompt(config *PromptConfig) (interface{}, error) {
// render the question template
userOut, _, err := core.RunTemplate(
PasswordQuestionTemplate,
PasswordTemplateData{
Password: *p,
Config: config,
},
)
if err != nil {
return "", err
}
if _, err := fmt.Fprint(terminal.NewAnsiStdout(p.Stdio().Out), userOut); err != nil {
return "", err
}
rr := p.NewRuneReader()
_ = rr.SetTermMode()
defer func() {
_ = rr.RestoreTermMode()
}()
// no help msg? Just return any response
if p.Help == "" {
line, err := rr.ReadLine(config.HideCharacter)
return string(line), err
}
cursor := p.NewCursor()
var line []rune
// process answers looking for help prompt answer
for {
line, err = rr.ReadLine(config.HideCharacter)
if err != nil {
return string(line), err
}
if string(line) == config.HelpInput {
// terminal will echo the \n so we need to jump back up one row
cursor.PreviousLine(1)
err = p.Render(
PasswordQuestionTemplate,
PasswordTemplateData{
Password: *p,
ShowHelp: true,
Config: config,
},
)
if err != nil {
return "", err
}
continue
}
break
}
lineStr := string(line)
p.AppendRenderedText(strings.Repeat(string(config.HideCharacter), len(lineStr)))
return lineStr, err
}
// Cleanup hides the string with a fixed number of characters.
func (prompt *Password) Cleanup(config *PromptConfig, val interface{}) error {
return nil
}

195
vendor/github.com/AlecAivazis/survey/v2/renderer.go generated vendored Normal file
View File

@ -0,0 +1,195 @@
package survey
import (
"bytes"
"fmt"
"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
"golang.org/x/term"
)
type Renderer struct {
stdio terminal.Stdio
renderedErrors bytes.Buffer
renderedText bytes.Buffer
}
type ErrorTemplateData struct {
Error error
Icon Icon
}
var ErrorTemplate = `{{color .Icon.Format }}{{ .Icon.Text }} Sorry, your reply was invalid: {{ .Error.Error }}{{color "reset"}}
`
func (r *Renderer) WithStdio(stdio terminal.Stdio) {
r.stdio = stdio
}
func (r *Renderer) Stdio() terminal.Stdio {
return r.stdio
}
func (r *Renderer) NewRuneReader() *terminal.RuneReader {
return terminal.NewRuneReader(r.stdio)
}
func (r *Renderer) NewCursor() *terminal.Cursor {
return &terminal.Cursor{
In: r.stdio.In,
Out: r.stdio.Out,
}
}
func (r *Renderer) Error(config *PromptConfig, invalid error) error {
// cleanup the currently rendered errors
r.resetPrompt(r.countLines(r.renderedErrors))
r.renderedErrors.Reset()
// cleanup the rest of the prompt
r.resetPrompt(r.countLines(r.renderedText))
r.renderedText.Reset()
userOut, layoutOut, err := core.RunTemplate(ErrorTemplate, &ErrorTemplateData{
Error: invalid,
Icon: config.Icons.Error,
})
if err != nil {
return err
}
// send the message to the user
if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil {
return err
}
// add the printed text to the rendered error buffer so we can cleanup later
r.appendRenderedError(layoutOut)
return nil
}
func (r *Renderer) OffsetCursor(offset int) {
cursor := r.NewCursor()
for offset > 0 {
cursor.PreviousLine(1)
offset--
}
}
func (r *Renderer) Render(tmpl string, data interface{}) error {
// cleanup the currently rendered text
lineCount := r.countLines(r.renderedText)
r.resetPrompt(lineCount)
r.renderedText.Reset()
// render the template summarizing the current state
userOut, layoutOut, err := core.RunTemplate(tmpl, data)
if err != nil {
return err
}
// print the summary
if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil {
return err
}
// add the printed text to the rendered text buffer so we can cleanup later
r.AppendRenderedText(layoutOut)
// nothing went wrong
return nil
}
func (r *Renderer) RenderWithCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswer, idx int) error {
cursor := r.NewCursor()
cursor.Restore() // clear any accessibility offsetting
if err := r.Render(tmpl, data); err != nil {
return err
}
cursor.Save()
offset := computeCursorOffset(MultiSelectQuestionTemplate, data, opts, idx, r.termWidthSafe())
r.OffsetCursor(offset)
return nil
}
// appendRenderedError appends text to the renderer's error buffer
// which is used to track what has been printed. It is not exported
// as errors should only be displayed via Error(config, error).
func (r *Renderer) appendRenderedError(text string) {
r.renderedErrors.WriteString(text)
}
// AppendRenderedText appends text to the renderer's text buffer
// which is used to track of what has been printed. The buffer is used
// to calculate how many lines to erase before updating the prompt.
func (r *Renderer) AppendRenderedText(text string) {
r.renderedText.WriteString(text)
}
func (r *Renderer) resetPrompt(lines int) {
// clean out current line in case tmpl didnt end in newline
cursor := r.NewCursor()
cursor.HorizontalAbsolute(0)
terminal.EraseLine(r.stdio.Out, terminal.ERASE_LINE_ALL)
// clean up what we left behind last time
for i := 0; i < lines; i++ {
cursor.PreviousLine(1)
terminal.EraseLine(r.stdio.Out, terminal.ERASE_LINE_ALL)
}
}
func (r *Renderer) termWidth() (int, error) {
fd := int(r.stdio.Out.Fd())
termWidth, _, err := term.GetSize(fd)
return termWidth, err
}
func (r *Renderer) termWidthSafe() int {
w, err := r.termWidth()
if err != nil || w == 0 {
// if we got an error due to terminal.GetSize not being supported
// on current platform then just assume a very wide terminal
w = 10000
}
return w
}
// countLines will return the count of `\n` with the addition of any
// lines that have wrapped due to narrow terminal width
func (r *Renderer) countLines(buf bytes.Buffer) int {
w := r.termWidthSafe()
bufBytes := buf.Bytes()
count := 0
curr := 0
for curr < len(bufBytes) {
var delim int
// read until the next newline or the end of the string
relDelim := bytes.IndexRune(bufBytes[curr:], '\n')
if relDelim != -1 {
count += 1 // new line found, add it to the count
delim = curr + relDelim
} else {
delim = len(bufBytes) // no new line found, read rest of text
}
str := string(bufBytes[curr:delim])
if lineWidth := terminal.StringWidth(str); lineWidth > w {
// account for word wrapping
count += lineWidth / w
if (lineWidth % w) == 0 {
// content whose width is exactly a multiplier of available width should not
// count as having wrapped on the last line
count -= 1
}
}
curr = delim + 1
}
return count
}

329
vendor/github.com/AlecAivazis/survey/v2/select.go generated vendored Normal file
View File

@ -0,0 +1,329 @@
package survey
import (
"errors"
"fmt"
"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
)
/*
Select is a prompt that presents a list of various options to the user
for them to select using the arrow keys and enter. Response type is a string.
color := ""
prompt := &survey.Select{
Message: "Choose a color:",
Options: []string{"red", "blue", "green"},
}
survey.AskOne(prompt, &color)
*/
type Select struct {
Renderer
Message string
Options []string
Default interface{}
Help string
PageSize int
VimMode bool
FilterMessage string
Filter func(filter string, value string, index int) bool
Description func(value string, index int) string
filter string
selectedIndex int
showingHelp bool
}
// SelectTemplateData is the data available to the templates when processing
type SelectTemplateData struct {
Select
PageEntries []core.OptionAnswer
SelectedIndex int
Answer string
ShowAnswer bool
ShowHelp bool
Description func(value string, index int) string
Config *PromptConfig
// These fields are used when rendering an individual option
CurrentOpt core.OptionAnswer
CurrentIndex int
}
// IterateOption sets CurrentOpt and CurrentIndex appropriately so a select option can be rendered individually
func (s SelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} {
copy := s
copy.CurrentIndex = ix
copy.CurrentOpt = opt
return copy
}
func (s SelectTemplateData) GetDescription(opt core.OptionAnswer) string {
if s.Description == nil {
return ""
}
return s.Description(opt.Value, opt.Index)
}
var SelectQuestionTemplate = `
{{- define "option"}}
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
{{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{end}}
{{- color "reset"}}
{{end}}
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}}
{{- else}}
{{- " "}}{{- color "cyan"}}[Use arrows to move, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
{{- "\n"}}
{{- range $ix, $option := .PageEntries}}
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end}}`
// OnChange is called on every keypress.
func (s *Select) OnChange(key rune, config *PromptConfig) bool {
options := s.filterOptions(config)
oldFilter := s.filter
// if the user pressed the enter key and the index is a valid option
if key == terminal.KeyEnter || key == '\n' {
// if the selected index is a valid option
if len(options) > 0 && s.selectedIndex < len(options) {
// we're done (stop prompting the user)
return true
}
// we're not done (keep prompting)
return false
// if the user pressed the up arrow or 'k' to emulate vim
} else if (key == terminal.KeyArrowUp || (s.VimMode && key == 'k')) && len(options) > 0 {
// if we are at the top of the list
if s.selectedIndex == 0 {
// start from the button
s.selectedIndex = len(options) - 1
} else {
// otherwise we are not at the top of the list so decrement the selected index
s.selectedIndex--
}
// if the user pressed down or 'j' to emulate vim
} else if (key == terminal.KeyTab || key == terminal.KeyArrowDown || (s.VimMode && key == 'j')) && len(options) > 0 {
// if we are at the bottom of the list
if s.selectedIndex == len(options)-1 {
// start from the top
s.selectedIndex = 0
} else {
// increment the selected index
s.selectedIndex++
}
// only show the help message if we have one
} else if string(key) == config.HelpInput && s.Help != "" {
s.showingHelp = true
// if the user wants to toggle vim mode on/off
} else if key == terminal.KeyEscape {
s.VimMode = !s.VimMode
// if the user hits any of the keys that clear the filter
} else if key == terminal.KeyDeleteWord || key == terminal.KeyDeleteLine {
s.filter = ""
// if the user is deleting a character in the filter
} else if key == terminal.KeyDelete || key == terminal.KeyBackspace {
// if there is content in the filter to delete
if s.filter != "" {
runeFilter := []rune(s.filter)
// subtract a line from the current filter
s.filter = string(runeFilter[0 : len(runeFilter)-1])
// we removed the last value in the filter
}
} else if key >= terminal.KeySpace {
s.filter += string(key)
// make sure vim mode is disabled
s.VimMode = false
}
s.FilterMessage = ""
if s.filter != "" {
s.FilterMessage = " " + s.filter
}
if oldFilter != s.filter {
// filter changed
options = s.filterOptions(config)
if len(options) > 0 && len(options) <= s.selectedIndex {
s.selectedIndex = len(options) - 1
}
}
// figure out the options and index to render
// figure out the page size
pageSize := s.PageSize
// if we dont have a specific one
if pageSize == 0 {
// grab the global value
pageSize = config.PageSize
}
// TODO if we have started filtering and were looking at the end of a list
// and we have modified the filter then we should move the page back!
opts, idx := paginate(pageSize, options, s.selectedIndex)
tmplData := SelectTemplateData{
Select: *s,
SelectedIndex: idx,
ShowHelp: s.showingHelp,
Description: s.Description,
PageEntries: opts,
Config: config,
}
// render the options
_ = s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx)
// keep prompting
return false
}
func (s *Select) filterOptions(config *PromptConfig) []core.OptionAnswer {
// the filtered list
answers := []core.OptionAnswer{}
// if there is no filter applied
if s.filter == "" {
return core.OptionAnswerList(s.Options)
}
// the filter to apply
filter := s.Filter
if filter == nil {
filter = config.Filter
}
for i, opt := range s.Options {
// i the filter says to include the option
if filter(s.filter, opt, i) {
answers = append(answers, core.OptionAnswer{
Index: i,
Value: opt,
})
}
}
// return the list of answers
return answers
}
func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
// if there are no options to render
if len(s.Options) == 0 {
// we failed
return "", errors.New("please provide options to select from")
}
s.selectedIndex = 0
if s.Default != nil {
switch defaultValue := s.Default.(type) {
case string:
var found bool
for i, opt := range s.Options {
if opt == defaultValue {
s.selectedIndex = i
found = true
}
}
if !found {
return "", fmt.Errorf("default value %q not found in options", defaultValue)
}
case int:
if defaultValue >= len(s.Options) {
return "", fmt.Errorf("default index %d exceeds the number of options", defaultValue)
}
s.selectedIndex = defaultValue
default:
return "", errors.New("default value of select must be an int or string")
}
}
// figure out the page size
pageSize := s.PageSize
// if we dont have a specific one
if pageSize == 0 {
// grab the global value
pageSize = config.PageSize
}
// figure out the options and index to render
opts, idx := paginate(pageSize, core.OptionAnswerList(s.Options), s.selectedIndex)
cursor := s.NewCursor()
cursor.Save() // for proper cursor placement during selection
cursor.Hide() // hide the cursor
defer cursor.Show() // show the cursor when we're done
defer cursor.Restore() // clear any accessibility offsetting on exit
tmplData := SelectTemplateData{
Select: *s,
SelectedIndex: idx,
Description: s.Description,
ShowHelp: s.showingHelp,
PageEntries: opts,
Config: config,
}
// ask the question
err := s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx)
if err != nil {
return "", err
}
rr := s.NewRuneReader()
_ = rr.SetTermMode()
defer func() {
_ = rr.RestoreTermMode()
}()
// start waiting for input
for {
r, _, err := rr.ReadRune()
if err != nil {
return "", err
}
if r == terminal.KeyInterrupt {
return "", terminal.InterruptErr
}
if r == terminal.KeyEndTransmission {
break
}
if s.OnChange(r, config) {
break
}
}
options := s.filterOptions(config)
s.filter = ""
s.FilterMessage = ""
if s.selectedIndex < len(options) {
return options[s.selectedIndex], err
}
return options[0], err
}
func (s *Select) Cleanup(config *PromptConfig, val interface{}) error {
cursor := s.NewCursor()
cursor.Restore()
return s.Render(
SelectQuestionTemplate,
SelectTemplateData{
Select: *s,
Answer: val.(core.OptionAnswer).Value,
ShowAnswer: true,
Description: s.Description,
Config: config,
},
)
}

474
vendor/github.com/AlecAivazis/survey/v2/survey.go generated vendored Normal file
View File

@ -0,0 +1,474 @@
package survey
import (
"bytes"
"errors"
"io"
"os"
"strings"
"unicode/utf8"
"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
)
// DefaultAskOptions is the default options on ask, using the OS stdio.
func defaultAskOptions() *AskOptions {
return &AskOptions{
Stdio: terminal.Stdio{
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
},
PromptConfig: PromptConfig{
PageSize: 7,
HelpInput: "?",
SuggestInput: "tab",
Icons: IconSet{
Error: Icon{
Text: "X",
Format: "red",
},
Help: Icon{
Text: "?",
Format: "cyan",
},
Question: Icon{
Text: "?",
Format: "green+hb",
},
MarkedOption: Icon{
Text: "[x]",
Format: "green",
},
UnmarkedOption: Icon{
Text: "[ ]",
Format: "default+hb",
},
SelectFocus: Icon{
Text: ">",
Format: "cyan+b",
},
},
Filter: func(filter string, value string, index int) (include bool) {
filter = strings.ToLower(filter)
// include this option if it matches
return strings.Contains(strings.ToLower(value), filter)
},
KeepFilter: false,
ShowCursor: false,
RemoveSelectAll: false,
RemoveSelectNone: false,
HideCharacter: '*',
},
}
}
func defaultPromptConfig() *PromptConfig {
return &defaultAskOptions().PromptConfig
}
func defaultIcons() *IconSet {
return &defaultPromptConfig().Icons
}
// OptionAnswer is an ergonomic alias for core.OptionAnswer
type OptionAnswer = core.OptionAnswer
// Icon holds the text and format to show for a particular icon
type Icon struct {
Text string
Format string
}
// IconSet holds the icons to use for various prompts
type IconSet struct {
HelpInput Icon
Error Icon
Help Icon
Question Icon
MarkedOption Icon
UnmarkedOption Icon
SelectFocus Icon
}
// Validator is a function passed to a Question after a user has provided a response.
// If the function returns an error, then the user will be prompted again for another
// response.
type Validator func(ans interface{}) error
// Transformer is a function passed to a Question after a user has provided a response.
// The function can be used to implement a custom logic that will result to return
// a different representation of the given answer.
//
// Look `TransformString`, `ToLower` `Title` and `ComposeTransformers` for more.
type Transformer func(ans interface{}) (newAns interface{})
// Question is the core data structure for a survey questionnaire.
type Question struct {
Name string
Prompt Prompt
Validate Validator
Transform Transformer
}
// PromptConfig holds the global configuration for a prompt
type PromptConfig struct {
PageSize int
Icons IconSet
HelpInput string
SuggestInput string
Filter func(filter string, option string, index int) bool
KeepFilter bool
ShowCursor bool
RemoveSelectAll bool
RemoveSelectNone bool
HideCharacter rune
}
// Prompt is the primary interface for the objects that can take user input
// and return a response.
type Prompt interface {
Prompt(config *PromptConfig) (interface{}, error)
Cleanup(*PromptConfig, interface{}) error
Error(*PromptConfig, error) error
}
// PromptAgainer Interface for Prompts that support prompting again after invalid input
type PromptAgainer interface {
PromptAgain(config *PromptConfig, invalid interface{}, err error) (interface{}, error)
}
// AskOpt allows setting optional ask options.
type AskOpt func(options *AskOptions) error
// AskOptions provides additional options on ask.
type AskOptions struct {
Stdio terminal.Stdio
Validators []Validator
PromptConfig PromptConfig
}
// WithStdio specifies the standard input, output and error files survey
// interacts with. By default, these are os.Stdin, os.Stdout, and os.Stderr.
func WithStdio(in terminal.FileReader, out terminal.FileWriter, err io.Writer) AskOpt {
return func(options *AskOptions) error {
options.Stdio.In = in
options.Stdio.Out = out
options.Stdio.Err = err
return nil
}
}
// WithFilter specifies the default filter to use when asking questions.
func WithFilter(filter func(filter string, value string, index int) (include bool)) AskOpt {
return func(options *AskOptions) error {
// save the filter internally
options.PromptConfig.Filter = filter
return nil
}
}
// WithKeepFilter sets the if the filter is kept after selections
func WithKeepFilter(KeepFilter bool) AskOpt {
return func(options *AskOptions) error {
// set the page size
options.PromptConfig.KeepFilter = KeepFilter
// nothing went wrong
return nil
}
}
// WithRemoveSelectAll remove the select all option in Multiselect
func WithRemoveSelectAll() AskOpt {
return func(options *AskOptions) error {
options.PromptConfig.RemoveSelectAll = true
return nil
}
}
// WithRemoveSelectNone remove the select none/unselect all in Multiselect
func WithRemoveSelectNone() AskOpt {
return func(options *AskOptions) error {
options.PromptConfig.RemoveSelectNone = true
return nil
}
}
// WithValidator specifies a validator to use while prompting the user
func WithValidator(v Validator) AskOpt {
return func(options *AskOptions) error {
// add the provided validator to the list
options.Validators = append(options.Validators, v)
// nothing went wrong
return nil
}
}
type wantsStdio interface {
WithStdio(terminal.Stdio)
}
// WithPageSize sets the default page size used by prompts
func WithPageSize(pageSize int) AskOpt {
return func(options *AskOptions) error {
// set the page size
options.PromptConfig.PageSize = pageSize
// nothing went wrong
return nil
}
}
// WithHelpInput changes the character that prompts look for to give the user helpful information.
func WithHelpInput(r rune) AskOpt {
return func(options *AskOptions) error {
// set the input character
options.PromptConfig.HelpInput = string(r)
// nothing went wrong
return nil
}
}
// WithIcons sets the icons that will be used when prompting the user
func WithIcons(setIcons func(*IconSet)) AskOpt {
return func(options *AskOptions) error {
// update the default icons with whatever the user says
setIcons(&options.PromptConfig.Icons)
// nothing went wrong
return nil
}
}
// WithShowCursor sets the show cursor behavior when prompting the user
func WithShowCursor(ShowCursor bool) AskOpt {
return func(options *AskOptions) error {
// set the page size
options.PromptConfig.ShowCursor = ShowCursor
// nothing went wrong
return nil
}
}
// WithHideCharacter sets the default character shown instead of the password for password inputs
func WithHideCharacter(char rune) AskOpt {
return func(options *AskOptions) error {
// set the hide character
options.PromptConfig.HideCharacter = char
// nothing went wrong
return nil
}
}
/*
AskOne performs the prompt for a single prompt and asks for validation if required.
Response types should be something that can be casted from the response type designated
in the documentation. For example:
name := ""
prompt := &survey.Input{
Message: "name",
}
survey.AskOne(prompt, &name)
*/
func AskOne(p Prompt, response interface{}, opts ...AskOpt) error {
err := Ask([]*Question{{Prompt: p}}, response, opts...)
if err != nil {
return err
}
return nil
}
/*
Ask performs the prompt loop, asking for validation when appropriate. The response
type can be one of two options. If a struct is passed, the answer will be written to
the field whose name matches the Name field on the corresponding question. Field types
should be something that can be casted from the response type designated in the
documentation. Note, a survey tag can also be used to identify a Otherwise, a
map[string]interface{} can be passed, responses will be written to the key with the
matching name. For example:
qs := []*survey.Question{
{
Name: "name",
Prompt: &survey.Input{Message: "What is your name?"},
Validate: survey.Required,
Transform: survey.Title,
},
}
answers := struct{ Name string }{}
err := survey.Ask(qs, &answers)
*/
func Ask(qs []*Question, response interface{}, opts ...AskOpt) error {
// build up the configuration options
options := defaultAskOptions()
for _, opt := range opts {
if opt == nil {
continue
}
if err := opt(options); err != nil {
return err
}
}
// if we weren't passed a place to record the answers
if response == nil {
// we can't go any further
return errors.New("cannot call Ask() with a nil reference to record the answers")
}
validate := func(q *Question, val interface{}) error {
if q.Validate != nil {
if err := q.Validate(val); err != nil {
return err
}
}
for _, v := range options.Validators {
if err := v(val); err != nil {
return err
}
}
return nil
}
// go over every question
for _, q := range qs {
// If Prompt implements controllable stdio, pass in specified stdio.
if p, ok := q.Prompt.(wantsStdio); ok {
p.WithStdio(options.Stdio)
}
var ans interface{}
var validationErr error
// prompt and validation loop
for {
if validationErr != nil {
if err := q.Prompt.Error(&options.PromptConfig, validationErr); err != nil {
return err
}
}
var err error
if promptAgainer, ok := q.Prompt.(PromptAgainer); ok && validationErr != nil {
ans, err = promptAgainer.PromptAgain(&options.PromptConfig, ans, validationErr)
} else {
ans, err = q.Prompt.Prompt(&options.PromptConfig)
}
if err != nil {
return err
}
validationErr = validate(q, ans)
if validationErr == nil {
break
}
}
if q.Transform != nil {
// check if we have a transformer available, if so
// then try to acquire the new representation of the
// answer, if the resulting answer is not nil.
if newAns := q.Transform(ans); newAns != nil {
ans = newAns
}
}
// tell the prompt to cleanup with the validated value
if err := q.Prompt.Cleanup(&options.PromptConfig, ans); err != nil {
return err
}
// add it to the map
if err := core.WriteAnswer(response, q.Name, ans); err != nil {
return err
}
}
// return the response
return nil
}
// paginate returns a single page of choices given the page size, the total list of
// possible choices, and the current selected index in the total list.
func paginate(pageSize int, choices []core.OptionAnswer, sel int) ([]core.OptionAnswer, int) {
var start, end, cursor int
if len(choices) < pageSize {
// if we dont have enough options to fill a page
start = 0
end = len(choices)
cursor = sel
} else if sel < pageSize/2 {
// if we are in the first half page
start = 0
end = pageSize
cursor = sel
} else if len(choices)-sel-1 < pageSize/2 {
// if we are in the last half page
start = len(choices) - pageSize
end = len(choices)
cursor = sel - start
} else {
// somewhere in the middle
above := pageSize / 2
below := pageSize - above
cursor = pageSize / 2
start = sel - above
end = sel + below
}
// return the subset we care about and the index
return choices[start:end], cursor
}
type IterableOpts interface {
IterateOption(int, core.OptionAnswer) interface{}
}
func computeCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswer, idx, tWidth int) int {
tmpls, err := core.GetTemplatePair(tmpl)
if err != nil {
return 0
}
t := tmpls[0]
renderOpt := func(ix int, opt core.OptionAnswer) string {
var buf bytes.Buffer
_ = t.ExecuteTemplate(&buf, "option", data.IterateOption(ix, opt))
return buf.String()
}
offset := len(opts) - idx
for i, o := range opts {
if i < idx {
continue
}
renderedOpt := renderOpt(i, o)
valWidth := utf8.RuneCount([]byte(renderedOpt))
if valWidth > tWidth {
splitCount := valWidth / tWidth
if valWidth%tWidth == 0 {
splitCount -= 1
}
offset += splitCount
}
}
return offset
}

View File

@ -0,0 +1,22 @@
Copyright (c) 2014 Takashi Kokubun
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More