mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 14:03:48 +00:00
Keep the NAS-side runtime bounded to the configured export path, make WebDAV and registration behavior env-driven, and add runtime coverage so the first storage loop can be verified locally. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
714 lines
21 KiB
Go
714 lines
21 KiB
Go
package nodeagent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type startedProcess struct {
|
|
cmd *exec.Cmd
|
|
output *lockedBuffer
|
|
}
|
|
|
|
type lockedBuffer struct {
|
|
mu sync.Mutex
|
|
b bytes.Buffer
|
|
}
|
|
|
|
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
return b.b.Write(p)
|
|
}
|
|
|
|
func (b *lockedBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
return b.b.String()
|
|
}
|
|
|
|
func TestRuntimeBinaryBindsToLoopbackByDefault(t *testing.T) {
|
|
repoRoot := testRepoRoot(t)
|
|
nodeAgentBin := buildNodeAgentBinary(t, repoRoot)
|
|
nodeAgentPort := freePort(t)
|
|
exportPath := filepath.Join(t.TempDir(), "export")
|
|
if err := os.MkdirAll(exportPath, 0o755); err != nil {
|
|
t.Fatalf("create export path: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exportPath, "seed.txt"), []byte("seed"), 0o644); err != nil {
|
|
t.Fatalf("write seed file: %v", err)
|
|
}
|
|
|
|
nodeAgentURL := "http://127.0.0.1:" + strconv.Itoa(nodeAgentPort)
|
|
nodeAgent := startBinaryProcess(t, repoRoot, nodeAgentBin, []string{
|
|
"PORT=" + strconv.Itoa(nodeAgentPort),
|
|
"BETTERNAS_EXPORT_PATH=" + exportPath,
|
|
})
|
|
defer nodeAgent.stop(t)
|
|
|
|
waitForHTTPStatus(t, nodeAgentURL+"/health", http.StatusOK)
|
|
|
|
propfindRequest, err := http.NewRequest("PROPFIND", nodeAgentURL+"/dav/", nil)
|
|
if err != nil {
|
|
t.Fatalf("build propfind request: %v", err)
|
|
}
|
|
propfindRequest.Header.Set("Depth", "0")
|
|
|
|
propfindResponse, err := http.DefaultClient.Do(propfindRequest)
|
|
if err != nil {
|
|
t.Fatalf("propfind WebDAV root: %v", err)
|
|
}
|
|
defer propfindResponse.Body.Close()
|
|
|
|
if propfindResponse.StatusCode != http.StatusMultiStatus {
|
|
t.Fatalf("propfind status = %d, want 207", propfindResponse.StatusCode)
|
|
}
|
|
|
|
getResponse, err := http.Get(nodeAgentURL + "/dav/seed.txt")
|
|
if err != nil {
|
|
t.Fatalf("get WebDAV file: %v", err)
|
|
}
|
|
defer getResponse.Body.Close()
|
|
|
|
getBody, err := io.ReadAll(getResponse.Body)
|
|
if err != nil {
|
|
t.Fatalf("read WebDAV body: %v", err)
|
|
}
|
|
|
|
if getResponse.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status = %d, want 200", getResponse.StatusCode)
|
|
}
|
|
if string(getBody) != "seed" {
|
|
t.Fatalf("get body = %q, want seed", string(getBody))
|
|
}
|
|
|
|
host, ok := firstNonLoopbackIPv4()
|
|
if !ok {
|
|
t.Skip("no non-loopback IPv4 address available to verify loopback-only binding")
|
|
}
|
|
|
|
client := &http.Client{Timeout: 500 * time.Millisecond}
|
|
_, err = client.Get("http://" + host + ":" + strconv.Itoa(nodeAgentPort) + "/health")
|
|
if err == nil {
|
|
t.Fatalf("expected loopback-only listener to reject non-loopback host %s", host)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeBinaryServesWebDAVWithExplicitListenAddress(t *testing.T) {
|
|
repoRoot := testRepoRoot(t)
|
|
nodeAgentBin := buildNodeAgentBinary(t, repoRoot)
|
|
nodeAgentPort := freePort(t)
|
|
exportPath := filepath.Join(t.TempDir(), "export")
|
|
if err := os.MkdirAll(exportPath, 0o755); err != nil {
|
|
t.Fatalf("create export path: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exportPath, "seed.txt"), []byte("seed"), 0o644); err != nil {
|
|
t.Fatalf("write seed file: %v", err)
|
|
}
|
|
|
|
nodeAgentURL := "http://127.0.0.1:" + strconv.Itoa(nodeAgentPort)
|
|
nodeAgent := startBinaryProcess(t, repoRoot, nodeAgentBin, []string{
|
|
"PORT=" + strconv.Itoa(nodeAgentPort),
|
|
"BETTERNAS_EXPORT_PATH=" + exportPath,
|
|
listenAddressEnvKey + "=:" + strconv.Itoa(nodeAgentPort),
|
|
"BETTERNAS_NODE_DIRECT_ADDRESS=" + nodeAgentURL,
|
|
})
|
|
defer nodeAgent.stop(t)
|
|
|
|
waitForHTTPStatus(t, nodeAgentURL+"/health", http.StatusOK)
|
|
|
|
propfindRequest, err := http.NewRequest("PROPFIND", nodeAgentURL+"/dav/", nil)
|
|
if err != nil {
|
|
t.Fatalf("build propfind request: %v", err)
|
|
}
|
|
propfindRequest.Header.Set("Depth", "0")
|
|
|
|
propfindResponse, err := http.DefaultClient.Do(propfindRequest)
|
|
if err != nil {
|
|
t.Fatalf("propfind WebDAV root: %v", err)
|
|
}
|
|
defer propfindResponse.Body.Close()
|
|
|
|
if propfindResponse.StatusCode != http.StatusMultiStatus {
|
|
t.Fatalf("propfind status = %d, want 207", propfindResponse.StatusCode)
|
|
}
|
|
|
|
getResponse, err := doRuntimeWebDAVRequest(nodeAgentURL, http.MethodGet, "/dav/seed.txt", nil)
|
|
if err != nil {
|
|
t.Fatalf("get WebDAV file: %v", err)
|
|
}
|
|
defer getResponse.Body.Close()
|
|
|
|
getBody, err := io.ReadAll(getResponse.Body)
|
|
if err != nil {
|
|
t.Fatalf("read WebDAV body: %v", err)
|
|
}
|
|
|
|
if getResponse.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status = %d, want 200", getResponse.StatusCode)
|
|
}
|
|
if string(getBody) != "seed" {
|
|
t.Fatalf("get body = %q, want seed", string(getBody))
|
|
}
|
|
}
|
|
|
|
func TestRuntimeBinaryOmitsDirectAddressForWildcardListenAddress(t *testing.T) {
|
|
repoRoot := testRepoRoot(t)
|
|
nodeAgentBin := buildNodeAgentBinary(t, repoRoot)
|
|
nodeAgentPort := freePort(t)
|
|
exportPath := filepath.Join(t.TempDir(), "export")
|
|
if err := os.MkdirAll(exportPath, 0o755); err != nil {
|
|
t.Fatalf("create export path: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exportPath, "seed.txt"), []byte("seed"), 0o644); err != nil {
|
|
t.Fatalf("write seed file: %v", err)
|
|
}
|
|
|
|
registerRequests := make(chan nodeRegistrationRequest, 1)
|
|
controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.EscapedPath() != registerNodeRoute {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
var request nodeRegistrationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Errorf("decode register request: %v", err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
registerRequests <- request
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"id":"runtime-node"}`)
|
|
}))
|
|
defer controlPlane.Close()
|
|
|
|
nodeAgentURL := "http://127.0.0.1:" + strconv.Itoa(nodeAgentPort)
|
|
nodeAgent := startBinaryProcess(t, repoRoot, nodeAgentBin, []string{
|
|
"PORT=" + strconv.Itoa(nodeAgentPort),
|
|
"BETTERNAS_EXPORT_PATH=" + exportPath,
|
|
"BETTERNAS_NODE_MACHINE_ID=runtime-machine",
|
|
"BETTERNAS_CONTROL_PLANE_URL=" + controlPlane.URL,
|
|
"BETTERNAS_NODE_REGISTER_ENABLED=true",
|
|
listenAddressEnvKey + "=:" + strconv.Itoa(nodeAgentPort),
|
|
})
|
|
defer nodeAgent.stop(t)
|
|
|
|
waitForHTTPStatus(t, nodeAgentURL+"/health", http.StatusOK)
|
|
|
|
registerRequest := awaitValue(t, registerRequests, 5*time.Second, "register request")
|
|
if registerRequest.DirectAddress != nil {
|
|
t.Fatalf("direct address = %#v, want nil for wildcard listener", registerRequest.DirectAddress)
|
|
}
|
|
|
|
getResponse, err := doRuntimeWebDAVRequest(nodeAgentURL, http.MethodGet, "/dav/seed.txt", nil)
|
|
if err != nil {
|
|
t.Fatalf("get WebDAV file: %v", err)
|
|
}
|
|
defer getResponse.Body.Close()
|
|
|
|
getBody, err := io.ReadAll(getResponse.Body)
|
|
if err != nil {
|
|
t.Fatalf("read WebDAV body: %v", err)
|
|
}
|
|
|
|
if getResponse.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status = %d, want 200", getResponse.StatusCode)
|
|
}
|
|
if string(getBody) != "seed" {
|
|
t.Fatalf("get body = %q, want seed", string(getBody))
|
|
}
|
|
}
|
|
|
|
func TestRuntimeBinaryRejectsInvalidListenAddress(t *testing.T) {
|
|
repoRoot := testRepoRoot(t)
|
|
nodeAgentBin := buildNodeAgentBinary(t, repoRoot)
|
|
nodeAgentPort := freePort(t)
|
|
exportPath := filepath.Join(t.TempDir(), "export")
|
|
if err := os.MkdirAll(exportPath, 0o755); err != nil {
|
|
t.Fatalf("create export path: %v", err)
|
|
}
|
|
|
|
command := exec.Command(nodeAgentBin)
|
|
command.Dir = repoRoot
|
|
command.Env = mergedEnv([]string{
|
|
"PORT=" + strconv.Itoa(nodeAgentPort),
|
|
"BETTERNAS_EXPORT_PATH=" + exportPath,
|
|
listenAddressEnvKey + "=localhost",
|
|
})
|
|
output, err := command.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected node-agent to reject invalid listen address")
|
|
}
|
|
|
|
if !strings.Contains(string(output), listenAddressEnvKey) {
|
|
t.Fatalf("output = %q, want %q guidance", string(output), listenAddressEnvKey)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeBinaryUsesOptionalControlPlaneSync(t *testing.T) {
|
|
repoRoot := testRepoRoot(t)
|
|
nodeAgentBin := buildNodeAgentBinary(t, repoRoot)
|
|
nodeAgentPort := freePort(t)
|
|
exportPath := filepath.Join(t.TempDir(), "export")
|
|
if err := os.MkdirAll(exportPath, 0o755); err != nil {
|
|
t.Fatalf("create export path: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exportPath, "seed.txt"), []byte("seed"), 0o644); err != nil {
|
|
t.Fatalf("write seed file: %v", err)
|
|
}
|
|
|
|
const (
|
|
machineID = "runtime-machine"
|
|
controlPlaneToken = "runtime-control-plane-token"
|
|
)
|
|
|
|
registerRequests := make(chan nodeRegistrationRequest, 1)
|
|
heartbeatRequests := make(chan nodeHeartbeatRequest, 4)
|
|
controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+controlPlaneToken {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
t.Errorf("authorization header = %q, want Bearer token", got)
|
|
return
|
|
}
|
|
|
|
switch r.URL.EscapedPath() {
|
|
case registerNodeRoute:
|
|
var request nodeRegistrationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Errorf("decode register request: %v", err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
registerRequests <- request
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"id":"runtime-node"}`)
|
|
case heartbeatRoute("runtime-node"):
|
|
var request nodeHeartbeatRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Errorf("decode heartbeat request: %v", err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
heartbeatRequests <- request
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer controlPlane.Close()
|
|
|
|
nodeAgentURL := "http://127.0.0.1:" + strconv.Itoa(nodeAgentPort)
|
|
nodeAgent := startBinaryProcess(t, repoRoot, nodeAgentBin, []string{
|
|
"PORT=" + strconv.Itoa(nodeAgentPort),
|
|
"BETTERNAS_EXPORT_PATH=" + exportPath,
|
|
"BETTERNAS_VERSION=test-version",
|
|
"BETTERNAS_NODE_MACHINE_ID=" + machineID,
|
|
"BETTERNAS_NODE_DISPLAY_NAME=Runtime NAS",
|
|
"BETTERNAS_EXPORT_LABEL=runtime-export",
|
|
"BETTERNAS_EXPORT_TAGS=runtime,finder",
|
|
"BETTERNAS_NODE_DIRECT_ADDRESS=" + nodeAgentURL,
|
|
"BETTERNAS_CONTROL_PLANE_URL=" + controlPlane.URL,
|
|
"BETTERNAS_CONTROL_PLANE_AUTH_TOKEN=" + controlPlaneToken,
|
|
"BETTERNAS_NODE_REGISTER_ENABLED=true",
|
|
"BETTERNAS_NODE_HEARTBEAT_ENABLED=true",
|
|
"BETTERNAS_NODE_HEARTBEAT_INTERVAL=100ms",
|
|
})
|
|
defer nodeAgent.stop(t)
|
|
|
|
waitForHTTPStatus(t, nodeAgentURL+"/health", http.StatusOK)
|
|
|
|
getResponse, err := doRuntimeWebDAVRequest(nodeAgentURL, http.MethodGet, "/dav/seed.txt", nil)
|
|
if err != nil {
|
|
t.Fatalf("get WebDAV file: %v", err)
|
|
}
|
|
defer getResponse.Body.Close()
|
|
|
|
getBody, err := io.ReadAll(getResponse.Body)
|
|
if err != nil {
|
|
t.Fatalf("read WebDAV body: %v", err)
|
|
}
|
|
if getResponse.StatusCode != http.StatusOK {
|
|
t.Fatalf("get WebDAV status = %d, want 200", getResponse.StatusCode)
|
|
}
|
|
if string(getBody) != "seed" {
|
|
t.Fatalf("get WebDAV body = %q, want seed", string(getBody))
|
|
}
|
|
|
|
registerRequest := awaitValue(t, registerRequests, 5*time.Second, "register request")
|
|
if registerRequest.MachineID != machineID {
|
|
t.Fatalf("machine id = %q, want %q", registerRequest.MachineID, machineID)
|
|
}
|
|
if registerRequest.DisplayName != "Runtime NAS" {
|
|
t.Fatalf("display name = %q, want Runtime NAS", registerRequest.DisplayName)
|
|
}
|
|
if registerRequest.AgentVersion != "test-version" {
|
|
t.Fatalf("agent version = %q, want test-version", registerRequest.AgentVersion)
|
|
}
|
|
if registerRequest.DirectAddress == nil || *registerRequest.DirectAddress != nodeAgentURL {
|
|
t.Fatalf("direct address = %#v, want %q", registerRequest.DirectAddress, nodeAgentURL)
|
|
}
|
|
if registerRequest.RelayAddress != nil {
|
|
t.Fatalf("relay address = %#v, want nil", registerRequest.RelayAddress)
|
|
}
|
|
if len(registerRequest.Exports) != 1 {
|
|
t.Fatalf("exports length = %d, want 1", len(registerRequest.Exports))
|
|
}
|
|
if registerRequest.Exports[0].Label != "runtime-export" {
|
|
t.Fatalf("export label = %q, want runtime-export", registerRequest.Exports[0].Label)
|
|
}
|
|
if registerRequest.Exports[0].Path != exportPath {
|
|
t.Fatalf("export path = %q, want %q", registerRequest.Exports[0].Path, exportPath)
|
|
}
|
|
if len(registerRequest.Exports[0].Protocols) != 1 || registerRequest.Exports[0].Protocols[0] != "webdav" {
|
|
t.Fatalf("export protocols = %#v, want [webdav]", registerRequest.Exports[0].Protocols)
|
|
}
|
|
if len(registerRequest.Exports[0].Tags) != 2 || registerRequest.Exports[0].Tags[0] != "runtime" || registerRequest.Exports[0].Tags[1] != "finder" {
|
|
t.Fatalf("export tags = %#v, want [runtime finder]", registerRequest.Exports[0].Tags)
|
|
}
|
|
|
|
heartbeatRequest := awaitValue(t, heartbeatRequests, 5*time.Second, "heartbeat request")
|
|
if heartbeatRequest.NodeID != "runtime-node" {
|
|
t.Fatalf("heartbeat node id = %q, want runtime-node", heartbeatRequest.NodeID)
|
|
}
|
|
if heartbeatRequest.Status != "online" {
|
|
t.Fatalf("heartbeat status = %q, want online", heartbeatRequest.Status)
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, heartbeatRequest.LastSeenAt); err != nil {
|
|
t.Fatalf("heartbeat lastSeenAt parse: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeBinaryIntegratesWithRealControlPlane(t *testing.T) {
|
|
repoRoot := testRepoRoot(t)
|
|
nodeAgentBin := buildNodeAgentBinary(t, repoRoot)
|
|
controlPlaneBin := buildControlPlaneBinary(t, repoRoot)
|
|
nodeAgentPort := freePort(t)
|
|
controlPlanePort := freePort(t)
|
|
exportPath := filepath.Join(t.TempDir(), "export")
|
|
if err := os.MkdirAll(exportPath, 0o755); err != nil {
|
|
t.Fatalf("create export path: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exportPath, "seed.txt"), []byte("seed"), 0o644); err != nil {
|
|
t.Fatalf("write seed file: %v", err)
|
|
}
|
|
|
|
controlPlaneURL := "http://127.0.0.1:" + strconv.Itoa(controlPlanePort)
|
|
nodeAgentURL := "http://127.0.0.1:" + strconv.Itoa(nodeAgentPort)
|
|
controlPlane := startBinaryProcess(t, repoRoot, controlPlaneBin, []string{
|
|
"PORT=" + strconv.Itoa(controlPlanePort),
|
|
"BETTERNAS_VERSION=test-version",
|
|
"BETTERNAS_EXAMPLE_MOUNT_URL=" + nodeAgentURL + "/dav/",
|
|
"BETTERNAS_NODE_DIRECT_ADDRESS=" + nodeAgentURL,
|
|
})
|
|
defer controlPlane.stop(t)
|
|
|
|
waitForHTTPStatus(t, controlPlaneURL+"/health", http.StatusOK)
|
|
|
|
nodeAgent := startBinaryProcess(t, repoRoot, nodeAgentBin, []string{
|
|
"PORT=" + strconv.Itoa(nodeAgentPort),
|
|
"BETTERNAS_EXPORT_PATH=" + exportPath,
|
|
"BETTERNAS_NODE_MACHINE_ID=runtime-machine",
|
|
"BETTERNAS_NODE_DISPLAY_NAME=Runtime NAS",
|
|
"BETTERNAS_EXPORT_LABEL=runtime-export",
|
|
"BETTERNAS_NODE_DIRECT_ADDRESS=" + nodeAgentURL,
|
|
"BETTERNAS_CONTROL_PLANE_URL=" + controlPlaneURL,
|
|
"BETTERNAS_NODE_REGISTER_ENABLED=true",
|
|
"BETTERNAS_NODE_HEARTBEAT_ENABLED=true",
|
|
"BETTERNAS_NODE_HEARTBEAT_INTERVAL=100ms",
|
|
})
|
|
defer nodeAgent.stop(t)
|
|
|
|
waitForHTTPStatus(t, nodeAgentURL+"/health", http.StatusOK)
|
|
waitForProcessOutput(t, nodeAgent, 5*time.Second, "registered as dev-node")
|
|
waitForProcessOutput(t, nodeAgent, 5*time.Second, "stopping heartbeats")
|
|
|
|
mountProfileRequest, err := http.NewRequest(http.MethodPost, controlPlaneURL+"/api/v1/mount-profiles/issue", strings.NewReader(`{"userId":"integration-user","deviceId":"integration-device","exportId":"dev-export"}`))
|
|
if err != nil {
|
|
t.Fatalf("build mount profile request: %v", err)
|
|
}
|
|
mountProfileRequest.Header.Set("Content-Type", "application/json")
|
|
|
|
mountProfileResponse, err := http.DefaultClient.Do(mountProfileRequest)
|
|
if err != nil {
|
|
t.Fatalf("issue mount profile: %v", err)
|
|
}
|
|
defer mountProfileResponse.Body.Close()
|
|
|
|
if mountProfileResponse.StatusCode != http.StatusOK {
|
|
t.Fatalf("mount profile status = %d, want 200", mountProfileResponse.StatusCode)
|
|
}
|
|
|
|
var mountProfile struct {
|
|
Protocol string `json:"protocol"`
|
|
MountURL string `json:"mountUrl"`
|
|
}
|
|
if err := json.NewDecoder(mountProfileResponse.Body).Decode(&mountProfile); err != nil {
|
|
t.Fatalf("decode mount profile: %v", err)
|
|
}
|
|
|
|
if mountProfile.Protocol != "webdav" {
|
|
t.Fatalf("mount profile protocol = %q, want webdav", mountProfile.Protocol)
|
|
}
|
|
if mountProfile.MountURL != nodeAgentURL+"/dav/" {
|
|
t.Fatalf("mount profile url = %q, want %q", mountProfile.MountURL, nodeAgentURL+"/dav/")
|
|
}
|
|
|
|
propfindRequest, err := http.NewRequest("PROPFIND", mountProfile.MountURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("build mount-url propfind request: %v", err)
|
|
}
|
|
propfindRequest.Header.Set("Depth", "0")
|
|
|
|
propfindResponse, err := http.DefaultClient.Do(propfindRequest)
|
|
if err != nil {
|
|
t.Fatalf("propfind mount profile url: %v", err)
|
|
}
|
|
defer propfindResponse.Body.Close()
|
|
|
|
if propfindResponse.StatusCode != http.StatusMultiStatus {
|
|
t.Fatalf("propfind status = %d, want 207", propfindResponse.StatusCode)
|
|
}
|
|
|
|
getResponse, err := doRuntimeWebDAVRequest(nodeAgentURL, http.MethodGet, "/dav/seed.txt", nil)
|
|
if err != nil {
|
|
t.Fatalf("get WebDAV file after control-plane sync: %v", err)
|
|
}
|
|
defer getResponse.Body.Close()
|
|
|
|
getBody, err := io.ReadAll(getResponse.Body)
|
|
if err != nil {
|
|
t.Fatalf("read WebDAV body after control-plane sync: %v", err)
|
|
}
|
|
|
|
if getResponse.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status after control-plane sync = %d, want 200", getResponse.StatusCode)
|
|
}
|
|
if string(getBody) != "seed" {
|
|
t.Fatalf("get body after control-plane sync = %q, want seed", string(getBody))
|
|
}
|
|
}
|
|
|
|
func testRepoRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
_, filename, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("resolve runtime integration test filename")
|
|
}
|
|
|
|
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "..", ".."))
|
|
}
|
|
|
|
func buildNodeAgentBinary(t *testing.T, repoRoot string) string {
|
|
t.Helper()
|
|
|
|
binDir := t.TempDir()
|
|
nodeAgentBin := filepath.Join(binDir, binaryName("node-agent"))
|
|
buildBinary(t, repoRoot, "./apps/node-agent/cmd/node-agent", nodeAgentBin)
|
|
return nodeAgentBin
|
|
}
|
|
|
|
func buildControlPlaneBinary(t *testing.T, repoRoot string) string {
|
|
t.Helper()
|
|
|
|
binDir := t.TempDir()
|
|
controlPlaneBin := filepath.Join(binDir, binaryName("control-plane"))
|
|
buildBinary(t, repoRoot, "./apps/control-plane/cmd/control-plane", controlPlaneBin)
|
|
return controlPlaneBin
|
|
}
|
|
|
|
func binaryName(base string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return base + ".exe"
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
func buildBinary(t *testing.T, repoRoot, packagePath, outputPath string) {
|
|
t.Helper()
|
|
|
|
command := exec.Command("go", "build", "-o", outputPath, packagePath)
|
|
command.Dir = repoRoot
|
|
command.Env = mergedEnv([]string{"CGO_ENABLED=0"})
|
|
output, err := command.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("build %s: %v\n%s", packagePath, err, string(output))
|
|
}
|
|
}
|
|
|
|
func startBinaryProcess(t *testing.T, repoRoot, binaryPath string, env []string) *startedProcess {
|
|
t.Helper()
|
|
|
|
output := &lockedBuffer{}
|
|
command := exec.Command(binaryPath)
|
|
command.Dir = repoRoot
|
|
command.Env = mergedEnv(env)
|
|
command.Stdout = output
|
|
command.Stderr = output
|
|
|
|
if err := command.Start(); err != nil {
|
|
t.Fatalf("start %s: %v", binaryPath, err)
|
|
}
|
|
|
|
return &startedProcess{
|
|
cmd: command,
|
|
output: output,
|
|
}
|
|
}
|
|
|
|
func (p *startedProcess) stop(t *testing.T) {
|
|
t.Helper()
|
|
|
|
if p == nil || p.cmd == nil || p.cmd.Process == nil {
|
|
return
|
|
}
|
|
|
|
_ = p.cmd.Process.Kill()
|
|
|
|
waitDone := make(chan error, 1)
|
|
go func() {
|
|
waitDone <- p.cmd.Wait()
|
|
}()
|
|
|
|
select {
|
|
case err := <-waitDone:
|
|
if err != nil && !strings.Contains(err.Error(), "signal: killed") {
|
|
t.Fatalf("wait for %s: %v\n%s", p.cmd.Path, err, p.output.String())
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
_ = p.cmd.Process.Kill()
|
|
err := <-waitDone
|
|
if err != nil && !strings.Contains(err.Error(), "signal: killed") {
|
|
t.Fatalf("kill %s: %v\n%s", p.cmd.Path, err, p.output.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func freePort(t *testing.T) int {
|
|
t.Helper()
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen for free port: %v", err)
|
|
}
|
|
defer listener.Close()
|
|
|
|
return listener.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func waitForHTTPStatus(t *testing.T, target string, wantStatus int) {
|
|
t.Helper()
|
|
|
|
waitForCondition(t, 10*time.Second, target, func() bool {
|
|
response, err := http.Get(target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
return response.StatusCode == wantStatus
|
|
})
|
|
}
|
|
|
|
func waitForProcessOutput(t *testing.T, process *startedProcess, timeout time.Duration, fragment string) {
|
|
t.Helper()
|
|
|
|
waitForCondition(t, timeout, "process output "+fragment, func() bool {
|
|
return strings.Contains(process.output.String(), fragment)
|
|
})
|
|
}
|
|
|
|
func doRuntimeWebDAVRequest(baseURL, method, requestPath string, body io.Reader) (*http.Response, error) {
|
|
request, err := http.NewRequest(method, baseURL+requestPath, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return http.DefaultClient.Do(request)
|
|
}
|
|
|
|
func firstNonLoopbackIPv4() (string, bool) {
|
|
interfaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
for _, iface := range interfaces {
|
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
switch value := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = value.IP
|
|
case *net.IPAddr:
|
|
ip = value.IP
|
|
}
|
|
|
|
if ip == nil || ip.IsLoopback() {
|
|
continue
|
|
}
|
|
|
|
if ipv4 := ip.To4(); ipv4 != nil {
|
|
return ipv4.String(), true
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func mergedEnv(overrides []string) []string {
|
|
values := make(map[string]string)
|
|
|
|
for _, entry := range os.Environ() {
|
|
key, value, ok := strings.Cut(entry, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
values[key] = value
|
|
}
|
|
|
|
for _, entry := range overrides {
|
|
key, value, ok := strings.Cut(entry, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
values[key] = value
|
|
}
|
|
|
|
merged := make([]string, 0, len(values))
|
|
for key, value := range values {
|
|
merged = append(merged, key+"="+value)
|
|
}
|
|
sort.Strings(merged)
|
|
return merged
|
|
}
|