package iclient import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gorilla/websocket" "github.com/lxc/incus/v7/shared/api" "github.com/stretchr/testify/require" ) // eventServer serves /1.0/events over a websocket, writing each event it is // given, and records the requests it was asked for. It never pings, so a // listener against it hears nothing between events. func eventServer(t *testing.T, send <-chan api.Event) (*Connection, *recorder) { t.Helper() return pingingEventServer(t, send, 0) } // pingingEventServer is eventServer with the keepalive incusd sends every 10s. // A ping interval of 0 means none at all. func pingingEventServer(t *testing.T, send <-chan api.Event, every time.Duration) (*Connection, *recorder) { t.Helper() seen := &recorder{} upgrader := websocket.Upgrader{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen.add(r) socket, err := upgrader.Upgrade(w, r, nil) if err != nil { return } defer func() { _ = socket.Close() }() // Nil while nothing pings, and a select on a nil channel never fires. var ping <-chan time.Time if every > 0 { ticker := time.NewTicker(every) defer ticker.Stop() ping = ticker.C } // One writer for both, since a websocket does not take two. for { select { case event, ok := <-send: if !ok { return } payload, err := json.Marshal(event) if err != nil { return } err = socket.WriteMessage(websocket.TextMessage, payload) if err != nil { return } case <-ping: err := socket.WriteControl(websocket.PingMessage, []byte("keepalive"), time.Now().Add(time.Second)) if err != nil { return } } } })) t.Cleanup(server.Close) conn, err := NewConnection(&ConfigRemoteInfo{ Name: "events", Addrs: []string{server.URL}, }) require.NoError(t, err) return conn, seen } func TestIncusListenEvents(t *testing.T) { t.Parallel() send := make(chan api.Event, 1) t.Cleanup(func() { close(send) }) conn, seen := eventServer(t, send) events, err := conn.ListenEvents(t.Context(), "myproject", []string{"lifecycle", "operation"}) require.NoError(t, err) send <- api.Event{Type: "lifecycle", Project: "myproject"} select { case event := <-events: require.Equal(t, "lifecycle", event.Type) case <-time.After(5 * time.Second): t.Fatal("no event arrived") } // The filter and the project are the caller's, not another listener's. req := seen.all()[0] require.Equal(t, "lifecycle,operation", req.query("type")) require.Equal(t, "myproject", req.query("project")) } // TestIncusListenEventsAllProjects: this form sends no project of its own, // which is what keeps incusd from refusing the pair. func TestIncusListenEventsAllProjects(t *testing.T) { t.Parallel() send := make(chan api.Event) t.Cleanup(func() { close(send) }) conn, seen := eventServer(t, send) _, err := conn.ListenEventsAllProjects(t.Context(), []string{"lifecycle"}) require.NoError(t, err) require.Eventually(t, func() bool { return seen.len() == 1 }, 5*time.Second, 10*time.Millisecond, "the listener never reached the server") req := seen.all()[0] require.Equal(t, "true", req.query("all-projects")) require.Empty(t, req.query("project"), "an all-projects listener is not scoped to one") require.Equal(t, "lifecycle", req.query("type"), "the type filter still applies") } // TestIncusListenEventsOwnFilter is the difference from upstream: a second // listener gets its own socket and its own filter, instead of joining the // first one's. func TestIncusListenEventsOwnFilter(t *testing.T) { t.Parallel() send := make(chan api.Event) t.Cleanup(func() { close(send) }) conn, seen := eventServer(t, send) _, err := conn.ListenEvents(t.Context(), "myproject", []string{"lifecycle"}) require.NoError(t, err) _, err = conn.ListenEvents(t.Context(), "myproject", []string{"operation"}) require.NoError(t, err) require.Eventually(t, func() bool { return seen.len() == 2 }, 5*time.Second, 10*time.Millisecond, "both listeners must reach the server") requests := seen.all() require.Equal(t, "lifecycle", requests[0].query("type")) require.Equal(t, "operation", requests[1].query("type"), "the second listener must ask for its own types") } // TestIncusListenEventsClosesOnSilence is the half-open case: a server that // neither sends nor closes. Without a read deadline the channel stays open until // the kernel's TCP keepalive gives up, and nothing above learns the stream died. func TestIncusListenEventsClosesOnSilence(t *testing.T) { t.Parallel() send := make(chan api.Event) t.Cleanup(func() { close(send) }) conn, _ := eventServer(t, send) conn.eventSilence = 200 * time.Millisecond events, err := conn.ListenEvents(t.Context(), "myproject", nil) require.NoError(t, err) select { case _, open := <-events: require.False(t, open, "the channel must close, not deliver") case <-time.After(5 * time.Second): t.Fatal("a silent socket left the channel open") } } // TestIncusListenEventsPingSurvivesSilence is the other half: the server's ping // is what says a quiet connection is still there, so a listener with nothing to // deliver must outlive the window rather than be torn down by it. func TestIncusListenEventsPingSurvivesSilence(t *testing.T) { t.Parallel() send := make(chan api.Event) t.Cleanup(func() { close(send) }) conn, _ := pingingEventServer(t, send, 50*time.Millisecond) conn.eventSilence = 300 * time.Millisecond events, err := conn.ListenEvents(t.Context(), "myproject", nil) require.NoError(t, err) select { case _, open := <-events: require.True(t, open, "a pinged socket must not be closed as silent") case <-time.After(time.Second): // Nothing delivered and nothing closed, which is the point. } } func TestIncusListenEventsClosesOnContext(t *testing.T) { t.Parallel() send := make(chan api.Event) t.Cleanup(func() { close(send) }) conn, _ := eventServer(t, send) incus := conn ctx, cancel := context.WithCancel(t.Context()) events, err := conn.ListenEvents(ctx, "myproject", nil) require.NoError(t, err) require.Equal(t, 1, incus.events.len()) cancel() select { case _, open := <-events: require.False(t, open, "the channel must close, not deliver") case <-time.After(5 * time.Second): t.Fatal("the channel stayed open after the context went") } // A listener that ended on its own must not stay registered. require.Eventually(t, func() bool { return incus.events.len() == 0 }, 5*time.Second, 10*time.Millisecond, "the ended listener is still registered") } // TestIncusListenEventsCancelImmediately is the shape a caller writes: build // the context, listen, cancel. The cancel may land before the read loop has // even started. func TestIncusListenEventsCancelImmediately(t *testing.T) { t.Parallel() send := make(chan api.Event) t.Cleanup(func() { close(send) }) conn, _ := eventServer(t, send) for range 20 { ctx, cancel := context.WithCancel(t.Context()) events, err := conn.ListenEvents(ctx, "myproject", nil) require.NoError(t, err) cancel() select { case _, open := <-events: require.False(t, open) case <-time.After(5 * time.Second): t.Fatal("the channel stayed open after the context went") } } incus := conn require.Eventually(t, func() bool { return incus.events.len() == 0 }, 5*time.Second, 10*time.Millisecond, "twenty ended listeners are still registered") } // TestIncusListenEventsCancelWhileDelivering cancels with the consumer not // reading, so the read loop is parked on the channel send rather than on the // socket. func TestIncusListenEventsCancelWhileDelivering(t *testing.T) { t.Parallel() send := make(chan api.Event) conn, _ := eventServer(t, send) ctx, cancel := context.WithCancel(t.Context()) events, err := conn.ListenEvents(ctx, "myproject", nil) require.NoError(t, err) // Fill past the buffer without reading a single one. go func() { for range incusEventBuffer * 2 { select { case send <- api.Event{Type: "lifecycle"}: case <-ctx.Done(): return } } }() time.Sleep(100 * time.Millisecond) cancel() // Drain whatever was buffered; the channel still has to close. closed := false for !closed { select { case _, open := <-events: closed = !open case <-time.After(5 * time.Second): t.Fatal("the channel stayed open while blocked on a send") } } } // TestIncusListenEventsCopyEndsOnItsOwnContext: a retuned copy is a view on the // parent, but its listeners are its own and end with the context they were // opened on, not with the parent's. func TestIncusListenEventsCopyEndsOnItsOwnContext(t *testing.T) { t.Parallel() send := make(chan api.Event) t.Cleanup(func() { close(send) }) conn, _ := eventServer(t, send) scoped := conn.WithMaxIdleConns(4, 2) ctx, cancel := context.WithCancel(t.Context()) events, err := scoped.ListenEvents(ctx, "myproject", nil) require.NoError(t, err) require.Equal(t, 1, scoped.events.len()) require.Equal(t, 0, conn.events.len(), "a copy's listener is not the parent's") cancel() select { case _, open := <-events: require.False(t, open, "the channel must close, not deliver") case <-time.After(5 * time.Second): t.Fatal("the copy's listener outlived its context") } require.Eventually(t, func() bool { return scoped.events.len() == 0 }, 5*time.Second, 10*time.Millisecond, "the ended listener is still registered") } func TestIncusListenEventsAgainstRealIncus(t *testing.T) { skipLocal(t) t.Parallel() conn := testConnection(t) ctx, cancel := context.WithCancel(t.Context()) events, err := conn.ListenEvents(ctx, "", []string{"lifecycle"}) require.NoError(t, err) cancel() select { case <-events: case <-time.After(10 * time.Second): t.Fatal("the event socket did not close") } } // TestLifecycleEvent: incusd fills Name and Project on instance events alone, // so every other kind has to be taken out of Source. func TestLifecycleEvent(t *testing.T) { t.Parallel() for _, tc := range []struct { name string lc api.EventLifecycle wantN string wantP string }{ { name: "an instance carries both already", lc: api.EventLifecycle{Action: "instance-started", Source: "/1.0/instances/web1?project=alpha", Name: "web1", Project: "alpha"}, wantN: "web1", wantP: "alpha", }, { name: "a network in the default project", lc: api.EventLifecycle{Action: "network-created", Source: "/1.0/networks/ic-q2mjfn37xz"}, wantN: "ic-q2mjfn37xz", // api.URL.Project omits the query for the default project, and so does // a resource that is not project-scoped, so this is not guessed at. wantP: "", }, { name: "a network in a project owning its own", lc: api.EventLifecycle{Action: "network-updated", Source: "/1.0/networks/br0?project=alpha"}, wantN: "br0", wantP: "alpha", }, { name: "a profile", lc: api.EventLifecycle{Action: "profile-updated", Source: "/1.0/profiles/default?project=alpha"}, wantN: "default", wantP: "alpha", }, { name: "a project is not project-scoped", lc: api.EventLifecycle{Action: "project-updated", Source: "/1.0/projects/alpha"}, wantN: "alpha", wantP: "", }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() metadata, err := json.Marshal(tc.lc) require.NoError(t, err) got, err := LifecycleEvent(api.Event{Type: "lifecycle", Metadata: metadata}) require.NoError(t, err) require.Equal(t, tc.wantN, got.Name) require.Equal(t, tc.wantP, got.Project) require.Equal(t, tc.lc.Action, got.Action) }) } } // TestLifecycleEventRejectsBadMetadata: metadata that will not parse is the one // failure this reports. func TestLifecycleEventRejectsBadMetadata(t *testing.T) { t.Parallel() _, err := LifecycleEvent(api.Event{Type: "lifecycle", Metadata: []byte("not json")}) require.Error(t, err) }