iclient/incus_test.go
Rama develop · réplica de lectura del repositorio original.
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)
}