From 815ffff168644a9ffac9c3af9b513c816a2fca76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Fri, 28 Oct 2022 17:50:31 +0200 Subject: [PATCH] feat: add hidden verbose flag to deploy (#512) --- cmd/meroxa/root/apps/deploy.go | 66 +++++++++----- cmd/meroxa/root/apps/deploy_test.go | 105 ++++++++++++++++++++--- cmd/meroxa/root/apps/upgrade.go | 4 +- cmd/meroxa/turbine/golang/build.go | 2 +- cmd/meroxa/turbine/golang/init.go | 2 +- cmd/meroxa/turbine/golang/upgrade.go | 2 +- cmd/meroxa/turbine/golang/utils.go | 2 +- cmd/meroxa/turbine/javascript/upgrade.go | 4 +- cmd/meroxa/turbine/python/build.go | 2 +- cmd/meroxa/turbine/python/upgrade.go | 4 +- cmd/meroxa/turbine/utils.go | 14 +-- log/spinner.go | 2 +- 12 files changed, 158 insertions(+), 51 deletions(-) diff --git a/cmd/meroxa/root/apps/deploy.go b/cmd/meroxa/root/apps/deploy.go index 21f36d470..9c095522c 100644 --- a/cmd/meroxa/root/apps/deploy.go +++ b/cmd/meroxa/root/apps/deploy.go @@ -66,6 +66,7 @@ type Deploy struct { DockerHubAccessToken string `long:"docker-hub-access-token" usage:"DockerHub access token to use to build and deploy the app" hidden:"true"` //nolint:lll Spec string `long:"spec" usage:"Deployment specification version to use to build and deploy the app" hidden:"true"` SkipCollectionValidation bool `long:"skip-collection-validation" usage:"Skips unique destination collection and looping validations"` //nolint:lll + Verbose bool `long:"verbose" usage:"Prints more logging messages" hidden:"true"` } client deployApplicationClient @@ -195,7 +196,7 @@ func (d *Deploy) Logger(logger log.Logger) { } func (d *Deploy) getPlatformImage(ctx context.Context) (string, error) { - d.logger.StartSpinner("\t", " Fetching Meroxa Platform source...") + d.logger.StartSpinner("\t", "Fetching Meroxa Platform source...") s, err := d.client.CreateSource(ctx) if err != nil { @@ -218,7 +219,7 @@ func (d *Deploy) getPlatformImage(ctx context.Context) (string, error) { d.logger.StopSpinnerWithStatus("\t", log.Failed) return "", err } - d.logger.StartSpinner("\t", fmt.Sprintf(" Building Meroxa Process image (%q)...", build.Uuid)) + d.logger.StartSpinner("\t", fmt.Sprintf("Building Meroxa Process image (%q)...", build.Uuid)) for { b, err := d.client.GetBuild(ctx, build.Uuid) @@ -276,7 +277,7 @@ func (d *Deploy) buildApp(ctx context.Context) (err error) { // getAppImage will check what type of build needs to perform and ultimately will return the image name to use // when deploying. func (d *Deploy) getAppImage(ctx context.Context) (string, error) { - d.logger.StartSpinner("\t", " Checking if application has processes...") + d.logger.StartSpinner("\t", "Checking if application has processes...") var fqImageName string needsToBuild, err := d.turbineCLI.NeedsToBuild(ctx, d.appName) @@ -287,7 +288,7 @@ func (d *Deploy) getAppImage(ctx context.Context) (string, error) { // If no need to build, return empty imageName which won't be utilized by the deployment process anyway. if !needsToBuild { - d.logger.StopSpinnerWithStatus("No need to create process image...\n", log.Successful) + d.logger.StopSpinnerWithStatus("No need to create process image\n", log.Successful) return "", nil } @@ -396,7 +397,7 @@ func (d *Deploy) getResourceCheckErrorMessage(ctx context.Context, resources []t } func (d *Deploy) checkResourceAvailability(ctx context.Context) error { - resourceCheckMessage := fmt.Sprintf(" Checking resource availability for application %q (%s) before deployment...", d.appName, d.lang) + resourceCheckMessage := fmt.Sprintf("Checking resource availability for application %q (%s) before deployment...", d.appName, d.lang) d.logger.StartSpinner("\t", resourceCheckMessage) @@ -578,12 +579,12 @@ func (d *Deploy) prepareAppName(ctx context.Context) string { func (d *Deploy) waitForDeployment(ctx context.Context, depUUID string) error { cctx, cancel := context.WithTimeout(ctx, minutesToWaitForDeployment*time.Minute) defer cancel() - checkLogsMsg := "Check `meroxa apps logs` for further information" - t := time.NewTicker(intervalCheckForDeployment) defer t.Stop() + prevLine := "" + for { select { case <-t.C: @@ -592,15 +593,26 @@ func (d *Deploy) waitForDeployment(ctx context.Context, depUUID string) error { if err != nil { return fmt.Errorf("couldn't fetch deployment status: %s", err.Error()) } + logs := strings.Split(deployment.Status.Details, "\n") + if d.flags.Verbose { + l := len(logs) + if l > 0 && logs[l-1] != prevLine { + prevLine = logs[l-1] + d.logger.Info(ctx, "\t"+logs[l-1]) + } + } + switch { case deployment.Status.State == meroxa.DeploymentStateDeployed: return nil case deployment.Status.State == meroxa.DeploymentStateDeployingError: - d.logger.Errorf(ctx, "\t %s Failed to deploy Application %q\n", d.logger.FailedMark(), d.appName) - for _, l := range logs { - d.logger.Errorf(ctx, "\t\t > %s", l) + if !d.flags.Verbose { + d.logger.Error(ctx, "\n") + for _, l := range logs { + d.logger.Errorf(ctx, "\t%s", l) + } } return fmt.Errorf("\n %s", checkLogsMsg) } @@ -686,25 +698,39 @@ func (d *Deploy) Execute(ctx context.Context) error { return err } - d.logger.StartSpinner("", fmt.Sprintf(" Deploying application %q...", d.appName)) - var deployment *meroxa.Deployment if deployment, err = d.deployApp(ctx, d.fnName, gitSha, d.specVersion); err != nil { - d.logger.StopSpinnerWithStatus("Couldn't complete the deployment", log.Failed) return err } - if err := d.waitForDeployment(ctx, deployment.UUID); err != nil { - d.logger.StopSpinnerWithStatus("Couldn't complete the deployment", log.Failed) - return err + deployMsg := fmt.Sprintf("Deploying application %q...", d.appName) + // In verbose mode, we'll use spinners for each deployment step + if d.flags.Verbose { + d.logger.Info(ctx, deployMsg+"\n") + } else { + d.logger.StartSpinner("", deployMsg) } - d.logger.StopSpinnerWithStatus("Deploying completed!", log.Successful) + err = d.waitForDeployment(ctx, deployment.UUID) + if err != nil { + deploymentErroredMsg := "Couldn't complete the deployment" + if !d.flags.Verbose { + d.logger.StopSpinnerWithStatus(deploymentErroredMsg, log.Failed) + } else { + d.logger.Error(ctx, fmt.Sprintf("\t%s %s", d.logger.FailedMark(), deploymentErroredMsg)) + } + return err + } dashboardURL := fmt.Sprintf("https://dashboard.meroxa.io/apps/%s/detail", d.appName) - output := fmt.Sprintf("\t%s Application %q successfully deployed!\n\n ✨ To visualize your application visit %s", - d.logger.SuccessfulCheck(), d.appName, dashboardURL) - d.logger.Info(ctx, output) + output := fmt.Sprintf("Application %q successfully deployed!\n\n ✨ To visualize your application visit %s", + d.appName, dashboardURL) + + if d.flags.Verbose { + d.logger.Info(ctx, fmt.Sprintf("\n\t%s %s", d.logger.SuccessfulCheck(), output)) + } else { + d.logger.StopSpinnerWithStatus(output, log.Successful) + } d.logger.JSON(ctx, app) return nil diff --git a/cmd/meroxa/root/apps/deploy_test.go b/cmd/meroxa/root/apps/deploy_test.go index 32025fed4..5a4a5f034 100644 --- a/cmd/meroxa/root/apps/deploy_test.go +++ b/cmd/meroxa/root/apps/deploy_test.go @@ -41,6 +41,7 @@ func TestDeployAppFlags(t *testing.T) { {name: "docker-hub-access-token", required: false, hidden: true}, {name: "spec", required: false, hidden: true}, {name: "skip-collection-validation", required: false, hidden: false}, + {name: "verbose", required: false, hidden: true}, } c := builder.BuildCobraCommand(&Deploy{}) @@ -867,11 +868,11 @@ func TestWaitForDeployment(t *testing.T) { name string meroxaClient func() meroxa.Client wantOutput func() string - noLogs bool + verboseFlag bool err error }{ { - name: "Deployment finishes successfully immediately", + name: "Deployment finishes successfully immediately (no verbose flag)", meroxaClient: func() meroxa.Client { client := mock.NewMockClient(ctrl) @@ -889,7 +890,26 @@ func TestWaitForDeployment(t *testing.T) { err: nil, }, { - name: "Deployment finishes successfully over time", + name: "Deployment finishes successfully immediately (with verbose flag)", + meroxaClient: func() meroxa.Client { + client := mock.NewMockClient(ctrl) + + client.EXPECT(). + GetDeployment(ctx, appName, uuid). + Return(&meroxa.Deployment{ + Status: meroxa.DeploymentStatus{ + State: meroxa.DeploymentStateDeployed, + Details: strings.Join(outputLogs, "\n"), + }, + }, nil) + return client + }, + wantOutput: func() string { return "\tnailed it\n" }, + verboseFlag: true, + err: nil, + }, + { + name: "Deployment finishes successfully over time (no verbose flag)", meroxaClient: func() meroxa.Client { client := mock.NewMockClient(ctrl) @@ -924,7 +944,49 @@ func TestWaitForDeployment(t *testing.T) { wantOutput: func() string { return "" }, }, { - name: "Deployment immediately failed", + name: "Deployment finishes successfully over time (with verbose flag)", + meroxaClient: func() meroxa.Client { + client := mock.NewMockClient(ctrl) + + first := client.EXPECT(). + GetDeployment(ctx, appName, uuid). + Return(&meroxa.Deployment{ + Status: meroxa.DeploymentStatus{ + State: meroxa.DeploymentStateDeploying, + Details: strings.Join(outputLogs[:1], "\n"), + }, + }, nil) + second := client.EXPECT(). + GetDeployment(ctx, appName, uuid). + Return(&meroxa.Deployment{ + Status: meroxa.DeploymentStatus{ + State: meroxa.DeploymentStateDeploying, + Details: strings.Join(outputLogs[:2], "\n"), + }, + }, nil) + third := client.EXPECT(). + GetDeployment(ctx, appName, uuid). + Return(&meroxa.Deployment{ + Status: meroxa.DeploymentStatus{ + State: meroxa.DeploymentStateDeployed, + Details: strings.Join(outputLogs, "\n"), + }, + }, nil).AnyTimes() + gomock.InOrder(first, second, third) + return client + }, + err: nil, + wantOutput: func() string { + errorMsg := "" + for _, l := range outputLogs { + errorMsg = fmt.Sprintf("%s\t%s\n", errorMsg, l) + } + return errorMsg + }, + verboseFlag: true, + }, + { + name: "Deployment immediately failed (no verbose flag)", meroxaClient: func() meroxa.Client { client := mock.NewMockClient(ctrl) @@ -938,31 +1000,48 @@ func TestWaitForDeployment(t *testing.T) { }, nil) return client }, - noLogs: false, wantOutput: func() string { - errorMsg := fmt.Sprintf("\t x Failed to deploy Application %q\n", appName) + errorMsg := "\n" for _, l := range outputLogs { - errorMsg = fmt.Sprintf("%s\t\t > %s\n", errorMsg, l) + errorMsg = fmt.Sprintf("%s\t%s\n", errorMsg, l) } return errorMsg }, err: fmt.Errorf("\n Check `meroxa apps logs` for further information"), }, { - name: "Failed to get latest deployment", + name: "Deployment immediately failed (with verbose flag)", meroxaClient: func() meroxa.Client { client := mock.NewMockClient(ctrl) client.EXPECT(). GetDeployment(ctx, appName, uuid). - Return(&meroxa.Deployment{}, fmt.Errorf("not today")) + Return(&meroxa.Deployment{ + Status: meroxa.DeploymentStatus{ + State: meroxa.DeploymentStateDeployingError, + Details: strings.Join(outputLogs, "\n"), + }, + }, nil) return client }, - noLogs: true, wantOutput: func() string { - return "" + return "\tnailed it\n" + }, + verboseFlag: true, + err: fmt.Errorf("\n Check `meroxa apps logs` for further information"), + }, + { + name: "Failed to get latest deployment", + meroxaClient: func() meroxa.Client { + client := mock.NewMockClient(ctrl) + + client.EXPECT(). + GetDeployment(ctx, appName, uuid). + Return(&meroxa.Deployment{}, fmt.Errorf("not today")) + return client }, - err: errors.New("couldn't fetch deployment status: not today"), + wantOutput: func() string { return "" }, + err: errors.New("couldn't fetch deployment status: not today"), }, } @@ -975,6 +1054,8 @@ func TestWaitForDeployment(t *testing.T) { appName: appName, } + d.flags.Verbose = tc.verboseFlag + err := d.waitForDeployment(ctx, uuid) require.Equal(t, tc.err, err, "errors are not equal") diff --git a/cmd/meroxa/root/apps/upgrade.go b/cmd/meroxa/root/apps/upgrade.go index eda2e01a9..bb73f5d04 100644 --- a/cmd/meroxa/root/apps/upgrade.go +++ b/cmd/meroxa/root/apps/upgrade.go @@ -73,7 +73,7 @@ func (u *Upgrade) Execute(ctx context.Context) error { var err error if u.config == nil { u.path, err = turbine.GetPath(u.flags.Path) - u.logger.StartSpinner("\t", fmt.Sprintf(" Fetching details of application in %q...", u.path)) + u.logger.StartSpinner("\t", fmt.Sprintf("Fetching details of application in %q...", u.path)) if err != nil { u.logger.StopSpinnerWithStatus("\t", log.Failed) return err @@ -114,7 +114,7 @@ func (u *Upgrade) Execute(ctx context.Context) error { return err } - u.logger.StartSpinner("\t", " Testing upgrades locally...") + u.logger.StartSpinner("\t", "Testing upgrades locally...") runOutput := "" buf := bytes.NewBufferString(runOutput) if u.run == nil { diff --git a/cmd/meroxa/turbine/golang/build.go b/cmd/meroxa/turbine/golang/build.go index 9a1fe8351..aa1a0bba6 100644 --- a/cmd/meroxa/turbine/golang/build.go +++ b/cmd/meroxa/turbine/golang/build.go @@ -13,7 +13,7 @@ import ( func (t *turbineGoCLI) Build(ctx context.Context, appName string, platform bool) (string, error) { var cmd *exec.Cmd - t.logger.StartSpinner("\t", " Building Golang binary...") + t.logger.StartSpinner("\t", "Building Golang binary...") if platform { cmd = exec.CommandContext(ctx, "go", "build", "--tags", "platform", "-o", appName, "./...") } else { diff --git a/cmd/meroxa/turbine/golang/init.go b/cmd/meroxa/turbine/golang/init.go index 7723f2a5c..926584a1c 100644 --- a/cmd/meroxa/turbine/golang/init.go +++ b/cmd/meroxa/turbine/golang/init.go @@ -27,7 +27,7 @@ func (t *turbineGoCLI) GitInit(ctx context.Context, name string) error { } func GoInit(l log.Logger, appPath string, skipInit, vendor bool) error { - l.StartSpinner("\t", " Running golang module initializing...") + l.StartSpinner("\t", "Running golang module initializing...") skipLog := "skipping go module initialization\n\tFor guidance, visit " + "https://docs.meroxa.com/beta-overview#go-mod-init-for-a-new-golang-turbine-data-application" goPath := os.Getenv("GOPATH") diff --git a/cmd/meroxa/turbine/golang/upgrade.go b/cmd/meroxa/turbine/golang/upgrade.go index 3e9f342bb..eabe0e191 100644 --- a/cmd/meroxa/turbine/golang/upgrade.go +++ b/cmd/meroxa/turbine/golang/upgrade.go @@ -31,7 +31,7 @@ func (t *turbineGoCLI) Upgrade(vendor bool) error { func (t *turbineGoCLI) tidy(vendor bool) error { var err error - t.logger.StartSpinner("\t", " Tidying up Golang modules...") + t.logger.StartSpinner("\t", "Tidying up Golang modules...") if vendor { _, err = os.Stat("vendor") if !os.IsNotExist(err) { diff --git a/cmd/meroxa/turbine/golang/utils.go b/cmd/meroxa/turbine/golang/utils.go index 12b184bf1..c7fe8bcab 100644 --- a/cmd/meroxa/turbine/golang/utils.go +++ b/cmd/meroxa/turbine/golang/utils.go @@ -9,7 +9,7 @@ import ( // GoGetDeps updates the latest Meroxa mods. func GoGetDeps(l log.Logger) error { - l.StartSpinner("\t", " Getting latest turbine-go and turbine-go/running dependencies...") + l.StartSpinner("\t", "Getting latest turbine-go and turbine-go/running dependencies...") cmd := exec.Command("go", "get", "-u", "github.com/meroxa/turbine-go") output, err := cmd.CombinedOutput() if err != nil { diff --git a/cmd/meroxa/turbine/javascript/upgrade.go b/cmd/meroxa/turbine/javascript/upgrade.go index e0fdd5261..db44bbea0 100644 --- a/cmd/meroxa/turbine/javascript/upgrade.go +++ b/cmd/meroxa/turbine/javascript/upgrade.go @@ -13,7 +13,7 @@ func (t *turbineJsCLI) Upgrade(vendor bool) error { cmd.Dir = t.appPath err := cmd.Wait() if err != nil { - t.logger.StartSpinner("\t", " Adding @meroxa/turbine-js-framework requirement...") + t.logger.StartSpinner("\t", "Adding @meroxa/turbine-js-framework requirement...") cmd = exec.Command("npm", "install", "@meroxa/turbine-js-framework", "--save") cmd.Dir = t.appPath out, err := cmd.CombinedOutput() @@ -38,7 +38,7 @@ func (t *turbineJsCLI) Upgrade(vendor bool) error { } t.logger.StopSpinnerWithStatus("Added @meroxa/turbine-js-framework requirement successfully!", log.Successful) } else { - t.logger.StartSpinner("\t", " Upgrading @meroxa/turbine-js-framework...") + t.logger.StartSpinner("\t", "Upgrading @meroxa/turbine-js-framework...") cmd = exec.Command("npm", "upgrade", "@meroxa/turbine-js-framework") cmd.Dir = t.appPath out, err := cmd.CombinedOutput() diff --git a/cmd/meroxa/turbine/python/build.go b/cmd/meroxa/turbine/python/build.go index 0f5b2af23..1a58a3667 100644 --- a/cmd/meroxa/turbine/python/build.go +++ b/cmd/meroxa/turbine/python/build.go @@ -11,7 +11,7 @@ import ( // Build created the needed structure for a python app. func (t *turbinePyCLI) Build(ctx context.Context, appName string, platform bool) (string, error) { - t.logger.StartSpinner("\t", " Building application...") + t.logger.StartSpinner("\t", "Building application...") cmd := exec.CommandContext(ctx, "turbine-py", "clibuild", t.appPath) output, err := cmd.CombinedOutput() if err != nil { diff --git a/cmd/meroxa/turbine/python/upgrade.go b/cmd/meroxa/turbine/python/upgrade.go index 5e00ffea2..2512ae9c3 100644 --- a/cmd/meroxa/turbine/python/upgrade.go +++ b/cmd/meroxa/turbine/python/upgrade.go @@ -15,7 +15,7 @@ func (t *turbinePyCLI) Upgrade(vendor bool) error { cmd.Dir = t.appPath err := cmd.Run() if err != nil { - t.logger.StartSpinner("\t", " Tidying up requirements.txt...") + t.logger.StartSpinner("\t", "Tidying up requirements.txt...") cmd = exec.Command("bash", "-c", "sed -i 's+meroxa-py++g' requirements.txt") cmd.Dir = t.appPath err1 := cmd.Run() @@ -31,7 +31,7 @@ func (t *turbinePyCLI) Upgrade(vendor bool) error { } } - t.logger.StartSpinner("\t", " Updating Python dependencies...") + t.logger.StartSpinner("\t", "Updating Python dependencies...") cmd = exec.Command("pip", "install", "turbine-py", "-U") cmd.Dir = t.appPath out, err := cmd.CombinedOutput() diff --git a/cmd/meroxa/turbine/utils.go b/cmd/meroxa/turbine/utils.go index a3890b7f1..e9a9b546b 100644 --- a/cmd/meroxa/turbine/utils.go +++ b/cmd/meroxa/turbine/utils.go @@ -90,7 +90,7 @@ func GetPath(flag string) (string, error) { // GetLangFromAppJSON returns specified language in users' app.json. func GetLangFromAppJSON(ctx context.Context, l log.Logger, pwd string) (string, error) { - l.StartSpinner("\t", " Determining application language from app.json...") + l.StartSpinner("\t", "Determining application language from app.json...") appConfig, err := ReadConfigFile(pwd) if err != nil { l.StopSpinnerWithStatus("Something went wrong reading your app.json", log.Failed) @@ -107,7 +107,7 @@ func GetLangFromAppJSON(ctx context.Context, l log.Logger, pwd string) (string, // GetAppNameFromAppJSON returns specified app name in users' app.json. func GetAppNameFromAppJSON(ctx context.Context, l log.Logger, pwd string) (string, error) { - l.StartSpinner("\t", " Reading application name from app.json...") + l.StartSpinner("\t", "Reading application name from app.json...") appConfig, err := ReadConfigFile(pwd) if err != nil { return "", err @@ -371,7 +371,7 @@ func UploadSource(ctx context.Context, logger log.Logger, language, appPath, app var err error if language == GoLang || language == JavaScript { - logger.StartSpinner("\t", fmt.Sprintf(" Creating Dockerfile before uploading source in %s", appPath)) + logger.StartSpinner("\t", fmt.Sprintf("Creating Dockerfile before uploading source in %s", appPath)) if language == GoLang { err = turbineGo.CreateDockerfile(appName, appPath) @@ -405,7 +405,7 @@ func UploadSource(ctx context.Context, logger log.Logger, language, appPath, app return err } - logger.StartSpinner("\t", fmt.Sprintf(" Creating %q in %q to upload to our build service...", appPath, dFile)) + logger.StartSpinner("\t", fmt.Sprintf("Creating %q in %q to upload to our build service...", appPath, dFile)) fileToWrite, err := os.OpenFile(dFile, os.O_CREATE|os.O_RDWR, os.FileMode(0777)) //nolint:gomnd defer func(fileToWrite *os.File) { @@ -415,7 +415,7 @@ func UploadSource(ctx context.Context, logger log.Logger, language, appPath, app } // remove .tar.gz file - logger.StartSpinner("\t", fmt.Sprintf(" Removing %q...", dFile)) + logger.StartSpinner("\t", fmt.Sprintf("Removing %q...", dFile)) removeErr := os.Remove(dFile) if removeErr != nil { logger.StopSpinnerWithStatus(fmt.Sprintf("\t Something went wrong trying to remove %q", dFile), log.Failed) @@ -439,7 +439,7 @@ func UploadSource(ctx context.Context, logger log.Logger, language, appPath, app } func uploadFile(ctx context.Context, logger log.Logger, filePath, url string) error { - logger.StartSpinner("\t", " Uploading source...") + logger.StartSpinner("\t", "Uploading source...") fh, err := os.Open(filePath) if err != nil { @@ -526,7 +526,7 @@ func createJavascriptDockerfile(ctx context.Context, appPath string) error { // cleanUpPythonTempBuildLocation removes any artifacts in the temporary directory. func cleanUpPythonTempBuildLocation(ctx context.Context, logger log.Logger, appPath string) { - logger.StartSpinner("\t", fmt.Sprintf(" Removing artifacts from building the Python Application at %s...", appPath)) + logger.StartSpinner("\t", fmt.Sprintf("Removing artifacts from building the Python Application at %s...", appPath)) cmd := exec.CommandContext(ctx, "turbine-py", "cliclean", appPath) output, err := cmd.CombinedOutput() diff --git a/log/spinner.go b/log/spinner.go index 32d7b144d..eb0999aed 100644 --- a/log/spinner.go +++ b/log/spinner.go @@ -38,7 +38,7 @@ type spinnerLogger struct { func (l *spinnerLogger) StartSpinner(prefix, suffix string) { l.s = spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(l.out)) //nolint:gomnd l.s.Prefix = prefix - l.s.Suffix = suffix + l.s.Suffix = " " + suffix l.s.Start() }