diff --git a/internal/api/deploy_test.go b/internal/api/deploy_test.go new file mode 100644 index 0000000..1a5272a --- /dev/null +++ b/internal/api/deploy_test.go @@ -0,0 +1,201 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flatrun/agent/internal/docker" + "github.com/flatrun/agent/pkg/config" + "github.com/gin-gonic/gin" +) + +func TestDeployDeploymentRejectsUnsupportedAction(t *testing.T) { + gin.SetMode(gin.TestMode) + + server := &Server{} + router := gin.New() + router.POST("/deployments/:name/deploy", server.deployDeployment) + + req := httptest.NewRequest(http.MethodPost, "/deployments/web/deploy", bytes.NewBufferString(`{"action":"delete"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeployDeploymentRejectsInvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + + server := &Server{} + router := gin.New() + router.POST("/deployments/:name/deploy", server.deployDeployment) + + req := httptest.NewRequest(http.MethodPost, "/deployments/web/deploy", bytes.NewBufferString(`{`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAddDeploymentComposeMountPersistsCompose(t *testing.T) { + gin.SetMode(gin.TestMode) + + tmpDir := t.TempDir() + deployDir := filepath.Join(tmpDir, "web") + if err := os.MkdirAll(deployDir, 0755); err != nil { + t.Fatalf("mkdir deployment: %v", err) + } + compose := `name: web +services: + app: + image: nginx:alpine + networks: + - proxy +networks: + proxy: + external: true +` + if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte(compose), 0644); err != nil { + t.Fatalf("write compose: %v", err) + } + + server := &Server{ + config: &config.Config{ + Infrastructure: config.InfrastructureConfig{DefaultProxyNetwork: "proxy"}, + }, + manager: docker.NewManager(tmpDir), + } + router := gin.New() + router.POST("/deployments/:name/compose/mount", server.addDeploymentComposeMount) + + req := httptest.NewRequest(http.MethodPost, "/deployments/web/compose/mount", bytes.NewBufferString(`{ + "source_path": "/config/nginx.conf", + "target_path": "/etc/nginx/conf.d/default.conf", + "service_name": "app", + "read_only": true + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + updated, err := os.ReadFile(filepath.Join(deployDir, "docker-compose.yml")) + if err != nil { + t.Fatalf("read compose: %v", err) + } + if !strings.Contains(string(updated), `./config/nginx.conf:/etc/nginx/conf.d/default.conf:ro`) { + t.Fatalf("compose missing mount:\n%s", string(updated)) + } +} + +func TestAddDeploymentComposeMountAppliesSELinuxRelabel(t *testing.T) { + gin.SetMode(gin.TestMode) + + tmpDir := t.TempDir() + deployDir := filepath.Join(tmpDir, "web") + if err := os.MkdirAll(deployDir, 0755); err != nil { + t.Fatalf("mkdir deployment: %v", err) + } + compose := `name: web +services: + app: + image: nginx:alpine + networks: + - proxy +networks: + proxy: + external: true +` + if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte(compose), 0644); err != nil { + t.Fatalf("write compose: %v", err) + } + + server := &Server{ + config: &config.Config{ + Infrastructure: config.InfrastructureConfig{DefaultProxyNetwork: "proxy"}, + }, + manager: docker.NewManager(tmpDir), + } + router := gin.New() + router.POST("/deployments/:name/compose/mount", server.addDeploymentComposeMount) + + req := httptest.NewRequest(http.MethodPost, "/deployments/web/compose/mount", bytes.NewBufferString(`{ + "source_path": "/data", + "target_path": "/var/lib/data", + "service_name": "app", + "read_only": true, + "selinux": "Z" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + updated, err := os.ReadFile(filepath.Join(deployDir, "docker-compose.yml")) + if err != nil { + t.Fatalf("read compose: %v", err) + } + if !strings.Contains(string(updated), `./data:/var/lib/data:ro,Z`) { + t.Fatalf("compose missing selinux-relabelled mount:\n%s", string(updated)) + } +} + +func TestAddDeploymentComposeMountRejectsInvalidSELinux(t *testing.T) { + gin.SetMode(gin.TestMode) + + server := &Server{} + router := gin.New() + router.POST("/deployments/:name/compose/mount", server.addDeploymentComposeMount) + + req := httptest.NewRequest(http.MethodPost, "/deployments/web/compose/mount", bytes.NewBufferString(`{ + "source_path": "/data", + "target_path": "/data", + "service_name": "app", + "selinux": "bogus" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAddDeploymentComposeMountRejectsTraversal(t *testing.T) { + gin.SetMode(gin.TestMode) + + server := &Server{} + router := gin.New() + router.POST("/deployments/:name/compose/mount", server.addDeploymentComposeMount) + + req := httptest.NewRequest(http.MethodPost, "/deployments/web/compose/mount", bytes.NewBufferString(`{ + "source_path": "../secret", + "target_path": "/secret", + "service_name": "app" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 54f6001..72545ae 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -324,11 +324,13 @@ func (s *Server) setupRoutes() { protected.POST("/deployments/:name/stop", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.stopDeployment) protected.POST("/deployments/:name/restart", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.restartDeployment) protected.POST("/deployments/:name/rebuild", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.rebuildDeployment) + protected.POST("/deployments/:name/deploy", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.deployDeployment) protected.POST("/deployments/:name/pull", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.pullDeploymentImage) protected.GET("/deployments/:name/images", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentImages) protected.POST("/deployments/:name/actions/:actionId", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.executeQuickAction) protected.GET("/deployments/:name/logs", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentLogs) protected.GET("/deployments/:name/compose", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentCompose) + protected.POST("/deployments/:name/compose/mount", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.addDeploymentComposeMount) // Network endpoints protected.GET("/networks", s.authMiddleware.RequirePermission(auth.PermNetworksRead), s.listNetworks) @@ -776,10 +778,15 @@ func (d *DatabaseConfigRequest) Validate() error { func (s *Server) createDeployment(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` + Image string `json:"image,omitempty"` ComposeContent string `json:"compose_content"` TemplateID string `json:"template_id,omitempty"` Metadata *models.ServiceMetadata `json:"metadata,omitempty"` EnvVars []EnvVar `json:"env_vars,omitempty"` + ContainerPort int `json:"container_port,omitempty"` + MapPorts bool `json:"map_ports,omitempty"` + HostPort string `json:"host_port,omitempty"` + Ports []PortConfig `json:"ports,omitempty"` AutoStart bool `json:"auto_start"` UseSharedDatabase bool `json:"use_shared_database"` ExistingDatabaseContainer string `json:"existing_database_container,omitempty"` @@ -804,7 +811,7 @@ func (s *Server) createDeployment(c *gin.Context) { } if req.ComposeContent == "" { - generated, err := s.generateComposeContent(req.Name, req.TemplateID) + generated, err := s.generateDeploymentCompose(req.Name, req.Image, req.TemplateID, req.ContainerPort, req.MapPorts, req.HostPort, req.Ports) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "compose_content is required when template cannot be resolved: " + err.Error(), @@ -1732,6 +1739,98 @@ func (s *Server) rebuildDeployment(c *gin.Context) { }) } +func (s *Server) deployDeployment(c *gin.Context) { + name := c.Param("name") + + req := struct { + Action string `json:"action"` + Pull *bool `json:"pull"` + OnlyLatest bool `json:"only_latest"` + }{ + Action: "restart", + } + if c.Request.Body != nil && c.Request.ContentLength != 0 { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request body: " + err.Error(), + }) + return + } + } + if req.Action == "" { + req.Action = "restart" + } + + pull := true + if req.Pull != nil { + pull = *req.Pull + } + + if req.Action != "restart" && req.Action != "rebuild" && req.Action != "start" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Unsupported deploy action. Use one of: restart, rebuild, start", + }) + return + } + + if _, err := s.manager.GetDeployment(name); err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Deployment not found: " + err.Error(), + }) + return + } + + dockerAuth, opts := s.deploymentAuthOptions(name) + defer dockerAuth.Close() + + var pullOutput string + if pull { + output, err := s.manager.PullDeployment(name, req.OnlyLatest, opts...) + pullOutput = output + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + "name": name, + "action": req.Action, + "pulled": false, + "pull_output": pullOutput, + }) + return + } + } + + var output string + var err error + switch req.Action { + case "restart": + output, err = s.manager.RestartDeployment(name, opts...) + case "rebuild": + output, err = s.manager.RebuildDeployment(name, opts...) + case "start": + output, err = s.manager.StartDeployment(name, opts...) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + "name": name, + "action": req.Action, + "pulled": pull, + "pull_output": pullOutput, + "deploy_output": output, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Deployment completed", + "name": name, + "action": req.Action, + "pulled": pull, + "pull_output": pullOutput, + "deploy_output": output, + }) +} + func (s *Server) pullDeploymentImage(c *gin.Context) { name := c.Param("name") @@ -1843,6 +1942,141 @@ func (s *Server) getDeploymentCompose(c *gin.Context) { }) } +func (s *Server) addDeploymentComposeMount(c *gin.Context) { + name := c.Param("name") + + var req struct { + SourcePath string `json:"source_path" binding:"required"` + TargetPath string `json:"target_path" binding:"required"` + ServiceName string `json:"service_name" binding:"required"` + ReadOnly bool `json:"read_only"` + SELinux string `json:"selinux"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + sourcePath, err := normalizeComposeMountSource(req.SourcePath) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + targetPath := strings.TrimSpace(req.TargetPath) + if !strings.HasPrefix(targetPath, "/") { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "target_path must be an absolute container path", + }) + return + } + + var opts []string + if req.ReadOnly { + opts = append(opts, "ro") + } + switch req.SELinux { + case "": + case "z", "Z": + opts = append(opts, req.SELinux) + default: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "selinux must be empty, 'z' (shared), or 'Z' (private)", + }) + return + } + + content, filename, err := s.manager.GetComposeFile(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": err.Error(), + }) + return + } + + volumeMount := sourcePath + ":" + targetPath + if len(opts) > 0 { + volumeMount += ":" + strings.Join(opts, ",") + } + + alreadyMounted := docker.HasVolumeMount(content, req.ServiceName, volumeMount) + updated, err := docker.AddVolumeToService(content, req.ServiceName, volumeMount) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + if updated == content && !alreadyMounted { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "service not found or compose file could not be updated", + }) + return + } + + if err := s.validateComposeContent(updated, name); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + if err := s.manager.UpdateDeployment(name, updated); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Mount added", + "name": name, + "filename": filename, + "content": updated, + "service_name": req.ServiceName, + "mount": volumeMount, + "added": !alreadyMounted, + }) +} + +func normalizeComposeMountSource(sourcePath string) (string, error) { + sourcePath = strings.TrimSpace(sourcePath) + if sourcePath == "" { + return "", fmt.Errorf("source_path is required") + } + if strings.Contains(sourcePath, "\x00") { + return "", fmt.Errorf("source_path is invalid") + } + + sourcePath = strings.ReplaceAll(sourcePath, "\\", "/") + if strings.HasPrefix(sourcePath, "/") { + sourcePath = "." + sourcePath + } + if sourcePath == "." { + return ".", nil + } + if !strings.HasPrefix(sourcePath, "./") { + sourcePath = "./" + strings.TrimPrefix(sourcePath, "/") + } + + cleaned := path.Clean(sourcePath) + if cleaned == "." { + return ".", nil + } + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("source_path must stay inside the deployment directory") + } + if !strings.HasPrefix(cleaned, "./") { + cleaned = "./" + cleaned + } + return cleaned, nil +} + func (s *Server) listNetworks(c *gin.Context) { networks, err := s.networksManager.ListNetworks() if err != nil { @@ -2607,6 +2841,7 @@ type MountSelection struct { type ComposeGenerateRequest struct { Name string `json:"name" binding:"required"` + Image string `json:"image,omitempty"` ContainerPort int `json:"container_port"` MapPorts bool `json:"map_ports"` HostPort string `json:"host_port"` @@ -2615,7 +2850,9 @@ type ComposeGenerateRequest struct { type PortConfig struct { ContainerPort int `json:"container_port"` + Container int `json:"container,omitempty"` HostPort string `json:"host_port"` + Host string `json:"host,omitempty"` } type ComposeUpdateRequest struct { @@ -2898,6 +3135,37 @@ func (s *Server) generateComposeWithOptions(templateID string, opts *ComposeGene return content, nil } +func (s *Server) generateDeploymentCompose(name, image, templateID string, containerPort int, mapPorts bool, hostPort string, ports []PortConfig) (string, error) { + useCustomOptions := image != "" || containerPort != 0 || mapPorts || hostPort != "" || len(ports) > 0 + if !useCustomOptions { + return s.generateComposeContent(name, templateID) + } + + composeReq := ComposeGenerateRequest{ + Name: name, + Image: image, + ContainerPort: containerPort, + MapPorts: mapPorts, + HostPort: hostPort, + } + if len(ports) > 0 { + composeReq.ContainerPort = ports[0].ContainerPort + if composeReq.ContainerPort == 0 { + composeReq.ContainerPort = ports[0].Container + } + hostPort := ports[0].HostPort + if hostPort == "" { + hostPort = ports[0].Host + } + if hostPort != "" { + composeReq.MapPorts = true + composeReq.HostPort = hostPort + } + } + + return s.generateComposeWithOptions(templateID, &composeReq) +} + func (s *Server) injectMounts(content string, selections []MountSelection, available []TemplateMount) string { mountMap := make(map[string]TemplateMount) for _, m := range available { @@ -2997,6 +3265,13 @@ func hasVolumeOptions(volume string) bool { func (s *Server) generateCustomCompose(opts *ComposeGenerateRequest) (string, error) { networkName := s.config.Infrastructure.DefaultProxyNetwork + image := strings.TrimSpace(opts.Image) + if image == "" { + image = "nginx:alpine" + } + if err := validateImageName(image); err != nil { + return "", err + } containerPort := opts.ContainerPort if containerPort == 0 { @@ -3011,7 +3286,7 @@ func (s *Server) generateCustomCompose(opts *ComposeGenerateRequest) (string, er content := fmt.Sprintf(`name: %s services: app: - image: nginx:alpine + image: %s container_name: %s %s networks: @@ -3021,11 +3296,25 @@ services: networks: %s: external: true -`, opts.Name, opts.Name, portConfig, networkName, networkName) +`, opts.Name, image, opts.Name, portConfig, networkName, networkName) return content, nil } +func validateImageName(image string) error { + if image == "" { + return fmt.Errorf("image is required") + } + if len(image) > 255 { + return fmt.Errorf("image name is too long") + } + validImage := regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]*$`) + if !validImage.MatchString(image) { + return fmt.Errorf("invalid image name %q", image) + } + return nil +} + type composeFile struct { Name string `yaml:"name"` Services map[string]composeService `yaml:"services"` diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 992a5f1..a58bb59 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -585,6 +585,98 @@ func TestGenerateComposeWithOptionsCustomTemplate(t *testing.T) { } } +func TestGenerateComposeWithOptionsCustomImage(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + opts := &ComposeGenerateRequest{ + Name: "custom-app", + Image: "ghcr.io/flatrun/example:1.2.3", + ContainerPort: 3000, + } + + result, err := s.generateComposeWithOptions("custom", opts) + if err != nil { + t.Fatalf("generateComposeWithOptions failed: %v", err) + } + + if !strings.Contains(result, "image: ghcr.io/flatrun/example:1.2.3") { + t.Error("Result should contain requested image") + } + if !strings.Contains(result, "3000") { + t.Error("Result should expose requested container port") + } +} + +func TestGenerateDeploymentComposePreservesDefaultTemplate(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + result, err := s.generateDeploymentCompose("default-app", "", "", 0, false, "", nil) + if err != nil { + t.Fatalf("generateDeploymentCompose failed: %v", err) + } + + if !strings.Contains(result, "image: nginx:alpine") { + t.Error("Default deployment compose should still use the static template") + } + if !strings.Contains(result, "./html:/usr/share/nginx/html:ro") { + t.Error("Default deployment compose should preserve static template mounts") + } +} + +func TestGenerateDeploymentComposeImageWithPorts(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + result, err := s.generateDeploymentCompose("api", "registry.example.com/team/api:main", "", 0, false, "", []PortConfig{ + {Container: 8080, Host: "18080"}, + }) + if err != nil { + t.Fatalf("generateDeploymentCompose failed: %v", err) + } + + if !strings.Contains(result, "image: registry.example.com/team/api:main") { + t.Error("Result should contain requested image") + } + if !strings.Contains(result, "18080:8080") { + t.Error("Result should contain requested port mapping") + } +} + +func TestGenerateComposeWithOptionsRejectsInvalidImage(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + _, err := s.generateComposeWithOptions("custom", &ComposeGenerateRequest{ + Name: "bad-app", + Image: "nginx:latest\n privileged: true", + }) + if err == nil { + t.Fatal("Expected invalid image to be rejected") + } +} + func TestInjectMounts(t *testing.T) { cfg := &config.Config{} s := &Server{config: cfg}