Skip to content

Commit 61eea4f

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

File tree

20 files changed

+288
-38
lines changed

20 files changed

+288
-38
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ jobs:
226226
fetch-depth: 1
227227
- uses: actions/setup-go@v4
228228
with:
229-
go-version: 1.20.x
229+
go-version: 1.21.x
230230
- uses: actions/cache@v3
231231
with:
232232
path: ~/.cache/lima/download
@@ -270,7 +270,7 @@ jobs:
270270
fetch-depth: 1
271271
- uses: actions/setup-go@v4
272272
with:
273-
go-version: 1.20.x
273+
go-version: 1.21.x
274274
- uses: actions/cache@v3
275275
with:
276276
path: ~/.cache/lima/download

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.7.0

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
222222
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
223223
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
224224
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
225+
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
226+
github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc=
225227
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
226228
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
227229
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -306,6 +308,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
306308
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
307309
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
308310
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
311+
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
309312
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
310313
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
311314
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: 19 additions & 0 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"
@@ -19,6 +20,7 @@ type GuestAgentClient interface {
1920
HTTPClient() *http.Client
2021
Info(context.Context) (*api.Info, error)
2122
Events(context.Context, func(api.Event)) error
23+
Inotify(context.Context, api.InotifyEvent) error
2224
}
2325

2426
type Proto = string
@@ -108,3 +110,20 @@ func (c *client) Events(ctx context.Context, onEvent func(api.Event)) error {
108110
onEvent(ev)
109111
}
110112
}
113+
114+
func (c *client) Inotify(ctx context.Context, event api.InotifyEvent) error {
115+
buffer := &bytes.Buffer{}
116+
encoder := json.NewEncoder(buffer)
117+
err := encoder.Encode(&event)
118+
if err != nil {
119+
return err
120+
}
121+
122+
u := fmt.Sprintf("http://%s/%s/inotify", c.dummyHost, c.version)
123+
resp, err := httpclientutil.Post(ctx, c.HTTPClient(), u, buffer)
124+
if err != nil {
125+
return err
126+
}
127+
defer resp.Body.Close()
128+
return nil
129+
}

pkg/guestagent/api/server/server.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,33 @@ 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+
flusher, ok := w.(http.Flusher)
94+
if !ok {
95+
panic("http.ResponseWriter has to implement http.Flusher")
96+
}
97+
98+
w.Header().Set("Content-Type", "application/x-ndjson")
99+
w.WriteHeader(http.StatusOK)
100+
flusher.Flush()
101+
}
102+
79103
func AddRoutes(r *mux.Router, b *Backend) {
80104
v1 := r.PathPrefix("/v1").Subrouter()
81105
v1.Path("/info").Methods("GET").HandlerFunc(b.GetInfo)
82106
v1.Path("/events").Methods("GET").HandlerFunc(b.GetEvents)
107+
v1.Path("/inotify").Methods("POST").HandlerFunc(b.PostInotify)
83108
}

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: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ type HostAgent struct {
5555
eventEncMu sync.Mutex
5656

5757
vSockPort int
58+
59+
clientMu sync.RWMutex
60+
client guestagentclient.GuestAgentClient
5861
}
5962

6063
type options struct {
@@ -542,39 +545,43 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
542545
}
543546
}
544547

545-
localUnix := filepath.Join(a.instDir, filenames.GuestAgentSock)
546-
remoteUnix := "/run/lima-guestagent.sock"
548+
local, remote := a.localAndRemoteGuestAgentPaths()
547549

548-
a.onClose = append(a.onClose, func() error {
549-
logrus.Debugf("Stop forwarding unix sockets")
550-
var errs []error
551-
for _, rule := range a.y.PortForwards {
552-
if rule.GuestSocket != "" {
553-
local := hostAddress(rule, guestagentapi.IPPort{})
554-
// using ctx.Background() because ctx has already been cancelled
555-
if err := forwardSSH(context.Background(), a.sshConfig, a.sshLocalPort, local, rule.GuestSocket, verbCancel, rule.Reverse); err != nil {
556-
errs = append(errs, err)
550+
if a.guestAgentProto != guestagentclient.VSOCK {
551+
a.onClose = append(a.onClose, func() error {
552+
logrus.Debugf("Stop forwarding unix sockets")
553+
var errs []error
554+
for _, rule := range a.y.PortForwards {
555+
if rule.GuestSocket != "" {
556+
local := hostAddress(rule, guestagentapi.IPPort{})
557+
// using ctx.Background() because ctx has already been cancelled
558+
if err := forwardSSH(context.Background(), a.sshConfig, a.sshLocalPort, local, rule.GuestSocket, verbCancel, rule.Reverse); err != nil {
559+
errs = append(errs, err)
560+
}
557561
}
558562
}
559-
}
560-
if err := forwardSSH(context.Background(), a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbCancel, false); err != nil {
561-
errs = append(errs, err)
562-
}
563-
return errors.Join(errs...)
564-
})
565-
566-
guestSocketAddr := localUnix
567-
if a.guestAgentProto == guestagentclient.VSOCK {
568-
guestSocketAddr = fmt.Sprintf("0.0.0.0:%d", a.vSockPort)
563+
if err := forwardSSH(context.Background(), a.sshConfig, a.sshLocalPort, local, remote, verbCancel, false); err != nil {
564+
errs = append(errs, err)
565+
}
566+
return errors.Join(errs...)
567+
})
569568
}
570569

571-
for {
572-
if !isGuestAgentSocketAccessible(ctx, guestSocketAddr, a.guestAgentProto, a.instName) {
573-
if a.guestAgentProto != guestagentclient.VSOCK {
574-
_ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false)
570+
go func() {
571+
if a.y.MountInotify != nil && *a.y.MountInotify {
572+
err := a.startInotify(ctx)
573+
if err != nil {
574+
logrus.WithError(err).Warn("failed to start inotify", err)
575575
}
576576
}
577-
if err := a.processGuestAgentEvents(ctx, guestSocketAddr, a.guestAgentProto, a.instName); err != nil {
577+
}()
578+
579+
for {
580+
client, err := a.getOrCreateClient(ctx)
581+
if err != nil && !errors.Is(err, context.Canceled) {
582+
logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly")
583+
}
584+
if err := a.processGuestAgentEvents(ctx, client); err != nil {
578585
if !errors.Is(err, context.Canceled) {
579586
logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly")
580587
}
@@ -587,21 +594,43 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
587594
}
588595
}
589596

590-
func isGuestAgentSocketAccessible(ctx context.Context, localUnix string, proto guestagentclient.Proto, instanceName string) bool {
591-
client, err := guestagentclient.NewGuestAgentClient(localUnix, proto, instanceName)
592-
if err != nil {
593-
return false
597+
func (a *HostAgent) getOrCreateClient(ctx context.Context) (guestagentclient.GuestAgentClient, error) {
598+
a.clientMu.Lock()
599+
defer a.clientMu.Unlock()
600+
if a.client != nil && isGuestAgentSocketAccessible(ctx, a.client) {
601+
return a.client, nil
594602
}
595-
_, err = client.Info(ctx)
596-
return err == nil
603+
var err error
604+
a.client, err = a.createClient(ctx)
605+
return a.client, err
597606
}
598607

599-
func (a *HostAgent) processGuestAgentEvents(ctx context.Context, localUnix string, proto guestagentclient.Proto, instanceName string) error {
600-
client, err := guestagentclient.NewGuestAgentClient(localUnix, proto, instanceName)
601-
if err != nil {
602-
return err
608+
func (a *HostAgent) createClient(ctx context.Context) (guestagentclient.GuestAgentClient, error) {
609+
local, remote := a.localAndRemoteGuestAgentPaths()
610+
if a.guestAgentProto != guestagentclient.VSOCK {
611+
_ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, local, remote, verbForward, false)
612+
}
613+
614+
return guestagentclient.NewGuestAgentClient(local, a.guestAgentProto, a.instName)
615+
}
616+
617+
func (a *HostAgent) localAndRemoteGuestAgentPaths() (string, string) {
618+
localUnix := filepath.Join(a.instDir, filenames.GuestAgentSock)
619+
remoteUnix := "/run/lima-guestagent.sock"
620+
621+
guestSocketAddr := localUnix
622+
if a.guestAgentProto == guestagentclient.VSOCK {
623+
guestSocketAddr = fmt.Sprintf("0.0.0.0:%d", a.vSockPort)
603624
}
625+
return guestSocketAddr, remoteUnix
626+
}
627+
628+
func isGuestAgentSocketAccessible(ctx context.Context, client guestagentclient.GuestAgentClient) bool {
629+
_, err := client.Info(ctx)
630+
return err == nil
631+
}
604632

633+
func (a *HostAgent) processGuestAgentEvents(ctx context.Context, client guestagentclient.GuestAgentClient) error {
605634
info, err := client.Info(ctx)
606635
if err != nil {
607636
return err

pkg/hostagent/inotify.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
if err != nil {
46+
logrus.WithError(err).Warn("failed to send inotify", err)
47+
}
48+
}
49+
}
50+
}
51+
52+
func (a *HostAgent) setupWatchers(events chan notify.EventInfo) error {
53+
for _, m := range a.y.Mounts {
54+
if *m.Writable {
55+
location, err := localpathutil.Expand(m.Location)
56+
if err != nil {
57+
return err
58+
}
59+
logrus.Infof("enable inotify for writable mount: %s", location)
60+
err = notify.Watch(path.Join(location, "..."), events, notify.Create|notify.Write)
61+
if err != nil {
62+
return err
63+
}
64+
}
65+
}
66+
return nil
67+
}
68+
69+
func filterEvents(event notify.EventInfo) bool {
70+
eventPath := event.Path()
71+
_, ok := inotifyCache[eventPath]
72+
if ok {
73+
// Ignore the duplicate inotify on mounted directories, so always remove a entry if already present
74+
delete(inotifyCache, eventPath)
75+
return true
76+
}
77+
inotifyCache[eventPath] = ""
78+
79+
if len(inotifyCache) >= CacheSize {
80+
inotifyCache = make(map[string]string)
81+
}
82+
return false
83+
}

0 commit comments

Comments
 (0)