mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 06:04:40 +00:00
Fix install script: strip v prefix from version for archive name
This commit is contained in:
parent
8002158a45
commit
1d564b738d
16 changed files with 552 additions and 26 deletions
|
|
@ -1,13 +1,16 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
|
@ -27,6 +30,7 @@ type app struct {
|
|||
authUsername string
|
||||
authPassword string
|
||||
exportMounts []exportMount
|
||||
controlPlane *controlPlaneSession
|
||||
}
|
||||
|
||||
type exportMount struct {
|
||||
|
|
@ -69,17 +73,92 @@ func newAppFromEnv() (*app, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var controlPlane *controlPlaneSession
|
||||
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
|
||||
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
|
||||
session, err := bootstrapNodeAgentFromEnv(exportPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
controlPlane = &session
|
||||
}
|
||||
|
||||
return newApp(appConfig{
|
||||
app, err := newApp(appConfig{
|
||||
exportPaths: exportPaths,
|
||||
authUsername: authUsername,
|
||||
authPassword: authPassword,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app.controlPlane = controlPlane
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *app) startControlPlaneLoop(ctx context.Context) {
|
||||
if a.controlPlane == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go runNodeHeartbeatLoop(
|
||||
ctx,
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
a.controlPlane.controlPlaneURL,
|
||||
a.controlPlane.sessionToken,
|
||||
a.controlPlane.nodeID,
|
||||
a.controlPlane.heartbeatInterval,
|
||||
time.Now,
|
||||
log.Default(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *app) controlPlaneEnabled() bool {
|
||||
return a.controlPlane != nil
|
||||
}
|
||||
|
||||
func defaultNodeHeartbeatInterval() time.Duration {
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
func heartbeatIntervalFromEnv() (time.Duration, error) {
|
||||
rawInterval := strings.TrimSpace(env("BETTERNAS_NODE_HEARTBEAT_INTERVAL", "30s"))
|
||||
if rawInterval == "" {
|
||||
return defaultNodeHeartbeatInterval(), nil
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(rawInterval)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if interval <= 0 {
|
||||
return 0, errors.New("BETTERNAS_NODE_HEARTBEAT_INTERVAL must be greater than zero")
|
||||
}
|
||||
|
||||
return interval, nil
|
||||
}
|
||||
|
||||
func runNodeHeartbeatLoop(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
baseURL string,
|
||||
sessionToken string,
|
||||
nodeID string,
|
||||
interval time.Duration,
|
||||
now func() time.Time,
|
||||
logger *log.Logger,
|
||||
) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := sendNodeHeartbeatAt(client, baseURL, sessionToken, nodeID, now().UTC()); err != nil && logger != nil {
|
||||
logger.Printf("betternas node heartbeat failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exportPathsFromEnv() ([]string, error) {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
type bootstrapResult struct {
|
||||
nodeID string
|
||||
type controlPlaneSession struct {
|
||||
nodeID string
|
||||
controlPlaneURL string
|
||||
sessionToken string
|
||||
heartbeatInterval time.Duration
|
||||
}
|
||||
|
||||
type nodeRegistrationRequest struct {
|
||||
|
|
@ -58,19 +61,23 @@ type nodeHeartbeatRequest struct {
|
|||
LastSeenAt string `json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
||||
func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error) {
|
||||
controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com"))
|
||||
if controlPlaneURL == "" {
|
||||
return bootstrapResult{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
|
||||
return controlPlaneSession{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
|
||||
}
|
||||
|
||||
username, err := requiredEnv("BETTERNAS_USERNAME")
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
return controlPlaneSession{}, err
|
||||
}
|
||||
password, err := requiredEnv("BETTERNAS_PASSWORD")
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
return controlPlaneSession{}, err
|
||||
}
|
||||
heartbeatInterval, err := heartbeatIntervalFromEnv()
|
||||
if err != nil {
|
||||
return controlPlaneSession{}, err
|
||||
}
|
||||
|
||||
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
|
||||
|
|
@ -82,7 +89,7 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
|||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
return controlPlaneSession{}, err
|
||||
}
|
||||
|
||||
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
|
||||
|
|
@ -93,17 +100,22 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
|||
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
||||
})
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
return controlPlaneSession{}, err
|
||||
}
|
||||
|
||||
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
|
||||
return bootstrapResult{}, err
|
||||
return controlPlaneSession{}, err
|
||||
}
|
||||
if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil {
|
||||
return bootstrapResult{}, err
|
||||
if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil {
|
||||
return controlPlaneSession{}, err
|
||||
}
|
||||
|
||||
return bootstrapResult{nodeID: registration.ID}, nil
|
||||
return controlPlaneSession{
|
||||
nodeID: registration.ID,
|
||||
controlPlaneURL: controlPlaneURL,
|
||||
sessionToken: sessionToken,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
|
||||
|
|
@ -168,10 +180,14 @@ func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token
|
|||
}
|
||||
|
||||
func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error {
|
||||
return sendNodeHeartbeatAt(client, baseURL, token, nodeID, time.Now().UTC())
|
||||
}
|
||||
|
||||
func sendNodeHeartbeatAt(client *http.Client, baseURL string, token string, nodeID string, at time.Time) error {
|
||||
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{
|
||||
NodeID: nodeID,
|
||||
Status: "online",
|
||||
LastSeenAt: time.Now().UTC().Format(time.RFC3339),
|
||||
LastSeenAt: at.UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
97
apps/node-agent/cmd/node-agent/control_plane_test.go
Normal file
97
apps/node-agent/cmd/node-agent/control_plane_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunNodeHeartbeatLoopSendsRecurringHeartbeats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var heartbeats []nodeHeartbeatRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/nodes/dev-node/heartbeat" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read heartbeat body: %v", err)
|
||||
}
|
||||
_ = r.Body.Close()
|
||||
|
||||
var heartbeat nodeHeartbeatRequest
|
||||
if err := json.Unmarshal(body, &heartbeat); err != nil {
|
||||
t.Fatalf("decode heartbeat body: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
heartbeats = append(heartbeats, heartbeat)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runNodeHeartbeatLoop(
|
||||
ctx,
|
||||
server.Client(),
|
||||
server.URL,
|
||||
"session-token",
|
||||
"dev-node",
|
||||
10*time.Millisecond,
|
||||
func() time.Time { return time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC) },
|
||||
log.New(io.Discard, "", 0),
|
||||
)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for {
|
||||
mu.Lock()
|
||||
count := len(heartbeats)
|
||||
mu.Unlock()
|
||||
if count >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("expected recurring heartbeats, got %d", count)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("heartbeat loop did not stop after context cancellation")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, heartbeat := range heartbeats {
|
||||
if heartbeat.NodeID != "dev-node" {
|
||||
t.Fatalf("expected node ID dev-node, got %q", heartbeat.NodeID)
|
||||
}
|
||||
if heartbeat.Status != "online" {
|
||||
t.Fatalf("expected status online, got %q", heartbeat.Status)
|
||||
}
|
||||
if heartbeat.LastSeenAt != "2025-01-01T12:00:00Z" {
|
||||
t.Fatalf("expected fixed lastSeenAt, got %q", heartbeat.LastSeenAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -14,6 +17,19 @@ func main() {
|
|||
log.Fatal(err)
|
||||
}
|
||||
|
||||
controlPlaneCtx, stopControlPlane := context.WithCancel(context.Background())
|
||||
defer stopControlPlane()
|
||||
if app.controlPlaneEnabled() {
|
||||
app.startControlPlaneLoop(controlPlaneCtx)
|
||||
}
|
||||
|
||||
signalContext, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stopSignals()
|
||||
go func() {
|
||||
<-signalContext.Done()
|
||||
stopControlPlane()
|
||||
}()
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: app.handler(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue