diff --git a/go.mod b/go.mod index 848b071..e60dbaa 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,13 @@ go 1.19 require ( github.com/ProtonMail/gopenpgp/v2 v2.5.0 + github.com/charmbracelet/bubbles v0.15.0 + github.com/charmbracelet/bubbletea v0.23.2 github.com/cheggaaa/pb/v3 v3.1.0 github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 github.com/franela/goblin v0.0.0-20211003143422-0a4f594942bf - github.com/mattn/go-isatty v0.0.16 + github.com/manifoldco/promptui v0.9.0 + github.com/mattn/go-isatty v0.0.17 github.com/mholt/archiver/v3 v3.5.1 github.com/onsi/gomega v1.20.2 github.com/pkg/errors v0.9.1 @@ -27,6 +30,8 @@ require ( github.com/ProtonMail/go-mime v0.0.0-20221031134845-8fd9bc37cf08 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/andybalholm/brotli v1.0.4 // indirect + github.com/aymanbagabas/go-osc52 v1.2.1 // indirect + github.com/charmbracelet/lipgloss v0.6.0 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cloudflare/circl v1.1.0 // indirect github.com/containerd/console v1.0.3 // indirect @@ -41,9 +46,14 @@ require ( github.com/klauspost/compress v1.15.9 // indirect github.com/klauspost/pgzip v1.2.5 // indirect github.com/lithammer/fuzzysearch v1.1.5 // indirect - github.com/manifoldco/promptui v0.9.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.14.0 // indirect github.com/nwaples/rardecode v1.1.3 // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/rivo/uniseg v0.4.2 // indirect @@ -53,6 +63,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.0.0-20220913120320-3275c407cedc // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 7bea090..7d7e2b4 100644 --- a/go.sum +++ b/go.sum @@ -24,12 +24,26 @@ github.com/andybalholm/brotli v0.0.0-20190621154722-5f990b63d2d6/go.mod h1:+lx6/ github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= +github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= +github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= +github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= +github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= +github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps= +github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= +github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= github.com/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04= github.com/cheggaaa/pb/v3 v3.1.0/go.mod h1:YjrevcBqadFDaGQKRdmZxTY42pXEqda48Ea3lt0K/BE= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= @@ -91,8 +105,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/marcinwyszynski/graphql v0.0.0-20210505073322-ed22d920d37d h1:9Z8P/yiZQQucF5Yo3bmn0JD7Y4TrtGETsbmZpexIuxc= @@ -103,11 +120,27 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= +github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0= +github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= @@ -141,6 +174,7 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/spacelift-io/archiver/v3 v3.3.1-0.20221117135619-d7d90ab08987 h1:L8buChChTRSHaGHco2ptAFivZUDYHhr7+OG5haf5FJI= @@ -183,6 +217,8 @@ golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 h1:lxqLZaMad/dJHMFZH0NiNpiEZI/nhgWhe4wgzpE+MuA= golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -196,6 +232,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cmd/module/flags.go b/internal/cmd/module/flags.go index a7ec438..e1a9c91 100644 --- a/internal/cmd/module/flags.go +++ b/internal/cmd/module/flags.go @@ -13,6 +13,23 @@ var flagCommitSHA = &cli.StringFlag{ Usage: "Commit `SHA` to use for the module version", } +var flagNoFindRepositoryRoot = &cli.BoolFlag{ + Name: "no-find-repository-root", + Usage: "Indicate whether spacectl should avoid finding the repository root (containing a .git directory) before packaging it.", + Value: false, +} + +var flagNoUpload = &cli.BoolFlag{ + Name: "no-upload", + Usage: "Indicate whether Spacectl should prepare the workspace archive, but skip uploading it. Useful for debugging ignorefiles.", + Value: false, +} + +var flagRunMetadata = &cli.StringFlag{ + Name: "run-metadata", + Usage: "Additional opaque metadata you will be able to access from policies handling this Run.", +} + var flagVersion = &cli.StringFlag{ Name: "version", Usage: "Semver `version` for the module version. If not provided, the version " + diff --git a/internal/cmd/module/local_preview.go b/internal/cmd/module/local_preview.go new file mode 100644 index 0000000..89a18e4 --- /dev/null +++ b/internal/cmd/module/local_preview.go @@ -0,0 +1,291 @@ +package module + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mholt/archiver/v3" + "github.com/shurcooL/graphql" + "github.com/urfave/cli/v2" + "golang.org/x/sync/errgroup" + + "github.com/spacelift-io/spacectl/internal" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" +) + +func localPreview() cli.ActionFunc { + return func(cliCtx *cli.Context) error { + moduleID := cliCtx.String(flagModuleID.Name) + ctx := context.Background() + + if !cliCtx.Bool(flagNoFindRepositoryRoot.Name) { + if err := internal.MoveToRepositoryRoot(); err != nil { + return fmt.Errorf("couldn't move to repository root: %w", err) + } + } + + fmt.Println("Packing local workspace...") + + var uploadMutation struct { + UploadLocalWorkspace struct { + ID string `graphql:"id"` + UploadURL string `graphql:"uploadUrl"` + } `graphql:"uploadLocalWorkspace(stack: $stack)"` + } + + uploadVariables := map[string]interface{}{ + "stack": graphql.ID(moduleID), + } + + if err := authenticated.Client.Mutate(ctx, &uploadMutation, uploadVariables); err != nil { + return err + } + + fp := filepath.Join(os.TempDir(), "spacectl", "local-workspace", fmt.Sprintf("%s.tar.gz", uploadMutation.UploadLocalWorkspace.ID)) + + matchFn, err := internal.GetIgnoreMatcherFn(ctx) + if err != nil { + return fmt.Errorf("couldn't analyze .gitignore and .terraformignore files") + } + + tgz := *archiver.DefaultTarGz + tgz.ForceArchiveImplicitTopLevelFolder = true + tgz.MatchFn = matchFn + + if err := tgz.Archive([]string{"."}, fp); err != nil { + return fmt.Errorf("couldn't archive local directory: %w", err) + } + + if cliCtx.Bool(flagNoUpload.Name) { + fmt.Println("No upload flag was provided, will not create run, saved archive at:", fp) + return nil + } + + defer os.Remove(fp) + + fmt.Println("Uploading local workspace...") + + if err := internal.UploadArchive(ctx, uploadMutation.UploadLocalWorkspace.UploadURL, fp); err != nil { + return fmt.Errorf("couldn't upload archive: %w", err) + } + + var triggerMutation struct { + VersionProposeLocalWorkspace []runQuery `graphql:"versionProposeLocalWorkspace(module: $module, workspace: $workspace)"` + } + + triggerVariables := map[string]interface{}{ + "module": graphql.ID(moduleID), + "workspace": graphql.ID(uploadMutation.UploadLocalWorkspace.ID), + } + + var requestOpts []graphql.RequestOption + if cliCtx.IsSet(flagRunMetadata.Name) { + requestOpts = append(requestOpts, graphql.WithHeader(internal.UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) + } + + if err := authenticated.Client.Mutate(ctx, &triggerMutation, triggerVariables, requestOpts...); err != nil { + return err + } + + model := newModuleLocalPreviewModel(moduleID, triggerMutation.VersionProposeLocalWorkspace) + + go func() { + // Refresh run state every 5 seconds. + ticker := time.NewTicker(time.Second * 5) + defer ticker.Stop() + for range ticker.C { + newRuns := make([]runQuery, len(triggerMutation.VersionProposeLocalWorkspace)) + var g errgroup.Group + for i := range newRuns { + index := i + g.Go(func() error { + var getRun struct { + Module struct { + Run runQuery `graphql:"run(id: $run)"` + } `graphql:"module(id: $module)"` + } + + if err := authenticated.Client.Query(ctx, &getRun, map[string]interface{}{ + "module": graphql.ID(moduleID), + "run": graphql.ID(triggerMutation.VersionProposeLocalWorkspace[index].ID), + }); err != nil { + return err + } + + newRuns[index] = getRun.Module.Run + + return nil + }) + } + if err := g.Wait(); err != nil { + log.Fatal("couldn't get runs: ", err) + } + model.setRuns(newRuns) + } + }() + + // Run the UI. + if _, err := tea.NewProgram(model).Run(); err != nil { + fmt.Println("could not run program:", err) + os.Exit(1) + } + + return nil + } +} + +type checkRunsFinishedMsg struct{} + +type moduleLocalPreviewModel struct { + sync.Mutex + ModuleID string + Runs []runQuery + SpinnersSlow []spinner.Model + SpinnersFast []spinner.Model +} + +type runQuery struct { + ID string `graphql:"id"` + Title string `graphql:"title"` + State string `graphql:"state"` + Finished bool `graphql:"finished"` +} + +func newModuleLocalPreviewModel(moduleID string, runsState []runQuery) *moduleLocalPreviewModel { + slowMiniDot := spinner.Spinner{ + Frames: []string{"⠂", "⠐"}, + FPS: time.Second, + } + spinnersSlow := make([]spinner.Model, len(runsState)) + spinnersFast := make([]spinner.Model, len(runsState)) + for i := range runsState { + spinnersSlow[i] = spinner.New(spinner.WithSpinner(slowMiniDot)) + spinnersFast[i] = spinner.New(spinner.WithSpinner(spinner.MiniDot)) + } + return &moduleLocalPreviewModel{ + ModuleID: moduleID, + Runs: runsState, + SpinnersSlow: spinnersSlow, + SpinnersFast: spinnersFast, + } +} + +func (m *moduleLocalPreviewModel) setRuns(newRuns []runQuery) { + m.Lock() + m.Runs = newRuns + m.Unlock() +} + +func (m *moduleLocalPreviewModel) Init() tea.Cmd { + var cmds []tea.Cmd + for i := range m.SpinnersSlow { + cmds = append(cmds, m.SpinnersSlow[i].Tick) + } + for i := range m.SpinnersFast { + cmds = append(cmds, m.SpinnersFast[i].Tick) + } + cmds = append(cmds, tea.Tick(time.Second/4, func(t time.Time) tea.Msg { + return checkRunsFinishedMsg{} + })) + return tea.Batch(cmds...) +} + +func (m *moduleLocalPreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.Lock() + defer m.Unlock() + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + default: + return m, nil + } + case spinner.TickMsg: + var cmd tea.Cmd + for i := range m.SpinnersSlow { + if m.SpinnersSlow[i].ID() == msg.ID { + m.SpinnersSlow[i], cmd = m.SpinnersSlow[i].Update(msg) + break + } + } + for i := range m.SpinnersFast { + if m.SpinnersFast[i].ID() == msg.ID { + m.SpinnersFast[i], cmd = m.SpinnersFast[i].Update(msg) + break + } + } + return m, cmd + case checkRunsFinishedMsg: + allTerminated := true + for _, run := range m.Runs { + if !run.Finished { + allTerminated = false + break + } + } + if allTerminated { + return m, tea.Tick(time.Second/4, func(t time.Time) tea.Msg { + fmt.Println() + return tea.Quit() + }) + } + + return m, tea.Tick(time.Second/4, func(t time.Time) tea.Msg { + return checkRunsFinishedMsg{} + }) + default: + return m, nil + } +} + +func (m *moduleLocalPreviewModel) View() (s string) { + m.Lock() + defer m.Unlock() + + s += "\n" + for i := range m.Runs { + spinnerView := m.SpinnersSlow[i].View() + if m.Runs[i].State != "QUEUED" { + spinnerView = m.SpinnersFast[i].View() + } + if m.Runs[i].Finished { + spinnerView = "⠿" + } + s += fmt.Sprintf(" %s %s • %s • %s\n", spinnerView, styledState(m.Runs[i].State), m.Runs[i].Title, authenticated.Client.URL( + "/module/%s/run/%s", + m.ModuleID, + m.Runs[i].ID, + )) + } + s += "\n" + return +} + +func styledState(state string) string { + var ( + inProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render + finishedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("70")).Render + failedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("160")).Render + ) + + switch state { + case "QUEUED": + return state + case "FINISHED": + return finishedStyle(state) + case "FAILED": + return failedStyle(state) + default: + return inProgressStyle(state) + } +} diff --git a/internal/cmd/module/module.go b/internal/cmd/module/module.go index 454737f..ae2e0cd 100644 --- a/internal/cmd/module/module.go +++ b/internal/cmd/module/module.go @@ -1,9 +1,10 @@ package module import ( + "github.com/urfave/cli/v2" + "github.com/spacelift-io/spacectl/internal/cmd" "github.com/spacelift-io/spacectl/internal/cmd/authenticated" - "github.com/urfave/cli/v2" ) // Command encapsulates the module command subtree. @@ -25,6 +26,20 @@ func Command() *cli.Command { Before: authenticated.Ensure, ArgsUsage: cmd.EmptyArgsUsage, }, + { + Category: "Module management", + Name: "local-preview", + Usage: "Start a preview (proposed version) based on the current project. Respects .gitignore and .terraformignore.", + Flags: []cli.Flag{ + flagModuleID, + flagNoFindRepositoryRoot, + flagNoUpload, + flagRunMetadata, + }, + Action: localPreview(), + Before: authenticated.Ensure, + ArgsUsage: cmd.EmptyArgsUsage, + }, }, } } diff --git a/internal/cmd/stack/local_preview.go b/internal/cmd/stack/local_preview.go index 3c00e46..aa39e13 100644 --- a/internal/cmd/stack/local_preview.go +++ b/internal/cmd/stack/local_preview.go @@ -3,16 +3,14 @@ package stack import ( "context" "fmt" - "net/http" "os" "path/filepath" - "github.com/cheggaaa/pb/v3" "github.com/mholt/archiver/v3" - ignore "github.com/sabhiram/go-gitignore" "github.com/shurcooL/graphql" "github.com/urfave/cli/v2" + "github.com/spacelift-io/spacectl/internal" "github.com/spacelift-io/spacectl/internal/cmd/authenticated" ) @@ -22,7 +20,7 @@ func localPreview() cli.ActionFunc { ctx := context.Background() if !cliCtx.Bool(flagNoFindRepositoryRoot.Name) { - if err := moveToRepositoryRoot(); err != nil { + if err := internal.MoveToRepositoryRoot(); err != nil { return fmt.Errorf("couldn't move to repository root: %w", err) } } @@ -46,7 +44,7 @@ func localPreview() cli.ActionFunc { fp := filepath.Join(os.TempDir(), "spacectl", "local-workspace", fmt.Sprintf("%s.tar.gz", uploadMutation.UploadLocalWorkspace.ID)) - matchFn, err := getIgnoreMatcherFn(ctx) + matchFn, err := internal.GetIgnoreMatcherFn(ctx) if err != nil { return fmt.Errorf("couldn't analyze .gitignore and .terraformignore files") } @@ -68,7 +66,7 @@ func localPreview() cli.ActionFunc { fmt.Println("Uploading local workspace...") - if err := uploadArchive(ctx, uploadMutation.UploadLocalWorkspace.UploadURL, fp); err != nil { + if err := internal.UploadArchive(ctx, uploadMutation.UploadLocalWorkspace.UploadURL, fp); err != nil { return fmt.Errorf("couldn't upload archive: %w", err) } @@ -85,7 +83,7 @@ func localPreview() cli.ActionFunc { var requestOpts []graphql.RequestOption if cliCtx.IsSet(flagRunMetadata.Name) { - requestOpts = append(requestOpts, graphql.WithHeader(UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) + requestOpts = append(requestOpts, graphql.WithHeader(internal.UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) } if err := authenticated.Client.Mutate(ctx, &triggerMutation, triggerVariables, requestOpts...); err != nil { @@ -112,95 +110,3 @@ func localPreview() cli.ActionFunc { return terminal.Error() } } - -func moveToRepositoryRoot() error { - startCwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("couldn't get current working directory: %w", err) - } - for { - if _, err := os.Stat(".git"); err == nil { - return nil - } else if !os.IsNotExist(err) { - return fmt.Errorf("couldn't stat .git directory: %w", err) - } - - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("couldn't get current working directory: %w", err) - } - - parent := filepath.Dir(cwd) - - if parent == cwd { - fmt.Println("Couldn't find repository root, using current directory.") - if err := os.Chdir(startCwd); err != nil { - return fmt.Errorf("couldn't set current working directory: %w", err) - } - return nil - } - - if err := os.Chdir(parent); err != nil { - return fmt.Errorf("couldn't set current working directory: %w", err) - } - } -} - -func getIgnoreMatcherFn(ctx context.Context) (func(filePath string) bool, error) { - gitignore, err := ignore.CompileIgnoreFile(".gitignore") - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("couldn't compile .gitignore file: %w", err) - } - terraformignore, err := ignore.CompileIgnoreFile(".terraformignore") - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("couldn't compile .terraformignore file: %w", err) - } - customignore := ignore.CompileIgnoreLines(".git", ".terraform") - return func(filePath string) bool { - if customignore.MatchesPath(filePath) { - return false - } - if gitignore != nil && gitignore.MatchesPath(filePath) { - return false - } - if terraformignore != nil && terraformignore.MatchesPath(filePath) { - return false - } - return true - }, nil -} - -func uploadArchive(ctx context.Context, uploadURL, path string) (err error) { - stat, err := os.Stat(path) - if err != nil { - return fmt.Errorf("couldn't stat archive file: %w", err) - } - - // #nosec G304 - f, err := os.Open(path) - if err != nil { - return fmt.Errorf("couldn't open archive file: %w", err) - } - - bar := pb.Full.Start64(stat.Size()) - barReader := bar.NewProxyReader(f) - defer bar.Finish() - - req, err := http.NewRequest(http.MethodPut, uploadURL, barReader) - if err != nil { - return fmt.Errorf("couldn't create upload request: %w", err) - } - req.ContentLength = stat.Size() - req = req.WithContext(ctx) - - response, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("couldn't upload workspace: %w", err) - } - defer response.Body.Close() - if code := response.StatusCode; code != http.StatusOK { - return fmt.Errorf("unexpected response code when uploading workspace: %d", code) - } - - return nil -} diff --git a/internal/cmd/stack/run_confirm.go b/internal/cmd/stack/run_confirm.go index 18f91f9..8bfe5a7 100644 --- a/internal/cmd/stack/run_confirm.go +++ b/internal/cmd/stack/run_confirm.go @@ -7,6 +7,7 @@ import ( "github.com/shurcooL/graphql" "github.com/urfave/cli/v2" + "github.com/spacelift-io/spacectl/internal" "github.com/spacelift-io/spacectl/internal/cmd/authenticated" ) @@ -29,7 +30,7 @@ func runConfirm() cli.ActionFunc { var requestOpts []graphql.RequestOption if cliCtx.IsSet(flagRunMetadata.Name) { - requestOpts = append(requestOpts, graphql.WithHeader(UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) + requestOpts = append(requestOpts, graphql.WithHeader(internal.UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) } if err := authenticated.Client.Mutate(ctx, &mutation, variables, requestOpts...); err != nil { diff --git a/internal/cmd/stack/run_trigger.go b/internal/cmd/stack/run_trigger.go index 03da59a..7edc44a 100644 --- a/internal/cmd/stack/run_trigger.go +++ b/internal/cmd/stack/run_trigger.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v2" "github.com/spacelift-io/spacectl/client/structs" + "github.com/spacelift-io/spacectl/internal" "github.com/spacelift-io/spacectl/internal/cmd/authenticated" ) @@ -35,7 +36,7 @@ func runTrigger(spaceliftType, humanType string) cli.ActionFunc { var requestOpts []graphql.RequestOption if cliCtx.IsSet(flagRunMetadata.Name) { - requestOpts = append(requestOpts, graphql.WithHeader(UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) + requestOpts = append(requestOpts, graphql.WithHeader(internal.UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) } if err := authenticated.Client.Mutate(ctx, &mutation, variables, requestOpts...); err != nil { diff --git a/internal/cmd/stack/task_command.go b/internal/cmd/stack/task_command.go index 324220d..7d58007 100644 --- a/internal/cmd/stack/task_command.go +++ b/internal/cmd/stack/task_command.go @@ -8,6 +8,7 @@ import ( "github.com/shurcooL/graphql" "github.com/urfave/cli/v2" + "github.com/spacelift-io/spacectl/internal" "github.com/spacelift-io/spacectl/internal/cmd/authenticated" ) @@ -30,7 +31,7 @@ func taskCommand(cliCtx *cli.Context) error { var requestOpts []graphql.RequestOption if cliCtx.IsSet(flagRunMetadata.Name) { - requestOpts = append(requestOpts, graphql.WithHeader(UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) + requestOpts = append(requestOpts, graphql.WithHeader(internal.UserProvidedRunMetadataHeader, cliCtx.String(flagRunMetadata.Name))) } if err := authenticated.Client.Mutate(ctx, &mutation, variables, requestOpts...); err != nil { diff --git a/internal/cmd/stack/constants.go b/internal/constants.go similarity index 92% rename from internal/cmd/stack/constants.go rename to internal/constants.go index 51edcea..5e6bdc5 100644 --- a/internal/cmd/stack/constants.go +++ b/internal/constants.go @@ -1,4 +1,4 @@ -package stack +package internal // UserProvidedRunMetadataHeader is the HTTP header used to pass arbitrary metadata // to runs when creating or confirming them. diff --git a/internal/local_preview.go b/internal/local_preview.go new file mode 100644 index 0000000..3ad5dd9 --- /dev/null +++ b/internal/local_preview.go @@ -0,0 +1,107 @@ +package internal + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/cheggaaa/pb/v3" + ignore "github.com/sabhiram/go-gitignore" +) + +// MoveToRepositoryRoot moves the current workdir to the git repository root. +func MoveToRepositoryRoot() error { + startCwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("couldn't get current working directory: %w", err) + } + for { + if _, err := os.Stat(".git"); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("couldn't stat .git directory: %w", err) + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("couldn't get current working directory: %w", err) + } + + parent := filepath.Dir(cwd) + + if parent == cwd { + fmt.Println("Couldn't find repository root, using current directory.") + if err := os.Chdir(startCwd); err != nil { + return fmt.Errorf("couldn't set current working directory: %w", err) + } + return nil + } + + if err := os.Chdir(parent); err != nil { + return fmt.Errorf("couldn't set current working directory: %w", err) + } + } +} + +// GetIgnoreMatcherFn creates an ignore-matcher for archiving purposes. Respects gitignore and terraformignore. +func GetIgnoreMatcherFn(ctx context.Context) (func(filePath string) bool, error) { + gitignore, err := ignore.CompileIgnoreFile(".gitignore") + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("couldn't compile .gitignore file: %w", err) + } + terraformignore, err := ignore.CompileIgnoreFile(".terraformignore") + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("couldn't compile .terraformignore file: %w", err) + } + customignore := ignore.CompileIgnoreLines(".git", ".terraform") + return func(filePath string) bool { + if customignore.MatchesPath(filePath) { + return false + } + if gitignore != nil && gitignore.MatchesPath(filePath) { + return false + } + if terraformignore != nil && terraformignore.MatchesPath(filePath) { + return false + } + return true + }, nil +} + +// UploadArchive uploads a tarball to the target endpoint and displays a fancy progress bar. +func UploadArchive(ctx context.Context, uploadURL, path string) (err error) { + stat, err := os.Stat(path) + if err != nil { + return fmt.Errorf("couldn't stat archive file: %w", err) + } + + // #nosec G304 + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("couldn't open archive file: %w", err) + } + + bar := pb.Full.Start64(stat.Size()) + barReader := bar.NewProxyReader(f) + defer bar.Finish() + + req, err := http.NewRequest(http.MethodPut, uploadURL, barReader) + if err != nil { + return fmt.Errorf("couldn't create upload request: %w", err) + } + req.ContentLength = stat.Size() + req = req.WithContext(ctx) + + response, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("couldn't upload workspace: %w", err) + } + defer response.Body.Close() + if code := response.StatusCode; code != http.StatusOK { + return fmt.Errorf("unexpected response code when uploading workspace: %d", code) + } + + return nil +}