mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-17 18:02:32 +00:00
Stabilize the node agent runtime loop.
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>
This commit is contained in:
parent
a7f85f4871
commit
273af4b0ab
14 changed files with 3294 additions and 36 deletions
357
apps/node-agent/internal/nodeagent/config.go
Normal file
357
apps/node-agent/internal/nodeagent/config.go
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
package nodeagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = "8090"
|
||||
defaultAgentVersion = "0.1.0-dev"
|
||||
defaultHeartbeatInterval = 30 * time.Second
|
||||
defaultListenHost = "127.0.0.1"
|
||||
exportPathEnvKey = "BETTERNAS_EXPORT_PATH"
|
||||
listenAddressEnvKey = "BETTERNAS_NODE_LISTEN_ADDRESS"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
ListenAddress string
|
||||
ExportPath string
|
||||
MachineID string
|
||||
DisplayName string
|
||||
AgentVersion string
|
||||
DirectAddress string
|
||||
RelayAddress string
|
||||
ExportLabel string
|
||||
ExportTags []string
|
||||
ControlPlaneURL string
|
||||
ControlPlaneToken string
|
||||
RegisterEnabled bool
|
||||
HeartbeatEnabled bool
|
||||
HeartbeatInterval time.Duration
|
||||
}
|
||||
|
||||
type envLookup func(string) (string, bool)
|
||||
|
||||
func LoadConfigFromEnv() (Config, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil || hostname == "" {
|
||||
hostname = "betternas-node"
|
||||
}
|
||||
|
||||
return loadConfig(os.LookupEnv, cwd, hostname)
|
||||
}
|
||||
|
||||
func loadConfig(lookup envLookup, cwd, hostname string) (Config, error) {
|
||||
port := envOrDefault(lookup, "PORT", defaultPort)
|
||||
|
||||
rawExportPath, err := envRequired(lookup, exportPathEnvKey)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
exportPath, err := resolveExportPath(rawExportPath, cwd)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
listenAddress := envOrDefault(lookup, listenAddressEnvKey, defaultListenAddress(port))
|
||||
|
||||
registerEnabled, err := envBool(lookup, "BETTERNAS_NODE_REGISTER_ENABLED", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
heartbeatEnabled, err := envBool(lookup, "BETTERNAS_NODE_HEARTBEAT_ENABLED", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
heartbeatInterval, err := envDuration(lookup, "BETTERNAS_NODE_HEARTBEAT_INTERVAL", defaultHeartbeatInterval)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
machineID, machineIDProvided := envOptional(lookup, "BETTERNAS_NODE_MACHINE_ID")
|
||||
if !machineIDProvided {
|
||||
machineID = hostname
|
||||
}
|
||||
|
||||
if registerEnabled && !machineIDProvided {
|
||||
return Config{}, fmt.Errorf("BETTERNAS_NODE_MACHINE_ID is required when BETTERNAS_NODE_REGISTER_ENABLED=true")
|
||||
}
|
||||
|
||||
displayName := envOrDefault(lookup, "BETTERNAS_NODE_DISPLAY_NAME", machineID)
|
||||
agentVersion := envOrDefault(lookup, "BETTERNAS_VERSION", defaultAgentVersion)
|
||||
directAddress := envOrDefault(lookup, "BETTERNAS_NODE_DIRECT_ADDRESS", defaultDirectAddress(listenAddress, port))
|
||||
relayAddress := envOrDefault(lookup, "BETTERNAS_NODE_RELAY_ADDRESS", "")
|
||||
exportLabel := envOrDefault(lookup, "BETTERNAS_EXPORT_LABEL", defaultExportLabel(exportPath))
|
||||
exportTags := parseCSVList(envOrDefault(lookup, "BETTERNAS_EXPORT_TAGS", ""))
|
||||
controlPlaneURL := strings.TrimRight(envOrDefault(lookup, "BETTERNAS_CONTROL_PLANE_URL", ""), "/")
|
||||
controlPlaneToken := envOrDefault(lookup, "BETTERNAS_CONTROL_PLANE_AUTH_TOKEN", "")
|
||||
|
||||
cfg := Config{
|
||||
Port: port,
|
||||
ListenAddress: listenAddress,
|
||||
ExportPath: exportPath,
|
||||
MachineID: machineID,
|
||||
DisplayName: displayName,
|
||||
AgentVersion: agentVersion,
|
||||
DirectAddress: directAddress,
|
||||
RelayAddress: relayAddress,
|
||||
ExportLabel: exportLabel,
|
||||
ExportTags: exportTags,
|
||||
ControlPlaneURL: controlPlaneURL,
|
||||
ControlPlaneToken: controlPlaneToken,
|
||||
RegisterEnabled: registerEnabled,
|
||||
HeartbeatEnabled: heartbeatEnabled,
|
||||
HeartbeatInterval: heartbeatInterval,
|
||||
}
|
||||
|
||||
if err := validateRuntimeConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func resolveExportPath(rawPath, cwd string) (string, error) {
|
||||
exportPath := strings.TrimSpace(rawPath)
|
||||
if exportPath == "" {
|
||||
return "", fmt.Errorf("export path is required")
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(exportPath) {
|
||||
basePath := cwd
|
||||
if workspaceRoot, ok := findWorkspaceRoot(cwd); ok {
|
||||
basePath = workspaceRoot
|
||||
}
|
||||
|
||||
exportPath = filepath.Join(basePath, exportPath)
|
||||
}
|
||||
|
||||
absolutePath, err := filepath.Abs(exportPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve export path %q: %w", rawPath, err)
|
||||
}
|
||||
|
||||
return filepath.Clean(absolutePath), nil
|
||||
}
|
||||
|
||||
func envRequired(lookup envLookup, key string) (string, error) {
|
||||
value, ok := lookup(key)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
|
||||
func envOptional(lookup envLookup, key string) (string, bool) {
|
||||
value, ok := lookup(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
func defaultListenAddress(port string) string {
|
||||
return net.JoinHostPort(defaultListenHost, port)
|
||||
}
|
||||
|
||||
func defaultDirectAddress(listenAddress, fallbackPort string) string {
|
||||
if strings.TrimSpace(listenAddress) == defaultListenAddress(fallbackPort) {
|
||||
return httpURL("localhost", fallbackPort)
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddress))
|
||||
if err != nil || strings.TrimSpace(port) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
host = strings.TrimSpace(host)
|
||||
if isWildcardListenHost(host) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return httpURL(host, port)
|
||||
}
|
||||
|
||||
func isWildcardListenHost(host string) bool {
|
||||
trimmed := strings.TrimSpace(host)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(trimmed)
|
||||
return ip != nil && ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func httpURL(host, port string) string {
|
||||
return (&url.URL{
|
||||
Scheme: "http",
|
||||
Host: net.JoinHostPort(host, port),
|
||||
}).String()
|
||||
}
|
||||
|
||||
func findWorkspaceRoot(start string) (string, bool) {
|
||||
current := filepath.Clean(start)
|
||||
|
||||
for {
|
||||
if hasPath(filepath.Join(current, "pnpm-workspace.yaml")) || hasPath(filepath.Join(current, "go.work")) || hasPath(filepath.Join(current, ".git")) {
|
||||
return current, true
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", false
|
||||
}
|
||||
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func hasPath(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func defaultExportLabel(exportPath string) string {
|
||||
label := filepath.Base(exportPath)
|
||||
if label == "." || label == string(filepath.Separator) || label == "" {
|
||||
return "export"
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
func parseCSVList(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
values := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
value := strings.TrimSpace(part)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[value] = struct{}{}
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
func envOrDefault(lookup envLookup, key, fallback string) string {
|
||||
value, ok := lookup(key)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func validateRuntimeConfig(cfg Config) error {
|
||||
if err := validateListenAddress(cfg.ListenAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.RegisterEnabled && strings.TrimSpace(cfg.ControlPlaneURL) == "" {
|
||||
return fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required when BETTERNAS_NODE_REGISTER_ENABLED=true")
|
||||
}
|
||||
|
||||
if cfg.RegisterEnabled && strings.TrimSpace(cfg.MachineID) == "" {
|
||||
return fmt.Errorf("BETTERNAS_NODE_MACHINE_ID is required when BETTERNAS_NODE_REGISTER_ENABLED=true")
|
||||
}
|
||||
|
||||
if cfg.HeartbeatEnabled && !cfg.RegisterEnabled {
|
||||
return fmt.Errorf("BETTERNAS_NODE_HEARTBEAT_ENABLED requires BETTERNAS_NODE_REGISTER_ENABLED=true")
|
||||
}
|
||||
|
||||
if cfg.HeartbeatEnabled && cfg.HeartbeatInterval <= 0 {
|
||||
return fmt.Errorf("BETTERNAS_NODE_HEARTBEAT_INTERVAL must be greater than zero")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListenAddress(address string) error {
|
||||
trimmed := strings.TrimSpace(address)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%s is required", listenAddressEnvKey)
|
||||
}
|
||||
|
||||
_, port, err := net.SplitHostPort(trimmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", listenAddressEnvKey, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(port) == "" {
|
||||
return fmt.Errorf("%s must include a port", listenAddressEnvKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func envBool(lookup envLookup, key string, fallback bool) (bool, error) {
|
||||
value, ok := lookup(key)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func envDuration(lookup envLookup, key string, fallback time.Duration) (time.Duration, error) {
|
||||
value, ok := lookup(key)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
|
||||
if parsed <= 0 {
|
||||
return 0, fmt.Errorf("%s must be greater than zero", key)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue