diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b265e6..b84ad27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [0.4.0-beta.7] - 2026-08-23 + +Seventh beta of the Albacore release, focused on reliable first deployments through the CLI. + +### Added +- Bind mount ownership declarations and file ownership changes +- One-shot service jobs whose status and output follow the container process +- Partial environment variable updates with plan support +- OpenAPI metadata for accepted values and plan capable operations + +### Changed +- Deployment environments use `.env` as the principal file while retaining `.env.flatrun` compatibility +- Environment discovery reports every `.env` file and identifies the principal file +- Deployment images report the resolved image alongside its compose source expression +- Registered database connections provide defaults for database browsing +- PostgreSQL and postgres are accepted as database type names + +### Fixed +- Seeded bind mounts retain image ownership, and redeploys no longer apply one service owner to another service's mounts +- PostgreSQL users can create tables in databases provisioned for them +- Routed services join the proxy network before a domain is reported as configured +- Failed domain changes restore deployment metadata and compose configuration +- Deployment and service jobs time out instead of blocking later actions indefinitely +- Nested settings updates preserve omitted sibling values +- Multi-database deployments do not receive misleading legacy database variables +- Compose validation can read environment values supplied in the same create request +- Environment based image updates keep the compose expression and update its variable + ## [0.4.0-beta.6] - 2026-08-23 Sixth beta of the Albacore release, making connected servers manageable as one Fleet. diff --git a/README.md b/README.md index 7a6a0ec..b3f7e62 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,10 @@ sudo journalctl -u flatrun-agent -f Full documentation, guides, and the API reference live at [flatrun.dev/docs](https://flatrun.dev/docs). +The running agent publishes its exact OpenAPI description at `/api/openapi.json`. It includes +request fields, accepted values, permissions, response shapes, and plan support for the installed +version. + ## Security - Use strong, unique API keys. diff --git a/VERSION b/VERSION index cc930a0..f8bf8e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0-beta.6 +0.4.0-beta.7 diff --git a/internal/api/database_defaults_test.go b/internal/api/database_defaults_test.go new file mode 100644 index 0000000..ebfab1e --- /dev/null +++ b/internal/api/database_defaults_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestDatabaseListUsesRegisteredConnectionWhenBodyIsEmpty(t *testing.T) { + server, _, httpServer := setupPlanTestServer(t) + server.config.Infrastructure.Database.Enabled = true + server.config.Infrastructure.Database.Type = "postgres" + server.config.Infrastructure.Database.Host = "127.0.0.1" + server.config.Infrastructure.Database.Port = 1 + server.config.Infrastructure.Database.RootUser = "postgres" + server.config.Infrastructure.Database.RootPassword = "not-a-secret" + + request, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/databases/list", bytes.NewReader(nil)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var body map[string]any + _ = json.NewDecoder(response.Body).Decode(&body) + if response.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, body = %#v", response.StatusCode, body) + } + message, _ := body["error"].(string) + if !strings.Contains(message, "127.0.0.1") || strings.Contains(message, "[::1]:0") { + t.Fatalf("error did not use the registered database: %q", message) + } +} diff --git a/internal/api/deployment_create_env_test.go b/internal/api/deployment_create_env_test.go new file mode 100644 index 0000000..46c1392 --- /dev/null +++ b/internal/api/deployment_create_env_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDeploymentCreateValidatesSuppliedEnvironmentFile(t *testing.T) { + _, deploymentsPath, httpServer := setupPlanTestServer(t) + compose := `services: + app: + image: nginx:alpine + env_file: .env +` + response, body := doJSON(t, http.MethodPost, httpServer.URL+"/api/deployments", map[string]any{ + "name": "env-app", + "compose_content": compose, + "env_vars": []map[string]string{ + {"key": "APP_MODE", "value": "staging"}, + }, + }) + if response.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, body = %#v", response.StatusCode, body) + } + for _, name := range []string{".env", ".env.flatrun"} { + content, err := os.ReadFile(filepath.Join(deploymentsPath, "env-app", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if !strings.Contains(string(content), "APP_MODE=staging") { + t.Fatalf("%s = %q", name, content) + } + } +} + +func TestDeploymentCreateOmitsLegacyVariablesForMultipleDatabases(t *testing.T) { + _, deploymentsPath, httpServer := setupPlanTestServer(t) + response, body := doJSON(t, http.MethodPost, httpServer.URL+"/api/deployments", map[string]any{ + "name": "multi-db-app", + "compose_content": "services:\n app:\n image: nginx:alpine\n", + "databases": []map[string]any{ + {"alias": "agent", "type": "postgresql", "mode": "external", "external_host": "agent-db.example.com", "external_port": 5432, "database_name": "agent", "username": "agent", "password": "agent-password"}, + {"alias": "gateway", "type": "postgres", "mode": "external", "external_host": "gateway-db.example.com", "external_port": 5432, "database_name": "gateway", "username": "gateway", "password": "gateway-password"}, + }, + }) + if response.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, body = %#v", response.StatusCode, body) + } + content, err := os.ReadFile(filepath.Join(deploymentsPath, "multi-db-app", ".env")) + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, expected := range []string{"AGENT_HOST=agent-db.example.com", "GATEWAY_HOST=gateway-db.example.com"} { + if !strings.Contains(text, expected) { + t.Fatalf("environment is missing %q: %s", expected, text) + } + } + if strings.Contains(text, "\nDB_HOST=") || strings.HasPrefix(text, "DB_HOST=") { + t.Fatalf("legacy database variables must be absent: %s", text) + } +} diff --git a/internal/api/domains_test.go b/internal/api/domains_test.go index e4654b0..5ecfa49 100644 --- a/internal/api/domains_test.go +++ b/internal/api/domains_test.go @@ -7,12 +7,14 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/gin-gonic/gin" "gopkg.in/yaml.v3" "github.com/flatrun/agent/internal/docker" + "github.com/flatrun/agent/pkg/config" "github.com/flatrun/agent/pkg/models" ) @@ -28,6 +30,9 @@ func setupDomainsTestServer(t *testing.T) (*Server, string, func()) { server := &Server{ manager: manager, + config: &config.Config{Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }}, } cleanup := func() { @@ -380,6 +385,13 @@ func TestAddDomain(t *testing.T) { if metadata.Domains[0].ID == "" { t.Error("expected domain ID to be generated") } + compose, _, err := server.manager.GetComposeFile("add-domain") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(compose, "- proxy") || !strings.Contains(compose, "proxy:\n external: true") { + t.Fatalf("routed service did not join proxy network: %s", compose) + } }) t.Run("rejects duplicate domain", func(t *testing.T) { diff --git a/internal/api/file_ownership_test.go b/internal/api/file_ownership_test.go new file mode 100644 index 0000000..c7b94cc --- /dev/null +++ b/internal/api/file_ownership_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + "testing" +) + +func TestDeploymentFileOwnershipUpdatesThroughHTTP(t *testing.T) { + _, deploymentsPath, httpServer := setupPlanTestServer(t) + target := filepath.Join(deploymentsPath, "owned-app", "data") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + + response, body := doJSON(t, http.MethodPut, httpServer.URL+"/api/deployments/owned-app/permissions/data", map[string]any{ + "uid": os.Geteuid(), "gid": os.Getegid(), "recursive": true, + }) + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %#v", response.StatusCode, body) + } + + response, body = doJSON(t, http.MethodPut, httpServer.URL+"/api/deployments/owned-app/permissions/", map[string]any{ + "uid": os.Geteuid(), "gid": os.Getegid(), + }) + if response.StatusCode != http.StatusInternalServerError || body["error"] != "deployment root ownership cannot be changed" { + t.Fatalf("root status = %d, body = %#v", response.StatusCode, body) + } +} diff --git a/internal/api/jobs_test.go b/internal/api/jobs_test.go index 1648073..6a91a45 100644 --- a/internal/api/jobs_test.go +++ b/internal/api/jobs_test.go @@ -250,6 +250,46 @@ func TestServiceJobThreadsEffectiveApplyOptions(t *testing.T) { } } +func TestServiceRunJobUsesOneShotExecution(t *testing.T) { + s := newJobTestServer(&fakeRunner{}) + action := make(chan string, 1) + s.runServiceAction = func(got, _, _ string, _ actionOptions, emit func(string)) error { + action <- got + emit("migration complete") + return nil + } + srv := newSkippableHTTPServer(t, newJobRouter(s)) + defer srv.Close() + + resp, err := http.Post( + srv.URL+"/api/deployments/app/services/migrate/job", + "application/json", + strings.NewReader(`{"action":"run"}`), + ) + if err != nil { + t.Fatalf("service run request failed: %v", err) + } + defer resp.Body.Close() + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %v", resp.StatusCode, body) + } + + select { + case got := <-action: + if got != "run" { + t.Fatalf("action = %q, want run", got) + } + case <-time.After(5 * time.Second): + t.Fatal("service run was not invoked") + } + snap := pollJob(t, srv.URL, "app", body["job_id"].(string)) + if snap.Status != JobSucceeded || snap.Output != "migration complete" { + t.Fatalf("job = %+v", snap) + } +} + func TestDeploymentJobStreamReplaysAndCompletes(t *testing.T) { s := &Server{ jobs: newJobRegistry(), diff --git a/internal/api/openapi.json b/internal/api/openapi.json index c48b487..818ae40 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "FlatRun Agent API", "description": "Generated from the agent's routes and the types its handlers bind and return.", - "version": "0.4.0-beta.6" + "version": "0.4.0-beta.7" }, "paths": { "/api/agent/update": { @@ -2104,7 +2104,8 @@ "tags": [ "config" ], - "x-permission": "config:write" + "x-permission": "config:write", + "x-plan-supported": true } }, "/api/containers": { @@ -3682,6 +3683,12 @@ "metadata": { "$ref": "#/components/schemas/models.ServiceMetadata" }, + "mount_ownership": { + "items": { + "$ref": "#/components/schemas/api.MountOwnershipRequest" + }, + "type": "array" + }, "name": { "type": "string" }, @@ -3791,6 +3798,7 @@ "registry_credential", "service_credentials", "seed_mounts", + "mount_ownership", "source" ] } @@ -3851,7 +3859,8 @@ "tags": [ "deployments" ], - "x-permission": "deployments:delete" + "x-permission": "deployments:delete", + "x-plan-supported": true }, "get": { "operationId": "get-deployments-by-name", @@ -3919,7 +3928,8 @@ "tags": [ "deployments" ], - "x-permission": "deployments:write" + "x-permission": "deployments:write", + "x-plan-supported": true } }, "/api/deployments/{name}/actions/{actionId}": { @@ -4909,7 +4919,8 @@ "tags": [ "deployments" ], - "x-permission": "deployments:write" + "x-permission": "deployments:write", + "x-plan-supported": true } }, "/api/deployments/{name}/domains/{domainId}": { @@ -4941,7 +4952,8 @@ "tags": [ "deployments" ], - "x-permission": "deployments:write" + "x-permission": "deployments:write", + "x-plan-supported": true }, "put": { "operationId": "put-deployments-by-name-domains-by-domainId", @@ -4981,7 +4993,8 @@ "tags": [ "deployments" ], - "x-permission": "deployments:write" + "x-permission": "deployments:write", + "x-plan-supported": true } }, "/api/deployments/{name}/env": { @@ -5048,7 +5061,61 @@ "tags": [ "deployments" ], - "x-permission": "deployments:write" + "x-permission": "deployments:write", + "x-plan-supported": true + } + }, + "/api/deployments/{name}/env/variables": { + "patch": { + "operationId": "patch-deployments-by-name-env-variables", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "remove": { + "items": { + "type": "string" + }, + "type": "array" + }, + "set": { + "items": { + "$ref": "#/components/schemas/api.EnvVar" + }, + "type": "array" + } + }, + "type": "object", + "x-property-order": [ + "set", + "remove" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write", + "x-plan-supported": true } }, "/api/deployments/{name}/extract": { @@ -5723,19 +5790,31 @@ "application/json": { "schema": { "properties": { + "gid": { + "type": "integer" + }, "mode": { "type": "integer" + }, + "recursive": { + "type": "boolean" + }, + "uid": { + "type": "integer" } }, - "required": [ - "mode" - ], "type": "object", "x-columns": [ - "mode" + "mode", + "uid", + "gid", + "recursive" ], "x-property-order": [ - "mode" + "mode", + "uid", + "gid", + "recursive" ] } } @@ -6047,6 +6126,14 @@ "schema": { "properties": { "action": { + "enum": [ + "start", + "stop", + "restart", + "rebuild", + "pull", + "run" + ], "type": "string" }, "force_recreate": { @@ -6059,6 +6146,9 @@ "type": "boolean" } }, + "required": [ + "action" + ], "type": "object", "x-columns": [ "action", @@ -7972,7 +8062,8 @@ "tags": [ "proxy" ], - "x-permission": "certificates:write" + "x-permission": "certificates:write", + "x-plan-supported": true } }, "/api/proxy/status/{name}": { @@ -11309,7 +11400,13 @@ "type": "integer" }, "mode": { - "type": "string" + "type": "string", + "enum": [ + "shared", + "create", + "existing", + "external" + ] }, "password": { "type": "string" @@ -11318,7 +11415,15 @@ "type": "string" }, "type": { - "type": "string" + "type": "string", + "enum": [ + "mysql", + "mariadb", + "postgres", + "postgresql", + "mongodb", + "redis" + ] }, "username": { "type": "string" @@ -11349,6 +11454,10 @@ "username", "password", "env_prefix" + ], + "required": [ + "type", + "mode" ] }, "api.DeploymentDiagnosticStep": { @@ -11635,6 +11744,36 @@ ], "x-render": "message" }, + "api.MountOwnershipRequest": { + "type": "object", + "properties": { + "host_path": { + "type": "string" + }, + "subdirectories": { + "type": "array", + "items": { + "type": "string" + } + }, + "user": { + "type": "string" + } + }, + "x-property-order": [ + "host_path", + "user", + "subdirectories" + ], + "x-columns": [ + "host_path", + "user" + ], + "required": [ + "host_path", + "user" + ] + }, "api.MountSelection": { "type": "object", "properties": { diff --git a/internal/api/plan_actions.go b/internal/api/plan_actions.go index 373b57b..2221cbc 100644 --- a/internal/api/plan_actions.go +++ b/internal/api/plan_actions.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/plan" "github.com/flatrun/agent/internal/proxy" "github.com/flatrun/agent/pkg/config" @@ -79,18 +80,18 @@ func (s *Server) vhostConfigPath(name string) string { } func (s *Server) planEnvUpdate(c *gin.Context, name string, envVars []EnvVar) { - envRel := filepath.Join(name, ".env.flatrun") + envRel := filepath.Join(name, ".env") beforeBytes, readErr := os.ReadFile(filepath.Join(s.config.DeploymentsPath, envRel)) exists := readErr == nil before := string(beforeBytes) after := renderEnvContent(envVars) p := s.newPlan("deployment.env.update", "deployment", name) - p.Snapshot.Files = plan.SnapshotFiles(s.config.DeploymentsPath, envRel) + p.Snapshot.Files = plan.SnapshotFiles(s.config.DeploymentsPath, envRel, filepath.Join(name, ".env.flatrun")) if exists && before == after { p.Changes = append(p.Changes, plan.Change{ - Type: "file", ID: ".env.flatrun", + Type: "file", ID: ".env", Actions: []string{plan.ActionNoOp}, Reason: "rendered content is identical to the current file", Sensitive: true, @@ -104,7 +105,7 @@ func (s *Server) planEnvUpdate(c *gin.Context, name string, envVars []EnvVar) { } added, changed, removed := diffEnvCounts(parseEnvContent(before), envVars) p.Changes = append(p.Changes, plan.Change{ - Type: "file", ID: ".env.flatrun", + Type: "file", ID: ".env", Actions: []string{action}, Reason: fmt.Sprintf("%d variable(s) added, %d changed, %d removed", added, changed, removed), Before: beforePtr, @@ -308,6 +309,30 @@ func (s *Server) planDomainChange(c *gin.Context, deployment *models.Deployment, p := s.newPlan(action, "deployment", name) p.Snapshot.Files = plan.SnapshotFiles(s.config.DeploymentsPath, metaRel, s.vhostConfigPath(name)) + composeCurrent, composeName, composeErr := s.manager.GetComposeFile(name) + if composeErr == nil { + composeAfter := composeCurrent + for _, configured := range depCopy.Metadata.GetDomains() { + if configured.Service == "" { + continue + } + composeAfter, err = docker.AddNetworkToComposeService(composeAfter, s.config.Infrastructure.DefaultProxyNetwork, configured.Service) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + if composeAfter != composeCurrent { + composeRel := filepath.Join(name, composeName) + p.Snapshot.Files = plan.SnapshotFiles(s.config.DeploymentsPath, metaRel, composeRel, s.vhostConfigPath(name)) + p.Changes = append(p.Changes, plan.Change{ + Type: "file", ID: composeName, + Actions: []string{plan.ActionUpdate}, + Reason: "the routed service joins the proxy network", + Before: plan.StrPtr(composeCurrent), After: plan.StrPtr(composeAfter), + }) + } + } afterMeta, err := yaml.Marshal(depCopy.Metadata) if err != nil { @@ -406,6 +431,12 @@ func applyPlannedDomainAdd(s *Server, p *plan.Plan) (gin.H, error) { if err != nil { return nil, apiErrf(http.StatusNotFound, "Deployment not found") } + originalMetadata, _ := cloneMetadata(deployment.Metadata) + originalCompose, _, _ := s.manager.GetComposeFile(name) + rollback := func() { + _ = s.manager.SaveMetadata(name, originalMetadata) + _ = s.manager.UpdateDeployment(name, originalCompose) + } var domain models.DomainConfig if err := json.Unmarshal(p.Request.Body, &domain); err != nil { return nil, apiErrf(http.StatusBadRequest, "invalid plan body: %s", err.Error()) @@ -413,13 +444,19 @@ func applyPlannedDomainAdd(s *Server, p *plan.Plan) (gin.H, error) { if err := s.mutateDomainAdd(deployment, &domain); err != nil { return nil, err } + if err := s.ensureServiceProxyNetwork(deployment, domain.Service); err != nil { + rollback() + return nil, apiErrf(http.StatusConflict, "Failed to connect service to proxy network: %s", err.Error()) + } if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + rollback() return nil, apiErrf(http.StatusInternalServerError, "Failed to save domain: %s", err.Error()) } var result *proxy.SetupResult if s.proxyOrchestrator != nil { result, err = s.proxyOrchestrator.SetupDeployment(deployment) if err != nil { + rollback() return nil, apiErrf(http.StatusConflict, "Failed to configure proxy: %s", err.Error()) } } @@ -433,6 +470,12 @@ func applyPlannedDomainUpdate(s *Server, p *plan.Plan) (gin.H, error) { if err != nil { return nil, apiErrf(http.StatusNotFound, "Deployment not found") } + originalMetadata, _ := cloneMetadata(deployment.Metadata) + originalCompose, _, _ := s.manager.GetComposeFile(name) + rollback := func() { + _ = s.manager.SaveMetadata(name, originalMetadata) + _ = s.manager.UpdateDeployment(name, originalCompose) + } var updatedDomain models.DomainConfig if err := json.Unmarshal(p.Request.Body, &updatedDomain); err != nil { return nil, apiErrf(http.StatusBadRequest, "invalid plan body: %s", err.Error()) @@ -440,11 +483,17 @@ func applyPlannedDomainUpdate(s *Server, p *plan.Plan) (gin.H, error) { if err := s.mutateDomainUpdate(deployment, domainID, &updatedDomain); err != nil { return nil, err } + if err := s.ensureServiceProxyNetwork(deployment, updatedDomain.Service); err != nil { + rollback() + return nil, apiErrf(http.StatusConflict, "Failed to connect service to proxy network: %s", err.Error()) + } if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + rollback() return nil, apiErrf(http.StatusInternalServerError, "Failed to save domain: %s", err.Error()) } result, err := s.proxyOrchestrator.SetupDeployment(deployment) if err != nil { + rollback() return nil, apiErrf(http.StatusConflict, "Failed to configure proxy: %s", err.Error()) } return gin.H{"message": "Domain updated successfully", "domain": updatedDomain, "proxy_result": result}, nil @@ -484,6 +533,26 @@ func (s *Server) planProxySetup(c *gin.Context, deployment *models.Deployment) { p := s.newPlan("proxy.setup", "deployment", name) metaRel := filepath.Join(name, "service.yml") p.Snapshot.Files = plan.SnapshotFiles(s.config.DeploymentsPath, metaRel, s.vhostConfigPath(name)) + composeCurrent, composeName, composeErr := s.manager.GetComposeFile(name) + if composeErr == nil && deployment.Metadata != nil { + composeAfter := composeCurrent + for _, domain := range deployment.Metadata.GetDomains() { + if domain.Service == "" { + continue + } + var networkErr error + composeAfter, networkErr = docker.AddNetworkToComposeService(composeAfter, s.config.Infrastructure.DefaultProxyNetwork, domain.Service) + if networkErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": networkErr.Error()}) + return + } + } + if composeAfter != composeCurrent { + composeRel := filepath.Join(name, composeName) + p.Snapshot.Files = plan.SnapshotFiles(s.config.DeploymentsPath, metaRel, composeRel, s.vhostConfigPath(name)) + p.Changes = append(p.Changes, plan.Change{Type: "file", ID: composeName, Actions: []string{plan.ActionUpdate}, Reason: "routed services join the proxy network", Before: plan.StrPtr(composeCurrent), After: plan.StrPtr(composeAfter)}) + } + } rendered, err := s.proxyOrchestrator.RenderDeployment(deployment) if err != nil { @@ -533,6 +602,9 @@ func applyPlannedProxySetup(s *Server, p *plan.Plan) (gin.H, error) { if err != nil { return nil, apiErrf(http.StatusNotFound, "Deployment not found") } + if err := s.ensureAllDomainProxyNetworks(deployment); err != nil { + return nil, apiErrf(http.StatusConflict, "Failed to connect service to proxy network: %s", err.Error()) + } result, err := s.proxyOrchestrator.SetupDeployment(deployment) if err != nil { return nil, err diff --git a/internal/api/plan_apply_test.go b/internal/api/plan_apply_test.go index cb0d68e..1167163 100644 --- a/internal/api/plan_apply_test.go +++ b/internal/api/plan_apply_test.go @@ -16,6 +16,7 @@ import ( "github.com/flatrun/agent/internal/ai" "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/database" "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/files" "github.com/flatrun/agent/internal/networks" @@ -48,6 +49,7 @@ func setupPlanTestServer(t *testing.T) (*Server, string, *httptest.Server) { configPath: configPath, router: gin.New(), manager: docker.NewManager(tmpDir), + databaseManager: database.NewManager(), networksManager: networks.NewManager(), authMiddleware: auth.NewMiddleware(&cfg.Auth), proxyOrchestrator: proxy.NewOrchestrator(cfg), diff --git a/internal/api/require_plan_test.go b/internal/api/require_plan_test.go index c40b553..7b4b2bc 100644 --- a/internal/api/require_plan_test.go +++ b/internal/api/require_plan_test.go @@ -58,6 +58,36 @@ func TestRequirePlanOffByDefault(t *testing.T) { } } +func TestDeploymentEnvironmentPatchPreservesOtherVariables(t *testing.T) { + _, tmpDir, ts := setupPlanTestServer(t) + createTestDeployment(t, tmpDir, "open", nil) + if err := os.WriteFile(filepath.Join(tmpDir, "open", ".env"), []byte("KEEP=one\nCHANGE=old\nREMOVE=gone\n"), 0o600); err != nil { + t.Fatal(err) + } + + body := map[string]interface{}{ + "set": []map[string]string{{"key": "CHANGE", "value": "new"}, {"key": "ADD", "value": "two"}}, + "remove": []string{"REMOVE"}, + } + resp, parsed := doJSON(t, http.MethodPatch, ts.URL+"/api/deployments/open/env/variables", body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %v", resp.StatusCode, parsed) + } + content, err := os.ReadFile(filepath.Join(tmpDir, "open", ".env")) + if err != nil { + t.Fatal(err) + } + got := string(content) + for _, expected := range []string{"KEEP=one", "CHANGE=new", "ADD=two"} { + if !strings.Contains(got, expected) { + t.Fatalf("environment is missing %q: %s", expected, got) + } + } + if strings.Contains(got, "REMOVE=") { + t.Fatalf("removed variable remains: %s", got) + } +} + func TestRequirePlanToggleViaMetadata(t *testing.T) { s, tmpDir, ts := setupPlanTestServer(t) createTestDeployment(t, tmpDir, "app", &models.ServiceMetadata{Name: "app", Type: "web"}) diff --git a/internal/api/server.go b/internal/api/server.go index 89d49fa..ffc2794 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "log" "math/big" "net/http" @@ -712,6 +713,7 @@ func (s *Server) setupRoutes() { // Deployment environment endpoints protected.GET("/deployments/:name/env", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentEnv) protected.PUT("/deployments/:name/env", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDeploymentEnv) + protected.PATCH("/deployments/:name/env/variables", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.patchDeploymentEnv) // Database endpoints protected.POST("/databases/test", s.authMiddleware.RequirePermission(auth.PermDatabasesRead), s.testDatabaseConnection) @@ -1083,8 +1085,8 @@ func (s *Server) getDeployment(c *gin.Context) { type DatabaseConfigRequest struct { Alias string `json:"alias"` - Type string `json:"type"` - Mode string `json:"mode"` + Type string `json:"type" binding:"required,oneof=mysql mariadb postgres postgresql mongodb redis"` + Mode string `json:"mode" binding:"required,oneof=shared create existing external"` Service string `json:"service,omitempty"` ExistingContainer string `json:"existing_container,omitempty"` ExternalHost string `json:"external_host,omitempty"` @@ -1095,7 +1097,14 @@ type DatabaseConfigRequest struct { EnvPrefix string `json:"env_prefix,omitempty"` } +type MountOwnershipRequest struct { + HostPath string `json:"host_path" binding:"required"` + User string `json:"user" binding:"required"` + Subdirectories []string `json:"subdirectories,omitempty"` +} + func (d *DatabaseConfigRequest) Validate() error { + d.Type = normalizeDatabaseType(d.Type) validTypes := map[string]bool{ "mysql": true, "postgres": true, "mariadb": true, "mongodb": true, "redis": true, @@ -1137,6 +1146,13 @@ func (d *DatabaseConfigRequest) Validate() error { return nil } +func normalizeDatabaseType(databaseType string) string { + if databaseType == "postgresql" { + return "postgres" + } + return databaseType +} + func (s *Server) createDeployment(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` @@ -1166,7 +1182,8 @@ func (s *Server) createDeployment(c *gin.Context) { // SeedMounts names the bind mounts, by host path, to fill from the // image when the host side is empty. A template's own seed mounts are // added to these. - SeedMounts []string `json:"seed_mounts,omitempty"` + SeedMounts []string `json:"seed_mounts,omitempty"` + MountOwnership []MountOwnershipRequest `json:"mount_ownership,omitempty"` // Source deploys from fetched code (a git URL today) instead of inline // compose content: the fetched tree becomes the deployment directory and // its compose file is what runs. @@ -1205,7 +1222,11 @@ func (s *Server) createDeployment(c *gin.Context) { req.ComposeContent = generated } - if err := s.validateComposeContent(req.ComposeContent, req.Name); err != nil { + validationDir := "" + if fetched != nil { + validationDir = fetched.dir + } + if err := s.validateNewComposeContent(req.ComposeContent, req.Name, req.EnvVars, validationDir); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "Invalid compose content: " + err.Error(), }) @@ -1219,14 +1240,28 @@ func (s *Server) createDeployment(c *gin.Context) { // Add proxy network if expose is enabled proxyNetworkName := s.config.Infrastructure.DefaultProxyNetwork - if req.Metadata != nil && req.Metadata.Networking.Expose && proxyNetworkName != "" { + if req.Metadata != nil && (req.Metadata.Networking.Expose || len(req.Metadata.GetDomains()) > 0) && proxyNetworkName != "" { if err := s.networksManager.EnsureNetwork(proxyNetworkName); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to ensure proxy network exists: " + err.Error(), }) return } - req.ComposeContent = s.addProxyNetwork(req.ComposeContent) + if len(req.Metadata.GetDomains()) > 0 { + for _, domain := range req.Metadata.GetDomains() { + if domain.Service == "" { + continue + } + updated, networkErr := docker.AddNetworkToComposeService(req.ComposeContent, proxyNetworkName, domain.Service) + if networkErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to add proxy network: " + networkErr.Error()}) + return + } + req.ComposeContent = updated + } + } else { + req.ComposeContent = s.addProxyNetwork(req.ComposeContent) + } if err := s.networksManager.EnsureContainerOnNetwork(proxyNetworkName, s.config.Nginx.ContainerName); err != nil { log.Printf("Warning: failed to ensure nginx on network %s: %v", proxyNetworkName, err) } @@ -1252,6 +1287,25 @@ func (s *Server) createDeployment(c *gin.Context) { req.ComposeContent = s.addContainerNetwork(req.ComposeContent, req.ExistingDatabaseContainer) } + mountOwnership := make([]docker.MountOwnership, 0, len(req.MountOwnership)) + if len(req.MountOwnership) > 0 { + declared := make(map[string]bool) + for _, hostPath := range docker.ExtractBindMounts(req.ComposeContent) { + declared[filepath.Clean(hostPath)] = true + } + for _, ownership := range req.MountOwnership { + if !declared[filepath.Clean(ownership.HostPath)] { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("mount ownership path %q is not a bind mount in compose", ownership.HostPath)}) + return + } + mountOwnership = append(mountOwnership, docker.MountOwnership{ + HostPath: ownership.HostPath, + User: ownership.User, + Subdirectories: ownership.Subdirectories, + }) + } + } + var createErr error if fetched != nil { createErr = s.manager.CreateDeploymentFromSource(req.Name, fetched.dir, req.ComposeContent, fetched.composeName) @@ -1325,7 +1379,6 @@ func (s *Server) createDeployment(c *gin.Context) { if req.TemplateID != "" { s.processTemplateFiles(req.Name, req.TemplateID, allEnvVars) s.processTemplateEnv(req.Name, req.TemplateID, req.ComposeContent, allEnvVars) - s.applyTemplateMountOwnership(req.Name, req.TemplateID) // An object store joins the shared object-storage network; ensure it // exists so the container can start (it is declared external). @@ -1350,6 +1403,16 @@ func (s *Server) createDeployment(c *gin.Context) { } } + if req.TemplateID != "" { + s.applyTemplateMountOwnership(req.Name, req.TemplateID) + } + if len(mountOwnership) > 0 { + if err := s.manager.ApplyMountOwnership(req.Name, mountOwnership); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Deployment created but failed to set mount ownership: " + err.Error()}) + return + } + } + if req.Metadata != nil { if len(databaseConfigs) > 0 { req.Metadata.Databases = databaseConfigs @@ -1541,7 +1604,7 @@ func (s *Server) createDatabaseForDeployment(deploymentName string) ([]EnvVar, e } var databaseURL string - switch dbConfig.Type { + switch normalizeDatabaseType(dbConfig.Type) { case "mysql", "mariadb": databaseURL = fmt.Sprintf("mysql://%s:%s@%s:%d/%s", dbUser, dbPassword, dbHost, dbConfig.Port, dbName) case "postgres": @@ -1555,12 +1618,12 @@ func (s *Server) createDatabaseForDeployment(deploymentName string) ([]EnvVar, e func (s *Server) createDatabasesForDeployment(deploymentName string, databases []DatabaseConfigRequest) ([]EnvVar, []models.DatabaseConfig, error) { var allEnvVars []EnvVar var configs []models.DatabaseConfig - isFirst := true + includeLegacy := len(databases) == 1 for i, dbReq := range databases { alias := dbReq.Alias if alias == "" { - if isFirst { + if i == 0 { alias = "primary" } else { alias = fmt.Sprintf("db%d", i+1) @@ -1626,7 +1689,7 @@ func (s *Server) createDatabasesForDeployment(deploymentName string, databases [ config.Username = dbUser config.IsShared = true - envVars = s.generateDatabaseEnvVars(envPrefix, dbHost, dbConfig.Port, dbName, dbUser, dbPassword, dbConfig.Type, isFirst) + envVars = s.generateDatabaseEnvVars(envPrefix, dbHost, dbConfig.Port, dbName, dbUser, dbPassword, normalizeDatabaseType(dbConfig.Type), includeLegacy) case "existing": config.Container = dbReq.ExistingContainer @@ -1651,7 +1714,7 @@ func (s *Server) createDatabasesForDeployment(deploymentName string, databases [ } config.Port = existDbPort - envVars = s.generateDatabaseEnvVars(envPrefix, dbReq.ExistingContainer, existDbPort, dbReq.DatabaseName, dbReq.Username, dbReq.Password, dbReq.Type, isFirst) + envVars = s.generateDatabaseEnvVars(envPrefix, dbReq.ExistingContainer, existDbPort, dbReq.DatabaseName, dbReq.Username, dbReq.Password, dbReq.Type, includeLegacy) case "external": config.Host = dbReq.ExternalHost @@ -1663,19 +1726,19 @@ func (s *Server) createDatabasesForDeployment(deploymentName string, databases [ config.Username = dbReq.Username } if dbReq.Password != "" { - envVars = s.generateDatabaseEnvVars(envPrefix, dbReq.ExternalHost, dbReq.ExternalPort, dbReq.DatabaseName, dbReq.Username, dbReq.Password, dbReq.Type, isFirst) + envVars = s.generateDatabaseEnvVars(envPrefix, dbReq.ExternalHost, dbReq.ExternalPort, dbReq.DatabaseName, dbReq.Username, dbReq.Password, dbReq.Type, includeLegacy) } } allEnvVars = append(allEnvVars, envVars...) configs = append(configs, config) - isFirst = false } return allEnvVars, configs, nil } func (s *Server) generateDatabaseEnvVars(prefix string, host string, port int, dbName, username, password, dbType string, includeLegacy bool) []EnvVar { + dbType = normalizeDatabaseType(dbType) var envVars []EnvVar envVars = append(envVars, @@ -1723,7 +1786,6 @@ func (s *Server) generateDatabaseEnvVars(prefix string, host string, port int, d func (s *Server) writeEnvFile(deploymentName string, envVars []EnvVar) error { deploymentPath := filepath.Join(s.config.DeploymentsPath, deploymentName) - envFilePath := filepath.Join(deploymentPath, ".env.flatrun") var content strings.Builder for _, env := range envVars { @@ -1732,7 +1794,12 @@ func (s *Server) writeEnvFile(deploymentName string, envVars []EnvVar) error { } } - return os.WriteFile(envFilePath, []byte(content.String()), 0600) + for _, name := range []string{".env", ".env.flatrun"} { + if err := os.WriteFile(filepath.Join(deploymentPath, name), []byte(content.String()), 0600); err != nil { + return err + } + } + return nil } func (s *Server) deleteDatabaseForDeployment(deploymentName string) error { @@ -1796,20 +1863,39 @@ func (s *Server) deleteDatabaseByAlias(deploymentName, alias string) error { func (s *Server) getDeploymentEnv(c *gin.Context) { name := c.Param("name") deploymentPath := filepath.Join(s.config.DeploymentsPath, name) - envFilePath := filepath.Join(deploymentPath, ".env.flatrun") - - content, err := os.ReadFile(envFilePath) + paths, err := filepath.Glob(filepath.Join(deploymentPath, ".env*")) if err != nil { - if os.IsNotExist(err) { - c.JSON(http.StatusOK, gin.H{"env_vars": []EnvVar{}}) - return - } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - - envVars := parseEnvContent(string(content)) - c.JSON(http.StatusOK, gin.H{"env_vars": envVars}) + type environmentFile struct { + Path string `json:"path"` + Principal bool `json:"principal"` + EnvVars []EnvVar `json:"env_vars"` + } + principal := ".env" + if _, err := os.Stat(filepath.Join(deploymentPath, principal)); os.IsNotExist(err) { + principal = ".env.flatrun" + } + files := make([]environmentFile, 0, len(paths)) + var principalVars []EnvVar + for _, envPath := range paths { + info, statErr := os.Stat(envPath) + if statErr != nil || !info.Mode().IsRegular() { + continue + } + content, readErr := os.ReadFile(envPath) + if readErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": readErr.Error()}) + return + } + file := environmentFile{Path: filepath.Base(envPath), Principal: filepath.Base(envPath) == principal, EnvVars: parseEnvContent(string(content))} + if file.Principal { + principalVars = file.EnvVars + } + files = append(files, file) + } + c.JSON(http.StatusOK, gin.H{"env_vars": principalVars, "principal_file": principal, "env_files": files}) } func (s *Server) updateDeploymentEnv(c *gin.Context) { @@ -1842,6 +1928,85 @@ func (s *Server) updateDeploymentEnv(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Environment variables updated"}) } +func (s *Server) patchDeploymentEnv(c *gin.Context) { + name := c.Param("name") + if !s.requireUnprotectedDeploymentAction(c, name, protectedActionUpdateEnv) { + return + } + + var req struct { + Set []EnvVar `json:"set"` + Remove []string `json:"remove"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + current, err := s.readPrincipalEnv(name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + remove := make(map[string]bool, len(req.Remove)) + for _, key := range req.Remove { + remove[key] = true + } + set := make(map[string]string, len(req.Set)) + for _, env := range req.Set { + if env.Key == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Environment variable names cannot be empty"}) + return + } + set[env.Key] = env.Value + } + merged := make([]EnvVar, 0, len(current)+len(set)) + seen := make(map[string]bool, len(current)+len(set)) + for _, env := range current { + if remove[env.Key] { + continue + } + if value, ok := set[env.Key]; ok { + env.Value = value + } + merged = append(merged, env) + seen[env.Key] = true + } + for _, env := range req.Set { + if !seen[env.Key] { + merged = append(merged, EnvVar{Key: env.Key, Value: set[env.Key]}) + seen[env.Key] = true + } + } + + if planRequested(c) { + s.planEnvUpdate(c, name, merged) + return + } + if !s.requirePlannedAction(c, name) { + return + } + if err := s.writeEnvFile(name, merged); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Environment variables updated", "env_vars": merged}) +} + +func (s *Server) readPrincipalEnv(name string) ([]EnvVar, error) { + deploymentPath := filepath.Join(s.config.DeploymentsPath, name) + for _, filename := range []string{".env", ".env.flatrun"} { + content, err := os.ReadFile(filepath.Join(deploymentPath, filename)) + if err == nil { + return parseEnvContent(string(content)), nil + } + if !os.IsNotExist(err) { + return nil, err + } + } + return []EnvVar{}, nil +} + func parseEnvContent(content string) []EnvVar { var envVars []EnvVar lines := strings.Split(content, "\n") @@ -2287,8 +2452,10 @@ func (o actionOptions) runOptions() []docker.RunOption { func (s *Server) defaultRunDeploymentAction(action, name string, actOpts actionOptions, emit func(line string)) error { authCfg, opts := s.deploymentAuthOptions(name) defer authCfg.Close() + ctx, cancel := context.WithTimeout(context.Background(), s.manager.CleanupTimeout()) + defer cancel() - opts = append(opts, docker.WithLineSink(emit)) + opts = append(opts, docker.WithLineSink(emit), docker.WithContext(ctx)) opts = append(opts, actOpts.runOptions()...) var err error @@ -2308,7 +2475,7 @@ func (s *Server) defaultRunDeploymentAction(action, name string, actOpts actionO } var streamableServiceActions = map[string]bool{ - "start": true, "stop": true, "restart": true, "rebuild": true, "pull": true, + "start": true, "stop": true, "restart": true, "rebuild": true, "pull": true, "run": true, } // enqueueServiceJob runs a single service's action as a streamed background job @@ -2316,7 +2483,7 @@ var streamableServiceActions = map[string]bool{ // taken from the body so one route covers every action. func (s *Server) enqueueServiceJob(c *gin.Context) { var req struct { - Action string `json:"action"` + Action string `json:"action" binding:"required,oneof=start stop restart rebuild pull run"` ForceRecreate bool `json:"force_recreate"` NoCache bool `json:"no_cache"` FreshPull bool `json:"fresh_pull"` @@ -2367,8 +2534,10 @@ func (s *Server) runServiceActionJob(job *ActionJob) { func (s *Server) defaultRunServiceAction(action, name, service string, actOpts actionOptions, emit func(line string)) error { authCfg, opts := s.deploymentAuthOptions(name) defer authCfg.Close() + ctx, cancel := context.WithTimeout(context.Background(), s.manager.CleanupTimeout()) + defer cancel() - opts = append(opts, docker.WithLineSink(emit)) + opts = append(opts, docker.WithLineSink(emit), docker.WithContext(ctx)) opts = append(opts, actOpts.runOptions()...) var err error @@ -2379,6 +2548,8 @@ func (s *Server) defaultRunServiceAction(action, name, service string, actOpts a _, err = s.manager.StopService(name, service, opts...) case "restart": _, err = s.manager.RestartService(name, service, opts...) + case "run": + _, err = s.manager.RunService(name, service, opts...) case "rebuild": _, err = s.manager.RebuildService(name, service, opts...) case "pull": @@ -3201,24 +3372,24 @@ func (s *Server) updateSettings(c *gin.Context) { var req struct { Domain *struct { DefaultDomain string `json:"default_domain"` - AutoSubdomain bool `json:"auto_subdomain"` - AutoSSL bool `json:"auto_ssl"` + AutoSubdomain *bool `json:"auto_subdomain"` + AutoSSL *bool `json:"auto_ssl"` SubdomainStyle string `json:"subdomain_style"` } `json:"domain,omitempty"` Nginx *struct { - Enabled bool `json:"enabled"` + Enabled *bool `json:"enabled"` Image string `json:"image"` ContainerName string `json:"container_name"` ConfigPath string `json:"config_path"` ReloadCommand string `json:"reload_command"` - External bool `json:"external"` + External *bool `json:"external"` RejectUnknownDomains *bool `json:"reject_unknown_domains"` } `json:"nginx,omitempty"` Certbot *struct { - Enabled bool `json:"enabled"` + Enabled *bool `json:"enabled"` Image string `json:"image"` Email string `json:"email"` - Staging bool `json:"staging"` + Staging *bool `json:"staging"` CertsPath string `json:"certs_path"` WebrootPath string `json:"webroot_path"` DNSProvider string `json:"dns_provider"` @@ -3227,7 +3398,7 @@ func (s *Server) updateSettings(c *gin.Context) { DefaultProxyNetwork string `json:"default_proxy_network"` DefaultDatabaseNetwork string `json:"default_database_network"` Database *struct { - Enabled bool `json:"enabled"` + Enabled *bool `json:"enabled"` Type string `json:"type"` Container string `json:"container"` Host string `json:"host"` @@ -3236,7 +3407,7 @@ func (s *Server) updateSettings(c *gin.Context) { RootPassword string `json:"root_password"` } `json:"database,omitempty"` Redis *struct { - Enabled bool `json:"enabled"` + Enabled *bool `json:"enabled"` Container string `json:"container"` Host string `json:"host"` Port int `json:"port"` @@ -3244,12 +3415,12 @@ func (s *Server) updateSettings(c *gin.Context) { } `json:"redis,omitempty"` } `json:"infrastructure,omitempty"` Security *struct { - Enabled bool `json:"enabled"` - RealtimeCapture bool `json:"realtime_capture"` + Enabled *bool `json:"enabled"` + RealtimeCapture *bool `json:"realtime_capture"` ScanInterval string `json:"scan_interval"` RetentionDays int `json:"retention_days"` RateThreshold int `json:"rate_threshold"` - AutoBlockEnabled bool `json:"auto_block_enabled"` + AutoBlockEnabled *bool `json:"auto_block_enabled"` AutoBlockThreshold int `json:"auto_block_threshold"` AutoBlockDuration string `json:"auto_block_duration"` } `json:"security,omitempty"` @@ -3264,17 +3435,27 @@ func (s *Server) updateSettings(c *gin.Context) { } if req.Domain != nil { - s.config.Domain.DefaultDomain = req.Domain.DefaultDomain - s.config.Domain.AutoSubdomain = req.Domain.AutoSubdomain - s.config.Domain.AutoSSL = req.Domain.AutoSSL + if req.Domain.DefaultDomain != "" { + s.config.Domain.DefaultDomain = req.Domain.DefaultDomain + } + if req.Domain.AutoSubdomain != nil { + s.config.Domain.AutoSubdomain = *req.Domain.AutoSubdomain + } + if req.Domain.AutoSSL != nil { + s.config.Domain.AutoSSL = *req.Domain.AutoSSL + } if req.Domain.SubdomainStyle != "" { s.config.Domain.SubdomainStyle = req.Domain.SubdomainStyle } } if req.Nginx != nil { - s.config.Nginx.Enabled = req.Nginx.Enabled - s.config.Nginx.External = req.Nginx.External + if req.Nginx.Enabled != nil { + s.config.Nginx.Enabled = *req.Nginx.Enabled + } + if req.Nginx.External != nil { + s.config.Nginx.External = *req.Nginx.External + } if req.Nginx.Image != "" { s.config.Nginx.Image = req.Nginx.Image } @@ -3293,8 +3474,12 @@ func (s *Server) updateSettings(c *gin.Context) { } if req.Certbot != nil { - s.config.Certbot.Enabled = req.Certbot.Enabled - s.config.Certbot.Staging = req.Certbot.Staging + if req.Certbot.Enabled != nil { + s.config.Certbot.Enabled = *req.Certbot.Enabled + } + if req.Certbot.Staging != nil { + s.config.Certbot.Staging = *req.Certbot.Staging + } if req.Certbot.Image != "" { s.config.Certbot.Image = req.Certbot.Image } @@ -3320,10 +3505,18 @@ func (s *Server) updateSettings(c *gin.Context) { s.config.Infrastructure.DefaultDatabaseNetwork = req.Infrastructure.DefaultDatabaseNetwork } if req.Infrastructure.Database != nil { - s.config.Infrastructure.Database.Enabled = req.Infrastructure.Database.Enabled - s.config.Infrastructure.Database.Type = req.Infrastructure.Database.Type - s.config.Infrastructure.Database.Container = req.Infrastructure.Database.Container - s.config.Infrastructure.Database.Host = req.Infrastructure.Database.Host + if req.Infrastructure.Database.Enabled != nil { + s.config.Infrastructure.Database.Enabled = *req.Infrastructure.Database.Enabled + } + if req.Infrastructure.Database.Type != "" { + s.config.Infrastructure.Database.Type = normalizeDatabaseType(req.Infrastructure.Database.Type) + } + if req.Infrastructure.Database.Container != "" { + s.config.Infrastructure.Database.Container = req.Infrastructure.Database.Container + } + if req.Infrastructure.Database.Host != "" { + s.config.Infrastructure.Database.Host = req.Infrastructure.Database.Host + } if req.Infrastructure.Database.Port > 0 { s.config.Infrastructure.Database.Port = req.Infrastructure.Database.Port } @@ -3335,9 +3528,15 @@ func (s *Server) updateSettings(c *gin.Context) { } } if req.Infrastructure.Redis != nil { - s.config.Infrastructure.Redis.Enabled = req.Infrastructure.Redis.Enabled - s.config.Infrastructure.Redis.Container = req.Infrastructure.Redis.Container - s.config.Infrastructure.Redis.Host = req.Infrastructure.Redis.Host + if req.Infrastructure.Redis.Enabled != nil { + s.config.Infrastructure.Redis.Enabled = *req.Infrastructure.Redis.Enabled + } + if req.Infrastructure.Redis.Container != "" { + s.config.Infrastructure.Redis.Container = req.Infrastructure.Redis.Container + } + if req.Infrastructure.Redis.Host != "" { + s.config.Infrastructure.Redis.Host = req.Infrastructure.Redis.Host + } if req.Infrastructure.Redis.Port > 0 { s.config.Infrastructure.Redis.Port = req.Infrastructure.Redis.Port } @@ -3350,9 +3549,15 @@ func (s *Server) updateSettings(c *gin.Context) { if req.Security != nil { prevEnabled := s.config.Security.Enabled prevRealtimeCapture := s.config.Security.RealtimeCapture - s.config.Security.Enabled = req.Security.Enabled - s.config.Security.RealtimeCapture = req.Security.RealtimeCapture - s.config.Security.AutoBlockEnabled = req.Security.AutoBlockEnabled + if req.Security.Enabled != nil { + s.config.Security.Enabled = *req.Security.Enabled + } + if req.Security.RealtimeCapture != nil { + s.config.Security.RealtimeCapture = *req.Security.RealtimeCapture + } + if req.Security.AutoBlockEnabled != nil { + s.config.Security.AutoBlockEnabled = *req.Security.AutoBlockEnabled + } if req.Security.RetentionDays > 0 { s.config.Security.RetentionDays = req.Security.RetentionDays } @@ -3387,8 +3592,12 @@ func (s *Server) updateSettings(c *gin.Context) { s.config.SystemTerminal.ProtectedMode = *req.SystemTerminal.ProtectedMode } - s.infraManager.UpdateConfig(s.config) - s.proxyOrchestrator.UpdateConfig(s.config) + if s.infraManager != nil { + s.infraManager.UpdateConfig(s.config) + } + if s.proxyOrchestrator != nil { + s.proxyOrchestrator.UpdateConfig(s.config) + } if s.configPath != "" { if err := config.Save(s.config, s.configPath); err != nil { @@ -4307,7 +4516,7 @@ func (s *Server) createDatabaseService(db *DatabaseConfig) map[string]interface{ rootPassword = db.Password } - switch db.Type { + switch normalizeDatabaseType(db.Type) { case "mysql": image = "mysql:8" volumePath = "/var/lib/mysql" @@ -4687,6 +4896,35 @@ func (s *Server) inferRegistryHostFromCompose(content string) string { } func (s *Server) validateComposeContent(content, name string) error { + return s.validateComposeContentIn(content, name, s.composeValidationDir(name)) +} + +func (s *Server) validateNewComposeContent(content, name string, envVars []EnvVar, sourceDir string) error { + dir := sourceDir + if dir == "" { + var err error + dir, err = os.MkdirTemp("", "flatrun-compose-validation-") + if err != nil { + return fmt.Errorf("create compose validation directory: %w", err) + } + defer os.RemoveAll(dir) + } + + if len(envVars) > 0 { + var envContent strings.Builder + for _, env := range envVars { + if env.Key != "" { + envContent.WriteString(fmt.Sprintf("%s=%s\n", env.Key, env.Value)) + } + } + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent.String()), 0600); err != nil { + return fmt.Errorf("write compose validation environment: %w", err) + } + } + return s.validateComposeContentIn(content, name, dir) +} + +func (s *Server) validateComposeContentIn(content, name, workingDir string) error { var compose composeFile if err := yaml.Unmarshal([]byte(content), &compose); err != nil { return fmt.Errorf("invalid YAML syntax: %w", err) @@ -4724,7 +4962,7 @@ func (s *Server) validateComposeContent(content, name string) error { } } - if err := validateComposeWithComposeGo(content, s.composeValidationDir(name)); err != nil { + if err := validateComposeWithComposeGo(content, workingDir); err != nil { return err } @@ -4756,6 +4994,7 @@ func validateComposeWithComposeGo(content, workingDir string) error { Environment: map[string]string{}, } _, err := loader.LoadWithContext(context.Background(), configDetails, func(o *loader.Options) { + o.SetProjectName("flatrun-validation", true) // Resolve relative paths (notably a relative env_file) against WorkingDir so an // existing deployment's ./.env is found in the deployment directory rather than // being read relative to the agent's own working directory. @@ -5646,6 +5885,10 @@ func (s *Server) setupProxy(c *gin.Context) { if !s.requirePlannedAction(c, name) { return } + if err := s.ensureAllDomainProxyNetworks(deployment); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "Failed to connect service to proxy network: " + err.Error()}) + return + } result, err := s.proxyOrchestrator.SetupDeployment(deployment) if err != nil { @@ -5915,6 +6158,62 @@ func (s *Server) resolveService(name string, serviceName string) (string, error) return s.manager.ResolveService(name, serviceName) } +func (s *Server) ensureServiceProxyNetwork(deployment *models.Deployment, service string) error { + if s.config == nil { + return nil + } + network := s.config.Infrastructure.DefaultProxyNetwork + if network == "" { + return fmt.Errorf("default proxy network is not configured") + } + content, _, err := s.manager.GetComposeFile(deployment.Name) + if err != nil { + return err + } + updated, err := docker.AddNetworkToComposeService(content, network, service) + if err != nil { + return err + } + if s.networksManager != nil { + if err := s.networksManager.EnsureNetwork(network); err != nil { + return err + } + } + if updated != content { + if err := s.manager.UpdateDeployment(deployment.Name, updated); err != nil { + return err + } + } + for _, candidate := range deployment.Services { + if candidate.Name == service && candidate.ContainerID != "" { + if s.networksManager != nil { + if err := s.networksManager.EnsureContainerOnNetwork(network, candidate.ContainerID); err != nil { + return err + } + } + break + } + } + return nil +} + +func (s *Server) ensureAllDomainProxyNetworks(deployment *models.Deployment) error { + if deployment.Metadata == nil { + return nil + } + seen := make(map[string]bool) + for _, domain := range deployment.Metadata.GetDomains() { + if domain.Service == "" || seen[domain.Service] { + continue + } + seen[domain.Service] = true + if err := s.ensureServiceProxyNetwork(deployment, domain.Service); err != nil { + return err + } + } + return nil +} + func (s *Server) addDomain(c *gin.Context) { name := c.Param("name") deployment, err := s.manager.GetDeployment(name) @@ -5939,13 +6238,21 @@ func (s *Server) addDomain(c *gin.Context) { if !s.requirePlannedAction(c, name) { return } + originalMetadata, _ := cloneMetadata(deployment.Metadata) + originalCompose, _, _ := s.manager.GetComposeFile(name) if err := s.mutateDomainAdd(deployment, &domain); err != nil { respondAPIError(c, err) return } + if err := s.ensureServiceProxyNetwork(deployment, domain.Service); err != nil { + _ = s.manager.UpdateDeployment(name, originalCompose) + c.JSON(http.StatusConflict, gin.H{"error": "Failed to connect service to proxy network: " + err.Error()}) + return + } if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + _ = s.manager.UpdateDeployment(name, originalCompose) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save domain: " + err.Error()}) return } @@ -5955,6 +6262,8 @@ func (s *Server) addDomain(c *gin.Context) { var err error result, err = s.proxyOrchestrator.SetupDeployment(deployment) if err != nil { + _ = s.manager.SaveMetadata(name, originalMetadata) + _ = s.manager.UpdateDeployment(name, originalCompose) c.JSON(http.StatusConflict, gin.H{"error": "Failed to configure proxy: " + err.Error()}) return } @@ -5993,19 +6302,29 @@ func (s *Server) updateDomain(c *gin.Context) { if !s.requirePlannedAction(c, name) { return } + originalMetadata, _ := cloneMetadata(deployment.Metadata) + originalCompose, _, _ := s.manager.GetComposeFile(name) if err := s.mutateDomainUpdate(deployment, domainID, &updatedDomain); err != nil { respondAPIError(c, err) return } + if err := s.ensureServiceProxyNetwork(deployment, updatedDomain.Service); err != nil { + _ = s.manager.UpdateDeployment(name, originalCompose) + c.JSON(http.StatusConflict, gin.H{"error": "Failed to connect service to proxy network: " + err.Error()}) + return + } if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil { + _ = s.manager.UpdateDeployment(name, originalCompose) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save domain: " + err.Error()}) return } result, err := s.proxyOrchestrator.SetupDeployment(deployment) if err != nil { + _ = s.manager.SaveMetadata(name, originalMetadata) + _ = s.manager.UpdateDeployment(name, originalCompose) c.JSON(http.StatusConflict, gin.H{"error": "Failed to configure proxy: " + err.Error()}) return } @@ -6829,25 +7148,45 @@ func (s *Server) chmodDeploymentFile(c *gin.Context) { path := c.Param("path") var req struct { - Mode int `json:"mode" binding:"required"` + Mode *int `json:"mode,omitempty"` + UID *int `json:"uid,omitempty"` + GID *int `json:"gid,omitempty"` + Recursive bool `json:"recursive,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } - if req.Mode < 0 || req.Mode > 0o777 { - c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be between 0 and 0777"}) + if req.Mode == nil && (req.UID == nil || req.GID == nil) { + c.JSON(http.StatusBadRequest, gin.H{"error": "mode or both uid and gid are required"}) return } - - if err := s.filesManager.Chmod(name, path, os.FileMode(req.Mode)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + if req.Mode != nil { + if *req.Mode < 0 || *req.Mode > 0o777 { + c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be between 0 and 0777"}) + return + } + if err := s.filesManager.Chmod(name, path, os.FileMode(*req.Mode)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + if req.UID != nil && req.GID != nil { + if err := s.filesManager.Chown(name, path, *req.UID, *req.GID, req.Recursive); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } } info, _ := s.filesManager.GetFileInfo(name, path) + message := "Permissions updated" + if req.Mode == nil { + message = "Ownership updated" + } else if req.UID != nil { + message = "Permissions and ownership updated" + } c.JSON(http.StatusOK, gin.H{ - "message": "Permissions updated", + "message": message, "file": info, }) } @@ -6956,12 +7295,13 @@ func (s *Server) testDatabaseConnection(c *gin.Context) { func (s *Server) listDatabasesInServer(c *gin.Context) { var cfg database.ConnectionConfig - if err := c.ShouldBindJSON(&cfg); err != nil { + if err := c.ShouldBindJSON(&cfg); err != nil && err != io.EOF { c.JSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return } + s.applyRegisteredDatabaseDefaults(&cfg) databases, err := s.databaseManager.ListDatabases(&cfg) if err != nil { @@ -6976,6 +7316,28 @@ func (s *Server) listDatabasesInServer(c *gin.Context) { }) } +func (s *Server) applyRegisteredDatabaseDefaults(connection *database.ConnectionConfig) { + registered := s.config.Infrastructure.Database + if connection.Type == "" { + connection.Type = registered.Type + } + if connection.Host == "" { + connection.Host = registered.Host + } + if connection.Port == 0 { + connection.Port = registered.Port + } + if connection.Username == "" { + connection.Username = registered.RootUser + } + if connection.Password == "" { + connection.Password = registered.RootPassword + } + if connection.Container == "" { + connection.Container = registered.Container + } +} + func (s *Server) listDatabaseTables(c *gin.Context) { var req struct { database.ConnectionConfig diff --git a/internal/api/settings_update_test.go b/internal/api/settings_update_test.go new file mode 100644 index 0000000..a64ab46 --- /dev/null +++ b/internal/api/settings_update_test.go @@ -0,0 +1,26 @@ +package api + +import ( + "net/http" + "testing" +) + +func TestSettingsUpdatePreservesOmittedCertbotFields(t *testing.T) { + server, _, httpServer := setupPlanTestServer(t) + server.config.Certbot.Enabled = true + server.config.Certbot.Staging = true + server.config.Certbot.Image = "certbot/certbot:latest" + + response, body := doJSON(t, http.MethodPut, httpServer.URL+"/api/settings", map[string]any{ + "certbot": map[string]any{"email": "ops@example.com"}, + }) + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %#v", response.StatusCode, body) + } + if !server.config.Certbot.Enabled || !server.config.Certbot.Staging { + t.Fatalf("omitted booleans changed: enabled=%v staging=%v", server.config.Certbot.Enabled, server.config.Certbot.Staging) + } + if server.config.Certbot.Image != "certbot/certbot:latest" || server.config.Certbot.Email != "ops@example.com" { + t.Fatalf("certbot settings = %#v", server.config.Certbot) + } +} diff --git a/internal/database/manager.go b/internal/database/manager.go index 59ab1ff..7161b4d 100644 --- a/internal/database/manager.go +++ b/internal/database/manager.go @@ -8,7 +8,7 @@ import ( "strings" _ "github.com/go-sql-driver/mysql" - _ "github.com/lib/pq" + "github.com/lib/pq" ) type networkInfo struct { @@ -65,6 +65,13 @@ type UserInfo struct { type Manager struct{} +func normalizeType(databaseType string) string { + if databaseType == "postgres" { + return "postgresql" + } + return databaseType +} + func NewManager() *Manager { return &Manager{} } @@ -129,7 +136,7 @@ func parsePort(s string) int { } func isDbPort(port int, dbType string) bool { - switch dbType { + switch normalizeType(dbType) { case "mysql", "mariadb": return port == 3306 case "postgresql": @@ -143,9 +150,10 @@ func isDbPort(port int, dbType string) bool { } func (m *Manager) buildDSN(cfg *ConnectionConfig) (string, error) { + cfgType := normalizeType(cfg.Type) host, port, _ := m.resolveContainerConnection(cfg) - switch cfg.Type { + switch cfgType { case "mysql", "mariadb": db := cfg.Database if db == "" { @@ -167,7 +175,7 @@ func (m *Manager) buildDSN(cfg *ConnectionConfig) (string, error) { } func (m *Manager) getDriver(dbType string) string { - switch dbType { + switch normalizeType(dbType) { case "mysql", "mariadb": return "mysql" case "postgresql": @@ -219,7 +227,7 @@ func (m *Manager) ListDatabases(cfg *ConnectionConfig) ([]DatabaseInfo, error) { defer db.Close() var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = "SHOW DATABASES" case "postgresql": @@ -269,7 +277,7 @@ func (m *Manager) ListTables(cfg *ConnectionConfig, database string) ([]TableInf defer db.Close() var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = fmt.Sprintf(` SELECT TABLE_NAME, TABLE_ROWS, ENGINE @@ -322,7 +330,7 @@ func (m *Manager) ListUsers(cfg *ConnectionConfig) ([]UserInfo, error) { defer db.Close() var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = "SELECT User, Host FROM mysql.user" case "postgresql": @@ -369,7 +377,7 @@ func (m *Manager) ListDatabaseUsers(cfg *ConnectionConfig, database string) ([]U database = strings.ReplaceAll(database, ";", "") var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = fmt.Sprintf(` SELECT DISTINCT User, Host FROM mysql.db WHERE Db = '%s' @@ -428,7 +436,7 @@ func (m *Manager) CreateDatabase(cfg *ConnectionConfig, dbName string) error { dbName = strings.ReplaceAll(dbName, "\"", "") var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = fmt.Sprintf("CREATE DATABASE `%s`", dbName) case "postgresql": @@ -457,7 +465,7 @@ func (m *Manager) CreateUser(cfg *ConnectionConfig, username, password, host str defer db.Close() var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": if host == "" { host = "%" @@ -489,18 +497,47 @@ func (m *Manager) GrantPrivileges(cfg *ConnectionConfig, username, database, hos defer db.Close() var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": if host == "" { host = "%" } query = fmt.Sprintf("GRANT ALL PRIVILEGES ON `%s`.* TO '%s'@'%s'", database, username, host) case "postgresql": - query = fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE \"%s\" TO %s", database, username) + query = fmt.Sprintf("ALTER DATABASE %s OWNER TO %s", pq.QuoteIdentifier(database), pq.QuoteIdentifier(username)) } - _, err = db.Exec(query) - return err + if _, err = db.Exec(query); err != nil { + return err + } + if normalizeType(cfg.Type) != "postgresql" { + return nil + } + if _, err := db.Exec(fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE %s TO %s", pq.QuoteIdentifier(database), pq.QuoteIdentifier(username))); err != nil { + return err + } + + targetConfig := *cfg + targetConfig.Database = database + targetDSN, err := m.buildDSN(&targetConfig) + if err != nil { + return err + } + targetDB, err := sql.Open(driver, targetDSN) + if err != nil { + return err + } + defer targetDB.Close() + + for _, statement := range []string{ + fmt.Sprintf("ALTER SCHEMA public OWNER TO %s", pq.QuoteIdentifier(username)), + fmt.Sprintf("GRANT ALL ON SCHEMA public TO %s", pq.QuoteIdentifier(username)), + } { + if _, err := targetDB.Exec(statement); err != nil { + return err + } + } + return nil } func (m *Manager) RevokePrivileges(cfg *ConnectionConfig, username, database string) error { @@ -521,7 +558,7 @@ func (m *Manager) RevokePrivileges(cfg *ConnectionConfig, username, database str defer db.Close() var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = fmt.Sprintf("REVOKE ALL PRIVILEGES ON `%s`.* FROM '%s'@'%%'", database, username) case "postgresql": @@ -562,7 +599,7 @@ func (m *Manager) DeleteDatabase(cfg *ConnectionConfig, dbName string) error { dbName = strings.ReplaceAll(dbName, "\"", "") var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", dbName) case "postgresql": @@ -591,7 +628,7 @@ func (m *Manager) DeleteUser(cfg *ConnectionConfig, username, host string) error defer db.Close() var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": if host == "" { host = "%" @@ -645,7 +682,7 @@ func (m *Manager) QueryTable(cfg *ConnectionConfig, database, table string, limi } var query string - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": query = fmt.Sprintf("SELECT * FROM `%s` LIMIT %d OFFSET %d", table, limit, offset) case "postgresql": @@ -822,7 +859,7 @@ func (m *Manager) DescribeTable(cfg *ConnectionConfig, database, table string) ( Indexes: []IndexSchema{}, } - switch cfg.Type { + switch normalizeType(cfg.Type) { case "mysql", "mariadb": if err := m.describeMySQLTable(db, table, schema); err != nil { return nil, err diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 81d9fe5..a0b255a 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "encoding/json" "fmt" "io" "os" @@ -36,11 +37,16 @@ type RunOption func(*runOpts) type runOpts struct { extraEnv []string lineSink func(string) + context context.Context forceRecreate bool noCache bool freshPull bool } +func WithContext(ctx context.Context) RunOption { + return func(o *runOpts) { o.context = ctx } +} + // WithForceRecreate recreates containers even when their config and image are // unchanged, so updated environment variables take effect. func WithForceRecreate() RunOption { @@ -174,6 +180,10 @@ func (c *ComposeExecutor) RestartService(deploymentPath, service string, opts .. return c.runCompose(deploymentPath, opts, "restart", service) } +func (c *ComposeExecutor) RunService(deploymentPath, service string, opts ...RunOption) (string, error) { + return c.runCompose(deploymentPath, opts, "run", "--rm", "--no-deps", service) +} + func (c *ComposeExecutor) RebuildService(deploymentPath, service string, opts ...RunOption) (string, error) { ro := resolveRunOpts(opts) if ro.noCache { @@ -213,10 +223,12 @@ func (c *ComposeExecutor) PS(deploymentPath string) (string, error) { } type ImageInfo struct { - Service string `json:"service"` - Image string `json:"image"` - IsLatest bool `json:"is_latest"` - IsBuild bool `json:"is_build"` + Service string `json:"service"` + Image string `json:"image"` + SourceImage string `json:"source_image,omitempty"` + Resolved bool `json:"resolved"` + IsLatest bool `json:"is_latest"` + IsBuild bool `json:"is_build"` } func (c *ComposeExecutor) Pull(deploymentPath string, onlyLatest bool, opts ...RunOption) (string, error) { @@ -253,16 +265,34 @@ func (c *ComposeExecutor) GetImageInfo(deploymentPath string) ([]ImageInfo, erro if err := yaml.Unmarshal(data, &compose); err != nil { return nil, err } + resolved := make(map[string]string) + if output, err := c.runCompose(deploymentPath, nil, "config", "--format", "json"); err == nil { + var rendered struct { + Services map[string]struct { + Image string `json:"image"` + } `json:"services"` + } + if json.Unmarshal([]byte(output), &rendered) == nil { + for service, config := range rendered.Services { + resolved[service] = config.Image + } + } + } var images []ImageInfo for name, svc := range compose.Services { info := ImageInfo{ - Service: name, - Image: svc.Image, - IsBuild: svc.Build != nil, + Service: name, + Image: svc.Image, + SourceImage: svc.Image, + IsBuild: svc.Build != nil, + } + if image := resolved[name]; image != "" { + info.Image = image + info.Resolved = true } - if svc.Image != "" { - info.IsLatest = isLatestTag(svc.Image) + if info.Image != "" { + info.IsLatest = isLatestTag(info.Image) } images = append(images, info) } @@ -422,15 +452,18 @@ func (c *ComposeExecutor) composeCommand(ctx context.Context, deploymentPath str } func (c *ComposeExecutor) runCompose(deploymentPath string, opts []RunOption, args ...string) (string, error) { - cmd, err := c.composeCommand(context.Background(), deploymentPath, args...) - if err != nil { - return "", err - } - var ro runOpts for _, opt := range opts { opt(&ro) } + ctx := ro.context + if ctx == nil { + ctx = context.Background() + } + cmd, err := c.composeCommand(ctx, deploymentPath, args...) + if err != nil { + return "", err + } // Expose the agent's own uid/gid to compose substitution so a template can // run its container as the user that owns the deployment directory, keeping diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go index eddce88..3c19589 100644 --- a/internal/docker/compose_test.go +++ b/internal/docker/compose_test.go @@ -112,7 +112,7 @@ func TestGetImageInfo(t *testing.T) { composeContent := `name: test-app services: web: - image: nginx:latest + image: ${WEB_IMAGE:-nginx:latest} db: image: postgres:15 cache: @@ -123,6 +123,9 @@ services: if err := os.WriteFile(filepath.Join(deploymentDir, "docker-compose.yml"), []byte(composeContent), 0644); err != nil { t.Fatalf("Failed to write compose file: %v", err) } + if err := os.WriteFile(filepath.Join(deploymentDir, ".env"), []byte("WEB_IMAGE=nginx:1.27\n"), 0600); err != nil { + t.Fatalf("Failed to write environment file: %v", err) + } executor := NewComposeExecutor(tmpDir) @@ -140,8 +143,12 @@ services: imageMap[img.Service] = img } - if !imageMap["web"].IsLatest { - t.Error("Expected nginx:latest to be marked as latest") + if imageMap["web"].Image != "nginx:1.27" || imageMap["web"].SourceImage != "${WEB_IMAGE:-nginx:latest}" || !imageMap["web"].Resolved { + t.Errorf("Expected the web image to be resolved with its source retained, got %+v", imageMap["web"]) + } + + if imageMap["web"].IsLatest { + t.Error("Expected nginx:1.27 to NOT be marked as latest") } if imageMap["db"].IsLatest { diff --git a/internal/docker/compose_yaml.go b/internal/docker/compose_yaml.go index 8c7a7fa..a0ae7d6 100644 --- a/internal/docker/compose_yaml.go +++ b/internal/docker/compose_yaml.go @@ -495,6 +495,14 @@ func RemoveVolumeFromService(content string, serviceName string, volumeMount str } func AddNetworkToCompose(content string, networkName string) (string, error) { + return addNetworkToComposeServices(content, networkName, nil) +} + +func AddNetworkToComposeService(content, networkName, serviceName string) (string, error) { + return addNetworkToComposeServices(content, networkName, map[string]bool{serviceName: true}) +} + +func addNetworkToComposeServices(content string, networkName string, selected map[string]bool) (string, error) { if networkName == "" { return content, nil } @@ -515,6 +523,9 @@ func AddNetworkToCompose(content string, networkName string) (string, error) { services, ok := compose["services"].(map[string]interface{}) if ok { for serviceName, serviceData := range services { + if selected != nil && !selected[serviceName] { + continue + } service, ok := serviceData.(map[string]interface{}) if !ok { continue diff --git a/internal/docker/compose_yaml_test.go b/internal/docker/compose_yaml_test.go index 55ffe53..260499b 100644 --- a/internal/docker/compose_yaml_test.go +++ b/internal/docker/compose_yaml_test.go @@ -397,6 +397,32 @@ services: } } +func TestAddNetworkToComposeServiceOnlyChangesRoutedService(t *testing.T) { + input := `services: + web: + image: nginx:alpine + worker: + image: busybox:latest +` + result, err := AddNetworkToComposeService(input, "proxy", "web") + if err != nil { + t.Fatal(err) + } + parsed, err := ParseComposeYAML(result) + if err != nil { + t.Fatal(err) + } + services := parsed["services"].(map[string]interface{}) + web := services["web"].(map[string]interface{}) + worker := services["worker"].(map[string]interface{}) + if web["networks"] == nil { + t.Fatalf("routed service did not join proxy: %s", result) + } + if worker["networks"] != nil { + t.Fatalf("unrouted service was changed: %s", result) + } +} + func TestMarshalComposeYAML_WithSecrets(t *testing.T) { compose := map[string]interface{}{ "name": "myapp", diff --git a/internal/docker/discovery.go b/internal/docker/discovery.go index 6edf701..b439af7 100644 --- a/internal/docker/discovery.go +++ b/internal/docker/discovery.go @@ -27,11 +27,60 @@ type composeFile struct { } type composeService struct { - Image string `yaml:"image"` - Ports []interface{} `yaml:"ports"` - Expose []interface{} `yaml:"expose"` - Networks []string `yaml:"networks"` - Volumes []string `yaml:"volumes"` + Image string `yaml:"image"` + Ports []interface{} `yaml:"ports"` + Expose []interface{} `yaml:"expose"` + Networks composeNetworks `yaml:"networks"` + Volumes []composeMount `yaml:"volumes"` +} + +type composeNetworks []string + +func (n *composeNetworks) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.SequenceNode: + var values []string + if err := node.Decode(&values); err != nil { + return err + } + *n = values + case yaml.MappingNode: + values := make([]string, 0, len(node.Content)/2) + for i := 0; i < len(node.Content); i += 2 { + values = append(values, node.Content[i].Value) + } + *n = values + } + return nil +} + +type composeMount struct { + Source string + Target string +} + +func (m *composeMount) UnmarshalYAML(node *yaml.Node) error { + if node.Kind == yaml.ScalarNode { + parts := strings.Split(node.Value, ":") + if len(parts) >= 2 { + m.Source = parts[0] + m.Target = parts[1] + } + return nil + } + var value struct { + Type string `yaml:"type"` + Source string `yaml:"source"` + Target string `yaml:"target"` + } + if err := node.Decode(&value); err != nil { + return err + } + if value.Type == "" || value.Type == "bind" { + m.Source = value.Source + m.Target = value.Target + } + return nil } func (d *Discovery) FindDeployments() ([]models.Deployment, error) { @@ -195,7 +244,7 @@ func (d *Discovery) parseComposeServices(composePath string) ([]models.Service, Name: name, Image: svc.Image, Status: "unknown", - Networks: svc.Networks, + Networks: []string(svc.Networks), } for _, p := range svc.Ports { @@ -330,11 +379,7 @@ func copyTree(src, dst string) error { // from template metadata. For paths not in fileMounts, a basename-contains-dot // heuristic is used as a fallback to avoid creating files as directories. func (d *Discovery) createBindMountDirs(deploymentPath, composeContent string, fileMounts []string) error { - var compose struct { - Services map[string]struct { - Volumes []string `yaml:"volumes"` - } `yaml:"services"` - } + var compose composeFile if err := yaml.Unmarshal([]byte(composeContent), &compose); err != nil { return nil // Skip if parse fails, not critical @@ -349,7 +394,7 @@ func (d *Discovery) createBindMountDirs(deploymentPath, composeContent string, f for _, service := range compose.Services { for _, volume := range service.Volumes { - hostPath := extractBindMountPath(volume) + hostPath := volume.Source if hostPath == "" { continue } @@ -447,11 +492,25 @@ type MountOwnership struct { // regular file (e.g. a generated .env) is only chowned, never turned into a // directory. func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwnership) error { + deploymentRoot, err := filepath.Abs(deploymentPath) + if err != nil { + return err + } for _, m := range mounts { base := m.HostPath if !filepath.IsAbs(base) { base = filepath.Join(deploymentPath, base) } + base, err = filepath.Abs(base) + if err != nil { + return err + } + if !pathWithin(deploymentRoot, base) || base == deploymentRoot { + return fmt.Errorf("mount path %q must stay inside the deployment directory", m.HostPath) + } + if err := rejectSymlinkComponents(deploymentRoot, filepath.Dir(base)); err != nil { + return err + } var uid, gid int if m.User != "" { @@ -478,6 +537,17 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn dirs := []string{base} for _, sub := range m.Subdirectories { subPath := filepath.Join(base, sub) + resolved, resolveErr := filepath.Abs(subPath) + if resolveErr != nil { + return resolveErr + } + if !pathWithin(base, resolved) || resolved == base { + return fmt.Errorf("mount subdirectory %q must stay inside %q", sub, m.HostPath) + } + if err := rejectSymlinkComponents(base, resolved); err != nil { + return err + } + subPath = resolved if err := os.MkdirAll(subPath, 0755); err != nil { return fmt.Errorf("create subdirectory %s: %w", subPath, err) } @@ -507,6 +577,33 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn return nil } +func pathWithin(root, candidate string) bool { + prefix := filepath.Clean(root) + string(os.PathSeparator) + return strings.HasPrefix(filepath.Clean(candidate), prefix) +} + +func rejectSymlinkComponents(root, candidate string) error { + relative, err := filepath.Rel(root, candidate) + if err != nil { + return err + } + current := root + for _, component := range strings.Split(relative, string(os.PathSeparator)) { + current = filepath.Join(current, component) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + return nil + } + if statErr != nil { + return statErr + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("mount subdirectory crosses symlink %q", current) + } + } + return nil +} + func parseUIDGID(user string) (int, int, error) { parts := strings.SplitN(user, ":", 2) if len(parts) != 2 { @@ -545,17 +642,32 @@ func InspectContainerUser(containerName string) (string, error) { // ExtractBindMounts parses compose content and returns bind mount host paths. func ExtractBindMounts(composeContent string) []string { + byService := ExtractBindMountsByService(composeContent) + + var paths []string + seen := make(map[string]bool) + for _, servicePaths := range byService { + for _, hostPath := range servicePaths { + if !seen[hostPath] { + seen[hostPath] = true + paths = append(paths, hostPath) + } + } + } + return paths +} + +func ExtractBindMountsByService(composeContent string) map[string][]string { var compose composeFile if err := yaml.Unmarshal([]byte(composeContent), &compose); err != nil { return nil } - var paths []string - seen := make(map[string]bool) - - for _, service := range compose.Services { + paths := make(map[string][]string) + for serviceName, service := range compose.Services { + seen := make(map[string]bool) for _, volume := range service.Volumes { - hostPath := extractBindMountPath(volume) + hostPath := volume.Source if hostPath == "" { continue } @@ -564,11 +676,10 @@ func ExtractBindMounts(composeContent string) []string { } if !seen[hostPath] { seen[hostPath] = true - paths = append(paths, hostPath) + paths[serviceName] = append(paths[serviceName], hostPath) } } } - return paths } diff --git a/internal/docker/discovery_test.go b/internal/docker/discovery_test.go index ddeb293..d6b53de 100644 --- a/internal/docker/discovery_test.go +++ b/internal/docker/discovery_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" "strings" "testing" @@ -308,6 +309,42 @@ func TestApplyMountOwnership(t *testing.T) { } }) + t.Run("rejects mounts outside the deployment", func(t *testing.T) { + for _, hostPath := range []string{"../outside", filepath.Join(tmpDir, "outside")} { + err := d.ApplyMountOwnership(deploymentPath, []MountOwnership{{HostPath: hostPath}}) + if err == nil { + t.Fatalf("expected %q to be rejected", hostPath) + } + } + }) + + t.Run("rejects subdirectories outside the mount", func(t *testing.T) { + err := d.ApplyMountOwnership(deploymentPath, []MountOwnership{{ + HostPath: "./safe", + Subdirectories: []string{"../../outside"}, + }}) + if err == nil { + t.Fatal("expected escaping subdirectory to be rejected") + } + }) + + t.Run("rejects subdirectories through a symlink", func(t *testing.T) { + base := filepath.Join(deploymentPath, "safe-link") + if err := os.MkdirAll(base, 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(tmpDir, filepath.Join(base, "outside")); err != nil { + t.Fatal(err) + } + err := d.ApplyMountOwnership(deploymentPath, []MountOwnership{{ + HostPath: "./safe-link", + Subdirectories: []string{"outside/new"}, + }}) + if err == nil { + t.Fatal("expected symlinked subdirectory to be rejected") + } + }) + t.Run("keeps an existing file mount a file", func(t *testing.T) { envPath := filepath.Join(deploymentPath, ".env") if err := os.WriteFile(envPath, []byte("APP_ENV=production\n"), 0600); err != nil { @@ -751,6 +788,18 @@ func TestExtractBindMounts(t *testing.T) { image: nginx volumes: - ./data:/var/data +`, + expected: []string{"./data"}, + }, + { + name: "long bind mount", + compose: `services: + app: + image: nginx + volumes: + - type: bind + source: ./data + target: /var/data `, expected: []string{"./data"}, }, @@ -828,3 +877,23 @@ func TestExtractBindMounts(t *testing.T) { }) } } + +func TestExtractBindMountsByService(t *testing.T) { + compose := `services: + web: + volumes: + - ./web:/app + - ./shared:/shared + graph: + volumes: + - ./graph:/var/lib/graph + - ./shared:/shared +` + got := ExtractBindMountsByService(compose) + if !reflect.DeepEqual(got["web"], []string{"./web", "./shared"}) { + t.Fatalf("web mounts = %v", got["web"]) + } + if !reflect.DeepEqual(got["graph"], []string{"./graph", "./shared"}) { + t.Fatalf("graph mounts = %v", got["graph"]) + } +} diff --git a/internal/docker/manager.go b/internal/docker/manager.go index bbb6148..74d7824 100644 --- a/internal/docker/manager.go +++ b/internal/docker/manager.go @@ -468,40 +468,45 @@ func (m *Manager) applyMountOwnershipFromContainer(name, deploymentPath string) return } - bindMounts := ExtractBindMounts(composeContent) + bindMounts := ExtractBindMountsByService(composeContent) if len(bindMounts) == 0 { return } - - containerName := m.getMainContainerName(deploymentPath) - if containerName == "" { - containerName = name - } - - user, err := InspectContainerUser(containerName) - if err != nil { - return + containers := m.getComposeContainers(deploymentPath) + containerByService := make(map[string]string, len(containers)) + for _, container := range containers { + containerByService[container.Service] = container.Name } - - if user == "0:0" { - return + pathUses := make(map[string]int) + for _, paths := range bindMounts { + for _, path := range paths { + pathUses[path]++ + } } - var mounts []MountOwnership - for _, path := range bindMounts { - mounts = append(mounts, MountOwnership{ - HostPath: path, - User: user, - }) + for service, paths := range bindMounts { + containerName := containerByService[service] + if containerName == "" { + continue + } + user, err := InspectContainerUser(containerName) + if err != nil || user == "0:0" { + continue + } + for _, path := range paths { + if pathUses[path] == 1 { + mounts = append(mounts, MountOwnership{HostPath: path, User: user}) + } + } } _ = m.discovery.ApplyMountOwnership(deploymentPath, mounts) } -func (m *Manager) getMainContainerName(deploymentPath string) string { +func (m *Manager) getComposeContainers(deploymentPath string) []composeContainer { output, err := m.executor.PS(deploymentPath) if err != nil { - return "" + return nil } var containers []composeContainer @@ -525,17 +530,7 @@ func (m *Manager) getMainContainerName(deploymentPath string) string { } } - for _, c := range containers { - if c.Service == "app" || c.Service == "web" { - return c.Name - } - } - - if len(containers) > 0 { - return containers[0].Name - } - - return "" + return containers } func (m *Manager) snapshotBindMounts(name, deploymentPath string) string { @@ -639,8 +634,8 @@ func (m *Manager) RestartDeployment(name string, opts ...RunOption) (string, err } go func() { - m.applyMountOwnershipFromContainer(name, deployment.Path) m.restoreBindMounts(deployment.Path, snapshotDir) + m.applyMountOwnershipFromContainer(name, deployment.Path) }() return output, nil @@ -666,8 +661,8 @@ func (m *Manager) RebuildDeployment(name string, opts ...RunOption) (string, err } go func() { - m.applyMountOwnershipFromContainer(name, deployment.Path) m.restoreBindMounts(deployment.Path, snapshotDir) + m.applyMountOwnershipFromContainer(name, deployment.Path) }() return output, nil @@ -710,6 +705,16 @@ func (m *Manager) RestartService(name, service string, opts ...RunOption) (strin return m.executor.RestartService(deployment.Path, service, opts...) } +func (m *Manager) RunService(name, service string, opts ...RunOption) (string, error) { + m.mu.RLock() + deployment, err := m.discovery.GetDeployment(name) + m.mu.RUnlock() + if err != nil { + return "", err + } + return m.executor.RunService(deployment.Path, service, opts...) +} + func (m *Manager) RebuildService(name, service string, opts ...RunOption) (string, error) { m.mu.RLock() deployment, err := m.discovery.GetDeployment(name) diff --git a/internal/docker/seed.go b/internal/docker/seed.go index 8e52655..03d236a 100644 --- a/internal/docker/seed.go +++ b/internal/docker/seed.go @@ -177,12 +177,7 @@ func (m *Manager) SeedMounts(name string, hostPaths []string) error { return err } - var compose struct { - Services map[string]struct { - Image string `yaml:"image"` - Volumes []string `yaml:"volumes"` - } `yaml:"services"` - } + var compose composeFile if err := yaml.Unmarshal([]byte(content), &compose); err != nil { return fmt.Errorf("failed to read the compose file: %w", err) } @@ -201,7 +196,7 @@ func (m *Manager) SeedMounts(name string, hostPaths []string) error { continue } for _, volume := range service.Volumes { - hostPath, containerPath := splitBindMount(volume) + hostPath, containerPath := volume.Source, volume.Target if hostPath == "" || containerPath == "" || !wanted[normalizeMountHostPath(hostPath)] { continue } @@ -313,6 +308,9 @@ func extractSeedTar(r io.Reader, destPath string, srcIsDir bool) error { rel := stripRoot(header.Name) if rel == "" { + if err := preserveSeedOwnership(destPath, header.Uid, header.Gid); err != nil { + return err + } continue } @@ -343,7 +341,10 @@ func extractSeedFile(tr *tar.Reader, destPath string) error { if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return err } - return writeSeedFile(tr, destPath, header.FileInfo().Mode()) + if err := writeSeedFile(tr, destPath, header.FileInfo().Mode()); err != nil { + return err + } + return preserveSeedOwnership(destPath, header.Uid, header.Gid) } } @@ -352,14 +353,15 @@ func extractSeedFile(tr *tar.Reader, destPath string) error { // fifos) are skipped: an image may carry them, but they are not configuration // worth reproducing on the host. func writeSeedEntry(tr *tar.Reader, header *tar.Header, target string) error { + var err error switch header.Typeflag { case tar.TypeDir: - return os.MkdirAll(target, header.FileInfo().Mode().Perm()) + err = os.MkdirAll(target, header.FileInfo().Mode().Perm()) case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - return writeSeedFile(tr, target, header.FileInfo().Mode()) + err = writeSeedFile(tr, target, header.FileInfo().Mode()) case tar.TypeSymlink: // A symlink's target is not followed, so a link pointing outside the // deployment only breaks; it cannot be used to write through. @@ -367,10 +369,21 @@ func writeSeedEntry(tr *tar.Reader, header *tar.Header, target string) error { return err } _ = os.Remove(target) - return os.Symlink(header.Linkname, target) + err = os.Symlink(header.Linkname, target) default: return nil } + if err != nil { + return err + } + return preserveSeedOwnership(target, header.Uid, header.Gid) +} + +func preserveSeedOwnership(path string, uid, gid int) error { + if os.Geteuid() != 0 { + return nil + } + return os.Lchown(path, uid, gid) } func writeSeedFile(r io.Reader, path string, mode os.FileMode) error { diff --git a/internal/docker/seed_test.go b/internal/docker/seed_test.go index 99e6811..93b1faf 100644 --- a/internal/docker/seed_test.go +++ b/internal/docker/seed_test.go @@ -19,6 +19,10 @@ func tarOf(t *testing.T, entries []tar.Header, bodies map[string]string) *bytes. tw := tar.NewWriter(&buf) for i := range entries { h := entries[i] + if os.Geteuid() != 0 && h.Uid == 0 && h.Gid == 0 { + h.Uid = os.Geteuid() + h.Gid = os.Getegid() + } if body, ok := bodies[h.Name]; ok { h.Size = int64(len(body)) } diff --git a/internal/files/manager.go b/internal/files/manager.go index 3c44683..c3a5ee8 100644 --- a/internal/files/manager.go +++ b/internal/files/manager.go @@ -162,6 +162,31 @@ func (m *Manager) Chmod(deploymentName, relativePath string, mode os.FileMode) e return os.Chmod(target, mode) } +func (m *Manager) Chown(deploymentName, relativePath string, uid, gid int, recursive bool) error { + if uid < 0 || gid < 0 { + return fmt.Errorf("uid and gid must be zero or greater") + } + if filepath.Clean(strings.TrimPrefix(relativePath, "/")) == "." { + return fmt.Errorf("deployment root ownership cannot be changed") + } + target, err := m.resolvePath(deploymentName, relativePath) + if err != nil { + return err + } + if _, err := os.Lstat(target); err != nil { + return err + } + if !recursive { + return os.Lchown(target, uid, gid) + } + return filepath.WalkDir(target, func(path string, _ os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + return os.Lchown(path, uid, gid) + }) +} + func (m *Manager) WriteFile(deploymentName, relativePath string, content io.Reader) error { filePath, err := m.resolvePath(deploymentName, relativePath) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index d2a36f2..c43579d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -460,14 +460,18 @@ func setDefaults(cfg *Config) { switch cfg.Infrastructure.Database.Type { case "mysql", "mariadb": cfg.Infrastructure.Database.Port = 3306 - case "postgres": + case "postgres", "postgresql": cfg.Infrastructure.Database.Port = 5432 default: cfg.Infrastructure.Database.Port = 3306 } } if cfg.Infrastructure.Database.RootUser == "" && cfg.Infrastructure.Database.Enabled { - cfg.Infrastructure.Database.RootUser = "root" + if cfg.Infrastructure.Database.Type == "postgres" || cfg.Infrastructure.Database.Type == "postgresql" { + cfg.Infrastructure.Database.RootUser = "postgres" + } else { + cfg.Infrastructure.Database.RootUser = "root" + } } if cfg.Infrastructure.Redis.Port == 0 && cfg.Infrastructure.Redis.Enabled { cfg.Infrastructure.Redis.Port = 6379 diff --git a/tools/genspec/main.go b/tools/genspec/main.go index 71cb35b..55570df 100644 --- a/tools/genspec/main.go +++ b/tools/genspec/main.go @@ -112,6 +112,12 @@ func build(root string) (*openAPI, error) { } if fn := handlers[r.Handler]; fn != nil { + if handlerCalls(fn.decl, "planRequested") { + if op.Extensions == nil { + op.Extensions = map[string]any{} + } + op.Extensions["x-plan-supported"] = true + } if bound, contentType := boundRequestType(fn.pkg, fn.decl); bound != nil { if ref := schemas.add(bound); ref != nil { op.RequestBody = &requestBody{ @@ -148,6 +154,24 @@ func build(root string) (*openAPI, error) { return spec, nil } +func handlerCalls(fn *ast.FuncDecl, name string) bool { + found := false + ast.Inspect(fn, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + switch called := call.Fun.(type) { + case *ast.Ident: + found = found || called.Name == name + case *ast.SelectorExpr: + found = found || called.Sel.Name == name + } + return !found + }) + return found +} + func readVersion(root string) string { raw, err := os.ReadFile(filepath.Join(root, "VERSION")) if err != nil { diff --git a/tools/genspec/schema.go b/tools/genspec/schema.go index bbdf682..4e028c1 100644 --- a/tools/genspec/schema.go +++ b/tools/genspec/schema.go @@ -88,6 +88,7 @@ type schema struct { Required []string `json:"required,omitempty"` Description string `json:"description,omitempty"` AdditionalProperties *schema `json:"additionalProperties,omitempty"` + Enum []string `json:"enum,omitempty"` } // schemaSet describes a type once however many endpoints use it. @@ -211,6 +212,9 @@ func (s *schemaSet) fields(t *types.Struct, depth int, out *schema) { if tag.required { out.Required = append(out.Required, name) } + if len(tag.enum) > 0 { + built.Enum = append([]string(nil), tag.enum...) + } } } @@ -232,6 +236,7 @@ type fieldTag struct { skip bool required bool hidden bool + enum []string } func parseTag(raw string) fieldTag { @@ -246,9 +251,15 @@ func parseTag(raw string) fieldTag { if jsonTag != "" { tag.name = strings.Split(jsonTag, ",")[0] } - if strings.Contains(parsed.Get("binding"), "required") { + binding := parsed.Get("binding") + if strings.Contains(binding, "required") { tag.required = true } + for _, rule := range strings.Split(binding, ",") { + if values := strings.TrimPrefix(rule, "oneof="); values != rule { + tag.enum = strings.Fields(values) + } + } if strings.TrimSpace(parsed.Get("cli")) == "-" { tag.hidden = true }