mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 19:45:14 -04:00
276 lines
9.9 KiB
Go
276 lines
9.9 KiB
Go
//go:build e2e
|
|
|
|
package harness
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/go-connections/nat"
|
|
"github.com/testcontainers/testcontainers-go"
|
|
tcexec "github.com/testcontainers/testcontainers-go/exec"
|
|
"github.com/testcontainers/testcontainers-go/network"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
|
|
"github.com/netbirdio/netbird/shared/management/client/rest"
|
|
)
|
|
|
|
const (
|
|
combinedDockerfile = "combined/Dockerfile.multistage"
|
|
// defaultCombinedImage is the local tag the combined server is built under
|
|
// from combinedDockerfile, so the e2e exercises this branch's code. Override
|
|
// with NB_E2E_COMBINED_IMAGE: a value containing a "/" is pulled as a
|
|
// published image; a bare tag is built under that name instead.
|
|
defaultCombinedImage = "netbird-combined:e2e"
|
|
combinedHTTPPort = "8080/tcp"
|
|
|
|
// combinedAlias is the combined server's network alias AND the deployment
|
|
// domain. The working manual setup uses a single NETBIRD_DOMAIN for the
|
|
// management exposed address, the proxy domain, and the agent-network
|
|
// cluster — so we mirror that: peers reach management/signal/relay at this
|
|
// name, the proxy registers this as its cluster, and the agent-network
|
|
// endpoint is <subdomain>.<combinedAlias>.
|
|
combinedAlias = "netbird.local"
|
|
combinedExposedURL = "http://" + combinedAlias + ":8080"
|
|
|
|
// containerIssuer is the embedded IdP issuer, used only for internal JWT
|
|
// validation (peers authenticate with setup keys / proxy tokens, not OIDC),
|
|
// so the in-container localhost address is fine.
|
|
containerIssuer = "http://localhost:8080/oauth2"
|
|
)
|
|
|
|
// Combined is a running combined NetBird server (management + signal + relay +
|
|
// STUN + embedded IdP) plus the connection details tests need. It owns the
|
|
// shared docker network that the proxy and client containers join.
|
|
type Combined struct {
|
|
container testcontainers.Container
|
|
network *testcontainers.DockerNetwork
|
|
// BaseURL is the host-reachable management API root, e.g. http://127.0.0.1:51234.
|
|
BaseURL string
|
|
// PAT is the admin Personal Access Token minted via Bootstrap.
|
|
PAT string
|
|
|
|
api *rest.Client
|
|
workDir string
|
|
}
|
|
|
|
// StartCombined builds the combined server from its multistage Dockerfile and
|
|
// boots it with setup-PAT enabled on a fresh shared network, returning once the
|
|
// API is serving. The caller still owns minting the admin PAT via Bootstrap.
|
|
func StartCombined(ctx context.Context) (*Combined, error) {
|
|
root, err := repoRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
combinedImage, err := resolveImage(ctx, root, "NB_E2E_COMBINED_IMAGE", defaultCombinedImage, combinedDockerfile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
net, err := network.New(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create shared network: %w", err)
|
|
}
|
|
|
|
// Work dir under /tmp so Docker Desktop file sharing (which excludes
|
|
// macOS's /var/folders TMPDIR) can bind-mount it.
|
|
workDir, err := os.MkdirTemp("/tmp", "nb-e2e-combined-*")
|
|
if err != nil {
|
|
_ = net.Remove(ctx)
|
|
return nil, fmt.Errorf("create work dir: %w", err)
|
|
}
|
|
|
|
cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, containerIssuer)
|
|
if err := os.WriteFile(filepath.Join(workDir, "config.yaml"), []byte(cfg), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container
|
|
_ = net.Remove(ctx)
|
|
return nil, fmt.Errorf("write combined config: %w", err)
|
|
}
|
|
dataDir := filepath.Join(workDir, "data")
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
_ = net.Remove(ctx)
|
|
return nil, fmt.Errorf("create datadir: %w", err)
|
|
}
|
|
// The config's agentNetwork.pricingDefaultsFile is a bare filename, so the
|
|
// server resolves it against the datadir; write it there. It is an explicitly
|
|
// configured path, so a failure to load fails the server's startup — which
|
|
// surfaces here as the /api/instance readiness wait timing out.
|
|
if err := os.WriteFile(filepath.Join(dataDir, PricingDefaultsFileName), []byte(pricingDefaultsYAML), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container
|
|
_ = net.Remove(ctx)
|
|
return nil, fmt.Errorf("write pricing defaults: %w", err)
|
|
}
|
|
|
|
req := testcontainers.ContainerRequest{
|
|
Image: combinedImage,
|
|
ExposedPorts: []string{combinedHTTPPort},
|
|
Networks: []string{net.Name},
|
|
NetworkAliases: map[string][]string{net.Name: {combinedAlias}},
|
|
Env: map[string]string{
|
|
"NB_SETUP_PAT_ENABLED": "true",
|
|
// Skip the GeoLite DB download — it blocks startup and agent-network
|
|
// ingest doesn't use geolocation.
|
|
"NB_DISABLE_GEOLOCATION": "true",
|
|
},
|
|
Cmd: []string{"--config", "/nb/config.yaml"},
|
|
HostConfigModifier: func(hc *container.HostConfig) {
|
|
hc.Binds = append(hc.Binds, workDir+":/nb")
|
|
},
|
|
WaitingFor: wait.ForHTTP("/api/instance").
|
|
WithPort(combinedHTTPPort).
|
|
WithStatusCodeMatcher(func(status int) bool { return status == 200 }).
|
|
WithStartupTimeout(120 * time.Second),
|
|
}
|
|
|
|
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
|
ContainerRequest: req,
|
|
Started: true,
|
|
})
|
|
if err != nil {
|
|
_ = net.Remove(ctx)
|
|
return nil, fmt.Errorf("start combined container: %w", err)
|
|
}
|
|
|
|
host, err := c.Host(ctx)
|
|
if err != nil {
|
|
_ = c.Terminate(ctx)
|
|
_ = net.Remove(ctx)
|
|
return nil, fmt.Errorf("container host: %w", err)
|
|
}
|
|
mapped, err := c.MappedPort(ctx, nat.Port(combinedHTTPPort))
|
|
if err != nil {
|
|
_ = c.Terminate(ctx)
|
|
_ = net.Remove(ctx)
|
|
return nil, fmt.Errorf("mapped port: %w", err)
|
|
}
|
|
|
|
return &Combined{
|
|
container: c,
|
|
network: net,
|
|
BaseURL: fmt.Sprintf("http://%s:%s", host, mapped.Port()),
|
|
workDir: workDir,
|
|
}, nil
|
|
}
|
|
|
|
// resolveImage returns the image to run for a component. By default it builds
|
|
// the image from the repo Dockerfile under localTag, so the e2e exercises the
|
|
// branch's code. The env override changes this: a value containing a "/" is a
|
|
// registry reference that testcontainers pulls (e.g. to test a published
|
|
// release); a bare tag is built under that name instead.
|
|
func resolveImage(ctx context.Context, root, envKey, localTag, dockerfile string) (string, error) {
|
|
if v := os.Getenv(envKey); v != "" {
|
|
if strings.Contains(v, "/") {
|
|
return v, nil
|
|
}
|
|
localTag = v
|
|
}
|
|
if err := buildImage(ctx, root, dockerfile, localTag); err != nil {
|
|
return "", err
|
|
}
|
|
return localTag, nil
|
|
}
|
|
|
|
// buildImage builds an image from a repo Dockerfile via buildx with BuildKit, so
|
|
// the Dockerfile cache mounts are honored and unchanged layers are reused. The
|
|
// result is loaded into the docker image store so testcontainers runs it by tag.
|
|
// When NB_E2E_BUILDX_CACHE names a directory (CI, with a container-driver
|
|
// builder from docker/setup-buildx-action), layer cache is read from and written
|
|
// to it as a local cache so actions/cache can persist it across runs; the Go
|
|
// compile itself still re-runs, as BuildKit mount caches can't be exported.
|
|
func buildImage(ctx context.Context, root, dockerfile, tag string) error {
|
|
args := []string{"buildx", "build", "-f", dockerfile, "-t", tag, "--load"}
|
|
if dir := os.Getenv("NB_E2E_BUILDX_CACHE"); dir != "" {
|
|
args = append(args,
|
|
"--cache-from", "type=local,src="+dir,
|
|
"--cache-to", "type=local,dest="+dir+",mode=max",
|
|
)
|
|
}
|
|
args = append(args, ".")
|
|
|
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
|
cmd.Dir = root
|
|
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("build image %s: %w\n%s", tag, err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateProxyTokenCLI mints a proxy access token via the server's `token
|
|
// create` CLI inside the container — the same path the manual install uses.
|
|
// This yields a GLOBAL (account-less) token, so the proxy serves the whole
|
|
// cluster (SynthesizeServicesForCluster); an account-scoped REST token instead
|
|
// drives the per-account path. Returns the plaintext token.
|
|
func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string, error) {
|
|
code, reader, err := c.container.Exec(ctx,
|
|
[]string{"/go/bin/netbird-server", "token", "create", "--name", name, "--config", "/nb/config.yaml"},
|
|
tcexec.Multiplexed())
|
|
if err != nil {
|
|
return "", fmt.Errorf("exec token create: %w", err)
|
|
}
|
|
out, _ := io.ReadAll(reader)
|
|
if code != 0 {
|
|
return "", fmt.Errorf("token create exited %d: %s", code, string(out))
|
|
}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Token:") {
|
|
tok := strings.TrimSpace(strings.TrimPrefix(line, "Token:"))
|
|
if tok != "" {
|
|
return tok, nil
|
|
}
|
|
}
|
|
}
|
|
return "", fmt.Errorf("token not found in CLI output: %s", string(out))
|
|
}
|
|
|
|
// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted
|
|
// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes.
|
|
func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) {
|
|
src := filepath.Join(c.workDir, "data", "store.db")
|
|
if _, err := os.Stat(src); err != nil {
|
|
return "", fmt.Errorf("management store not found at %s: %w", src, err)
|
|
}
|
|
dst := filepath.Join(dstDir, "store.db")
|
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
data, err := os.ReadFile(src + suffix)
|
|
if err != nil {
|
|
if os.IsNotExist(err) && suffix != "" {
|
|
continue // sidecar only exists in WAL mode
|
|
}
|
|
return "", fmt.Errorf("read %s: %w", src+suffix, err)
|
|
}
|
|
if err := os.WriteFile(dst+suffix, data, 0o600); err != nil {
|
|
return "", fmt.Errorf("write %s: %w", dst+suffix, err)
|
|
}
|
|
}
|
|
return dst, nil
|
|
}
|
|
|
|
// Logs returns the combined server container logs, for diagnostics.
|
|
func (c *Combined) Logs(ctx context.Context) string {
|
|
return containerLogs(ctx, c.container)
|
|
}
|
|
|
|
// Terminate stops the container, removes the shared network, and cleans the
|
|
// work dir.
|
|
func (c *Combined) Terminate(ctx context.Context) error {
|
|
var err error
|
|
if c.container != nil {
|
|
err = c.container.Terminate(ctx)
|
|
}
|
|
if c.network != nil {
|
|
_ = c.network.Remove(ctx)
|
|
}
|
|
if c.workDir != "" {
|
|
_ = os.RemoveAll(c.workDir)
|
|
}
|
|
return err
|
|
}
|