mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-16 14:01:06 +00:00
Make control-plane the real mount authority
Split node enrollment from export sync and issue Finder-compatible DAV credentials so the stack proves the real backend seam before any web UI consumes it.
This commit is contained in:
parent
5bc24fa99d
commit
b5f8ea9c52
28 changed files with 1345 additions and 423 deletions
|
|
@ -8,6 +8,7 @@ It is intentionally small for now:
|
|||
- `GET /version`
|
||||
- `POST /api/v1/nodes/register`
|
||||
- `POST /api/v1/nodes/{nodeId}/heartbeat`
|
||||
- `PUT /api/v1/nodes/{nodeId}/exports`
|
||||
- `GET /api/v1/exports`
|
||||
- `POST /api/v1/mount-profiles/issue`
|
||||
- `POST /api/v1/cloud-profiles/issue`
|
||||
|
|
@ -19,4 +20,7 @@ The request and response shapes must follow the contracts in
|
|||
`BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN`, client flows use
|
||||
`BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, and node registration returns an
|
||||
`X-BetterNAS-Node-Token` header for subsequent node-scoped register and
|
||||
heartbeat calls. Multi-export registrations should also send an explicit `mountPath` per export so mount profiles can stay stable across runtimes.
|
||||
heartbeat and export sync calls. Mount profiles now return standard WebDAV
|
||||
username and password credentials, and multi-export sync should send an
|
||||
explicit `mountPath` per export so mount profiles can stay stable across
|
||||
runtimes.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ type appConfig struct {
|
|||
statePath string
|
||||
clientToken string
|
||||
nodeBootstrapToken string
|
||||
davAuthSecret string
|
||||
davCredentialTTL time.Duration
|
||||
}
|
||||
|
||||
type app struct {
|
||||
|
|
@ -32,6 +34,14 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
|
|||
return nil, errors.New("node bootstrap token is required")
|
||||
}
|
||||
|
||||
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
|
||||
if config.davAuthSecret == "" {
|
||||
return nil, errors.New("dav auth secret is required")
|
||||
}
|
||||
if config.davCredentialTTL <= 0 {
|
||||
return nil, errors.New("dav credential ttl must be greater than 0")
|
||||
}
|
||||
|
||||
store, err := newMemoryStore(config.statePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -88,13 +98,20 @@ type storageExport struct {
|
|||
}
|
||||
|
||||
type mountProfile struct {
|
||||
ID string `json:"id"`
|
||||
ExportID string `json:"exportId"`
|
||||
Protocol string `json:"protocol"`
|
||||
DisplayName string `json:"displayName"`
|
||||
MountURL string `json:"mountUrl"`
|
||||
Readonly bool `json:"readonly"`
|
||||
CredentialMode string `json:"credentialMode"`
|
||||
ID string `json:"id"`
|
||||
ExportID string `json:"exportId"`
|
||||
Protocol string `json:"protocol"`
|
||||
DisplayName string `json:"displayName"`
|
||||
MountURL string `json:"mountUrl"`
|
||||
Readonly bool `json:"readonly"`
|
||||
Credential mountCredential `json:"credential"`
|
||||
}
|
||||
|
||||
type mountCredential struct {
|
||||
Mode string `json:"mode"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type cloudProfile struct {
|
||||
|
|
@ -115,12 +132,15 @@ type storageExportInput struct {
|
|||
}
|
||||
|
||||
type nodeRegistrationRequest struct {
|
||||
MachineID string `json:"machineId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
DirectAddress *string `json:"directAddress"`
|
||||
RelayAddress *string `json:"relayAddress"`
|
||||
Exports []storageExportInput `json:"exports"`
|
||||
MachineID string `json:"machineId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
DirectAddress *string `json:"directAddress"`
|
||||
RelayAddress *string `json:"relayAddress"`
|
||||
}
|
||||
|
||||
type nodeExportsRequest struct {
|
||||
Exports []storageExportInput `json:"exports"`
|
||||
}
|
||||
|
||||
type nodeHeartbeatRequest struct {
|
||||
|
|
@ -130,8 +150,6 @@ type nodeHeartbeatRequest struct {
|
|||
}
|
||||
|
||||
type mountProfileRequest struct {
|
||||
UserID string `json:"userId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ExportID string `json:"exportId"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
davCredentialTTL, err := parseRequiredDurationEnv("BETTERNAS_DAV_CREDENTIAL_TTL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newApp(
|
||||
appConfig{
|
||||
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
|
||||
|
|
@ -41,6 +51,8 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
|
|||
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
|
||||
clientToken: clientToken,
|
||||
nodeBootstrapToken: nodeBootstrapToken,
|
||||
davAuthSecret: davAuthSecret,
|
||||
davCredentialTTL: davCredentialTTL,
|
||||
},
|
||||
startedAt,
|
||||
)
|
||||
|
|
|
|||
71
apps/control-plane/cmd/control-plane/mount_credentials.go
Normal file
71
apps/control-plane/cmd/control-plane/mount_credentials.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mountCredentialModeBasicAuth = "basic-auth"
|
||||
|
||||
type signedMountCredentialClaims struct {
|
||||
Version int `json:"v"`
|
||||
NodeID string `json:"nodeId"`
|
||||
MountPath string `json:"mountPath"`
|
||||
Username string `json:"username"`
|
||||
Readonly bool `json:"readonly"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func issueMountCredential(secret string, nodeID string, mountPath string, readonly bool, issuedAt time.Time, ttl time.Duration) (string, mountCredential, error) {
|
||||
credentialID, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", mountCredential{}, err
|
||||
}
|
||||
|
||||
usernameToken, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", mountCredential{}, err
|
||||
}
|
||||
|
||||
claims := signedMountCredentialClaims{
|
||||
Version: 1,
|
||||
NodeID: nodeID,
|
||||
MountPath: mountPath,
|
||||
Username: "mount-" + usernameToken,
|
||||
Readonly: readonly,
|
||||
ExpiresAt: issuedAt.UTC().Add(ttl).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
password, err := signMountCredentialClaims(secret, claims)
|
||||
if err != nil {
|
||||
return "", mountCredential{}, err
|
||||
}
|
||||
|
||||
return "mount-" + credentialID, mountCredential{
|
||||
Mode: mountCredentialModeBasicAuth,
|
||||
Username: claims.Username,
|
||||
Password: password,
|
||||
ExpiresAt: claims.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func signMountCredentialClaims(secret string, claims signedMountCredentialClaims) (string, error) {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode mount credential claims: %w", err)
|
||||
}
|
||||
|
||||
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
||||
signature := signMountCredentialPayload(secret, encodedPayload)
|
||||
return encodedPayload + "." + signature, nil
|
||||
}
|
||||
|
||||
func signMountCredentialPayload(secret string, encodedPayload string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(encodedPayload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
|
@ -31,6 +31,11 @@ var (
|
|||
nodeAgentBinaryErr error
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeDAVAuthSecret = "runtime-dav-auth-secret"
|
||||
runtimeDAVCredentialTTL = "1h"
|
||||
)
|
||||
|
||||
func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
|
||||
exportDir := t.TempDir()
|
||||
writeExportFile(t, exportDir, "README.txt", "betterNAS export\n")
|
||||
|
|
@ -38,166 +43,42 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
|
|||
nextcloud := httptest.NewServer(http.NotFoundHandler())
|
||||
defer nextcloud.Close()
|
||||
|
||||
nodeAgent := startNodeAgentBinary(t, exportDir)
|
||||
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
|
||||
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{exportDir}, "machine-runtime-1")
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
directAddress := nodeAgent.baseURL
|
||||
registration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-runtime-1",
|
||||
DisplayName: "Runtime NAS",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Photos",
|
||||
Path: exportDir,
|
||||
MountPath: defaultWebDAVPath,
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"runtime"},
|
||||
}},
|
||||
})
|
||||
if registration.Node.ID != "dev-node" {
|
||||
t.Fatalf("expected node ID %q, got %q", "dev-node", registration.Node.ID)
|
||||
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{exportDir})
|
||||
export := exports[exportDir]
|
||||
if export.ID != "dev-export" {
|
||||
t.Fatalf("expected export ID %q, got %q", "dev-export", export.ID)
|
||||
}
|
||||
if registration.NodeToken == "" {
|
||||
t.Fatal("expected runtime registration to return a node token")
|
||||
}
|
||||
|
||||
exports := getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports")
|
||||
if len(exports) != 1 {
|
||||
t.Fatalf("expected 1 export, got %d", len(exports))
|
||||
}
|
||||
if exports[0].ID != "dev-export" {
|
||||
t.Fatalf("expected export ID %q, got %q", "dev-export", exports[0].ID)
|
||||
}
|
||||
if exports[0].Path != exportDir {
|
||||
t.Fatalf("expected exported path %q, got %q", exportDir, exports[0].Path)
|
||||
}
|
||||
if exports[0].MountPath != defaultWebDAVPath {
|
||||
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, exports[0].MountPath)
|
||||
if export.MountPath != defaultWebDAVPath {
|
||||
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, export.MountPath)
|
||||
}
|
||||
|
||||
mount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "runtime-user",
|
||||
DeviceID: "runtime-device",
|
||||
ExportID: exports[0].ID,
|
||||
ExportID: export.ID,
|
||||
})
|
||||
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
|
||||
t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL)
|
||||
}
|
||||
if mount.Credential.Mode != mountCredentialModeBasicAuth {
|
||||
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
|
||||
}
|
||||
|
||||
assertHTTPStatus(t, client, "PROPFIND", mount.MountURL, http.StatusMultiStatus)
|
||||
assertMountedFileContents(t, client, mount.MountURL+"README.txt", "betterNAS export\n")
|
||||
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", mount.MountURL, mount.Credential.Username, mount.Credential.Password, http.StatusMultiStatus)
|
||||
assertMountedFileContentsWithBasicAuth(t, client, mount.MountURL+"README.txt", mount.Credential.Username, mount.Credential.Password, "betterNAS export\n")
|
||||
|
||||
cloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
|
||||
UserID: "runtime-user",
|
||||
ExportID: exports[0].ID,
|
||||
ExportID: export.ID,
|
||||
Provider: "nextcloud",
|
||||
})
|
||||
if cloud.BaseURL != nextcloud.URL {
|
||||
t.Fatalf("expected runtime cloud baseUrl %q, got %q", nextcloud.URL, cloud.BaseURL)
|
||||
}
|
||||
expectedCloudPath := cloudProfilePathForExport(exports[0].ID)
|
||||
if cloud.Path != expectedCloudPath {
|
||||
t.Fatalf("expected runtime cloud path %q, got %q", expectedCloudPath, cloud.Path)
|
||||
}
|
||||
|
||||
postJSONAuthStatus(t, client, registration.NodeToken, controlPlane.baseURL+"/api/v1/nodes/"+registration.Node.ID+"/heartbeat", nodeHeartbeatRequest{
|
||||
NodeID: registration.Node.ID,
|
||||
Status: "online",
|
||||
LastSeenAt: "2025-01-02T03:04:05Z",
|
||||
}, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestControlPlaneBinaryReRegistrationReconcilesExports(t *testing.T) {
|
||||
nextcloud := httptest.NewServer(http.NotFoundHandler())
|
||||
defer nextcloud.Close()
|
||||
|
||||
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
directAddress := "http://nas.local:8090"
|
||||
firstRegistration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-runtime-2",
|
||||
DisplayName: "Runtime NAS",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/exports/docs/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"runtime"},
|
||||
},
|
||||
{
|
||||
Label: "Media",
|
||||
Path: "/srv/media",
|
||||
MountPath: "/dav/exports/media/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"runtime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
initialExports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
|
||||
docsExport := initialExports["/srv/docs"]
|
||||
if _, ok := initialExports["/srv/media"]; !ok {
|
||||
t.Fatal("expected media export to be registered")
|
||||
}
|
||||
|
||||
secondRegistration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", firstRegistration.NodeToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-runtime-2",
|
||||
DisplayName: "Runtime NAS Updated",
|
||||
AgentVersion: "1.2.4",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs v2",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/exports/docs-v2/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"runtime", "updated"},
|
||||
},
|
||||
{
|
||||
Label: "Backups",
|
||||
Path: "/srv/backups",
|
||||
MountPath: "/dav/exports/backups/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"runtime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if secondRegistration.Node.ID != firstRegistration.Node.ID {
|
||||
t.Fatalf("expected node ID %q after re-registration, got %q", firstRegistration.Node.ID, secondRegistration.Node.ID)
|
||||
}
|
||||
|
||||
updatedExports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
|
||||
if len(updatedExports) != 2 {
|
||||
t.Fatalf("expected 2 exports after re-registration, got %d", len(updatedExports))
|
||||
}
|
||||
if updatedExports["/srv/docs"].ID != docsExport.ID {
|
||||
t.Fatalf("expected docs export to keep ID %q, got %q", docsExport.ID, updatedExports["/srv/docs"].ID)
|
||||
}
|
||||
if updatedExports["/srv/docs"].Label != "Docs v2" {
|
||||
t.Fatalf("expected docs export label to update, got %q", updatedExports["/srv/docs"].Label)
|
||||
}
|
||||
if updatedExports["/srv/docs"].MountPath != "/dav/exports/docs-v2/" {
|
||||
t.Fatalf("expected docs export mountPath to update, got %q", updatedExports["/srv/docs"].MountPath)
|
||||
}
|
||||
if _, ok := updatedExports["/srv/media"]; ok {
|
||||
t.Fatal("expected stale media export to be removed")
|
||||
}
|
||||
if _, ok := updatedExports["/srv/backups"]; !ok {
|
||||
t.Fatal("expected backups export to be present")
|
||||
if cloud.Path != cloudProfilePathForExport(export.ID) {
|
||||
t.Fatalf("expected runtime cloud path %q, got %q", cloudProfilePathForExport(export.ID), cloud.Path)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,53 +91,18 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
|
|||
nextcloud := httptest.NewServer(http.NotFoundHandler())
|
||||
defer nextcloud.Close()
|
||||
|
||||
nodeAgent := startNodeAgentBinaryWithExports(t, []string{firstExportDir, secondExportDir})
|
||||
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
|
||||
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{firstExportDir, secondExportDir}, "machine-runtime-multi")
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
firstMountPath := nodeAgentMountPathForExport(firstExportDir, 2)
|
||||
secondMountPath := nodeAgentMountPathForExport(secondExportDir, 2)
|
||||
directAddress := nodeAgent.baseURL
|
||||
registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-runtime-multi",
|
||||
DisplayName: "Runtime Multi NAS",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs",
|
||||
Path: firstExportDir,
|
||||
MountPath: firstMountPath,
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"runtime"},
|
||||
},
|
||||
{
|
||||
Label: "Media",
|
||||
Path: secondExportDir,
|
||||
MountPath: secondMountPath,
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"runtime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
exports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
|
||||
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{firstExportDir, secondExportDir})
|
||||
firstExport := exports[firstExportDir]
|
||||
secondExport := exports[secondExportDir]
|
||||
|
||||
firstMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "runtime-user",
|
||||
DeviceID: "runtime-device",
|
||||
ExportID: firstExport.ID,
|
||||
})
|
||||
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "runtime-user",
|
||||
DeviceID: "runtime-device",
|
||||
ExportID: secondExport.ID,
|
||||
})
|
||||
firstMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: firstExport.ID})
|
||||
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: secondExport.ID})
|
||||
if firstMount.MountURL == secondMount.MountURL {
|
||||
t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL)
|
||||
}
|
||||
|
|
@ -267,10 +113,10 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
|
|||
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
|
||||
}
|
||||
|
||||
assertHTTPStatus(t, client, "PROPFIND", firstMount.MountURL, http.StatusMultiStatus)
|
||||
assertHTTPStatus(t, client, "PROPFIND", secondMount.MountURL, http.StatusMultiStatus)
|
||||
assertMountedFileContents(t, client, firstMount.MountURL+"README.txt", "first runtime export\n")
|
||||
assertMountedFileContents(t, client, secondMount.MountURL+"README.txt", "second runtime export\n")
|
||||
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, firstMount.Credential.Username, firstMount.Credential.Password, http.StatusMultiStatus)
|
||||
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", secondMount.MountURL, secondMount.Credential.Username, secondMount.Credential.Password, http.StatusMultiStatus)
|
||||
assertMountedFileContentsWithBasicAuth(t, client, firstMount.MountURL+"README.txt", firstMount.Credential.Username, firstMount.Credential.Password, "first runtime export\n")
|
||||
assertMountedFileContentsWithBasicAuth(t, client, secondMount.MountURL+"README.txt", secondMount.Credential.Username, secondMount.Credential.Password, "second runtime export\n")
|
||||
|
||||
firstCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
|
||||
UserID: "runtime-user",
|
||||
|
|
@ -319,6 +165,8 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
|
|||
"BETTERNAS_CONTROL_PLANE_STATE_PATH="+statePath,
|
||||
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN="+testClientToken,
|
||||
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
|
||||
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
|
||||
"BETTERNAS_DAV_CREDENTIAL_TTL="+runtimeDAVCredentialTTL,
|
||||
)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
|
@ -343,15 +191,13 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
|
|||
}
|
||||
}
|
||||
|
||||
func startNodeAgentBinary(t *testing.T, exportPath string) runningBinary {
|
||||
return startNodeAgentBinaryWithExports(t, []string{exportPath})
|
||||
}
|
||||
|
||||
func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) runningBinary {
|
||||
func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, exportPaths []string, machineID string) runningBinary {
|
||||
t.Helper()
|
||||
|
||||
port := reserveTCPPort(t)
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
|
||||
logPath := filepath.Join(t.TempDir(), "node-agent.log")
|
||||
nodeTokenPath := filepath.Join(t.TempDir(), "node-token")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create node-agent log file: %v", err)
|
||||
|
|
@ -368,6 +214,14 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
|
|||
os.Environ(),
|
||||
"PORT="+port,
|
||||
"BETTERNAS_EXPORT_PATHS_JSON="+string(rawExportPaths),
|
||||
"BETTERNAS_CONTROL_PLANE_URL="+controlPlaneBaseURL,
|
||||
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
|
||||
"BETTERNAS_NODE_TOKEN_PATH="+nodeTokenPath,
|
||||
"BETTERNAS_NODE_MACHINE_ID="+machineID,
|
||||
"BETTERNAS_NODE_DISPLAY_NAME="+machineID,
|
||||
"BETTERNAS_NODE_DIRECT_ADDRESS="+baseURL,
|
||||
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
|
||||
"BETTERNAS_VERSION=runtime-test-version",
|
||||
)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
|
@ -382,7 +236,6 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
|
|||
waitDone <- cmd.Wait()
|
||||
}()
|
||||
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
|
||||
waitForHTTPStatus(t, baseURL+"/health", waitDone, logPath, http.StatusOK)
|
||||
registerProcessCleanup(t, ctx, cancel, cmd, waitDone, logFile, logPath, "node-agent")
|
||||
|
||||
|
|
@ -392,6 +245,30 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
|
|||
}
|
||||
}
|
||||
|
||||
func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, expectedPaths []string) map[string]storageExport {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
exports := getJSONAuth[[]storageExport](t, client, testClientToken, endpoint)
|
||||
exportsByPath := exportsByPath(exports)
|
||||
allPresent := true
|
||||
for _, expectedPath := range expectedPaths {
|
||||
if _, ok := exportsByPath[expectedPath]; !ok {
|
||||
allPresent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allPresent {
|
||||
return exportsByPath
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("exports for %v did not appear in time", expectedPaths)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildControlPlaneBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -411,6 +288,7 @@ func buildControlPlaneBinary(t *testing.T) string {
|
|||
controlPlaneBinaryPath = filepath.Join(tempDir, "control-plane")
|
||||
cmd := exec.Command("go", "build", "-o", controlPlaneBinaryPath, ".")
|
||||
cmd.Dir = filepath.Dir(filename)
|
||||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
controlPlaneBinaryErr = fmt.Errorf("build control-plane binary: %w\n%s", err, output)
|
||||
|
|
@ -443,6 +321,7 @@ func buildNodeAgentBinary(t *testing.T) string {
|
|||
nodeAgentBinaryPath = filepath.Join(tempDir, "node-agent")
|
||||
cmd := exec.Command("go", "build", "-o", nodeAgentBinaryPath, "./cmd/node-agent")
|
||||
cmd.Dir = filepath.Clean(filepath.Join(filepath.Dir(filename), "../../../node-agent"))
|
||||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
nodeAgentBinaryErr = fmt.Errorf("build node-agent binary: %w\n%s", err, output)
|
||||
|
|
@ -532,10 +411,16 @@ func registerProcessCleanup(t *testing.T, ctx context.Context, cancel context.Ca
|
|||
})
|
||||
}
|
||||
|
||||
func assertMountedFileContents(t *testing.T, client *http.Client, endpoint string, expected string) {
|
||||
func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, endpoint string, username string, password string, expected string) {
|
||||
t.Helper()
|
||||
|
||||
response, err := client.Get(endpoint)
|
||||
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build GET request for %s: %v", endpoint, err)
|
||||
}
|
||||
request.SetBasicAuth(username, password)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", endpoint, err)
|
||||
}
|
||||
|
|
@ -554,13 +439,14 @@ func assertMountedFileContents(t *testing.T, client *http.Client, endpoint strin
|
|||
}
|
||||
}
|
||||
|
||||
func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint string, expectedStatus int) {
|
||||
func assertHTTPStatusWithBasicAuth(t *testing.T, client *http.Client, method string, endpoint string, username string, password string, expectedStatus int) {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequest(method, endpoint, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build %s request for %s: %v", method, endpoint, err)
|
||||
}
|
||||
request.SetBasicAuth(username, password)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ func (a *app) handler() http.Handler {
|
|||
mux.HandleFunc("GET /version", a.handleVersion)
|
||||
mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
|
||||
mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports)
|
||||
mux.HandleFunc("GET /api/v1/exports", a.handleExportsList)
|
||||
mux.HandleFunc("POST /api/v1/mount-profiles/issue", a.handleMountProfileIssue)
|
||||
mux.HandleFunc("POST /api/v1/cloud-profiles/issue", a.handleCloudProfileIssue)
|
||||
|
|
@ -127,6 +128,37 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID := r.PathValue("nodeId")
|
||||
|
||||
request, err := decodeNodeExportsRequest(w, r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateNodeExportsRequest(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !a.authorizeNode(w, r, nodeID) {
|
||||
return
|
||||
}
|
||||
|
||||
exports, err := a.store.upsertExports(nodeID, request)
|
||||
if err != nil {
|
||||
statusCode := http.StatusInternalServerError
|
||||
if errors.Is(err, errNodeNotFound) {
|
||||
statusCode = http.StatusNotFound
|
||||
}
|
||||
http.Error(w, err.Error(), statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, exports)
|
||||
}
|
||||
|
||||
func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireClientAuth(w, r) {
|
||||
return
|
||||
|
|
@ -163,14 +195,27 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
credentialID, credential, err := issueMountCredential(
|
||||
a.config.davAuthSecret,
|
||||
context.node.ID,
|
||||
mountProfilePathForExport(context.export.MountPath),
|
||||
false,
|
||||
a.now(),
|
||||
a.config.davCredentialTTL,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, mountProfile{
|
||||
ID: fmt.Sprintf("mount-%s-%s", request.DeviceID, context.export.ID),
|
||||
ExportID: context.export.ID,
|
||||
Protocol: "webdav",
|
||||
DisplayName: context.export.Label,
|
||||
MountURL: mountURL,
|
||||
Readonly: false,
|
||||
CredentialMode: "session-token",
|
||||
ID: credentialID,
|
||||
ExportID: context.export.ID,
|
||||
Protocol: "webdav",
|
||||
DisplayName: context.export.Label,
|
||||
MountURL: mountURL,
|
||||
Readonly: false,
|
||||
Credential: credential,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +271,6 @@ func decodeNodeRegistrationRequest(w http.ResponseWriter, r *http.Request) (node
|
|||
"agentVersion",
|
||||
"directAddress",
|
||||
"relayAddress",
|
||||
"exports",
|
||||
); err != nil {
|
||||
return nodeRegistrationRequest{}, err
|
||||
}
|
||||
|
|
@ -258,9 +302,22 @@ func decodeNodeRegistrationRequest(w http.ResponseWriter, r *http.Request) (node
|
|||
return nodeRegistrationRequest{}, err
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func decodeNodeExportsRequest(w http.ResponseWriter, r *http.Request) (nodeExportsRequest, error) {
|
||||
object, err := decodeRawObjectRequest(w, r)
|
||||
if err != nil {
|
||||
return nodeExportsRequest{}, err
|
||||
}
|
||||
if err := object.validateRequiredKeys("exports"); err != nil {
|
||||
return nodeExportsRequest{}, err
|
||||
}
|
||||
|
||||
request := nodeExportsRequest{}
|
||||
request.Exports, err = object.storageExportInputsField("exports")
|
||||
if err != nil {
|
||||
return nodeRegistrationRequest{}, err
|
||||
return nodeExportsRequest{}, err
|
||||
}
|
||||
|
||||
return request, nil
|
||||
|
|
@ -495,10 +552,18 @@ func validateNodeRegistrationRequest(request *nodeRegistrationRequest) error {
|
|||
return err
|
||||
}
|
||||
|
||||
seenPaths := make(map[string]struct{}, len(request.Exports))
|
||||
seenMountPaths := make(map[string]struct{}, len(request.Exports))
|
||||
for index := range request.Exports {
|
||||
export := &request.Exports[index]
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNodeExportsRequest(request nodeExportsRequest) error {
|
||||
return validateStorageExportInputs(request.Exports)
|
||||
}
|
||||
|
||||
func validateStorageExportInputs(exports []storageExportInput) error {
|
||||
seenPaths := make(map[string]struct{}, len(exports))
|
||||
seenMountPaths := make(map[string]struct{}, len(exports))
|
||||
for index := range exports {
|
||||
export := &exports[index]
|
||||
export.Label = strings.TrimSpace(export.Label)
|
||||
if export.Label == "" {
|
||||
return fmt.Errorf("exports[%d].label is required", index)
|
||||
|
|
@ -514,7 +579,7 @@ func validateNodeRegistrationRequest(request *nodeRegistrationRequest) error {
|
|||
seenPaths[export.Path] = struct{}{}
|
||||
|
||||
export.MountPath = strings.TrimSpace(export.MountPath)
|
||||
if len(request.Exports) > 1 && export.MountPath == "" {
|
||||
if len(exports) > 1 && export.MountPath == "" {
|
||||
return fmt.Errorf("exports[%d].mountPath is required when registering multiple exports", index)
|
||||
}
|
||||
if export.MountPath != "" {
|
||||
|
|
@ -567,12 +632,6 @@ func validateNodeHeartbeatRequest(nodeID string, request nodeHeartbeatRequest) e
|
|||
}
|
||||
|
||||
func validateMountProfileRequest(request mountProfileRequest) error {
|
||||
if strings.TrimSpace(request.UserID) == "" {
|
||||
return errors.New("userId is required")
|
||||
}
|
||||
if strings.TrimSpace(request.DeviceID) == "" {
|
||||
return errors.New("deviceId is required")
|
||||
}
|
||||
if strings.TrimSpace(request.ExportID) == "" {
|
||||
return errors.New("exportId is required")
|
||||
}
|
||||
|
|
@ -773,6 +832,23 @@ func requiredEnv(key string) (string, error) {
|
|||
return value, nil
|
||||
}
|
||||
|
||||
func parseRequiredDurationEnv(key string) (time.Duration, error) {
|
||||
value, err := requiredEnv(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a valid duration: %w", key, err)
|
||||
}
|
||||
if duration <= 0 {
|
||||
return 0, fmt.Errorf("%s must be greater than 0", key)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error {
|
||||
defer r.Body.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -92,16 +92,23 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
|
|||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: &relayAddress,
|
||||
})
|
||||
if registration.NodeToken == "" {
|
||||
t.Fatal("expected node registration to return a node token")
|
||||
}
|
||||
|
||||
syncedExports := syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Photos",
|
||||
Path: "/srv/photos",
|
||||
MountPath: "/dav/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"family"},
|
||||
}},
|
||||
})
|
||||
if registration.NodeToken == "" {
|
||||
t.Fatal("expected node registration to return a node token")
|
||||
if len(syncedExports) != 1 {
|
||||
t.Fatalf("expected sync to return 1 export, got %d", len(syncedExports))
|
||||
}
|
||||
|
||||
node := registration.Node
|
||||
|
|
@ -137,13 +144,11 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
|
|||
if exports[0].Path != "/srv/photos" {
|
||||
t.Fatalf("expected export path %q, got %q", "/srv/photos", exports[0].Path)
|
||||
}
|
||||
if exports[0].MountPath != "" {
|
||||
t.Fatalf("expected empty mountPath for default export, got %q", exports[0].MountPath)
|
||||
if exports[0].MountPath != "/dav/" {
|
||||
t.Fatalf("expected mountPath %q, got %q", "/dav/", exports[0].MountPath)
|
||||
}
|
||||
|
||||
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "user-1",
|
||||
DeviceID: "device-1",
|
||||
ExportID: exports[0].ID,
|
||||
})
|
||||
if mount.ExportID != exports[0].ID {
|
||||
|
|
@ -161,8 +166,17 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
|
|||
if mount.Readonly {
|
||||
t.Fatal("expected mount profile to be read-write")
|
||||
}
|
||||
if mount.CredentialMode != "session-token" {
|
||||
t.Fatalf("expected credentialMode session-token, got %q", mount.CredentialMode)
|
||||
if mount.Credential.Mode != mountCredentialModeBasicAuth {
|
||||
t.Fatalf("expected credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
|
||||
}
|
||||
if mount.Credential.Username == "" {
|
||||
t.Fatal("expected mount credential username to be set")
|
||||
}
|
||||
if mount.Credential.Password == "" {
|
||||
t.Fatal("expected mount credential password to be set")
|
||||
}
|
||||
if mount.Credential.ExpiresAt == "" {
|
||||
t.Fatal("expected mount credential expiry to be set")
|
||||
}
|
||||
|
||||
cloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
|
||||
|
|
@ -202,7 +216,7 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing.T) {
|
||||
func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, server := newTestControlPlaneServer(t, appConfig{version: "test-version"})
|
||||
|
|
@ -215,6 +229,30 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
|
|||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
})
|
||||
|
||||
putJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/exports/docs/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"work"},
|
||||
},
|
||||
{
|
||||
Label: "Media",
|
||||
Path: "/srv/media",
|
||||
MountPath: "/dav/exports/media/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"personal"},
|
||||
},
|
||||
},
|
||||
}, http.StatusUnauthorized)
|
||||
|
||||
syncNodeExports(t, server.Client(), firstRegistration.NodeToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs",
|
||||
|
|
@ -235,22 +273,6 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
|
|||
},
|
||||
})
|
||||
|
||||
postJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", nodeRegistrationRequest{
|
||||
MachineID: "machine-1",
|
||||
DisplayName: "Unauthorized Re-register",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Docs",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/exports/docs/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"work"},
|
||||
}},
|
||||
}, http.StatusUnauthorized)
|
||||
|
||||
initialExports := exportsByPath(getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports"))
|
||||
docsExport := initialExports["/srv/docs"]
|
||||
mediaExport := initialExports["/srv/media"]
|
||||
|
|
@ -261,6 +283,30 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
|
|||
AgentVersion: "1.2.4",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
})
|
||||
|
||||
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs v2",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/exports/docs-v2/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"work", "updated"},
|
||||
},
|
||||
{
|
||||
Label: "Backups",
|
||||
Path: "/srv/backups",
|
||||
MountPath: "/dav/exports/backups/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{"system"},
|
||||
},
|
||||
},
|
||||
}, http.StatusUnauthorized)
|
||||
|
||||
syncNodeExports(t, server.Client(), firstRegistration.NodeToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs v2",
|
||||
|
|
@ -330,12 +376,14 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test
|
|||
defer server.Close()
|
||||
|
||||
directAddress := "http://nas.local:8090"
|
||||
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-multi",
|
||||
DisplayName: "Multi Export NAS",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
})
|
||||
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs",
|
||||
|
|
@ -360,16 +408,8 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test
|
|||
docsExport := exports["/srv/docs"]
|
||||
mediaExport := exports["/srv/media"]
|
||||
|
||||
docsMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "user-1",
|
||||
DeviceID: "device-1",
|
||||
ExportID: docsExport.ID,
|
||||
})
|
||||
mediaMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "user-1",
|
||||
DeviceID: "device-1",
|
||||
ExportID: mediaExport.ID,
|
||||
})
|
||||
docsMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: docsExport.ID})
|
||||
mediaMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: mediaExport.ID})
|
||||
if docsMount.MountURL == mediaMount.MountURL {
|
||||
t.Fatalf("expected distinct mount URLs for configured export paths, got %q", docsMount.MountURL)
|
||||
}
|
||||
|
|
@ -408,12 +448,14 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
|
|||
defer server.Close()
|
||||
|
||||
relayAddress := "https://nas.example.test/control"
|
||||
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-relay",
|
||||
DisplayName: "Relay NAS",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: nil,
|
||||
RelayAddress: &relayAddress,
|
||||
})
|
||||
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Relay",
|
||||
Path: "/srv/relay",
|
||||
|
|
@ -424,35 +466,30 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
|
|||
}},
|
||||
})
|
||||
|
||||
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "user-1",
|
||||
DeviceID: "device-1",
|
||||
ExportID: "dev-export",
|
||||
})
|
||||
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"})
|
||||
if mount.MountURL != "https://nas.example.test/control/dav/relay/" {
|
||||
t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL)
|
||||
}
|
||||
|
||||
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-no-target",
|
||||
DisplayName: "No Target NAS",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: nil,
|
||||
RelayAddress: nil,
|
||||
})
|
||||
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Offline",
|
||||
Path: "/srv/offline",
|
||||
MountPath: "/dav/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
}},
|
||||
})
|
||||
|
||||
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "user-1",
|
||||
DeviceID: "device-2",
|
||||
ExportID: "dev-export-2",
|
||||
}, http.StatusServiceUnavailable)
|
||||
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export-2"}, http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *testing.T) {
|
||||
|
|
@ -462,15 +499,18 @@ func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *t
|
|||
defer server.Close()
|
||||
|
||||
directAddress := "http://nas.local:8090"
|
||||
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||
MachineID: "machine-cloud",
|
||||
DisplayName: "Cloud NAS",
|
||||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
})
|
||||
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Photos",
|
||||
Path: "/srv/photos",
|
||||
MountPath: "/dav/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
|
|
@ -512,6 +552,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
|
|||
AgentVersion: "1.2.3",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
})
|
||||
syncNodeExports(t, firstServer.Client(), registration.NodeToken, firstServer.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Docs",
|
||||
Path: "/srv/docs",
|
||||
|
|
@ -540,11 +582,7 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
|
|||
t.Fatalf("expected persisted mountPath %q, got %q", "/dav/persisted/", exports[0].MountPath)
|
||||
}
|
||||
|
||||
mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "user-1",
|
||||
DeviceID: "device-1",
|
||||
ExportID: exports[0].ID,
|
||||
})
|
||||
mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: exports[0].ID})
|
||||
if mount.MountURL != "http://nas.local:8090/dav/persisted/" {
|
||||
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL)
|
||||
}
|
||||
|
|
@ -555,6 +593,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
|
|||
AgentVersion: "1.2.4",
|
||||
DirectAddress: &directAddress,
|
||||
RelayAddress: nil,
|
||||
})
|
||||
syncNodeExports(t, secondServer.Client(), registration.NodeToken, secondServer.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Docs Updated",
|
||||
Path: "/srv/docs",
|
||||
|
|
@ -580,16 +620,14 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
|
|||
"displayName":"Primary NAS",
|
||||
"agentVersion":"1.2.3",
|
||||
"directAddress":"http://nas.local:8090",
|
||||
"relayAddress":null,
|
||||
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
|
||||
"relayAddress":null
|
||||
}`, http.StatusUnauthorized)
|
||||
|
||||
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
|
||||
"machineId":"machine-1",
|
||||
"displayName":"Primary NAS",
|
||||
"agentVersion":"1.2.3",
|
||||
"relayAddress":null,
|
||||
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
|
||||
"relayAddress":null
|
||||
}`, http.StatusBadRequest)
|
||||
|
||||
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
|
||||
|
|
@ -597,32 +635,6 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
|
|||
"displayName":"Primary NAS",
|
||||
"agentVersion":"1.2.3",
|
||||
"directAddress":"nas.local:8090",
|
||||
"relayAddress":null,
|
||||
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
|
||||
}`, http.StatusBadRequest)
|
||||
|
||||
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
|
||||
"machineId":"machine-1",
|
||||
"displayName":"Primary NAS",
|
||||
"agentVersion":"1.2.3",
|
||||
"directAddress":"http://nas.local:8090",
|
||||
"relayAddress":null,
|
||||
"exports":[
|
||||
{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]},
|
||||
{"label":"Docs Duplicate","path":"/srv/docs-2","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]}
|
||||
]
|
||||
}`, http.StatusBadRequest)
|
||||
|
||||
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
|
||||
"machineId":"machine-1",
|
||||
"displayName":"Primary NAS",
|
||||
"agentVersion":"1.2.3",
|
||||
"directAddress":"http://nas.local:8090",
|
||||
"relayAddress":null,
|
||||
"exports":[
|
||||
{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]},
|
||||
{"label":"Media","path":"/srv/media","protocols":["webdav"],"capacityBytes":null,"tags":[]}
|
||||
]
|
||||
}`, http.StatusBadRequest)
|
||||
|
||||
response := postRawJSONAuth(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
|
||||
|
|
@ -631,8 +643,7 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
|
|||
"agentVersion":"1.2.3",
|
||||
"directAddress":"http://nas.local:8090",
|
||||
"relayAddress":null,
|
||||
"ignoredTopLevel":"ok",
|
||||
"exports":[{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[],"ignoredNested":"ok"}]
|
||||
"ignoredTopLevel":"ok"
|
||||
}`)
|
||||
defer response.Body.Close()
|
||||
|
||||
|
|
@ -653,6 +664,58 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
|
|||
t.Fatalf("expected node ID %q, got %q", "dev-node", node.ID)
|
||||
}
|
||||
|
||||
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{{
|
||||
Label: "Docs",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/docs/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
}},
|
||||
}, http.StatusUnauthorized)
|
||||
|
||||
putJSONAuthStatus(t, server.Client(), nodeToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/docs/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
},
|
||||
{
|
||||
Label: "Media",
|
||||
Path: "/srv/media",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}, http.StatusBadRequest)
|
||||
|
||||
putJSONAuthStatus(t, server.Client(), nodeToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
|
||||
Exports: []storageExportInput{
|
||||
{
|
||||
Label: "Docs",
|
||||
Path: "/srv/docs",
|
||||
MountPath: "/dav/docs/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
},
|
||||
{
|
||||
Label: "Docs Duplicate",
|
||||
Path: "/srv/docs-2",
|
||||
MountPath: "/dav/docs/",
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}, http.StatusBadRequest)
|
||||
|
||||
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{
|
||||
NodeID: node.ID,
|
||||
Status: "online",
|
||||
|
|
@ -686,9 +749,9 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
|
|||
getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/exports", http.StatusUnauthorized)
|
||||
getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/exports", http.StatusUnauthorized)
|
||||
|
||||
postRawJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", `{}`, http.StatusBadRequest)
|
||||
|
||||
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||
UserID: "user-1",
|
||||
DeviceID: "device-1",
|
||||
ExportID: "missing-export",
|
||||
}, http.StatusNotFound)
|
||||
|
||||
|
|
@ -711,6 +774,12 @@ func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.
|
|||
if config.nodeBootstrapToken == "" {
|
||||
config.nodeBootstrapToken = testNodeBootstrapToken
|
||||
}
|
||||
if config.davAuthSecret == "" {
|
||||
config.davAuthSecret = "test-dav-auth-secret"
|
||||
}
|
||||
if config.davCredentialTTL == 0 {
|
||||
config.davCredentialTTL = time.Hour
|
||||
}
|
||||
|
||||
app, err := newApp(config, testControlPlaneNow)
|
||||
if err != nil {
|
||||
|
|
@ -755,6 +824,25 @@ func registerNode(t *testing.T, client *http.Client, endpoint string, token stri
|
|||
}
|
||||
}
|
||||
|
||||
func syncNodeExports(t *testing.T, client *http.Client, token string, endpoint string, payload nodeExportsRequest) []storageExport {
|
||||
t.Helper()
|
||||
|
||||
response := putJSONAuthResponse(t, client, token, endpoint, payload)
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
responseBody, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("put %s: expected status 200, got %d: %s", endpoint, response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var exports []storageExport
|
||||
if err := json.NewDecoder(response.Body).Decode(&exports); err != nil {
|
||||
t.Fatalf("decode %s response: %v", endpoint, err)
|
||||
}
|
||||
|
||||
return exports
|
||||
}
|
||||
|
||||
func getJSON[T any](t *testing.T, client *http.Client, endpoint string) T {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -836,15 +924,39 @@ func postJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoin
|
|||
}
|
||||
}
|
||||
|
||||
func putJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoint string, payload any, expectedStatus int) {
|
||||
t.Helper()
|
||||
|
||||
response := putJSONAuthResponse(t, client, token, endpoint, payload)
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != expectedStatus {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("put %s: expected status %d, got %d: %s", endpoint, expectedStatus, response.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func postJSONAuthResponse(t *testing.T, client *http.Client, token string, endpoint string, payload any) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
return jsonAuthResponse(t, client, http.MethodPost, token, endpoint, payload)
|
||||
}
|
||||
|
||||
func putJSONAuthResponse(t *testing.T, client *http.Client, token string, endpoint string, payload any) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
return jsonAuthResponse(t, client, http.MethodPut, token, endpoint, payload)
|
||||
}
|
||||
|
||||
func jsonAuthResponse(t *testing.T, client *http.Client, method string, token string, endpoint string, payload any) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload for %s: %v", endpoint, err)
|
||||
}
|
||||
|
||||
return doRequest(t, client, http.MethodPost, endpoint, bytes.NewReader(body), authHeaders(token))
|
||||
return doRequest(t, client, method, endpoint, bytes.NewReader(body), authHeaders(token))
|
||||
}
|
||||
|
||||
func postRawJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoint string, raw string, expectedStatus int) {
|
||||
|
|
|
|||
|
|
@ -197,14 +197,43 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
|
|||
RelayAddress: copyStringPointer(request.RelayAddress),
|
||||
}
|
||||
|
||||
state.NodesByID[nodeID] = node
|
||||
return nodeRegistrationResult{
|
||||
Node: node,
|
||||
IssuedNodeToken: issuedNodeToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
nextState := cloneStoreState(s.state)
|
||||
exports, err := upsertExportsInState(&nextState, nodeID, request.Exports)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.persistLocked(nextState); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.state = nextState
|
||||
return exports, nil
|
||||
}
|
||||
|
||||
func upsertExportsInState(state *storeState, nodeID string, exports []storageExportInput) ([]storageExport, error) {
|
||||
if _, ok := state.NodesByID[nodeID]; !ok {
|
||||
return nil, errNodeNotFound
|
||||
}
|
||||
|
||||
exportIDsByPath, ok := state.ExportIDsByNodePath[nodeID]
|
||||
if !ok {
|
||||
exportIDsByPath = make(map[string]string)
|
||||
state.ExportIDsByNodePath[nodeID] = exportIDsByPath
|
||||
}
|
||||
|
||||
keepPaths := make(map[string]struct{}, len(request.Exports))
|
||||
for _, export := range request.Exports {
|
||||
keepPaths := make(map[string]struct{}, len(exports))
|
||||
for _, export := range exports {
|
||||
exportID, ok := exportIDsByPath[export.Path]
|
||||
if !ok {
|
||||
exportID = nextExportID(state)
|
||||
|
|
@ -233,11 +262,19 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
|
|||
delete(state.ExportsByID, exportID)
|
||||
}
|
||||
|
||||
state.NodesByID[nodeID] = node
|
||||
return nodeRegistrationResult{
|
||||
Node: node,
|
||||
IssuedNodeToken: issuedNodeToken,
|
||||
}, nil
|
||||
nodeExports := make([]storageExport, 0, len(exportIDsByPath))
|
||||
for exportPath, exportID := range exportIDsByPath {
|
||||
if _, ok := keepPaths[exportPath]; !ok {
|
||||
continue
|
||||
}
|
||||
nodeExports = append(nodeExports, copyStorageExport(state.ExportsByID[exportID]))
|
||||
}
|
||||
|
||||
sort.Slice(nodeExports, func(i, j int) bool {
|
||||
return nodeExports[i].ID < nodeExports[j].ID
|
||||
})
|
||||
|
||||
return nodeExports, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue