From 3f870c65d741b3d7c56033bdbc5d481f97e80144 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 22:35:06 +0100 Subject: [PATCH] feat: Improve first deployment reliability First deployments preserve bind mount ownership and create usable databases. Routed services join the proxy and certificate failures restore prior configuration. Compose environment changes remain consistent across validation and deployment. Jobs report container outcomes and time out instead of locking later operations. --- CHANGELOG.md | 28 ++ README.md | 4 + VERSION | 2 +- internal/api/database_defaults_test.go | 39 ++ internal/api/deployment_create_env_test.go | 65 +++ internal/api/domains_test.go | 12 + internal/api/file_ownership_test.go | 30 ++ internal/api/jobs_test.go | 40 ++ internal/api/openapi.json | 171 ++++++- internal/api/plan_actions.go | 80 +++- internal/api/plan_apply_test.go | 2 + internal/api/require_plan_test.go | 30 ++ internal/api/server.go | 504 ++++++++++++++++++--- internal/api/settings_update_test.go | 26 ++ internal/database/manager.go | 75 ++- internal/docker/compose.go | 61 ++- internal/docker/compose_test.go | 13 +- internal/docker/compose_yaml.go | 11 + internal/docker/compose_yaml_test.go | 26 ++ internal/docker/discovery.go | 149 +++++- internal/docker/discovery_test.go | 69 +++ internal/docker/manager.go | 73 +-- internal/docker/seed.go | 35 +- internal/docker/seed_test.go | 4 + internal/files/manager.go | 25 + pkg/config/config.go | 8 +- tools/genspec/main.go | 24 + tools/genspec/schema.go | 13 +- 28 files changed, 1424 insertions(+), 195 deletions(-) create mode 100644 internal/api/database_defaults_test.go create mode 100644 internal/api/deployment_create_env_test.go create mode 100644 internal/api/file_ownership_test.go create mode 100644 internal/api/settings_update_test.go 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 }