From 38c62c5e815fdc93e6e88543be1eb9ef7699eaf5 Mon Sep 17 00:00:00 2001 From: Balaji Vijayakumar Date: Tue, 12 Mar 2024 11:08:30 +0530 Subject: [PATCH] Support for inotify in mounted directories Signed-off-by: Balaji Vijayakumar --- cmd/limactl/editflags/editflags.go | 2 + examples/default.yaml | 4 + go.mod | 1 + go.sum | 3 + pkg/guestagent/api/client/client.go | 8 ++ pkg/guestagent/api/guestservice.pb.desc | 11 +- pkg/guestagent/api/guestservice.pb.go | 130 ++++++++++++++---- pkg/guestagent/api/guestservice.proto | 6 + pkg/guestagent/api/guestservice_grpc.pb.go | 72 +++++++++- pkg/guestagent/api/server/server.go | 10 ++ pkg/guestagent/guestagent.go | 1 + pkg/guestagent/guestagent_linux.go | 12 ++ pkg/hostagent/hostagent.go | 17 ++- pkg/hostagent/inotify.go | 111 +++++++++++++++ pkg/hostagent/inotify_darwin.go | 7 + pkg/hostagent/inotify_linux.go | 7 + pkg/hostagent/inotify_others.go | 9 ++ pkg/httpclientutil/httpclientutil.go | 22 ++- pkg/limayaml/defaults.go | 10 ++ pkg/limayaml/defaults_test.go | 5 + pkg/limayaml/limayaml.go | 1 + pkg/limayaml/validate.go | 3 + pkg/vz/vz_driver_darwin.go | 1 + .../content/en/docs/Config/Mount/_index.md | 36 +++++ .../en/docs/Releases/Experimental/_index.md | 1 + 25 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 pkg/hostagent/inotify.go create mode 100644 pkg/hostagent/inotify_darwin.go create mode 100644 pkg/hostagent/inotify_linux.go create mode 100644 pkg/hostagent/inotify_others.go diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go index 9848d15ad48..405c909a76d 100644 --- a/cmd/limactl/editflags/editflags.go +++ b/cmd/limactl/editflags/editflags.go @@ -49,6 +49,7 @@ func registerEdit(cmd *cobra.Command, commentPrefix string) { }) flags.Bool("mount-writable", false, commentPrefix+"make all mounts writable") + flags.Bool("mount-inotify", false, commentPrefix+"enable inotify for mounts") flags.StringSlice("network", nil, commentPrefix+"additional networks, e.g., \"vzNAT\" or \"lima:shared\" to assign vmnet IP") _ = cmd.RegisterFlagCompletionFunc("network", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -154,6 +155,7 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { false, }, {"mount-type", d(".mountType = %q"), false, false}, + {"mount-inotify", d(".mountInotify = %s"), false, true}, {"mount-writable", d(".mounts[].writable = %s"), false, false}, { "network", diff --git a/examples/default.yaml b/examples/default.yaml index d9040fac8cc..6861d7c393f 100644 --- a/examples/default.yaml +++ b/examples/default.yaml @@ -103,6 +103,10 @@ mounts: # 🟢 Builtin default: "reverse-sshfs" (for QEMU), "virtiofs" (for vz) mountType: null +# Enable inotify support for mounted directories (EXPERIMENTAL) +# 🟢 Builtin default: Disabled by default +mountInotify: null + # Lima disks to attach to the instance. The disks will be accessible from inside the # instance, labeled by name. (e.g. if the disk is named "data", it will be labeled # "lima-data" inside the instance). The disk will be mounted inside the instance at diff --git a/go.mod b/go.mod index 8b3d5a3204b..af655156294 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/nxadm/tail v1.4.11 github.com/opencontainers/go-digest v1.0.0 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 + github.com/rjeczalik/notify v0.9.3 github.com/sethvargo/go-password v0.2.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.0 diff --git a/go.sum b/go.sum index d74c0368110..67c93ddb9a1 100644 --- a/go.sum +++ b/go.sum @@ -236,6 +236,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= +github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -335,6 +337,7 @@ golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/pkg/guestagent/api/client/client.go b/pkg/guestagent/api/client/client.go index 2312723dfb5..2152e0d5723 100644 --- a/pkg/guestagent/api/client/client.go +++ b/pkg/guestagent/api/client/client.go @@ -49,3 +49,11 @@ func (c *GuestAgentClient) Events(ctx context.Context, eventCb func(response *ap eventCb(recv) } } + +func (c *GuestAgentClient) Inotify(ctx context.Context) (api.GuestService_PostInotifyClient, error) { + inotify, err := c.cli.PostInotify(ctx) + if err != nil { + return nil, err + } + return inotify, nil +} diff --git a/pkg/guestagent/api/guestservice.pb.desc b/pkg/guestagent/api/guestservice.pb.desc index 2587c5ac481..415d90c53b3 100644 --- a/pkg/guestagent/api/guestservice.pb.desc +++ b/pkg/guestagent/api/guestservice.pb.desc @@ -1,5 +1,5 @@ -† +” guestservice.protogoogle/protobuf/empty.protogoogle/protobuf/timestamp.proto"0 Info( local_ports ( 2.IPPortR @@ -11,7 +11,12 @@ localPorts" errors ( Rerrors", IPPort ip ( Rip -port (Rport2g +port (Rport"X +Inotify + +mount_path ( R mountPath. +time ( 2.google.protobuf.TimestampRtime2š GuestService( GetInfo.google.protobuf.Empty.Info- - GetEvents.google.protobuf.Empty.Event0B!Zgithub.com/lima-vm/lima/pkg/apibproto3 \ No newline at end of file + GetEvents.google.protobuf.Empty.Event01 + PostInotify.Inotify.google.protobuf.Empty(B!Zgithub.com/lima-vm/lima/pkg/apibproto3 \ No newline at end of file diff --git a/pkg/guestagent/api/guestservice.pb.go b/pkg/guestagent/api/guestservice.pb.go index fdf4601f4d5..cfad0613dce 100644 --- a/pkg/guestagent/api/guestservice.pb.go +++ b/pkg/guestagent/api/guestservice.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.28.1 -// protoc v4.25.2 +// protoc v4.25.3 // source: guestservice.proto package api @@ -195,6 +195,61 @@ func (x *IPPort) GetPort() int32 { return 0 } +type Inotify struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + MountPath string `protobuf:"bytes,1,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"` + Time *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time,proto3" json:"time,omitempty"` +} + +func (x *Inotify) Reset() { + *x = Inotify{} + if protoimpl.UnsafeEnabled { + mi := &file_guestservice_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Inotify) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Inotify) ProtoMessage() {} + +func (x *Inotify) ProtoReflect() protoreflect.Message { + mi := &file_guestservice_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Inotify.ProtoReflect.Descriptor instead. +func (*Inotify) Descriptor() ([]byte, []int) { + return file_guestservice_proto_rawDescGZIP(), []int{3} +} + +func (x *Inotify) GetMountPath() string { + if x != nil { + return x.MountPath + } + return "" +} + +func (x *Inotify) GetTime() *timestamppb.Timestamp { + if x != nil { + return x.Time + } + return nil +} + var File_guestservice_proto protoreflect.FileDescriptor var file_guestservice_proto_rawDesc = []byte{ @@ -221,16 +276,25 @@ var file_guestservice_proto_rawDesc = []byte{ 0x72, 0x6f, 0x72, 0x73, 0x22, 0x2c, 0x0a, 0x06, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x70, 0x6f, - 0x72, 0x74, 0x32, 0x67, 0x0a, 0x0c, 0x47, 0x75, 0x65, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x05, 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, - 0x47, 0x65, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, - 0x79, 0x1a, 0x06, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x42, 0x21, 0x5a, 0x1f, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x6d, 0x61, 0x2d, 0x76, - 0x6d, 0x2f, 0x6c, 0x69, 0x6d, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x69, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x74, 0x22, 0x58, 0x0a, 0x07, 0x49, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x12, 0x1d, 0x0a, + 0x0a, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, 0x2e, 0x0a, 0x04, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x32, 0x9a, 0x01, 0x0a, + 0x0c, 0x47, 0x75, 0x65, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x28, 0x0a, + 0x07, 0x47, 0x65, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x05, 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x06, 0x2e, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x31, 0x0a, 0x0b, 0x50, 0x6f, 0x73, 0x74, 0x49, 0x6e, + 0x6f, 0x74, 0x69, 0x66, 0x79, 0x12, 0x08, 0x2e, 0x49, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x1a, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x28, 0x01, 0x42, 0x21, 0x5a, 0x1f, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x6d, 0x61, 0x2d, 0x76, 0x6d, 0x2f, + 0x6c, 0x69, 0x6d, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -245,28 +309,32 @@ func file_guestservice_proto_rawDescGZIP() []byte { return file_guestservice_proto_rawDescData } -var file_guestservice_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_guestservice_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_guestservice_proto_goTypes = []interface{}{ (*Info)(nil), // 0: Info (*Event)(nil), // 1: Event (*IPPort)(nil), // 2: IPPort - (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 4: google.protobuf.Empty + (*Inotify)(nil), // 3: Inotify + (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 5: google.protobuf.Empty } var file_guestservice_proto_depIdxs = []int32{ 2, // 0: Info.local_ports:type_name -> IPPort - 3, // 1: Event.time:type_name -> google.protobuf.Timestamp + 4, // 1: Event.time:type_name -> google.protobuf.Timestamp 2, // 2: Event.local_ports_added:type_name -> IPPort 2, // 3: Event.local_ports_removed:type_name -> IPPort - 4, // 4: GuestService.GetInfo:input_type -> google.protobuf.Empty - 4, // 5: GuestService.GetEvents:input_type -> google.protobuf.Empty - 0, // 6: GuestService.GetInfo:output_type -> Info - 1, // 7: GuestService.GetEvents:output_type -> Event - 6, // [6:8] is the sub-list for method output_type - 4, // [4:6] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 4, // 4: Inotify.time:type_name -> google.protobuf.Timestamp + 5, // 5: GuestService.GetInfo:input_type -> google.protobuf.Empty + 5, // 6: GuestService.GetEvents:input_type -> google.protobuf.Empty + 3, // 7: GuestService.PostInotify:input_type -> Inotify + 0, // 8: GuestService.GetInfo:output_type -> Info + 1, // 9: GuestService.GetEvents:output_type -> Event + 5, // 10: GuestService.PostInotify:output_type -> google.protobuf.Empty + 8, // [8:11] is the sub-list for method output_type + 5, // [5:8] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_guestservice_proto_init() } @@ -311,6 +379,18 @@ func file_guestservice_proto_init() { return nil } } + file_guestservice_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Inotify); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -318,7 +398,7 @@ func file_guestservice_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_guestservice_proto_rawDesc, NumEnums: 0, - NumMessages: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/guestagent/api/guestservice.proto b/pkg/guestagent/api/guestservice.proto index 48fbfc5d445..3780cbdefbc 100644 --- a/pkg/guestagent/api/guestservice.proto +++ b/pkg/guestagent/api/guestservice.proto @@ -7,6 +7,7 @@ import "google/protobuf/timestamp.proto"; service GuestService { rpc GetInfo(google.protobuf.Empty) returns (Info); rpc GetEvents(google.protobuf.Empty) returns (stream Event); + rpc PostInotify(stream Inotify) returns (google.protobuf.Empty); } message Info { @@ -24,3 +25,8 @@ message IPPort { string ip = 1; int32 port = 2; } + +message Inotify { + string mount_path = 1; + google.protobuf.Timestamp time = 2; +} diff --git a/pkg/guestagent/api/guestservice_grpc.pb.go b/pkg/guestagent/api/guestservice_grpc.pb.go index ba5258ddac0..475e10e1de6 100644 --- a/pkg/guestagent/api/guestservice_grpc.pb.go +++ b/pkg/guestagent/api/guestservice_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.2.0 -// - protoc v4.25.2 +// - protoc v4.25.3 // source: guestservice.proto package api @@ -25,6 +25,7 @@ const _ = grpc.SupportPackageIsVersion7 type GuestServiceClient interface { GetInfo(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Info, error) GetEvents(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (GuestService_GetEventsClient, error) + PostInotify(ctx context.Context, opts ...grpc.CallOption) (GuestService_PostInotifyClient, error) } type guestServiceClient struct { @@ -76,12 +77,47 @@ func (x *guestServiceGetEventsClient) Recv() (*Event, error) { return m, nil } +func (c *guestServiceClient) PostInotify(ctx context.Context, opts ...grpc.CallOption) (GuestService_PostInotifyClient, error) { + stream, err := c.cc.NewStream(ctx, &GuestService_ServiceDesc.Streams[1], "/GuestService/PostInotify", opts...) + if err != nil { + return nil, err + } + x := &guestServicePostInotifyClient{stream} + return x, nil +} + +type GuestService_PostInotifyClient interface { + Send(*Inotify) error + CloseAndRecv() (*emptypb.Empty, error) + grpc.ClientStream +} + +type guestServicePostInotifyClient struct { + grpc.ClientStream +} + +func (x *guestServicePostInotifyClient) Send(m *Inotify) error { + return x.ClientStream.SendMsg(m) +} + +func (x *guestServicePostInotifyClient) CloseAndRecv() (*emptypb.Empty, error) { + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + m := new(emptypb.Empty) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // GuestServiceServer is the server API for GuestService service. // All implementations must embed UnimplementedGuestServiceServer // for forward compatibility type GuestServiceServer interface { GetInfo(context.Context, *emptypb.Empty) (*Info, error) GetEvents(*emptypb.Empty, GuestService_GetEventsServer) error + PostInotify(GuestService_PostInotifyServer) error mustEmbedUnimplementedGuestServiceServer() } @@ -95,6 +131,9 @@ func (UnimplementedGuestServiceServer) GetInfo(context.Context, *emptypb.Empty) func (UnimplementedGuestServiceServer) GetEvents(*emptypb.Empty, GuestService_GetEventsServer) error { return status.Errorf(codes.Unimplemented, "method GetEvents not implemented") } +func (UnimplementedGuestServiceServer) PostInotify(GuestService_PostInotifyServer) error { + return status.Errorf(codes.Unimplemented, "method PostInotify not implemented") +} func (UnimplementedGuestServiceServer) mustEmbedUnimplementedGuestServiceServer() {} // UnsafeGuestServiceServer may be embedded to opt out of forward compatibility for this service. @@ -147,6 +186,32 @@ func (x *guestServiceGetEventsServer) Send(m *Event) error { return x.ServerStream.SendMsg(m) } +func _GuestService_PostInotify_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GuestServiceServer).PostInotify(&guestServicePostInotifyServer{stream}) +} + +type GuestService_PostInotifyServer interface { + SendAndClose(*emptypb.Empty) error + Recv() (*Inotify, error) + grpc.ServerStream +} + +type guestServicePostInotifyServer struct { + grpc.ServerStream +} + +func (x *guestServicePostInotifyServer) SendAndClose(m *emptypb.Empty) error { + return x.ServerStream.SendMsg(m) +} + +func (x *guestServicePostInotifyServer) Recv() (*Inotify, error) { + m := new(Inotify) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // GuestService_ServiceDesc is the grpc.ServiceDesc for GuestService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -165,6 +230,11 @@ var GuestService_ServiceDesc = grpc.ServiceDesc{ Handler: _GuestService_GetEvents_Handler, ServerStreams: true, }, + { + StreamName: "PostInotify", + Handler: _GuestService_PostInotify_Handler, + ClientStreams: true, + }, }, Metadata: "guestservice.proto", } diff --git a/pkg/guestagent/api/server/server.go b/pkg/guestagent/api/server/server.go index 8c2a778b537..93acc55b2b2 100644 --- a/pkg/guestagent/api/server/server.go +++ b/pkg/guestagent/api/server/server.go @@ -36,3 +36,13 @@ func (s GuestServer) GetEvents(_ *emptypb.Empty, stream api.GuestService_GetEven } return nil } + +func (s GuestServer) PostInotify(server api.GuestService_PostInotifyServer) error { + for { + recv, err := server.Recv() + if err != nil { + return err + } + s.Agent.HandleInotify(recv) + } +} diff --git a/pkg/guestagent/guestagent.go b/pkg/guestagent/guestagent.go index 53cb853858a..b84e29fe537 100644 --- a/pkg/guestagent/guestagent.go +++ b/pkg/guestagent/guestagent.go @@ -10,4 +10,5 @@ type Agent interface { Info(ctx context.Context) (*api.Info, error) Events(ctx context.Context, ch chan *api.Event) LocalPorts(ctx context.Context) ([]*api.IPPort, error) + HandleInotify(event *api.Inotify) } diff --git a/pkg/guestagent/guestagent_linux.go b/pkg/guestagent/guestagent_linux.go index 06764b533a0..2aed509583a 100644 --- a/pkg/guestagent/guestagent_linux.go +++ b/pkg/guestagent/guestagent_linux.go @@ -3,6 +3,7 @@ package guestagent import ( "context" "errors" + "os" "reflect" "sync" "syscall" @@ -332,3 +333,14 @@ func (a *agent) fixSystemTimeSkew() { ticker.Stop() } } + +func (a *agent) HandleInotify(event *api.Inotify) { + location := event.MountPath + if _, err := os.Stat(location); err == nil { + local := event.Time.AsTime().Local() + err := os.Chtimes(location, local, local) + if err != nil { + logrus.Errorf("error in inotify handle. Event: %s, Error: %s", event, err) + } + } +} diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 27e5160eea3..948762dfd31 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -588,6 +588,21 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { } return errors.Join(errs...) }) + + go func() { + if a.y.MountInotify != nil && *a.y.MountInotify { + if a.client == nil || !isGuestAgentSocketAccessible(ctx, a.client) { + if a.driver.ForwardGuestAgent() { + _ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false) + } + } + err := a.startInotify(ctx) + if err != nil { + logrus.WithError(err).Warn("failed to start inotify", err) + } + } + }() + for { if a.client == nil || !isGuestAgentSocketAccessible(ctx, a.client) { if a.driver.ForwardGuestAgent() { @@ -602,7 +617,7 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { } } } else { - if !errors.Is(err, context.Canceled) { + if !strings.Contains(err.Error(), context.Canceled.Error()) { logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly") } } diff --git a/pkg/hostagent/inotify.go b/pkg/hostagent/inotify.go new file mode 100644 index 00000000000..2cc2866ac7f --- /dev/null +++ b/pkg/hostagent/inotify.go @@ -0,0 +1,111 @@ +package hostagent + +import ( + "context" + "os" + "path" + "path/filepath" + "strings" + + guestagentapi "github.com/lima-vm/lima/pkg/guestagent/api" + "github.com/lima-vm/lima/pkg/localpathutil" + "github.com/rjeczalik/notify" + "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const CacheSize = 10000 + +var ( + inotifyCache = make(map[string]int64) + mountSymlinks = make(map[string]string) +) + +func (a *HostAgent) startInotify(ctx context.Context) error { + mountWatchCh := make(chan notify.EventInfo, 128) + err := a.setupWatchers(mountWatchCh) + if err != nil { + return err + } + client, err := a.getOrCreateClient(ctx) + if err != nil { + logrus.WithError(err).Error("failed to create client for inotify") + } + inotifyClient, err := client.Inotify(ctx) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return nil + case watchEvent := <-mountWatchCh: + watchPath := watchEvent.Path() + stat, err := os.Stat(watchPath) + if err != nil { + continue + } + + if filterEvents(watchEvent, stat) { + continue + } + + for k, v := range mountSymlinks { + if strings.HasPrefix(watchPath, k) { + watchPath = strings.ReplaceAll(watchPath, k, v) + } + } + utcTimestamp := timestamppb.New(stat.ModTime().UTC()) + event := &guestagentapi.Inotify{MountPath: watchPath, Time: utcTimestamp} + err = inotifyClient.Send(event) + if err != nil { + logrus.WithError(err).Warn("failed to send inotify") + } + } + } +} + +func (a *HostAgent) setupWatchers(events chan notify.EventInfo) error { + for _, m := range a.y.Mounts { + if !*m.Writable { + continue + } + location, err := localpathutil.Expand(m.Location) + if err != nil { + return err + } + symlink, err := filepath.EvalSymlinks(location) + if err != nil { + return err + } + if location != symlink { + mountSymlinks[symlink] = location + } + + logrus.Infof("enable inotify for writable mount: %s", location) + err = notify.Watch(path.Join(location, "..."), events, GetNotifyEvent()) + if err != nil { + return err + } + } + return nil +} + +func filterEvents(event notify.EventInfo, stat os.FileInfo) bool { + currTime := stat.ModTime() + eventPath := event.Path() + cacheMilli, ok := inotifyCache[eventPath] + if ok { + // Ignore repeated events for 10ms to exclude recursive inotify events + if currTime.UnixMilli()-cacheMilli < 10 { + return true + } + } + inotifyCache[eventPath] = currTime.UnixMilli() + + if len(inotifyCache) >= CacheSize { + inotifyCache = make(map[string]int64) + } + return false +} diff --git a/pkg/hostagent/inotify_darwin.go b/pkg/hostagent/inotify_darwin.go new file mode 100644 index 00000000000..77a1a675daf --- /dev/null +++ b/pkg/hostagent/inotify_darwin.go @@ -0,0 +1,7 @@ +package hostagent + +import "github.com/rjeczalik/notify" + +func GetNotifyEvent() notify.Event { + return notify.Create | notify.Write | notify.FSEventsInodeMetaMod +} diff --git a/pkg/hostagent/inotify_linux.go b/pkg/hostagent/inotify_linux.go new file mode 100644 index 00000000000..dca99f4fede --- /dev/null +++ b/pkg/hostagent/inotify_linux.go @@ -0,0 +1,7 @@ +package hostagent + +import "github.com/rjeczalik/notify" + +func GetNotifyEvent() notify.Event { + return notify.Create | notify.Write | notify.InAttrib +} diff --git a/pkg/hostagent/inotify_others.go b/pkg/hostagent/inotify_others.go new file mode 100644 index 00000000000..c954b328cdf --- /dev/null +++ b/pkg/hostagent/inotify_others.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux + +package hostagent + +import "github.com/rjeczalik/notify" + +func GetNotifyEvent() notify.Event { + return notify.Create | notify.Write +} diff --git a/pkg/httpclientutil/httpclientutil.go b/pkg/httpclientutil/httpclientutil.go index 9c2f9e1dfc5..9aec62c4329 100644 --- a/pkg/httpclientutil/httpclientutil.go +++ b/pkg/httpclientutil/httpclientutil.go @@ -32,6 +32,22 @@ func Get(ctx context.Context, c *http.Client, url string) (*http.Response, error return resp, nil } +func Post(ctx context.Context, c *http.Client, url string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "POST", url, body) + if err != nil { + return nil, err + } + resp, err := c.Do(req) + if err != nil { + return nil, err + } + if err := Successful(resp); err != nil { + resp.Body.Close() + return nil, err + } + return resp, nil +} + func readAtMost(r io.Reader, maxBytes int) ([]byte, error) { lr := &io.LimitedReader{ R: r, @@ -85,13 +101,13 @@ func Successful(resp *http.Response) error { return nil } -// NewHTTPClientWithConn creates a client. +// NewHTTPClientWithDialFn creates a client. // conn is a raw net.Conn instance. -func NewHTTPClientWithConn(conn net.Conn) (*http.Client, error) { +func NewHTTPClientWithDialFn(dialFn func(ctx context.Context) (net.Conn, error)) (*http.Client, error) { hc := &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return conn, nil + return dialFn(ctx) }, }, } diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go index c2b3decbf5a..4ebccc4e7de 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -579,6 +579,16 @@ func FillDefault(y, d, o *LimaYAML, filePath string) { } } + if y.MountInotify == nil { + y.MountInotify = d.MountInotify + } + if o.MountInotify != nil { + y.MountInotify = o.MountInotify + } + if y.MountInotify == nil { + y.MountInotify = ptr.Of(false) + } + // Combine all mounts; highest priority entry determines writable status. // Only works for exact matches; does not normalize case or resolve symlinks. mounts := make([]Mount, 0, len(d.Mounts)+len(y.Mounts)+len(o.Mounts)) diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go index 2a54b22d96f..b9a0da980ce 100644 --- a/pkg/limayaml/defaults_test.go +++ b/pkg/limayaml/defaults_test.go @@ -226,6 +226,8 @@ func TestFillDefault(t *testing.T) { expect.MountType = ptr.Of(NINEP) + expect.MountInotify = ptr.Of(false) + expect.Provision = y.Provision expect.Provision[0].Mode = ProvisionModeSystem @@ -426,6 +428,7 @@ func TestFillDefault(t *testing.T) { "default": d.HostResolver.Hosts["default"], } expect.MountType = ptr.Of(VIRTIOFS) + expect.MountInotify = ptr.Of(false) expect.CACertificates.RemoveDefaults = ptr.Of(true) expect.CACertificates.Certs = []string{ "-----BEGIN CERTIFICATE-----\nYOUR-ORGS-TRUSTED-CA-CERT\n-----END CERTIFICATE-----\n", @@ -559,6 +562,7 @@ func TestFillDefault(t *testing.T) { }, }, }, + MountInotify: ptr.Of(true), Provision: []Provision{ { Script: "#!/bin/true", @@ -636,6 +640,7 @@ func TestFillDefault(t *testing.T) { expect.Mounts[0].Virtiofs.QueueSize = ptr.Of(2048) expect.MountType = ptr.Of(NINEP) + expect.MountInotify = ptr.Of(true) // o.Networks[1] is overriding the d.Networks[0].Lima entry for the "def0" interface expect.Networks = append(append(d.Networks, y.Networks...), o.Networks[0]) diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go index 38ce1e5a408..189175aee4f 100644 --- a/pkg/limayaml/limayaml.go +++ b/pkg/limayaml/limayaml.go @@ -18,6 +18,7 @@ type LimaYAML struct { AdditionalDisks []Disk `yaml:"additionalDisks,omitempty" json:"additionalDisks,omitempty"` Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"` MountType *MountType `yaml:"mountType,omitempty" json:"mountType,omitempty"` + MountInotify *bool `yaml:"mountInotify,omitempty" json:"mountInotify,omitempty"` SSH SSH `yaml:"ssh,omitempty" json:"ssh,omitempty"` // REQUIRED (FIXME) Firmware Firmware `yaml:"firmware,omitempty" json:"firmware,omitempty"` Audio Audio `yaml:"audio,omitempty" json:"audio,omitempty"` diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go index d4faf4ccdc7..d0a76ff1250 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -474,4 +474,7 @@ func warnExperimental(y LimaYAML) { if y.Audio.Device != nil && *y.Audio.Device != "" { logrus.Warn("`audio.device` is experimental") } + if y.MountInotify != nil && *y.MountInotify { + logrus.Warn("`mountInotify` is experimental") + } } diff --git a/pkg/vz/vz_driver_darwin.go b/pkg/vz/vz_driver_darwin.go index 2ebc9a9d225..a92fe86744a 100644 --- a/pkg/vz/vz_driver_darwin.go +++ b/pkg/vz/vz_driver_darwin.go @@ -39,6 +39,7 @@ var knownYamlProperties = []string{ "Message", "Mounts", "MountType", + "MountInotify", "Networks", "OS", "Plain", diff --git a/website/content/en/docs/Config/Mount/_index.md b/website/content/en/docs/Config/Mount/_index.md index 32df9779ea9..33154bbec73 100644 --- a/website/content/en/docs/Config/Mount/_index.md +++ b/website/content/en/docs/Config/Mount/_index.md @@ -165,3 +165,39 @@ mountType: "wsl2" #### Caveats - WSL2 file permissions may not work exactly as expected when accessing files that are natively on the Windows disk ([more info](https://github.com/MicrosoftDocs/WSL/blob/mattw-wsl2-explainer/WSL/file-permissions.md)) - WSL2's disk sharing system uses a 9P protocol server, making the performance similar to [Lima's 9p](#9p) mode ([more info](https://github.com/MicrosoftDocs/WSL/blob/mattw-wsl2-explainer/WSL/wsl2-architecture.md#wsl-2-architectural-flow)) + +## Mount Inotify +> **Warning** +> "mountInotify" is experimental + +| âš¡ Requirement | Lima >= 0.21.0 | +| ----------------- |----------------| + +The `mountInotify` support enables inotify support for all different mountTypes like 9p, virtiofs etc. + +When mountInotify is enabled, +- hostagent will listen and send inotify events from host machine to guest. +- Guest will modify the file to trigger inotify on guest side + +This support will be enabled only for writable mounts because only for writable mount guest will be able to trigger inotify + +An example configuration: +{{< tabpane text=true >}} +{{% tab header="CLI" %}} +```bash +limactl start --mount-inotify +``` +{{% /tab %}} +{{% tab header="YAML" %}} +```yaml +mountInotify: true +mounts: + - location: "~" + writable: true +``` +{{% /tab %}} +{{< /tabpane >}} + +#### Caveats +- For `mountType: 9p`, Inotify events are not triggered for nested files from the listening directory. +- Inotify events are not triggered when files are removed from host \ No newline at end of file diff --git a/website/content/en/docs/Releases/Experimental/_index.md b/website/content/en/docs/Releases/Experimental/_index.md index 09bb9e527f1..564208f6a62 100644 --- a/website/content/en/docs/Releases/Experimental/_index.md +++ b/website/content/en/docs/Releases/Experimental/_index.md @@ -15,6 +15,7 @@ The following features are experimental and subject to change: - `mode: user-v2` in `networks.yml` and relevant configuration in `lima.yaml` - `audio.device` - `arch: armv7l` +- `mountInotify: true` The following commands are experimental and subject to change: