Fix install script: strip v prefix from version for archive name

This commit is contained in:
Harivansh Rathi 2026-04-01 21:06:40 -04:00
parent 8002158a45
commit 1d564b738d
16 changed files with 552 additions and 26 deletions

View file

@ -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) {

View file

@ -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

View 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)
}
}
}

View file

@ -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(),