From 3f61394a22d9bf5ca96232b705fa5f0fa311b27f Mon Sep 17 00:00:00 2001 From: Scott Mead Date: Tue, 18 Aug 2026 17:24:15 +0000 Subject: [PATCH] postgres create-branch: add --ttl and --no-expiry flags Setting a branch's expiration on `databricks postgres create-branch` previously required a hand-written `--json` spec. Add two convenience flags: --ttl sets spec.ttl; accepts the REST API form (604800s), a Go duration (168h), or day/week units (7d, 3w) --no-expiry sets spec.no_expiry One of --ttl, --no-expiry, or a spec expiration in --json is required; they are mutually exclusive. The flags are wired through the existing createBranchOverrides hook in a PreRunE that shapes req.Branch.Spec before the generated RunE merges --json and calls the API, so the generated command and the SDK are untouched. Co-authored-by: Isaac --- .../cli/postgres-create-branch-ttl.md | 1 + .../postgres/create-branch/out.test.toml | 2 + .../postgres/create-branch/output.txt | 96 +++++++ .../workspace/postgres/create-branch/script | 28 ++ .../postgres/create-branch/test.toml | 4 + cmd/workspace/postgres/overrides.go | 268 +++++++++++++++++- cmd/workspace/postgres/overrides_test.go | 167 +++++++++++ 7 files changed, 557 insertions(+), 9 deletions(-) create mode 100644 .nextchanges/cli/postgres-create-branch-ttl.md create mode 100644 acceptance/cmd/workspace/postgres/create-branch/out.test.toml create mode 100644 acceptance/cmd/workspace/postgres/create-branch/output.txt create mode 100644 acceptance/cmd/workspace/postgres/create-branch/script create mode 100644 acceptance/cmd/workspace/postgres/create-branch/test.toml diff --git a/.nextchanges/cli/postgres-create-branch-ttl.md b/.nextchanges/cli/postgres-create-branch-ttl.md new file mode 100644 index 00000000000..98fd4b89f24 --- /dev/null +++ b/.nextchanges/cli/postgres-create-branch-ttl.md @@ -0,0 +1 @@ +Added `--ttl` and `--no-expiry` flags to `databricks postgres create-branch` so a branch's expiration can be set without hand-writing a `--json` spec. `--ttl` accepts the REST API duration form (`604800s`), a Go duration (`168h`), or day/week units (`7d`, `3w`); `--no-expiry` creates a branch that never expires. One of `--ttl`, `--no-expiry`, or a spec expiration in `--json` is required. diff --git a/acceptance/cmd/workspace/postgres/create-branch/out.test.toml b/acceptance/cmd/workspace/postgres/create-branch/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/cmd/workspace/postgres/create-branch/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/cmd/workspace/postgres/create-branch/output.txt b/acceptance/cmd/workspace/postgres/create-branch/output.txt new file mode 100644 index 00000000000..b5e4e866a24 --- /dev/null +++ b/acceptance/cmd/workspace/postgres/create-branch/output.txt @@ -0,0 +1,96 @@ + +=== Set up the parent project +=== create-branch --ttl: API (604800s), Go (168h) and day (7d) forms all go on the wire as seconds +=== create-branch --no-expiry +=== create-branch --ttl combined with a --json spec: the flag's ttl survives the json merge +=== the pre-existing flags still work alongside --ttl: --name (body), --replace-existing (query), --timeout (wait) +=== the --no-wait path still works alongside --no-expiry +=== An expiration is required: no flag fails before any API call (no request recorded) +=== Conflicting flags fail before any API call (no request recorded) +=== Recorded create-branch request bodies{ + "method": "POST", + "path": "/api/2.0/postgres/projects/acc-proj/branches", + "q": { + "branch_id": "branch-ttl-api" + }, + "body": { + "spec": { + "ttl": "604800s" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/acc-proj/branches", + "q": { + "branch_id": "branch-ttl-go" + }, + "body": { + "spec": { + "ttl": "604800s" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/acc-proj/branches", + "q": { + "branch_id": "branch-ttl-days" + }, + "body": { + "spec": { + "ttl": "604800s" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/acc-proj/branches", + "q": { + "branch_id": "branch-noexp" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/acc-proj/branches", + "q": { + "branch_id": "branch-mix" + }, + "body": { + "spec": { + "source_branch": "projects/acc-proj/branches/production", + "ttl": "604800s" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/acc-proj/branches", + "q": { + "branch_id": "branch-flags", + "replace_existing": "true" + }, + "body": { + "name": "display-name", + "spec": { + "ttl": "604800s" + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/acc-proj/branches", + "q": { + "branch_id": "branch-nowait" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} diff --git a/acceptance/cmd/workspace/postgres/create-branch/script b/acceptance/cmd/workspace/postgres/create-branch/script new file mode 100644 index 00000000000..2efc432b8f2 --- /dev/null +++ b/acceptance/cmd/workspace/postgres/create-branch/script @@ -0,0 +1,28 @@ +title "Set up the parent project" +$CLI postgres create-project acc-proj > LOG.setup 2>&1 + +title "create-branch --ttl: API (604800s), Go (168h) and day (7d) forms all go on the wire as seconds" +$CLI postgres create-branch projects/acc-proj branch-ttl-api --ttl 604800s > LOG.ttl-api 2>&1 +$CLI postgres create-branch projects/acc-proj branch-ttl-go --ttl 168h > LOG.ttl-go 2>&1 +$CLI postgres create-branch projects/acc-proj branch-ttl-days --ttl 7d > LOG.ttl-days 2>&1 + +title "create-branch --no-expiry" +$CLI postgres create-branch projects/acc-proj branch-noexp --no-expiry > LOG.noexp 2>&1 + +title "create-branch --ttl combined with a --json spec: the flag's ttl survives the json merge" +$CLI postgres create-branch projects/acc-proj branch-mix --ttl 604800s --json '{"spec":{"source_branch":"projects/acc-proj/branches/production"}}' > LOG.mix 2>&1 + +title "the pre-existing flags still work alongside --ttl: --name (body), --replace-existing (query), --timeout (wait)" +$CLI postgres create-branch projects/acc-proj branch-flags --ttl 168h --name display-name --replace-existing --timeout 60s > LOG.flags 2>&1 + +title "the --no-wait path still works alongside --no-expiry" +$CLI postgres create-branch projects/acc-proj branch-nowait --no-expiry --no-wait > LOG.nowait 2>&1 + +title "An expiration is required: no flag fails before any API call (no request recorded)" +musterr $CLI postgres create-branch projects/acc-proj branch-none &> LOG.required + +title "Conflicting flags fail before any API call (no request recorded)" +musterr $CLI postgres create-branch projects/acc-proj branch-conflict --ttl 1h --no-expiry &> LOG.conflict + +title "Recorded create-branch request bodies" +print_requests.py //branches diff --git a/acceptance/cmd/workspace/postgres/create-branch/test.toml b/acceptance/cmd/workspace/postgres/create-branch/test.toml new file mode 100644 index 00000000000..0a8b72e87a0 --- /dev/null +++ b/acceptance/cmd/workspace/postgres/create-branch/test.toml @@ -0,0 +1,4 @@ +RecordRequests = true + +# Not a bundle command; pin a single engine so it runs once. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/cmd/workspace/postgres/overrides.go b/cmd/workspace/postgres/overrides.go index 0f5ca2030c7..b062999a315 100644 --- a/cmd/workspace/postgres/overrides.go +++ b/cmd/workspace/postgres/overrides.go @@ -1,10 +1,17 @@ package postgres import ( + "encoding/json" "errors" "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/common/types/duration" "github.com/databricks/databricks-sdk-go/service/postgres" "github.com/spf13/cobra" ) @@ -56,23 +63,266 @@ func createRoleOverride(createRoleCmd *cobra.Command, _ *postgres.CreateRoleRequ // body, and the server rejects with a confusing "Field 'role' is required" // message. func rejectWrappedRoleJSON(cmd *cobra.Command) error { - // These checks are internal invariants — postgres create-role is a - // generated command and always has a *flags.JsonFlag for --json. A - // future codegen/refactor change could break that, and we want loud - // breakage rather than a silently-disabled guard. + jf, err := postgresJSONFlag(cmd, "create-role") + if err != nil { + return err + } + return jf.RejectWrappedJSON("role", `databricks postgres create-role projects//branches/ \ + --role-id \ + --json '{"spec": {"identity_type": "SERVICE_PRINCIPAL", "postgres_role": "", "auth_method": "LAKEBASE_OAUTH_V1"}}'`) +} + +// createBranchOverride adds --ttl and --no-expiry flags to create-branch, so +// setting a branch's expiration doesn't need a hand-written --json spec (the +// generated command leaves the nested spec to --json). It sets req.Branch.Spec in +// PreRunE, before the generated RunE merges --json and calls the API — the same +// way the generated --name flag pre-populates the request. +func createBranchOverride(cmd *cobra.Command, req *postgres.CreateBranchRequest) { + var ttl string + var noExpiry bool + cmd.Flags().StringVar(&ttl, "ttl", "", "Relative time-to-live before the branch expires, e.g. 604800s, 168h, or 7d (7 days). Required unless --no-expiry.") + cmd.Flags().BoolVar(&noExpiry, "no-expiry", false, "Create the branch with no expiration. Required unless --ttl.") + + prevPreRunE := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + jsonHasExpiration := false + if cmd.Flags().Changed("json") { + jf, err := postgresJSONFlag(cmd, "create-branch") + if err != nil { + return err + } + jsonHasExpiration = jsonSetsExpiration(jf.Raw()) + } + + spec, err := reconcileBranchExpiration(req.Branch.Spec, cmd.Flags().Changed("ttl"), ttl, cmd.Flags().Changed("no-expiry"), noExpiry, jsonHasExpiration) + if err != nil { + return err + } + req.Branch.Spec = spec + + if prevPreRunE != nil { + return prevPreRunE(cmd, args) + } + return nil + } + + cmd.Long += ` + + Branch expiration (required — set exactly one): + --ttl relative time-to-live; sets spec.ttl. Accepts the REST API + form (604800s), a Go duration (168h), or day/week units + (7d, 3w) — 604800s, 168h and 7d all mean 7 days. + --no-expiry the branch never expires (sets spec.no_expiry). + + You must set --ttl, --no-expiry, or an expiration inside --json (spec.ttl / + spec.expire_time / spec.no_expiry). These are mutually exclusive. + + Examples: + + # Expire after 7 days + databricks postgres create-branch projects/ --ttl 604800s + + # Never expire + databricks postgres create-branch projects/ --no-expiry` +} + +// postgresJSONFlag returns the --json flag for a generated postgres create +// command, or a loud internal error if it is missing or mistyped (a codegen +// change would be the cause). cmdName names the command for the error message. +func postgresJSONFlag(cmd *cobra.Command, cmdName string) (*flags.JsonFlag, error) { flag := cmd.Flags().Lookup("json") if flag == nil { - return errors.New("internal: postgres create-role expected a --json flag; this override is wired to the wrong command") + return nil, fmt.Errorf("internal: postgres %s expected a --json flag; this override is wired to the wrong command", cmdName) } jf, ok := flag.Value.(*flags.JsonFlag) if !ok { - return fmt.Errorf("internal: postgres create-role --json flag has unexpected type %T; expected *flags.JsonFlag", flag.Value) + return nil, fmt.Errorf("internal: postgres %s --json flag has unexpected type %T; expected *flags.JsonFlag", cmdName, flag.Value) } - return jf.RejectWrappedJSON("role", `databricks postgres create-role projects//branches/ \ - --role-id \ - --json '{"spec": {"identity_type": "SERVICE_PRINCIPAL", "postgres_role": "", "auth_method": "LAKEBASE_OAUTH_V1"}}'`) + return jf, nil +} + +// reconcileBranchExpiration builds the BranchSpec from the --ttl / --no-expiry +// flags before --json is merged; jsonHasExpiration reports whether --json already +// sets one. Exactly one expiration source is required, and the three are mutually +// exclusive (the API rejects no_expiry=false and multiple sources). When the +// expiration comes from --json alone, spec is left for the generated RunE to merge. +func reconcileBranchExpiration(spec *postgres.BranchSpec, ttlChanged bool, ttl string, noExpiryChanged, noExpiry, jsonHasExpiration bool) (*postgres.BranchSpec, error) { + // --no-expiry=false asks for "expiry on" without saying how, which the API + // rejects; point the user at the flag that expresses intent. + if noExpiryChanged && !noExpiry { + return nil, errors.New("--no-expiry=false is not valid; set --ttl such as 604800s, or spec.expire_time via --json") + } + + flagTTL := ttlChanged + flagNoExpiry := noExpiryChanged && noExpiry + + sources := 0 + for _, set := range []bool{flagTTL, flagNoExpiry, jsonHasExpiration} { + if set { + sources++ + } + } + switch { + case sources == 0: + return nil, errors.New("a branch expiration is required; set --ttl such as 604800s, --no-expiry, or a spec expiration in --json") + case sources > 1: + return nil, errors.New("branch expiration set more than once; use exactly one of --ttl, --no-expiry, or a spec expiration in --json") + } + + switch { + case flagTTL: + d, err := parseTTL(ttl) + if err != nil { + return nil, err + } + if spec == nil { + spec = &postgres.BranchSpec{} + } + spec.Ttl = d + case flagNoExpiry: + if spec == nil { + spec = &postgres.BranchSpec{} + } + spec.NoExpiry = true + case jsonHasExpiration: + // Expiration comes from --json; leave spec for the generated RunE to merge. + } + return spec, nil +} + +// jsonSetsExpiration reports whether a --json request body actually sets a branch +// expiration: a non-null spec.ttl or spec.expire_time, or spec.no_expiry: true. A +// null value or no_expiry: false does not count (no_expiry=false is invalid and +// is dropped on the wire), so those fall through to the "expiration required" +// error rather than a confusing server rejection. Malformed JSON returns false +// and is left for the generated --json path to report. +func jsonSetsExpiration(raw []byte) bool { + if len(raw) == 0 { + return false + } + var probe struct { + Spec *struct { + Ttl json.RawMessage `json:"ttl"` + ExpireTime json.RawMessage `json:"expire_time"` + NoExpiry *bool `json:"no_expiry"` + } `json:"spec"` + } + if err := json.Unmarshal(raw, &probe); err != nil || probe.Spec == nil { + return false + } + s := probe.Spec + return jsonValueSet(s.Ttl) || jsonValueSet(s.ExpireTime) || (s.NoExpiry != nil && *s.NoExpiry) +} + +// jsonValueSet reports whether a raw JSON value is present and not null. +func jsonValueSet(raw json.RawMessage) bool { + return len(raw) > 0 && strings.TrimSpace(string(raw)) != "null" +} + +// parseTTL parses the --ttl value into a *duration.Duration. It accepts the REST +// API's protobuf-duration form ("604800s"), Go-style durations ("168h"), and the +// day/week extension ("7d", "3w"); duration.New re-serializes to the API form. +func parseTTL(s string) (*duration.Duration, error) { + if s == "" { + return nil, errors.New("--ttl must not be empty; use e.g. 604800s, 168h, or 7d for 7 days") + } + d, err := parseTTLDuration(s) + if err != nil { + return nil, fmt.Errorf("invalid --ttl %q: %w; use e.g. 604800s, 168h, 7d, or 3w", s, err) + } + if d <= 0 { + return nil, fmt.Errorf("invalid --ttl %q: must be a positive duration", s) + } + return duration.New(d), nil +} + +// ttlComponent matches one leading "" component of a duration +// string, including the day (d) and week (w) units time.ParseDuration lacks. +var ttlComponent = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s|m|h|d|w)`) + +const ( + hoursPerDay = 24 + hoursPerWeek = 24 * 7 + // maxDurationHours is the largest whole-hour count representable as a + // time.Duration (an int64 nanosecond count). + maxDurationHours = int64(math.MaxInt64) / int64(time.Hour) +) + +// parseTTLDuration parses a Go duration string extended with day (d, = 24h) and +// week (w, = 168h) units. Components in those units are summed here; all other +// components (h and below) are handed to time.ParseDuration unchanged, which +// keeps its exact integer handling for the common forms. +func parseTTLDuration(s string) (time.Duration, error) { + // Fast path: no day/week unit means time.ParseDuration handles it all. No + // other supported unit contains 'd' or 'w', so this check is exact. + if !strings.ContainsAny(s, "dw") { + return time.ParseDuration(s) + } + + rest := s + neg := false + switch { + case strings.HasPrefix(rest, "-"): + neg, rest = true, rest[1:] + case strings.HasPrefix(rest, "+"): + rest = rest[1:] + } + + var extra time.Duration // accumulated day/week components + var residual strings.Builder // remaining components, parsed by time.ParseDuration + for rest != "" { + m := ttlComponent.FindStringSubmatch(rest) + if m == nil { + return 0, fmt.Errorf("unrecognized duration component %q", rest) + } + switch m[2] { + case "d", "w": + hoursPerUnit := int64(hoursPerDay) + if m[2] == "w" { + hoursPerUnit = hoursPerWeek + } + if strings.Contains(m[1], ".") { + f, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return 0, err + } + ns := f * float64(hoursPerUnit) * float64(time.Hour) + if ns > float64(math.MaxInt64) { + return 0, fmt.Errorf("duration component %s%s overflows", m[1], m[2]) + } + extra += time.Duration(ns) + } else { + n, err := strconv.ParseInt(m[1], 10, 64) + if err != nil { + return 0, err + } + if n > maxDurationHours/hoursPerUnit { + return 0, fmt.Errorf("duration component %s%s overflows", m[1], m[2]) + } + extra += time.Duration(n*hoursPerUnit) * time.Hour + } + default: + residual.WriteString(m[1]) + residual.WriteString(m[2]) + } + rest = rest[len(m[0]):] + } + + total := extra + if residual.Len() > 0 { + d, err := time.ParseDuration(residual.String()) + if err != nil { + return 0, err + } + total += d + } + if neg { + total = -total + } + return total, nil } func init() { createRoleOverrides = append(createRoleOverrides, createRoleOverride) + createBranchOverrides = append(createBranchOverrides, createBranchOverride) } diff --git a/cmd/workspace/postgres/overrides_test.go b/cmd/workspace/postgres/overrides_test.go index 46b30426177..ee0ed02f091 100644 --- a/cmd/workspace/postgres/overrides_test.go +++ b/cmd/workspace/postgres/overrides_test.go @@ -2,8 +2,10 @@ package postgres import ( "testing" + "time" "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/service/postgres" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -54,3 +56,168 @@ func TestRejectWrappedRoleJSON(t *testing.T) { assert.Contains(t, err.Error(), "internal:") }) } + +const sevenDays = 7 * 24 * time.Hour + +func TestReconcileBranchExpiration(t *testing.T) { + tests := []struct { + name string + spec *postgres.BranchSpec + ttlChanged bool + ttl string + noExpiryChanged bool + noExpiry bool + jsonExpiration bool + wantErr string + check func(t *testing.T, spec *postgres.BranchSpec) + }{ + { + name: "no flags, nil spec -> expiration required", + wantErr: "a branch expiration is required", + }, + { + name: "ttl API form", + ttlChanged: true, + ttl: "604800s", + check: func(t *testing.T, spec *postgres.BranchSpec) { + require.NotNil(t, spec.Ttl) + assert.Equal(t, sevenDays, spec.Ttl.AsDuration()) + assert.False(t, spec.NoExpiry) + }, + }, + { + name: "ttl Go duration form", + ttlChanged: true, + ttl: "168h", + check: func(t *testing.T, spec *postgres.BranchSpec) { + require.NotNil(t, spec.Ttl) + assert.Equal(t, sevenDays, spec.Ttl.AsDuration()) + }, + }, + { + name: "no-expiry flag", + noExpiryChanged: true, + noExpiry: true, + check: func(t *testing.T, spec *postgres.BranchSpec) { + assert.True(t, spec.NoExpiry) + }, + }, + { + name: "non-expiration spec field is not an expiration source", + spec: &postgres.BranchSpec{SourceBranch: "projects/p/branches/main"}, + wantErr: "a branch expiration is required", + }, + { + name: "json expiration alone leaves spec untouched", + jsonExpiration: true, + check: func(t *testing.T, spec *postgres.BranchSpec) { + // spec stays nil so the generated RunE merges --json's expiration. + assert.Nil(t, spec) + }, + }, + { + name: "ttl and no-expiry conflict", + ttlChanged: true, + ttl: "604800s", + noExpiryChanged: true, + noExpiry: true, + wantErr: "set more than once", + }, + { + name: "ttl and json expiration conflict", + ttlChanged: true, + ttl: "604800s", + jsonExpiration: true, + wantErr: "set more than once", + }, + { + name: "no-expiry=false is rejected", + noExpiryChanged: true, + noExpiry: false, + wantErr: "--no-expiry=false is not valid", + }, + { + name: "invalid ttl", + ttlChanged: true, + ttl: "7x", + wantErr: "invalid --ttl", + }, + { + name: "empty ttl", + ttlChanged: true, + ttl: "", + wantErr: "must not be empty", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + spec, err := reconcileBranchExpiration(tc.spec, tc.ttlChanged, tc.ttl, tc.noExpiryChanged, tc.noExpiry, tc.jsonExpiration) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + tc.check(t, spec) + }) + } +} + +func TestParseTTL(t *testing.T) { + valid := map[string]time.Duration{ + "604800s": sevenDays, + "168h": sevenDays, + "7d": sevenDays, + "1w": sevenDays, + "1.5s": 1500 * time.Millisecond, + "12d": 12 * 24 * time.Hour, + "3w": 3 * 7 * 24 * time.Hour, + "1.5d": 36 * time.Hour, + "1w3d": 10 * 24 * time.Hour, + "7d12h": 7*24*time.Hour + 12*time.Hour, + } + for in, want := range valid { + t.Run("valid/"+in, func(t *testing.T) { + d, err := parseTTL(in) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, want, d.AsDuration()) + }) + } + + for _, in := range []string{"", "abc", "0s", "0d", "-1h", "7x", "7dd", "d", "100000000000000000d", "9999999999w"} { + t.Run("invalid/"+in, func(t *testing.T) { + _, err := parseTTL(in) + assert.Error(t, err) + }) + } +} + +func TestJSONSetsExpiration(t *testing.T) { + tests := map[string]bool{ + `{"spec":{"ttl":"604800s"}}`: true, + `{"spec":{"no_expiry":true}}`: true, + `{"spec":{"expire_time":"2030-01-01T00:00:00Z"}}`: true, + `{"spec":{"no_expiry":false}}`: false, + `{"spec":{"ttl":null}}`: false, + `{"spec":{"source_branch":"x"}}`: false, + `{"spec":{}}`: false, + `{}`: false, + ``: false, + `not json`: false, + } + for raw, want := range tests { + t.Run(raw, func(t *testing.T) { + assert.Equal(t, want, jsonSetsExpiration([]byte(raw))) + }) + } +} + +func TestCreateBranchOverrideWiring(t *testing.T) { + cmd := newCreateBranch() + assert.NotNil(t, cmd.Flags().Lookup("ttl")) + assert.NotNil(t, cmd.Flags().Lookup("no-expiry")) + assert.Contains(t, cmd.Long, "--ttl") + assert.Contains(t, cmd.Long, "604800s") +}