From ee32dd9c170e588954c0510f27f6aab8afc08b8c Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 23 Mar 2026 14:17:55 +0100 Subject: [PATCH 1/3] feat(cli): Add setup command for infrastructure deployment Adds `flatrun-agent setup infra ` CLI command that deploys infrastructure services from embedded templates. Validates that the requested template is a valid infrastructure type, uses programmatic Docker and network APIs, and creates all required config files. Currently supports nginx. Extensible for future services. Signed-off-by: nfebe --- cmd/agent/main.go | 3 + cmd/agent/setup.go | 223 ++++++++++++++++++++++++++++++++++++++++ cmd/agent/setup_test.go | 161 +++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 cmd/agent/setup.go create mode 100644 cmd/agent/setup_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index c9abc1a..ead2aea 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -24,6 +24,9 @@ func main() { case "update": handleUpdate(os.Args[2:]) return + case "setup": + handleSetup(os.Args[2:]) + return case "version": printVersion() return diff --git a/cmd/agent/setup.go b/cmd/agent/setup.go new file mode 100644 index 0000000..9ef1555 --- /dev/null +++ b/cmd/agent/setup.go @@ -0,0 +1,223 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/flatrun/agent/internal/docker" + "github.com/flatrun/agent/internal/networks" + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/templates" + "gopkg.in/yaml.v3" +) + +type templateMetadata struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Category string `yaml:"category"` +} + +func handleSetup(args []string) { + if len(args) == 0 { + setupUsage() + os.Exit(1) + } + + switch args[0] { + case "infra": + handleSetupInfra(args[1:]) + case "help", "--help", "-h": + setupUsage() + default: + fmt.Fprintf(os.Stderr, "Unknown setup target: %s\n", args[0]) + setupUsage() + os.Exit(1) + } +} + +func setupUsage() { + fmt.Println("Usage: flatrun-agent setup [options]") + fmt.Println() + fmt.Println("Targets:") + fmt.Println(" infra Deploy an infrastructure service from embedded templates") + fmt.Println() + fmt.Println("Options:") + fmt.Println(" --config, -c Path to configuration file") + fmt.Println(" --force Re-deploy even if already exists") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" flatrun-agent setup infra nginx") + fmt.Println(" flatrun-agent setup infra nginx --config /etc/flatrun/config.yml") + fmt.Println(" flatrun-agent setup infra nginx --force") +} + +func handleSetupInfra(args []string) { + setupFlags := flag.NewFlagSet("setup infra", flag.ExitOnError) + configPath := setupFlags.String("config", "", "Path to configuration file") + shortConfig := setupFlags.String("c", "", "Path to configuration file (short)") + force := setupFlags.Bool("force", false, "Re-deploy even if already exists") + + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: flatrun-agent setup infra [options]") + fmt.Fprintln(os.Stderr) + printAvailableInfraTemplates() + os.Exit(1) + } + + serviceName := args[0] + if err := setupFlags.Parse(args[1:]); err != nil { + os.Exit(1) + } + + cfgPath := *configPath + if cfgPath == "" { + cfgPath = *shortConfig + } + + templateID := "infra/" + serviceName + + meta, err := loadInfraMetadata(templateID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %s is not a valid infrastructure template: %v\n", serviceName, err) + fmt.Fprintln(os.Stderr) + printAvailableInfraTemplates() + os.Exit(1) + } + + if meta.Type != "infrastructure" && meta.Category != "infrastructure" { + fmt.Fprintf(os.Stderr, "Error: %s is not an infrastructure template (type=%s, category=%s)\n", + serviceName, meta.Type, meta.Category) + os.Exit(1) + } + + resolvedConfigPath := config.FindConfigPath(cfgPath) + cfg, err := config.Load(resolvedConfigPath) + if err != nil { + log.Fatalf("Failed to load config from %s: %v", resolvedConfigPath, err) + } + + deployDir := filepath.Join(cfg.DeploymentsPath, serviceName) + if !*force { + if _, err := os.Stat(filepath.Join(deployDir, "docker-compose.yml")); err == nil { + fmt.Printf("%s is already deployed at %s (use --force to re-deploy)\n", serviceName, deployDir) + return + } + } + + if err := deployInfraService(cfg, serviceName, templateID); err != nil { + log.Fatalf("Failed to setup %s: %v", serviceName, err) + } +} + +func loadInfraMetadata(templateID string) (*templateMetadata, error) { + data, err := templates.GetMetadata(templateID) + if err != nil { + return nil, err + } + var meta templateMetadata + if err := yaml.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func printAvailableInfraTemplates() { + fmt.Fprintln(os.Stderr, "Available infrastructure services:") + infraTemplates, err := templates.List() + if err != nil { + return + } + for _, id := range infraTemplates { + if !strings.HasPrefix(id, "infra/") { + continue + } + meta, err := loadInfraMetadata(id) + if err != nil { + continue + } + name := strings.TrimPrefix(id, "infra/") + fmt.Fprintf(os.Stderr, " %-18s %s\n", name, meta.Name) + } +} + +func deployInfraService(cfg *config.Config, serviceName, templateID string) error { + deployDir := filepath.Join(cfg.DeploymentsPath, serviceName) + + fmt.Printf("Deploying %s infrastructure...\n", serviceName) + + compose, err := templates.GetCompose(templateID) + if err != nil { + return fmt.Errorf("read compose template: %w", err) + } + + content := string(compose) + content = strings.ReplaceAll(content, "${NAME}", serviceName) + content = strings.ReplaceAll(content, "${PROXY_NETWORK}", cfg.Infrastructure.DefaultProxyNetwork) + + manager := docker.NewManager(cfg.DeploymentsPath) + if err := manager.CreateDeployment(serviceName, content); err != nil { + return fmt.Errorf("create deployment: %w", err) + } + + if err := writeInfraTemplateFiles(cfg, serviceName, templateID, deployDir); err != nil { + return fmt.Errorf("write template files: %w", err) + } + + netManager := networks.NewManager() + if err := netManager.EnsureNetwork(cfg.Infrastructure.DefaultProxyNetwork); err != nil { + return fmt.Errorf("ensure proxy network: %w", err) + } + + executor := docker.NewComposeExecutor(cfg.DeploymentsPath) + if output, err := executor.Up(deployDir); err != nil { + return fmt.Errorf("start service: %s: %w", output, err) + } + + fmt.Printf("%s infrastructure deployed\n", serviceName) + return nil +} + +func writeInfraTemplateFiles(cfg *config.Config, serviceName, templateID, deployDir string) error { + switch templateID { + case "infra/nginx": + return writeNginxFiles(cfg, deployDir) + } + return nil +} + +func writeNginxFiles(cfg *config.Config, deployDir string) error { + for _, dir := range []string{"conf.d", "certs", "html", "lua"} { + if err := os.MkdirAll(filepath.Join(deployDir, dir), 0755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + } + + nginxConf, err := templates.GetNginxConfig(false) + if err != nil { + return fmt.Errorf("read nginx.conf: %w", err) + } + if err := os.WriteFile(filepath.Join(deployDir, "nginx.conf"), nginxConf, 0644); err != nil { + return fmt.Errorf("write nginx.conf: %w", err) + } + + rateLimits := filepath.Join(deployDir, "conf.d", "rate_limits.conf") + if _, err := os.Stat(rateLimits); os.IsNotExist(err) { + if err := os.WriteFile(rateLimits, []byte(""), 0644); err != nil { + return fmt.Errorf("write rate_limits.conf: %w", err) + } + } + + welcomePage, err := templates.GetWelcomePage() + if err == nil { + indexPath := filepath.Join(deployDir, "html", "index.html") + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + _ = os.WriteFile(indexPath, welcomePage, 0644) + } + } + + return nil +} diff --git a/cmd/agent/setup_test.go b/cmd/agent/setup_test.go new file mode 100644 index 0000000..39ce511 --- /dev/null +++ b/cmd/agent/setup_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flatrun/agent/pkg/config" +) + +func TestLoadInfraMetadata_ValidTemplate(t *testing.T) { + meta, err := loadInfraMetadata("infra/nginx") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if meta.Name != "Nginx" { + t.Errorf("Expected name 'Nginx', got '%s'", meta.Name) + } + if meta.Type != "infrastructure" { + t.Errorf("Expected type 'infrastructure', got '%s'", meta.Type) + } +} + +func TestLoadInfraMetadata_NonExistent(t *testing.T) { + _, err := loadInfraMetadata("infra/nonexistent") + if err == nil { + t.Error("Expected error for non-existent template") + } +} + +func TestLoadInfraMetadata_NotInfra(t *testing.T) { + meta, err := loadInfraMetadata("wordpress") + if err != nil { + t.Skipf("wordpress template not embedded: %v", err) + } + if meta.Type == "infrastructure" { + t.Error("wordpress should not be an infrastructure template") + } +} + +func TestWriteNginxFiles(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + DeploymentsPath: tmpDir, + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + + deployDir := filepath.Join(tmpDir, "nginx") + if err := os.MkdirAll(deployDir, 0755); err != nil { + t.Fatal(err) + } + + if err := writeNginxFiles(cfg, deployDir); err != nil { + t.Fatalf("writeNginxFiles failed: %v", err) + } + + expectedFiles := []string{ + "nginx.conf", + "conf.d/rate_limits.conf", + "html/index.html", + } + for _, f := range expectedFiles { + path := filepath.Join(deployDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Expected file %s to exist", f) + } + } + + expectedDirs := []string{"conf.d", "certs", "html", "lua"} + for _, d := range expectedDirs { + path := filepath.Join(deployDir, d) + info, err := os.Stat(path) + if os.IsNotExist(err) { + t.Errorf("Expected directory %s to exist", d) + } else if !info.IsDir() { + t.Errorf("Expected %s to be a directory", d) + } + } +} + +func TestWriteNginxFiles_PreservesExistingIndex(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + DeploymentsPath: tmpDir, + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + + deployDir := filepath.Join(tmpDir, "nginx") + htmlDir := filepath.Join(deployDir, "html") + if err := os.MkdirAll(htmlDir, 0755); err != nil { + t.Fatal(err) + } + + customContent := []byte("custom") + if err := os.WriteFile(filepath.Join(htmlDir, "index.html"), customContent, 0644); err != nil { + t.Fatal(err) + } + + if err := writeNginxFiles(cfg, deployDir); err != nil { + t.Fatalf("writeNginxFiles failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(htmlDir, "index.html")) + if err != nil { + t.Fatal(err) + } + if string(data) != string(customContent) { + t.Error("writeNginxFiles should not overwrite existing index.html") + } +} + +func TestWriteNginxFiles_PreservesExistingRateLimits(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + DeploymentsPath: tmpDir, + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + + deployDir := filepath.Join(tmpDir, "nginx") + confDir := filepath.Join(deployDir, "conf.d") + if err := os.MkdirAll(confDir, 0755); err != nil { + t.Fatal(err) + } + + customContent := []byte("limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;") + if err := os.WriteFile(filepath.Join(confDir, "rate_limits.conf"), customContent, 0644); err != nil { + t.Fatal(err) + } + + if err := writeNginxFiles(cfg, deployDir); err != nil { + t.Fatalf("writeNginxFiles failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(confDir, "rate_limits.conf")) + if err != nil { + t.Fatal(err) + } + if string(data) != string(customContent) { + t.Error("writeNginxFiles should not overwrite existing rate_limits.conf") + } +} + +func TestDeployInfraService_InvalidTemplate(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + + err := deployInfraService(cfg, "nonexistent", "infra/nonexistent") + if err == nil { + t.Error("Expected error for non-existent template") + } +} From e359ef172a791f6b0f8e071fd9bb879293968bfd Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 23 Mar 2026 14:46:44 +0100 Subject: [PATCH 2/3] refactor(setup): Extract setup handlers into dedicated package Move setup wizard logic from internal/api/setup_handlers.go into a clean internal/setup/ package with proper separation of concerns: - manager.go: state management, IP resolution, config access - validation.go: system checks (Docker, disk, memory, ports) - handlers.go: HTTP handlers wrapping Manager + auth.Manager All 15 tests pass. Server routes rewired to use new package. Signed-off-by: nfebe --- internal/api/server.go | 31 +- internal/api/setup_handlers.go | 423 ------------------ internal/setup/handlers.go | 235 ++++++++++ .../handlers_test.go} | 147 +++--- internal/setup/manager.go | 100 +++++ internal/setup/validation.go | 144 ++++++ 6 files changed, 591 insertions(+), 489 deletions(-) delete mode 100644 internal/api/setup_handlers.go create mode 100644 internal/setup/handlers.go rename internal/{api/setup_handlers_test.go => setup/handlers_test.go} (74%) create mode 100644 internal/setup/manager.go create mode 100644 internal/setup/validation.go diff --git a/internal/api/server.go b/internal/api/server.go index 5e231d6..58291ee 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -35,6 +35,7 @@ import ( "github.com/flatrun/agent/internal/proxy" "github.com/flatrun/agent/internal/scheduler" "github.com/flatrun/agent/internal/security" + "github.com/flatrun/agent/internal/setup" "github.com/flatrun/agent/internal/system" "github.com/flatrun/agent/internal/traffic" "github.com/flatrun/agent/pkg/config" @@ -75,7 +76,8 @@ type Server struct { auditMiddleware *audit.Middleware powerDNSManager *dns.PowerDNSManager clusterManager *cluster.Manager - cachedIP string + setupManager *setup.Manager + setupHandlers *setup.Handlers statsMu sync.RWMutex statsCache gin.H @@ -112,7 +114,7 @@ func New(cfg *config.Config, configPath string) *Server { } else if len(origins) > 0 { corsConfig.AllowOrigins = origins } else { - instanceIP := resolvePublicIP() + instanceIP := setup.ResolvePublicIP() corsConfig.AllowOriginFunc = func(origin string) bool { return strings.HasPrefix(origin, "http://"+instanceIP) || strings.HasPrefix(origin, "https://"+instanceIP) @@ -129,7 +131,8 @@ func New(cfg *config.Config, configPath string) *Server { _ = pluginRegistry.LoadFromDisk() authMiddleware := auth.NewMiddleware(&cfg.Auth) - setupComplete := isSetupCompleteCheck(cfg.DeploymentsPath) + setupManager := setup.NewManager(cfg, configPath) + setupComplete := setupManager.IsComplete() var authManager *auth.Manager if authMgr, authErr := auth.NewManager(cfg.DeploymentsPath, &cfg.Auth, setupComplete); authErr != nil { log.Printf("Warning: Failed to initialize auth manager: %v", authErr) @@ -254,6 +257,8 @@ func New(cfg *config.Config, configPath string) *Server { auditMiddleware: auditMiddleware, powerDNSManager: powerDNSManager, clusterManager: clusterManager, + setupManager: setupManager, + setupHandlers: setup.NewHandlers(setupManager, authManager), } if backupManager != nil { @@ -284,19 +289,19 @@ func (s *Server) setupRoutes() { api.GET("/containers/:id/exec", s.containerExec) // Setup endpoints (public, gated by setup state) - setup := api.Group("/setup") + setupGroup := api.Group("/setup") { - setup.GET("/status", s.getSetupStatus) + setupGroup.GET("/status", s.setupHandlers.GetStatus) - guarded := setup.Group("") - guarded.GET("/info", s.getSetupInfo) - guarded.Use(s.setupGuard()) + guarded := setupGroup.Group("") + guarded.GET("/info", s.setupHandlers.GetInfo) + guarded.Use(s.setupHandlers.Guard()) { - guarded.POST("/validate", s.validateSystem) - guarded.GET("/verify-dns", s.verifyDNS) - guarded.POST("/settings", s.configureSettings) - guarded.POST("/authentication", s.configureAuthentication) - guarded.POST("/complete", s.completeSetup) + guarded.POST("/validate", s.setupHandlers.ValidateSystem) + guarded.GET("/verify-dns", s.setupHandlers.VerifyDNS) + guarded.POST("/settings", s.setupHandlers.ConfigureSettings) + guarded.POST("/authentication", s.setupHandlers.ConfigureAuthentication) + guarded.POST("/complete", s.setupHandlers.Complete) } } diff --git a/internal/api/setup_handlers.go b/internal/api/setup_handlers.go deleted file mode 100644 index 4ecd5fa..0000000 --- a/internal/api/setup_handlers.go +++ /dev/null @@ -1,423 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" - - "github.com/flatrun/agent/internal/auth" - "github.com/flatrun/agent/internal/system" - "github.com/flatrun/agent/pkg/config" - "github.com/flatrun/agent/pkg/version" - "github.com/gin-gonic/gin" -) - -type setupState struct { - Initialized bool `json:"initialized"` - CompletedAt string `json:"completed_at,omitempty"` -} - -func (s *Server) setupStatePath() string { - return filepath.Join(s.config.DeploymentsPath, ".flatrun", "setup.json") -} - -func isSetupCompleteCheck(deploymentsPath string) bool { - data, err := os.ReadFile(filepath.Join(deploymentsPath, ".flatrun", "setup.json")) - if err != nil { - return false - } - var state setupState - if err := json.Unmarshal(data, &state); err != nil { - return false - } - return state.Initialized -} - -func (s *Server) isSetupComplete() bool { - return isSetupCompleteCheck(s.config.DeploymentsPath) -} - -func (s *Server) markSetupComplete() error { - state := setupState{ - Initialized: true, - CompletedAt: time.Now().UTC().Format(time.RFC3339), - } - data, err := json.MarshalIndent(state, "", " ") - if err != nil { - return err - } - dir := filepath.Dir(s.setupStatePath()) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - return os.WriteFile(s.setupStatePath(), data, 0644) -} - -func (s *Server) setupGuard() gin.HandlerFunc { - return func(c *gin.Context) { - if s.isSetupComplete() { - c.JSON(http.StatusForbidden, gin.H{ - "error": "Setup has already been completed", - }) - c.Abort() - return - } - c.Next() - } -} - -func (s *Server) getSetupStatus(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "initialized": s.isSetupComplete(), - }) -} - -func (s *Server) getSetupInfo(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "instance_ip": s.getInstanceIP(), - "agent_version": version.Get(), - }) -} - -func (s *Server) validateSystem(c *gin.Context) { - checks := []gin.H{ - checkDocker(), - checkDockerCompose(), - checkDiskSpace(s.config.DeploymentsPath), - checkMemory(), - checkPort(80, "FlatRun welcome page"), - checkPort(443, "FlatRun"), - } - - c.JSON(http.StatusOK, gin.H{ - "checks": checks, - }) -} - -func checkDocker() gin.H { - check := gin.H{"name": "Docker", "required": true} - if _, err := exec.LookPath("docker"); err != nil { - check["status"] = "fail" - check["message"] = "Docker is not installed" - return check - } - out, err := exec.Command("docker", "info", "--format", "{{.ServerVersion}}").Output() - if err != nil { - check["status"] = "fail" - check["message"] = "Docker is installed but not running" - } else { - check["status"] = "pass" - check["message"] = fmt.Sprintf("Docker %s", strings.TrimSpace(string(out))) - } - return check -} - -func checkDockerCompose() gin.H { - check := gin.H{"name": "Docker Compose", "required": true} - out, err := exec.Command("docker", "compose", "version", "--short").Output() - if err != nil { - check["status"] = "fail" - check["message"] = "Docker Compose plugin not found" - } else { - check["status"] = "pass" - check["message"] = fmt.Sprintf("Docker Compose %s", strings.TrimSpace(string(out))) - } - return check -} - -func checkDiskSpace(path string) gin.H { - check := gin.H{"name": "Disk Space", "required": true} - diskFree := getDiskFreeGB(path) - if diskFree < 1 { - check["status"] = "fail" - check["message"] = "Less than 1 GB free disk space" - } else if diskFree < 5 { - check["status"] = "warn" - check["message"] = fmt.Sprintf("%.1f GB free (5 GB+ recommended)", diskFree) - } else { - check["status"] = "pass" - check["message"] = fmt.Sprintf("%.1f GB free", diskFree) - } - return check -} - -func checkMemory() gin.H { - check := gin.H{"name": "Memory", "required": false} - totalMB := getHostMemoryMB() - if totalMB < 512 { - check["status"] = "warn" - check["message"] = fmt.Sprintf("%d MB total (512 MB+ recommended)", totalMB) - } else { - check["status"] = "pass" - check["message"] = fmt.Sprintf("%d MB total", totalMB) - } - return check -} - -func checkPort(port int, inUseBy string) gin.H { - check := gin.H{ - "name": fmt.Sprintf("Port %d", port), - "required": false, - "status": "pass", - } - if isPortAvailable(port) { - check["message"] = fmt.Sprintf("Port %d is available", port) - } else { - check["message"] = fmt.Sprintf("Port %d is in use by %s", port, inUseBy) - } - return check -} - -func (s *Server) verifyDNS(c *gin.Context) { - domain := c.Query("domain") - if domain == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "domain parameter is required"}) - return - } - - ips, err := net.LookupHost(domain) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "valid": false, - "domain": domain, - "expected": s.getInstanceIP(), - "actual": []string{}, - "message": "DNS lookup failed: " + err.Error(), - }) - return - } - - instanceIP := s.getInstanceIP() - valid := false - for _, ip := range ips { - if ip == instanceIP { - valid = true - break - } - } - - c.JSON(http.StatusOK, gin.H{ - "valid": valid, - "domain": domain, - "expected": instanceIP, - "actual": ips, - }) -} - -func (s *Server) configureSettings(c *gin.Context) { - var req struct { - Domain string `json:"domain"` - AutoSSL *bool `json:"auto_ssl"` - CORSOrigins []string `json:"cors_origins"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Domain != "" { - s.config.Domain.DefaultDomain = req.Domain - } - if req.AutoSSL != nil { - s.config.Domain.AutoSSL = *req.AutoSSL - } - if len(req.CORSOrigins) > 0 { - originMap := make(map[string]bool) - for _, e := range s.config.API.AllowedOrigins { - originMap[e] = true - } - for _, origin := range req.CORSOrigins { - if !originMap[origin] { - s.config.API.AllowedOrigins = append(s.config.API.AllowedOrigins, origin) - originMap[origin] = true - } - } - } - - if err := config.Save(s.config, s.configPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "Settings configured", - "domain": s.config.Domain.DefaultDomain, - "auto_ssl": s.config.Domain.AutoSSL, - }) -} - -func (s *Server) configureAuthentication(c *gin.Context) { - var req struct { - AuthMethod string `json:"auth_method"` - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.AuthMethod == "" { - req.AuthMethod = "both" - } - if req.AuthMethod != "password" && req.AuthMethod != "apikey" && req.AuthMethod != "both" { - c.JSON(http.StatusBadRequest, gin.H{"error": "auth_method must be 'password', 'apikey', or 'both'"}) - return - } - - if s.authManager == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Auth manager not available"}) - return - } - - result := gin.H{ - "auth_method": req.AuthMethod, - } - - var userID int64 - - if req.AuthMethod == "password" || req.AuthMethod == "both" { - if req.Username == "" || req.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "username and password are required for password authentication"}) - return - } - if len(req.Password) < 8 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"}) - return - } - - user, err := s.authManager.CreateUser(req.Username, req.Email, req.Password, auth.RoleAdmin, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) - return - } - userID = user.ID - result["username"] = user.Username - result["user_uid"] = user.UID - } - - if req.AuthMethod == "apikey" || req.AuthMethod == "both" { - if userID == 0 { - username := req.Username - if username == "" { - username = "system" - } - sysUser, err := s.authManager.CreateUser(username, "", "apikey-only-no-password-login", auth.RoleAdmin, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create system user: " + err.Error()}) - return - } - userID = sysUser.ID - } - - apiKey, plainKey, err := s.authManager.CreateAPIKey( - userID, - "Setup API Key", - "Generated during initial setup", - auth.RoleAdmin, - nil, - nil, - time.Time{}, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create API key: " + err.Error()}) - return - } - result["api_key"] = plainKey - result["api_key_id"] = apiKey.KeyID - } - - c.JSON(http.StatusOK, result) -} - -func (s *Server) completeSetup(c *gin.Context) { - if err := s.markSetupComplete(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark setup complete: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "Setup completed successfully", - "completed_at": time.Now().UTC().Format(time.RFC3339), - }) -} - - -func (s *Server) getInstanceIP() string { - if s.cachedIP != "" { - return s.cachedIP - } - - ip := resolvePublicIP() - s.cachedIP = ip - return ip -} - -func resolvePublicIP() string { - if ip, err := system.GetPublicIP("4"); err == nil { - return ip - } - - addrs, err := net.InterfaceAddrs() - if err == nil { - for _, addr := range addrs { - if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String() - } - } - } - - return "127.0.0.1" -} - -func getHostMemoryMB() uint64 { - data, err := os.ReadFile("/proc/meminfo") - if err != nil { - return 0 - } - for _, line := range strings.Split(string(data), "\n") { - if strings.HasPrefix(line, "MemTotal:") { - fields := strings.Fields(line) - if len(fields) < 2 { - return 0 - } - kb, err := strconv.ParseUint(fields[1], 10, 64) - if err != nil { - return 0 - } - return kb / 1024 - } - } - return 0 -} - -func isPortAvailable(port int) bool { - portStr := fmt.Sprintf("%d", port) - for _, host := range []string{"0.0.0.0", "127.0.0.1", "::1"} { - ln, err := net.Listen("tcp", net.JoinHostPort(host, portStr)) - if err != nil { - return false - } - ln.Close() - } - return true -} - -func getDiskFreeGB(path string) float64 { - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { - return -1 - } - return float64(stat.Bavail*uint64(stat.Bsize)) / (1024 * 1024 * 1024) -} diff --git a/internal/setup/handlers.go b/internal/setup/handlers.go new file mode 100644 index 0000000..bb53f1e --- /dev/null +++ b/internal/setup/handlers.go @@ -0,0 +1,235 @@ +package setup + +import ( + "net" + "net/http" + "time" + + "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/version" + "github.com/gin-gonic/gin" +) + +type Handlers struct { + manager *Manager + authManager *auth.Manager +} + +func NewHandlers(manager *Manager, authManager *auth.Manager) *Handlers { + return &Handlers{ + manager: manager, + authManager: authManager, + } +} + +func (h *Handlers) Guard() gin.HandlerFunc { + return func(c *gin.Context) { + if h.manager.IsComplete() { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Setup has already been completed", + }) + c.Abort() + return + } + c.Next() + } +} + +func (h *Handlers) GetStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "initialized": h.manager.IsComplete(), + }) +} + +func (h *Handlers) GetInfo(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "instance_ip": h.manager.GetInstanceIP(), + "agent_version": version.Get(), + }) +} + +func (h *Handlers) ValidateSystem(c *gin.Context) { + checks := RunSystemChecks(h.manager.config.DeploymentsPath) + c.JSON(http.StatusOK, gin.H{ + "checks": checks, + }) +} + +func (h *Handlers) VerifyDNS(c *gin.Context) { + domain := c.Query("domain") + if domain == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "domain parameter is required"}) + return + } + + ips, err := net.LookupHost(domain) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "valid": false, + "domain": domain, + "expected": h.manager.GetInstanceIP(), + "actual": []string{}, + "message": "DNS lookup failed: " + err.Error(), + }) + return + } + + instanceIP := h.manager.GetInstanceIP() + valid := false + for _, ip := range ips { + if ip == instanceIP { + valid = true + break + } + } + + c.JSON(http.StatusOK, gin.H{ + "valid": valid, + "domain": domain, + "expected": instanceIP, + "actual": ips, + }) +} + +func (h *Handlers) ConfigureSettings(c *gin.Context) { + var req struct { + Domain string `json:"domain"` + AutoSSL *bool `json:"auto_ssl"` + CORSOrigins []string `json:"cors_origins"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + cfg := h.manager.config + if req.Domain != "" { + cfg.Domain.DefaultDomain = req.Domain + } + if req.AutoSSL != nil { + cfg.Domain.AutoSSL = *req.AutoSSL + } + if len(req.CORSOrigins) > 0 { + originMap := make(map[string]bool) + for _, e := range cfg.API.AllowedOrigins { + originMap[e] = true + } + for _, origin := range req.CORSOrigins { + if !originMap[origin] { + cfg.API.AllowedOrigins = append(cfg.API.AllowedOrigins, origin) + originMap[origin] = true + } + } + } + + if err := config.Save(cfg, h.manager.configPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Settings configured", + "domain": cfg.Domain.DefaultDomain, + "auto_ssl": cfg.Domain.AutoSSL, + }) +} + +func (h *Handlers) ConfigureAuthentication(c *gin.Context) { + var req struct { + AuthMethod string `json:"auth_method"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.AuthMethod == "" { + req.AuthMethod = "both" + } + if req.AuthMethod != "password" && req.AuthMethod != "apikey" && req.AuthMethod != "both" { + c.JSON(http.StatusBadRequest, gin.H{"error": "auth_method must be 'password', 'apikey', or 'both'"}) + return + } + + if h.authManager == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Auth manager not available"}) + return + } + + result := gin.H{ + "auth_method": req.AuthMethod, + } + + var userID int64 + + if req.AuthMethod == "password" || req.AuthMethod == "both" { + if req.Username == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username and password are required for password authentication"}) + return + } + if len(req.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"}) + return + } + + user, err := h.authManager.CreateUser(req.Username, req.Email, req.Password, auth.RoleAdmin, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) + return + } + userID = user.ID + result["username"] = user.Username + result["user_uid"] = user.UID + } + + if req.AuthMethod == "apikey" || req.AuthMethod == "both" { + if userID == 0 { + username := req.Username + if username == "" { + username = "system" + } + sysUser, err := h.authManager.CreateUser(username, "", "apikey-only-no-password-login", auth.RoleAdmin, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create system user: " + err.Error()}) + return + } + userID = sysUser.ID + } + + apiKey, plainKey, err := h.authManager.CreateAPIKey( + userID, + "Setup API Key", + "Generated during initial setup", + auth.RoleAdmin, + nil, + nil, + time.Time{}, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create API key: " + err.Error()}) + return + } + result["api_key"] = plainKey + result["api_key_id"] = apiKey.KeyID + } + + c.JSON(http.StatusOK, result) +} + +func (h *Handlers) Complete(c *gin.Context) { + if err := h.manager.MarkComplete(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark setup complete: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Setup completed successfully", + "completed_at": time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/internal/api/setup_handlers_test.go b/internal/setup/handlers_test.go similarity index 74% rename from internal/api/setup_handlers_test.go rename to internal/setup/handlers_test.go index 9de07fc..c74d001 100644 --- a/internal/api/setup_handlers_test.go +++ b/internal/setup/handlers_test.go @@ -1,4 +1,4 @@ -package api +package setup import ( "encoding/json" @@ -10,14 +10,12 @@ import ( "testing" "github.com/flatrun/agent/internal/auth" - "github.com/flatrun/agent/internal/docker" - "github.com/flatrun/agent/internal/networks" "github.com/flatrun/agent/pkg/config" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) -func setupTestSetupServer(t *testing.T) (*Server, string) { +func setupTestServer(t *testing.T) (*Handlers, *Manager, *gin.Engine, string) { t.Helper() gin.SetMode(gin.TestMode) @@ -50,45 +48,53 @@ func setupTestSetupServer(t *testing.T) (*Server, string) { }, } + configPath := filepath.Join(tmpDir, "config.yml") + if err := config.Save(cfg, configPath); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + router := gin.New() router.Use(cors.New(cors.Config{ AllowAllOrigins: true, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Content-Type", "Authorization", "Accept"}, })) - authMiddleware := auth.NewMiddleware(&cfg.Auth) + + mgr := NewManager(cfg, configPath) + authManager, err := auth.NewManager(tmpDir, &cfg.Auth, false) if err != nil { t.Fatalf("Failed to create auth manager: %v", err) } - authMiddleware.SetManager(authManager) - configPath := filepath.Join(tmpDir, "config.yml") - if err := config.Save(cfg, configPath); err != nil { - t.Fatalf("Failed to save config: %v", err) - } + handlers := NewHandlers(mgr, authManager) + + setupGroup := router.Group("/api/setup") + { + setupGroup.GET("/status", handlers.GetStatus) - s := &Server{ - config: cfg, - configPath: configPath, - router: router, - manager: docker.NewManager(tmpDir), - networksManager: networks.NewManager(), - authMiddleware: authMiddleware, - authManager: authManager, + guarded := setupGroup.Group("") + guarded.GET("/info", handlers.GetInfo) + guarded.Use(handlers.Guard()) + { + guarded.POST("/validate", handlers.ValidateSystem) + guarded.GET("/verify-dns", handlers.VerifyDNS) + guarded.POST("/settings", handlers.ConfigureSettings) + guarded.POST("/authentication", handlers.ConfigureAuthentication) + guarded.POST("/complete", handlers.Complete) + } } - s.setupRoutes() - return s, tmpDir + return handlers, mgr, router, tmpDir } func TestGetSetupStatus(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/setup/status", nil) - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) @@ -105,12 +111,12 @@ func TestGetSetupStatus(t *testing.T) { } func TestGetSetupInfo(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/setup/info", nil) - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) @@ -130,16 +136,16 @@ func TestGetSetupInfo(t *testing.T) { } func TestSetupGuardBlocksAfterComplete(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, mgr, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) - if err := s.markSetupComplete(); err != nil { + if err := mgr.MarkComplete(); err != nil { t.Fatalf("Failed to mark setup complete: %v", err) } w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/validate", nil) - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("Expected 403, got %d: %s", w.Code, w.Body.String()) @@ -147,16 +153,16 @@ func TestSetupGuardBlocksAfterComplete(t *testing.T) { } func TestSetupStatusAfterComplete(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, mgr, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) - if err := s.markSetupComplete(); err != nil { + if err := mgr.MarkComplete(); err != nil { t.Fatalf("Failed to mark setup complete: %v", err) } w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/setup/status", nil) - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d", w.Code) @@ -171,12 +177,12 @@ func TestSetupStatusAfterComplete(t *testing.T) { } func TestValidateSystem(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/validate", nil) - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) @@ -196,12 +202,12 @@ func TestValidateSystem(t *testing.T) { } func TestVerifyDNSMissingParam(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/setup/verify-dns", nil) - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("Expected 400, got %d", w.Code) @@ -209,28 +215,29 @@ func TestVerifyDNSMissingParam(t *testing.T) { } func TestConfigureSettings(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, mgr, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) body := `{"domain": "test.example.com", "auto_ssl": false, "cors_origins": ["https://panel.example.com"]}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/settings", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) } - if s.config.Domain.DefaultDomain != "test.example.com" { - t.Errorf("Expected domain to be test.example.com, got %s", s.config.Domain.DefaultDomain) + cfg := mgr.Config() + if cfg.Domain.DefaultDomain != "test.example.com" { + t.Errorf("Expected domain to be test.example.com, got %s", cfg.Domain.DefaultDomain) } - if s.config.Domain.AutoSSL != false { + if cfg.Domain.AutoSSL != false { t.Error("Expected auto_ssl to be false") } found := false - for _, o := range s.config.API.AllowedOrigins { + for _, o := range cfg.API.AllowedOrigins { if o == "https://panel.example.com" { found = true break @@ -242,14 +249,14 @@ func TestConfigureSettings(t *testing.T) { } func TestConfigureAuthenticationPassword(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) body := `{"auth_method": "password", "username": "testadmin", "password": "securepass123", "email": "test@example.com"}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/authentication", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) @@ -267,14 +274,14 @@ func TestConfigureAuthenticationPassword(t *testing.T) { } func TestConfigureAuthenticationAPIKey(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) body := `{"auth_method": "apikey"}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/authentication", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) @@ -289,14 +296,14 @@ func TestConfigureAuthenticationAPIKey(t *testing.T) { } func TestConfigureAuthenticationBoth(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) body := `{"auth_method": "both", "username": "admin", "password": "securepass123"}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/authentication", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) @@ -314,14 +321,14 @@ func TestConfigureAuthenticationBoth(t *testing.T) { } func TestConfigureAuthenticationShortPassword(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) body := `{"auth_method": "password", "username": "admin", "password": "short"}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/authentication", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("Expected 400, got %d: %s", w.Code, w.Body.String()) @@ -329,30 +336,30 @@ func TestConfigureAuthenticationShortPassword(t *testing.T) { } func TestCompleteSetup(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, mgr, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/setup/complete", nil) - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) } - if !s.isSetupComplete() { + if !mgr.IsComplete() { t.Error("Expected setup to be marked complete") } } func TestSetupCORSHeaders(t *testing.T) { - s, tmpDir := setupTestSetupServer(t) + _, _, router, tmpDir := setupTestServer(t) defer os.RemoveAll(tmpDir) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/setup/status", nil) req.Header.Set("Origin", "http://192.168.1.100") - s.router.ServeHTTP(w, req) + router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("Expected 200, got %d", w.Code) @@ -363,3 +370,37 @@ func TestSetupCORSHeaders(t *testing.T) { t.Errorf("Expected CORS origin header '*' (AllowedOrigins=[*]), got %s", corsHeader) } } + +func TestManagerIsComplete(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{DeploymentsPath: tmpDir} + mgr := NewManager(cfg, "") + + if mgr.IsComplete() { + t.Error("Expected IsComplete to be false initially") + } + + if err := mgr.MarkComplete(); err != nil { + t.Fatalf("Failed to mark complete: %v", err) + } + + if !mgr.IsComplete() { + t.Error("Expected IsComplete to be true after marking complete") + } +} + +func TestIsCompleteStandalone(t *testing.T) { + tmpDir := t.TempDir() + + if IsComplete(tmpDir) { + t.Error("Expected false for fresh directory") + } + + flatrunDir := filepath.Join(tmpDir, ".flatrun") + os.MkdirAll(flatrunDir, 0755) + os.WriteFile(filepath.Join(flatrunDir, "setup.json"), []byte(`{"initialized": true}`), 0644) + + if !IsComplete(tmpDir) { + t.Error("Expected true after writing state file") + } +} diff --git a/internal/setup/manager.go b/internal/setup/manager.go new file mode 100644 index 0000000..d3c7cca --- /dev/null +++ b/internal/setup/manager.go @@ -0,0 +1,100 @@ +package setup + +import ( + "encoding/json" + "net" + "os" + "path/filepath" + "time" + + "github.com/flatrun/agent/internal/system" + "github.com/flatrun/agent/pkg/config" +) + +type State struct { + Initialized bool `json:"initialized"` + CompletedAt string `json:"completed_at,omitempty"` +} + +type Manager struct { + config *config.Config + configPath string + cachedIP string +} + +func NewManager(cfg *config.Config, configPath string) *Manager { + return &Manager{ + config: cfg, + configPath: configPath, + } +} + +func (m *Manager) Config() *config.Config { + return m.config +} + +func (m *Manager) ConfigPath() string { + return m.configPath +} + +func (m *Manager) statePath() string { + return filepath.Join(m.config.DeploymentsPath, ".flatrun", "setup.json") +} + +func IsComplete(deploymentsPath string) bool { + data, err := os.ReadFile(filepath.Join(deploymentsPath, ".flatrun", "setup.json")) + if err != nil { + return false + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return false + } + return state.Initialized +} + +func (m *Manager) IsComplete() bool { + return IsComplete(m.config.DeploymentsPath) +} + +func (m *Manager) MarkComplete() error { + state := State{ + Initialized: true, + CompletedAt: time.Now().UTC().Format(time.RFC3339), + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + dir := filepath.Dir(m.statePath()) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(m.statePath(), data, 0644) +} + +func (m *Manager) GetInstanceIP() string { + if m.cachedIP != "" { + return m.cachedIP + } + ip := ResolvePublicIP() + m.cachedIP = ip + return ip +} + +func ResolvePublicIP() string { + if ip, err := system.GetPublicIP("4"); err == nil { + return ip + } + + addrs, err := net.InterfaceAddrs() + if err == nil { + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + + return "127.0.0.1" +} diff --git a/internal/setup/validation.go b/internal/setup/validation.go new file mode 100644 index 0000000..3d46867 --- /dev/null +++ b/internal/setup/validation.go @@ -0,0 +1,144 @@ +package setup + +import ( + "fmt" + "net" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +type CheckResult struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` + Required bool `json:"required"` +} + +func RunSystemChecks(deploymentsPath string) []CheckResult { + return []CheckResult{ + CheckDocker(), + CheckDockerCompose(), + CheckDiskSpace(deploymentsPath), + CheckMemory(), + CheckPort(80, "FlatRun welcome page"), + CheckPort(443, "FlatRun"), + } +} + +func CheckDocker() CheckResult { + check := CheckResult{Name: "Docker", Required: true} + if _, err := exec.LookPath("docker"); err != nil { + check.Status = "fail" + check.Message = "Docker is not installed" + return check + } + out, err := exec.Command("docker", "info", "--format", "{{.ServerVersion}}").Output() + if err != nil { + check.Status = "fail" + check.Message = "Docker is installed but not running" + } else { + check.Status = "pass" + check.Message = fmt.Sprintf("Docker %s", strings.TrimSpace(string(out))) + } + return check +} + +func CheckDockerCompose() CheckResult { + check := CheckResult{Name: "Docker Compose", Required: true} + out, err := exec.Command("docker", "compose", "version", "--short").Output() + if err != nil { + check.Status = "fail" + check.Message = "Docker Compose plugin not found" + } else { + check.Status = "pass" + check.Message = fmt.Sprintf("Docker Compose %s", strings.TrimSpace(string(out))) + } + return check +} + +func CheckDiskSpace(path string) CheckResult { + check := CheckResult{Name: "Disk Space", Required: true} + diskFree := GetDiskFreeGB(path) + if diskFree < 1 { + check.Status = "fail" + check.Message = "Less than 1 GB free disk space" + } else if diskFree < 5 { + check.Status = "warn" + check.Message = fmt.Sprintf("%.1f GB free (5 GB+ recommended)", diskFree) + } else { + check.Status = "pass" + check.Message = fmt.Sprintf("%.1f GB free", diskFree) + } + return check +} + +func CheckMemory() CheckResult { + check := CheckResult{Name: "Memory", Required: false} + totalMB := GetHostMemoryMB() + if totalMB < 512 { + check.Status = "warn" + check.Message = fmt.Sprintf("%d MB total (512 MB+ recommended)", totalMB) + } else { + check.Status = "pass" + check.Message = fmt.Sprintf("%d MB total", totalMB) + } + return check +} + +func CheckPort(port int, inUseBy string) CheckResult { + check := CheckResult{ + Name: fmt.Sprintf("Port %d", port), + Required: false, + Status: "pass", + } + if IsPortAvailable(port) { + check.Message = fmt.Sprintf("Port %d is available", port) + } else { + check.Message = fmt.Sprintf("Port %d is in use by %s", port, inUseBy) + } + return check +} + +func IsPortAvailable(port int) bool { + portStr := fmt.Sprintf("%d", port) + for _, host := range []string{"0.0.0.0", "127.0.0.1", "::1"} { + ln, err := net.Listen("tcp", net.JoinHostPort(host, portStr)) + if err != nil { + return false + } + ln.Close() + } + return true +} + +func GetDiskFreeGB(path string) float64 { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return -1 + } + return float64(stat.Bavail*uint64(stat.Bsize)) / (1024 * 1024 * 1024) +} + +func GetHostMemoryMB() uint64 { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0 + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) + if len(fields) < 2 { + return 0 + } + kb, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0 + } + return kb / 1024 + } + } + return 0 +} From 41bf0d4c8a621fa9b770243560a94bc89866d133 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 23 Mar 2026 14:58:30 +0100 Subject: [PATCH 3/3] enhance(setup): Metadata-driven template files and robust port checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded writeNginxFiles/writeInfraTemplateFiles with a generic writeSetupFiles that reads dirs and files from metadata.yml. New infra templates only need metadata — no per-template Go code. Port checks now handle systems with IPv6 disabled by testing IPv6 support before treating bind failures as "port in use." Signed-off-by: nfebe --- cmd/agent/setup.go | 88 ++++++++++++++++++---------- cmd/agent/setup_test.go | 94 +++++++++++++++++++----------- internal/setup/validation.go | 16 ++++- templates/infra/nginx/metadata.yml | 18 ++++++ templates/templates.go | 4 ++ 5 files changed, 153 insertions(+), 67 deletions(-) diff --git a/cmd/agent/setup.go b/cmd/agent/setup.go index 9ef1555..1e460b5 100644 --- a/cmd/agent/setup.go +++ b/cmd/agent/setup.go @@ -16,9 +16,23 @@ import ( ) type templateMetadata struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Category string `yaml:"category"` + Name string `yaml:"name"` + Type string `yaml:"type"` + Category string `yaml:"category"` + Setup setupManifest `yaml:"setup"` +} + +type setupManifest struct { + Dirs []string `yaml:"dirs"` + Files []setupFileEntry `yaml:"files"` +} + +type setupFileEntry struct { + Src string `yaml:"src"` + Dest string `yaml:"dest"` + Overwrite bool `yaml:"overwrite"` + External bool `yaml:"external"` + Empty bool `yaml:"empty"` } func handleSetup(args []string) { @@ -163,7 +177,12 @@ func deployInfraService(cfg *config.Config, serviceName, templateID string) erro return fmt.Errorf("create deployment: %w", err) } - if err := writeInfraTemplateFiles(cfg, serviceName, templateID, deployDir); err != nil { + meta, err := loadInfraMetadata(templateID) + if err != nil { + return fmt.Errorf("load metadata: %w", err) + } + + if err := writeSetupFiles(meta, templateID, deployDir); err != nil { return fmt.Errorf("write template files: %w", err) } @@ -181,41 +200,46 @@ func deployInfraService(cfg *config.Config, serviceName, templateID string) erro return nil } -func writeInfraTemplateFiles(cfg *config.Config, serviceName, templateID, deployDir string) error { - switch templateID { - case "infra/nginx": - return writeNginxFiles(cfg, deployDir) - } - return nil -} - -func writeNginxFiles(cfg *config.Config, deployDir string) error { - for _, dir := range []string{"conf.d", "certs", "html", "lua"} { +func writeSetupFiles(meta *templateMetadata, templateID, deployDir string) error { + for _, dir := range meta.Setup.Dirs { if err := os.MkdirAll(filepath.Join(deployDir, dir), 0755); err != nil { - return fmt.Errorf("create %s: %w", dir, err) + return fmt.Errorf("create directory %s: %w", dir, err) } } - nginxConf, err := templates.GetNginxConfig(false) - if err != nil { - return fmt.Errorf("read nginx.conf: %w", err) - } - if err := os.WriteFile(filepath.Join(deployDir, "nginx.conf"), nginxConf, 0644); err != nil { - return fmt.Errorf("write nginx.conf: %w", err) - } + for _, f := range meta.Setup.Files { + destPath := filepath.Join(deployDir, f.Dest) - rateLimits := filepath.Join(deployDir, "conf.d", "rate_limits.conf") - if _, err := os.Stat(rateLimits); os.IsNotExist(err) { - if err := os.WriteFile(rateLimits, []byte(""), 0644); err != nil { - return fmt.Errorf("write rate_limits.conf: %w", err) + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("create parent dir for %s: %w", f.Dest, err) + } + + if !f.Overwrite { + if _, err := os.Stat(destPath); err == nil { + continue + } + } + + if f.Empty { + if err := os.WriteFile(destPath, []byte(""), 0644); err != nil { + return fmt.Errorf("write %s: %w", f.Dest, err) + } + continue + } + + var data []byte + var err error + if f.External { + data, err = templates.FS.ReadFile(f.Src) + } else { + data, err = templates.GetFile(templateID, f.Src) + } + if err != nil { + return fmt.Errorf("read template file %s: %w", f.Src, err) } - } - welcomePage, err := templates.GetWelcomePage() - if err == nil { - indexPath := filepath.Join(deployDir, "html", "index.html") - if _, err := os.Stat(indexPath); os.IsNotExist(err) { - _ = os.WriteFile(indexPath, welcomePage, 0644) + if err := os.WriteFile(destPath, data, 0644); err != nil { + return fmt.Errorf("write %s: %w", f.Dest, err) } } diff --git a/cmd/agent/setup_test.go b/cmd/agent/setup_test.go index 39ce511..54ce3f2 100644 --- a/cmd/agent/setup_test.go +++ b/cmd/agent/setup_test.go @@ -38,22 +38,29 @@ func TestLoadInfraMetadata_NotInfra(t *testing.T) { } } -func TestWriteNginxFiles(t *testing.T) { - tmpDir := t.TempDir() - cfg := &config.Config{ - DeploymentsPath: tmpDir, - Infrastructure: config.InfrastructureConfig{ - DefaultProxyNetwork: "proxy", - }, +func TestLoadInfraMetadata_HasSetupManifest(t *testing.T) { + meta, err := loadInfraMetadata("infra/nginx") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if len(meta.Setup.Dirs) == 0 { + t.Error("Expected setup dirs to be defined") + } + if len(meta.Setup.Files) == 0 { + t.Error("Expected setup files to be defined") } +} - deployDir := filepath.Join(tmpDir, "nginx") - if err := os.MkdirAll(deployDir, 0755); err != nil { - t.Fatal(err) +func TestWriteSetupFiles(t *testing.T) { + meta, err := loadInfraMetadata("infra/nginx") + if err != nil { + t.Fatalf("Failed to load metadata: %v", err) } - if err := writeNginxFiles(cfg, deployDir); err != nil { - t.Fatalf("writeNginxFiles failed: %v", err) + deployDir := t.TempDir() + + if err := writeSetupFiles(meta, "infra/nginx", deployDir); err != nil { + t.Fatalf("writeSetupFiles failed: %v", err) } expectedFiles := []string{ @@ -80,16 +87,13 @@ func TestWriteNginxFiles(t *testing.T) { } } -func TestWriteNginxFiles_PreservesExistingIndex(t *testing.T) { - tmpDir := t.TempDir() - cfg := &config.Config{ - DeploymentsPath: tmpDir, - Infrastructure: config.InfrastructureConfig{ - DefaultProxyNetwork: "proxy", - }, +func TestWriteSetupFiles_PreservesExistingIndex(t *testing.T) { + meta, err := loadInfraMetadata("infra/nginx") + if err != nil { + t.Fatalf("Failed to load metadata: %v", err) } - deployDir := filepath.Join(tmpDir, "nginx") + deployDir := t.TempDir() htmlDir := filepath.Join(deployDir, "html") if err := os.MkdirAll(htmlDir, 0755); err != nil { t.Fatal(err) @@ -100,8 +104,8 @@ func TestWriteNginxFiles_PreservesExistingIndex(t *testing.T) { t.Fatal(err) } - if err := writeNginxFiles(cfg, deployDir); err != nil { - t.Fatalf("writeNginxFiles failed: %v", err) + if err := writeSetupFiles(meta, "infra/nginx", deployDir); err != nil { + t.Fatalf("writeSetupFiles failed: %v", err) } data, err := os.ReadFile(filepath.Join(htmlDir, "index.html")) @@ -109,20 +113,17 @@ func TestWriteNginxFiles_PreservesExistingIndex(t *testing.T) { t.Fatal(err) } if string(data) != string(customContent) { - t.Error("writeNginxFiles should not overwrite existing index.html") + t.Error("writeSetupFiles should not overwrite existing index.html") } } -func TestWriteNginxFiles_PreservesExistingRateLimits(t *testing.T) { - tmpDir := t.TempDir() - cfg := &config.Config{ - DeploymentsPath: tmpDir, - Infrastructure: config.InfrastructureConfig{ - DefaultProxyNetwork: "proxy", - }, +func TestWriteSetupFiles_PreservesExistingRateLimits(t *testing.T) { + meta, err := loadInfraMetadata("infra/nginx") + if err != nil { + t.Fatalf("Failed to load metadata: %v", err) } - deployDir := filepath.Join(tmpDir, "nginx") + deployDir := t.TempDir() confDir := filepath.Join(deployDir, "conf.d") if err := os.MkdirAll(confDir, 0755); err != nil { t.Fatal(err) @@ -133,8 +134,8 @@ func TestWriteNginxFiles_PreservesExistingRateLimits(t *testing.T) { t.Fatal(err) } - if err := writeNginxFiles(cfg, deployDir); err != nil { - t.Fatalf("writeNginxFiles failed: %v", err) + if err := writeSetupFiles(meta, "infra/nginx", deployDir); err != nil { + t.Fatalf("writeSetupFiles failed: %v", err) } data, err := os.ReadFile(filepath.Join(confDir, "rate_limits.conf")) @@ -142,7 +143,32 @@ func TestWriteNginxFiles_PreservesExistingRateLimits(t *testing.T) { t.Fatal(err) } if string(data) != string(customContent) { - t.Error("writeNginxFiles should not overwrite existing rate_limits.conf") + t.Error("writeSetupFiles should not overwrite existing rate_limits.conf") + } +} + +func TestWriteSetupFiles_OverwritesNginxConf(t *testing.T) { + meta, err := loadInfraMetadata("infra/nginx") + if err != nil { + t.Fatalf("Failed to load metadata: %v", err) + } + + deployDir := t.TempDir() + oldContent := []byte("old nginx config") + if err := os.WriteFile(filepath.Join(deployDir, "nginx.conf"), oldContent, 0644); err != nil { + t.Fatal(err) + } + + if err := writeSetupFiles(meta, "infra/nginx", deployDir); err != nil { + t.Fatalf("writeSetupFiles failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(deployDir, "nginx.conf")) + if err != nil { + t.Fatal(err) + } + if string(data) == string(oldContent) { + t.Error("nginx.conf should be overwritten (overwrite: true)") } } diff --git a/internal/setup/validation.go b/internal/setup/validation.go index 3d46867..3c215d7 100644 --- a/internal/setup/validation.go +++ b/internal/setup/validation.go @@ -104,13 +104,27 @@ func CheckPort(port int, inUseBy string) CheckResult { func IsPortAvailable(port int) bool { portStr := fmt.Sprintf("%d", port) - for _, host := range []string{"0.0.0.0", "127.0.0.1", "::1"} { + for _, host := range []string{"0.0.0.0", "127.0.0.1"} { ln, err := net.Listen("tcp", net.JoinHostPort(host, portStr)) if err != nil { return false } ln.Close() } + if ln, err := net.Listen("tcp6", net.JoinHostPort("::1", portStr)); err == nil { + ln.Close() + } else if isIPv6Supported() { + return false + } + return true +} + +func isIPv6Supported() bool { + ln, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + return false + } + ln.Close() return true } diff --git a/templates/infra/nginx/metadata.yml b/templates/infra/nginx/metadata.yml index 3b8c329..2b894a3 100644 --- a/templates/infra/nginx/metadata.yml +++ b/templates/infra/nginx/metadata.yml @@ -5,3 +5,21 @@ logo: https://cdn.simpleicons.org/nginx category: infrastructure type: infrastructure priority: 100 + +setup: + dirs: + - conf.d + - certs + - html + - lua + files: + - src: nginx.conf + dest: nginx.conf + overwrite: true + - src: welcome/index.html + dest: html/index.html + overwrite: false + external: true + - dest: conf.d/rate_limits.conf + overwrite: false + empty: true diff --git a/templates/templates.go b/templates/templates.go index 38a0176..af13a94 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -76,6 +76,10 @@ func GetCompose(name string) ([]byte, error) { return FS.ReadFile(filepath.Join(name, "docker-compose.yml")) } +func GetFile(templateID, filename string) ([]byte, error) { + return FS.ReadFile(filepath.Join(templateID, filename)) +} + func GetWelcomePage() ([]byte, error) { return FS.ReadFile("welcome/index.html") }