From c41abb56a3adcdc98c0c6233a3fc5111e609d483 Mon Sep 17 00:00:00 2001 From: IrvingMg Date: Mon, 22 Dec 2025 15:45:54 +0100 Subject: [PATCH 1/4] Add limactl watch command and JSON events for test scripts Signed-off-by: IrvingMg --- cmd/limactl/main.go | 1 + cmd/limactl/watch.go | 240 ++++++++++++++++++++++++++++++ hack/test-port-forwarding.pl | 57 +++++-- hack/test-templates.sh | 31 +++- pkg/driver/driver.go | 6 + pkg/driver/vz/vm_darwin.go | 28 +++- pkg/driver/vz/vz_driver_darwin.go | 15 +- pkg/hostagent/events/events.go | 38 +++++ pkg/hostagent/events/watcher.go | 6 +- pkg/hostagent/hostagent.go | 38 ++++- pkg/hostagent/port.go | 28 +++- pkg/instance/start.go | 2 +- pkg/instance/stop.go | 2 +- pkg/portfwd/forward.go | 33 +++- 14 files changed, 500 insertions(+), 25 deletions(-) create mode 100644 cmd/limactl/watch.go diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 408e3a9e9d5..a838c6b2c27 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -208,6 +208,7 @@ func newApp() *cobra.Command { newNetworkCommand(), newCloneCommand(), newRenameCommand(), + newWatchCommand(), ) addPluginCommands(rootCmd) diff --git a/cmd/limactl/watch.go b/cmd/limactl/watch.go new file mode 100644 index 00000000000..1ae5737f0c7 --- /dev/null +++ b/cmd/limactl/watch.go @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "path/filepath" + "time" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/hostagent/events" + "github.com/lima-vm/lima/v2/pkg/limatype" + "github.com/lima-vm/lima/v2/pkg/limatype/filenames" + "github.com/lima-vm/lima/v2/pkg/store" +) + +func newWatchCommand() *cobra.Command { + watchCommand := &cobra.Command{ + Use: "watch [INSTANCE]...", + Short: "Watch events from instances", + Long: `Watch events from Lima instances. + +Events include status changes (starting, running, stopping), port forwarding +events, and other instance lifecycle events. + +If no instance is specified, events from all running instances are watched. + +The command will continue watching until interrupted (Ctrl+C).`, + Example: ` # Watch events from all instances: + $ limactl watch + + # Watch events from a specific instance: + $ limactl watch default + + # Watch events in JSON format (for scripting): + $ limactl watch --json default`, + Args: WrapArgsError(cobra.ArbitraryArgs), + RunE: watchAction, + ValidArgsFunction: watchBashComplete, + GroupID: advancedCommand, + } + watchCommand.Flags().Bool("json", false, "Output events as newline-delimited JSON") + return watchCommand +} + +// watchEvent wraps an event with its instance name for JSON output. +type watchEvent struct { + Instance string `json:"instance"` + Event events.Event `json:"event"` +} + +func watchAction(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + jsonFormat, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + + // Determine which instances to watch + var instNames []string + if len(args) > 0 { + instNames = args + } else { + // Watch all instances + allInstances, err := store.Instances() + if err != nil { + return err + } + if len(allInstances) == 0 { + logrus.Warn("No instances found.") + return nil + } + instNames = allInstances + } + + // Validate instances and collect their log paths + type instanceInfo struct { + name string + haStdoutPath string + haStderrPath string + } + var instances []instanceInfo + + for _, instName := range instNames { + inst, err := store.Inspect(ctx, instName) + if err != nil { + return err + } + if inst.Status != limatype.StatusRunning { + logrus.Warnf("Instance %q is not running (status: %s). Watching for events anyway...", instName, inst.Status) + } + instances = append(instances, instanceInfo{ + name: instName, + haStdoutPath: filepath.Join(inst.Dir, filenames.HostAgentStdoutLog), + haStderrPath: filepath.Join(inst.Dir, filenames.HostAgentStderrLog), + }) + } + + // If only one instance, watch it directly + if len(instances) == 1 { + inst := instances[0] + return events.Watch(ctx, inst.haStdoutPath, inst.haStderrPath, time.Now(), !jsonFormat, func(ev events.Event) bool { + if jsonFormat { + we := watchEvent{Instance: inst.name, Event: ev} + j, err := json.Marshal(we) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error marshaling event: %v\n", err) + return false + } + fmt.Fprintln(cmd.OutOrStdout(), string(j)) + } else { + printHumanReadableEvent(cmd, inst.name, ev) + } + return false + }) + } + + // Watch multiple instances concurrently + type eventWithInstance struct { + instance string + event events.Event + } + eventCh := make(chan eventWithInstance) + errCh := make(chan error, len(instances)) + + for _, inst := range instances { + go func() { + err := events.Watch(ctx, inst.haStdoutPath, inst.haStderrPath, time.Now(), !jsonFormat, func(ev events.Event) bool { + select { + case eventCh <- eventWithInstance{instance: inst.name, event: ev}: + case <-ctx.Done(): + return true + } + return false + }) + if err != nil { + errCh <- fmt.Errorf("instance %s: %w", inst.name, err) + } + }() + } + + // Process events from all instances + for { + select { + case <-ctx.Done(): + return nil + case err := <-errCh: + return err + case ev := <-eventCh: + if jsonFormat { + we := watchEvent{Instance: ev.instance, Event: ev.event} + j, err := json.Marshal(we) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error marshaling event: %v\n", err) + continue + } + fmt.Fprintln(cmd.OutOrStdout(), string(j)) + } else { + printHumanReadableEvent(cmd, ev.instance, ev.event) + } + } + } +} + +func printHumanReadableEvent(cmd *cobra.Command, instName string, ev events.Event) { + timestamp := ev.Time.Format("2006-01-02 15:04:05") + out := cmd.OutOrStdout() + + printEvent := func(msg string) { + fmt.Fprintf(out, "%s %s | %s\n", timestamp, instName, msg) + } + + // Status changes + if ev.Status.Running { + if ev.Status.Degraded { + printEvent("running (degraded)") + } else { + printEvent("running") + } + } + if ev.Status.Exiting { + printEvent("exiting") + } + + // SSH port + if ev.Status.SSHLocalPort != 0 { + printEvent(fmt.Sprintf("ssh available on port %d", ev.Status.SSHLocalPort)) + } + + // Errors + for _, e := range ev.Status.Errors { + printEvent(fmt.Sprintf("error: %s", e)) + } + + // Cloud-init progress + if ev.Status.CloudInitProgress != nil { + if ev.Status.CloudInitProgress.Completed { + printEvent("cloud-init completed") + } else if ev.Status.CloudInitProgress.LogLine != "" { + printEvent(fmt.Sprintf("cloud-init: %s", ev.Status.CloudInitProgress.LogLine)) + } + } + + // Port forwarding events + if ev.Status.PortForward != nil { + pf := ev.Status.PortForward + switch pf.Type { + case events.PortForwardEventForwarding: + printEvent(fmt.Sprintf("forwarding %s %s to %s", pf.Protocol, pf.GuestAddr, pf.HostAddr)) + case events.PortForwardEventNotForwarding: + printEvent(fmt.Sprintf("not forwarding %s %s", pf.Protocol, pf.GuestAddr)) + case events.PortForwardEventStopping: + printEvent(fmt.Sprintf("stopping forwarding %s %s", pf.Protocol, pf.GuestAddr)) + case events.PortForwardEventFailed: + printEvent(fmt.Sprintf("failed to forward %s %s: %s", pf.Protocol, pf.GuestAddr, pf.Error)) + } + } + + // Vsock events + if ev.Status.Vsock != nil { + vs := ev.Status.Vsock + switch vs.Type { + case events.VsockEventStarted: + printEvent(fmt.Sprintf("started vsock forwarder: %s -> vsock:%d", vs.HostAddr, vs.VsockPort)) + case events.VsockEventSkipped: + printEvent(fmt.Sprintf("skipped vsock forwarder: %s", vs.Reason)) + case events.VsockEventFailed: + printEvent(fmt.Sprintf("failed to start vsock forwarder: %s", vs.Reason)) + } + } +} + +func watchBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return bashCompleteInstanceNames(cmd) +} diff --git a/hack/test-port-forwarding.pl b/hack/test-port-forwarding.pl index 4c586dbf442..0e8c0d0321a 100755 --- a/hack/test-port-forwarding.pl +++ b/hack/test-port-forwarding.pl @@ -18,6 +18,7 @@ use Config qw(%Config); use File::Spec::Functions qw(catfile); use IO::Handle qw(); +use JSON::PP; use Socket qw(inet_ntoa); use Sys::Hostname qw(hostname); @@ -172,8 +173,10 @@ # Record current log size, so we can skip prior output $ENV{HOME_HOST} ||= "$ENV{HOME}"; $ENV{LIMA_HOME} ||= "$ENV{HOME_HOST}/.lima"; -my $ha_log = "$ENV{LIMA_HOME}/$instance/ha.stderr.log"; -my $ha_log_size = -s $ha_log or die; +my $ha_stdout_log = "$ENV{LIMA_HOME}/$instance/ha.stdout.log"; +my $ha_stderr_log = "$ENV{LIMA_HOME}/$instance/ha.stderr.log"; +my $ha_stdout_log_size = -s $ha_stdout_log or die; +my $ha_stderr_log_size = -s $ha_stderr_log or die; # Setup a netcat listener on the guest for each test foreach my $id (0..@test-1) { @@ -218,18 +221,54 @@ close($netcat); } -# Extract forwarding log messages from hostagent log -open(my $log, "< $ha_log") or die "Can't read $ha_log: $!"; -seek($log, $ha_log_size, 0) or die "Can't seek $ha_log to $ha_log_size: $!"; +# Extract forwarding log messages from hostagent JSON event log +my $json_parser = JSON::PP->new->utf8->relaxed; + +open(my $log, "< $ha_stdout_log") or die "Can't read $ha_stdout_log: $!"; +seek($log, $ha_stdout_log_size, 0) or die "Can't seek $ha_stdout_log to $ha_stdout_log_size: $!"; my %seen; my %failed_to_listen_tcp; + while (<$log>) { - $seen{$1}++ if /(Forwarding TCP from .*? to ((\d.*?|\[.*?\]):\d+|\/[^"]+))/; - $seen{$1}++ if /(Not forwarding TCP .*?:\d+)/; - $failed_to_listen_tcp{$2}=$1 if /(failed to listen tcp: listen tcp (.*?:\d+):[^"]+)/; + chomp; + next unless /^\s*\{/; # Skip non-JSON lines + + my $event = eval { $json_parser->decode($_) }; + next unless $event; + + my $pf = $event->{status}{portForward}; + next unless $pf && $pf->{type}; + + my $type = $pf->{type}; + my $protocol = uc($pf->{protocol} || "tcp"); + my $guest_addr = $pf->{guestAddr} || ""; + my $host_addr = $pf->{hostAddr} || ""; + my $error = $pf->{error} || ""; + + if ($type eq "forwarding") { + my $msg = "Forwarding $protocol from $guest_addr to $host_addr"; + $seen{$msg}++; + } elsif ($type eq "not-forwarding") { + my $msg = "Not forwarding $protocol $guest_addr"; + $seen{$msg}++; + } elsif ($type eq "failed" && $error =~ /listen tcp/) { + # Extract the address from the error message + if ($error =~ /listen tcp (.*?:\d+):/) { + my $addr = $1; + $failed_to_listen_tcp{$addr} = "failed to listen tcp: $error"; + } + } } close $log or die; +# Also check stderr log for failed_to_listen_tcp messages (these may not be in JSON events) +open(my $stderr_log, "< $ha_stderr_log") or die "Can't read $ha_stderr_log: $!"; +seek($stderr_log, $ha_stderr_log_size, 0) or die "Can't seek $ha_stderr_log to $ha_stderr_log_size: $!"; +while (<$stderr_log>) { + $failed_to_listen_tcp{$2}=$1 if /(failed to listen tcp: listen tcp (.*?:\d+):[^"]+)/; +} +close $stderr_log or die; + my $rc = 0; my %expected; foreach my $id (0..@test-1) { @@ -237,7 +276,7 @@ my $err = ""; $expected{$test->{log_msg}}++; unless ($seen{$test->{log_msg}}) { - $err .= "\n Message missing from ha.stderr.log"; + $err .= "\n Message missing from ha.stdout.log (JSON events)"; } my $log = qx(limactl shell --workdir / $instance sh -c "cd; cat $listener.$id"); chomp $log; diff --git a/hack/test-templates.sh b/hack/test-templates.sh index 8ea9b8da5ed..374b737ee92 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -100,7 +100,7 @@ case "$NAME" in ;; esac -if limactl ls -q | grep -q "$NAME"; then +if limactl ls -q "$NAME" 2>/dev/null; then ERROR "Instance $NAME already exists" exit 1 fi @@ -155,7 +155,7 @@ INFO "Creating \"$NAME\" from \"$FILE_HOST\"" defer "limactl delete -f \"$NAME\"" if [[ -n ${CHECKS["disk"]} ]]; then - if ! limactl disk ls | grep -q "^data\s"; then + if [[ -z "$(limactl disk ls data --json 2>/dev/null)" ]]; then defer "limactl disk delete data" limactl disk create data --size 10G fi @@ -325,10 +325,27 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then if [[ "$(limactl ls "${NAME}" --yq .vmType)" == "vz" ]]; then INFO "Testing SSH over vsock" set -x + log_file="$HOME_HOST/.lima/${NAME}/ha.stdout.log" + + # Helper function to check vsock events in new log lines only + # $1: event_type to check for + # $2: line number to start checking from + check_vsock_event() { + local event_type="$1" + local start_line="$2" + # Check only lines added after start_line + if tail -n +"$start_line" "$log_file" | jq -e --arg type "$event_type" 'select(.status.vsock.type == $type)' >/dev/null 2>&1; then + return 0 + fi + return 1 + } + INFO "Testing .ssh.overVsock=true configuration" limactl stop "${NAME}" + log_lines_before=$(($(wc -l <"$log_file"))) # Detection of the SSH server on VSOCK may fail; however, a failing log indicates that controlling detection via the environment variable works as expected. - if ! limactl start --set '.ssh.overVsock=true' "${NAME}" 2>&1 | grep -i -E "(started vsock forwarder|SSH server does not seem to be running on vsock port)"; then + limactl start --set '.ssh.overVsock=true' "${NAME}" + if ! check_vsock_event "started" "$log_lines_before" && ! check_vsock_event "failed" "$log_lines_before"; then set +x diagnose "${NAME}" ERROR ".ssh.overVsock=true did not enable vsock forwarder" @@ -336,8 +353,10 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then fi INFO 'Testing .ssh.overVsock=null configuration' limactl stop "${NAME}" + log_lines_before=$(($(wc -l <"$log_file"))) # Detection of the SSH server on VSOCK may fail; however, a failing log indicates that controlling detection via the environment variable works as expected. - if ! limactl start --set '.ssh.overVsock=null' "${NAME}" 2>&1 | grep -i -E "(started vsock forwarder|SSH server does not seem to be running on vsock port)"; then + limactl start --set '.ssh.overVsock=null' "${NAME}" + if ! check_vsock_event "started" "$log_lines_before" && ! check_vsock_event "failed" "$log_lines_before"; then set +x diagnose "${NAME}" ERROR ".ssh.overVsock=null did not enable vsock forwarder" @@ -345,7 +364,9 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then fi INFO "Testing .ssh.overVsock=false configuration" limactl stop "${NAME}" - if ! limactl start --set '.ssh.overVsock=false' "${NAME}" 2>&1 | grep -i "skipping detection of SSH server on vsock port"; then + log_lines_before=$(($(wc -l <"$log_file"))) + limactl start --set '.ssh.overVsock=false' "${NAME}" + if ! check_vsock_event "skipped" "$log_lines_before"; then set +x diagnose "${NAME}" ERROR ".ssh.overVsock=false did not disable vsock forwarder" diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go index ff2aa8c5540..031a2576f2a 100644 --- a/pkg/driver/driver.go +++ b/pkg/driver/driver.go @@ -7,9 +7,15 @@ import ( "context" "net" + "github.com/lima-vm/lima/v2/pkg/hostagent/events" "github.com/lima-vm/lima/v2/pkg/limatype" ) +// VsockEventEmitter is an optional interface for drivers to emit vsock events. +type VsockEventEmitter interface { + SetVsockEventCallback(callback func(*events.VsockEvent)) +} + // Lifecycle defines basic lifecycle operations. type Lifecycle interface { // Validate returns error if the current driver isn't support for given config diff --git a/pkg/driver/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go index 397c3d5f3d2..6cfc55f2347 100644 --- a/pkg/driver/vz/vm_darwin.go +++ b/pkg/driver/vz/vm_darwin.go @@ -28,6 +28,7 @@ import ( "github.com/lima-vm/go-qcow2reader/image/raw" "github.com/sirupsen/logrus" + "github.com/lima-vm/lima/v2/pkg/hostagent/events" "github.com/lima-vm/lima/v2/pkg/imgutil/proxyimgutil" "github.com/lima-vm/lima/v2/pkg/iso9660util" "github.com/lima-vm/lima/v2/pkg/limatype" @@ -55,7 +56,7 @@ type virtualMachineWrapper struct { // Hold all *os.File created via socketpair() so that they won't get garbage collected. f.FD() gets invalid if f gets garbage collected. var vmNetworkFiles = make([]*os.File, 1) -func startVM(ctx context.Context, inst *limatype.Instance, sshLocalPort int) (vm *virtualMachineWrapper, waitSSHLocalPortAccessible <-chan any, errCh chan error, err error) { +func startVM(ctx context.Context, inst *limatype.Instance, sshLocalPort int, onVsockEvent func(*events.VsockEvent)) (vm *virtualMachineWrapper, waitSSHLocalPortAccessible <-chan any, errCh chan error, err error) { usernetClient, stopUsernet, err := startUsernet(ctx, inst) if err != nil { return nil, nil, nil, err @@ -113,18 +114,43 @@ func startVM(ctx context.Context, inst *limatype.Instance, sshLocalPort int) (vm } if !useSSHOverVsock { logrus.Info("ssh.overVsock is false, skipping detection of SSH server on vsock port") + if onVsockEvent != nil { + onVsockEvent(&events.VsockEvent{ + Type: events.VsockEventSkipped, + Reason: "ssh.overVsock is false", + }) + } } else if err := usernetClient.WaitOpeningSSHPort(ctx, inst); err == nil { hostAddress := net.JoinHostPort(inst.SSHAddress, strconv.Itoa(usernetSSHLocalPort)) if err := wrapper.startVsockForwarder(ctx, 22, hostAddress); err == nil { logrus.Infof("Detected SSH server is listening on the vsock port; changed %s to proxy for the vsock port", hostAddress) + if onVsockEvent != nil { + onVsockEvent(&events.VsockEvent{ + Type: events.VsockEventStarted, + HostAddr: hostAddress, + VsockPort: 22, + }) + } usernetSSHLocalPort = 0 // disable gvisor ssh port forwarding } else { logrus.WithError(err).WithField("hostAddress", hostAddress). Debugf("Failed to start vsock forwarder (systemd is older than v256?)") logrus.Info("SSH server does not seem to be running on vsock port, using usernet forwarder") + if onVsockEvent != nil { + onVsockEvent(&events.VsockEvent{ + Type: events.VsockEventFailed, + Reason: "SSH server does not seem to be running on vsock port", + }) + } } } else { logrus.WithError(err).Warn("Failed to wait for the guest SSH server to become available, falling back to usernet forwarder") + if onVsockEvent != nil { + onVsockEvent(&events.VsockEvent{ + Type: events.VsockEventFailed, + Reason: "Failed to wait for guest SSH server", + }) + } } err := usernetClient.ConfigureDriver(ctx, inst, usernetSSHLocalPort) if err != nil { diff --git a/pkg/driver/vz/vz_driver_darwin.go b/pkg/driver/vz/vz_driver_darwin.go index 566ebe602e1..53eb0e957f9 100644 --- a/pkg/driver/vz/vz_driver_darwin.go +++ b/pkg/driver/vz/vz_driver_darwin.go @@ -24,6 +24,7 @@ import ( "github.com/lima-vm/lima/v2/pkg/driver" "github.com/lima-vm/lima/v2/pkg/driverutil" + "github.com/lima-vm/lima/v2/pkg/hostagent/events" "github.com/lima-vm/lima/v2/pkg/limatype" "github.com/lima-vm/lima/v2/pkg/limayaml" "github.com/lima-vm/lima/v2/pkg/osutil" @@ -87,9 +88,19 @@ type LimaVzDriver struct { machine *virtualMachineWrapper waitSSHLocalPortAccessible <-chan any + + onVsockEvent func(*events.VsockEvent) } -var _ driver.Driver = (*LimaVzDriver)(nil) +var ( + _ driver.Driver = (*LimaVzDriver)(nil) + _ driver.VsockEventEmitter = (*LimaVzDriver)(nil) +) + +// SetVsockEventCallback implements driver.VsockEventEmitter. +func (l *LimaVzDriver) SetVsockEventCallback(callback func(*events.VsockEvent)) { + l.onVsockEvent = callback +} func New() *LimaVzDriver { return &LimaVzDriver{ @@ -329,7 +340,7 @@ func (l *LimaVzDriver) CreateDisk(ctx context.Context) error { func (l *LimaVzDriver) Start(ctx context.Context) (chan error, error) { logrus.Infof("Starting VZ (hint: to watch the boot progress, see %q)", filepath.Join(l.Instance.Dir, "serial*.log")) - vm, waitSSHLocalPortAccessible, errCh, err := startVM(ctx, l.Instance, l.SSHLocalPort) + vm, waitSSHLocalPortAccessible, errCh, err := startVM(ctx, l.Instance, l.SSHLocalPort, l.onVsockEvent) if err != nil { if errors.Is(err, vz.ErrUnsupportedOSVersion) { return nil, fmt.Errorf("vz driver requires macOS 13 or higher to run: %w", err) diff --git a/pkg/hostagent/events/events.go b/pkg/hostagent/events/events.go index 3afa14319ff..6f851d3933c 100644 --- a/pkg/hostagent/events/events.go +++ b/pkg/hostagent/events/events.go @@ -20,6 +20,12 @@ type Status struct { // Cloud-init progress information CloudInitProgress *CloudInitProgress `json:"cloudInitProgress,omitempty"` + + // Port forwarding event + PortForward *PortForwardEvent `json:"portForward,omitempty"` + + // Vsock forwarder event + Vsock *VsockEvent `json:"vsock,omitempty"` } type CloudInitProgress struct { @@ -31,6 +37,38 @@ type CloudInitProgress struct { Active bool `json:"active,omitempty"` } +type PortForwardEventType string + +const ( + PortForwardEventForwarding PortForwardEventType = "forwarding" + PortForwardEventNotForwarding PortForwardEventType = "not-forwarding" + PortForwardEventStopping PortForwardEventType = "stopping" + PortForwardEventFailed PortForwardEventType = "failed" +) + +type PortForwardEvent struct { + Type PortForwardEventType `json:"type"` + Protocol string `json:"protocol,omitempty"` + GuestAddr string `json:"guestAddr,omitempty"` + HostAddr string `json:"hostAddr,omitempty"` + Error string `json:"error,omitempty"` +} + +type VsockEventType string + +const ( + VsockEventStarted VsockEventType = "started" + VsockEventSkipped VsockEventType = "skipped" + VsockEventFailed VsockEventType = "failed" +) + +type VsockEvent struct { + Type VsockEventType `json:"type"` + HostAddr string `json:"hostAddr,omitempty"` + VsockPort int `json:"vsockPort,omitempty"` + Reason string `json:"reason,omitempty"` +} + type Event struct { Time time.Time `json:"time,omitempty"` Status Status `json:"status,omitempty"` diff --git a/pkg/hostagent/events/watcher.go b/pkg/hostagent/events/watcher.go index 3bd1107a128..f5ff79d0ab4 100644 --- a/pkg/hostagent/events/watcher.go +++ b/pkg/hostagent/events/watcher.go @@ -15,7 +15,7 @@ import ( "github.com/lima-vm/lima/v2/pkg/logrusutil" ) -func Watch(ctx context.Context, haStdoutPath, haStderrPath string, begin time.Time, onEvent func(Event) bool) error { +func Watch(ctx context.Context, haStdoutPath, haStderrPath string, begin time.Time, propagateStderr bool, onEvent func(Event) bool) error { haStdoutTail, err := tail.TailFile(haStdoutPath, tail.Config{ Follow: true, @@ -69,7 +69,9 @@ loop: if line.Err != nil { logrus.Error(line.Err) } - logrusutil.PropagateJSON(logrus.StandardLogger(), []byte(line.Text), "[hostagent] ", begin) + if propagateStderr { + logrusutil.PropagateJSON(logrus.StandardLogger(), []byte(line.Text), "[hostagent] ", begin) + } } } diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 957b7f5115e..1d084f8b81f 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -245,7 +245,6 @@ func New(ctx context.Context, instName string, stdout io.Writer, signalCh chan o instName: instName, instSSHAddress: inst.SSHAddress, sshConfig: sshConfig, - grpcPortForwarder: portfwd.NewPortForwarder(rules, ignoreTCP, ignoreUDP), driver: limaDriver, signalCh: signalCh, eventEnc: json.NewEncoder(stdout), @@ -254,7 +253,20 @@ func New(ctx context.Context, instName string, stdout io.Writer, signalCh chan o guestAgentAliveCh: make(chan struct{}), showProgress: o.showProgress, } - a.portForwarder = newPortForwarder(sshConfig, a.sshAddressPort, rules, ignoreTCP, inst.VMType) + a.grpcPortForwarder = portfwd.NewPortForwarder(rules, ignoreTCP, ignoreUDP, func(ev *events.PortForwardEvent) { + a.emitPortForwardEvent(context.Background(), ev) + }) + a.portForwarder = newPortForwarder(sshConfig, a.sshAddressPort, rules, ignoreTCP, inst.VMType, func(ev *events.PortForwardEvent) { + a.emitPortForwardEvent(context.Background(), ev) + }) + + // Set up vsock event callback if the driver supports it + if vsockEmitter, ok := limaDriver.Driver.(driver.VsockEventEmitter); ok { + vsockEmitter.SetVsockEventCallback(func(ev *events.VsockEvent) { + a.emitVsockEvent(context.Background(), ev) + }) + } + return a, nil } @@ -336,6 +348,28 @@ func (a *HostAgent) emitCloudInitProgressEvent(ctx context.Context, progress *ev a.emitEvent(ctx, ev) } +func (a *HostAgent) emitPortForwardEvent(ctx context.Context, pfEvent *events.PortForwardEvent) { + a.statusMu.RLock() + currentStatus := a.currentStatus + a.statusMu.RUnlock() + + currentStatus.PortForward = pfEvent + + ev := events.Event{Status: currentStatus} + a.emitEvent(ctx, ev) +} + +func (a *HostAgent) emitVsockEvent(ctx context.Context, vsockEvent *events.VsockEvent) { + a.statusMu.RLock() + currentStatus := a.currentStatus + a.statusMu.RUnlock() + + currentStatus.Vsock = vsockEvent + + ev := events.Event{Status: currentStatus} + a.emitEvent(ctx, ev) +} + func generatePassword(length int) (string, error) { // avoid any special symbols, to make it easier to copy/paste return password.Generate(length, length/4, 0, false, false) diff --git a/pkg/hostagent/port.go b/pkg/hostagent/port.go index 6ecc92e940d..2234dc92fd1 100644 --- a/pkg/hostagent/port.go +++ b/pkg/hostagent/port.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" "github.com/lima-vm/lima/v2/pkg/guestagent/api" + "github.com/lima-vm/lima/v2/pkg/hostagent/events" "github.com/lima-vm/lima/v2/pkg/limatype" "github.com/lima-vm/lima/v2/pkg/limayaml" ) @@ -21,19 +22,27 @@ type portForwarder struct { rules []limatype.PortForward ignore bool vmType limatype.VMType + onEvent func(*events.PortForwardEvent) } const sshGuestPort = 22 var IPv4loopback1 = limayaml.IPv4loopback1 -func newPortForwarder(sshConfig *ssh.SSHConfig, sshAddressPort func() (string, int), rules []limatype.PortForward, ignore bool, vmType limatype.VMType) *portForwarder { +func newPortForwarder(sshConfig *ssh.SSHConfig, sshAddressPort func() (string, int), rules []limatype.PortForward, ignore bool, vmType limatype.VMType, onEvent func(*events.PortForwardEvent)) *portForwarder { return &portForwarder{ sshConfig: sshConfig, sshAddressPort: sshAddressPort, rules: rules, ignore: ignore, vmType: vmType, + onEvent: onEvent, + } +} + +func (pf *portForwarder) emitEvent(ev *events.PortForwardEvent) { + if pf.onEvent != nil { + pf.onEvent(ev) } } @@ -97,6 +106,12 @@ func (pf *portForwarder) OnEvent(ctx context.Context, ev *api.Event) { continue } logrus.Infof("Stopping forwarding TCP from %s to %s", remote, local) + pf.emitEvent(&events.PortForwardEvent{ + Type: events.PortForwardEventStopping, + Protocol: "tcp", + GuestAddr: remote, + HostAddr: local, + }) if err := forwardTCP(ctx, pf.sshConfig, sshAddress, sshPort, local, remote, verbCancel); err != nil { logrus.WithError(err).Warnf("failed to stop forwarding tcp port %d", f.Port) } @@ -109,10 +124,21 @@ func (pf *portForwarder) OnEvent(ctx context.Context, ev *api.Event) { if local == "" { if !pf.ignore { logrus.Infof("Not forwarding TCP %s", remote) + pf.emitEvent(&events.PortForwardEvent{ + Type: events.PortForwardEventNotForwarding, + Protocol: "tcp", + GuestAddr: remote, + }) } continue } logrus.Infof("Forwarding TCP from %s to %s", remote, local) + pf.emitEvent(&events.PortForwardEvent{ + Type: events.PortForwardEventForwarding, + Protocol: "tcp", + GuestAddr: remote, + HostAddr: local, + }) if err := forwardTCP(ctx, pf.sshConfig, sshAddress, sshPort, local, remote, verbForward); err != nil { logrus.WithError(err).Warnf("failed to set up forwarding tcp port %d (negligible if already forwarded)", f.Port) } diff --git a/pkg/instance/start.go b/pkg/instance/start.go index 8e5c3806f81..042b106420f 100644 --- a/pkg/instance/start.go +++ b/pkg/instance/start.go @@ -354,7 +354,7 @@ func watchHostAgentEvents(ctx context.Context, inst *limatype.Instance, haStdout return false } - if xerr := hostagentevents.Watch(ctx, haStdoutPath, haStderrPath, begin, onEvent); xerr != nil { + if xerr := hostagentevents.Watch(ctx, haStdoutPath, haStderrPath, begin, true, onEvent); xerr != nil { return xerr } diff --git a/pkg/instance/stop.go b/pkg/instance/stop.go index af4605b85ec..f65ce4577d0 100644 --- a/pkg/instance/stop.go +++ b/pkg/instance/stop.go @@ -70,7 +70,7 @@ func waitForHostAgentTermination(ctx context.Context, inst *limatype.Instance, b haStdoutPath := filepath.Join(inst.Dir, filenames.HostAgentStdoutLog) haStderrPath := filepath.Join(inst.Dir, filenames.HostAgentStderrLog) - if err := hostagentevents.Watch(ctx, haStdoutPath, haStderrPath, begin, onEvent); err != nil { + if err := hostagentevents.Watch(ctx, haStdoutPath, haStderrPath, begin, true, onEvent); err != nil { return err } diff --git a/pkg/portfwd/forward.go b/pkg/portfwd/forward.go index 1edd08fee0a..3f5b2559359 100644 --- a/pkg/portfwd/forward.go +++ b/pkg/portfwd/forward.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" "github.com/lima-vm/lima/v2/pkg/guestagent/api" + "github.com/lima-vm/lima/v2/pkg/hostagent/events" "github.com/lima-vm/lima/v2/pkg/limatype" "github.com/lima-vm/lima/v2/pkg/limayaml" ) @@ -22,14 +23,22 @@ type Forwarder struct { ignoreTCP bool ignoreUDP bool closableListeners *ClosableListeners + onEvent func(*events.PortForwardEvent) } -func NewPortForwarder(rules []limatype.PortForward, ignoreTCP, ignoreUDP bool) *Forwarder { +func NewPortForwarder(rules []limatype.PortForward, ignoreTCP, ignoreUDP bool, onEvent func(*events.PortForwardEvent)) *Forwarder { return &Forwarder{ rules: rules, ignoreTCP: ignoreTCP, ignoreUDP: ignoreUDP, closableListeners: NewClosableListener(), + onEvent: onEvent, + } +} + +func (fw *Forwarder) emitEvent(ev *events.PortForwardEvent) { + if fw.onEvent != nil { + fw.onEvent(ev) } } @@ -47,13 +56,29 @@ func (fw *Forwarder) OnEvent(ctx context.Context, dialContext func(ctx context.C if local == "" { if !fw.ignoreTCP && f.Protocol == "tcp" { logrus.Infof("Not forwarding TCP %s", remote) + fw.emitEvent(&events.PortForwardEvent{ + Type: events.PortForwardEventNotForwarding, + Protocol: f.Protocol, + GuestAddr: remote, + }) } if !fw.ignoreUDP && f.Protocol == "udp" { logrus.Infof("Not forwarding UDP %s", remote) + fw.emitEvent(&events.PortForwardEvent{ + Type: events.PortForwardEventNotForwarding, + Protocol: f.Protocol, + GuestAddr: remote, + }) } continue } logrus.Infof("Forwarding %s from %s to %s", strings.ToUpper(f.Protocol), remote, local) + fw.emitEvent(&events.PortForwardEvent{ + Type: events.PortForwardEventForwarding, + Protocol: f.Protocol, + GuestAddr: remote, + HostAddr: local, + }) fw.closableListeners.Forward(ctx, dialContext, f.Protocol, local, remote) } for _, f := range ev.RemovedLocalPorts { @@ -61,6 +86,12 @@ func (fw *Forwarder) OnEvent(ctx context.Context, dialContext func(ctx context.C if local == "" { continue } + fw.emitEvent(&events.PortForwardEvent{ + Type: events.PortForwardEventStopping, + Protocol: f.Protocol, + GuestAddr: remote, + HostAddr: local, + }) fw.closableListeners.Remove(ctx, f.Protocol, local, remote) logrus.Debugf("Port forwarding closed proto:%s host:%s guest:%s", f.Protocol, local, remote) } From 8ce30895daf2b22cdcd83a701eeb96cc6c2bdeeb Mon Sep 17 00:00:00 2001 From: IrvingMg Date: Tue, 23 Dec 2025 09:34:33 +0100 Subject: [PATCH 2/4] Fix vz integration test Signed-off-by: IrvingMg --- hack/test-templates.sh | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/hack/test-templates.sh b/hack/test-templates.sh index 374b737ee92..066da79378c 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -327,14 +327,11 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then set -x log_file="$HOME_HOST/.lima/${NAME}/ha.stdout.log" - # Helper function to check vsock events in new log lines only + # Helper function to check vsock events in the log file # $1: event_type to check for - # $2: line number to start checking from check_vsock_event() { local event_type="$1" - local start_line="$2" - # Check only lines added after start_line - if tail -n +"$start_line" "$log_file" | jq -e --arg type "$event_type" 'select(.status.vsock.type == $type)' >/dev/null 2>&1; then + if jq -e --arg type "$event_type" 'select(.status.vsock.type == $type)' "$log_file" >/dev/null 2>&1; then return 0 fi return 1 @@ -342,10 +339,9 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then INFO "Testing .ssh.overVsock=true configuration" limactl stop "${NAME}" - log_lines_before=$(($(wc -l <"$log_file"))) # Detection of the SSH server on VSOCK may fail; however, a failing log indicates that controlling detection via the environment variable works as expected. limactl start --set '.ssh.overVsock=true' "${NAME}" - if ! check_vsock_event "started" "$log_lines_before" && ! check_vsock_event "failed" "$log_lines_before"; then + if ! check_vsock_event "started" && ! check_vsock_event "failed"; then set +x diagnose "${NAME}" ERROR ".ssh.overVsock=true did not enable vsock forwarder" @@ -353,10 +349,9 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then fi INFO 'Testing .ssh.overVsock=null configuration' limactl stop "${NAME}" - log_lines_before=$(($(wc -l <"$log_file"))) # Detection of the SSH server on VSOCK may fail; however, a failing log indicates that controlling detection via the environment variable works as expected. limactl start --set '.ssh.overVsock=null' "${NAME}" - if ! check_vsock_event "started" "$log_lines_before" && ! check_vsock_event "failed" "$log_lines_before"; then + if ! check_vsock_event "started" && ! check_vsock_event "failed"; then set +x diagnose "${NAME}" ERROR ".ssh.overVsock=null did not enable vsock forwarder" @@ -364,9 +359,8 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then fi INFO "Testing .ssh.overVsock=false configuration" limactl stop "${NAME}" - log_lines_before=$(($(wc -l <"$log_file"))) limactl start --set '.ssh.overVsock=false' "${NAME}" - if ! check_vsock_event "skipped" "$log_lines_before"; then + if ! check_vsock_event "skipped"; then set +x diagnose "${NAME}" ERROR ".ssh.overVsock=false did not disable vsock forwarder" From 06ea985cce245551565e96e04c2e714b49bd9fec Mon Sep 17 00:00:00 2001 From: IrvingMg Date: Tue, 23 Dec 2025 09:41:52 +0100 Subject: [PATCH 3/4] Call OutOrStdout and ErrOrStderr once before loop Signed-off-by: IrvingMg --- cmd/limactl/watch.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/limactl/watch.go b/cmd/limactl/watch.go index 1ae5737f0c7..369a6d896a3 100644 --- a/cmd/limactl/watch.go +++ b/cmd/limactl/watch.go @@ -6,6 +6,7 @@ package main import ( "encoding/json" "fmt" + "io" "path/filepath" "time" @@ -101,6 +102,9 @@ func watchAction(cmd *cobra.Command, args []string) error { }) } + stdout := cmd.OutOrStdout() + stderr := cmd.ErrOrStderr() + // If only one instance, watch it directly if len(instances) == 1 { inst := instances[0] @@ -109,12 +113,12 @@ func watchAction(cmd *cobra.Command, args []string) error { we := watchEvent{Instance: inst.name, Event: ev} j, err := json.Marshal(we) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error marshaling event: %v\n", err) + fmt.Fprintf(stderr, "error marshaling event: %v\n", err) return false } - fmt.Fprintln(cmd.OutOrStdout(), string(j)) + fmt.Fprintln(stdout, string(j)) } else { - printHumanReadableEvent(cmd, inst.name, ev) + printHumanReadableEvent(stdout, inst.name, ev) } return false }) @@ -156,20 +160,19 @@ func watchAction(cmd *cobra.Command, args []string) error { we := watchEvent{Instance: ev.instance, Event: ev.event} j, err := json.Marshal(we) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "error marshaling event: %v\n", err) + fmt.Fprintf(stderr, "error marshaling event: %v\n", err) continue } - fmt.Fprintln(cmd.OutOrStdout(), string(j)) + fmt.Fprintln(stdout, string(j)) } else { - printHumanReadableEvent(cmd, ev.instance, ev.event) + printHumanReadableEvent(stdout, ev.instance, ev.event) } } } } -func printHumanReadableEvent(cmd *cobra.Command, instName string, ev events.Event) { +func printHumanReadableEvent(out io.Writer, instName string, ev events.Event) { timestamp := ev.Time.Format("2006-01-02 15:04:05") - out := cmd.OutOrStdout() printEvent := func(msg string) { fmt.Fprintf(out, "%s %s | %s\n", timestamp, instName, msg) From 6c50ece95836d7897a593a36876c84beef134ba8 Mon Sep 17 00:00:00 2001 From: IrvingMg Date: Tue, 23 Dec 2025 09:52:32 +0100 Subject: [PATCH 4/4] Clean up watch command Signed-off-by: IrvingMg --- cmd/limactl/watch.go | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/cmd/limactl/watch.go b/cmd/limactl/watch.go index 369a6d896a3..130be35c9fa 100644 --- a/cmd/limactl/watch.go +++ b/cmd/limactl/watch.go @@ -48,12 +48,17 @@ The command will continue watching until interrupted (Ctrl+C).`, return watchCommand } -// watchEvent wraps an event with its instance name for JSON output. type watchEvent struct { Instance string `json:"instance"` Event events.Event `json:"event"` } +type instanceInfo struct { + name string + haStdoutPath string + haStderrPath string +} + func watchAction(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -79,12 +84,6 @@ func watchAction(cmd *cobra.Command, args []string) error { instNames = allInstances } - // Validate instances and collect their log paths - type instanceInfo struct { - name string - haStdoutPath string - haStderrPath string - } var instances []instanceInfo for _, instName := range instNames { @@ -124,19 +123,14 @@ func watchAction(cmd *cobra.Command, args []string) error { }) } - // Watch multiple instances concurrently - type eventWithInstance struct { - instance string - event events.Event - } - eventCh := make(chan eventWithInstance) + eventCh := make(chan watchEvent) errCh := make(chan error, len(instances)) for _, inst := range instances { go func() { err := events.Watch(ctx, inst.haStdoutPath, inst.haStderrPath, time.Now(), !jsonFormat, func(ev events.Event) bool { select { - case eventCh <- eventWithInstance{instance: inst.name, event: ev}: + case eventCh <- watchEvent{Instance: inst.name, Event: ev}: case <-ctx.Done(): return true } @@ -157,15 +151,14 @@ func watchAction(cmd *cobra.Command, args []string) error { return err case ev := <-eventCh: if jsonFormat { - we := watchEvent{Instance: ev.instance, Event: ev.event} - j, err := json.Marshal(we) + j, err := json.Marshal(ev) if err != nil { fmt.Fprintf(stderr, "error marshaling event: %v\n", err) continue } fmt.Fprintln(stdout, string(j)) } else { - printHumanReadableEvent(stdout, ev.instance, ev.event) + printHumanReadableEvent(stdout, ev.Instance, ev.Event) } } } @@ -178,7 +171,6 @@ func printHumanReadableEvent(out io.Writer, instName string, ev events.Event) { fmt.Fprintf(out, "%s %s | %s\n", timestamp, instName, msg) } - // Status changes if ev.Status.Running { if ev.Status.Degraded { printEvent("running (degraded)") @@ -189,18 +181,12 @@ func printHumanReadableEvent(out io.Writer, instName string, ev events.Event) { if ev.Status.Exiting { printEvent("exiting") } - - // SSH port if ev.Status.SSHLocalPort != 0 { printEvent(fmt.Sprintf("ssh available on port %d", ev.Status.SSHLocalPort)) } - - // Errors for _, e := range ev.Status.Errors { printEvent(fmt.Sprintf("error: %s", e)) } - - // Cloud-init progress if ev.Status.CloudInitProgress != nil { if ev.Status.CloudInitProgress.Completed { printEvent("cloud-init completed") @@ -208,8 +194,6 @@ func printHumanReadableEvent(out io.Writer, instName string, ev events.Event) { printEvent(fmt.Sprintf("cloud-init: %s", ev.Status.CloudInitProgress.LogLine)) } } - - // Port forwarding events if ev.Status.PortForward != nil { pf := ev.Status.PortForward switch pf.Type { @@ -223,8 +207,6 @@ func printHumanReadableEvent(out io.Writer, instName string, ev events.Event) { printEvent(fmt.Sprintf("failed to forward %s %s: %s", pf.Protocol, pf.GuestAddr, pf.Error)) } } - - // Vsock events if ev.Status.Vsock != nil { vs := ev.Status.Vsock switch vs.Type {