From 9a3b481dbac98ba4037141defc752e42b79fb4bb Mon Sep 17 00:00:00 2001 From: Antti Kupila Date: Tue, 21 Apr 2020 10:51:23 +0200 Subject: [PATCH] cli: use ui logger --- cli/app.go | 289 +++++++++++++++++++++++++-------------------- cli/diagnostic.go | 108 +++++++++++++++++ cli/indicator.go | 27 +++++ cli/logger.go | 272 +++++++++++++++++++++++++++++++++--------- cli/prefix.go | 51 -------- cli/prefix_test.go | 71 ----------- cli/progress.go | 71 +++++++++++ cmd/deploy.go | 5 +- cmd/generate.go | 5 +- go.mod | 1 + resource/loader.go | 13 +- 11 files changed, 592 insertions(+), 321 deletions(-) create mode 100644 cli/diagnostic.go create mode 100644 cli/indicator.go delete mode 100644 cli/prefix.go delete mode 100644 cli/prefix_test.go create mode 100644 cli/progress.go diff --git a/cli/app.go b/cli/app.go index 4dd9658..1449250 100644 --- a/cli/app.go +++ b/cli/app.go @@ -9,6 +9,7 @@ import ( "os" "reflect" "strings" + "sync" "time" "github.com/aws/aws-sdk-go-v2/aws/external" @@ -16,94 +17,65 @@ import ( "github.com/func/func/provider/aws" "github.com/func/func/resource" "github.com/func/func/source" + "github.com/func/func/ui" + "github.com/func/func/version" "github.com/ghodss/yaml" "github.com/hashicorp/hcl/v2" "golang.org/x/sync/errgroup" ) -// A Logger is used for logging activity to the user. -type Logger interface { - Errorf(format string, args ...interface{}) - Warningf(format string, args ...interface{}) - Infof(format string, args ...interface{}) - Debugf(format string, args ...interface{}) - Tracef(format string, args ...interface{}) - - Writer(level LogLevel) io.Writer -} - -// PrefixLogger allows creating sub loggers with a prefix. -type PrefixLogger interface { - Logger - WithPrefix(name string) PrefixLogger -} - // App encapsulates all cli business logic. type App struct { - Log Logger + Log *logger Stdout io.Writer + + loader *resource.Loader } -// NewApp creates a new App with the given log level. -func NewApp(verbosity int) *App { - logger := &StdLogger{ - Output: os.Stderr, - Level: LogLevel(verbosity), - } +// NewApp creates a new cli app. +func NewApp(verbose bool) *App { return &App{ - Log: logger, + Log: newLogger(os.Stderr, verbose), Stdout: os.Stdout, } } -type diagPrinter func(diags hcl.Diagnostics) - -func (a *App) loadResources(dir string) (resource.List, diagPrinter, hcl.Diagnostics) { - reg := &resource.Registry{} - reg.Add("aws:iam_role", reflect.TypeOf(&aws.IAMRole{})) - reg.Add("aws:lambda_function", reflect.TypeOf(&aws.LambdaFunction{})) +func (a *App) loadResources(dir string) (resource.List, hcl.Diagnostics) { + if a.loader == nil { + reg := &resource.Registry{} + reg.Add("aws:iam_role", reflect.TypeOf(&aws.IAMRole{})) + reg.Add("aws:lambda_function", reflect.TypeOf(&aws.LambdaFunction{})) - loader := &resource.Loader{ - Registry: reg, + a.loader = &resource.Loader{ + Registry: reg, + } } - a.Log.Debugf("Loading config from %s", dir) - - list, diags := loader.LoadDir(dir) - printer := func(diags hcl.Diagnostics) { - out := a.Log.Writer(allLevels) - loader.PrintDiagnostics(out, diags) - } - a.Log.Tracef("Loaded %d resources", len(list)) - return list, printer, diags + return a.loader.LoadDir(dir) } type sourcecode struct { - Resource string + Resource *resource.Resource Source *source.Code + Checksum string Key string } -func (a *App) sources(resources resource.List) ([]sourcecode, error) { +func sources(resources resource.List) ([]sourcecode, error) { sources := resources.WithSource() out := make([]sourcecode, len(sources)) g, _ := errgroup.WithContext(context.Background()) for i, res := range sources { i, res := i, res g.Go(func() error { - log := a.Log - if pfx, ok := log.(PrefixLogger); ok { - log = pfx.WithPrefix(fmt.Sprintf(" [%s] ", res.Name)) - } - log.Tracef("Computing source checksum") sum, err := res.SourceCode.Checksum() if err != nil { return fmt.Errorf("%s: compute source checksum: %v", res.Name, err) } - log.Tracef("Source checksum = %s", sum) out[i] = sourcecode{ - Resource: res.Name, + Resource: res, Source: res.SourceCode, + Checksum: sum, Key: sum + ".zip", } return nil @@ -115,76 +87,65 @@ func (a *App) sources(resources resource.List) ([]sourcecode, error) { return out, nil } -func (a *App) ensureSource(ctx context.Context, src sourcecode, s3 *source.S3) error { - log := a.Log - if pfx, ok := log.(PrefixLogger); ok { - log = pfx.WithPrefix(fmt.Sprintf(" %s ", src.Resource)) - } - log.Tracef("Checking if source exists") +func ensureSource(ctx context.Context, src sourcecode, s3 *source.S3, step *logStep) error { + step.Icon = true + step.Verbosef("Dir: %s", src.Source.Files.Root) + step.Verbosef("Checksum: %s", src.Checksum[0:12]) exists, err := s3.Has(ctx, src.Key) if err != nil { return fmt.Errorf("check existing source: %w", err) } if exists { - log.Tracef("Source ok") return nil } files := src.Source.Files if len(src.Source.Build) > 0 { - log.Infof("Building") + buildScriptStep := step.Step("Build") + buildDir, err := ioutil.TempDir("", "func-build") if err != nil { return err } - log.Tracef("Build dir: %s", buildDir) defer func() { - log.Tracef("Removing temporary build dir %s", buildDir) _ = os.RemoveAll(buildDir) }() - log.Tracef("Copying source %d files to build dir", len(files.Files)) if err := files.Copy(buildDir); err != nil { return err } - log.Tracef("Executing build") - - buildTime := time.Now() - n := len(src.Source.Build) for i, s := range src.Source.Build { - line, stdout, stderr := log, log, log - if pfx, ok := line.(PrefixLogger); ok { - indexPrefix := pfx.WithPrefix(fmt.Sprintf(" %d/%d ", i+1, n)) - line = indexPrefix - stdout = indexPrefix.WithPrefix("| ") - stderr = indexPrefix.WithPrefix("| ") - } + buildStep := buildScriptStep.Step("$ " + string(s)) + io := &window{MaxLines: 3} + buildStep.Push(io) buildContext := &source.BuildContext{ Dir: buildDir, - Stdout: stdout.Writer(Info), - Stderr: stderr.Writer(allLevels), + Stdout: io, + Stderr: io, } - line.Debugf("$ %s", s) - stepTime := time.Now() if err := s.Exec(ctx, buildContext); err != nil { + buildStep.Errorf("Step failed: %v", err) return fmt.Errorf("exec step %d: %s: %w", i, s, err) } - line.Tracef("Done in %s", time.Since(stepTime).Round(time.Millisecond)) + // buildStep.Remove(io) + buildStep.Done() } - log.Debugf("Build completed in %s", time.Since(buildTime).Round(time.Millisecond)) - log.Tracef("Collecting build artifacts") + collectStep := step.Step("Collect build artifacts") output, err := source.Collect(buildDir) if err != nil { return err } - log.Tracef("Got %d build artifacts", len(output.Files)) files = output + collectStep.Verbosef("Got %d build artifacts", len(files.Files)) + collectStep.Done() + + buildScriptStep.Done() } - log.Debugf("Creating source zip") + compressStep := step.Step("Compress") zipfile := strings.Replace(src.Key, ".", "-*.", 1) f, err := ioutil.TempFile("", zipfile) if err != nil { @@ -203,20 +164,67 @@ func (a *App) ensureSource(ctx context.Context, src sourcecode, s3 *source.S3) e if _, err := f.Seek(0, 0); err != nil { return err } + compressStep.Done() + + stat, err := f.Stat() + if err != nil { + return err + } - log.Infof("Uploading") - err = s3.Upload(ctx, src.Key, f) + uploadStep := step.Step("Upload") + progress := newProgressBar(16) + progress.SetProgress(0.75) + uploadStep.Push(progress) + r := &uploadReader{ + File: f, + Progress: progress, + Size: stat.Size(), + } + err = s3.Upload(ctx, src.Key, r) if err != nil { return fmt.Errorf("upload: %w", err) } - log.Debugf("Upload complete") + uploadStep.Remove(progress) + uploadStep.Done() return nil } +type uploadReader struct { + File *os.File + Progress *progressBar + Size int64 + read int64 +} + +func (r *uploadReader) Read(p []byte) (int, error) { + return r.File.Read(p) +} + +func (r *uploadReader) ReadAt(p []byte, off int64) (int, error) { + n, err := r.File.ReadAt(p, off) + if err != nil { + return n, err + } + + r.read += int64(n) + + time.Sleep(50 * time.Millisecond) + + // Data appears to be read twice, possibly for signing the request + percent := float64(r.read-r.Size) / float64(r.Size) + r.Progress.SetProgress(percent) + + return n, err +} + +func (r *uploadReader) Seek(offset int64, whence int) (int64, error) { + return r.File.Seek(offset, whence) +} + func sourceLocations(sources []sourcecode, bucket string) map[string]cloudformation.S3Location { out := make(map[string]cloudformation.S3Location, len(sources)) for _, src := range sources { - out[src.Resource] = cloudformation.S3Location{ + out[src.Resource.Name] = cloudformation.S3Location{ Bucket: bucket, Key: src.Key, } @@ -235,13 +243,15 @@ type GenerateCloudFormationOpts struct { // GenerateCloudFormation generates a CloudFormation template from the // resources in the given directory. func (a *App) GenerateCloudFormation(ctx context.Context, dir string, opts GenerateCloudFormationOpts) int { - resources, printDiags, diags := a.loadResources(dir) - printDiags(diags) + step := a.Log.Step("Load resource configurations") + resources, diags := a.loadResources(dir) + step.PrintDiags(diags, a.loader.Files()) if diags.HasErrors() { return 1 } + step.Done() - srcs, err := a.sources(resources) + srcs, err := sources(resources) if err != nil { a.Log.Errorf("Could not collect source files: %v", err) return 1 @@ -258,12 +268,13 @@ func (a *App) GenerateCloudFormation(ctx context.Context, dir string, opts Gener } s3 := source.NewS3(cfg, opts.SourceBucket) + srcStep := a.Log.Step("Process source code") g, gctx := errgroup.WithContext(ctx) for _, src := range srcs { src := src g.Go(func() error { - if err := a.ensureSource(gctx, src, s3); err != nil { - return fmt.Errorf("%s: %w", src.Resource, err) + if err := ensureSource(gctx, src, s3, srcStep); err != nil { + return fmt.Errorf("%s: %w", src.Resource.Name, err) } return nil }) @@ -273,14 +284,18 @@ func (a *App) GenerateCloudFormation(ctx context.Context, dir string, opts Gener a.Log.Errorf("Could not process source: %v", err) return 1 } + + srcStep.Done() } + step = a.Log.Step("Generate CloudFormation template") locs := sourceLocations(srcs, opts.SourceBucket) tmpl, diags := cloudformation.Generate(resources, locs) - printDiags(diags) + step.PrintDiags(diags, a.loader.Files()) if diags.HasErrors() { os.Exit(1) } + step.Done() var out []byte switch strings.ToLower(opts.Format) { @@ -305,6 +320,10 @@ func (a *App) GenerateCloudFormation(ctx context.Context, dir string, opts Gener if !strings.HasSuffix(outstr, "\n") { outstr += "\n" } + + // Better way to ensure render completes + time.Sleep(100 * time.Millisecond) + fmt.Fprint(a.Stdout, outstr) return 0 } @@ -323,15 +342,24 @@ func (a *App) DeployCloudFormation(ctx context.Context, dir string, opts Deploym return 2 } - resources, printDiags, diags := a.loadResources(dir) - printDiags(diags) + defer func() { + // Better way to ensure render completes + time.Sleep(200 * time.Millisecond) + }() + + a.Log.Infof(ui.Format("func ", ui.Bold) + + ui.Format(version.Version, ui.Dim) + "\n", + ) + + step := a.Log.Step("Load resource configurations") + resources, diags := a.loadResources(dir) + step.PrintDiags(diags, a.loader.Files()) if diags.HasErrors() { return 1 } + step.Done() - a.Log.Debugf("Processing source files") - - srcs, err := a.sources(resources) + srcs, err := sources(resources) if err != nil { a.Log.Errorf("Could not collect source files: %v", err) return 1 @@ -348,52 +376,51 @@ func (a *App) DeployCloudFormation(ctx context.Context, dir string, opts Deploym cf := cloudformation.NewClient(cfg) s3 := source.NewS3(cfg, opts.SourceBucket) + srcStep := a.Log.Step("Process source code") + locs := sourceLocations(srcs, opts.SourceBucket) + tmpl, diags := cloudformation.Generate(resources, locs) + srcStep.PrintDiags(diags, a.loader.Files()) + if diags.HasErrors() { + return 1 + } + // Concurrently process sources and create change set. // Sources may require build/upload time, - // Change set creation takes a ~3 seconds. + // Change set creation can take a couple seconds. g, gctx := errgroup.WithContext(ctx) // 1/2: Process & upload source code + var wg sync.WaitGroup for _, src := range srcs { src := src + wg.Add(1) g.Go(func() error { - if err := a.ensureSource(gctx, src, s3); err != nil { - return fmt.Errorf("%s: %w", src.Resource, err) + defer wg.Done() + step := srcStep.Step(src.Resource.Name) + if err := ensureSource(gctx, src, s3, step); err != nil { + return fmt.Errorf("%s: %w", src.Resource.Name, err) } + step.Done() return nil }) } - - a.Log.Debugf("Generating CloudFormation template") - - locs := sourceLocations(srcs, opts.SourceBucket) - tmpl, diags := cloudformation.Generate(resources, locs) - if diags.HasErrors() { - printDiags(diags) - return 1 - } + go func() { + wg.Wait() + srcStep.Done() + }() // 2/2: Change set var changeset *cloudformation.ChangeSet g.Go(func() error { - a.Log.Debugf("Creating CloudFormation change set") - - a.Log.Tracef("Getting CloudFormation stack") stack, err := cf.StackByName(gctx, opts.StackName) if err != nil { return fmt.Errorf("get stack: %w", err) } - if stack.ID == "" { - a.Log.Tracef("Stack does not exist") - } else { - a.Log.Tracef("Got CloudFormation stack: %s", stack.ID) - } cs, err := cf.CreateChangeSet(gctx, stack, tmpl) if err != nil { return fmt.Errorf("create change set: %w", err) } - a.Log.Tracef("Created CloudFormation change set %s\n", cs.ID) changeset = cs return nil @@ -405,7 +432,7 @@ func (a *App) DeployCloudFormation(ctx context.Context, dir string, opts Deploym } if len(changeset.Changes) == 0 { - a.Log.Infof("No changes") + a.Log.Infof(ui.Format("\nNo changes", ui.Dim)) if err := cf.DeleteChangeSet(ctx, changeset); err != nil { // Safe to ignore a.Log.Errorf("Error cleaning up change set: %v\n", err) @@ -414,7 +441,7 @@ func (a *App) DeployCloudFormation(ctx context.Context, dir string, opts Deploym return 0 } - a.Log.Debugf("Deploying") + step = a.Log.Step("Deploy") deployment, err := cf.ExecuteChangeSet(ctx, changeset) if err != nil { @@ -422,10 +449,11 @@ func (a *App) DeployCloudFormation(ctx context.Context, dir string, opts Deploym return 1 } + deploySteps := make(map[string]*logStep) for ev := range cf.Events(ctx, deployment) { switch e := ev.(type) { case cloudformation.ErrorEvent: - a.Log.Errorf("Deployment error: %v", e.Error) + step.Errorf("Deployment error: %v", e.Error) return 1 case cloudformation.ResourceEvent: name := tmpl.LookupResource(e.LogicalID) @@ -433,22 +461,29 @@ func (a *App) DeployCloudFormation(ctx context.Context, dir string, opts Deploym // No mapping for resources that are being deleted name = e.LogicalID } - line := a.Log - if pfx, ok := line.(PrefixLogger); ok { - line = pfx.WithPrefix(fmt.Sprintf(" [%s] ", name)) + resStep, ok := deploySteps[name] + if !ok { + resStep = step.Step(e.Operation.String() + " " + name) + resStep.Icon = true + deploySteps[name] = resStep + } + switch e.State { + case cloudformation.StateComplete: + resStep.Done() + case cloudformation.StateFailed: + resStep.Errorf("%s failed because %s", e.Operation, e.Reason) } - line.Debugf("%s %s %s", e.Operation, e.State, e.Reason) case cloudformation.StackEvent: if e.State == cloudformation.StateComplete { if e.Operation == cloudformation.StackRollback { - a.Log.Errorf("Deployment failed: %s", e.Reason) + step.Errorf("Deployment failed: %s", e.Reason) return 1 } } } } - a.Log.Infof("Done") + step.Done() return 0 } diff --git a/cli/diagnostic.go b/cli/diagnostic.go new file mode 100644 index 0000000..2a748fe --- /dev/null +++ b/cli/diagnostic.go @@ -0,0 +1,108 @@ +package cli + +import ( + "bufio" + "fmt" + "strings" + + "github.com/func/func/ui" + "github.com/hashicorp/hcl/v2" + "github.com/mitchellh/go-wordwrap" +) + +type diagnostic struct { + *hcl.Diagnostic + File *hcl.File + + ExtendLines int +} + +func (d diagnostic) Render(f ui.Frame) string { + var out strings.Builder + + out.WriteByte('\n') + + switch d.Severity { + case hcl.DiagError: + out.WriteString(ui.Format("Error", ui.Red) + ": ") + case hcl.DiagWarning: + out.WriteString(ui.Format("Warning", ui.Yellow) + ": ") + } + out.WriteString(d.Summary) + out.WriteString("\n") + + if d.Detail != "" { + padRight := 8 // So text doesn't go all the way to the edge; easier to read + out.WriteString(wordwrap.WrapString(d.Detail, uint(f.Width-padRight))) + out.WriteString("\n") + } + out.WriteString("\n") + + if d.Subject != nil { + subjectRange := *d.Subject + contextRange := subjectRange + if d.Context != nil { + contextRange = hcl.RangeOver(contextRange, *d.Context) + } + + if d.File != nil && d.File.Bytes != nil { + out.WriteString(ui.Format(d.Subject.Filename, ui.Dim, ui.Italic)) + out.WriteString("\n\n") + + src := d.File.Bytes + sc := hcl.NewRangeScanner(src, d.Subject.Filename, bufio.ScanLines) + + for sc.Scan() { + lineRange := sc.Range() + if lineRange.Start.Line < d.Subject.Start.Line-d.ExtendLines { + // Too early, skip + continue + } + if lineRange.Start.Line > d.Subject.Start.Line+d.ExtendLines { + // Too late, skip + continue + } + + linePrefix := fmt.Sprintf("%4d │ ", lineRange.Start.Line) + codeWidth := f.Width - ui.StringWidth(linePrefix) + + if !lineRange.Overlaps(contextRange) { + // Not in context, print dim + out.WriteString(ui.Format(linePrefix, ui.Dim)) + out.WriteString(wrapCode(string(sc.Bytes()), codeWidth)) + out.WriteString("\n") + continue + } + + beforeRange, highlightRange, afterRange := lineRange.PartitionAround(subjectRange) + out.WriteString(ui.Format(fmt.Sprintf("%4d ", lineRange.Start.Line), ui.Bold)) + out.WriteString(ui.Format("│ ", ui.Dim)) + if highlightRange.Empty() { + out.WriteString(wrapCode(string(sc.Bytes()), codeWidth)) + } else { + before := beforeRange.SliceBytes(src) + highlighted := highlightRange.SliceBytes(src) + after := afterRange.SliceBytes(src) + out.WriteString(wrapCode(string(before), codeWidth)) + out.WriteString(ui.Format(wrapCode(string(highlighted), codeWidth), ui.Bold, ui.Underline)) + out.WriteString(wrapCode(string(after), codeWidth)) + } + out.WriteByte('\n') + } + } + } + + return out.String() +} + +func wrapCode(code string, w int) string { + wrapped := ui.Wrap(code, w) + var out strings.Builder + for i, l := range strings.Split(wrapped, "\n") { + if i > 0 { + out.WriteString(ui.Format("\n ┊ ", ui.Dim)) + } + out.WriteString(l) + } + return out.String() +} diff --git a/cli/indicator.go b/cli/indicator.go new file mode 100644 index 0000000..57adb45 --- /dev/null +++ b/cli/indicator.go @@ -0,0 +1,27 @@ +package cli + +import ( + "time" + + "github.com/func/func/ui" +) + +var lineIndicatorFrames = []string{ + "╴ ", + "─ ", + "╶╴", + " ─", + " ╶", + " ╶", + " ─", + "╶╴", + "─ ", + "╴ ", + "╴ ", +} + +func lineIndicator(_ ui.Frame) string { + frameDur := time.Second / 25 + num := int(time.Now().UnixNano()/frameDur.Nanoseconds()) % len(lineIndicatorFrames) + return lineIndicatorFrames[num] +} diff --git a/cli/logger.go b/cli/logger.go index a6b9729..6936859 100644 --- a/cli/logger.go +++ b/cli/logger.go @@ -3,84 +3,244 @@ package cli import ( "fmt" "io" - "io/ioutil" "strings" + "sync" + "time" + + "github.com/func/func/ui" + "github.com/hashicorp/hcl/v2" + "github.com/mattn/go-runewidth" ) -// LogLevel defines the log level to use. -type LogLevel int +type logger struct { + Verbose bool + ui.Stack +} -// Log levels: -const ( - Info LogLevel = iota - Debug - Trace -) +func newLogger(out io.Writer, verbose bool) *logger { + log := &logger{ + Verbose: verbose, + } + ui := ui.New(out, log) -var allLevels LogLevel = -1 + go func() { + for { + ui.Render() + time.Sleep(time.Second / 60) + } + }() + + return log +} -// StdLogger is a logger that writes to the given output without ANSI movement -// escape codes. -type StdLogger struct { - Output io.Writer - Level LogLevel +func (l *logger) Infof(format string, args ...interface{}) { + str := fmt.Sprintf(format, args...) + l.Stack.Push(infoMsg(str)) } -func (l *StdLogger) output(level LogLevel, format string, args []interface{}) { - if l.Level < level { +func (l *logger) Verbosef(format string, args ...interface{}) { + if !l.Verbose { return } - msg := fmt.Sprintf(format, args...) - if !strings.HasSuffix(msg, "\n") { - msg += "\n" + str := fmt.Sprintf(format, args...) + str = ui.Format(str, ui.Dim) + l.Stack.Push(verboseMsg(str)) +} + +func (l *logger) Errorf(format string, args ...interface{}) { + str := fmt.Sprintf(format, args...) + l.Stack.Push(errorMsg(str)) +} + +func (l *logger) Render(frame ui.Frame) string { + padLeft := 2 + frame.Width -= padLeft + return ui.Pad( + l.Stack.Render(frame), + ui.Padding{Top: 1, Bottom: 1, Left: padLeft}, + ) +} + +func (l *logger) Step(name string) *logStep { + s := newStep(name, l.Verbose) + s.Icon = true + l.Stack.Push(s) + return s +} + +type logStep struct { // nolint: maligned + ui.Stack + + Name string + Verbose bool + Icon bool + Prefix string + + mu sync.Mutex + started time.Time + stopped *time.Time + err bool +} + +func newStep(name string, verbose bool) *logStep { + return &logStep{ + Name: name, + Verbose: verbose, + started: time.Now(), } - if _, err := fmt.Fprint(l.Output, msg); err != nil { - panic(err) +} + +var ( + iconWidth = 2 + iconErr = ui.Format(runewidth.FillRight("❌", iconWidth), ui.Red) + iconDone = ui.Format(runewidth.FillRight("✅", iconWidth), ui.Green) +) + +func (s *logStep) Render(f ui.Frame) string { + s.mu.Lock() + defer s.mu.Unlock() + + cols := make([]string, 0, 3) + + prefix := s.Prefix + indent := ui.StringWidth(prefix) + if s.Icon { + switch { + case s.err: + cols = append(cols, iconErr) + case s.stopped != nil: + cols = append(cols, iconDone) + default: + var spinner string + if time.Since(s.started) > 250*time.Millisecond { + spinner = ui.Format(lineIndicator(f), ui.Green, ui.Dim) + } else { + spinner = "" + } + cols = append(cols, ui.PadRight(spinner, iconWidth)) + } + + indent += iconWidth + 1 + prefix = ui.PadRight(prefix, indent) } + f.Width -= indent + + name := ui.Format(s.Name, ui.Bold) + cols = append(cols, name) + + if s.stopped != nil { + d := s.stopped.Sub(s.started) + cols = append(cols, durStr(d)) + } else if !s.err { + d := time.Since(s.started).Truncate(time.Second) + cols = append(cols, durStr(d)) + } + + str := ui.Rows( + ui.Cols(cols...), + ui.Prefix( + s.Stack.Render(f), + prefix, + ), + ) + return str } -// Errorf writes an error level log message. -func (l *StdLogger) Errorf(format string, args ...interface{}) { l.output(allLevels, format, args) } +func durStr(d time.Duration) string { + switch { + case d < 100*time.Millisecond: + return "" + case d > 10*time.Second: + d = d.Round(100 * time.Millisecond) + case d > time.Second: + d = d.Round(10 * time.Millisecond) + default: + d = d.Round(time.Millisecond) + } + return ui.Format("("+d.String()+")", ui.Dim) +} -// Warningf writes a warning level log message. -func (l *StdLogger) Warningf(format string, args ...interface{}) { l.output(allLevels, format, args) } +func (s *logStep) Done() { + s.mu.Lock() + now := time.Now() + s.stopped = &now + s.mu.Unlock() +} -// Infof writes an info level log message. -func (l *StdLogger) Infof(format string, args ...interface{}) { l.output(Info, format, args) } +func (s *logStep) Step(name string) *logStep { + sub := newStep(name, s.Verbose) + sub.Prefix = " " + s.Stack.Push(sub) + return sub +} -// Debugf writes a debug level log message. -func (l *StdLogger) Debugf(format string, args ...interface{}) { l.output(Debug, format, args) } +func (s *logStep) Infof(format string, args ...interface{}) { + str := fmt.Sprintf(format, args...) + s.Stack.Push(infoMsg(str)) +} -// Tracef writes a trace level log message. -func (l *StdLogger) Tracef(format string, args ...interface{}) { l.output(Trace, format, args) } +func (s *logStep) Verbosef(format string, args ...interface{}) { + if !s.Verbose { + return + } + str := fmt.Sprintf(format, args...) + str = ui.Format(str, ui.Dim) + s.Stack.Push(verboseMsg(str)) +} -// WithPrefix creates a new logger that prefixes every log line with the given -// prefix. In case the output is already prefixed, the parent prefix appears -// first in the output. -func (l *StdLogger) WithPrefix(prefix string) PrefixLogger { - out := l.Output - for { - pw, ok := out.(*PrefixWriter) - if !ok { - break - } - out = pw.Output - prefix = string(pw.Prefix) + prefix +func (s *logStep) Errorf(format string, args ...interface{}) { + str := fmt.Sprintf(format, args...) + s.Stack.Push(errorMsg(str)) + s.mu.Lock() + s.err = true + s.mu.Unlock() +} + +type infoMsg string + +func (msg infoMsg) Render(f ui.Frame) string { + return string(msg) +} + +type verboseMsg string + +func (msg verboseMsg) Render(f ui.Frame) string { + return ui.Format(string(msg), ui.Dim) +} + +type errorMsg string + +func (msg errorMsg) Render(f ui.Frame) string { + return ui.Format("Error", ui.Red, ui.Bold) + ": " + string(msg) +} + +func (s *logStep) PrintDiags(diags hcl.Diagnostics, files map[string]*hcl.File) { + for _, d := range diags { + s.Stack.Push(diagnostic{ + Diagnostic: d, + File: files[d.Subject.Filename], + ExtendLines: 3, + }) } - return &StdLogger{ - Level: l.Level, - Output: &PrefixWriter{ - Output: out, - Prefix: []byte(prefix), - }, + if diags.HasErrors() { + s.mu.Lock() + s.err = true + s.mu.Unlock() } } -// Writer returns an io.Writer for the given log level. If the log level is not -// exceeded, any data written is discarded. -func (l *StdLogger) Writer(level LogLevel) io.Writer { - if l.Level < level { - return ioutil.Discard +type window struct { + strings.Builder + MaxLines int +} + +func (w *window) Render(f ui.Frame) string { + raw := strings.TrimSpace(w.String()) + lines := strings.Split(raw, "\n") + to := len(lines) - 1 + from := to - w.MaxLines + if from < 0 { + from = 0 } - return l.Output + return ui.Format(strings.Join(lines[from:to], "\n"), ui.Dim) } diff --git a/cli/prefix.go b/cli/prefix.go deleted file mode 100644 index ef8ab34..0000000 --- a/cli/prefix.go +++ /dev/null @@ -1,51 +0,0 @@ -package cli - -import ( - "bytes" - "io" -) - -// PrefixWriter wraps an io.Writer with a writer that prefixes every line -// with the given prefix. -type PrefixWriter struct { - Output io.Writer - Prefix []byte - - buf bytes.Buffer -} - -// Write writes the given p to the underlying writer, prefixing every line -// separated by \n with the prefix. The output will never end with only the -// prefix. -func (pw *PrefixWriter) Write(p []byte) (int, error) { - n := 0 - for { - i := bytes.IndexByte(p, '\n') - last := i < 0 - if last { - i = len(p) - } else { - i++ - } - line := p[:i] - p = p[i:] - if len(line) > 0 { - if _, err := pw.buf.Write(pw.Prefix); err != nil { - return n, err - } - } - m, err := pw.buf.Write(line) - n += m - if err != nil { - return n, err - } - if last { - break - } - } - if _, err := pw.buf.WriteTo(pw.Output); err != nil { - return 0, err - } - pw.buf.Reset() - return n, nil -} diff --git a/cli/prefix_test.go b/cli/prefix_test.go deleted file mode 100644 index efa8d81..0000000 --- a/cli/prefix_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package cli - -import ( - "bytes" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestPrefixLineWriter(t *testing.T) { - tests := []struct { - name string - prefix string - input string - want string - }{ - { - name: "NoInput", - prefix: "", - input: "", - want: "", - }, - { - name: "NoPrefix", - prefix: "", - input: "foo", - want: "foo", - }, - { - name: "Prefix", - prefix: ">", - input: "foo", - want: ">foo", - }, - { - name: "NoTrailingNewline", - prefix: ">", - input: "foo\nbar", - want: ">foo\n>bar", - }, - { - name: "TrailingNewline", - prefix: ">", - input: "foo\nbar\n", - want: ">foo\n>bar\n", - }, - { - name: "EmptyLines", - prefix: ">", - input: "foo\n\nbar\n\nbaz\n\n", - want: ">foo\n>\n>bar\n>\n>baz\n>\n", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var buf bytes.Buffer - pw := &PrefixWriter{Output: &buf, Prefix: []byte(tc.prefix)} - n, err := pw.Write([]byte(tc.input)) - if err != nil { - t.Fatal(err) - } - if n != len(tc.input) { - t.Fatalf("Write returned %d, want %d", n, len(tc.input)) - } - if diff := cmp.Diff(buf.String(), tc.want); diff != "" { - t.Errorf("Diff (-got +want)\n%s", diff) - } - }) - } -} diff --git a/cli/progress.go b/cli/progress.go new file mode 100644 index 0000000..e0ad48e --- /dev/null +++ b/cli/progress.go @@ -0,0 +1,71 @@ +package cli + +import ( + "strings" + "sync" + + "github.com/func/func/ui" +) + +type progressBar struct { + Width int + Frames string + + mu sync.Mutex + progress float64 + buf strings.Builder +} + +func newProgressBar(width int) *progressBar { + return &progressBar{ + Width: width, + Frames: "●○", + } +} + +func (p *progressBar) SetProgress(progress float64) { + p.mu.Lock() + defer p.mu.Unlock() + p.progress = progress +} + +func (p *progressBar) Render(frame ui.Frame) string { + p.mu.Lock() + defer p.mu.Unlock() + + width := p.Width + if width > frame.Width { + width = frame.Width + } + + frames := []rune(p.Frames) + filled := frames[0] + unfilled := frames[len(frames)-1] + var mid []rune + if len(frames) > 2 { + mid = frames[1 : len(frames)-1] + } + + p.buf.Reset() + for i := 0; i < width; i++ { + offset := float64(i) / float64(width) + v := (p.progress - offset) * float64(width) + if v > 0.99 { + v = 1 + } + + if v >= 1 { + p.buf.WriteRune(filled) + continue + } + if v > 0 && len(mid) > 0 { + index := v*float64((len(mid)+1)) - 1 + if index >= 0 { + p.buf.WriteRune(mid[int(index)]) + continue + } + } + p.buf.WriteString(ui.Format(string(unfilled), ui.Dim)) + } + return p.buf.String() +} diff --git a/cmd/deploy.go b/cmd/deploy.go index 8264268..44d8fcb 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -15,8 +15,7 @@ func deployCommand() *cobra.Command { Short: "Deploy CloudFormation stack", } flags := cmd.Flags() - - logLevel := flags.CountP("v", "v", "Log level") + verbose := flags.Bool("verbose", false, "Enable verbose output") var opts cli.DeploymentOpts flags.StringVarP(&opts.StackName, "stack", "s", "", "CloudFormation stack name") @@ -29,7 +28,7 @@ func deployCommand() *cobra.Command { os.Exit(1) } - app := cli.NewApp(*logLevel) + app := cli.NewApp(*verbose) ctx := context.Background() code := app.DeployCloudFormation(ctx, dir, opts) diff --git a/cmd/generate.go b/cmd/generate.go index d56a92c..1787da1 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -15,8 +15,7 @@ func generateCommand() *cobra.Command { Short: "Generate CloudFormation template", } flags := cmd.Flags() - - logLevel := flags.CountP("v", "v", "Log level") + verbose := flags.Bool("verbose", false, "Enable verbose output") var opts cli.GenerateCloudFormationOpts flags.StringVarP(&opts.Format, "format", "f", "yaml", "Output format") @@ -30,7 +29,7 @@ func generateCommand() *cobra.Command { os.Exit(1) } - app := cli.NewApp(*logLevel) + app := cli.NewApp(*verbose) ctx := context.Background() code := app.GenerateCloudFormation(ctx, dir, opts) diff --git a/go.mod b/go.mod index 4da4940..0b4af7b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/go-cmp v0.4.0 github.com/hashicorp/hcl/v2 v2.3.0 github.com/mattn/go-runewidth v0.0.9 + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 github.com/pkg/errors v0.9.1 // indirect github.com/smartystreets/assertions v1.0.0 // indirect github.com/spf13/cobra v0.0.6 diff --git a/resource/loader.go b/resource/loader.go index 268b73c..475a07b 100644 --- a/resource/loader.go +++ b/resource/loader.go @@ -1,8 +1,6 @@ package resource import ( - "fmt" - "io" "os" "path/filepath" @@ -53,12 +51,7 @@ func (l *Loader) LoadDir(dir string) (List, hcl.Diagnostics) { return g, diags } -// PrintDiagnostics writes human readable diagnostics that were produced from -// loading resources. -func (l *Loader) PrintDiagnostics(w io.Writer, diags hcl.Diagnostics) { - wr := hcl.NewDiagnosticTextWriter(w, l.parser.Files(), 0, true) - err := wr.WriteDiagnostics(diags) - if err != nil { - fmt.Fprintln(w, err) - } +// Files returns all the loaded files, keyed by file name. +func (l *Loader) Files() map[string]*hcl.File { + return l.parser.Files() }