diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03aa35e5eba0..9785d5b50395 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -216,7 +216,7 @@ jobs: fetch-depth: 1 - uses: actions/setup-go@v4 with: - go-version: 1.20.x + go-version: 1.21.x - uses: actions/cache@v3 with: path: ~/.cache/lima/download @@ -260,7 +260,7 @@ jobs: fetch-depth: 1 - uses: actions/setup-go@v4 with: - go-version: 1.20.x + go-version: 1.21.x - uses: actions/cache@v3 with: path: ~/.cache/lima/download diff --git a/go.mod b/go.mod index 24bc55d0e3c8..349a9fb5faab 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,7 @@ module github.com/lima-vm/lima -go 1.20 + +go 1.21 require ( github.com/AlecAivazis/survey/v2 v2.3.7 @@ -33,6 +34,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.7.0 diff --git a/go.sum b/go.sum index 2d56d41ca227..fb9012c0f014 100644 --- a/go.sum +++ b/go.sum @@ -220,6 +220,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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -302,6 +304,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +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/api.go b/pkg/guestagent/api/api.go index 02baa066fdd5..210bb074b54c 100644 --- a/pkg/guestagent/api/api.go +++ b/pkg/guestagent/api/api.go @@ -2,6 +2,7 @@ package api import ( "net" + "os" "strconv" "time" ) @@ -34,3 +35,8 @@ type Event struct { LocalPortsRemoved []IPPort `json:"localPortsRemoved,omitempty"` Errors []string `json:"errors,omitempty"` } + +type InotifyEvent struct { + Location string `json:"location,omitempty"` + Mode os.FileMode `json:"mode,omitempty"` +} diff --git a/pkg/guestagent/api/client/client.go b/pkg/guestagent/api/client/client.go index d58aaeefe194..318863b2f313 100644 --- a/pkg/guestagent/api/client/client.go +++ b/pkg/guestagent/api/client/client.go @@ -4,6 +4,7 @@ package client // Apache License 2.0 import ( + "bytes" "context" "encoding/json" "fmt" @@ -19,6 +20,7 @@ type GuestAgentClient interface { HTTPClient() *http.Client Info(context.Context) (*api.Info, error) Events(context.Context, func(api.Event)) error + Inotify(context.Context, api.InotifyEvent) error } type Proto = string @@ -108,3 +110,20 @@ func (c *client) Events(ctx context.Context, onEvent func(api.Event)) error { onEvent(ev) } } + +func (c *client) Inotify(ctx context.Context, event api.InotifyEvent) error { + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + err := encoder.Encode(&event) + if err != nil { + return err + } + + u := fmt.Sprintf("http://%s/%s/inotify", c.dummyHost, c.version) + resp, err := httpclientutil.Post(ctx, c.HTTPClient(), u, buffer) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} diff --git a/pkg/guestagent/api/server/server.go b/pkg/guestagent/api/server/server.go index 240fe7511fe3..27f73aa99f08 100644 --- a/pkg/guestagent/api/server/server.go +++ b/pkg/guestagent/api/server/server.go @@ -76,8 +76,33 @@ func (b *Backend) GetEvents(w http.ResponseWriter, r *http.Request) { } } +// ReceiveInotify is the handler for POST /v{N}/inotify. +func (b *Backend) ReceiveInotify(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + _, cancel := context.WithCancel(ctx) + defer cancel() + + inotifyEvent := api.InotifyEvent{} + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&inotifyEvent); err != nil { + logrus.Warn(err) + return + } + b.Agent.HandleInotify(inotifyEvent) + + flusher, ok := w.(http.Flusher) + if !ok { + panic("http.ResponseWriter has to implement http.Flusher") + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + flusher.Flush() +} + func AddRoutes(r *mux.Router, b *Backend) { v1 := r.PathPrefix("/v1").Subrouter() v1.Path("/info").Methods("GET").HandlerFunc(b.GetInfo) v1.Path("/events").Methods("GET").HandlerFunc(b.GetEvents) + v1.Path("/inotify").Methods("POST").HandlerFunc(b.ReceiveInotify) } diff --git a/pkg/guestagent/guestagent.go b/pkg/guestagent/guestagent.go index e84975fcf53f..a5daf06051de 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.InotifyEvent) } diff --git a/pkg/guestagent/guestagent_linux.go b/pkg/guestagent/guestagent_linux.go index 2d46b9eaf1bf..b7ae6e54e44f 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" @@ -333,3 +334,13 @@ func (a *agent) fixSystemTimeSkew() { ticker.Stop() } } + +func (a *agent) HandleInotify(event api.InotifyEvent) { + location := event.Location + if _, err := os.Stat(location); err == nil { + err := os.Chmod(location, event.Mode) + if err != nil { + logrus.Errorf("error in inotify handle. Event: %s, Error: %s", event, err) + } + } +} diff --git a/pkg/hostagent/events/test.txt b/pkg/hostagent/events/test.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index e2aef3dfb545..906997bfaffe 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -568,13 +568,19 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { guestSocketAddr = fmt.Sprintf("0.0.0.0:%d", a.vSockPort) } + go func() { + err := a.startInotify(ctx, guestSocketAddr, localUnix, remoteUnix) + if err != nil { + logrus.WithError(err).Warn("failed to start inotify") + } + }() + for { - if !isGuestAgentSocketAccessible(ctx, guestSocketAddr, a.guestAgentProto, a.instName) { - if a.guestAgentProto != guestagentclient.VSOCK { - _ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false) - } + client, err := a.createClient(ctx, guestSocketAddr, localUnix, remoteUnix) + if err != nil && !errors.Is(err, context.Canceled) { + logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly") } - if err := a.processGuestAgentEvents(ctx, guestSocketAddr, a.guestAgentProto, a.instName); err != nil { + if err := a.processGuestAgentEvents(ctx, client); err != nil { if !errors.Is(err, context.Canceled) { logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly") } @@ -587,6 +593,20 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { } } +func (a *HostAgent) createClient(ctx context.Context, guestSocketAddr string, localUnix string, remoteUnix string) (guestagentclient.GuestAgentClient, error) { + if !isGuestAgentSocketAccessible(ctx, guestSocketAddr, a.guestAgentProto, a.instName) { + if a.guestAgentProto != guestagentclient.VSOCK { + _ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false) + } + } + + client, err := guestagentclient.NewGuestAgentClient(localUnix, a.guestAgentProto, a.instName) + if err != nil { + return nil, err + } + return client, nil +} + func isGuestAgentSocketAccessible(ctx context.Context, localUnix string, proto guestagentclient.Proto, instanceName string) bool { client, err := guestagentclient.NewGuestAgentClient(localUnix, proto, instanceName) if err != nil { @@ -596,12 +616,7 @@ func isGuestAgentSocketAccessible(ctx context.Context, localUnix string, proto g return err == nil } -func (a *HostAgent) processGuestAgentEvents(ctx context.Context, localUnix string, proto guestagentclient.Proto, instanceName string) error { - client, err := guestagentclient.NewGuestAgentClient(localUnix, proto, instanceName) - if err != nil { - return err - } - +func (a *HostAgent) processGuestAgentEvents(ctx context.Context, client guestagentclient.GuestAgentClient) error { info, err := client.Info(ctx) if err != nil { return err diff --git a/pkg/hostagent/inotify.go b/pkg/hostagent/inotify.go new file mode 100644 index 000000000000..4013ff5cdd8a --- /dev/null +++ b/pkg/hostagent/inotify.go @@ -0,0 +1,88 @@ +package hostagent + +import ( + "context" + "os" + "path" + + guestagentapi "github.com/lima-vm/lima/pkg/guestagent/api" + "github.com/lima-vm/lima/pkg/localpathutil" + "github.com/rjeczalik/notify" + "github.com/sirupsen/logrus" +) + +const CacheSize = 10000 + +var ( + inotifyCache = make(map[string]string) +) + +func (a *HostAgent) startInotify(ctx context.Context, guestSocketAddr string, localUnix string, remoteUnix string) error { + mountWatchCh := make(chan notify.EventInfo) + err := a.setupWatchers(mountWatchCh) + + if err != nil { + return err + } + + for { + select { + + case <-ctx.Done(): + return nil + case watchEvent := <-mountWatchCh: + stat, err := os.Stat(watchEvent.Path()) + if err != nil { + logrus.Warn("ignore inotify event", watchEvent.Path()) + continue + } + + if filterEvents(watchEvent) { + logrus.Warn("ignore inotify event", watchEvent.Path()) + continue + } + + client, err := a.createClient(ctx, guestSocketAddr, localUnix, remoteUnix) + if err != nil { + logrus.WithError(err).Warn("failed to create guestagent for inotify") + } + + err = client.Inotify(ctx, guestagentapi.InotifyEvent{Location: watchEvent.Path(), Mode: stat.Mode()}) + if err != nil { + logrus.WithError(err).Warn("failed to send inotify to guestagent") + } + } + } +} + +func (a *HostAgent) setupWatchers(events chan notify.EventInfo) error { + for _, m := range a.y.Mounts { + if *m.Writable { + location, err := localpathutil.Expand(m.Location) + if err != nil { + return err + } + err = notify.Watch(path.Join(location, "..."), events, notify.All) + if err != nil { + return err + } + } + } + return nil +} + +func filterEvents(event notify.EventInfo) bool { + eventPath := event.Path() + _, ok := inotifyCache[eventPath] + if ok { + //Ignore the duplicate inotify on mounted directories, so always remove a entry if already present + delete(inotifyCache, eventPath) + return true + } + inotifyCache[eventPath] = "" + + if len(inotifyCache) >= CacheSize { + clear(inotifyCache) + } + return false +} diff --git a/pkg/httpclientutil/httpclientutil.go b/pkg/httpclientutil/httpclientutil.go index 90b6c3fecbf8..71c1271fbb81 100644 --- a/pkg/httpclientutil/httpclientutil.go +++ b/pkg/httpclientutil/httpclientutil.go @@ -31,6 +31,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,