From 78c2890ce0c4c6330c980bc1e973d22ef52cec5a Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Mon, 13 Apr 2026 10:53:06 +0200 Subject: [PATCH 1/2] fix: APIAPEX-2747 add follow flag to instance logs --- docs/vcr_instance_log.md | 41 ++++++++++++++++++++++-------- vcr/instance/log/log.go | 37 +++++++++++++++++---------- vcr/instance/log/log_test.go | 49 ++++++++++++++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 25 deletions(-) diff --git a/docs/vcr_instance_log.md b/docs/vcr_instance_log.md index d8e55e0..e7ccd80 100644 --- a/docs/vcr_instance_log.md +++ b/docs/vcr_instance_log.md @@ -1,6 +1,9 @@ ## vcr instance log -This command will output the log of an instance. +Fetch logs from a deployed VCR instance. + +By default, the command retrieves the last N log entries (controlled by `--history`) and exits. +Use the `--follow` (`-f`) flag to continuously stream new log entries until you press Ctrl+C. ``` vcr instance log --project-name --instance-name [flags] @@ -9,23 +12,41 @@ vcr instance log --project-name --instance-name [ ### Examples ``` -# Output instance log by instance id: +# Print the last logs by instance id (default, no follow): $ vcr instance log --id -# Output instance log by project and instance name: +# Print the last logs by project and instance name: $ vcr instance log --project-name --instance-name +# Continuously stream new logs (follow mode): +$ vcr instance log --project-name --instance-name --follow + +# Follow logs using the short flag: +$ vcr instance log -p -n -f + +# Print the last 500 log entries and exit: +$ vcr instance log --id --history 500 + +# Filter to show only errors and above: +$ vcr instance log -p -n --log-level error + +# Show only application logs (exclude provider logs): +$ vcr instance log -p -n --source-type application + +# Combine filters with follow: +$ vcr instance log -p -n -l warn -s application -f ``` ### Options ``` - --history int Prints the last N number of records (default 300) - -i, --id string Instance ID - -n, --instance-name string Instance name (must be used with project-name flag) - -l, --log-level string Filter for log level, e.g.trace, debug, info, warn, error, fatal - -p, --project-name string Project name (must be used with instance-name flag) - -s, --source-type string Filter for source type e.g. application, provider + -f, --follow Continuously stream new log entries (press Ctrl+C to stop) + --history int Number of historical log entries to fetch initially (default 300) + -i, --id string Instance UUID (alternative to project-name + instance-name) + -n, --instance-name string Instance name (requires --project-name) + -l, --log-level string Minimum log level: trace, debug, info, warn, error, fatal + -p, --project-name string Project name (requires --instance-name) + -s, --source-type string Filter by source: application, provider ``` ### Options inherited from parent commands @@ -44,4 +65,4 @@ $ vcr instance log --project-name --instance-name * [vcr instance](vcr_instance.md) - Used for instance management -###### Auto generated by spf13/cobra on 26-Nov-2024 +###### Auto generated by spf13/cobra on 13-Apr-2026 diff --git a/vcr/instance/log/log.go b/vcr/instance/log/log.go index 0b70a32..b829a8b 100644 --- a/vcr/instance/log/log.go +++ b/vcr/instance/log/log.go @@ -52,6 +52,7 @@ type Options struct { LogLevel string SourceType string Limit int + Follow bool } func NewCmdInstanceLog(f cmdutil.Factory) *cobra.Command { @@ -62,11 +63,12 @@ func NewCmdInstanceLog(f cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "log", Aliases: []string{"logs"}, - Short: "Stream real-time logs from a deployed VCR instance", - Long: heredoc.Doc(`Stream real-time logs from a deployed VCR instance. + Short: "Fetch logs from a deployed VCR instance", + Long: heredoc.Doc(`Fetch logs from a deployed VCR instance. - This command connects to a running instance and streams its logs in real-time - to your terminal. Logs are continuously fetched until you press Ctrl+C. + By default, the command retrieves the last N log entries (controlled by --history) + and exits. Use --follow (-f) to continuously stream new log entries until you + press Ctrl+C. IDENTIFYING THE INSTANCE You can identify the instance using either: @@ -93,27 +95,29 @@ func NewCmdInstanceLog(f cmdutil.Factory) *cobra.Command { `), Args: cobra.MaximumNArgs(0), Example: heredoc.Doc(` - # Stream logs by project and instance name + # Print the last logs by project and instance name (default, exits after output) $ vcr instance log --project-name my-app --instance-name dev 2024-01-15T10:30:00Z [application] Server started on port 3000 2024-01-15T10:30:01Z [application] Connected to database - ^C - Interrupt received, stopping... - # Stream logs by instance ID + # Print the last logs by instance ID $ vcr instance log --id 12345678-1234-1234-1234-123456789abc + # Continuously stream new logs (press Ctrl+C to stop) + $ vcr instance log -p my-app -n dev --follow + $ vcr instance log -p my-app -n dev -f + + # Print the last 500 log entries and exit + $ vcr instance log -p my-app -n dev --history 500 + # Filter to show only errors and above $ vcr instance log -p my-app -n dev --log-level error # Show only application logs (exclude provider logs) $ vcr instance log -p my-app -n dev --source-type application - # Increase history to last 500 log entries - $ vcr instance log -p my-app -n dev --history 500 - - # Combine filters - $ vcr instance log -p my-app -n dev -l warn -s application + # Combine filters with follow + $ vcr instance log -p my-app -n dev -l warn -s application -f `), RunE: func(_ *cobra.Command, _ []string) error { ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) @@ -129,6 +133,7 @@ func NewCmdInstanceLog(f cmdutil.Factory) *cobra.Command { cmd.Flags().StringVarP(&opts.InstanceName, "instance-name", "n", "", "Instance name (requires --project-name)") cmd.Flags().StringVarP(&opts.LogLevel, "log-level", "l", "", "Minimum log level: trace, debug, info, warn, error, fatal") cmd.Flags().StringVarP(&opts.SourceType, "source-type", "s", "", "Filter by source: application, provider") + cmd.Flags().BoolVarP(&opts.Follow, "follow", "f", false, "Continuously stream new log entries (press Ctrl+C to stop)") return cmd } @@ -146,6 +151,12 @@ func runLog(ctx context.Context, opts *Options) error { opts.InstanceID = inst.ID + // Without --follow just print the historical logs and exit. + if !opts.Follow { + fetchLogs(io, opts, time.Time{}) + return nil + } + ticker := time.NewTicker(TickerInterval) defer ticker.Stop() lastTimestamp := time.Time{} diff --git a/vcr/instance/log/log_test.go b/vcr/instance/log/log_test.go index d174767..030bd31 100644 --- a/vcr/instance/log/log_test.go +++ b/vcr/instance/log/log_test.go @@ -21,8 +21,10 @@ func TestLog(t *testing.T) { type mock struct { LogListLogsByInstanceIDTimes int LogGetInstByProjAndInstNameTimes int + LogGetInstanceByIDTimes int LogListLogsByInstanceIDReturnErr error LogGetInstByProjAndInstNameReturnErr error + LogGetInstanceByIDReturnErr error LogReturnLogs []api.Log LogReturnInstance api.Instance LogProjectName string @@ -69,6 +71,41 @@ func TestLog(t *testing.T) { errMsg: "failed to validate flags: must provide either 'id' flag or 'project-name' and 'instance-name' flags", }, }, + { + name: "default-no-follow-fetches-once-by-instance-id", + cli: "--id=abc-123", + mock: mock{ + LogListLogsByInstanceIDTimes: 1, + LogGetInstByProjAndInstNameTimes: 0, + LogGetInstanceByIDTimes: 1, + LogReturnInstance: api.Instance{ID: "abc-123"}, + LogInstanceID: "abc-123", + LogReturnLogs: []api.Log{{Timestamp: time.Now(), SourceType: "application", Message: "hello"}}, + LogListLogsByInstanceIDReturnErr: nil, + LogGetInstByProjAndInstNameReturnErr: nil, + LogGetInstanceByIDReturnErr: nil, + }, + want: want{ + stdout: "[application] hello", + }, + }, + { + name: "default-no-follow-get-instance-error", + cli: "--id=bad-id", + mock: mock{ + LogListLogsByInstanceIDTimes: 0, + LogGetInstByProjAndInstNameTimes: 0, + LogGetInstanceByIDTimes: 1, + LogReturnInstance: api.Instance{}, + LogInstanceID: "bad-id", + LogListLogsByInstanceIDReturnErr: nil, + LogGetInstByProjAndInstNameReturnErr: nil, + LogGetInstanceByIDReturnErr: errors.New("datastore error"), + }, + want: want{ + errMsg: "failed to get instance", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -83,6 +120,10 @@ func TestLog(t *testing.T) { GetInstanceByProjectAndInstanceName(gomock.Any(), tt.mock.LogProjectName, tt.mock.LogInstanceName). Times(tt.mock.LogGetInstByProjAndInstNameTimes). Return(tt.mock.LogReturnInstance, tt.mock.LogGetInstByProjAndInstNameReturnErr) + datastoreMock.EXPECT(). + GetInstanceByID(gomock.Any(), tt.mock.LogInstanceID). + Times(tt.mock.LogGetInstanceByIDTimes). + Return(tt.mock.LogReturnInstance, tt.mock.LogGetInstanceByIDReturnErr) datastoreMock.EXPECT().ListLogsByInstanceID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Times(tt.mock.LogListLogsByInstanceIDTimes). Return(tt.mock.LogReturnLogs, tt.mock.LogListLogsByInstanceIDReturnErr) @@ -104,7 +145,7 @@ func TestLog(t *testing.T) { if _, err := cmd.ExecuteC(); err != nil && tt.want.errMsg != "" { require.Error(t, err, "should throw error") - require.Equal(t, tt.want.errMsg, err.Error()) + require.Contains(t, err.Error(), tt.want.errMsg) return } cmdOut := &testutil.CmdOut{ @@ -116,7 +157,11 @@ func TestLog(t *testing.T) { return } require.NoError(t, err, "should not throw error") - require.Equal(t, tt.want.stdout, cmdOut.String()) + if tt.want.stdout != "" { + require.Contains(t, cmdOut.String(), tt.want.stdout) + } else { + require.Equal(t, tt.want.stdout, cmdOut.String()) + } }) } } From 3543b9a99e482cff02cdfce1f6ccd6bb33cb1be7 Mon Sep 17 00:00:00 2001 From: Gorka Revilla Date: Wed, 15 Apr 2026 12:00:16 +0200 Subject: [PATCH 2/2] test: add tests --- .gitignore | 10 ++++++++ vcr/instance/log/log_test.go | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/.gitignore b/.gitignore index eaebdac..7957e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,16 @@ bin .DS_Store +# Binaries generated by make +vcr +vcr_linux_amd64 +vcr_linux_arm64 +vcr_windows_amd64.exe +vcr_macos_amd64 +vcr_macos_arm64 +tests/integration/bin/vcr-cli +tests/integration/bin/mockserver + # Lefthook local overrides lefthook-local.yml diff --git a/vcr/instance/log/log_test.go b/vcr/instance/log/log_test.go index 030bd31..9575b0d 100644 --- a/vcr/instance/log/log_test.go +++ b/vcr/instance/log/log_test.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "io" + "os" "testing" "time" @@ -294,3 +295,50 @@ func Test_printLogs(t *testing.T) { }) } } + +func TestLog_Follow(t *testing.T) { + ctrl := gomock.NewController(t) + + datastoreMock := mocks.NewMockDatastoreInterface(ctrl) + deploymentMock := mocks.NewMockDeploymentInterface(ctrl) + + datastoreMock.EXPECT(). + GetInstanceByID(gomock.Any(), "abc-123"). + Times(1). + Return(api.Instance{ID: "abc-123"}, nil) + + // Track how many times ListLogsByInstanceID is called and send SIGTERM + // after the second tick so the follow loop exits cleanly. + callCount := 0 + datastoreMock.EXPECT(). + ListLogsByInstanceID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + MinTimes(2). + DoAndReturn(func(_ interface{}, _ interface{}, _ interface{}, _ interface{}) ([]api.Log, error) { + callCount++ + if callCount >= 2 { + // Send an interrupt to the current process so runLog's signal + // handler fires and the follow loop exits. + p, _ := os.FindProcess(os.Getpid()) + _ = p.Signal(os.Interrupt) + } + return []api.Log{{Timestamp: time.Now(), SourceType: "application", Message: "streaming"}}, nil + }) + + ios, _, stdout, _ := iostreams.Test() + + argv, err := shlex.Split("--id=abc-123 --follow") + require.NoError(t, err) + + f := testutil.DefaultFactoryMock(t, ios, nil, nil, datastoreMock, deploymentMock, nil, nil) + + cmd := NewCmdInstanceLog(f) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + require.NoError(t, err, "follow should exit cleanly on interrupt") + require.GreaterOrEqual(t, callCount, 2, "logs should have been fetched at least twice") + require.Contains(t, stdout.String(), "[application] streaming") +}