From 47efc1a5ddbc3d9bc1a1f503e04c8358d20fe3ea Mon Sep 17 00:00:00 2001 From: unclegedd Date: Thu, 7 Mar 2024 16:30:21 -0600 Subject: [PATCH] WIP: progress bar works for local and remote pkgs --- go.mod | 6 +- src/cmd/uds.go | 24 ++- src/pkg/bundle/deploy.go | 26 +-- src/pkg/bundle/tui/tui.go | 261 ++++++++++++++++++++++++++++ src/pkg/sources/remote.go | 4 + src/pkg/sources/tarball.go | 9 +- src/test/packages/gitrepo/zarf.yaml | 45 +++++ 7 files changed, 354 insertions(+), 21 deletions(-) create mode 100644 src/pkg/bundle/tui/tui.go diff --git a/go.mod b/go.mod index a22f12707..338896163 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.21.6 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/alecthomas/jsonschema v0.0.0-20220216202328-9eeeec9d044b + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 github.com/defenseunicorns/maru-runner v0.0.1 github.com/defenseunicorns/zarf v0.32.4 github.com/fsnotify/fsnotify v1.7.0 @@ -138,10 +141,7 @@ require ( github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/charmbracelet/bubbles v0.16.1 // indirect - github.com/charmbracelet/bubbletea v0.25.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/lipgloss v0.9.1 // indirect github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect diff --git a/src/cmd/uds.go b/src/cmd/uds.go index ce266a7ec..1d15b4544 100644 --- a/src/cmd/uds.go +++ b/src/cmd/uds.go @@ -5,20 +5,25 @@ package cmd import ( + "bytes" "os" "path/filepath" "strings" "github.com/AlecAivazis/survey/v2" + tea "github.com/charmbracelet/bubbletea" "github.com/defenseunicorns/uds-cli/src/config" "github.com/defenseunicorns/uds-cli/src/config/lang" "github.com/defenseunicorns/uds-cli/src/pkg/bundle" + "github.com/defenseunicorns/uds-cli/src/pkg/bundle/tui" zarfConfig "github.com/defenseunicorns/zarf/src/config" "github.com/defenseunicorns/zarf/src/pkg/message" zarfUtils "github.com/defenseunicorns/zarf/src/pkg/utils" zarfTypes "github.com/defenseunicorns/zarf/src/types" goyaml "github.com/goccy/go-yaml" + "github.com/pterm/pterm" "github.com/spf13/cobra" + "golang.org/x/term" ) var createCmd = &cobra.Command{ @@ -83,9 +88,22 @@ var deployCmd = &cobra.Command{ bndlClient := bundle.NewOrDie(&bundleCfg) defer bndlClient.ClearPaths() - if err := bndlClient.Deploy(); err != nil { - bndlClient.ClearPaths() - message.Fatalf(err, "Failed to deploy bundle: %s", err.Error()) + // disable pterm output (for now) and set default output to buffer + pterm.DisableOutput() + var ptermBuffer bytes.Buffer + pterm.SetDefaultOutput(&ptermBuffer) + + // start up bubbletea + m := tui.InitModel("", bndlClient, &ptermBuffer, tui.DeployOp) + // detect tty + if term.IsTerminal(int(os.Stdout.Fd())) { + tui.Program = tea.NewProgram(&m) + } else { + tui.Program = tea.NewProgram(&m, tea.WithInput(nil)) + } + + if _, err := tui.Program.Run(); err != nil { + panic(err) } }, } diff --git a/src/pkg/bundle/deploy.go b/src/pkg/bundle/deploy.go index cdb834fda..9c946999d 100644 --- a/src/pkg/bundle/deploy.go +++ b/src/pkg/bundle/deploy.go @@ -5,6 +5,7 @@ package bundle import ( + "bytes" "context" "encoding/json" "fmt" @@ -15,6 +16,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/defenseunicorns/uds-cli/src/config" + "github.com/defenseunicorns/uds-cli/src/pkg/bundle/tui" "github.com/defenseunicorns/uds-cli/src/pkg/sources" "github.com/defenseunicorns/uds-cli/src/types" zarfConfig "github.com/defenseunicorns/zarf/src/config" @@ -35,14 +37,9 @@ type ZarfOverrideMap map[string]map[string]map[string]interface{} var templatedVarRegex = regexp.MustCompile(`\${([^}]+)}`) // Deploy deploys a bundle -func (b *Bundle) Deploy() error { +func (b *Bundle) Deploy(ptermBuf *bytes.Buffer) error { ctx := context.TODO() - pterm.Println() - metadataSpinner := message.NewProgressSpinner("Loading bundle metadata") - - defer metadataSpinner.Stop() - // Check that provided oci source path is valid, and update it if it's missing the full path source, err := CheckOCISourcePath(b.cfg.DeployOpts.Source) if err != nil { @@ -79,8 +76,6 @@ func (b *Bundle) Deploy() error { return err } - metadataSpinner.Successf("Loaded bundle metadata") - // confirm deploy if ok := b.confirmBundleDeploy(); !ok { return fmt.Errorf("bundle deployment cancelled") @@ -110,13 +105,13 @@ func (b *Bundle) Deploy() error { if len(userSpecifiedPackages) != len(packagesToDeploy) { return fmt.Errorf("invalid zarf packages specified by --packages") } - return deployPackages(packagesToDeploy, resume, b, zarfPackageNameMap) + return deployPackages(packagesToDeploy, resume, b, ptermBuf, zarfPackageNameMap) } - return deployPackages(b.bundle.Packages, resume, b, zarfPackageNameMap) + return deployPackages(b.bundle.Packages, resume, b, ptermBuf, zarfPackageNameMap) } -func deployPackages(packages []types.Package, resume bool, b *Bundle, zarfPackageNameMap map[string]string) error { +func deployPackages(packages []types.Package, resume bool, b *Bundle, ptermBuf *bytes.Buffer, zarfPackageNameMap map[string]string) error { // map of Zarf pkgs and their vars bundleExportedVars := make(map[string]map[string]string) @@ -189,6 +184,13 @@ func deployPackages(packages []types.Package, resume bool, b *Bundle, zarfPackag if err != nil { return err } + + // enable output to start filling pterm buffer + pterm.DisableOutput() + + // bubbletea recommends calling the Program directly; calling model.Update() doesn't work + // https://github.com/charmbracelet/bubbletea/discussions/374 + tui.Program.Send(fmt.Sprintf("package:%s", pkg.Name)) if err := pkgClient.Deploy(); err != nil { return err } @@ -271,8 +273,6 @@ func (b *Bundle) confirmBundleDeploy() (confirm bool) { Message: "Deploy this bundle?", } - pterm.Println() - if err := survey.AskOne(prompt, &confirm); err != nil || !confirm { return false } diff --git a/src/pkg/bundle/tui/tui.go b/src/pkg/bundle/tui/tui.go new file mode 100644 index 000000000..7d81b4c0c --- /dev/null +++ b/src/pkg/bundle/tui/tui.go @@ -0,0 +1,261 @@ +package tui + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/defenseunicorns/zarf/src/config" + "github.com/defenseunicorns/zarf/src/pkg/k8s" + "github.com/defenseunicorns/zarf/src/pkg/message" + "github.com/defenseunicorns/zarf/src/types" + "github.com/pterm/pterm" +) + +// todo: watch naming collisions, spinner also has a TickMsg +type tickMsg time.Time +type operation string + +const ( + DeployOp operation = "deploy" + tick operation = "tick" +) + +var ( + Program *tea.Program + resetProgress bool +) + +func InitModel(content string, client bndlClientShim, buf *bytes.Buffer, op operation) model { + // configure spinner + s := spinner.New() + s.Spinner = spinner.Monkey + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + return model{ + content: content, + bndlClient: client, + packageOutputBuffer: buf, + op: op, + quitChan: make(chan int), + componentChan: make(chan int), + progress: progress.New(progress.WithDefaultGradient()), + currentPkg: "", + spinner: s, + } +} + +// private interface to decouple tui pkg from bundle pkg +type bndlClientShim interface { + Deploy(*bytes.Buffer) error + ClearPaths() +} + +type model struct { + content string + ready bool + packageOutputBuffer *bytes.Buffer + bndlClient bndlClientShim + op operation + quitChan chan int + progress progress.Model + currentPkg string + totalComponents int + componentChan chan int + spinner spinner.Model +} + +func (m model) Init() tea.Cmd { + return tea.Sequence(func() tea.Msg { + return m.op + }, m.spinner.Tick) +} + +// allows us to get way more Zarf output +// adopted from: +// https://stackoverflow.com/questions/74375547/how-to-deal-with-log-output-which-contains-progress-bar +func cleanFlushInfo(bytesBuffer *bytes.Buffer) string { + scanner := bufio.NewScanner(bytesBuffer) + finalString := "" + + for scanner.Scan() { + line := scanner.Text() + chunks := strings.Split(line, "\r") + lastChunk := chunks[len(chunks)-1] // fetch the last update of the line + finalString += lastChunk + "\n" + } + return finalString +} + +// todo: I think Zarf has this... +func GetDeployedPackage(packageName string) (deployedPackage *types.DeployedPackage) { + // Get the secret that describes the deployed package + k8sClient, _ := k8s.New(message.Debugf, k8s.Labels{config.ZarfManagedByLabel: "zarf"}) + secret, err := k8sClient.GetSecret("zarf", config.ZarfPackagePrefix+packageName) + if err != nil { + return deployedPackage + } + + err = json.Unmarshal(secret.Data["data"], &deployedPackage) + if err != nil { + panic(0) + } + return deployedPackage +} + +func finalPause() tea.Cmd { + return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg { + return nil + }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmds []tea.Cmd + ) + select { + case <-m.quitChan: + return m, tea.Sequence(tea.Quit) + default: + switch msg := msg.(type) { + case progress.FrameMsg: + progressModel, cmd := m.progress.Update(msg) + m.progress = progressModel.(progress.Model) + return m, cmd + case tickMsg: + var progressCmd tea.Cmd + if m.totalComponents > 0 { + deployedPkg := GetDeployedPackage(m.currentPkg) + if deployedPkg != nil && !resetProgress { + // todo: instead of going off of DeployedComponents, find a way to include deployedPkg.DeployedComponents[0].Status + progressCmd = m.progress.SetPercent(float64(len(deployedPkg.DeployedComponents)) / float64(m.totalComponents)) + if m.progress.Percent() == 1 { + // stop the spinner and show success + m.spinner.Spinner.Frames = []string{""} + m.spinner.Style = lipgloss.NewStyle().SetString("✅") + } + } else { + // handle upgrade scenario by resetting the progress bar until DeployedComponents is back to 1 (ie. the first component) + progressCmd = m.progress.SetPercent(0) + if deployedPkg != nil && len(deployedPkg.DeployedComponents) == 1 { + resetProgress = false + } + } + } + s, spinnerCmd := m.spinner.Update(spinner.TickMsg{}) + m.spinner = s + + return m, tea.Sequence(progressCmd, spinnerCmd, tickCmd()) + case operation: + m.ready = true + m.packageOutputBuffer.Reset() + + switch msg { + case DeployOp: + // run Deploy concurrently so we can update the TUI while it runs + go func() { + // todo: don't actually put the buffer in the call to Deploy() + if err := m.bndlClient.Deploy(m.packageOutputBuffer); err != nil { + // todo: this doesn't work + // test by deploying git-repo bundle followed by local-and-remote bundle + m.spinner.Spinner.Frames = []string{""} + m.spinner.Style = lipgloss.NewStyle().SetString("❌") + // use existing Zarf pterm things for errors + pterm.EnableOutput() + pterm.SetDefaultOutput(os.Stderr) + m.bndlClient.ClearPaths() + message.Fatalf(err, "Failed to deploy bundle: %s", err.Error()) + m.quitChan <- 1 + } + m.quitChan <- 1 + }() + // use a ticker to update the TUI while the deploy runs + return m, tickCmd() + } + case tea.KeyMsg: + if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { + return m, tea.Quit + } + case string: + if strings.Split(msg, ":")[0] == "package" { + pkgName := strings.Split(msg, ":")[1] + m.currentPkg = pkgName + // if pkg is already deployed, set resetProgress to true + if deployedPkg := GetDeployedPackage(pkgName); deployedPkg != nil && len(deployedPkg.DeployedComponents) != 0 { + resetProgress = true + } + } else if strings.Split(msg, ":")[0] == "totalComponents" { + if totalComponents, err := strconv.Atoi(strings.Split(msg, ":")[1]); err == nil { + m.totalComponents = totalComponents // else return err + } + } + } + } + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + if !m.ready { + return "\n Initializing..." + } + return fmt.Sprintf("%s", m.boxView()) +} + +func (m model) boxView() string { + width := 100 + question := lipgloss.NewStyle(). + Width(50). + Align(lipgloss.Left). + Padding(0, 3). + Render(fmt.Sprintf("%s Deploying: %s", m.spinner.View(), m.currentPkg)) + + progressBar := lipgloss.NewStyle(). + Width(50). + Align(lipgloss.Left). + Padding(0, 3). + MarginTop(1). + Render(m.progress.View()) + + ui := lipgloss.JoinVertical(lipgloss.Center, question, progressBar) + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#874BFD")). + Padding(1, 0). + BorderTop(true). + BorderLeft(true). + BorderRight(true). + BorderBottom(true) + subtle := lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + + box := lipgloss.Place(width, 9, + lipgloss.Left, lipgloss.Top, + boxStyle.Render(ui), + lipgloss.WithWhitespaceForeground(subtle), + ) + + return box +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/src/pkg/sources/remote.go b/src/pkg/sources/remote.go index 08d3c2c13..ac76d591d 100644 --- a/src/pkg/sources/remote.go +++ b/src/pkg/sources/remote.go @@ -13,6 +13,7 @@ import ( "sync" "github.com/defenseunicorns/uds-cli/src/config" + "github.com/defenseunicorns/uds-cli/src/pkg/bundle/tui" "github.com/defenseunicorns/uds-cli/src/pkg/cache" "github.com/defenseunicorns/uds-cli/src/pkg/utils" "github.com/defenseunicorns/zarf/src/pkg/layout" @@ -49,6 +50,9 @@ func (r *RemoteBundle) LoadPackage(dst *layout.PackagePaths, unarchiveAll bool) return err } + // record number of components to be deployed for TUI + tui.Program.Send(fmt.Sprintf("totalComponents:%d", len(pkg.Components))) + dst.SetFromLayers(layers) err = sources.ValidatePackageIntegrity(dst, pkg.Metadata.AggregateChecksum, r.isPartial) diff --git a/src/pkg/sources/tarball.go b/src/pkg/sources/tarball.go index eabeaaa9b..6413db91c 100644 --- a/src/pkg/sources/tarball.go +++ b/src/pkg/sources/tarball.go @@ -12,8 +12,6 @@ import ( "path/filepath" "strings" - "github.com/defenseunicorns/uds-cli/src/config" - "github.com/defenseunicorns/uds-cli/src/pkg/utils" "github.com/defenseunicorns/zarf/src/pkg/layout" "github.com/defenseunicorns/zarf/src/pkg/message" "github.com/defenseunicorns/zarf/src/pkg/oci" @@ -23,6 +21,10 @@ import ( zarfTypes "github.com/defenseunicorns/zarf/src/types" av4 "github.com/mholt/archiver/v4" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/defenseunicorns/uds-cli/src/config" + "github.com/defenseunicorns/uds-cli/src/pkg/bundle/tui" + "github.com/defenseunicorns/uds-cli/src/pkg/utils" ) // TarballBundle is a package source for local tarball bundles that implements Zarf's packager.PackageSource @@ -51,6 +53,9 @@ func (t *TarballBundle) LoadPackage(dst *layout.PackagePaths, unarchiveAll bool) } dst.SetFromPaths(files) + // record number of components to be deployed for TUI + tui.Program.Send(fmt.Sprintf("totalComponents:%d", len(pkg.Components))) + if err := sources.ValidatePackageIntegrity(dst, pkg.Metadata.AggregateChecksum, t.isPartial); err != nil { return err } diff --git a/src/test/packages/gitrepo/zarf.yaml b/src/test/packages/gitrepo/zarf.yaml index 90b39b3b6..7fb5964bf 100644 --- a/src/test/packages/gitrepo/zarf.yaml +++ b/src/test/packages/gitrepo/zarf.yaml @@ -8,3 +8,48 @@ components: required: true repos: - https://github.com/defenseunicorns/uds-package-dubbd + - name: nginx-remote + required: true + manifests: + - name: simple-nginx-deployment + namespace: nginx + files: + # remote manifests are specified with a URL and you can verify integrity of a manifest + # by adding a sha256sum to the end of the URL, separated by an @: + - https://k8s.io/examples/application/deployment.yaml@c57f73449b26eae02ca2a549c388807d49ef6d3f2dc040a9bbb1290128d97157 + # this sha256 can be discovered using: + # zarf prepare sha256sum https://k8s.io/examples/application/deployment.yaml + actions: + onDeploy: + # the following checks were computed by viewing the success state of the package deployment + # and creating `wait` actions that match + after: + - wait: + cluster: + kind: deployment + name: nginx-deployment + namespace: nginx + condition: available + # image discovery is supported in all manifests and charts using: + # zarf prepare find-images + images: + - docker.io/library/nginx:1.14.2 + - name: podinfo + required: true + charts: + - name: podinfo + version: 6.4.0 + namespace: podinfo + url: https://github.com/stefanprodan/podinfo.git + gitPath: charts/podinfo + images: + - ghcr.io/stefanprodan/podinfo:6.4.0 + actions: + onDeploy: + after: + - wait: + cluster: + kind: deployment + name: podinfo + namespace: podinfo + condition: available