Ctrl K
ComunidadGuías…

iclient/incus_events_test.go

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
}