Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 164 additions & 101 deletions build/devenv/cli/ccv.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,20 @@ 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 (
LocalWASPLoadDashboard = "http://localhost:3000/d/WASPLoadTests/wasp-load-test?orgId=1&from=now-5m&to=now&refresh=5s"
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to ensure the TUI is fully decoupled from the devenv, thats accomplished with the Reporter interface. The environment notifies the reporter when something interesting happens. There are several reporters in this PR:

  • Verbose: use the same output as today.
  • Fancy: full TUI with dynamic output.
  • Simple: line-based output, this is the default for non-TTY environments.


var rootCmd = &cobra.Command{
Use: "ccv",
Expand All @@ -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:
Expand All @@ -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
},
}

Expand All @@ -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
},
}

Expand Down Expand Up @@ -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"
}
Comment on lines +236 to +240

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a small random fix. The "active" config is stored in the home directory but we were ignoring it.

If you run "ccv up --profile phased.profile", a subsequent call to "ccv restart" will stop the current environment and bring the phased profile up again. Without the fix it was reverting to the standard profile.

}

// --profile is mutually exclusive with --env-mode (when explicitly set).
Expand All @@ -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)
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Comment on lines +599 to +603

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
},
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably a lot of this can be done by the logger. It would be nice to initialize the logger to a file rather than all this redirect stuff.

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()))
Comment on lines +742 to +747

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"},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading