From 981e9d7ff3f23188578d2208c108c493b7a903d8 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 24 May 2026 18:58:41 +0100 Subject: [PATCH] feat(api): Add deploy endpoint, compose mount, and custom-image create Adds POST /deployments/:name/deploy that bundles image pull and the requested lifecycle action in one request, so scripted rollouts no longer need a separate pull-then-restart sequence. Adds POST /deployments/:name/compose/mount that wires a deployment- directory path into one compose service as a bind mount, with traversal protection on the source and absolute-path enforcement on the target. The mount handler also honors an optional SELinux relabel flag for hosts where SELinux is enforcing, so the resulting bind mount stays readable inside the container on RHEL-family distributions. Lets deployment creation request a specific container image and exposes basic validation on it, so the custom-compose path is no longer hardcoded to nginx:alpine. --- internal/api/deploy_test.go | 201 ++++++++++++++++++++++++ internal/api/server.go | 295 +++++++++++++++++++++++++++++++++++- internal/api/server_test.go | 92 +++++++++++ 3 files changed, 585 insertions(+), 3 deletions(-) create mode 100644 internal/api/deploy_test.go 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}