Ctrl K
ComunidadGuías…

iclient/incus_test.go

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

Archivo original ↗Preguntar a KiBot
package iclient

import (
	"context"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"sync"
	"testing"
	"time"

	"github.com/lxc/incus/v7/shared/api"
	"github.com/stretchr/testify/require"
)

// testConnection dials the remote the test environment points at.
func testConnection(t *testing.T) *Connection {
	t.Helper()

	config, err := ReadConfig("")
	require.NoError(t, err)

	info, err := config.RemoteInfos(os.Getenv("INCUS_REMOTE"))
	require.NoError(t, err)

	conn, err := NewConnection(info)
	require.NoError(t, err)

	return conn
}

// testRegistry resolves the docker.io remote, following whatever mirror the
// Incus configuration points it at. Without one it is the stock OCI registry,
// which is what `incus remote add docker.io https://docker.io --protocol=oci`
// would register, so the tests need no change to the user's configuration.
func testRegistry(t *testing.T, config *Config) *ConfigRemoteInfo {
	t.Helper()

	registry, err := config.RemoteInfos("docker.io")
	if errors.Is(err, ErrConfigRemoteNotFound) {
		return &ConfigRemoteInfo{
			Name:     "docker.io",
			Addrs:    []string{"https://docker.io"},
			Protocol: "oci",
			Public:   true,
		}
	}

	require.NoError(t, err)

	return registry
}

// transportOf digs out the transport a connection was built with.
func transportOf(t *testing.T, conn *Connection) (*Connection, *http.Transport) {
	t.Helper()

	transport, ok := conn.http.Transport.(*http.Transport)
	require.True(t, ok)

	return conn, transport
}

// TestIncusTransportTuning pins the settings that are wrong by default or wrong
// if someone "tidies" them later.
func TestIncusTransportTuning(t *testing.T) {
	t.Parallel()

	for _, tt := range []struct{ name, addr string }{
		{"unix", "unix:///tmp/x.socket"},
		{"https", "https://127.0.0.1:8443"},
	} {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			conn, err := NewConnection(&ConfigRemoteInfo{Name: tt.name, Addrs: []string{tt.addr}})
			require.NoError(t, err)

			incus, transport := transportOf(t, conn)

			// A whole-request deadline would cut off the event stream, an
			// operation long-poll and every console or SFTP transfer.
			require.Zero(t, incus.http.Timeout, "the context is the per-call deadline, not Client.Timeout")

			// Blocking on this starves workers behind long-lived streams.
			require.Zero(t, transport.MaxConnsPerHost, "MaxConnsPerHost must stay unbounded")

			// The default of 2 makes a worker pool reconnect constantly.
			require.Greater(t, transport.MaxIdleConnsPerHost, 2)

			require.False(t, transport.DisableKeepAlives, "a worker pool reconnects constantly without pooling")
			require.False(t, transport.ForceAttemptHTTP2, "events and exec need an HTTP/1.1 upgrade")

			require.NotZero(t, transport.TLSHandshakeTimeout)
			require.GreaterOrEqual(t, transport.ResponseHeaderTimeout, time.Hour,
				"an operation wait sends no header until it finishes")
		})
	}
}

func TestIncusTransportPerAddressKind(t *testing.T) {
	t.Parallel()

	unix, err := NewConnection(&ConfigRemoteInfo{Name: "u", Addrs: []string{"unix:///tmp/x.socket"}})
	require.NoError(t, err)

	incus, transport := transportOf(t, unix)
	require.Equal(t, "/tmp/x.socket", incus.socketPath)
	require.Nil(t, transport.TLSClientConfig, "a unix socket needs no TLS")
	require.Nil(t, transport.Proxy, "a unix socket is never proxied")

	tls, err := NewConnection(&ConfigRemoteInfo{Name: "t", Addrs: []string{"https://127.0.0.1:8443"}})
	require.NoError(t, err)

	incus, transport = transportOf(t, tls)
	require.Empty(t, incus.socketPath)
	require.NotNil(t, transport.TLSClientConfig)
	require.NotNil(t, transport.Proxy, "an https remote honors the proxy environment")
}

// TestIncusWithMaxIdleConns: the transport must NOT be shared with the copy,
// because the pool size belongs to the pool.
func TestIncusWithMaxIdleConns(t *testing.T) {
	t.Parallel()

	conn, err := NewConnection(&ConfigRemoteInfo{
		Name:  "t",
		Addrs: []string{"https://127.0.0.1:8443"},
	})
	require.NoError(t, err)

	_, before := transportOf(t, conn)

	tuned := conn.WithMaxIdleConns(7, 3)

	incus, after := transportOf(t, tuned)

	require.NotSame(t, before, after, "resizing a live pool under in-flight requests is a race")
	require.Equal(t, 7, after.MaxIdleConns)
	require.Equal(t, 3, after.MaxIdleConnsPerHost)

	// The original keeps the defaults.
	require.Equal(t, incusMaxIdleConns, before.MaxIdleConns)
	require.Equal(t, incusMaxIdleConnsPerHost, before.MaxIdleConnsPerHost)

	// Everything else survives the clone.
	require.NotNil(t, after.TLSClientConfig)
	require.NotNil(t, after.Proxy)
	require.False(t, after.ForceAttemptHTTP2)
	require.Equal(t, incusResponseHeaderTimeout, after.ResponseHeaderTimeout)
	require.Zero(t, incus.http.Timeout)
}

func TestIncusWithMaxIdleConnsKeepsTheDialer(t *testing.T) {
	t.Parallel()

	conn, err := NewConnection(&ConfigRemoteInfo{Name: "u", Addrs: []string{"unix:///tmp/x.socket"}})
	require.NoError(t, err)

	incus, transport := transportOf(t, conn.WithMaxIdleConns(4, 2))

	require.NotNil(t, transport.DialContext, "a unix connection is nothing without its dialer")
	require.Equal(t, "/tmp/x.socket", incus.socketPath)
}

func TestNewConnectionIncusNoAddress(t *testing.T) {
	t.Parallel()

	_, err := NewConnection(&ConfigRemoteInfo{Name: "empty"})
	require.ErrorIs(t, err, ErrConnectionNoAddress)
}

func TestIncusSocketPathExplicit(t *testing.T) {
	t.Parallel()

	require.Equal(t, "/tmp/a.socket", incusSocketPath("unix:///tmp/a.socket"))
	require.Equal(t, "/tmp/b.socket", incusSocketPath("unix:/tmp/b.socket"))
}

func TestIncusSocketPathFromEnv(t *testing.T) {
	t.Setenv("INCUS_SOCKET", "/tmp/from-env.socket")
	require.Equal(t, "/tmp/from-env.socket", incusSocketPath("unix://"))

	// An address carrying a path beats the environment.
	require.Equal(t, "/tmp/explicit.socket", incusSocketPath("unix:///tmp/explicit.socket"))
}

func TestIncusSocketPathFromDir(t *testing.T) {
	dir := t.TempDir()

	t.Setenv("INCUS_SOCKET", "")
	t.Setenv("INCUS_DIR", dir)

	require.Equal(t, filepath.Join(dir, "unix.socket"), incusSocketPath("unix://"))
}

func TestIncusGetInstanceNames(t *testing.T) {
	skipLocal(t)
	t.Parallel()

	conn := testConnection(t)

	names, err := conn.GetInstanceNames(t.Context(), "", nil)
	require.NoError(t, err)

	// Every name must be a bare name, not the resource URL the API returns.
	for _, name := range names {
		require.NotContains(t, name, "/", "GetInstanceNames must strip the URL prefix")
	}
}

func TestIncusGetInstancesRecursion(t *testing.T) {
	skipLocal(t)
	t.Parallel()

	ctx := t.Context()
	conn := testConnection(t)

	instances, err := conn.GetInstances(ctx, "", nil)
	require.NoError(t, err)

	full, err := conn.GetInstances(ctx, "", &GetInstancesArgs{Full: true})
	require.NoError(t, err)

	require.Len(t, full, len(instances), "recursion=1 and recursion=2 must see the same set")

	names, err := conn.GetInstanceNames(ctx, "", nil)
	require.NoError(t, err)

	require.Len(t, names, len(instances), "the name list must match the recursive list")
}

// TestIncusGetInstanceNotFound pins the error mapping: an API error envelope
// has to come back as a 404 StatusError, not as a decode failure or a nil.
func TestIncusGetInstanceNotFound(t *testing.T) {
	skipLocal(t)
	t.Parallel()

	ctx := t.Context()
	conn := testConnection(t)

	_, _, err := conn.GetInstance(ctx, "", "ic-iclient-does-not-exist", nil)
	require.Error(t, err)
	require.True(t, api.StatusErrorCheck(err, 404), "want a 404 StatusError, got %v", err)

	_, _, err = conn.GetInstanceState(ctx, "", "ic-iclient-does-not-exist")
	require.Error(t, err)
	require.True(t, api.StatusErrorCheck(err, 404), "want a 404 StatusError, got %v", err)
}

// TestIncusGetInstanceRoundTrip only runs where the remote already has an
// instance; it checks the single-instance calls agree with the list ones.
func TestIncusGetInstanceRoundTrip(t *testing.T) {
	skipLocal(t)
	t.Parallel()

	ctx := t.Context()
	conn := testConnection(t)

	names, err := conn.GetInstanceNames(ctx, "", nil)
	require.NoError(t, err)

	if len(names) == 0 {
		t.Skip("no instance on the remote to read")
	}

	name := names[0]

	instance, etag, err := conn.GetInstance(ctx, "", name, nil)
	require.NoError(t, err)
	require.Equal(t, name, instance.Name)
	require.NotEmpty(t, etag, "GetInstance must return the ETag header")

	full, _, err := conn.GetInstance(ctx, "", name, &GetInstanceArgs{Full: true})
	require.NoError(t, err)
	require.Equal(t, name, full.Name)

	state, _, err := conn.GetInstanceState(ctx, "", name)
	require.NoError(t, err)
	require.NotEmpty(t, state.Status)
}

// TestIncusUnknownProjectIsEmpty pins what the server actually does: an
// unknown project is an empty collection, not a 404.
func TestIncusUnknownProjectIsEmpty(t *testing.T) {
	skipLocal(t)
	t.Parallel()

	conn := testConnection(t)

	names, err := conn.GetInstanceNames(t.Context(), "ic-iclient-no-such-project", nil)
	require.NoError(t, err)
	require.Empty(t, names)
}

// recordingRequest is one request a recording server saw.
type recordingRequest struct {
	method string
	url    url.URL
	etag   string
	body   string
	header http.Header
}

// uri is the path and query, the way the request went out.
func (r recordingRequest) uri() string {
	return r.url.RequestURI()
}

// query returns one query parameter of the request.
func (r recordingRequest) query(key string) string {
	return r.url.Query().Get(key)
}

// recorder collects what a test server saw. The handler runs on its own
// goroutine, so every access goes through the lock.
type recorder struct {
	mu       sync.Mutex
	requests []recordingRequest
}

// add records one request, draining its body.
func (r *recorder) add(req *http.Request) {
	body, _ := io.ReadAll(req.Body)

	r.mu.Lock()
	defer r.mu.Unlock()

	r.requests = append(r.requests, recordingRequest{
		method: req.Method,
		url:    *req.URL,
		etag:   req.Header.Get("If-Match"),
		body:   string(body),
		header: req.Header.Clone(),
	})
}

// all returns the requests seen so far, in order.
func (r *recorder) all() []recordingRequest {
	r.mu.Lock()
	defer r.mu.Unlock()

	return append([]recordingRequest(nil), r.requests...)
}

// uris returns the path and query of every request, in order.
func (r *recorder) uris() []string {
	requests := r.all()

	uris := make([]string, 0, len(requests))
	for _, req := range requests {
		uris = append(uris, req.uri())
	}

	return uris
}

// len returns how many requests have arrived.
func (r *recorder) len() int {
	r.mu.Lock()
	defer r.mu.Unlock()

	return len(r.requests)
}

// recordingServer answers like Incus and records every request it was sent.
func recordingServer(t *testing.T, metadata string) (*Connection, *recorder) {
	t.Helper()

	seen := &recorder{}

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seen.add(r)

		w.Header().Set("ETag", "test-etag")
		w.Header().Set("Content-Type", "application/json")

		_, _ = io.WriteString(w, `{"type":"sync","status_code":200,"metadata":`+metadata+`}`)
	}))
	t.Cleanup(server.Close)

	conn, err := NewConnection(&ConfigRemoteInfo{
		Name:  "recording",
		Addrs: []string{server.URL},
	})
	require.NoError(t, err)

	return conn, seen
}

// TestIncusRequestURLs pins what goes on the wire; a real server answers happily
// with the project or recursion parameter dropped.
func TestIncusRequestURLs(t *testing.T) {
	t.Parallel()

	ctx := t.Context()

	for _, tt := range []struct {
		name     string
		metadata string
		call     func(*Connection) error
		want     string
	}{
		{
			"GetInstanceNames", `[]`,
			func(c *Connection) error { _, err := c.GetInstanceNames(ctx, "myproject", nil); return err },
			"/1.0/instances?project=myproject",
		},
		{
			"GetInstanceNames typed", `[]`,
			func(c *Connection) error {
				_, err := c.GetInstanceNames(ctx, "myproject", &GetInstancesArgs{Type: api.InstanceTypeContainer})
				return err
			},
			"/1.0/instances?instance-type=container&project=myproject",
		},
		{
			"GetInstances", `[]`,
			func(c *Connection) error { _, err := c.GetInstances(ctx, "myproject", nil); return err },
			"/1.0/instances?project=myproject&recursion=1",
		},
		{
			"GetInstancesFull", `[]`,
			func(c *Connection) error {
				_, err := c.GetInstances(ctx, "myproject", &GetInstancesArgs{Full: true})
				return err
			},
			"/1.0/instances?project=myproject&recursion=2",
		},
		{
			"GetInstance", `{}`,
			func(c *Connection) error { _, _, err := c.GetInstance(ctx, "myproject", "web-1", nil); return err },
			"/1.0/instances/web-1?project=myproject",
		},
		{
			"GetInstance full", `{}`,
			func(c *Connection) error {
				_, _, err := c.GetInstance(ctx, "myproject", "web-1", &GetInstanceArgs{Full: true})

				return err
			},
			"/1.0/instances/web-1?project=myproject&recursion=1",
		},
		{
			"GetInstanceState", `{}`,
			func(c *Connection) error { _, _, err := c.GetInstanceState(ctx, "myproject", "web-1"); return err },
			"/1.0/instances/web-1/state?project=myproject",
		},
		{
			"name is escaped", `{}`,
			func(c *Connection) error { _, _, err := c.GetInstance(ctx, "myproject", "a/b", nil); return err },
			"/1.0/instances/a%2Fb?project=myproject",
		},
		{
			"GetStoragePool", `{"name":"default","driver":"zfs"}`,
			func(c *Connection) error { _, _, err := c.GetStoragePool(ctx, "default"); return err },
			"/1.0/storage-pools/default",
		},
		{
			"GetStoragePoolVolume with target", `{"name":"vol-1"}`,
			func(c *Connection) error {
				_, _, err := c.GetStoragePoolVolume(ctx, "myproject", "default", "custom", "vol-1", &GetStoragePoolVolumeArgs{Full: true, Target: "node1"})
				return err
			},
			"/1.0/storage-pools/default/volumes/custom/vol-1?project=myproject&recursion=1&target=node1",
		},
		{
			"CreateStoragePoolVolume with target", `{}`,
			func(c *Connection) error {
				return c.CreateStoragePoolVolume(ctx, "myproject", "default", api.StorageVolumesPost{Name: "vol-1", Type: "custom"}, &CreateStoragePoolVolumeArgs{Target: "node1"})
			},
			"/1.0/storage-pools/default/volumes/custom?project=myproject&target=node1",
		},
		{
			"DeleteStoragePoolVolume with target", `{}`,
			func(c *Connection) error {
				return c.DeleteStoragePoolVolume(ctx, "myproject", "default", "custom", "vol-1", &DeleteStoragePoolVolumeArgs{Target: "node1"})
			},
			"/1.0/storage-pools/default/volumes/custom/vol-1?project=myproject&target=node1",
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			conn, seen := recordingServer(t, tt.metadata)

			require.NoError(t, tt.call(conn))
			require.Equal(t, []string{tt.want}, seen.uris())
		})
	}
}

// TestIncusPerCallProject: one connection serves two projects, and neither call
// leaves anything behind on it.
func TestIncusPerCallProject(t *testing.T) {
	t.Parallel()

	ctx := t.Context()

	conn, seen := recordingServer(t, `[]`)

	_, err := conn.GetInstanceNames(ctx, "second", nil)
	require.NoError(t, err)

	_, err = conn.GetInstanceNames(ctx, "myproject", nil)
	require.NoError(t, err)

	require.Equal(t, []string{
		"/1.0/instances?project=second",
		"/1.0/instances?project=myproject",
	}, seen.uris())
}

func TestIncusEmptyProject(t *testing.T) {
	t.Parallel()

	conn, seen := recordingServer(t, `[]`)

	_, err := conn.GetInstanceNames(t.Context(), "", nil)
	require.NoError(t, err)

	require.Equal(t, []string{"/1.0/instances"}, seen.uris(), "an empty project sends no parameter")
}

// TestIncusInstancesArgs covers the axes the args struct replaced.
func TestIncusInstancesArgs(t *testing.T) {
	t.Parallel()

	ctx := t.Context()

	for _, tt := range []struct {
		name string
		args *GetInstancesArgs
		want string
	}{
		{"nil is the zero value", nil, "/1.0/instances?project=myproject&recursion=1"},
		{"full", &GetInstancesArgs{Full: true}, "/1.0/instances?project=myproject&recursion=2"},
		{
			"filters are rendered as the API reads them",
			&GetInstancesArgs{Filters: []string{"status=Running"}},
			"/1.0/instances?filter=status+eq+Running&project=myproject&recursion=1",
		},
		{
			"every axis at once",
			&GetInstancesArgs{
				Type:    api.InstanceTypeContainer,
				Full:    true,
				Filters: []string{"status=Running", "type=container"},
			},
			"/1.0/instances?filter=status+eq+Running+and+type+eq+container" +
				"&instance-type=container&project=myproject&recursion=2",
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			conn, seen := recordingServer(t, `[]`)

			_, err := conn.GetInstances(ctx, "myproject", tt.args)
			require.NoError(t, err)
			require.Equal(t, []string{tt.want}, seen.uris())
		})
	}
}

// TestIncusGetInstancesAllProjects: incusd refuses a request carrying both, so
// the all-projects form must send no project of its own.
func TestIncusGetInstancesAllProjects(t *testing.T) {
	t.Parallel()

	conn, seen := recordingServer(t, `[]`)

	_, err := conn.GetInstancesAllProjects(t.Context(), &GetInstancesArgs{Full: true})
	require.NoError(t, err)

	require.Equal(t, []string{"/1.0/instances?all-projects=true&recursion=2"}, seen.uris())
}

func TestIncusGetInstanceReturnsETag(t *testing.T) {
	t.Parallel()

	conn, _ := recordingServer(t, `{"name":"web-1"}`)

	instance, etag, err := conn.GetInstance(t.Context(), "myproject", "web-1", nil)
	require.NoError(t, err)
	require.Equal(t, "web-1", instance.Name)
	require.Equal(t, "test-etag", etag)
}

// TestIncusErrorEnvelope proves an API error envelope becomes a StatusError
// rather than a decode failure or a silent success.
func TestIncusErrorEnvelope(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusNotFound)

		_, _ = io.WriteString(w, `{"type":"error","error_code":404,"error":"Instance not found"}`)
	}))
	t.Cleanup(server.Close)

	conn, err := NewConnection(&ConfigRemoteInfo{Name: "erroring", Addrs: []string{server.URL}})
	require.NoError(t, err)

	_, _, err = conn.GetInstance(t.Context(), "myproject", "nope", nil)
	require.Error(t, err)
	require.True(t, api.StatusErrorCheck(err, 404), "want a 404 StatusError, got %v", err)
	require.Contains(t, err.Error(), "Instance not found")
}

// TestIncusContextCancelled is the reason the fork exists: a call must abandon
// its request when the context goes, instead of waiting on the server.
func TestIncusContextCancelled(t *testing.T) {
	t.Parallel()

	release := make(chan struct{})

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-release:
		case <-r.Context().Done():
		}
	}))

	t.Cleanup(func() {
		close(release)
		server.Close()
	})

	conn, err := NewConnection(&ConfigRemoteInfo{Name: "hanging", Addrs: []string{server.URL}})
	require.NoError(t, err)

	ctx, cancel := context.WithCancel(context.Background())

	go func() {
		time.Sleep(50 * time.Millisecond)
		cancel()
	}()

	_, err = conn.GetInstanceNames(ctx, "myproject", nil)
	require.ErrorIs(t, err, context.Canceled)
}