diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b293b79..7d0c7f5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,6 +30,15 @@ jobs: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Autobuild uses the runner Go by default; go.mod requires 1.27 and the + # image still ships an older toolchain. Pin the rc until go.dev publishes + # a stable 1.27 (then switch to go-version-file: go.mod). + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.27.0-rc.2" + cache: true + - name: Initialize CodeQL uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 with: diff --git a/Dockerfile b/Dockerfile index de02954..2ec1399 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx -FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS base +FROM --platform=$BUILDPLATFORM golang:1.27rc2-alpine@sha256:dcbb18cc5fa1082364dc6aa95224b6b55429d09cbb9631a053d8064c1c367300 AS base ENV GO111MODULE=on ENV CGO_ENABLED=0 COPY --from=xx / / diff --git a/flake.nix b/flake.nix index b11697e..55fd3e4 100644 --- a/flake.nix +++ b/flake.nix @@ -21,11 +21,12 @@ system: let pkgs = import nixpkgs { inherit system; }; - go = pkgs.go; + # nixpkgs' default `go` may lag; pin the toolchain Nabat requires. + go = pkgs.go_1_27; buildGoModule' = pkgs.buildGoModule.override { inherit go; }; - deployahVendorHash = "sha256-CoOzDrJDWuJCBgN1PQoS3k28Mrg78tv4FxvMvZGn+Ms="; + deployahVendorHash = "sha256-KmIlfzjPCysvdQu7O0oIdsVjkdsXK+vLWNtQLdYDJ5A="; deployah = import ./nix/deployah.nix { buildGoModule = buildGoModule'; @@ -65,6 +66,7 @@ flake-utils deployah system + go ; lib = lib'; }; diff --git a/go.mod b/go.mod index b3cfbec..d1d783e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module deployah.dev/deployah -go 1.26.5 +go 1.27 require ( dario.cat/mergo v1.0.2 @@ -31,7 +31,7 @@ require ( k8s.io/apimachinery v0.36.3 k8s.io/client-go v0.36.3 mvdan.cc/sh/v3 v3.13.1 - nabat.dev v0.6.3 + nabat.dev v0.8.0 sigs.k8s.io/e2e-framework v0.7.0 sigs.k8s.io/kind v0.32.0 sigs.k8s.io/yaml v1.6.0 @@ -63,10 +63,10 @@ require ( github.com/chai2010/gettext-go v1.0.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260803092147-8b693049ce2a // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20260705004817-2cc9a8fe1146 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260803091719-3755ebad01b1 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -89,7 +89,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dlclark/regexp2/v2 v2.2.2 // indirect + github.com/dlclark/regexp2/v2 v2.5.2 // indirect github.com/docker/cli v29.6.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/docker/go-connections v0.8.1 // indirect @@ -144,11 +144,11 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.12.3 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/miekg/dns v1.1.68 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -196,7 +196,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark v1.8.5 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index 96af928..ab23e80 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/ultraviolet v0.0.0-20260803092147-8b693049ce2a h1:v7lgqJrF9VVoemVEpBKrstxfDR1476qJU8Vy7ZY5Ho4= +github.com/charmbracelet/ultraviolet v0.0.0-20260803092147-8b693049ce2a/go.mod h1:psnCZIfwwxVs6v6DhUc6NJ8AQ3ejvs2ejKwoOMeVmUk= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= @@ -89,8 +89,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/slice v0.0.0-20260705004817-2cc9a8fe1146 h1:1KOokKxCor/1b/idDJ67I/7xUYx6lIhcD8aG4OZKQSc= -github.com/charmbracelet/x/exp/slice v0.0.0-20260705004817-2cc9a8fe1146/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260803091719-3755ebad01b1 h1:mg80wPD5ZV0wDYdZAhHilmwGaTv8uEedVRnNiM0TehA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260803091719-3755ebad01b1/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -154,8 +154,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0= -github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= +github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= @@ -357,16 +357,16 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 h1:BXxTozrOU8zgC5dkpn3J6NTRdoP+hjok/e+ACr4Hibk= github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3/go.mod h1:x1uk6vxTiVuNt6S5R2UYgdhpj3oKojXvOXauHZ7dEnI= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -511,8 +511,8 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= @@ -704,8 +704,8 @@ k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0x k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= -nabat.dev v0.6.3 h1:SFYOSHJTMzJzacHyVzvnEq0IA95+r0PpxQr/aRjM2UY= -nabat.dev v0.6.3/go.mod h1:A1OPeIUHLkDG8BOnO3rfeBubcw0iPJ8GY7MuB/0lBB0= +nabat.dev v0.8.0 h1:8tilU5GFZQHC0R7SzTtFO0a5xi9CMuQEG7ZMuYpUCrg= +nabat.dev v0.8.0/go.mod h1:S44WwDY/h4hQgAPrtEmazmFuQUO8eKISu1CR0Ea/nJE= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= diff --git a/internal/cmd/cluster/cluster.go b/internal/cmd/cluster/cluster.go index 3af3f2e..49a3eb7 100644 --- a/internal/cmd/cluster/cluster.go +++ b/internal/cmd/cluster/cluster.go @@ -46,7 +46,7 @@ func Register(app *nabat.App) { registerUp(group) registerDown(group) - registerStatus(group, app) + registerStatus(group) registerKubeconfig(group) } @@ -69,21 +69,6 @@ func newManager(c *nabat.Context, extra ...localkube.Option) (*localkube.Manager return localkube.New(opts...) } -// runMaybeSpinner runs fn, optionally wrapped in a spinner. When spin is true -// it delegates to Context.Spinner; otherwise it calls fn directly with a nil -// handle. The no-spinner path is used for fast idempotent no-ops, where the -// spinner would add visual noise and (because it starts a Bubble Tea program) -// can leak terminal capability-probe replies onto the next shell prompt. -// -// Callbacks must guard handle use with a nil check, since fn receives nil on -// the no-spinner path. -func runMaybeSpinner(c *nabat.Context, title string, spin bool, fn func(*nabat.Spinner) error) error { - if spin { - return c.Spinner(title, fn) - } - return fn(nil) -} - // phaseLabel maps a localkube Step to a short human-readable phrase for use // as a spinner subtitle. Returns "" for steps that don't need a subtitle // (e.g. the top-level StepCreating, whose base title already covers it). diff --git a/internal/cmd/cluster/down.go b/internal/cmd/cluster/down.go index b02340f..72a3360 100644 --- a/internal/cmd/cluster/down.go +++ b/internal/cmd/cluster/down.go @@ -63,33 +63,33 @@ func runDown(c *nabat.Context) error { return nil } - if !opts.Force { - confirmed, confirmErr := c.Confirm( - "Delete the local cluster? This removes all workloads running in it.", - nabat.WithAffirmative("Yes, delete it"), - nabat.WithNegative("No, cancel"), - ) - if confirmErr != nil { - return fmt.Errorf("confirmation: %w", confirmErr) - } - if !confirmed { - c.Info("Delete cancelled") - return nil - } + confirmed, confirmErr := c.Confirm( + "Delete the local cluster? This removes all workloads running in it.", + nabat.WithAffirmative("Yes, delete it"), + nabat.WithNegative("No, cancel"), + nabat.WithYes(opts.Force), + nabat.WithBypassHint("--force"), + ) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + c.Info("Delete cancelled") + return nil } // Stop the cloud provider container first; ignore ErrUnsupported (engine mismatch). - if spinErr := c.Spinner("Stopping cloud provider...", func(_ *nabat.Spinner) error { + if spinErr := c.Spinner(func(_ *nabat.Spinner) error { stopErr := m.StopCloudProvider(c, localkube.WithClusterName(clusterName)) if stopErr != nil && !errors.Is(stopErr, localkube.ErrUnsupported) { return stopErr } return nil - }); spinErr != nil { + }, nabat.WithTitle("Stopping cloud provider...")); spinErr != nil { return fmt.Errorf("stop cloud provider: %w", spinErr) } - if spinErr := c.Spinner("Deleting local cluster", func(sp *nabat.Spinner) error { + if spinErr := c.Spinner(func(sp *nabat.Spinner) error { return m.Delete(c, clusterName, localkube.WithIgnoreMissing(), localkube.WithDeleteEventHandler(func(e localkube.Event) { @@ -100,7 +100,7 @@ func runDown(c *nabat.Context) error { } }), ) - }); spinErr != nil { + }, nabat.WithTitle("Deleting local cluster")); spinErr != nil { return fmt.Errorf("delete local cluster: %w", spinErr) } diff --git a/internal/cmd/cluster/status.go b/internal/cmd/cluster/status.go index 96abcc2..b96aa45 100644 --- a/internal/cmd/cluster/status.go +++ b/internal/cmd/cluster/status.go @@ -71,8 +71,7 @@ type clusterStatusView struct { } // registerStatus attaches the "status" subcommand to the cluster group. -// app is passed so the command can read the resolved theme at run time. -func registerStatus(group *nabat.Command, app *nabat.App) { +func registerStatus(group *nabat.Command) { group.MustCommand("status", nabat.WithDescription("Show the local cluster status and access info"), nabat.WithLongDescription("Show the local cluster's health, metadata, whether the cloud provider is running, "+ @@ -84,13 +83,11 @@ deployah cluster status # Output as JSON deployah cluster status --output json`), - nabat.WithRun(func(c *nabat.Context) error { - return runStatus(c, app.Theme()) - }), + nabat.WithRun(runStatus), ) } -func runStatus(c *nabat.Context, th theme.ResolvedTheme) error { +func runStatus(c *nabat.Context) error { opts := &statusOptions{} if err := c.Bind(opts); err != nil { return fmt.Errorf("binding options: %w", err) @@ -146,7 +143,7 @@ func runStatus(c *nabat.Context, th theme.ResolvedTheme) error { case cli.OutputFormatYAML: return c.YAML(view) default: - renderStatusSummary(c, th, view) + renderStatusSummary(c, view) return nil } } @@ -320,36 +317,32 @@ func hasPortMappedAccess(entries []accessEntry) bool { // renderStatusSummary prints the cluster status and access info as human-readable // output: a compact key/value summary (themed) followed by the access table. -func renderStatusSummary(c *nabat.Context, th theme.ResolvedTheme, view clusterStatusView) { - // Header line: themed cluster name + colored status badge. +func renderStatusSummary(c *nabat.Context, view clusterStatusView) { c.Printf("%s %s\n\n", - th.Style(theme.TextTitle).Render(view.Name), - statusBadge(th, view.Status)) + c.Render(theme.TextTitle, view.Name), + c.Badge(statusIcon(view.Status), view.Status)) - // Aligned key/value summary. Pad the label first, then style it, so the - // ANSI escapes are not counted in the column width and alignment holds. - label := th.Style(theme.TextMuted) - field := func(name, value string) { - c.Printf(" %s %s\n", label.Render(fmt.Sprintf("%-14s", name)), value) + cloudProvider := boolText(view.CloudProviderRunning, "running", "stopped") + fields := []nabat.Field{ + {Key: "Backend", Value: fmt.Sprintf("%s (%s)", view.Backend, view.Runtime)}, + {Key: "Nodes", Value: nodesText(view.Nodes, view.Roles)}, + {Key: "Context", Value: view.Context}, + {Key: "Cloud provider", Value: c.Badge(statusIcon(cloudProvider), cloudProvider)}, } - - field("Backend", fmt.Sprintf("%s (%s)", view.Backend, view.Runtime)) - field("Nodes", nodesText(view.Nodes, view.Roles)) - field("Context", view.Context) - field("Cloud provider", statusBadge(th, boolText(view.CloudProviderRunning, "running", "stopped"))) if view.CreatedAt != "" { - field("Created", view.CreatedAt) + fields = append(fields, nabat.Field{Key: "Created", Value: view.CreatedAt}) } if view.Kubeconfig != "" { - field("Kubeconfig", shortenHome(view.Kubeconfig)) + fields = append(fields, nabat.Field{Key: "Kubeconfig", Value: shortenHome(view.Kubeconfig)}) } + c.Fields(fields, nabat.WithFieldKeyWidth(14)).Print() if len(view.Access) == 0 { return } c.Println("") - c.Printf("%s\n", th.Style(theme.TextTitle).Render("Ingress / LoadBalancer access")) + c.Printf("%s\n", c.Render(theme.TextTitle, "Ingress / LoadBalancer access")) accessRows := make([][]string, 0, len(view.Access)) for _, a := range view.Access { target := a.Host @@ -371,18 +364,18 @@ func renderStatusSummary(c *nabat.Context, th theme.ResolvedTheme, view clusterS } if len(hostsLines) > 0 { c.Println("") - c.Printf("%s\n", th.Style(theme.TextMuted).Render("Add these entries to /etc/hosts to resolve ingress hosts locally:")) + c.Printf("%s\n", c.Render(theme.TextMuted, "Add these entries to /etc/hosts to resolve ingress hosts locally:")) c.Println(" " + strings.Join(hostsLines, "\n ")) } if len(curlLines) > 0 { c.Println("") - c.Printf("%s\n", th.Style(theme.TextMuted).Render("Or test without editing /etc/hosts:")) + c.Printf("%s\n", c.Render(theme.TextMuted, "Or test without editing /etc/hosts:")) c.Println(" " + strings.Join(curlLines, "\n ")) } if hasPortMappedAccess(view.Access) { c.Println("") - c.Printf("%s\n", th.Style(theme.TextMuted).Render( + c.Printf("%s\n", c.Render(theme.TextMuted, "Note: gateway ports are bound on all interfaces (0.0.0.0); your firewall may prompt for access.")) } } @@ -434,22 +427,19 @@ func pluralizeRole(role string, count int) string { return role + "s" } -// statusBadge renders a themed "● " indicator using nabat theme tokens. -// Colors are stripped automatically by the output writer when stdout is not a -// TTY or NO_COLOR is set. -func statusBadge(th theme.ResolvedTheme, status string) string { - var tok theme.Token +// statusIcon maps a cluster/cloud-provider status word onto a Nabat badge icon. +// Deployah owns the word→icon mapping; Nabat does not interpret status strings. +func statusIcon(status string) nabat.Icon { switch status { case "running": - tok = theme.StatusSuccess + return nabat.IconSuccess case "unhealthy": - tok = theme.StatusWarning + return nabat.IconWarning case "stopped": - tok = theme.StatusError + return nabat.IconError default: // "unknown" and anything else - tok = theme.TextMuted + return nabat.IconUnknown } - return th.Style(tok).Render("● " + status) } // shortenHome replaces the user's home directory prefix with "~" so long paths diff --git a/internal/cmd/cluster/up.go b/internal/cmd/cluster/up.go index b3b83a8..a85bf70 100644 --- a/internal/cmd/cluster/up.go +++ b/internal/cmd/cluster/up.go @@ -99,15 +99,15 @@ func runUp(c *nabat.Context) error { _, getErr := m.Get(c, clusterName) clusterExisted := getErr == nil - // Skip the spinner when the cluster already exists: Create is then a fast - // idempotent no-op, so the spinner only adds noise (and risks leaking - // terminal probe replies on the quick path). + // Spinner delays animation until work runs past the default grace period, + // so a fast idempotent Create (cluster already exists) prints at most a + // static line and never probes the terminal. const createTitle = "Creating local cluster" - if spinErr := runMaybeSpinner(c, createTitle, !clusterExisted, func(sp *nabat.Spinner) error { + if spinErr := c.Spinner(func(sp *nabat.Spinner) error { return m.Create(c, clusterName, localkube.WithCreateIfMissing(), localkube.WithCreateEventHandler(func(e localkube.Event) { - if sp == nil || e.Status != localkube.StepStarted { + if e.Status != localkube.StepStarted { return } if lbl := phaseLabel(e.Step); lbl != "" { @@ -115,7 +115,7 @@ func runUp(c *nabat.Context) error { } }), ) - }); spinErr != nil { + }, nabat.WithTitle(createTitle)); spinErr != nil { return fmt.Errorf("create local cluster: %w", spinErr) } @@ -185,13 +185,12 @@ func runUp(c *nabat.Context) error { } // Background: start container and return immediately. Capture the prior - // state first so the message reflects whether we actually started it, and - // skip the spinner when it is already running (StartCloudProvider is then a - // fast no-op). + // state first so the message reflects whether we actually started it. + // Delayed spinner covers the already-running fast path. cloudWasRunning := m.CloudProviderRunning(c) - if startErr := runMaybeSpinner(c, "Starting cloud provider...", !cloudWasRunning, func(_ *nabat.Spinner) error { + if startErr := c.Spinner(func(_ *nabat.Spinner) error { return m.StartCloudProvider(c) - }); startErr != nil { + }, nabat.WithTitle("Starting cloud provider...")); startErr != nil { if errors.Is(startErr, localkube.ErrUnsupported) { c.Info("Cloud provider not supported on this runtime; LoadBalancer and Ingress will not be reachable") nextSteps(true) diff --git a/internal/cmd/delete/delete.go b/internal/cmd/delete/delete.go index 1f73a2a..80ca5af 100644 --- a/internal/cmd/delete/delete.go +++ b/internal/cmd/delete/delete.go @@ -149,34 +149,34 @@ func runDelete(c *nabat.Context) error { return renderDryRunPreview(c, opts.Project, opts.Environment, release, opts.ShowResources, opts.Output) } - if !opts.Yes { - targetCtx := cluster.Context() - if fallback, current := cluster.ContextFallback(); fallback { - targetCtx = current - } - prompt := fmt.Sprintf("Delete project '%s' in environment '%s'?", opts.Project, opts.Environment) - if targetCtx != "" { - prompt = fmt.Sprintf("Delete project '%s' in environment '%s' (context: %s)?", opts.Project, opts.Environment, targetCtx) - } - confirmed, confirmErr := c.Confirm( - prompt, - nabat.WithAffirmative("Yes, delete it"), - nabat.WithNegative("No, cancel"), - ) - if confirmErr != nil { - return fmt.Errorf("confirmation: %w", confirmErr) - } - if !confirmed { - c.Info("Delete cancelled") - return nil - } + targetCtx := cluster.Context() + if fallback, current := cluster.ContextFallback(); fallback { + targetCtx = current + } + prompt := fmt.Sprintf("Delete project '%s' in environment '%s'?", opts.Project, opts.Environment) + if targetCtx != "" { + prompt = fmt.Sprintf("Delete project '%s' in environment '%s' (context: %s)?", opts.Project, opts.Environment, targetCtx) + } + confirmed, confirmErr := c.Confirm( + prompt, + nabat.WithAffirmative("Yes, delete it"), + nabat.WithNegative("No, cancel"), + nabat.WithYes(opts.Yes), + nabat.WithBypassHint("--yes"), + ) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + c.Info("Delete cancelled") + return nil } err = c.Spinner( - fmt.Sprintf("Deleting '%s' in '%s'...", opts.Project, opts.Environment), func(_ *nabat.Spinner) error { return helmClient.DeleteRelease(c, opts.Project, opts.Environment, opts.Wait) }, + nabat.WithTitle(fmt.Sprintf("Deleting '%s' in '%s'...", opts.Project, opts.Environment)), ) if err != nil { return fmt.Errorf("delete release: %w", err) diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go index 2b26e98..1b34cb2 100644 --- a/internal/cmd/deploy/deploy.go +++ b/internal/cmd/deploy/deploy.go @@ -12,7 +12,6 @@ import ( "helm.sh/helm/v4/pkg/postrenderer" "k8s.io/client-go/kubernetes" "nabat.dev/nabat" - "nabat.dev/theme" "deployah.dev/deployah/internal/cmd/cmdopts" "deployah.dev/deployah/internal/extras" @@ -67,12 +66,7 @@ deployah deploy prod --explain # Preview what a deploy would change, without touching the cluster deployah plan prod --offline`), - nabat.WithRun(func(c *nabat.Context) error { - // Captured here (not read from *nabat.Context, which has no - // public accessor for it) so RenderText can color its output; - // see internal/plan/format_text.go's TextOptions.Theme doc. - return runDeploy(c, app.Theme()) - }), + nabat.WithRun(runDeploy), ) } @@ -85,12 +79,11 @@ type deployPlan struct { cleanup func() } -func runDeploy(c *nabat.Context, resolvedTheme theme.ResolvedTheme) error { +func runDeploy(c *nabat.Context) error { opts := &Options{} if err := c.Bind(opts); err != nil { return fmt.Errorf("binding options: %w", err) } - c.Logger().Debug("starting deployment process") sess := session.FromContext(c) @@ -205,7 +198,7 @@ func runDeploy(c *nabat.Context, resolvedTheme theme.ResolvedTheme) error { c.Printf("CRDs: %d from .deployah/crds/ (policy %s)\n", n, opts.CRDs) } - textOpts := planengine.TextOptions{Mode: planengine.ModeCompact, Theme: resolvedTheme} + textOpts := planengine.TextOptions{Mode: planengine.ModeCompact, Theme: c.Theme()} if renderErr := planengine.RenderText(c.IO().Out, plan.diff, textOpts); renderErr != nil { return fmt.Errorf("render plan: %w", renderErr) } @@ -263,18 +256,16 @@ func skipWhenIdle(helmIdle bool, crdCount int) bool { } // confirmApply gates the real apply behind --yes or an interactive prompt. -// proceed is false with a nil error on a clean "no"; err is non-nil only when -// non-interactive without --yes, or the prompt fails. prompt must be non-empty. +// proceed is false with a nil error on a clean "no"; err is non-nil when +// non-interactive without --yes ([nabat.ErrConfirmationRequired]), or the +// prompt fails. prompt must be non-empty. func confirmApply(c *nabat.Context, opts *Options, prompt string) (proceed bool, err error) { - if opts.Yes { - return true, nil - } - if !c.IsInteractive() { - return false, errors.New("refusing to deploy without confirmation; re-run with --yes") - } - confirmed, confirmErr := c.Confirm(prompt) + confirmed, confirmErr := c.Confirm(prompt, + nabat.WithYes(opts.Yes), + nabat.WithBypassHint("--yes"), + ) if confirmErr != nil { - return false, fmt.Errorf("confirmation: %w", confirmErr) + return false, confirmErr } return confirmed, nil } diff --git a/internal/cmd/deploy/deploy_flow_test.go b/internal/cmd/deploy/deploy_flow_test.go index b1c20d4..9da61d0 100644 --- a/internal/cmd/deploy/deploy_flow_test.go +++ b/internal/cmd/deploy/deploy_flow_test.go @@ -23,6 +23,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" + "nabat.dev/nabat" "deployah.dev/deployah/internal/extras" "deployah.dev/deployah/internal/k8s" @@ -32,7 +33,6 @@ import ( planengine "deployah.dev/deployah/internal/plan" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // newClusterWithStub builds a [session.Cluster] whose Kubernetes client is @@ -99,8 +99,8 @@ func TestConfirmApply(t *testing.T) { name string opts *Options wantProceed bool - wantErr bool - errContains []string + wantErrIs error + wantHint string }{ { name: "yes skips prompt", @@ -108,10 +108,10 @@ func TestConfirmApply(t *testing.T) { wantProceed: true, }, { - name: "non-interactive without yes refuses", - opts: &Options{Yes: false}, - wantErr: true, - errContains: []string{"refusing to deploy without confirmation", "--yes"}, + name: "non-interactive without yes refuses", + opts: &Options{Yes: false}, + wantErrIs: nabat.ErrConfirmationRequired, + wantHint: "--yes", }, } @@ -120,12 +120,12 @@ func TestConfirmApply(t *testing.T) { t.Parallel() c := nabatContext(t) // nabattest.NewIO reports non-TTY by default proceed, err := confirmApply(c, tt.opts, "Apply these changes?") - if tt.wantErr { - require.Error(t, err) + if tt.wantErrIs != nil { + require.ErrorIs(t, err, tt.wantErrIs) assert.False(t, proceed) - for _, s := range tt.errContains { - assert.Contains(t, err.Error(), s) - } + var ce *nabat.ConfirmationError + require.ErrorAs(t, err, &ce) + assert.Equal(t, tt.wantHint, ce.BypassHint) return } require.NoError(t, err) @@ -171,13 +171,11 @@ func TestSkipDeploy_NoChanges_ShowsReadinessSummary(t *testing.T) { t.Parallel() k8sClient := fake.NewSimpleClientset( &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "web-1", - Namespace: "default", - Labels: map[string]string{ - "app.kubernetes.io/instance": "web-production", - "app.kubernetes.io/component": "web", - }, + Name: "web-1", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "web-production", + "app.kubernetes.io/component": "web", }, Status: corev1.PodStatus{ Phase: corev1.PodRunning, @@ -300,13 +298,11 @@ func TestApplyCRDsOnly_ReportsSuccessAndReadiness(t *testing.T) { t.Parallel() k8sClient := fake.NewSimpleClientset( &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "web-1", - Namespace: "default", - Labels: map[string]string{ - "app.kubernetes.io/instance": "web-production", - "app.kubernetes.io/component": "web", - }, + Name: "web-1", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "web-production", + "app.kubernetes.io/component": "web", }, Status: corev1.PodStatus{ Phase: corev1.PodRunning, diff --git a/internal/cmd/deploy/deploy_test.go b/internal/cmd/deploy/deploy_test.go index 3681688..feb5767 100644 --- a/internal/cmd/deploy/deploy_test.go +++ b/internal/cmd/deploy/deploy_test.go @@ -121,15 +121,9 @@ func nabatContext(t *testing.T) *nabat.Context { // returned Context reports as non-interactive (no TTY). func nabatContextWithIO(t *testing.T) (*nabat.Context, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { t.Helper() - var captured *nabat.Context io, in, out, errOut := nabattest.NewIO() app := nabat.MustNew("test", nabat.WithIO(io)) - app.MustCommand("run", nabat.WithRun(func(c *nabat.Context) error { - captured = c - return nil - })) - require.NoError(t, nabattest.Run(t, app, []string{"run"})) - return captured, in, out, errOut + return nabattest.Context(t, app), in, out, errOut } // releaseWithResolvedFQDN builds a prior release whose deployah.resolved diff --git a/internal/cmd/deploy/watcher_test.go b/internal/cmd/deploy/watcher_test.go index 95655ef..06c537c 100644 --- a/internal/cmd/deploy/watcher_test.go +++ b/internal/cmd/deploy/watcher_test.go @@ -31,7 +31,6 @@ import ( "deployah.dev/deployah/internal/k8s" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clienttesting "k8s.io/client-go/testing" ) @@ -57,7 +56,7 @@ func makeDeployEvent(uid types.UID, evType, reason, object, message string, coun // written to stderr (the title + plain-text row table). func runStatus(t *testing.T, fn func(*nabat.Status), title string) string { t.Helper() - io, _, _, stderr := nabattest.NewIO() + io, _, _, _ := nabattest.NewIO() app := nabat.MustNew("test", nabat.WithIO(io)) app.MustCommand("run", nabat.WithRun(func(c *nabat.Context) error { return c.Status(func(st *nabat.Status) error { @@ -65,8 +64,9 @@ func runStatus(t *testing.T, fn func(*nabat.Status), title string) string { return nil }, nabat.WithTitle(title)) })) - require.NoError(t, nabattest.Run(t, app, []string{"run"})) - return stderr.String() + got := nabattest.Capture(t, app, []string{"run"}) + require.NoError(t, got.Err) + return got.Stderr.String() } // TestDeployWatcher_Warnings_CollectsOnlyWarningEvents verifies that @@ -259,13 +259,11 @@ func TestDeployWatcher_UpdateTitle_StaleWithoutPods(t *testing.T) { func readyPod(name, component, release string) *corev1.Pod { return &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: "default", - Labels: map[string]string{ - "app.kubernetes.io/instance": release, - "deployah.dev/component": component, - }, + Name: name, + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": release, + "deployah.dev/component": component, }, Status: corev1.PodStatus{ Phase: corev1.PodRunning, diff --git a/internal/cmd/initialize/components.go b/internal/cmd/initialize/components.go index 248d09b..4846e26 100644 --- a/internal/cmd/initialize/components.go +++ b/internal/cmd/initialize/components.go @@ -318,7 +318,7 @@ func collectComponentRole(c *nabat.Context, component *spec.Component, component labels = append(labels, roleLabels[r]) } - choice, err := nabat.Select(c, + choice, err := c.Select( fmt.Sprintf("Role for %s - how does this component run?", componentName), labels, roleLabels[spec.ComponentRoleService], @@ -341,7 +341,7 @@ func collectComponentKind(c *nabat.Context, component *spec.Component, component labels = append(labels, kindLabels[k]) } - choice, err := nabat.Select(c, + choice, err := c.Select( fmt.Sprintf("Kind for %s - does it keep data on disk?", componentName), labels, kindLabels[spec.ComponentKindStateless], @@ -412,7 +412,7 @@ func collectComponentResources(c *nabat.Context, component *spec.Component, comp } labels = append(labels, customResourcesLabel) - choice, err := nabat.Select(c, + choice, err := c.Select( fmt.Sprintf("Resources for %s — Select a resource preset or enter custom values", componentName), labels, presetLabel(spec.ResourcePresetSmall), @@ -549,23 +549,19 @@ func collectComponentAutoscaling(c *nabat.Context, component *spec.Component, co Enabled: true, } - // Initialize the bound variables so the TTY form shows the defaults - // prefilled. nabat.WithDefault only sets the non-interactive - // fallback value; it does not prefill the widget. - minReplicasStr := strconv.Itoa(DefaultMinReplicas) - maxReplicasStr := strconv.Itoa(DefaultMaxReplicas) + var minReplicasStr, maxReplicasStr string err = c.Form( nabat.WithFormTitle(fmt.Sprintf("Autoscaling Configuration for %s", componentName)), nabat.WithFormField(&minReplicasStr, "Minimum Replicas", "Minimum number of replicas to maintain", nabat.WithHint(strconv.Itoa(DefaultMinReplicas)), - nabat.WithDefault(strconv.Itoa(DefaultMinReplicas)), + nabat.WithPrefill(strconv.Itoa(DefaultMinReplicas)), nabat.WithValidate(func(s string) error { return validate.ValidatePositiveInteger(s, "minimum replicas") }), ), nabat.WithFormField(&maxReplicasStr, "Maximum Replicas", "Maximum number of replicas allowed", nabat.WithHint(strconv.Itoa(DefaultMaxReplicas)), - nabat.WithDefault(strconv.Itoa(DefaultMaxReplicas)), + nabat.WithPrefill(strconv.Itoa(DefaultMaxReplicas)), nabat.WithValidate(func(s string) error { return validate.ValidatePositiveInteger(s, "maximum replicas") }), ), ) @@ -748,7 +744,7 @@ func collectComponentEnvironments(c *nabat.Context, component *spec.Component, c return nil } - selectedEnvs, err := nabat.MultiSelect(c, + selectedEnvs, err := c.MultiSelect( fmt.Sprintf("Environment Selection for %s — Select one or more environments for this component", componentName), availableEnvironments, availableEnvironments, diff --git a/internal/cmd/initialize/environments.go b/internal/cmd/initialize/environments.go index e370cbf..7b7994e 100644 --- a/internal/cmd/initialize/environments.go +++ b/internal/cmd/initialize/environments.go @@ -42,19 +42,11 @@ func resolveEnvironments(c *nabat.Context, config *ProjectConfig, flagEnvironmen // When the local environment is declined, the input requires at least one // name; the rule is enforced inline so no warning outlives the step. func collectEnvironments(c *nabat.Context, config *ProjectConfig) error { - // A bound form field is used instead of c.Confirm: the ad-hoc Confirm - // only applies WithDefault as its non-interactive fallback, so it - // always starts on "No"; a form field starts on the target's value. - useLocal := true - err := c.Form( - nabat.WithFormGroup( - nabat.WithFormField(&useLocal, - StepEnvironments+" — Set up a local environment?", - "Creates a kind cluster config; matches 'deployah cluster up'", - nabat.WithAffirmative("Yes"), - nabat.WithNegative("No"), - ), - ), + useLocal, err := c.Confirm( + StepEnvironments+" — Set up a local environment?", + nabat.WithAffirmative("Yes"), + nabat.WithNegative("No"), + nabat.WithInitial(true), ) if err != nil { return fmt.Errorf("failed to collect local environment choice: %w", err) diff --git a/internal/cmd/initialize/init.go b/internal/cmd/initialize/init.go index b702248..cbad12e 100644 --- a/internal/cmd/initialize/init.go +++ b/internal/cmd/initialize/init.go @@ -167,49 +167,23 @@ func runInit(c *nabat.Context) error { // checkOverwrite guards against silently clobbering an existing spec file. // It reports whether the caller should proceed with initialization. func checkOverwrite(c *nabat.Context, opts *Options) (proceed bool, err error) { - confirmOverwrite := func(prompt string) (bool, error) { - return c.Confirm(prompt, - nabat.WithAffirmative("Yes, overwrite"), - nabat.WithNegative("No, cancel"), - ) - } - proceed, cancelMsg, err := resolveOverwrite(opts.Output, opts.Force, c.IsInteractive(), confirmOverwrite) - if err != nil { - return false, err - } - if cancelMsg != "" { - c.Info(cancelMsg) - } - return proceed, nil -} - -// resolveOverwrite implements the overwrite decision matrix, taking confirm -// as a func so the file-exists x force x interactive matrix can be unit -// tested without simulating a real TTY session. -func resolveOverwrite(path string, force, interactive bool, confirm func(prompt string) (bool, error)) (proceed bool, cancelMsg string, err error) { - if force { - return true, "", nil + if _, statErr := os.Stat(opts.Output); statErr != nil { + return true, nil } - if _, statErr := os.Stat(path); statErr != nil { - return true, "", nil - } - - if !interactive { - // Fail closed with a plain, grep-friendly error instead of letting - // nabat's Confirm crash with "requires interactive terminal". - return false, "", fmt.Errorf("%s already exists; pass --force to overwrite", path) - } - - // Only reached on the interactive, file-exists, non-force path. - overwrite, confirmErr := confirm(fmt.Sprintf("%s already exists. Overwrite it?", path)) + overwrite, confirmErr := c.Confirm( + fmt.Sprintf("%s already exists. Overwrite it?", opts.Output), + nabat.WithAffirmative("Yes, overwrite"), + nabat.WithNegative("No, cancel"), + nabat.WithYes(opts.Force), + nabat.WithBypassHint("--force"), + ) if confirmErr != nil { - return false, "", fmt.Errorf("failed to confirm overwrite: %w", confirmErr) + return false, confirmErr } if !overwrite { - // cancelMsg is non-empty only here, so the caller can print it. - return false, "Cancelled: " + path + " was not modified.", nil + c.Info("Cancelled: " + opts.Output + " was not modified.") + return false, nil } - - return true, "", nil + return true, nil } diff --git a/internal/cmd/initialize/init_test.go b/internal/cmd/initialize/init_test.go index bb58e97..b8719ab 100644 --- a/internal/cmd/initialize/init_test.go +++ b/internal/cmd/initialize/init_test.go @@ -1,126 +1,78 @@ package initialize import ( - "errors" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "nabat.dev/nabat" + "nabat.dev/nabat/nabattest" ) -// TestResolveOverwrite covers the file-exists x force x interactive matrix -// that guards deployah init from silently clobbering an existing spec. -func TestResolveOverwrite(t *testing.T) { +// TestCheckOverwrite covers the file-exists x force matrix that guards +// deployah init from silently clobbering an existing spec. +func TestCheckOverwrite(t *testing.T) { t.Parallel() existing := filepath.Join(t.TempDir(), "deployah.yaml") require.NoError(t, os.WriteFile(existing, []byte("apiVersion: v1-alpha.2\n"), 0o600)) - missing := filepath.Join(t.TempDir(), "deployah.yaml") - - failConfirm := func(string) (bool, error) { - return false, errors.New("confirm must not be called on this path") - } + missing := filepath.Join(t.TempDir(), "missing.yaml") tests := []struct { name string path string force bool - interactive bool - confirm func(string) (bool, error) wantProceed bool - wantCancel string - wantErr string + wantErrIs error + wantHint string }{ { name: "force with existing file always proceeds", path: existing, force: true, - interactive: false, - confirm: failConfirm, wantProceed: true, }, { name: "force with missing file always proceeds", path: missing, force: true, - interactive: false, - confirm: failConfirm, - wantProceed: true, - }, - { - name: "no force, missing file, non-interactive proceeds", - path: missing, - force: false, - interactive: false, - confirm: failConfirm, wantProceed: true, }, { - name: "no force, missing file, interactive proceeds", + name: "no force, missing file proceeds", path: missing, force: false, - interactive: true, - confirm: failConfirm, wantProceed: true, }, { name: "no force, existing file, non-interactive fails closed", path: existing, force: false, - interactive: false, - confirm: failConfirm, wantProceed: false, - wantErr: "pass --force to overwrite", - }, - { - name: "no force, existing file, interactive, confirmed", - path: existing, - force: false, - interactive: true, - confirm: func(string) (bool, error) { return true, nil }, - wantProceed: true, - }, - { - name: "no force, existing file, interactive, declined", - path: existing, - force: false, - interactive: true, - confirm: func(string) (bool, error) { return false, nil }, - wantProceed: false, - wantCancel: "Cancelled: " + existing + " was not modified.", + wantErrIs: nabat.ErrConfirmationRequired, + wantHint: "--force", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - proceed, cancelMsg, err := resolveOverwrite(tt.path, tt.force, tt.interactive, tt.confirm) - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) + io, _, _, _ := nabattest.NewIO() + app := nabat.MustNew("test", nabat.WithIO(io)) + c := nabattest.Context(t, app) + + proceed, err := checkOverwrite(c, &Options{Output: tt.path, Force: tt.force}) + if tt.wantErrIs != nil { + require.ErrorIs(t, err, tt.wantErrIs) + var ce *nabat.ConfirmationError + require.ErrorAs(t, err, &ce) + assert.Equal(t, tt.wantHint, ce.BypassHint) } else { require.NoError(t, err) } assert.Equal(t, tt.wantProceed, proceed) - assert.Equal(t, tt.wantCancel, cancelMsg) }) } } - -// TestResolveOverwrite_ConfirmError verifies that an error from the confirm -// callback propagates instead of being swallowed. -func TestResolveOverwrite_ConfirmError(t *testing.T) { - t.Parallel() - - existing := filepath.Join(t.TempDir(), "deployah.yaml") - require.NoError(t, os.WriteFile(existing, []byte("apiVersion: v1-alpha.2\n"), 0o600)) - - sentinel := errors.New("boom") - _, _, err := resolveOverwrite(existing, false, true, func(string) (bool, error) { - return false, sentinel - }) - require.Error(t, err) - assert.ErrorIs(t, err, sentinel) -} diff --git a/internal/cmd/initialize/summary.go b/internal/cmd/initialize/summary.go index 44e9493..6ad1dc7 100644 --- a/internal/cmd/initialize/summary.go +++ b/internal/cmd/initialize/summary.go @@ -35,12 +35,13 @@ func showSummaryAndSave(c *nabat.Context, config *ProjectConfig) error { return nil } - // WithDefault(true) lets non-interactive / CI mode save without prompting. + // WithPrefill(true) seeds the TTY on Yes and lets non-interactive / CI + // mode save without prompting. save, err := c.Confirm( fmt.Sprintf("Save to %s?", config.OutputPath), nabat.WithAffirmative("Yes"), nabat.WithNegative("No"), - nabat.WithDefault(true), + nabat.WithPrefill(true), ) if err != nil { return fmt.Errorf("failed to confirm save: %w", err) diff --git a/internal/cmd/initialize/summary_test.go b/internal/cmd/initialize/summary_test.go index f4443cc..d2fdd67 100644 --- a/internal/cmd/initialize/summary_test.go +++ b/internal/cmd/initialize/summary_test.go @@ -14,20 +14,13 @@ import ( ) // nabatContext builds a minimal *nabat.Context for tests that call -// functions requiring one. +// functions requiring one. Non-TTY: any prompt reachable from the tested +// function must have a fallback, or must not be reached. func nabatContext(t *testing.T) *nabat.Context { t.Helper() - var captured *nabat.Context - // Non-TTY: any prompt reachable from the tested function must have a - // fallback, or must not be reached. io, _, _, _ := nabattest.NewIO() app := nabat.MustNew("test", nabat.WithIO(io)) - app.MustCommand("run", nabat.WithRun(func(c *nabat.Context) error { - captured = c - return nil - })) - require.NoError(t, nabattest.Run(t, app, []string{"run"})) - return captured + return nabattest.Context(t, app) } // TestShowSummaryAndSave_RoleAwareComponentsProduceValidSpec is an @@ -195,13 +188,8 @@ func TestShowSummaryAndSave_ExtrasAlreadyExist(t *testing.T) { } io, _, _, errOut := nabattest.NewIO() - var captured *nabat.Context app := nabat.MustNew("test", nabat.WithIO(io)) - app.MustCommand("run", nabat.WithRun(func(c *nabat.Context) error { - captured = c - return nil - })) - require.NoError(t, nabattest.Run(t, app, []string{"run"})) - require.NoError(t, showSummaryAndSave(captured, config)) + c := nabattest.Context(t, app) + require.NoError(t, showSummaryAndSave(c, config)) assert.Contains(t, errOut.String(), ".deployah/manifests/ and .deployah/crds/ already exist") } diff --git a/internal/cmd/plan/plan.go b/internal/cmd/plan/plan.go index d4f52b7..993ded2 100644 --- a/internal/cmd/plan/plan.go +++ b/internal/cmd/plan/plan.go @@ -15,11 +15,12 @@ package plan import ( + "bytes" "errors" "fmt" + "strings" "nabat.dev/nabat" - "nabat.dev/theme" "deployah.dev/deployah/internal/cmd/cmdopts" "deployah.dev/deployah/internal/drift" @@ -77,12 +78,7 @@ deployah plan production --output json # Gate a CI job on exit code 2 (pending changes) vs. 0 (no changes) deployah plan production --detailed-exitcode`), - nabat.WithRun(func(c *nabat.Context) error { - // Captured here (not read from *nabat.Context, which has no - // public accessor for it) so RenderText can color its output; - // see internal/plan/format_text.go's TextOptions.Theme doc. - return runPlan(c, app.Theme()) - }), + nabat.WithRun(runPlan), ) } @@ -112,12 +108,11 @@ func validateOptions(c *nabat.Context) error { return nil } -func runPlan(c *nabat.Context, resolvedTheme theme.ResolvedTheme) error { +func runPlan(c *nabat.Context) error { opts := &Options{} if err := c.Bind(opts); err != nil { return fmt.Errorf("binding options: %w", err) } - sess := session.FromContext(c) // Prescan the raw (pre-envsubst) manifest for ${VAR} tokens so the @@ -163,7 +158,7 @@ func runPlan(c *nabat.Context, resolvedTheme theme.ResolvedTheme) error { if opts.Offline { return runOffline(c, sess, platform, manifest, opts, resolvedSpec) } - return runOnline(c, sess, platform, manifest, opts, resolvedSpec, resolvedTheme) + return runOnline(c, sess, platform, manifest, opts, resolvedSpec) } // runOffline renders the chart without contacting the cluster and prints a @@ -218,7 +213,7 @@ func runOffline(c *nabat.Context, sess *session.Session, platform *spec.Platform // runOnline renders the chart, diffs it against the last successful // release, and displays the resulting plan. -func runOnline(c *nabat.Context, sess *session.Session, platform *spec.PlatformConfig, manifest *spec.Spec, opts *Options, resolvedSpec *spec.ResolvedSpec, resolvedTheme theme.ResolvedTheme) error { +func runOnline(c *nabat.Context, sess *session.Session, platform *spec.PlatformConfig, manifest *spec.Spec, opts *Options, resolvedSpec *spec.ResolvedSpec) error { cluster, err := sess.Target(c, opts.Environment) if err != nil { return fmt.Errorf("target cluster: %w", err) @@ -274,7 +269,7 @@ func runOnline(c *nabat.Context, sess *session.Session, platform *spec.PlatformC } } - return outputPlan(c, p, opts, resolvedTheme) + return outputPlan(c, p, opts) } // checkDrift runs `--drift` detection against the resolved cluster and @@ -309,16 +304,20 @@ func checkDrift(c *nabat.Context, cluster *session.Cluster, p *planengine.Plan, return nil } -func outputPlan(c *nabat.Context, p *planengine.Plan, opts *Options, resolvedTheme theme.ResolvedTheme) error { +func outputPlan(c *nabat.Context, p *planengine.Plan, opts *Options) error { if opts.OutputFormat == outputFormatJSON { - if err := planengine.RenderJSON(c.IO().Out, p); err != nil { + var buf bytes.Buffer + if err := planengine.RenderJSON(&buf, p); err != nil { return fmt.Errorf("render json: %w", err) } + if err := c.FprintHighlight(c.IO().Out, strings.TrimRight(buf.String(), "\n"), "json"); err != nil { + return fmt.Errorf("write json: %w", err) + } } else { textOpts := planengine.TextOptions{ Mode: textMode(opts), ShowSecrets: opts.ShowSecrets, - Theme: resolvedTheme, + Theme: c.Theme(), } if err := planengine.RenderText(c.IO().Out, p, textOpts); err != nil { return fmt.Errorf("render text: %w", err) diff --git a/internal/cmd/plan/plan_test.go b/internal/cmd/plan/plan_test.go index 6a6d36d..c49b4ac 100644 --- a/internal/cmd/plan/plan_test.go +++ b/internal/cmd/plan/plan_test.go @@ -29,7 +29,6 @@ import ( "k8s.io/apimachinery/pkg/labels" "nabat.dev/nabat" "nabat.dev/nabat/nabattest" - "nabat.dev/theme" "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/render" @@ -102,20 +101,12 @@ func (s *stubHelmClient) RollbackRelease(context.Context, string, int, time.Dura var _ session.HelmClient = (*stubHelmClient)(nil) -// nabatContext returns a bare *nabat.Context and its captured stdout buffer, -// following the same pattern as internal/cmd/deploy/deploy_test.go's -// nabatContext helper. +// nabatContext returns a bare *nabat.Context and its captured stdout buffer. func nabatContext(t *testing.T) (*nabat.Context, *bytes.Buffer) { t.Helper() io, _, out, _ := nabattest.NewIO() - var captured *nabat.Context app := nabat.MustNew("test", nabat.WithIO(io)) - app.MustCommand("run", nabat.WithRun(func(c *nabat.Context) error { - captured = c - return nil - })) - require.NoError(t, nabattest.Run(t, app, []string{"run"})) - return captured, out + return nabattest.Context(t, app), out } // sessionWithStub builds a [session.Session] whose Helm client is stub. @@ -312,7 +303,7 @@ func TestRunOnline(t *testing.T) { opts := testOptions() opts.DetailedExitCode = tt.detailed - err := runOnline(c, sess, nil, testManifest(), opts, nil, theme.ResolvedTheme{}) + err := runOnline(c, sess, nil, testManifest(), opts, nil) if tt.wantErrIs != nil { require.Error(t, err) assert.ErrorIs(t, err, tt.wantErrIs) @@ -342,17 +333,12 @@ func TestRunOnline_DriftOnFreshInstall_NoStdoutFootprint(t *testing.T) { sess := sessionWithStub(stub) io, _, out, errOut := nabattest.NewIO() - var captured *nabat.Context app := nabat.MustNew("test", nabat.WithIO(io)) - app.MustCommand("run", nabat.WithRun(func(c *nabat.Context) error { - captured = c - return nil - })) - require.NoError(t, nabattest.Run(t, app, []string{"run"})) + c := nabattest.Context(t, app) opts := testOptions() opts.Drift = true - err := runOnline(captured, sess, nil, testManifest(), opts, nil, theme.ResolvedTheme{}) + err := runOnline(c, sess, nil, testManifest(), opts, nil) require.NoError(t, err, "checkDrift must short-circuit cleanly without a working cluster config") assert.NotContains(t, out.String(), "Drift (cluster changed outside deployah):", @@ -492,7 +478,7 @@ spec: c, out := nabatContext(t) c.SetContext(session.WithContext(c.Context(), sess)) - err := runOnline(c, sess, nil, testManifest(), testOptions(), nil, theme.ResolvedTheme{}) + err := runOnline(c, sess, nil, testManifest(), testOptions(), nil) require.NoError(t, err) assert.Contains(t, out.String(), "CRDs: 1 pending from .deployah/crds/") } diff --git a/internal/cmd/resolve/resolve.go b/internal/cmd/resolve/resolve.go index 0fb03af..028ad4a 100644 --- a/internal/cmd/resolve/resolve.go +++ b/internal/cmd/resolve/resolve.go @@ -18,7 +18,6 @@ package resolve import ( - "encoding/json" "fmt" "maps" "slices" @@ -322,12 +321,7 @@ func runEnvironmentsOverview(c *nabat.Context, sess *session.Session, output str rows := buildEnvironmentOverview(rawSpec, platform, sess.CurrentKubeContext()) if strings.EqualFold(output, "json") { - data, marshalErr := json.MarshalIndent(map[string][]envOverviewRow{"environments": rows}, "", " ") - if marshalErr != nil { - return fmt.Errorf("json marshal: %w", marshalErr) - } - c.Println(string(data)) - return nil + return c.JSON(map[string][]envOverviewRow{"environments": rows}) } if platform == nil { @@ -386,10 +380,5 @@ func outputJSON(c *nabat.Context, resolved *spec.ResolvedSpec, report *spec.Reso } } - data, err := json.MarshalIndent(out, "", " ") - if err != nil { - return fmt.Errorf("json marshal: %w", err) - } - c.Println(string(data)) - return nil + return c.JSON(out) } diff --git a/internal/cmd/shell/selector.go b/internal/cmd/shell/selector.go index 5f43477..84c2803 100644 --- a/internal/cmd/shell/selector.go +++ b/internal/cmd/shell/selector.go @@ -12,7 +12,7 @@ func selectComponentInteractively(c *nabat.Context, components []string) (string return components[0], nil } - return nabat.Select(c, + return c.Select( "Select Component — Choose which component to connect to:", components, components[0], @@ -33,7 +33,7 @@ func selectContainerInteractively(c *nabat.Context, containers []string, compone } } - return nabat.Select(c, + return c.Select( fmt.Sprintf("Select Container — Choose which container in component '%s' to connect to:", componentName), containers, defaultContainer, @@ -46,7 +46,7 @@ func selectEnvironmentInteractively(c *nabat.Context, environments []string, pro return environments[0], nil } - return nabat.Select(c, + return c.Select( fmt.Sprintf("Select Environment — Choose which environment for project '%s' component '%s' to connect to:", projectName, componentName), environments, environments[0], diff --git a/internal/extras/crd_test.go b/internal/extras/crd_test.go index bbb46f9..bd7b834 100644 --- a/internal/extras/crd_test.go +++ b/internal/extras/crd_test.go @@ -95,7 +95,7 @@ func (f *fakeCRDClient) Apply(_ context.Context, name string, patch []byte) (*ap } f.lastPatch = append([]byte(nil), patch...) crd := &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: name}, + Name: name, } f.objects[name] = crd f.applies++ @@ -174,7 +174,7 @@ func TestApplyCRDs_CreateIfMissing(t *testing.T) { client := newFakeCRDClient() client.establishAfterNGets = 1 existing := &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: "existing.example.com", ResourceVersion: "1"}, + Name: "existing.example.com", ResourceVersion: "1", } client.objects[existing.Name] = existing @@ -197,7 +197,7 @@ func TestApplyCRDs_CreateReplaceAppliesExisting(t *testing.T) { client := newFakeCRDClient() client.establishAfterNGets = 1 client.objects["widgets.example.com"] = &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: "widgets.example.com", ResourceVersion: "7"}, + Name: "widgets.example.com", ResourceVersion: "7", } stats, err := applyCRDs(t.Context(), client, []Object{ @@ -263,7 +263,7 @@ func TestApplyCRDs_WaitAPIError(t *testing.T) { t.Parallel() client := newFakeCRDClient() client.objects["broken.example.com"] = &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: "broken.example.com"}, + Name: "broken.example.com", } client.failGetAfter = 1 @@ -411,7 +411,7 @@ spec: t.Parallel() client := newFakeCRDClient() client.objects["widgets.example.com"] = &apiextensionsv1.CustomResourceDefinition{ - ObjectMeta: metav1.ObjectMeta{Name: "widgets.example.com"}, + Name: "widgets.example.com", } client.applyErr = errors.New("ssa rejected") _, err := applyCRDs(t.Context(), client, []Object{ diff --git a/internal/helm/helm.go b/internal/helm/helm.go index 62ab519..5e9cff3 100644 --- a/internal/helm/helm.go +++ b/internal/helm/helm.go @@ -456,8 +456,7 @@ func (c *Client) wrapHelmError(operation, releaseName string, err error) error { return fmt.Errorf("operation timed out for release '%s': %w", releaseName, err) } - var opErr *net.OpError - if errors.As(err, &opErr) { + if _, ok := errors.AsType[*net.OpError](err); ok { return fmt.Errorf("unable to connect to Kubernetes cluster: %w", err) } diff --git a/internal/k8s/events_test.go b/internal/k8s/events_test.go index 02de0ad..1c27fed 100644 --- a/internal/k8s/events_test.go +++ b/internal/k8s/events_test.go @@ -44,12 +44,10 @@ const ( func makeEvent(uid types.UID, involvedName, reason, message, evType, rv string) *corev1.Event { now := metav1.NewTime(time.Now()) return &corev1.Event{ - ObjectMeta: metav1.ObjectMeta{ - Name: involvedName + "." + string(uid), - Namespace: testNamespace, - UID: uid, - ResourceVersion: rv, - }, + Name: involvedName + "." + string(uid), + Namespace: testNamespace, + UID: uid, + ResourceVersion: rv, InvolvedObject: corev1.ObjectReference{ Kind: "Pod", Name: involvedName, @@ -203,12 +201,10 @@ func TestWatchDeployEvents_FieldTranslation(t *testing.T) { ts := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC) ev := &corev1.Event{ - ObjectMeta: metav1.ObjectMeta{ - Name: testRelease + "-api-abc.uid-field", - Namespace: testNamespace, - UID: "uid-field", - ResourceVersion: "101", - }, + Name: testRelease + "-api-abc.uid-field", + Namespace: testNamespace, + UID: "uid-field", + ResourceVersion: "101", InvolvedObject: corev1.ObjectReference{ Kind: "ReplicaSet", Name: testRelease + "-api-abc", @@ -237,7 +233,7 @@ func TestToDeployEvent_ZeroCount(t *testing.T) { ev := watch.Event{ Type: watch.Added, Object: &corev1.Event{ - ObjectMeta: metav1.ObjectMeta{UID: "uid-zero"}, + UID: "uid-zero", InvolvedObject: corev1.ObjectReference{ Kind: "Pod", Name: "myapp-prod-api-xyz", @@ -269,10 +265,8 @@ func TestToDeployEvent_TimestampFallbacks(t *testing.T) { ev := watch.Event{ Type: watch.Added, Object: &corev1.Event{ - ObjectMeta: metav1.ObjectMeta{ - UID: "uid-ts", - ResourceVersion: "101", - }, + UID: "uid-ts", + ResourceVersion: "101", InvolvedObject: corev1.ObjectReference{ Kind: "Pod", Name: "myapp-prod-api-abc", diff --git a/internal/k8s/tlscert_test.go b/internal/k8s/tlscert_test.go index e66b9e1..ccff4f0 100644 --- a/internal/k8s/tlscert_test.go +++ b/internal/k8s/tlscert_test.go @@ -27,7 +27,6 @@ import ( "deployah.dev/deployah/internal/spec" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" certutil "k8s.io/client-go/util/cert" ) @@ -43,11 +42,9 @@ func leafOf(t *testing.T, certPEM []byte) *x509.Certificate { func tlsSecret(certPEM, keyPEM []byte, secretType corev1.SecretType) *corev1.Secret { return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: selfSignedSecretName(tlsTestFQDN), - Namespace: testNamespace, - }, - Type: secretType, + Name: selfSignedSecretName(tlsTestFQDN), + Namespace: testNamespace, + Type: secretType, Data: map[string][]byte{ corev1.TLSCertKey: certPEM, corev1.TLSPrivateKeyKey: keyPEM, diff --git a/internal/localkube/credentials.go b/internal/localkube/credentials.go index 25e734b..cc15493 100644 --- a/internal/localkube/credentials.go +++ b/internal/localkube/credentials.go @@ -165,11 +165,9 @@ func applyRegistryAuthSecret( secretData []byte, ) error { secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: registryAuthSecretName, - Namespace: namespace, - }, - Type: corev1.SecretTypeDockerConfigJson, + Name: registryAuthSecretName, + Namespace: namespace, + Type: corev1.SecretTypeDockerConfigJson, Data: map[string][]byte{ corev1.DockerConfigJsonKey: secretData, }, diff --git a/internal/localkube/credentials_test.go b/internal/localkube/credentials_test.go index 48d7ba4..8155ef0 100644 --- a/internal/localkube/credentials_test.go +++ b/internal/localkube/credentials_test.go @@ -164,7 +164,7 @@ func TestPatchDefaultServiceAccount_AddsEntry(t *testing.T) { // Pre-create the default SA (fake client starts empty). sa := &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, + Name: "default", Namespace: "default", } client := fake.NewSimpleClientset(sa) @@ -186,7 +186,7 @@ func TestPatchDefaultServiceAccount_Idempotent(t *testing.T) { t.Parallel() sa := &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, + Name: "default", Namespace: "default", ImagePullSecrets: []corev1.LocalObjectReference{ {Name: registryAuthSecretName}, }, @@ -266,7 +266,7 @@ func TestSyncRegistryAuth_WritesSecretAndPatchesSA(t *testing.T) { require.NoError(t, err) sa := &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "default"}, + Name: "default", Namespace: "default", } client := fake.NewSimpleClientset(sa) diff --git a/internal/localkube/kind.go b/internal/localkube/kind.go index b5c1882..1e602a1 100644 --- a/internal/localkube/kind.go +++ b/internal/localkube/kind.go @@ -150,11 +150,9 @@ func buildKindConfig(cfg *createConfig) (*kindv1alpha4.Cluster, error) { }) } return &kindv1alpha4.Cluster{ - TypeMeta: kindv1alpha4.TypeMeta{ - Kind: "Cluster", - APIVersion: "kind.x-k8s.io/v1alpha4", - }, - Nodes: []kindv1alpha4.Node{node}, + Kind: "Cluster", + APIVersion: "kind.x-k8s.io/v1alpha4", + Nodes: []kindv1alpha4.Node{node}, }, nil } diff --git a/internal/readiness/readiness_test.go b/internal/readiness/readiness_test.go index 02b0e8c..d08c9b0 100644 --- a/internal/readiness/readiness_test.go +++ b/internal/readiness/readiness_test.go @@ -23,7 +23,6 @@ import ( "k8s.io/client-go/kubernetes/fake" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func pod(name, component string, ready bool) *corev1.Pod { @@ -32,13 +31,11 @@ func pod(name, component string, ready bool) *corev1.Pod { phase = corev1.PodPending } return &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: "default", - Labels: map[string]string{ - "app.kubernetes.io/instance": "web-production", - "deployah.dev/component": component, - }, + Name: name, + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/instance": "web-production", + "deployah.dev/component": component, }, Status: corev1.PodStatus{ Phase: phase, diff --git a/internal/spec/resolve.go b/internal/spec/resolve.go index 35c1a4f..e2f7d73 100644 --- a/internal/spec/resolve.go +++ b/internal/spec/resolve.go @@ -105,8 +105,7 @@ func Resolve( rc, compFields, err := resolveComponent(compName, comp, platformEnv, env, substReport, platform) if err != nil { - var re *ResolutionError - if errors.As(err, &re) { + if re, ok := errors.AsType[*ResolutionError](err); ok { report.ErrorCode = re.Code } else { report.ErrorCode = ErrCodeDomainGap diff --git a/nix/apps/default.nix b/nix/apps/default.nix index 56cb80c..b6dec03 100644 --- a/nix/apps/default.nix +++ b/nix/apps/default.nix @@ -5,10 +5,11 @@ flake-utils, deployah, system, + go, }: let - quality = import ./quality.nix { inherit pkgs lib; }; + quality = import ./quality.nix { inherit pkgs lib go; }; testing = import ./testing.nix { inherit lib; }; vendor = import ./vendor.nix { inherit pkgs system; }; demo = import ./demo.nix { inherit pkgs lib deployah; }; diff --git a/nix/apps/quality.nix b/nix/apps/quality.nix index 85743e1..100a7d4 100644 --- a/nix/apps/quality.nix +++ b/nix/apps/quality.nix @@ -1,20 +1,31 @@ # Code quality apps: fmt, lint, lint-md, tidy -{ pkgs, lib }: +{ + pkgs, + lib, + go, +}: { + # Skip until golangci-lint supports Go 1.27: + # https://github.com/golangci/golangci-lint/issues/6643 + # nixpkgs' binary is built with Go 1.26 and rejects go.mod 1.27. fmt = lib.mkApp { name = "fmt"; - description = "Format Go files (gofumpt + gci via golangci-lint)"; + description = "Format Go files (skipped until Go 1.27 support)"; script = '' - exec ${pkgs.golangci-lint}/bin/golangci-lint fmt ./... + echo "skipping golangci-lint fmt: Go 1.27 not supported yet" + echo "see https://github.com/golangci/golangci-lint/issues/6643" + exit 0 ''; }; lint = lib.mkApp { name = "lint"; - description = "Run golangci-lint"; + description = "Run golangci-lint (skipped until Go 1.27 support)"; script = '' - exec ${pkgs.golangci-lint}/bin/golangci-lint run ./... + echo "skipping golangci-lint: Go 1.27 not supported yet" + echo "see https://github.com/golangci/golangci-lint/issues/6643" + exit 0 ''; }; @@ -30,7 +41,7 @@ name = "tidy"; description = "Run go mod tidy for the module"; script = '' - exec ${pkgs.go}/bin/go mod tidy + exec ${go}/bin/go mod tidy ''; }; @@ -38,7 +49,7 @@ name = "gen-docs"; description = "Generate the CLI reference under docs/cli from the command tree"; script = '' - exec ${pkgs.go}/bin/go run ./internal/tools/gendocs + exec ${go}/bin/go run ./internal/tools/gendocs ''; }; } diff --git a/nix/checks.nix b/nix/checks.nix index e028c88..915cb1b 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -32,8 +32,18 @@ in git-hooks.lib.${system}.run { inherit src; hooks = { - golangci-lint = { + # Use the pinned Go toolchain's gofmt; git-hooks' default wrapper + # still ships Go 1.26 and rejects go.mod 1.27. + gofmt = { enable = true; + entry = "${go}/bin/gofmt -l -w"; + files = "\\.go$"; + }; + # Disabled while go.mod is 1.27: nixpkgs golangci-lint is built with + # Go 1.26 and fails config load. Re-enable when + # https://github.com/golangci/golangci-lint/issues/6643 lands. + golangci-lint = { + enable = false; extraPackages = [ go ]; }; markdownlint = {