test(surf,conga,lagoon): summaries, reports, stats and checklist flags
- serve summary lines at -v and -q, records and HideOnTTY, DSN labels - shutdown report: aborted requests fail the stop, a drained stop is nil - request log: request_id cap, default on, off by config or env, never the path for matched, 404 and 405 routes, component http; route:list --json - WorkerStats through runAttempt: done, failed, in flight, handed back - queue:clear and lagoon's production question on a pseudo-terminal: no cancels with exit 1, --force, -n and other environments skip it - Docker: PendingMigrations, migrate/rollback/deferred:purge --pretend, production runs off a terminal never read stdin, migrate:status --json - key:generate --json and -q
This commit is contained in:
133
modules/lagoon/commands_flags_test.go
Normal file
133
modules/lagoon/commands_flags_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// envApp is an app in environment env with a fixed test app key and an
|
||||
// optional DSN.
|
||||
func envApp(t *testing.T, env, dsn string, extra ...string) *backpack.App {
|
||||
t.Helper()
|
||||
environ := append([]string{
|
||||
"SUMMER_ENV=" + env,
|
||||
"SUMMER_APP__KEY=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("k"), 32)),
|
||||
}, extra...)
|
||||
if dsn != "" {
|
||||
environ = append(environ, "SUMMER_DATABASE__DSN="+dsn)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: env, Environ: environ})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
// probeConfirm is a command that runs confirmProduction, as migrate does.
|
||||
func probeConfirm(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "probe:confirm",
|
||||
Description: "Ask like migrate does",
|
||||
Flags: []bonfire.Flag{{Name: "force", Description: forceDescription, Bare: true}},
|
||||
Run: func(_ context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
if err := confirmProduction(app, in, out, "Apply 3 migrations to the production database?", "Migration cancelled."); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Println("proceeded")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmProductionNonTTYProceeds: in production a console that is not
|
||||
// an interactive terminal never asks and never reads stdin, so migrate,
|
||||
// migrate:rollback and deferred:purge proceed in deploys and cron;
|
||||
// bonfire.Call does the same. Every destructive command offers --force.
|
||||
func TestConfirmProductionNonTTYProceeds(t *testing.T) {
|
||||
app := envApp(t, "production", "")
|
||||
in := strings.NewReader("n\n")
|
||||
var out, errW bytes.Buffer
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{probeConfirm(app)}, in, &out, &errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := bonfire.Execute(context.Background(), root, []string{"probe:confirm"}); code != bonfire.ExitOK {
|
||||
t.Fatalf("exit %d\n%s", code, errW.String())
|
||||
}
|
||||
if in.Len() != 2 || out.String() != "proceeded\n" {
|
||||
t.Fatalf("stdin left %d, output %q: the probe asked or did not proceed", in.Len(), out.String())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := bonfire.Call(context.Background(), []bonfire.Command{probeConfirm(app)}, "probe:confirm", nil, &buf); err != nil || buf.String() != "proceeded\n" {
|
||||
t.Fatalf("Call: %v, %q", err, buf.String())
|
||||
}
|
||||
|
||||
for _, c := range RuntimeCommands(app, nil) {
|
||||
switch c.Name {
|
||||
case "migrate", "migrate:rollback", "deferred:purge":
|
||||
force := false
|
||||
for _, f := range c.Flags {
|
||||
if f.Name == "force" && f.Bare {
|
||||
force = true
|
||||
}
|
||||
}
|
||||
if !force || !strings.Contains(c.Long, "--force skips the") || !strings.Contains(c.Long, "production") {
|
||||
t.Errorf("%s does not document the production question and --force", c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyGenerateJSON: key:generate --json prints {"key": "..."} with a
|
||||
// 32-byte base64 key, under -q too.
|
||||
func TestKeyGenerateJSON(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, args := range [][]string{{"--json"}, {"--json", "-q"}} {
|
||||
var buf bytes.Buffer
|
||||
if err := bonfire.Call(context.Background(), []bonfire.Command{KeyGenerateCommand()}, "key:generate", args, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil || len(got) != 1 {
|
||||
t.Fatalf("%v: not {\"key\": ...}: %v\n%s", args, err, buf.String())
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(got["key"])
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatalf("%v: key %q is not 32 base64 bytes", args, got["key"])
|
||||
}
|
||||
if seen[got["key"]] {
|
||||
t.Fatal("key:generate printed the same key twice")
|
||||
}
|
||||
seen[got["key"]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyGenerateQuiet: under -q key:generate prints the bare key and a
|
||||
// newline (KEY=$(bin/acme key:generate -q)); without it the key follows
|
||||
// the success mark.
|
||||
func TestKeyGenerateQuiet(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := bonfire.Call(context.Background(), []bonfire.Command{KeyGenerateCommand()}, "key:generate", []string{"-q"}, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
key := strings.TrimSuffix(out, "\n")
|
||||
if strings.Count(out, "\n") != 1 || strings.ContainsAny(key, " ✓") || len(key) != 44 {
|
||||
t.Fatalf("-q output = %q, want the bare 44-character key", out)
|
||||
}
|
||||
buf.Reset()
|
||||
if err := bonfire.Call(context.Background(), []bonfire.Command{KeyGenerateCommand()}, "key:generate", nil, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(buf.String(), "✓ ") || len(strings.TrimSpace(strings.TrimPrefix(buf.String(), "✓ "))) != 44 {
|
||||
t.Fatalf("default output = %q", buf.String())
|
||||
}
|
||||
}
|
||||
296
modules/lagoon/pending_test.go
Normal file
296
modules/lagoon/pending_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// pendingPlugins are two plugins with one and two migrations.
|
||||
func pendingPlugins() (alpha, beta migPlugin) {
|
||||
table := func(name string) *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "202610210001_create_" + name,
|
||||
Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE ` + name + ` (id BIGSERIAL PRIMARY KEY)`).Error },
|
||||
Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS ` + name).Error },
|
||||
}
|
||||
}
|
||||
seed := &gormigrate.Migration{
|
||||
ID: "202610210002_seed_pending_beta",
|
||||
Migrate: func(tx *gorm.DB) error { return tx.Exec(`INSERT INTO pending_beta DEFAULT VALUES`).Error },
|
||||
Rollback: func(tx *gorm.DB) error { return tx.Exec(`DELETE FROM pending_beta`).Error },
|
||||
}
|
||||
return migPlugin{id: "demo.alpha", migrations: []*gormigrate.Migration{table("pending_alpha")}},
|
||||
migPlugin{id: "demo.beta", migrations: []*gormigrate.Migration{table("pending_beta"), seed}}
|
||||
}
|
||||
|
||||
// pluginRefs returns the refs of pending that belong to plugin IDs (the
|
||||
// framework sets come first and are left out).
|
||||
func pluginRefs(pending []MigrationRef) []string {
|
||||
var out []string
|
||||
for _, r := range pending {
|
||||
if strings.HasPrefix(r.Plugin, "demo.") {
|
||||
out = append(out, r.Plugin+":"+r.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runLagoon runs one runtime command on a fresh app (a non-terminal stdin
|
||||
// holding "n", which a question would consume) and returns stdout, stderr,
|
||||
// the exit code and what is left of stdin.
|
||||
func runLagoon(t *testing.T, env, dsn string, plugins []party.Plugin, args ...string) (string, string, int, int) {
|
||||
t.Helper()
|
||||
app := envApp(t, env, dsn, "SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://"+t.TempDir())
|
||||
in := strings.NewReader("n\n")
|
||||
var out, errW bytes.Buffer
|
||||
root, err := bonfire.NewRootIO("acme", RuntimeCommands(app, plugins), in, &out, &errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := bonfire.Execute(context.Background(), root, args)
|
||||
return out.String(), errW.String(), code, in.Len()
|
||||
}
|
||||
|
||||
// TestPendingMigrations: with no history every migration is pending,
|
||||
// framework sets first, then plugins in order; applied IDs drop out, a
|
||||
// partly applied set keeps only the rest, and a missing history table
|
||||
// makes its whole set pending.
|
||||
func TestPendingMigrations(t *testing.T) {
|
||||
db, _ := dedicatedDB(t, "lagoon_pending")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alpha, beta := pendingPlugins()
|
||||
plugins := []party.Plugin{alpha, beta}
|
||||
|
||||
all, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"demo.alpha:202610210001_create_pending_alpha", "demo.beta:202610210001_create_pending_beta", "demo.beta:202610210002_seed_pending_beta"}
|
||||
if got := pluginRefs(all); strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("plugin refs = %v, want %v", got, want)
|
||||
}
|
||||
if len(all) <= len(want) || strings.HasPrefix(all[0].Plugin, "demo.") {
|
||||
t.Fatalf("framework sets do not come first: %v", all)
|
||||
}
|
||||
for _, ref := range all[:len(all)-len(want)] {
|
||||
if strings.HasPrefix(ref.Plugin, "demo.") {
|
||||
t.Fatalf("a plugin ref sits among the framework sets: %v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// Framework and alpha applied: only beta is pending, its history table
|
||||
// still missing.
|
||||
if err := Migrate(gdb, []party.Plugin{alpha}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gdb.Migrator().HasTable("summer_migrations_demo_beta") {
|
||||
t.Fatal("beta's history table exists before beta migrated")
|
||||
}
|
||||
some, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(pluginRefs(some), ",") != strings.Join(want[1:], ",") || len(some) != 2 {
|
||||
t.Fatalf("after alpha: %v, want only beta's two", some)
|
||||
}
|
||||
|
||||
// beta applied, then its seed removed from the history: one pending.
|
||||
if err := Migrate(gdb, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`DELETE FROM summer_migrations_demo_beta WHERE id = ?`, "202610210002_seed_pending_beta").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partly, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(partly) != 1 || partly[0] != (MigrationRef{Plugin: "demo.beta", ID: "202610210002_seed_pending_beta"}) {
|
||||
t.Fatalf("partly applied = %v", partly)
|
||||
}
|
||||
if _, err := PendingMigrations(nil, plugins); err == nil {
|
||||
t.Fatal("a nil db did not fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigratePretend: migrate --pretend lists every pending migration and
|
||||
// applies nothing; migrate in production off a terminal then applies them
|
||||
// without asking (stdin unread), and both report nothing left after.
|
||||
func TestMigratePretend(t *testing.T) {
|
||||
db, dsn := dedicatedDB(t, "lagoon_pretend")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alpha, beta := pendingPlugins()
|
||||
plugins := []party.Plugin{alpha, beta}
|
||||
before, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, errOut, code, left := runLagoon(t, "production", dsn, plugins, "migrate", "--pretend")
|
||||
if code != bonfire.ExitOK || left != 2 {
|
||||
t.Fatalf("--pretend: exit %d, stdin left %d\n%s%s", code, left, out, errOut)
|
||||
}
|
||||
for _, ref := range before {
|
||||
if !strings.Contains(out, ref.Plugin+": "+ref.ID+"\n") {
|
||||
t.Errorf("--pretend lacks %s: %s:\n%s", ref.Plugin, ref.ID, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, fmt.Sprintf("%d migrations would run", len(before))) {
|
||||
t.Errorf("--pretend lacks the count:\n%s", out)
|
||||
}
|
||||
after, err := PendingMigrations(gdb, plugins)
|
||||
if err != nil || len(after) != len(before) || gdb.Migrator().HasTable("pending_alpha") {
|
||||
t.Fatalf("--pretend applied something: %v, %v", after, err)
|
||||
}
|
||||
|
||||
// -q keeps the data lines (the IDs) and drops the count line.
|
||||
out, _, code, _ = runLagoon(t, "production", dsn, plugins, "migrate", "--pretend", "-q")
|
||||
if code != bonfire.ExitOK || !strings.Contains(out, "demo.beta: 202610210002_seed_pending_beta\n") || strings.Contains(out, "would run") {
|
||||
t.Errorf("--pretend -q:\n%s", out)
|
||||
}
|
||||
|
||||
out, errOut, code, left = runLagoon(t, "production", dsn, plugins, "migrate")
|
||||
if code != bonfire.ExitOK || left != 2 || strings.Contains(out, "[y/N]") {
|
||||
t.Fatalf("production migrate off a terminal: exit %d, stdin left %d\n%s%s", code, left, out, errOut)
|
||||
}
|
||||
if !strings.Contains(out, fmt.Sprintf("Applied %d migrations", len(before))) || !strings.Contains(out, "demo.beta: 2 applied") {
|
||||
t.Errorf("migrate result lines:\n%s", out)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("pending_beta") {
|
||||
t.Fatal("migrate did not apply beta")
|
||||
}
|
||||
out, _, _, _ = runLagoon(t, "production", dsn, plugins, "migrate", "--pretend")
|
||||
if !strings.Contains(out, "0 migrations would run") {
|
||||
t.Errorf("--pretend after migrate:\n%s", out)
|
||||
}
|
||||
out, _, _, _ = runLagoon(t, "production", dsn, plugins, "migrate")
|
||||
if !strings.Contains(out, "Nothing to migrate") {
|
||||
t.Errorf("second migrate:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollbackPretend: migrate:rollback --pretend names the migration that
|
||||
// would roll back and changes nothing; the real rollback in production off
|
||||
// a terminal proceeds without asking.
|
||||
func TestRollbackPretend(t *testing.T) {
|
||||
db, dsn := dedicatedDB(t, "lagoon_rollback_pretend")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alpha, beta := pendingPlugins()
|
||||
plugins := []party.Plugin{alpha, beta}
|
||||
if err := Migrate(gdb, []party.Plugin{alpha}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, code, _ := runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta", "--pretend")
|
||||
if code != bonfire.ExitOK || !strings.Contains(out, "demo.beta has no applied migration to roll back") {
|
||||
t.Fatalf("pretend without history: exit %d\n%s", code, out)
|
||||
}
|
||||
if err := Migrate(gdb, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _, code, _ = runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta", "--pretend")
|
||||
if code != bonfire.ExitOK || !strings.Contains(out, "Would roll back 202610210002_seed_pending_beta of demo.beta") {
|
||||
t.Fatalf("pretend: exit %d\n%s", code, out)
|
||||
}
|
||||
var n int64
|
||||
if err := gdb.Table("summer_migrations_demo_beta").Count(&n).Error; err != nil || n != 2 {
|
||||
t.Fatalf("pretend changed the history: %d rows, %v", n, err)
|
||||
}
|
||||
|
||||
out, errOut, code, left := runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta")
|
||||
if code != bonfire.ExitOK || left != 2 || !strings.Contains(out, "Rolled back 202610210002_seed_pending_beta of demo.beta") {
|
||||
t.Fatalf("rollback: exit %d, stdin left %d\n%s%s", code, left, out, errOut)
|
||||
}
|
||||
if err := gdb.Table("summer_migrations_demo_beta").Count(&n).Error; err != nil || n != 1 {
|
||||
t.Fatalf("rollback left %d history rows, %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeferredPurgePretend: deferred:purge --pretend counts the bindings
|
||||
// older than --days and deletes none; the real purge in production off a
|
||||
// terminal proceeds without asking.
|
||||
func TestDeferredPurgePretend(t *testing.T) {
|
||||
db, dsn := dedicatedDB(t, "lagoon_purge_pretend")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(gdb, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-10 * 24 * time.Hour)
|
||||
for i, created := range []time.Time{old, old, time.Now()} {
|
||||
row := DeferredBinding{MasterType: "acme.blog.post", MasterField: "cover", SlaveType: "acme.unknown", SlaveID: fmt.Sprint(i + 1),
|
||||
SessionKey: "session", IsBind: true, BackendUserID: 1, CreatedAt: created, UpdatedAt: created}
|
||||
if err := gdb.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
out, errOut, code, _ := runLagoon(t, "production", dsn, nil, "deferred:purge", "--days", "5", "--pretend")
|
||||
if code != bonfire.ExitOK || !strings.Contains(out, "2 deferred bindings older than 5 days would be purged") {
|
||||
t.Fatalf("pretend: exit %d\n%s%s", code, out, errOut)
|
||||
}
|
||||
var n int64
|
||||
if err := gdb.Model(&DeferredBinding{}).Count(&n).Error; err != nil || n != 3 {
|
||||
t.Fatalf("pretend deleted bindings: %d left, %v", n, err)
|
||||
}
|
||||
|
||||
out, errOut, code, left := runLagoon(t, "production", dsn, nil, "deferred:purge", "--days", "5")
|
||||
if code != bonfire.ExitOK || left != 2 || !strings.Contains(out, "older than 5 days") || strings.Contains(out, "[y/N]") {
|
||||
t.Fatalf("purge: exit %d, stdin left %d\n%s%s", code, left, out, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateStatusJSON: migrate:status --json lists every declared
|
||||
// migration of the plugins with its table and applied flag, under -q too.
|
||||
func TestMigrateStatusJSON(t *testing.T) {
|
||||
db, dsn := dedicatedDB(t, "lagoon_status_json")
|
||||
gdb, err := Use(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alpha, beta := pendingPlugins()
|
||||
plugins := []party.Plugin{alpha, beta}
|
||||
if err := Migrate(gdb, []party.Plugin{alpha}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"migrate:status", "--json"}, {"migrate:status", "--json", "-q"}} {
|
||||
out, errOut, code, _ := runLagoon(t, "development", dsn, plugins, args...)
|
||||
if code != bonfire.ExitOK {
|
||||
t.Fatalf("%v: exit %d\n%s", args, code, errOut)
|
||||
}
|
||||
var list []statusJSON
|
||||
if err := json.Unmarshal([]byte(out), &list); err != nil {
|
||||
t.Fatalf("%v: not JSON: %v\n%s", args, err, out)
|
||||
}
|
||||
want := []statusJSON{
|
||||
{Plugin: "demo.alpha", Table: "summer_migrations_demo_alpha", ID: "202610210001_create_pending_alpha", Applied: true},
|
||||
{Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210001_create_pending_beta"},
|
||||
{Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210002_seed_pending_beta"},
|
||||
}
|
||||
if fmt.Sprint(list) != fmt.Sprint(want) {
|
||||
t.Errorf("%v: status = %+v, want %+v", args, list, want)
|
||||
}
|
||||
if !strings.Contains(out, `"applied": false`) {
|
||||
t.Errorf("%v: pending entries lack \"applied\": false:\n%s", args, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
152
modules/lagoon/pty_linux_test.go
Normal file
152
modules/lagoon/pty_linux_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
//go:build linux
|
||||
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
)
|
||||
|
||||
// ptyOutput collects what the program writes to a pseudo-terminal.
|
||||
type ptyOutput struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (p *ptyOutput) String() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.buf.String()
|
||||
}
|
||||
|
||||
// openPTY opens a pseudo-terminal pair: the slave is a terminal for the
|
||||
// console (stdin and stdout), input written to the master reaches the
|
||||
// slave's stdin, and everything the slave prints is collected.
|
||||
func openPTY(t *testing.T) (master, slave *os.File, output *ptyOutput) {
|
||||
t.Helper()
|
||||
m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open /dev/ptmx: %v", err)
|
||||
}
|
||||
var unlock int32
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); e != 0 {
|
||||
t.Fatalf("unlock pty: %v", e)
|
||||
}
|
||||
var n uint32
|
||||
if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, m.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))); e != 0 {
|
||||
t.Fatalf("pty number: %v", e)
|
||||
}
|
||||
s, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("open pty slave: %v", err)
|
||||
}
|
||||
output = &ptyOutput{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := m.Read(buf)
|
||||
output.mu.Lock()
|
||||
output.buf.Write(buf[:n])
|
||||
output.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = s.Close()
|
||||
_ = m.Close()
|
||||
<-done
|
||||
})
|
||||
return m, s, output
|
||||
}
|
||||
|
||||
// waitFor waits until the pty output contains want.
|
||||
func waitFor(t *testing.T, output *ptyOutput, want string) {
|
||||
t.Helper()
|
||||
for range 200 {
|
||||
if strings.Contains(output.String(), want) {
|
||||
return
|
||||
}
|
||||
<-time.After(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("pty output lacks %q:\n%s", want, output.String())
|
||||
}
|
||||
|
||||
// runOnPTY runs probe:confirm with args on a fresh pseudo-terminal after
|
||||
// typing answer, and returns the exit code and the terminal output.
|
||||
func runOnPTY(t *testing.T, env, answer string, args ...string) (int, *ptyOutput) {
|
||||
t.Helper()
|
||||
master, slave, output := openPTY(t)
|
||||
if _, err := master.Write([]byte(answer)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := bonfire.NewRootIO("acme", []bonfire.Command{probeConfirm(envApp(t, env, ""))}, slave, slave, slave)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// --no-ansi keeps the line prompt (huh needs a terminal size).
|
||||
code := bonfire.Execute(context.Background(), root, append([]string{"--no-ansi", "probe:confirm"}, args...))
|
||||
return code, output
|
||||
}
|
||||
|
||||
// TestConfirmProductionInteractiveNo: on an interactive terminal in
|
||||
// production the question is asked; "n" or an empty answer prints the
|
||||
// cancel line and exits 1 without an error block (Reported); "y" proceeds.
|
||||
func TestConfirmProductionInteractiveNo(t *testing.T) {
|
||||
for _, answer := range []string{"n\n", "\n", "no\n"} {
|
||||
code, output := runOnPTY(t, "production", answer)
|
||||
if code != bonfire.ExitFailure {
|
||||
t.Fatalf("answer %q: exit %d\n%s", answer, code, output.String())
|
||||
}
|
||||
waitFor(t, output, "Migration cancelled.")
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "Apply 3 migrations to the production database? [y/N]") {
|
||||
t.Errorf("answer %q: question not asked:\n%s", answer, got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR") || strings.Contains(got, "proceeded") {
|
||||
t.Errorf("answer %q: error block or the step ran:\n%s", answer, got)
|
||||
}
|
||||
}
|
||||
code, output := runOnPTY(t, "production", "y\n")
|
||||
if code != bonfire.ExitOK {
|
||||
t.Fatalf("answer y: exit %d\n%s", code, output.String())
|
||||
}
|
||||
waitFor(t, output, "proceeded")
|
||||
}
|
||||
|
||||
// TestForceSkipsConfirm: --force, -n and any environment other than
|
||||
// production proceed on an interactive terminal without asking (the typed
|
||||
// "n" is never read).
|
||||
func TestForceSkipsConfirm(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
env string
|
||||
args []string
|
||||
}{
|
||||
"--force": {"production", []string{"--force"}},
|
||||
"-n": {"production", []string{"-n"}},
|
||||
"staging": {"staging", nil},
|
||||
"development": {"development", nil},
|
||||
} {
|
||||
code, output := runOnPTY(t, tc.env, "n\n", tc.args...)
|
||||
if code != bonfire.ExitOK {
|
||||
t.Fatalf("%s: exit %d\n%s", name, code, output.String())
|
||||
}
|
||||
waitFor(t, output, "proceeded")
|
||||
if strings.Contains(output.String(), "[y/N]") {
|
||||
t.Errorf("%s: the question was asked:\n%s", name, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user