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
1 change: 1 addition & 0 deletions cmd/limactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ func newApp() *cobra.Command {
newNetworkCommand(),
newCloneCommand(),
newRenameCommand(),
newWatchCommand(),
)
addPluginCommands(rootCmd)

Expand Down
225 changes: 225 additions & 0 deletions cmd/limactl/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"encoding/json"
"fmt"
"io"
"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
}

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()

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
}

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),
})
}

stdout := cmd.OutOrStdout()
stderr := cmd.ErrOrStderr()

// 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(stderr, "error marshaling event: %v\n", err)
return false
}
fmt.Fprintln(stdout, string(j))
} else {
printHumanReadableEvent(stdout, inst.name, ev)
}
return false
})
}

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 <- watchEvent{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 {
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)
}
}
}
}

func printHumanReadableEvent(out io.Writer, instName string, ev events.Event) {
timestamp := ev.Time.Format("2006-01-02 15:04:05")

printEvent := func(msg string) {
fmt.Fprintf(out, "%s %s | %s\n", timestamp, instName, msg)
}

if ev.Status.Running {
if ev.Status.Degraded {
printEvent("running (degraded)")
} else {
printEvent("running")
}
}
if ev.Status.Exiting {
printEvent("exiting")
}
if ev.Status.SSHLocalPort != 0 {
printEvent(fmt.Sprintf("ssh available on port %d", ev.Status.SSHLocalPort))
}
for _, e := range ev.Status.Errors {
printEvent(fmt.Sprintf("error: %s", e))
}
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))
}
}
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))
}
}
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)
}
57 changes: 48 additions & 9 deletions hack/test-port-forwarding.pl
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -218,26 +221,62 @@
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) {
my $test = $test[$id];
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;
Expand Down
25 changes: 20 additions & 5 deletions hack/test-templates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -325,10 +325,23 @@ 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 the log file
# $1: event_type to check for
check_vsock_event() {
local event_type="$1"
if jq -e --arg type "$event_type" 'select(.status.vsock.type == $type)' "$log_file" >/dev/null 2>&1; then
return 0
fi
return 1
}

INFO "Testing .ssh.overVsock=true configuration"
limactl stop "${NAME}"
# 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" && ! check_vsock_event "failed"; then
set +x
diagnose "${NAME}"
ERROR ".ssh.overVsock=true did not enable vsock forwarder"
Expand All @@ -337,15 +350,17 @@ if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then
INFO 'Testing .ssh.overVsock=null configuration'
limactl stop "${NAME}"
# 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" && ! check_vsock_event "failed"; then
set +x
diagnose "${NAME}"
ERROR ".ssh.overVsock=null did not enable vsock forwarder"
exit 1
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
limactl start --set '.ssh.overVsock=false' "${NAME}"
if ! check_vsock_event "skipped"; then
set +x
diagnose "${NAME}"
ERROR ".ssh.overVsock=false did not disable vsock forwarder"
Expand Down
Loading