Skip to content

Commit 5359c05

Browse files
committed
Support for inotify in mounted directories
Signed-off-by: Balaji Vijayakumar <[email protected]>
1 parent f9ea34c commit 5359c05

File tree

20 files changed

+235
-12
lines changed

20 files changed

+235
-12
lines changed

cmd/limactl/editflags/editflags.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func registerEdit(cmd *cobra.Command, commentPrefix string) {
4949
})
5050

5151
flags.Bool("mount-writable", false, commentPrefix+"make all mounts writable")
52+
flags.Bool("mount-inotify", false, commentPrefix+"enable inotify for mounts")
5253

5354
flags.StringSlice("network", nil, commentPrefix+"additional networks, e.g., \"vzNAT\" or \"lima:shared\" to assign vmnet IP")
5455
_ = 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) {
154155
false,
155156
},
156157
{"mount-type", d(".mountType = %q"), false, false},
158+
{"mount-inotify", d(".mountInotify = %s"), false, true},
157159
{"mount-writable", d(".mounts[].writable = %s"), false, false},
158160
{
159161
"network",

examples/default.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ mounts:
103103
# 🟢 Builtin default: "reverse-sshfs" (for QEMU), "virtiofs" (for vz)
104104
mountType: null
105105

106+
# Enable inotify support for mounted directories (EXPERIMENTAL)
107+
# 🟢 Builtin default: Disabled by default
108+
mountInotify: null
109+
110+
106111
# Lima disks to attach to the instance. The disks will be accessible from inside the
107112
# instance, labeled by name. (e.g. if the disk is named "data", it will be labeled
108113
# "lima-data" inside the instance). The disk will be mounted inside the instance at

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ require (
3333
github.com/nxadm/tail v1.4.11
3434
github.com/opencontainers/go-digest v1.0.0
3535
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58
36+
github.com/rjeczalik/notify v0.9.3
3637
github.com/sethvargo/go-password v0.2.0
3738
github.com/sirupsen/logrus v1.9.3
3839
github.com/spf13/cobra v1.8.0

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
221221
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
222222
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
223223
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
224+
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
225+
github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc=
224226
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
225227
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
226228
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -305,6 +307,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
305307
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
306308
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
307309
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
310+
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
308311
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
309312
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
310313
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

pkg/guestagent/api/api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ type Event struct {
3232
LocalPortsRemoved []IPPort `json:"localPortsRemoved,omitempty"`
3333
Errors []string `json:"errors,omitempty"`
3434
}
35+
36+
type InotifyEvent struct {
37+
Location string `json:"location,omitempty"`
38+
Time time.Time `json:"time,omitempty"`
39+
}

pkg/guestagent/api/client/client.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package client
44
// Apache License 2.0
55

66
import (
7+
"bytes"
78
"context"
89
"encoding/json"
910
"fmt"
@@ -18,12 +19,13 @@ type GuestAgentClient interface {
1819
HTTPClient() *http.Client
1920
Info(context.Context) (*api.Info, error)
2021
Events(context.Context, func(api.Event)) error
22+
Inotify(context.Context, api.InotifyEvent) error
2123
}
2224

2325
// NewGuestAgentClient creates a client.
2426
// remote is a path to the UNIX socket, without unix:// prefix or a remote hostname/IP address.
25-
func NewGuestAgentClient(conn net.Conn) (GuestAgentClient, error) {
26-
hc, err := httpclientutil.NewHTTPClientWithConn(conn)
27+
func NewGuestAgentClient(dialFn func(ctx context.Context) (net.Conn, error)) (GuestAgentClient, error) {
28+
hc, err := httpclientutil.NewHTTPClientWithDialFn(dialFn)
2729
if err != nil {
2830
return nil, err
2931
}
@@ -81,3 +83,20 @@ func (c *client) Events(ctx context.Context, onEvent func(api.Event)) error {
8183
onEvent(ev)
8284
}
8385
}
86+
87+
func (c *client) Inotify(ctx context.Context, event api.InotifyEvent) error {
88+
buffer := &bytes.Buffer{}
89+
encoder := json.NewEncoder(buffer)
90+
err := encoder.Encode(&event)
91+
if err != nil {
92+
return err
93+
}
94+
95+
u := fmt.Sprintf("http://%s/%s/inotify", c.dummyHost, c.version)
96+
resp, err := httpclientutil.Post(ctx, c.HTTPClient(), u, buffer)
97+
if err != nil {
98+
return err
99+
}
100+
defer resp.Body.Close()
101+
return nil
102+
}

pkg/guestagent/api/server/server.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,28 @@ func (b *Backend) GetEvents(w http.ResponseWriter, r *http.Request) {
7676
}
7777
}
7878

79+
// PostInotify is the handler for POST /v{N}/inotify.
80+
func (b *Backend) PostInotify(w http.ResponseWriter, r *http.Request) {
81+
ctx := r.Context()
82+
_, cancel := context.WithCancel(ctx)
83+
defer cancel()
84+
85+
inotifyEvent := api.InotifyEvent{}
86+
decoder := json.NewDecoder(r.Body)
87+
if err := decoder.Decode(&inotifyEvent); err != nil {
88+
logrus.Warn(err)
89+
return
90+
}
91+
go b.Agent.HandleInotify(inotifyEvent)
92+
93+
w.Header().Set("Content-Type", "application/json")
94+
w.WriteHeader(http.StatusOK)
95+
_, _ = w.Write([]byte(""))
96+
}
97+
7998
func AddRoutes(r *mux.Router, b *Backend) {
8099
v1 := r.PathPrefix("/v1").Subrouter()
81100
v1.Path("/info").Methods("GET").HandlerFunc(b.GetInfo)
82101
v1.Path("/events").Methods("GET").HandlerFunc(b.GetEvents)
102+
v1.Path("/inotify").Methods("POST").HandlerFunc(b.PostInotify)
83103
}

pkg/guestagent/guestagent.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ type Agent interface {
1010
Info(ctx context.Context) (*api.Info, error)
1111
Events(ctx context.Context, ch chan api.Event)
1212
LocalPorts(ctx context.Context) ([]api.IPPort, error)
13+
HandleInotify(event api.InotifyEvent)
1314
}

pkg/guestagent/guestagent_linux.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package guestagent
33
import (
44
"context"
55
"errors"
6+
"os"
67
"reflect"
78
"sync"
89
"syscall"
@@ -333,3 +334,13 @@ func (a *agent) fixSystemTimeSkew() {
333334
ticker.Stop()
334335
}
335336
}
337+
338+
func (a *agent) HandleInotify(event api.InotifyEvent) {
339+
location := event.Location
340+
if _, err := os.Stat(location); err == nil {
341+
err := os.Chtimes(location, event.Time.Local(), event.Time.Local())
342+
if err != nil {
343+
logrus.Errorf("error in inotify handle. Event: %s, Error: %s", event, err)
344+
}
345+
}
346+
}

pkg/hostagent/hostagent.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,16 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
555555
}
556556
return errors.Join(errs...)
557557
})
558+
559+
go func() {
560+
if a.y.MountInotify != nil && *a.y.MountInotify {
561+
err := a.startInotify(ctx)
562+
if err != nil {
563+
logrus.WithError(err).Warn("failed to start inotify", err)
564+
}
565+
}
566+
}()
567+
558568
for {
559569
client, err := a.getOrCreateClient(ctx)
560570
if err == nil {
@@ -590,13 +600,8 @@ func (a *HostAgent) getOrCreateClient(ctx context.Context) (guestagentclient.Gue
590600
return a.client, err
591601
}
592602

593-
func (a *HostAgent) createClient(ctx context.Context) (guestagentclient.GuestAgentClient, error) {
594-
conn, err := a.driver.GuestAgentConn(ctx)
595-
if err != nil {
596-
return nil, err
597-
}
598-
599-
return guestagentclient.NewGuestAgentClient(conn)
603+
func (a *HostAgent) createClient(_ context.Context) (guestagentclient.GuestAgentClient, error) {
604+
return guestagentclient.NewGuestAgentClient(a.driver.GuestAgentConn)
600605
}
601606

602607
func (a *HostAgent) processGuestAgentEvents(ctx context.Context, client guestagentclient.GuestAgentClient) error {

pkg/hostagent/inotify.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package hostagent
2+
3+
import (
4+
"context"
5+
"os"
6+
"path"
7+
8+
guestagentapi "github.com/lima-vm/lima/pkg/guestagent/api"
9+
"github.com/lima-vm/lima/pkg/localpathutil"
10+
"github.com/rjeczalik/notify"
11+
"github.com/sirupsen/logrus"
12+
)
13+
14+
const CacheSize = 10000
15+
16+
var inotifyCache = make(map[string]string)
17+
18+
func (a *HostAgent) startInotify(ctx context.Context) error {
19+
mountWatchCh := make(chan notify.EventInfo, 128)
20+
err := a.setupWatchers(mountWatchCh)
21+
if err != nil {
22+
return err
23+
}
24+
25+
for {
26+
select {
27+
case <-ctx.Done():
28+
return nil
29+
case watchEvent := <-mountWatchCh:
30+
client, err := a.getOrCreateClient(ctx)
31+
if err != nil {
32+
logrus.Error("failed to create client for inotify", err)
33+
}
34+
stat, err := os.Stat(watchEvent.Path())
35+
if err != nil {
36+
continue
37+
}
38+
39+
if filterEvents(watchEvent) {
40+
continue
41+
}
42+
43+
event := guestagentapi.InotifyEvent{Location: watchEvent.Path(), Time: stat.ModTime().UTC()}
44+
err = client.Inotify(ctx, event)
45+
46+
if err != nil {
47+
logrus.WithError(err).Warn("failed to send inotify", err)
48+
}
49+
}
50+
}
51+
}
52+
53+
func (a *HostAgent) setupWatchers(events chan notify.EventInfo) error {
54+
for _, m := range a.y.Mounts {
55+
if *m.Writable {
56+
location, err := localpathutil.Expand(m.Location)
57+
if err != nil {
58+
return err
59+
}
60+
logrus.Infof("enable inotify for writable mount: %s", location)
61+
err = notify.Watch(path.Join(location, "..."), events, notify.Create|notify.Write)
62+
if err != nil {
63+
return err
64+
}
65+
}
66+
}
67+
return nil
68+
}
69+
70+
func filterEvents(event notify.EventInfo) bool {
71+
eventPath := event.Path()
72+
_, ok := inotifyCache[eventPath]
73+
if ok {
74+
// Ignore the duplicate inotify on mounted directories, so always remove a entry if already present
75+
delete(inotifyCache, eventPath)
76+
return true
77+
}
78+
inotifyCache[eventPath] = ""
79+
80+
if len(inotifyCache) >= CacheSize {
81+
inotifyCache = make(map[string]string)
82+
}
83+
return false
84+
}

pkg/httpclientutil/httpclientutil.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,22 @@ func Get(ctx context.Context, c *http.Client, url string) (*http.Response, error
3232
return resp, nil
3333
}
3434

35+
func Post(ctx context.Context, c *http.Client, url string, body io.Reader) (*http.Response, error) {
36+
req, err := http.NewRequestWithContext(ctx, "POST", url, body)
37+
if err != nil {
38+
return nil, err
39+
}
40+
resp, err := c.Do(req)
41+
if err != nil {
42+
return nil, err
43+
}
44+
if err := Successful(resp); err != nil {
45+
resp.Body.Close()
46+
return nil, err
47+
}
48+
return resp, nil
49+
}
50+
3551
func readAtMost(r io.Reader, maxBytes int) ([]byte, error) {
3652
lr := &io.LimitedReader{
3753
R: r,
@@ -85,13 +101,13 @@ func Successful(resp *http.Response) error {
85101
return nil
86102
}
87103

88-
// NewHTTPClientWithConn creates a client.
104+
// NewHTTPClientWithDialFn creates a client.
89105
// conn is a raw net.Conn instance.
90-
func NewHTTPClientWithConn(conn net.Conn) (*http.Client, error) {
106+
func NewHTTPClientWithDialFn(dialFn func(ctx context.Context) (net.Conn, error)) (*http.Client, error) {
91107
hc := &http.Client{
92108
Transport: &http.Transport{
93109
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
94-
return conn, nil
110+
return dialFn(ctx)
95111
},
96112
},
97113
}

pkg/limayaml/defaults.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,16 @@ func FillDefault(y, d, o *LimaYAML, filePath string) {
525525
}
526526
}
527527

528+
if y.MountInotify == nil {
529+
y.MountInotify = d.MountInotify
530+
}
531+
if o.MountInotify != nil {
532+
y.MountInotify = o.MountInotify
533+
}
534+
if y.MountInotify == nil {
535+
y.MountInotify = ptr.Of(false)
536+
}
537+
528538
// Combine all mounts; highest priority entry determines writable status.
529539
// Only works for exact matches; does not normalize case or resolve symlinks.
530540
mounts := make([]Mount, 0, len(d.Mounts)+len(y.Mounts)+len(o.Mounts))

pkg/limayaml/defaults_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ func TestFillDefault(t *testing.T) {
203203

204204
expect.MountType = ptr.Of(NINEP)
205205

206+
expect.MountInotify = ptr.Of(false)
207+
206208
expect.Provision = y.Provision
207209
expect.Provision[0].Mode = ProvisionModeSystem
208210

@@ -390,6 +392,7 @@ func TestFillDefault(t *testing.T) {
390392
"default": d.HostResolver.Hosts["default"],
391393
}
392394
expect.MountType = ptr.Of(VIRTIOFS)
395+
expect.MountInotify = ptr.Of(false)
393396
expect.CACertificates.RemoveDefaults = ptr.Of(true)
394397
expect.CACertificates.Certs = []string{
395398
"-----BEGIN CERTIFICATE-----\nYOUR-ORGS-TRUSTED-CA-CERT\n-----END CERTIFICATE-----\n",
@@ -520,6 +523,7 @@ func TestFillDefault(t *testing.T) {
520523
},
521524
},
522525
},
526+
MountInotify: ptr.Of(true),
523527
Provision: []Provision{
524528
{
525529
Script: "#!/bin/true",
@@ -596,6 +600,7 @@ func TestFillDefault(t *testing.T) {
596600
expect.Mounts[0].Virtiofs.QueueSize = ptr.Of(2048)
597601

598602
expect.MountType = ptr.Of(NINEP)
603+
expect.MountInotify = ptr.Of(true)
599604

600605
// o.Networks[1] is overriding the d.Networks[0].Lima entry for the "def0" interface
601606
expect.Networks = append(append(d.Networks, y.Networks...), o.Networks[0])

pkg/limayaml/limayaml.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type LimaYAML struct {
1818
AdditionalDisks []Disk `yaml:"additionalDisks,omitempty" json:"additionalDisks,omitempty"`
1919
Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"`
2020
MountType *MountType `yaml:"mountType,omitempty" json:"mountType,omitempty"`
21+
MountInotify *bool `yaml:"mountInotify,omitempty" json:"mountInotify,omitempty"`
2122
SSH SSH `yaml:"ssh,omitempty" json:"ssh,omitempty"` // REQUIRED (FIXME)
2223
Firmware Firmware `yaml:"firmware,omitempty" json:"firmware,omitempty"`
2324
Audio Audio `yaml:"audio,omitempty" json:"audio,omitempty"`

pkg/limayaml/validate.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,4 +471,7 @@ func warnExperimental(y LimaYAML) {
471471
if y.Audio.Device != nil && *y.Audio.Device != "" {
472472
logrus.Warn("`audio.device` is experimental")
473473
}
474+
if y.MountInotify != nil && *y.MountInotify {
475+
logrus.Warn("`mountInotify` is experimental")
476+
}
474477
}

pkg/vz/vz_driver_darwin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ func (l *LimaVzDriver) Validate() error {
5454
"Disk",
5555
"Mounts",
5656
"MountType",
57+
"MountInotify",
5758
"SSH",
5859
"Firmware",
5960
"Provision",

0 commit comments

Comments
 (0)