Skip to content

Commit 7f643bf

Browse files
committed
Add limactl watch command and JSON events for test scripts
Signed-off-by: IrvingMg <[email protected]>
1 parent cb0e125 commit 7f643bf

File tree

14 files changed

+491
-24
lines changed

14 files changed

+491
-24
lines changed

cmd/limactl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ func newApp() *cobra.Command {
208208
newNetworkCommand(),
209209
newCloneCommand(),
210210
newRenameCommand(),
211+
newWatchCommand(),
211212
)
212213
addPluginCommands(rootCmd)
213214

cmd/limactl/watch.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"path/filepath"
10+
"time"
11+
12+
"github.com/sirupsen/logrus"
13+
"github.com/spf13/cobra"
14+
15+
"github.com/lima-vm/lima/v2/pkg/hostagent/events"
16+
"github.com/lima-vm/lima/v2/pkg/limatype"
17+
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
18+
"github.com/lima-vm/lima/v2/pkg/store"
19+
)
20+
21+
func newWatchCommand() *cobra.Command {
22+
watchCommand := &cobra.Command{
23+
Use: "watch [INSTANCE]...",
24+
Short: "Watch events from instances",
25+
Long: `Watch events from Lima instances.
26+
27+
Events include status changes (starting, running, stopping), port forwarding
28+
events, and other instance lifecycle events.
29+
30+
If no instance is specified, events from all running instances are watched.
31+
32+
The command will continue watching until interrupted (Ctrl+C).`,
33+
Example: ` # Watch events from all instances:
34+
$ limactl watch
35+
36+
# Watch events from a specific instance:
37+
$ limactl watch default
38+
39+
# Watch events in JSON format (for scripting):
40+
$ limactl watch --json default`,
41+
Args: WrapArgsError(cobra.ArbitraryArgs),
42+
RunE: watchAction,
43+
ValidArgsFunction: watchBashComplete,
44+
GroupID: advancedCommand,
45+
}
46+
watchCommand.Flags().Bool("json", false, "Output events as newline-delimited JSON")
47+
return watchCommand
48+
}
49+
50+
// watchEvent wraps an event with its instance name for JSON output.
51+
type watchEvent struct {
52+
Instance string `json:"instance"`
53+
Event events.Event `json:"event"`
54+
}
55+
56+
func watchAction(cmd *cobra.Command, args []string) error {
57+
ctx := cmd.Context()
58+
59+
jsonFormat, err := cmd.Flags().GetBool("json")
60+
if err != nil {
61+
return err
62+
}
63+
64+
// Determine which instances to watch
65+
var instNames []string
66+
if len(args) > 0 {
67+
instNames = args
68+
} else {
69+
// Watch all instances
70+
allInstances, err := store.Instances()
71+
if err != nil {
72+
return err
73+
}
74+
if len(allInstances) == 0 {
75+
logrus.Warn("No instances found.")
76+
return nil
77+
}
78+
instNames = allInstances
79+
}
80+
81+
// Validate instances and collect their log paths
82+
type instanceInfo struct {
83+
name string
84+
haStdoutPath string
85+
haStderrPath string
86+
}
87+
var instances []instanceInfo
88+
89+
for _, instName := range instNames {
90+
inst, err := store.Inspect(ctx, instName)
91+
if err != nil {
92+
return err
93+
}
94+
if inst.Status != limatype.StatusRunning {
95+
logrus.Warnf("Instance %q is not running (status: %s). Watching for events anyway...", instName, inst.Status)
96+
}
97+
instances = append(instances, instanceInfo{
98+
name: instName,
99+
haStdoutPath: filepath.Join(inst.Dir, filenames.HostAgentStdoutLog),
100+
haStderrPath: filepath.Join(inst.Dir, filenames.HostAgentStderrLog),
101+
})
102+
}
103+
104+
// If only one instance, watch it directly
105+
if len(instances) == 1 {
106+
inst := instances[0]
107+
return events.Watch(ctx, inst.haStdoutPath, inst.haStderrPath, time.Now(), !jsonFormat, func(ev events.Event) bool {
108+
if jsonFormat {
109+
we := watchEvent{Instance: inst.name, Event: ev}
110+
j, err := json.Marshal(we)
111+
if err != nil {
112+
fmt.Fprintf(cmd.ErrOrStderr(), "error marshaling event: %v\n", err)
113+
return false
114+
}
115+
fmt.Fprintln(cmd.OutOrStdout(), string(j))
116+
} else {
117+
printHumanReadableEvent(cmd, inst.name, ev)
118+
}
119+
return false
120+
})
121+
}
122+
123+
// Watch multiple instances concurrently
124+
type eventWithInstance struct {
125+
instance string
126+
event events.Event
127+
}
128+
eventCh := make(chan eventWithInstance)
129+
errCh := make(chan error, len(instances))
130+
131+
for _, inst := range instances {
132+
go func() {
133+
err := events.Watch(ctx, inst.haStdoutPath, inst.haStderrPath, time.Now(), !jsonFormat, func(ev events.Event) bool {
134+
select {
135+
case eventCh <- eventWithInstance{instance: inst.name, event: ev}:
136+
case <-ctx.Done():
137+
return true
138+
}
139+
return false
140+
})
141+
if err != nil {
142+
errCh <- fmt.Errorf("instance %s: %w", inst.name, err)
143+
}
144+
}()
145+
}
146+
147+
// Process events from all instances
148+
for {
149+
select {
150+
case <-ctx.Done():
151+
return nil
152+
case err := <-errCh:
153+
return err
154+
case ev := <-eventCh:
155+
if jsonFormat {
156+
we := watchEvent{Instance: ev.instance, Event: ev.event}
157+
j, err := json.Marshal(we)
158+
if err != nil {
159+
fmt.Fprintf(cmd.ErrOrStderr(), "error marshaling event: %v\n", err)
160+
continue
161+
}
162+
fmt.Fprintln(cmd.OutOrStdout(), string(j))
163+
} else {
164+
printHumanReadableEvent(cmd, ev.instance, ev.event)
165+
}
166+
}
167+
}
168+
}
169+
170+
func printHumanReadableEvent(cmd *cobra.Command, instName string, ev events.Event) {
171+
timestamp := ev.Time.Format("2006-01-02 15:04:05")
172+
out := cmd.OutOrStdout()
173+
174+
printEvent := func(msg string) {
175+
fmt.Fprintf(out, "%s %s | %s\n", timestamp, instName, msg)
176+
}
177+
178+
// Status changes
179+
if ev.Status.Running {
180+
if ev.Status.Degraded {
181+
printEvent("running (degraded)")
182+
} else {
183+
printEvent("running")
184+
}
185+
}
186+
if ev.Status.Exiting {
187+
printEvent("exiting")
188+
}
189+
190+
// SSH port
191+
if ev.Status.SSHLocalPort != 0 {
192+
printEvent(fmt.Sprintf("ssh available on port %d", ev.Status.SSHLocalPort))
193+
}
194+
195+
// Errors
196+
for _, e := range ev.Status.Errors {
197+
printEvent(fmt.Sprintf("error: %s", e))
198+
}
199+
200+
// Cloud-init progress
201+
if ev.Status.CloudInitProgress != nil {
202+
if ev.Status.CloudInitProgress.Completed {
203+
printEvent("cloud-init completed")
204+
} else if ev.Status.CloudInitProgress.LogLine != "" {
205+
printEvent(fmt.Sprintf("cloud-init: %s", ev.Status.CloudInitProgress.LogLine))
206+
}
207+
}
208+
209+
// Port forwarding events
210+
if ev.Status.PortForward != nil {
211+
pf := ev.Status.PortForward
212+
switch pf.Type {
213+
case events.PortForwardEventForwarding:
214+
printEvent(fmt.Sprintf("forwarding %s %s to %s", pf.Protocol, pf.GuestAddr, pf.HostAddr))
215+
case events.PortForwardEventNotForwarding:
216+
printEvent(fmt.Sprintf("not forwarding %s %s", pf.Protocol, pf.GuestAddr))
217+
case events.PortForwardEventStopping:
218+
printEvent(fmt.Sprintf("stopping forwarding %s %s", pf.Protocol, pf.GuestAddr))
219+
case events.PortForwardEventFailed:
220+
printEvent(fmt.Sprintf("failed to forward %s %s: %s", pf.Protocol, pf.GuestAddr, pf.Error))
221+
}
222+
}
223+
224+
// Vsock events
225+
if ev.Status.Vsock != nil {
226+
vs := ev.Status.Vsock
227+
switch vs.Type {
228+
case events.VsockEventStarted:
229+
printEvent(fmt.Sprintf("started vsock forwarder: %s -> vsock:%d", vs.HostAddr, vs.VsockPort))
230+
case events.VsockEventSkipped:
231+
printEvent(fmt.Sprintf("skipped vsock forwarder: %s", vs.Reason))
232+
case events.VsockEventFailed:
233+
printEvent(fmt.Sprintf("failed to start vsock forwarder: %s", vs.Reason))
234+
}
235+
}
236+
}
237+
238+
func watchBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
239+
return bashCompleteInstanceNames(cmd)
240+
}

hack/test-port-forwarding.pl

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Config qw(%Config);
1919
use File::Spec::Functions qw(catfile);
2020
use IO::Handle qw();
21+
use JSON::PP;
2122
use Socket qw(inet_ntoa);
2223
use Sys::Hostname qw(hostname);
2324

@@ -172,8 +173,10 @@
172173
# Record current log size, so we can skip prior output
173174
$ENV{HOME_HOST} ||= "$ENV{HOME}";
174175
$ENV{LIMA_HOME} ||= "$ENV{HOME_HOST}/.lima";
175-
my $ha_log = "$ENV{LIMA_HOME}/$instance/ha.stderr.log";
176-
my $ha_log_size = -s $ha_log or die;
176+
my $ha_stdout_log = "$ENV{LIMA_HOME}/$instance/ha.stdout.log";
177+
my $ha_stderr_log = "$ENV{LIMA_HOME}/$instance/ha.stderr.log";
178+
my $ha_stdout_log_size = -s $ha_stdout_log or die;
179+
my $ha_stderr_log_size = -s $ha_stderr_log or die;
177180

178181
# Setup a netcat listener on the guest for each test
179182
foreach my $id (0..@test-1) {
@@ -218,26 +221,62 @@
218221
close($netcat);
219222
}
220223

221-
# Extract forwarding log messages from hostagent log
222-
open(my $log, "< $ha_log") or die "Can't read $ha_log: $!";
223-
seek($log, $ha_log_size, 0) or die "Can't seek $ha_log to $ha_log_size: $!";
224+
# Extract forwarding log messages from hostagent JSON event log
225+
my $json_parser = JSON::PP->new->utf8->relaxed;
226+
227+
open(my $log, "< $ha_stdout_log") or die "Can't read $ha_stdout_log: $!";
228+
seek($log, $ha_stdout_log_size, 0) or die "Can't seek $ha_stdout_log to $ha_stdout_log_size: $!";
224229
my %seen;
225230
my %failed_to_listen_tcp;
231+
226232
while (<$log>) {
227-
$seen{$1}++ if /(Forwarding TCP from .*? to ((\d.*?|\[.*?\]):\d+|\/[^"]+))/;
228-
$seen{$1}++ if /(Not forwarding TCP .*?:\d+)/;
229-
$failed_to_listen_tcp{$2}=$1 if /(failed to listen tcp: listen tcp (.*?:\d+):[^"]+)/;
233+
chomp;
234+
next unless /^\s*\{/; # Skip non-JSON lines
235+
236+
my $event = eval { $json_parser->decode($_) };
237+
next unless $event;
238+
239+
my $pf = $event->{status}{portForward};
240+
next unless $pf && $pf->{type};
241+
242+
my $type = $pf->{type};
243+
my $protocol = uc($pf->{protocol} || "tcp");
244+
my $guest_addr = $pf->{guestAddr} || "";
245+
my $host_addr = $pf->{hostAddr} || "";
246+
my $error = $pf->{error} || "";
247+
248+
if ($type eq "forwarding") {
249+
my $msg = "Forwarding $protocol from $guest_addr to $host_addr";
250+
$seen{$msg}++;
251+
} elsif ($type eq "not-forwarding") {
252+
my $msg = "Not forwarding $protocol $guest_addr";
253+
$seen{$msg}++;
254+
} elsif ($type eq "failed" && $error =~ /listen tcp/) {
255+
# Extract the address from the error message
256+
if ($error =~ /listen tcp (.*?:\d+):/) {
257+
my $addr = $1;
258+
$failed_to_listen_tcp{$addr} = "failed to listen tcp: $error";
259+
}
260+
}
230261
}
231262
close $log or die;
232263

264+
# Also check stderr log for failed_to_listen_tcp messages (these may not be in JSON events)
265+
open(my $stderr_log, "< $ha_stderr_log") or die "Can't read $ha_stderr_log: $!";
266+
seek($stderr_log, $ha_stderr_log_size, 0) or die "Can't seek $ha_stderr_log to $ha_stderr_log_size: $!";
267+
while (<$stderr_log>) {
268+
$failed_to_listen_tcp{$2}=$1 if /(failed to listen tcp: listen tcp (.*?:\d+):[^"]+)/;
269+
}
270+
close $stderr_log or die;
271+
233272
my $rc = 0;
234273
my %expected;
235274
foreach my $id (0..@test-1) {
236275
my $test = $test[$id];
237276
my $err = "";
238277
$expected{$test->{log_msg}}++;
239278
unless ($seen{$test->{log_msg}}) {
240-
$err .= "\n Message missing from ha.stderr.log";
279+
$err .= "\n Message missing from ha.stdout.log (JSON events)";
241280
}
242281
my $log = qx(limactl shell --workdir / $instance sh -c "cd; cat $listener.$id");
243282
chomp $log;

0 commit comments

Comments
 (0)