diff --git a/build/devenv/cli/ccv.go b/build/devenv/cli/ccv.go index a0772fc69..c71f7fa97 100644 --- a/build/devenv/cli/ccv.go +++ b/build/devenv/cli/ccv.go @@ -36,6 +36,9 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/cli/log" "github.com/smartcontractkit/chainlink-ccv/build/devenv/cli/send" "github.com/smartcontractkit/chainlink-ccv/build/devenv/evm" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/reporter" + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" + "github.com/smartcontractkit/chainlink-ccv/build/devenv/util" ) const ( @@ -43,8 +46,10 @@ const ( LocalCCVDashboard = "http://localhost:3000/d/f8a04cef-653f-46d3-86df-87c532300672/ccv-services?orgId=1&refresh=5s" ) -// newEnvFn is set by PersistentPreRunE based on the --env-mode flag. -var newEnvFn func() error +// newEnvFn is set by PersistentPreRunE (or applyProfile) based on the +// --env-mode flag. The Reporter receives component-level events in phased +// mode; legacy mode wraps the monolith with stage-level events only. +var newEnvFn func(r devenvruntime.Reporter) error var rootCmd = &cobra.Command{ Use: "ccv", @@ -67,15 +72,15 @@ var rootCmd = &cobra.Command{ } switch mode { case "legacy": - // Both env constructors return a value that the up/restart commands - // discard, so adapt them to the error-only fn. - newEnvFn = func() error { + newEnvFn = func(r devenvruntime.Reporter) error { + r.OnStageStart("env") _, err := ccv.NewEnvironment() + r.OnStageFinish("env", err) return err } case "phased": - newEnvFn = func() error { - _, err := ccv.NewPhasedEnvironment() + newEnvFn = func(r devenvruntime.Reporter) error { + _, err := ccv.NewPhasedEnvironmentWithReporter(r) return err } default: @@ -98,7 +103,17 @@ var restartCmd = &cobra.Command{ if err := framework.RemoveTestContainers(); err != nil { return fmt.Errorf("failed to clean Docker resources: %w", err) } - return newEnvFn() + verbose, _ := cmd.Flags().GetBool("verbose") + term, cleanup, logPath, err := redirectToLogFile(verbose, "ccv-restart") + if err != nil { + return err + } + defer cleanup() + r := reporter.New(verbose, term) + outToml := resolveOutToml() + runErr := r.Run(func() error { return newEnvFn(r) }) + r.PrintSummary(outToml, logPath) + return runErr }, } @@ -111,7 +126,17 @@ var upCmd = &cobra.Command{ if err := applyEnvConfig(cmd, args); err != nil { return err } - return newEnvFn() + verbose, _ := cmd.Flags().GetBool("verbose") + term, cleanup, logPath, err := redirectToLogFile(verbose, "ccv-up") + if err != nil { + return err + } + defer cleanup() + r := reporter.New(verbose, term) + outToml := resolveOutToml() + runErr := r.Run(func() error { return newEnvFn(r) }) + r.PrintSummary(outToml, logPath) + return runErr }, } @@ -208,7 +233,11 @@ func applyEnvConfig(cmd *cobra.Command, args []string) error { profilePath = positional } if profilePath == "" { - profilePath = "standard.profile" + if saved := getActiveConfig(); strings.HasSuffix(strings.TrimSpace(saved), ".profile") { + profilePath = strings.TrimSpace(saved) + } else { + profilePath = "standard.profile" + } } // --profile is mutually exclusive with --env-mode (when explicitly set). @@ -220,6 +249,7 @@ func applyEnvConfig(cmd *cobra.Command, args []string) error { if err := applyProfile(profilePath); err != nil { return err } + saveActiveConfig(profilePath) if outputFlag != "" { _ = os.Setenv("CTF_OUTPUT", outputFlag) } @@ -240,13 +270,15 @@ func applyProfile(profilePath string) error { switch p.Environment { case "legacy": - newEnvFn = func() error { + newEnvFn = func(r devenvruntime.Reporter) error { + r.OnStageStart("env") _, err := ccv.NewEnvironment() + r.OnStageFinish("env", err) return err } case "phased": - newEnvFn = func() error { - _, err := ccv.NewPhasedEnvironment() + newEnvFn = func(r devenvruntime.Reporter) error { + _, err := ccv.NewPhasedEnvironmentWithReporter(r) return err } } @@ -536,7 +568,7 @@ Examples: profileName, _ := cmd.Flags().GetString("profile") timeout, _ := cmd.Flags().GetDuration("timeout") buildTarget, _ := cmd.Flags().GetString("build") - logPath, _ := cmd.Flags().GetString("log") + verbose, _ := cmd.Flags().GetBool("verbose") if len(args) > 0 && patternFlag != "" { return fmt.Errorf("cannot combine a suite name with --pattern") @@ -563,107 +595,91 @@ Examples: // file so the terminal only shows concise progress lines. We redirect at // the OS fd level (dup2) so that subprocesses, zerolog, and fmt.Print* // calls all land in the log regardless of how they open stdout/stderr. - progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) } - if logPath != "" { - lf, err := os.Create(logPath) - if err != nil { - return fmt.Errorf("failed to create log file %s: %w", logPath, err) - } - defer lf.Close() - - // Save the real terminal fds so progress messages can still reach it. - realStdoutFd, _ := syscall.Dup(int(os.Stdout.Fd())) - realStderrFd, _ := syscall.Dup(int(os.Stderr.Fd())) - realTerm := os.NewFile(uintptr(realStderrFd), "real_stderr") - defer func() { - // Restore terminal fds on exit. - _ = syscall.Dup2(realStdoutFd, int(os.Stdout.Fd())) - _ = syscall.Dup2(realStderrFd, int(os.Stderr.Fd())) - _ = syscall.Close(realStdoutFd) - // realStderrFd is owned by realTerm; closing realTerm closes it. - _ = realTerm.Close() - }() - - // Redirect stdout and stderr to the log file. - _ = syscall.Dup2(int(lf.Fd()), int(os.Stdout.Fd())) - _ = syscall.Dup2(int(lf.Fd()), int(os.Stderr.Fd())) - - progress = func(msg string) { - fmt.Fprintf(realTerm, "[ccv test] %s\n", msg) - } + // In verbose mode no redirect happens; the caller gets raw zerolog output. + term, cleanup, _, err := redirectToLogFile(verbose, "ccv-test") + if err != nil { + return err } - - // Stage 1: optional image build. - if buildEnabled { - progress(fmt.Sprintf("building images (just %s)...", buildTarget)) - buildCmd := exec.Command("just", buildTarget) - buildCmd.Stdout = os.Stdout - buildCmd.Stderr = os.Stderr - if err := buildCmd.Run(); err != nil { - return fmt.Errorf("just %s failed: %w", buildTarget, err) + defer cleanup() + + r := reporter.New(verbose, term) + + var testErr error + runErr := r.Run(func() error { + // Stage 1: optional image build. + if buildEnabled { + r.OnStageStart("build") + buildCmd := exec.Command("just", buildTarget) + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + buildErr := buildCmd.Run() + r.OnStageFinish("build", buildErr) + if buildErr != nil { + return fmt.Errorf("just %s failed: %w", buildTarget, buildErr) + } } - } - // Stage 2: optional environment start. - var extraEnv []string - if profileName != "" { - if !strings.HasSuffix(profileName, ".profile") { - profileName += ".profile" - } - outputFile := fmt.Sprintf("test-%s-out.toml", generateRunID()) - absOutput, err := filepath.Abs(outputFile) - if err != nil { - return fmt.Errorf("failed to resolve output path: %w", err) + // Stage 2: optional environment start. + var extraEnv []string + if profileName != "" { + if !strings.HasSuffix(profileName, ".profile") { + profileName += ".profile" + } + outputFile := fmt.Sprintf("test-%s-out.toml", generateRunID()) + absOutput, err := filepath.Abs(outputFile) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + _ = framework.RemoveTestContainers() + + if err := applyProfile(profileName); err != nil { + return err + } + saveActiveConfig(profileName) + _ = os.Setenv("CTF_OUTPUT", outputFile) + _ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") + if err := newEnvFn(r); err != nil { + return fmt.Errorf("environment startup failed: %w", err) + } + extraEnv = []string{fmt.Sprintf("SMOKE_TEST_CONFIG=%s", absOutput)} } - progress("tearing down any existing environment...") - _ = framework.RemoveTestContainers() - progress(fmt.Sprintf("starting environment (profile: %s, output: %s)...", profileName, absOutput)) - if err := applyProfile(profileName); err != nil { - return err + // Stage 3: run the test. + timeoutStr := "0" + if timeout > 0 { + timeoutStr = timeout.String() } - _ = os.Setenv("CTF_OUTPUT", outputFile) - _ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") - if err := newEnvFn(); err != nil { - return fmt.Errorf("environment startup failed: %w", err) + goTestArgs := []string{ + "test", "-v", "-count=1", + "-run", testPattern, + fmt.Sprintf("-timeout=%s", timeoutStr), } - extraEnv = []string{fmt.Sprintf("SMOKE_TEST_CONFIG=%s", absOutput)} - } + r.OnStageStart("test") + goTestCmd := exec.Command("go", goTestArgs...) + goTestCmd.Dir = testDir + goTestCmd.Stdout = os.Stdout + goTestCmd.Stderr = os.Stderr + goTestCmd.Stdin = os.Stdin + if len(extraEnv) > 0 { + goTestCmd.Env = append(os.Environ(), extraEnv...) + } + testErr = goTestCmd.Run() + r.OnStageFinish("test", testErr) + return testErr + }) - // Stage 3: run the test. - timeoutStr := "0" - if timeout > 0 { - timeoutStr = timeout.String() - } - goTestArgs := []string{ - "test", "-v", "-count=1", - "-run", testPattern, - fmt.Sprintf("-timeout=%s", timeoutStr), - } - progress(fmt.Sprintf("running test %s...", testPattern)) - goTestCmd := exec.Command("go", goTestArgs...) - goTestCmd.Dir = testDir - goTestCmd.Stdout = os.Stdout - goTestCmd.Stderr = os.Stderr - goTestCmd.Stdin = os.Stdin - if len(extraEnv) > 0 { - goTestCmd.Env = append(os.Environ(), extraEnv...) + if runErr == nil { + runErr = testErr } - if err := goTestCmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - if logPath != "" { - progress(fmt.Sprintf("FAILED (log: %s)", logPath)) - } + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { os.Exit(status.ExitStatus()) } os.Exit(1) } - return fmt.Errorf("test run failed: %w", err) - } - if logPath != "" { - progress(fmt.Sprintf("PASSED (log: %s)", logPath)) + return fmt.Errorf("test run failed: %w", runErr) } return nil }, @@ -706,6 +722,53 @@ func generateRunID() string { return id.String()[:8] } +// redirectToLogFile redirects os.Stdout and os.Stderr to an auto-created log +// file in CCVConfigDir() unless verbose is true. It returns the writer that +// the fancy reporter should render to (the saved real-terminal fd), and a +// cleanup func that restores the original fds. In verbose mode nothing is +// redirected and the returned writer is os.Stderr. +func redirectToLogFile(verbose bool, prefix string) (term *os.File, cleanup func(), logPath string, err error) { + noop := func() {} + if verbose { + return os.Stderr, noop, "", nil + } + + logPath = filepath.Join(util.CCVConfigDir(), fmt.Sprintf("%s-%d.log", prefix, time.Now().UnixMilli())) + lf, err := os.Create(logPath) + if err != nil { + return nil, noop, "", fmt.Errorf("failed to create log file %s: %w", logPath, err) + } + + realStdoutFd, _ := syscall.Dup(int(os.Stdout.Fd())) + realStderrFd, _ := syscall.Dup(int(os.Stderr.Fd())) + realTerm := os.NewFile(uintptr(realStderrFd), "real_stderr") + + _ = syscall.Dup2(int(lf.Fd()), int(os.Stdout.Fd())) + _ = syscall.Dup2(int(lf.Fd()), int(os.Stderr.Fd())) + + cleanupFn := func() { + _ = syscall.Dup2(realStdoutFd, int(os.Stdout.Fd())) + _ = syscall.Dup2(realStderrFd, int(os.Stderr.Fd())) + _ = syscall.Close(realStdoutFd) + _ = realTerm.Close() + _ = lf.Close() + } + return realTerm, cleanupFn, logPath, nil +} + +// resolveOutToml returns the path to the env-out.toml that Store() will have +// written. It mirrors the logic in config.go:Store without re-running it. +func resolveOutToml() string { + if override := os.Getenv(ccv.EnvVarTestOutput); override != "" { + return override + } + base, err := ccv.BaseConfigPath() + if err != nil { + return "" + } + return fmt.Sprintf("%s-out.toml", strings.ReplaceAll(base, ".toml", "")) +} + var indexerDBShellCmd = &cobra.Command{ Use: "db-shell", Aliases: []string{"db"}, @@ -934,6 +997,7 @@ func init() { rootCmd.PersistentFlags().BoolP("debug", "d", false, "Enable running services with dlv to allow remote debugging.") rootCmd.PersistentFlags().String("env-mode", "legacy", "Environment startup mode: legacy (default) or phased.") rootCmd.PersistentFlags().String("log-level", "", "Log level for services that support it (e.g. debug, info, warn)") + rootCmd.PersistentFlags().Bool("verbose", false, "Show raw log output instead of the fancy progress UI") // Fund addresses rootCmd.AddCommand(fundAddressesCmd) @@ -976,7 +1040,6 @@ func init() { testCmd.Flags().StringP("pattern", "r", "", "Raw Go test pattern (alternative to a named suite positional arg)") testCmd.Flags().Duration("timeout", 0, "Test timeout (0 = unlimited)") testCmd.Flags().String("build", "build-docker", "Just target to build Docker images before starting (e.g. build-docker, build-docker-ci); pass 'false' to skip; silently ignored when --profile is absent") - testCmd.Flags().String("log", "", "Write verbose output (build, env, test) to this file; only progress lines appear on the terminal") rootCmd.AddCommand(testCmd) rootCmd.AddCommand(indexerDBShellCmd) rootCmd.AddCommand(printAddressesCmd) diff --git a/build/devenv/components/committeeccv/component.go b/build/devenv/components/committeeccv/component.go index 3f84b245c..4b9c8d807 100644 --- a/build/devenv/components/committeeccv/component.go +++ b/build/devenv/components/committeeccv/component.go @@ -8,6 +8,7 @@ import ( "slices" "sort" "strconv" + "sync" "github.com/pelletier/go-toml/v2" @@ -53,7 +54,24 @@ func factory(_ map[string]any) (devenvruntime.Component, error) { return &component{}, nil } -type component struct{} +type component struct { + mu sync.Mutex + status string +} + +func (c *component) setStatus(s string) { + c.mu.Lock() + c.status = s + c.mu.Unlock() +} + +// Status implements the devenvruntime.StatusGetter optional interface so the TUI +// reporter can poll for fine-grained progress during the long Phase 3 setup. +func (c *component) Status() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.status +} func (c *component) ValidateConfig(componentConfig any) error { _, err := decodeConfig(componentConfig) @@ -90,10 +108,11 @@ func (c *component) RunPhase3( } // Work on a copy of the shared Phase-2 environment. localEnv := *inputs.env + c.setStatus("ensuring aggregator credentials") if err := ensureAggregatorCredentials(aggregators); err != nil { return nil, nil, err } - return runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv) + return runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv, c.setStatus) } // phase3Inputs holds the decoded prior-phase outputs consumed by both the @@ -193,11 +212,13 @@ func runPhase3Core( aggregators []*services.AggregatorInput, verifiers []*committeeverifier.Input, localEnv *deployment.Environment, + setStatus func(string), ) (map[string]any, []devenvruntime.Effect, error) { // Step 1c: Deploy committee verifiers (+ resolvers) and mock receivers on chain. // These were previously deployed by the Phase-2 kitchen-sink changeset; the split // moves them here so Phase 2 deploys only protocol contracts. Must run before lane // configuration (Step 5b), which wires the committee verifiers into the lanes. + setStatus("deploying committee verifiers") if err := deployCommitteeVerifiersAndReceivers(inputs, localEnv); err != nil { return nil, nil, err } @@ -206,6 +227,7 @@ func runPhase3Core( // Phase 2, where the CommitteeVerifier resolver was not yet deployed, so token pools could // not be wired to it. Runs before lane config (Step 5b), matching the original ordering. // TODO: move to a dedicated token-transfer Phase 3 component. + setStatus("configuring token transfers") if len(inputs.impls) > 0 { if err := ccdeploy.ConfigureAllTokenTransfers(inputs.impls, inputs.selectors, localEnv, inputs.topology); err != nil { return nil, nil, fmt.Errorf("committeeccv: configure all token transfers: %w", err) @@ -213,6 +235,7 @@ func runPhase3Core( } // Step 2: Launch standalone verifier containers (reads HMAC creds from agg.Out). + setStatus("launching verifier containers") if err := committeeverifier.LaunchStandaloneVerifiers( verifiers, aggregators, committeeverifier.CommitteeAggregatorNames(inputs.topology), inputs.blockchainOutputs, inputs.jdInfra, @@ -220,6 +243,7 @@ func runPhase3Core( ); err != nil { return nil, nil, fmt.Errorf("committeeccv: failed to launch standalone verifiers: %w", err) } + setStatus("registering verifiers with JD") if err := committeeverifier.RegisterStandaloneVerifiersWithJD(ctx, verifiers, inputs.jdInfra.OffchainClient); err != nil { return nil, nil, fmt.Errorf("committeeccv: failed to register standalone verifiers with JD: %w", err) } @@ -232,6 +256,7 @@ func runPhase3Core( jobs.SyncEnvNodeIDs(inputs.jdInfra, localEnv) // Step 3: Generate shared TLS certificates from aggregator container names. + setStatus("generating TLS certificates") var sharedTLSCerts *services.TLSCertPaths if len(aggregators) > 0 { var allHostnames []string @@ -267,6 +292,7 @@ func runPhase3Core( // Step 5b: Configure lanes. This requires verifiers to be registered in JD (done above) // because ApplyVerifierConfig fetches verifier signing keys from JD by node ID. + setStatus("configuring lanes") if len(inputs.impls) > 0 && len(inputs.blockchains) > 0 { if err := ccdeploy.ConnectAllChainsCanonical(inputs.impls, inputs.blockchains, inputs.selectors, localEnv, inputs.topology); err != nil { return nil, nil, fmt.Errorf("committeeccv: configure lanes: %w", err) @@ -274,6 +300,7 @@ func runPhase3Core( } // Step 6: Generate aggregator committee configuration. + setStatus("generating aggregator configs") for _, agg := range aggregators { if agg == nil { continue @@ -301,6 +328,7 @@ func runPhase3Core( } // Step 7: Launch full aggregator containers. + setStatus("launching aggregators") for _, agg := range aggregators { if agg == nil { continue @@ -313,6 +341,7 @@ func runPhase3Core( } // Step 8: Generate verifier job specs and emit job proposal effects. + setStatus("generating verifier job specs") effects, err := buildVerifierJobSpecEffects(localEnv, verifiers, inputs.topology, inputs.obs, sharedTLSCerts, inputs.blockchainOutputs, inputs.ds) if err != nil { return nil, nil, err diff --git a/build/devenv/components/committeeccv/component_clnode.go b/build/devenv/components/committeeccv/component_clnode.go index 061d32c35..11555fb9b 100644 --- a/build/devenv/components/committeeccv/component_clnode.go +++ b/build/devenv/components/committeeccv/component_clnode.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" "strings" + "sync" "github.com/pelletier/go-toml/v2" @@ -41,7 +42,24 @@ func clnodeFactory(_ map[string]any) (devenvruntime.Component, error) { return &clnodeComponent{}, nil } -type clnodeComponent struct{} +type clnodeComponent struct { + mu sync.Mutex + status string +} + +func (c *clnodeComponent) setStatus(s string) { + c.mu.Lock() + c.status = s + c.mu.Unlock() +} + +// Status implements the devenvruntime.StatusGetter optional interface so the TUI +// reporter can poll for fine-grained progress during the long Phase 3 setup. +func (c *clnodeComponent) Status() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.status +} func (c *clnodeComponent) ValidateConfig(componentConfig any) error { _, err := decodeCLNodeConfig(componentConfig) @@ -76,6 +94,7 @@ func (c *clnodeComponent) RunPhase3( localEnv := *inputs.env // Step 1: Generate HMAC credentials (must precede bakeNodeSecrets). + c.setStatus("ensuring aggregator credentials") if err := ensureAggregatorCredentials(aggregators); err != nil { return nil, nil, err } @@ -83,6 +102,7 @@ func (c *clnodeComponent) RunPhase3( // Step 1b: Bake secrets into node specs, then launch and register CL nodes. // Must run before step 2 (verifier launch) so JD has the node IDs needed // by ApplyVerifierConfig when fetching CL-mode signing keys. + c.setStatus("launching CL nodes") clNodeClients, nodeIDs, err := launchCLNodes(ctx, &cfg, verifiers, aggregators, inputs.topology, inputs.blockchains, inputs.jdInfra) if err != nil { return nil, nil, fmt.Errorf("committeeccv_clnode: %w", err) @@ -91,7 +111,7 @@ func (c *clnodeComponent) RunPhase3( localEnv.NodeIDs = nodeIDs } - outputs, effects, err := runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv) + outputs, effects, err := runPhase3Core(ctx, inputs, aggregators, verifiers, &localEnv, c.setStatus) if err != nil { return nil, nil, err } diff --git a/build/devenv/components/protocol_contracts/component.go b/build/devenv/components/protocol_contracts/component.go index 1bd6ceefd..883d015fb 100644 --- a/build/devenv/components/protocol_contracts/component.go +++ b/build/devenv/components/protocol_contracts/component.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "sync" "github.com/rs/zerolog" zlog "github.com/rs/zerolog/log" @@ -53,7 +54,23 @@ func factory(_ map[string]any) (devenvruntime.Component, error) { } type component struct { - lggr zerolog.Logger + mu sync.Mutex + status string + lggr zerolog.Logger +} + +func (p *component) setStatus(s string) { + p.mu.Lock() + p.status = s + p.mu.Unlock() +} + +// Status implements the devenvruntime.StatusGetter optional interface so the TUI +// reporter can poll for fine-grained progress during the long deploy loop. +func (p *component) Status() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.status } func (p *component) SetLogger(lggr zerolog.Logger) { @@ -151,6 +168,7 @@ func (p *component) RunPhase2( if nerr != nil { return nil, nil, nerr } + p.setStatus(fmt.Sprintf("deploying chain %s (%d of %d)", blockchains[i].ChainID, i+1, len(impls))) p.lggr.Info().Uint64("Selector", networkInfo.ChainSelector).Msg("Deploying chain selector") // Shift the deployer nonce intentionally so each chain gets different // contract addresses, catching bugs that assume address uniformity. @@ -204,6 +222,7 @@ func (p *component) RunPhase2( } e.DataStore = ds.Seal() + p.setStatus("finalizing") timeTrack.Record("[contracts] deployed") // Finalize CLDF: snapshot env metadata and print deployed addresses. diff --git a/build/devenv/environment_phased.go b/build/devenv/environment_phased.go index 7c3a23d11..a1402a191 100644 --- a/build/devenv/environment_phased.go +++ b/build/devenv/environment_phased.go @@ -10,12 +10,15 @@ import ( "github.com/smartcontractkit/chainlink-ccv/build/devenv/timing" ) -// NewPhasedEnvironment creates a new CCIP CCV environment using the phased -// component runtime. It loads the raw TOML config, hands control to the -// runtime, then serializes the raw accumulated output map (minus runtime-only -// "_"-prefixed keys) to the env-out.toml file consumed by downstream tests. It -// returns the full accumulated output map. -func NewPhasedEnvironment() (out map[string]any, err error) { +// NewPhasedEnvironment creates the environment using the phased runtime with a +// no-op reporter. Use NewPhasedEnvironmentWithReporter to supply a live reporter. +func NewPhasedEnvironment() (map[string]any, error) { + return NewPhasedEnvironmentWithReporter(devenvruntime.NoopReporter{}) +} + +// NewPhasedEnvironmentWithReporter creates a new CCIP CCV environment using the +// phased component runtime, reporting lifecycle events to the provided reporter. +func NewPhasedEnvironmentWithReporter(reporter devenvruntime.Reporter) (out map[string]any, err error) { ctx := L.WithContext(context.Background()) configs := strings.Split(os.Getenv(EnvVarTestConfigs), ",") @@ -49,7 +52,7 @@ func NewPhasedEnvironment() (out map[string]any, err error) { sendStartupMetrics(dxTracker, err, elapsed) }() - out, err = devenvruntime.NewEnvironmentWithRegistry(ctx, rawConfig, devenvruntime.GlobalRegistry(), newDevenvEffectExecutor(), L) + out, err = devenvruntime.NewEnvironmentWithRegistry(ctx, rawConfig, devenvruntime.GlobalRegistry(), newDevenvEffectExecutor(), L, reporter) if err != nil { return nil, err } diff --git a/build/devenv/go.mod b/build/devenv/go.mod index 82081931c..731edad37 100644 --- a/build/devenv/go.mod +++ b/build/devenv/go.mod @@ -12,6 +12,9 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/Masterminds/semver/v3 v3.5.0 github.com/c-bata/go-prompt v0.2.6 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/term v0.2.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/ethereum/go-ethereum v1.17.4 github.com/go-resty/resty/v2 v2.17.2 @@ -69,6 +72,20 @@ require ( go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect ) +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect +) + require ( cloud.google.com/go/auth v0.16.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect diff --git a/build/devenv/go.sum b/build/devenv/go.sum index 69af32b5d..ad680c887 100644 --- a/build/devenv/go.sum +++ b/build/devenv/go.sum @@ -131,6 +131,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUY github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk= github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= @@ -210,6 +212,18 @@ github.com/cespare/cp v1.1.1/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -332,6 +346,8 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= @@ -799,6 +815,8 @@ github.com/linode/linodego v1.49.0 h1:MNd3qwvQzbXB5mCpvdCqlUIu1RPA9oC+50LyB9kK+G github.com/linode/linodego v1.49.0/go.mod h1:B+HAM3//4w1wOS0BwdaQBKwBxlfe6kYJ7bSC6jJ/xtc= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +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/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -827,6 +845,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -895,6 +915,12 @@ github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -1310,6 +1336,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc= github.com/xdrpp/goxdr v0.1.1/go.mod h1:dXo1scL/l6s7iME1gxHWo2XCppbHEKZS7m/KyYWkNzA= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xssnick/tonutils-go v1.14.1 h1:zV/iVYl/h3hArS+tPsd9XrSFfGert3r21caMltPSeHg= @@ -1613,6 +1641,7 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/build/devenv/reporter/bubbletea.go b/build/devenv/reporter/bubbletea.go new file mode 100644 index 000000000..0f0256816 --- /dev/null +++ b/build/devenv/reporter/bubbletea.go @@ -0,0 +1,381 @@ +package reporter + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" +) + +// ── messages ──────────────────────────────────────────────────────────────── + +type tickMsg time.Time + +type componentStartedMsg struct { + phase int + name string +} + +type componentFinishedMsg struct { + phase int + name string + err error +} + +type statusUpdateMsg struct { + phase int + name string + status string +} + +type ( + stageStartedMsg struct{ name string } + stageFinishedMsg struct { + name string + err error + } +) +type doneMsg struct{ err error } + +// ── styles ─────────────────────────────────────────────────────────────────── + +type tuiStyles struct { + ok lipgloss.Style + fail lipgloss.Style + stage lipgloss.Style + sep lipgloss.Style + dim lipgloss.Style + active lipgloss.Style +} + +func newSepStyle(out io.Writer) lipgloss.Style { + return lipgloss.NewRenderer(out).NewStyle().Foreground(lipgloss.Color("8")) +} + +func newStyles(out io.Writer) tuiStyles { + r := lipgloss.NewRenderer(out) + return tuiStyles{ + ok: r.NewStyle().Foreground(lipgloss.Color("2")), // green + fail: r.NewStyle().Foreground(lipgloss.Color("1")), // red + stage: r.NewStyle().Foreground(lipgloss.Color("4")), // blue + sep: newSepStyle(out), // dark gray + dim: r.NewStyle().Foreground(lipgloss.Color("8")), // dark gray + active: r.NewStyle().Foreground(lipgloss.Color("2")).Bold(true), // bold green — in-progress + } +} + +var spinnerFrames = []string{"|", "/", "-", "\\"} + +// fmtDur formats a duration for stable fixed-width display: always in seconds +// (never ms), one decimal place, seconds zero-padded within minutes so the +// string length is stable (e.g. "1m09.3s" not "1m9.3s"). +func fmtDur(d time.Duration) string { + if d < 0 { + d = 0 + } + d = d.Round(100 * time.Millisecond) + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + mins := int(d.Minutes()) + secs := d.Seconds() - float64(mins)*60 + return fmt.Sprintf("%dm%04.1fs", mins, secs) +} + +// ── model ──────────────────────────────────────────────────────────────────── + +type statusEntry struct { + text string + at time.Time +} + +type activeComp struct { + phase int + name string + start time.Time + statuses []statusEntry +} + +type tuiModel struct { + styles tuiStyles + log []string // completed / stage lines + active map[string]*activeComp + activeOrder []string // insertion-ordered keys for stable display + runStart time.Time + frame int + done bool + cancelled bool + finalErr error + width int +} + +func newModel(out io.Writer) tuiModel { + return tuiModel{ + styles: newStyles(out), + active: make(map[string]*activeComp), + runStart: time.Now(), + width: 80, + } +} + +func compKey(phase int, name string) string { + return fmt.Sprintf("%d:%s", phase, name) +} + +func (m tuiModel) Init() tea.Cmd { + return tickEvery(100 * time.Millisecond) +} + +func tickEvery(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.cancelled = true + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + + case tickMsg: + m.frame++ + if m.done && len(m.active) == 0 { + return m, tea.Quit + } + return m, tickEvery(100 * time.Millisecond) + + case stageStartedMsg: + m.log = append(m.log, m.styles.stage.Render("── "+msg.name)) + + case stageFinishedMsg: + if msg.err != nil { + m.log = append(m.log, m.styles.fail.Render(fmt.Sprintf("✗ %s failed: %v", msg.name, msg.err))) + } + + case componentStartedMsg: + key := compKey(msg.phase, msg.name) + m.active[key] = &activeComp{phase: msg.phase, name: msg.name, start: time.Now()} + m.activeOrder = append(m.activeOrder, key) + + case componentFinishedMsg: + key := compKey(msg.phase, msg.name) + cs, ok := m.active[key] + finishedAt := time.Now() + start := finishedAt + if ok { + start = cs.start + } + dur := fmtDur(finishedAt.Sub(start)) + delete(m.active, key) + // Remove from ordered list. + for i, k := range m.activeOrder { + if k == key { + m.activeOrder = append(m.activeOrder[:i], m.activeOrder[i+1:]...) + break + } + } + if msg.err != nil { + m.log = append(m.log, m.styles.fail.Render( + fmt.Sprintf("✗ [%d] %-28s %7s error: %v", msg.phase, msg.name, dur, msg.err))) + } else { + // Green checkmark and name; phase and duration in default color. + m.log = append(m.log, m.styles.ok.Render("✓")+ + fmt.Sprintf(" [%d] ", msg.phase)+ + m.styles.ok.Render(fmt.Sprintf("%-28s", msg.name))+ + fmt.Sprintf(" %7s", dur)) + } + // Append accumulated status lines with per-entry durations. + if ok { + n := len(cs.statuses) + for i, entry := range cs.statuses { + var entryDur time.Duration + if i < n-1 { + entryDur = cs.statuses[i+1].at.Sub(entry.at) + } else { + entryDur = finishedAt.Sub(entry.at) + } + prefix := " ├── " + if i == n-1 { + prefix = " └── " + } + m.log = append(m.log, m.styles.dim.Render(fmt.Sprintf("%s%7s %s", prefix, fmtDur(entryDur), entry.text))) + } + } + if m.done && len(m.active) == 0 { + return m, tea.Quit + } + + case statusUpdateMsg: + key := compKey(msg.phase, msg.name) + if cs, ok := m.active[key]; ok && msg.status != "" { + if n := len(cs.statuses); n == 0 || cs.statuses[n-1].text != msg.status { + cs.statuses = append(cs.statuses, statusEntry{text: msg.status, at: time.Now()}) + } + } + + case doneMsg: + m.done = true + m.finalErr = msg.err + if len(m.active) == 0 { + return m, tea.Quit + } + } + + return m, nil +} + +func (m tuiModel) View() string { + var sb strings.Builder + + for _, line := range m.log { + sb.WriteString(line) + sb.WriteString("\n") + } + + if len(m.active) == 0 { + return sb.String() + } + + sep := strings.Repeat("─", min(m.width, 60)) + sb.WriteString(m.styles.sep.Render(sep)) + sb.WriteString("\n") + + spin := spinnerFrames[m.frame%len(spinnerFrames)] + for _, key := range m.activeOrder { + cs, ok := m.active[key] + if !ok { + continue + } + elapsed := time.Since(cs.start) + cumulative := time.Since(m.runStart) + // Line 1: spinner+phase in default, name in bold green, duration in default. + fmt.Fprintf(&sb, "%s [%d] ", spin, cs.phase) + sb.WriteString(m.styles.active.Render(fmt.Sprintf("%-28s", cs.name))) + fmt.Fprintf(&sb, " %7s", fmtDur(elapsed)) + sb.WriteString(" " + m.styles.dim.Render(fmt.Sprintf("(%s)", fmtDur(cumulative)))) + sb.WriteString("\n") + // Status lines use tree connectors: ├── for middle entries, └── for last. + for i, entry := range cs.statuses { + var entryDur time.Duration + if i < len(cs.statuses)-1 { + entryDur = cs.statuses[i+1].at.Sub(entry.at) + } else { + entryDur = time.Since(entry.at) + } + prefix := " ├── " + if i == len(cs.statuses)-1 { + prefix = " └── " + } + line := fmt.Sprintf("%s%7s %s", prefix, fmtDur(entryDur), entry.text) + sb.WriteString(m.styles.dim.Render(line)) + sb.WriteString("\n") + } + } + + return sb.String() +} + +// ── reporter ───────────────────────────────────────────────────────────────── + +type bubbletearReporter struct { + mu sync.Mutex + program *tea.Program + finalErr error + elapsed time.Duration + out io.Writer + stopPollers map[string]func() +} + +func newBubbletearReporter(out io.Writer) *bubbletearReporter { + return &bubbletearReporter{ + out: out, + stopPollers: make(map[string]func()), + } +} + +func (r *bubbletearReporter) OnStart(phase int, name string, comp devenvruntime.Component) { + r.program.Send(componentStartedMsg{phase: phase, name: name}) + + s, ok := comp.(devenvruntime.StatusGetter) + if !ok { + return + } + + stop := make(chan struct{}) + key := compKey(phase, name) + r.mu.Lock() + r.stopPollers[key] = sync.OnceFunc(func() { close(stop) }) + r.mu.Unlock() + + go func() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + r.program.Send(statusUpdateMsg{phase: phase, name: name, status: s.Status()}) + } + } + }() +} + +func (r *bubbletearReporter) OnFinish(phase int, name string, err error) { + key := compKey(phase, name) + r.mu.Lock() + if stop, ok := r.stopPollers[key]; ok { + stop() + delete(r.stopPollers, key) + } + r.mu.Unlock() + r.program.Send(componentFinishedMsg{phase: phase, name: name, err: err}) +} + +func (r *bubbletearReporter) OnStageStart(name string) { + r.program.Send(stageStartedMsg{name: name}) +} + +func (r *bubbletearReporter) OnStageFinish(name string, err error) { + r.program.Send(stageFinishedMsg{name: name, err: err}) +} + +func (r *bubbletearReporter) Run(fn func() error) error { + prog := tea.NewProgram(newModel(r.out), tea.WithOutput(r.out)) + r.program = prog + + start := time.Now() + go func() { + err := fn() + prog.Send(doneMsg{err: err}) + }() + + m, err := prog.Run() + r.elapsed = time.Since(start) + if err != nil { + return err + } + if tm, ok := m.(tuiModel); ok { + if tm.cancelled { + os.Exit(130) + } + r.finalErr = tm.finalErr + } + return r.finalErr +} + +func (r *bubbletearReporter) PrintSummary(outTomlPath, logFilePath string) { + printSummary(r.out, outTomlPath, logFilePath, r.elapsed) +} diff --git a/build/devenv/reporter/factory.go b/build/devenv/reporter/factory.go new file mode 100644 index 000000000..476dccd07 --- /dev/null +++ b/build/devenv/reporter/factory.go @@ -0,0 +1,28 @@ +package reporter + +import ( + "io" + "os" + + "github.com/charmbracelet/x/term" + + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" +) + +// New returns the appropriate Reporter for the current output context. +// +// - verbose=true → NoopReporter (caller keeps raw zerolog output) +// - TTY detected → BubbletearReporter (animated TUI) +// - otherwise → SimpleFancyReporter (line-based progress) +// +// out is the writer that the reporter should render to (typically the real +// terminal fd after stdout/stderr have been redirected to a log file). +func New(verbose bool, out io.Writer) devenvruntime.Reporter { + if verbose { + return devenvruntime.NoopReporter{} + } + if f, ok := out.(*os.File); ok && term.IsTerminal(f.Fd()) { + return newBubbletearReporter(out) + } + return newSimpleReporter(out) +} diff --git a/build/devenv/reporter/simple.go b/build/devenv/reporter/simple.go new file mode 100644 index 000000000..10eb6141a --- /dev/null +++ b/build/devenv/reporter/simple.go @@ -0,0 +1,81 @@ +package reporter + +import ( + "fmt" + "io" + "sync" + "time" + + devenvruntime "github.com/smartcontractkit/chainlink-ccv/build/devenv/runtime" +) + +type componentStart struct { + phase int + name string + start time.Time +} + +// simpleReporter writes one line per component completion to out. +// No cursor movement — safe for pipes, CI, and log capture. +type simpleReporter struct { + mu sync.Mutex + out io.Writer + starts map[string]componentStart // key: "phase:name" + elapsed time.Duration +} + +func newSimpleReporter(out io.Writer) *simpleReporter { + return &simpleReporter{ + out: out, + starts: make(map[string]componentStart), + } +} + +func (r *simpleReporter) key(phase int, name string) string { + return fmt.Sprintf("%d:%s", phase, name) +} + +func (r *simpleReporter) OnStart(phase int, name string, _ devenvruntime.Component) { + r.mu.Lock() + defer r.mu.Unlock() + r.starts[r.key(phase, name)] = componentStart{phase: phase, name: name, start: time.Now()} +} + +func (r *simpleReporter) OnFinish(phase int, name string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + k := r.key(phase, name) + start := time.Now() + if cs, ok := r.starts[k]; ok { + start = cs.start + } + delete(r.starts, k) + + dur := time.Since(start).Round(time.Millisecond) + if err != nil { + fmt.Fprintf(r.out, "✗ [%d] %-28s %6s error: %v\n", phase, name, dur, err) + } else { + fmt.Fprintf(r.out, "✓ [%d] %-28s %6s\n", phase, name, dur) + } +} + +func (r *simpleReporter) OnStageStart(name string) { + fmt.Fprintf(r.out, "── %s\n", name) +} + +func (r *simpleReporter) OnStageFinish(name string, err error) { + if err != nil { + fmt.Fprintf(r.out, "✗ %s failed: %v\n", name, err) + } +} + +func (r *simpleReporter) Run(fn func() error) error { + start := time.Now() + err := fn() + r.elapsed = time.Since(start) + return err +} + +func (r *simpleReporter) PrintSummary(outTomlPath, logFilePath string) { + printSummary(r.out, outTomlPath, logFilePath, r.elapsed) +} diff --git a/build/devenv/reporter/summary.go b/build/devenv/reporter/summary.go new file mode 100644 index 000000000..ed8c45a7c --- /dev/null +++ b/build/devenv/reporter/summary.go @@ -0,0 +1,189 @@ +package reporter + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +// printSummary writes a human-readable summary of the environment to out. +// outTomlPath is the path to the env-out.toml produced by Store(); an empty +// string or a non-existent file is handled gracefully. +func printSummary(out io.Writer, outTomlPath, logFilePath string, elapsed time.Duration) { + if outTomlPath == "" { + return + } + + abs, err := resolveTomlPath(outTomlPath) + if err != nil { + fmt.Fprintf(out, "\nenv output: %s (not found)\n", outTomlPath) + return + } + + var raw map[string]any + if _, err := toml.DecodeFile(abs, &raw); err != nil { + fmt.Fprintf(out, "\nenv output: %s (parse error: %v)\n", abs, err) + return + } + + sep := newSepStyle(out) + fmt.Fprintln(out) + fmt.Fprintln(out, sep.Render(strings.Repeat("─", 60))) + fmt.Fprintf(out, "env output: %s\n", abs) + + summarizeBlockchains(out, raw) + summarizeAggregators(out, raw) + summarizeVerifiers(out, raw) + summarizeIndexers(out, raw) + summarizeExecutors(out, raw) + + if elapsed > 0 { + fmt.Fprintf(out, "total: %s\n", elapsed.Round(time.Second)) + } + if logFilePath != "" { + fmt.Fprintf(out, "log: %s\n", logFilePath) + } +} + +func resolveTomlPath(path string) (string, error) { + if _, err := os.Stat(path); err == nil { + return path, nil + } + // The Store function writes relative to DefaultConfigDir ("."). + // Check the current working directory. + abs := "./" + path + if _, err := os.Stat(abs); err == nil { + return abs, nil + } + return "", fmt.Errorf("not found") +} + +// ── section helpers ─────────────────────────────────────────────────────────── + +func summarizeBlockchains(out io.Writer, raw map[string]any) { + bcs := toSlice(raw["blockchains"]) + if len(bcs) == 0 { + return + } + fmt.Fprintf(out, "chains (%d):\n", len(bcs)) + for _, item := range bcs { + m, ok := item.(map[string]any) + if !ok { + continue + } + chainID := strField(m, "chain_id") + o := subMap(m, "out") + nodes := toSlice(o["nodes"]) + for _, n := range nodes { + nm, ok := n.(map[string]any) + if !ok { + continue + } + http := strField(nm, "http_url") + ws := strField(nm, "ws_url") + fmt.Fprintf(out, " chain %s http: %s ws: %s\n", chainID, http, ws) + } + } +} + +func summarizeAggregators(out io.Writer, raw map[string]any) { + aggs := toSlice(raw["aggregators"]) + if len(aggs) == 0 { + return + } + fmt.Fprintf(out, "aggregators (%d):\n", len(aggs)) + for _, item := range aggs { + m, ok := item.(map[string]any) + if !ok { + continue + } + o := subMap(m, "out") + name := strField(m, "committee_name") + url := strField(o, "external_https_url") + if url == "" { + url = strField(o, "external_http_url") + } + fmt.Fprintf(out, " %-20s grpc: %s\n", name, url) + } +} + +func summarizeVerifiers(out io.Writer, raw map[string]any) { + vs := toSlice(raw["verifiers"]) + if len(vs) == 0 { + return + } + fmt.Fprintf(out, "verifiers (%d)\n", len(vs)) +} + +func summarizeIndexers(out io.Writer, raw map[string]any) { + idxs := toSlice(raw["indexer"]) + if len(idxs) == 0 { + return + } + fmt.Fprintf(out, "indexers (%d):\n", len(idxs)) + for _, item := range idxs { + m, ok := item.(map[string]any) + if !ok { + continue + } + name := strField(m, "container_name") + o := subMap(m, "out") + url := strField(o, "http_url") + fmt.Fprintf(out, " %-20s url: %s\n", name, url) + } +} + +func summarizeExecutors(out io.Writer, raw map[string]any) { + exs := toSlice(raw["executor"]) + if len(exs) == 0 { + return + } + fmt.Fprintf(out, "executors (%d):\n", len(exs)) + for _, item := range exs { + m, ok := item.(map[string]any) + if !ok { + continue + } + name := strField(m, "container_name") + o := subMap(m, "out") + url := strField(o, "http_url") + fmt.Fprintf(out, " %s %s\n", name, url) + } +} + +// ── TOML decode helpers ─────────────────────────────────────────────────────── + +// toSlice normalises a TOML array-of-tables ([]any) or a single table +// (map[string]any) into a []any. +func toSlice(v any) []any { + if v == nil { + return nil + } + if s, ok := v.([]any); ok { + return s + } + if m, ok := v.(map[string]any); ok { + return []any{m} + } + return nil +} + +func subMap(m map[string]any, key string) map[string]any { + if m == nil { + return nil + } + sub, _ := m[key].(map[string]any) + return sub +} + +func strField(m map[string]any, key string) string { + if m == nil { + return "" + } + s, _ := m[key].(string) + return s +} diff --git a/build/devenv/runtime/component.go b/build/devenv/runtime/component.go index 4df8bd49b..88f975ab9 100644 --- a/build/devenv/runtime/component.go +++ b/build/devenv/runtime/component.go @@ -40,3 +40,11 @@ type Phase4Component interface { type LogSetter interface { SetLogger(lggr zerolog.Logger) } + +// StatusGetter is an optional interface components may implement to report +// finer-grained internal status during execution. The reporter polls Status() +// at ~200ms intervals; statuses that change and revert within a single poll +// window will be missed. +type StatusGetter interface { + Status() string +} diff --git a/build/devenv/runtime/environment.go b/build/devenv/runtime/environment.go index 842e9c18e..1cf92acf5 100644 --- a/build/devenv/runtime/environment.go +++ b/build/devenv/runtime/environment.go @@ -15,7 +15,7 @@ import ( // NewEnvironment runs the environment startup using the global registry. func NewEnvironment(ctx context.Context, rawConfig map[string]any, logger zerolog.Logger) (map[string]any, error) { - return NewEnvironmentWithRegistry(ctx, rawConfig, global, noopEffectExecutor{}, logger) + return NewEnvironmentWithRegistry(ctx, rawConfig, global, noopEffectExecutor{}, logger, NoopReporter{}) } // NewEnvironmentWithRegistry runs the environment startup using the provided registry. @@ -37,7 +37,7 @@ func NewEnvironment(ctx context.Context, rawConfig map[string]any, logger zerolo // After all components in a phase run, the runtime collects their Effect // requests and executes them in a fixed order (CLNodeConfigEffect → // FundingEffect → JobProposalEffect) before advancing to the next phase. -func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r *Registry, effectExecutor EffectExecutor, logger zerolog.Logger) (map[string]any, error) { +func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r *Registry, effectExecutor EffectExecutor, logger zerolog.Logger, reporter Reporter) (map[string]any, error) { if effectExecutor == nil { effectExecutor = noopEffectExecutor{} } @@ -92,11 +92,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p1, ok := comp.(Phase1Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p1.RunPhase1(ctx, rawConfig, rawConfig[key]) + out, effects, runErr := p1.RunPhase1(ctx, rawConfig, rawConfig[key]) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase1 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase1 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err @@ -120,11 +122,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p2, ok := comp.(Phase2Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p2.RunPhase2(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) + out, effects, runErr := p2.RunPhase2(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase2 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase2 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err @@ -148,11 +152,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p3, ok := comp.(Phase3Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p3.RunPhase3(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) + out, effects, runErr := p3.RunPhase3(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase3 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase3 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err @@ -176,11 +182,13 @@ func NewEnvironmentWithRegistry(ctx context.Context, rawConfig map[string]any, r } comp := specific[key] if p4, ok := comp.(Phase4Component); ok { + reporter.OnStart(phase, key, comp) start := time.Now() - out, effects, err := p4.RunPhase4(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) + out, effects, runErr := p4.RunPhase4(ctx, rawConfig, rawConfig[key], maps.Clone(phaseSnapshot)) compTimings.Record(phase, key, start, time.Now()) - if err != nil { - return nil, fmt.Errorf("phase4 %s: %w", key, err) + reporter.OnFinish(phase, key, runErr) + if runErr != nil { + return nil, fmt.Errorf("phase4 %s: %w", key, runErr) } if err := mergeNoOverwrite(accumulated, out, phase, key); err != nil { return nil, err diff --git a/build/devenv/runtime/environment_test.go b/build/devenv/runtime/environment_test.go index 02e2d9a2c..544e63b81 100644 --- a/build/devenv/runtime/environment_test.go +++ b/build/devenv/runtime/environment_test.go @@ -14,7 +14,7 @@ import ( func runEnv(t *testing.T, r *devenvruntime.Registry, rawConfig map[string]any) (map[string]any, error) { t.Helper() - return devenvruntime.NewEnvironmentWithRegistry(context.Background(), rawConfig, r, nil, zerolog.Nop()) + return devenvruntime.NewEnvironmentWithRegistry(context.Background(), rawConfig, r, nil, zerolog.Nop(), devenvruntime.NoopReporter{}) } func compFactory(c devenvruntime.Component) devenvruntime.ComponentFactory { diff --git a/build/devenv/runtime/reporter.go b/build/devenv/runtime/reporter.go new file mode 100644 index 000000000..b8fa924fc --- /dev/null +++ b/build/devenv/runtime/reporter.go @@ -0,0 +1,35 @@ +package devenvruntime + +// Reporter receives lifecycle events from the runtime and CLI stages. +// Implementations handle rendering; the runtime and CLI stay display-unaware. +type Reporter interface { + // Component lifecycle — called by the phased runtime for each component. + OnStart(phase int, name string, component Component) + OnFinish(phase int, name string, err error) + + // Stage lifecycle — called by the CLI for build / env / test stages. + OnStageStart(name string) + OnStageFinish(name string, err error) + + // Run hands control to the reporter so it can set up its display before + // invoking fn. Simple reporters call fn() directly on the same goroutine. + // The Bubbletea reporter starts the TUI on the main goroutine and runs fn + // in a background goroutine, bridging events via program.Send() internally. + Run(fn func() error) error + + // PrintSummary renders the post-run summary. outTomlPath may be empty if + // the environment was never started (e.g. test against a running env). + // logFilePath is the path to the verbose log file, or empty in verbose mode. + PrintSummary(outTomlPath, logFilePath string) +} + +// NoopReporter is a Reporter that does nothing. Used in verbose mode and +// in legacy/monolith mode where component-level events are unavailable. +type NoopReporter struct{} + +func (NoopReporter) OnStart(int, string, Component) {} +func (NoopReporter) OnFinish(int, string, error) {} +func (NoopReporter) OnStageStart(string) {} +func (NoopReporter) OnStageFinish(string, error) {} +func (NoopReporter) Run(fn func() error) error { return fn() } +func (NoopReporter) PrintSummary(string, string) {}