diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/config.example.yml b/config.example.yml index a0cc84c..459d96d 100644 --- a/config.example.yml +++ b/config.example.yml @@ -115,6 +115,8 @@ system_terminal: enabled: false cleanup: timeout: 2m0s +files: + show_hidden: true plans: ttl: 24h0m0s retention_days: 30 diff --git a/internal/ai/provider.go b/internal/ai/provider.go index aa05a46..6608cc5 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -19,6 +19,10 @@ type Message struct { // while showing the operator a short label. Never sent to the // provider. Display string `json:"display,omitempty"` + // Hidden marks a turn the UI must not show at all: prompts composed + // by the product (e.g. "analyze these logs") rather than typed by + // the operator. The model still sees Content. + Hidden bool `json:"hidden,omitempty"` // ToolCalls is set on an assistant message that wants tools run. ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ToolCallID and Name identify a role:"tool" result message. diff --git a/internal/ai/session.go b/internal/ai/session.go index 10a7a62..a5596ae 100644 --- a/internal/ai/session.go +++ b/internal/ai/session.go @@ -74,8 +74,8 @@ func (s *Session) touch() { // AddUserMessage records a user turn. When display differs from // content, the model sees content (e.g. message plus embedded logs) // while the UI shows display (e.g. a short label). -func (s *Session) AddUserMessage(content, display string) { - s.Messages = append(s.Messages, Message{Role: "user", Content: content, Display: display}) +func (s *Session) AddUserMessage(content, display string, hidden bool) { + s.Messages = append(s.Messages, Message{Role: "user", Content: content, Display: display, Hidden: hidden}) s.touch() } @@ -206,6 +206,9 @@ func (s *Session) DisplayMessages() []DisplayTurn { for _, m := range s.Messages { switch m.Role { case "user": + if m.Hidden { + continue + } shown := m.Content if m.Display != "" { shown = m.Display diff --git a/internal/api/ai_session_handlers.go b/internal/api/ai_session_handlers.go index f647672..1300162 100644 --- a/internal/api/ai_session_handlers.go +++ b/internal/api/ai_session_handlers.go @@ -136,6 +136,7 @@ func (s *Server) createAISession(c *gin.Context) { AutoRun bool `json:"auto_run"` Message string `json:"message"` Context string `json:"context"` + Seed bool `json:"seed"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -161,7 +162,7 @@ func (s *Server) createAISession(c *gin.Context) { prompt := ai.BuildSessionPrompt(req.Scope, req.Deployment, s.config.AI.DocsURL) sess := ai.NewSession(req.Scope, req.Deployment, req.AutoRun, sessionActorFrom(c), prompt) content, display := composeUserMessage(req.Message, req.Context) - sess.AddUserMessage(content, display) + sess.AddUserMessage(content, display, req.Seed) if err := s.advanceSession(c, sess); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) @@ -224,7 +225,7 @@ func (s *Server) postAISessionMessage(c *gin.Context) { } content, display := composeUserMessage(req.Message, req.Context) - sess.AddUserMessage(content, display) + sess.AddUserMessage(content, display, false) if err := s.advanceSession(c, sess); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return diff --git a/internal/api/ai_session_test.go b/internal/api/ai_session_test.go index 3371f16..084ece7 100644 --- a/internal/api/ai_session_test.go +++ b/internal/api/ai_session_test.go @@ -131,6 +131,51 @@ func TestAISessionHidesBulkyContext(t *testing.T) { } } +func TestAISessionHidesSeededPrompt(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) + + stub := &scriptedProvider{responses: []*ai.Response{{Content: "All healthy.", Model: "scripted"}}} + s.aiProvider = stub + + resp, parsed := doJSON(t, http.MethodPost, ts.URL+"/api/ai/sessions", map[string]interface{}{ + "scope": "deployment", + "deployment": "myapp", + "auto_run": true, + "message": "Analyze the recent logs for myapp.", + "context": "```\nGET /health 200 OK\n```", + "seed": true, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body %v", resp.StatusCode, parsed) + } + + // A seeded prompt is composed by the product, not typed by the + // operator, so the transcript starts with the assistant's answer. + messages := parsed["messages"].([]interface{}) + for _, m := range messages { + turn := m.(map[string]interface{}) + if turn["role"] == "user" { + t.Errorf("seeded prompt leaked into the transcript: %v", turn["content"]) + } + } + if len(messages) == 0 { + t.Fatal("expected the assistant turn in the transcript") + } + + // The model must still receive the seeded prompt and its context. + var prompt strings.Builder + for _, m := range stub.lastRequestMessages() { + prompt.WriteString(m.Content) + } + if !strings.Contains(prompt.String(), "Analyze the recent logs for myapp.") { + t.Error("seeded prompt was not sent to the model") + } + if !strings.Contains(prompt.String(), "GET /health 200 OK") { + t.Error("seeded context was not sent to the model") + } +} + func TestAISessionApprovalGating(t *testing.T) { s, tmpDir, ts := setupPlanTestServer(t) createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) diff --git a/internal/api/container_exec.go b/internal/api/container_exec.go index 45d6027..83323e8 100644 --- a/internal/api/container_exec.go +++ b/internal/api/container_exec.go @@ -109,7 +109,19 @@ func (s *Server) containerExec(c *gin.Context) { cmd := exec.Command("docker", "exec", "-i", "-t", containerID, shell) cmd.Env = append(os.Environ(), "TERM=xterm-256color") - // Start with PTY + guard := func(command string) (bool, *models.ProtectedCommandRule, error) { + return s.protectedContainerCommandBlocked(containerID, command) + } + streamPTY(conn, cmd, guard) +} + +// terminalCommandGuard decides whether a submitted command line may run. +type terminalCommandGuard func(command string) (bool, *models.ProtectedCommandRule, error) + +// streamPTY runs cmd under a PTY and pumps bytes between it and the +// websocket: binary frames carry raw terminal bytes, JSON text frames carry +// resizes, and each submitted line is checked against guard before it runs. +func streamPTY(conn *websocket.Conn, cmd *exec.Cmd, guard terminalCommandGuard) { ptmx, err := pty.Start(cmd) if err != nil { sendError(conn, "Failed to start terminal: "+err.Error()) @@ -181,7 +193,7 @@ func (s *Server) containerExec(c *gin.Context) { } } - if blocked, err := s.handleTerminalInput(containerID, ptmx, conn, message, &commandBuffer); err != nil { + if blocked, err := handleTerminalInput(guard, ptmx, conn, message, &commandBuffer); err != nil { log.Printf("PTY write error: %v", err) return } else if blocked { @@ -205,7 +217,7 @@ func (s *Server) containerExec(c *gin.Context) { wg.Wait() } -func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) { +func handleTerminalInput(guard terminalCommandGuard, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) { for _, b := range message { switch b { case '\r', '\n': @@ -214,7 +226,7 @@ func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *we if command == "" { continue } - blocked, rule, err := s.protectedContainerCommandBlocked(containerID, command) + blocked, rule, err := guard(command) if err != nil { return false, err } diff --git a/internal/api/server.go b/internal/api/server.go index 400b5da..c366033 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -3,6 +3,8 @@ package api import ( "context" cryptoRand "crypto/rand" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "log" @@ -133,6 +135,13 @@ func New(cfg *config.Config, configPath string) *Server { manager := docker.NewManager(cfg.DeploymentsPath) manager.SetCleanupTimeout(cfg.Cleanup.Timeout) + + // Deploys read template copies from disk, so sync them with the + // binary's embedded set before anything can deploy. + builtinTemplatesDir := filepath.Join(cfg.DeploymentsPath, ".flatrun", "templates") + if err := os.MkdirAll(builtinTemplatesDir, 0755); err == nil { + ensureBuiltinTemplates(builtinTemplatesDir) + } certsDiscovery := certs.NewDiscovery(cfg.DeploymentsPath) networksManager := networks.NewManager() pluginsDir := filepath.Join(cfg.DeploymentsPath, ".flatrun", "plugins") @@ -309,6 +318,7 @@ func (s *Server) setupRoutes() { // WebSocket endpoint handles its own auth via first-message api.GET("/containers/:id/exec", s.containerExec) api.GET("/system/terminal", s.systemTerminal) + api.GET("/system/terminal/interactive", s.systemTerminalInteractive) // Setup endpoints (public, gated by setup state) setupGroup := api.Group("/setup") @@ -998,6 +1008,7 @@ 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) } @@ -2833,11 +2844,32 @@ type TemplateMetadata struct { ContainerPort int `yaml:"container_port"` Mounts []TemplateMount `yaml:"mounts"` Files []TemplateFile `yaml:"files"` + Env TemplateEnv `yaml:"env"` +} + +// TemplateEnv describes how a platform's environment file is produced. The +// template defines everything: which file to write (file), where the example +// shipped inside the deployed image lives (example_path, preferred base when +// readable), the fallback content (template), and which secrets to generate +// fresh per deployment. Nothing is assumed about the platform. +type TemplateEnv struct { + File string `json:"file,omitempty" yaml:"file,omitempty"` + ExamplePath string `json:"example_path,omitempty" yaml:"example_path,omitempty"` + Template string `json:"template,omitempty" yaml:"template,omitempty"` + Secrets []TemplateEnvSecret `json:"secrets,omitempty" yaml:"secrets,omitempty"` +} + +type TemplateEnvSecret struct { + Key string `json:"key" yaml:"key"` + Encoding string `json:"encoding,omitempty" yaml:"encoding,omitempty"` // base64 (default), hex, alphanumeric + Bytes int `json:"bytes,omitempty" yaml:"bytes,omitempty"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` } type TemplateMount struct { ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` + HostPath string `json:"host_path,omitempty" yaml:"host_path,omitempty"` ContainerPath string `json:"container_path" yaml:"container_path"` Description string `json:"description" yaml:"description"` Type string `json:"type" yaml:"type"` @@ -2846,6 +2878,15 @@ type TemplateMount struct { Subdirectories []string `json:"subdirectories,omitempty" yaml:"subdirectories,omitempty"` } +// templateMountHostPath resolves where a template mount lives in the +// deployment directory: an explicit host_path from the metadata, or ./. +func templateMountHostPath(m TemplateMount) string { + if m.HostPath != "" { + return m.HostPath + } + return "./" + m.ID +} + type Template struct { ID string `json:"id"` Name string `json:"name" yaml:"name"` @@ -2870,7 +2911,7 @@ func (s *Server) listTemplates(c *gin.Context) { return } - s.ensureBuiltinTemplates(templatesDir) + ensureBuiltinTemplates(templatesDir) typeFilter := c.DefaultQuery("type", "") @@ -3303,6 +3344,17 @@ func (s *Server) generateComposeWithOptions(templateID string, opts *ComposeGene templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates") + var metadata TemplateMetadata + metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") + metadataContent, err := os.ReadFile(metadataPath) + if err == nil { + _ = yaml.Unmarshal(metadataContent, &metadata) + } + + if opts.Image != "" { + return s.generateImageComposeWithTemplate(opts, &metadata) + } + composeBytes, err := templates.GetCompose(templateID) if err != nil { composePath := filepath.Join(templatesDir, templateID, "docker-compose.yml") @@ -3320,13 +3372,6 @@ func (s *Server) generateComposeWithOptions(templateID string, opts *ComposeGene content = strings.ReplaceAll(content, "${PROXY_NETWORK}", networkName) content = replaceHardcodedNetwork(content, "proxy", networkName) - var metadata TemplateMetadata - metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") - metadataContent, err := os.ReadFile(metadataPath) - if err == nil { - _ = yaml.Unmarshal(metadataContent, &metadata) - } - if opts.MapPorts && opts.HostPort != "" { containerPort := opts.ContainerPort if containerPort == 0 && metadata.ContainerPort > 0 { @@ -3400,7 +3445,7 @@ func (s *Server) injectMounts(content string, selections []MountSelection, avail if sel.Type == "volume" { hostPath = sel.ID + "_data" } else { - hostPath = "./" + sel.ID + hostPath = templateMountHostPath(mount) } newVolumes = append(newVolumes, fmt.Sprintf("%s:%s", hostPath, mount.ContainerPath)) } @@ -3477,6 +3522,35 @@ func hasVolumeOptions(volume string) bool { return len(strings.Split(volume, ":")) >= 3 } +// generateImageComposeWithTemplate keeps the user-provided image as the +// deployed service but applies the template's defaults (container port and +// bind mounts) on top of the generated compose. Explicit selections override +// the defaults entirely; without them the template's required mounts apply. +func (s *Server) generateImageComposeWithTemplate(opts *ComposeGenerateRequest, metadata *TemplateMetadata) (string, error) { + if opts.ContainerPort == 0 && metadata.ContainerPort > 0 { + opts.ContainerPort = metadata.ContainerPort + } + + content, err := s.generateCustomCompose(opts) + if err != nil { + return "", err + } + + selections := opts.Mounts + if len(selections) == 0 { + for _, m := range metadata.Mounts { + if m.Required { + selections = append(selections, MountSelection{ID: m.ID, Enabled: true, Type: m.Type}) + } + } + } + if len(selections) > 0 && len(metadata.Mounts) > 0 { + content = s.injectMounts(content, selections, metadata.Mounts) + } + + return content, nil +} + func (s *Server) generateCustomCompose(opts *ComposeGenerateRequest) (string, error) { networkName := s.config.Infrastructure.DefaultProxyNetwork image := strings.TrimSpace(opts.Image) @@ -3680,18 +3754,30 @@ func extractNetworkNames(networks interface{}) []string { } } -func (s *Server) ensureBuiltinTemplates(templatesDir string) { +func ensureBuiltinTemplates(templatesDir string) { builtinList, err := templates.List() if err != nil { return } + // On-disk copies are what deploys actually read; overwrite them + // whenever the embedded set changed (typically an agent upgrade), + // otherwise stale metadata keeps shaping new deployments. + checksumPath := filepath.Join(templatesDir, ".builtin-checksum") + checksum := templates.Checksum() + stale := true + if current, err := os.ReadFile(checksumPath); err == nil && strings.TrimSpace(string(current)) == checksum { + stale = false + } + for _, tmplID := range builtinList { templatePath := filepath.Join(templatesDir, tmplID) composePath := filepath.Join(templatePath, "docker-compose.yml") - if _, err := os.Stat(composePath); err == nil { - continue + if !stale { + if _, err := os.Stat(composePath); err == nil { + continue + } } if err := os.MkdirAll(templatePath, 0755); err != nil { @@ -3708,6 +3794,10 @@ func (s *Server) ensureBuiltinTemplates(templatesDir string) { _ = os.WriteFile(composePath, composeContent, 0644) } } + + if stale && checksum != "" { + _ = os.WriteFile(checksumPath, []byte(checksum), 0644) + } } func (s *Server) generateComposeContent(name, templateID string) (string, error) { @@ -3890,6 +3980,188 @@ func (s *Server) processTemplateFiles(deploymentName, templateID string, envVars } } +// processTemplateEnv produces the platform's environment file for a new +// deployment, entirely driven by the template's env spec. Base content +// precedence: the example file inside the deployed image (when the template +// declares one and it is readable), then the template's own fallback content, +// then whatever the template already placed at the target path. The +// deployment's environment variables and freshly generated secrets are then +// applied on top, key by key. +func (s *Server) processTemplateEnv(deploymentName, templateID, composeContent string, envVars []EnvVar) { + templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates") + metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") + + metadataContent, err := os.ReadFile(metadataPath) + if err != nil { + return + } + + var metadata TemplateMetadata + if err := yaml.Unmarshal(metadataContent, &metadata); err != nil { + return + } + + spec := metadata.Env + if spec.File == "" { + return + } + + targetPath := filepath.Join(s.config.DeploymentsPath, deploymentName, spec.File) + + var base string + if spec.ExamplePath != "" { + if image := mainServiceImage(composeContent); image != "" { + if content, err := docker.ExtractFileFromImage(image, spec.ExamplePath); err == nil { + base = string(content) + } else { + log.Printf("template env: could not read %s from %s: %v", spec.ExamplePath, image, err) + } + } + } + if base == "" { + base = spec.Template + } + if base == "" { + if existing, err := os.ReadFile(targetPath); err == nil { + base = string(existing) + } + } + + content := buildTemplateEnvContent(base, deploymentName, envVars, spec) + if err := os.WriteFile(targetPath, []byte(content), 0600); err != nil { + log.Printf("template env: failed to write %s: %v", targetPath, err) + } +} + +func buildTemplateEnvContent(base, deploymentName string, envVars []EnvVar, spec TemplateEnv) string { + content := strings.ReplaceAll(base, "${NAME}", deploymentName) + + var keys []string + values := make(map[string]string) + for _, env := range envVars { + if env.Key == "" { + continue + } + if _, ok := values[env.Key]; !ok { + keys = append(keys, env.Key) + } + values[env.Key] = env.Value + } + + for _, secret := range spec.Secrets { + if secret.Key == "" { + continue + } + if _, ok := values[secret.Key]; ok { + continue + } + value, err := generateSecretValue(secret) + if err != nil { + continue + } + keys = append(keys, secret.Key) + values[secret.Key] = value + } + + return applyEnvValues(content, keys, values) +} + +// applyEnvValues sets KEY=value pairs in dotenv-formatted content, replacing +// lines whose key matches and appending the rest. +func applyEnvValues(content string, keys []string, values map[string]string) string { + lines := strings.Split(content, "\n") + seen := make(map[string]bool) + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + eq := strings.IndexByte(trimmed, '=') + if eq <= 0 { + continue + } + key := strings.TrimSpace(trimmed[:eq]) + if value, ok := values[key]; ok { + lines[i] = key + "=" + quoteEnvValue(value) + seen[key] = true + } + } + + content = strings.Join(lines, "\n") + var missing []string + for _, key := range keys { + if !seen[key] { + missing = append(missing, key+"="+quoteEnvValue(values[key])) + } + } + if len(missing) > 0 { + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += strings.Join(missing, "\n") + "\n" + } + return content +} + +func quoteEnvValue(value string) string { + if strings.ContainsAny(value, " \t#\"") { + return strconv.Quote(value) + } + return value +} + +func generateSecretValue(spec TemplateEnvSecret) (string, error) { + n := spec.Bytes + if n <= 0 { + n = 32 + } + buf := make([]byte, n) + if _, err := cryptoRand.Read(buf); err != nil { + return "", err + } + + var value string + switch spec.Encoding { + case "hex": + value = hex.EncodeToString(buf) + case "alphanumeric": + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + out := make([]byte, n) + for i, b := range buf { + out[i] = charset[int(b)%len(charset)] + } + value = string(out) + default: + value = base64.StdEncoding.EncodeToString(buf) + } + + return spec.Prefix + value, nil +} + +// mainServiceImage picks the image of the service a template's env example +// should come from: the conventional "app" service, or the first service with +// an image. +func mainServiceImage(content string) string { + var compose composeFile + if err := yaml.Unmarshal([]byte(content), &compose); err != nil { + return "" + } + if svc, ok := compose.Services["app"]; ok && svc.Image != "" { + return svc.Image + } + names := make([]string, 0, len(compose.Services)) + for name := range compose.Services { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if image := compose.Services[name].Image; image != "" { + return image + } + } + return "" +} + func (s *Server) applyTemplateMountOwnership(deploymentName, templateID string) { templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates") metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") @@ -3913,9 +4185,8 @@ func (s *Server) applyTemplateMountOwnership(deploymentName, templateID string) if m.Type != "file" { continue } - hostPath := "./" + m.ID mounts = append(mounts, docker.MountOwnership{ - HostPath: hostPath, + HostPath: templateMountHostPath(m), User: m.User, Subdirectories: m.Subdirectories, }) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index a58bb59..6a3bb1e 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -613,6 +613,303 @@ func TestGenerateComposeWithOptionsCustomImage(t *testing.T) { } } +func TestGenerateComposeWithOptionsImageKeepsUserImageWithTemplateDefaults(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + templateDir := cfg.DeploymentsPath + "/.flatrun/templates/laravel" + if err := createDir(templateDir); err != nil { + t.Fatalf("failed to create template dir: %v", err) + } + + composeContent := `name: ${NAME} +services: + app: + image: serversideup/php:8.3 + expose: + - "8000" + networks: + - proxy + +networks: + proxy: + external: true +` + if err := writeFile(templateDir+"/docker-compose.yml", composeContent); err != nil { + t.Fatalf("failed to write compose: %v", err) + } + + metadata := `name: Laravel +container_port: 8000 +mounts: + - id: app + name: Application + container_path: /app + type: file + required: false + - id: storage + name: Storage + container_path: /app/storage + type: file + required: true + - id: cache + name: Cache + container_path: /app/bootstrap/cache + type: file + required: false +` + if err := writeFile(templateDir+"/metadata.yml", metadata); err != nil { + t.Fatalf("failed to write metadata: %v", err) + } + + opts := &ComposeGenerateRequest{ + Name: "my-laravel", + Image: "ghcr.io/me/laravel-app:1.0", + } + + result, err := s.generateComposeWithOptions("laravel", opts) + if err != nil { + t.Fatalf("generateComposeWithOptions failed: %v", err) + } + + if !strings.Contains(result, "ghcr.io/me/laravel-app:1.0") { + t.Error("Result should keep the user-provided image") + } + if strings.Contains(result, "serversideup/php") { + t.Error("Result should not use the template's image") + } + if !strings.Contains(result, "8000") { + t.Error("Result should use the template's container port") + } + // Without explicit selections the template's required mounts apply. + if !strings.Contains(result, "./storage:/app/storage") { + t.Error("Result should inject the template's required mounts by default") + } + if strings.Contains(result, "./app:/app") || strings.Contains(result, "/app/bootstrap/cache") { + t.Error("Result should not inject optional mounts by default") + } + + // Explicit selections override the defaults entirely, including + // deselecting required mounts. + opts.Mounts = []MountSelection{ + {ID: "storage", Enabled: false, Type: "file"}, + {ID: "cache", Enabled: true, Type: "file"}, + } + result, err = s.generateComposeWithOptions("laravel", opts) + if err != nil { + t.Fatalf("generateComposeWithOptions with selections failed: %v", err) + } + if strings.Contains(result, "./storage:/app/storage") { + t.Error("Deselected required mount must not be injected") + } + if !strings.Contains(result, "./cache:/app/bootstrap/cache") { + t.Error("Result should inject the selected cache mount") + } +} + +func TestApplyEnvValues(t *testing.T) { + base := `APP_NAME=Laravel +APP_KEY= +# DB settings +DB_HOST=127.0.0.1 +DB_PASSWORD= +` + result := applyEnvValues(base, []string{"APP_KEY", "DB_HOST", "DB_PASSWORD", "REDIS_HOST"}, map[string]string{ + "APP_KEY": "base64:abc123", + "DB_HOST": "shared-mysql", + "DB_PASSWORD": "s3cret", + "REDIS_HOST": "redis", + }) + + for _, want := range []string{ + "APP_KEY=base64:abc123\n", + "DB_HOST=shared-mysql\n", + "DB_PASSWORD=s3cret\n", + "REDIS_HOST=redis\n", + "APP_NAME=Laravel\n", + "# DB settings\n", + } { + if !strings.Contains(result, want) { + t.Errorf("result missing %q:\n%s", want, result) + } + } + if strings.Contains(result, "DB_HOST=127.0.0.1") { + t.Errorf("old DB_HOST value should be replaced:\n%s", result) + } + if strings.Count(result, "DB_HOST=") != 1 { + t.Errorf("DB_HOST should appear exactly once:\n%s", result) + } +} + +func TestApplyEnvValuesQuotesSpecialValues(t *testing.T) { + result := applyEnvValues("", []string{"APP_NAME"}, map[string]string{"APP_NAME": "My App #1"}) + if !strings.Contains(result, `APP_NAME="My App #1"`) { + t.Errorf("value with spaces should be quoted, got:\n%s", result) + } +} + +func TestGenerateSecretValue(t *testing.T) { + v, err := generateSecretValue(TemplateEnvSecret{Key: "APP_KEY", Prefix: "base64:", Encoding: "base64", Bytes: 32}) + if err != nil { + t.Fatalf("generateSecretValue failed: %v", err) + } + if !strings.HasPrefix(v, "base64:") { + t.Errorf("expected base64: prefix, got %q", v) + } + if len(v) < len("base64:")+40 { + t.Errorf("expected ~44 chars of base64 after prefix, got %q", v) + } + + hexVal, err := generateSecretValue(TemplateEnvSecret{Key: "TOKEN", Encoding: "hex", Bytes: 16}) + if err != nil { + t.Fatalf("generateSecretValue hex failed: %v", err) + } + if len(hexVal) != 32 { + t.Errorf("expected 32 hex chars, got %d (%q)", len(hexVal), hexVal) + } +} + +func TestBuildTemplateEnvContentSecretsAndOverrides(t *testing.T) { + base := "APP_NAME=${NAME}\nAPP_KEY=\nDB_HOST=localhost\n" + spec := TemplateEnv{Secrets: []TemplateEnvSecret{ + {Key: "APP_KEY", Prefix: "base64:", Encoding: "base64"}, + {Key: "PROVIDED_SECRET", Encoding: "hex"}, + }} + envVars := []EnvVar{ + {Key: "DB_HOST", Value: "shared-mysql"}, + {Key: "PROVIDED_SECRET", Value: "user-supplied"}, + } + + result := buildTemplateEnvContent(base, "my-app", envVars, spec) + + if !strings.Contains(result, "APP_NAME=my-app") { + t.Errorf("${NAME} should be substituted:\n%s", result) + } + if !strings.Contains(result, "APP_KEY=base64:") { + t.Errorf("APP_KEY should be generated:\n%s", result) + } + if !strings.Contains(result, "DB_HOST=shared-mysql") { + t.Errorf("DB_HOST should be overridden:\n%s", result) + } + if !strings.Contains(result, "PROVIDED_SECRET=user-supplied") { + t.Errorf("user-provided value must win over secret generation:\n%s", result) + } +} + +func TestProcessTemplateEnvRequiresSpecFile(t *testing.T) { + cfg := &config.Config{DeploymentsPath: t.TempDir()} + s := &Server{config: cfg} + + templateDir := cfg.DeploymentsPath + "/.flatrun/templates/anything" + if err := createDir(templateDir); err != nil { + t.Fatalf("failed to create template dir: %v", err) + } + // Secrets without a declared file: the template did not define env + // handling, so nothing may be written. + metadata := `name: Anything +env: + secrets: + - key: APP_KEY +` + if err := writeFile(templateDir+"/metadata.yml", metadata); err != nil { + t.Fatalf("failed to write metadata: %v", err) + } + deploymentDir := cfg.DeploymentsPath + "/my-app" + if err := createDir(deploymentDir); err != nil { + t.Fatalf("failed to create deployment dir: %v", err) + } + + s.processTemplateEnv("my-app", "anything", "services: {}", nil) + + entries, err := os.ReadDir(deploymentDir) + if err != nil { + t.Fatalf("read deployment dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("no files should be written without env.file, found %d", len(entries)) + } +} + +func TestProcessTemplateEnvUsesSpecFallback(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + templateDir := cfg.DeploymentsPath + "/.flatrun/templates/laravel" + if err := createDir(templateDir); err != nil { + t.Fatalf("failed to create template dir: %v", err) + } + metadata := `name: Laravel +env: + file: .env + example_path: /app/.env.example + template: | + APP_NAME=${NAME} + APP_KEY= + DB_HOST= + secrets: + - key: APP_KEY + prefix: "base64:" +` + if err := writeFile(templateDir+"/metadata.yml", metadata); err != nil { + t.Fatalf("failed to write metadata: %v", err) + } + + deploymentDir := cfg.DeploymentsPath + "/my-laravel" + if err := createDir(deploymentDir); err != nil { + t.Fatalf("failed to create deployment dir: %v", err) + } + + envVars := []EnvVar{{Key: "DB_HOST", Value: "shared-mysql"}} + + // The compose resolves no image, so the spec's fallback content is the base. + s.processTemplateEnv("my-laravel", "laravel", "services: {}", envVars) + + content, err := os.ReadFile(deploymentDir + "/.env") + if err != nil { + t.Fatalf("expected .env to be written: %v", err) + } + text := string(content) + if !strings.Contains(text, "APP_NAME=my-laravel") { + t.Errorf(".env should contain the deployment name:\n%s", text) + } + if !strings.Contains(text, "APP_KEY=base64:") { + t.Errorf(".env should contain a generated APP_KEY:\n%s", text) + } + if !strings.Contains(text, "DB_HOST=shared-mysql") { + t.Errorf(".env should contain the database host:\n%s", text) + } +} + +func TestMainServiceImage(t *testing.T) { + compose := `services: + db: + image: mysql:8 + app: + image: ghcr.io/me/app:1 +` + if got := mainServiceImage(compose); got != "ghcr.io/me/app:1" { + t.Errorf("mainServiceImage = %q, want app service image", got) + } + + compose = `services: + web: + image: nginx:alpine +` + if got := mainServiceImage(compose); got != "nginx:alpine" { + t.Errorf("mainServiceImage = %q, want nginx:alpine", got) + } +} + func TestGenerateDeploymentComposePreservesDefaultTemplate(t *testing.T) { cfg := &config.Config{ DeploymentsPath: t.TempDir(), diff --git a/internal/api/system_terminal_interactive.go b/internal/api/system_terminal_interactive.go new file mode 100644 index 0000000..ad5cfa1 --- /dev/null +++ b/internal/api/system_terminal_interactive.go @@ -0,0 +1,63 @@ +package api + +import ( + "os" + "os/exec" + "path/filepath" + + "github.com/flatrun/agent/pkg/models" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// systemTerminalInteractive runs the host shell under a real PTY over a +// websocket, using the same stream protocol as the container exec terminal. +// The JSON command endpoint stays as the API surface for programmatic use. +func (s *Server) systemTerminalInteractive(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + if !s.authenticateSystemTerminal(c, conn) { + return + } + if s.config != nil && s.config.SystemTerminal.ProtectedMode.Enabled && s.config.SystemTerminal.ProtectedMode.DisableTerminal { + sendTerminalError(conn, "protected_mode", "System terminal access is disabled by global protected mode settings") + return + } + if s.authMiddleware != nil && s.authMiddleware.IsAuthEnabled() { + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"auth_success"}`)); err != nil { + return + } + } + + cmd := exec.Command(hostShell(), "-l") + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + if s.config != nil && s.config.DeploymentsPath != "" { + if abs, err := filepath.Abs(s.config.DeploymentsPath); err == nil { + cmd.Dir = abs + } + } + + guard := func(command string) (bool, *models.ProtectedCommandRule, error) { + if s.config == nil { + return false, nil, nil + } + return protectedCommandBlocked(&s.config.SystemTerminal.ProtectedMode, command) + } + streamPTY(conn, cmd, guard) +} + +func hostShell() string { + if shell := os.Getenv("SHELL"); shell != "" { + return shell + } + for _, shell := range []string{"/bin/bash", "/bin/sh"} { + if _, err := os.Stat(shell); err == nil { + return shell + } + } + return "sh" +} diff --git a/internal/api/system_terminal_interactive_test.go b/internal/api/system_terminal_interactive_test.go new file mode 100644 index 0000000..4cf3669 --- /dev/null +++ b/internal/api/system_terminal_interactive_test.go @@ -0,0 +1,148 @@ +package api + +import ( + "encoding/json" + "os/exec" + "strings" + "testing" + "time" + + "github.com/creack/pty" + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func dialSystemTerminalInteractive(t *testing.T, cfg *config.Config) *websocket.Conn { + t.Helper() + gin.SetMode(gin.TestMode) + + server := &Server{config: cfg} + router := gin.New() + router.GET("/system/terminal/interactive", server.systemTerminalInteractive) + + httpServer := newSkippableHTTPServer(t, router) + t.Cleanup(httpServer.Close) + + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/system/terminal/interactive" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial websocket: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +func requirePTY(t *testing.T) { + t.Helper() + cmd := exec.Command("sh", "-c", "true") + ptmx, err := pty.Start(cmd) + if err != nil { + t.Skipf("PTY not available in this environment: %v", err) + } + ptmx.Close() + _ = cmd.Wait() +} + +// readUntil collects frames until the predicate matches or the deadline hits. +func readUntil(t *testing.T, conn *websocket.Conn, want func(string) bool) string { + t.Helper() + var all strings.Builder + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, message, err := conn.ReadMessage() + if err != nil { + continue + } + all.Write(message) + if want(all.String()) { + return all.String() + } + } + return all.String() +} + +func TestSystemTerminalInteractiveDisabledByProtectedMode(t *testing.T) { + cfg := &config.Config{ + SystemTerminal: config.SystemTerminalConfig{ + ProtectedMode: models.ProtectedModeConfig{ + Enabled: true, + DisableTerminal: true, + }, + }, + } + conn := dialSystemTerminalInteractive(t, cfg) + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, message, err := conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read websocket response: %v", err) + } + + var payload struct { + Type string `json:"type"` + Code string `json:"code"` + } + if err := json.Unmarshal(message, &payload); err != nil { + t.Fatalf("expected JSON error payload, got %q", message) + } + if payload.Type != "error" || payload.Code != "protected_mode" { + t.Fatalf("expected protected_mode error, got %q", message) + } +} + +func TestSystemTerminalInteractiveRunsShellUnderPTY(t *testing.T) { + requirePTY(t) + + conn := dialSystemTerminalInteractive(t, &config.Config{DeploymentsPath: t.TempDir()}) + + // tty only succeeds with a real terminal attached; piped execution + // prints "not a tty" instead of a device path. + if err := conn.WriteMessage(websocket.TextMessage, []byte("tty && echo flatrun-pty-ok\r")); err != nil { + t.Fatalf("failed to send command: %v", err) + } + + output := readUntil(t, conn, func(s string) bool { return strings.Contains(s, "flatrun-pty-ok") }) + if !strings.Contains(output, "flatrun-pty-ok") { + t.Fatalf("shell did not run under a tty, output:\n%s", output) + } + if strings.Contains(output, "not a tty") { + t.Fatalf("shell reports no tty, output:\n%s", output) + } +} + +func TestSystemTerminalInteractiveBlocksProtectedCommands(t *testing.T) { + requirePTY(t) + + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + SystemTerminal: config.SystemTerminalConfig{ + ProtectedMode: models.ProtectedModeConfig{ + Enabled: true, + BlockedCommandRules: []models.ProtectedCommandRule{ + {ID: "no-secrets", Name: "No secret reads", Match: "contains", Pattern: "cat /etc/shadow"}, + }, + }, + }, + } + conn := dialSystemTerminalInteractive(t, cfg) + + if err := conn.WriteMessage(websocket.TextMessage, []byte("cat /etc/shadow\r")); err != nil { + t.Fatalf("failed to send command: %v", err) + } + output := readUntil(t, conn, func(s string) bool { return strings.Contains(s, "Command blocked") }) + if !strings.Contains(output, "Command blocked") { + t.Fatalf("expected the command to be blocked, output:\n%s", output) + } + + // A harmless command must still run after the block. + if err := conn.WriteMessage(websocket.TextMessage, []byte("echo flatrun-still-alive\r")); err != nil { + t.Fatalf("failed to send follow-up command: %v", err) + } + output = readUntil(t, conn, func(s string) bool { return strings.Contains(s, "flatrun-still-alive") }) + if !strings.Contains(output, "flatrun-still-alive") { + t.Fatalf("terminal did not survive the blocked command, output:\n%s", output) + } +} diff --git a/internal/docker/discovery.go b/internal/docker/discovery.go index 9208914..dd72881 100644 --- a/internal/docker/discovery.go +++ b/internal/docker/discovery.go @@ -377,8 +377,12 @@ type MountOwnership struct { } // ApplyMountOwnership sets ownership and creates subdirectories for bind mounts. -// When User is specified (UID:GID format), directories are chowned to that user. -// When User is empty, directories are chmod'd to 0777 as a fallback for non-template deploys. +// When User is specified (UID:GID format), the mount is chowned recursively to +// that user so intermediate directories and pre-existing content end up owned +// by the container too. When User is empty, directories are chmod'd to 0777 as +// a fallback for non-template deploys. A host path that already exists as a +// regular file (e.g. a generated .env) is only chowned, never turned into a +// directory. func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwnership) error { for _, m := range mounts { base := m.HostPath @@ -386,6 +390,24 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn base = filepath.Join(deploymentPath, base) } + var uid, gid int + if m.User != "" { + var err error + uid, gid, err = parseUIDGID(m.User) + if err != nil { + return fmt.Errorf("parse user %q: %w", m.User, err) + } + } + + if info, err := os.Stat(base); err == nil && !info.IsDir() { + if m.User != "" { + if err := os.Chown(base, uid, gid); err != nil { + return fmt.Errorf("chown %s: %w", base, err) + } + } + continue + } + if err := os.MkdirAll(base, 0755); err != nil { return fmt.Errorf("create mount dir %s: %w", base, err) } @@ -400,14 +422,14 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn } if m.User != "" { - uid, gid, err := parseUIDGID(m.User) - if err != nil { - return fmt.Errorf("parse user %q: %w", m.User, err) - } - for _, dir := range dirs { - if err := os.Chown(dir, uid, gid); err != nil { - return fmt.Errorf("chown %s: %w", dir, err) + err := filepath.WalkDir(base, func(path string, _ os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr } + return os.Chown(path, uid, gid) + }) + if err != nil { + return fmt.Errorf("chown %s: %w", base, err) } } else { for _, dir := range dirs { diff --git a/internal/docker/discovery_test.go b/internal/docker/discovery_test.go index e65337f..5433b3d 100644 --- a/internal/docker/discovery_test.go +++ b/internal/docker/discovery_test.go @@ -306,6 +306,64 @@ func TestApplyMountOwnership(t *testing.T) { t.Fatal("Expected error for invalid user format") } }) + + 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 { + t.Fatalf("Failed to write env file: %v", err) + } + + user := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + mounts := []MountOwnership{ + { + HostPath: "./.env", + User: user, + }, + } + + if err := d.ApplyMountOwnership(deploymentPath, mounts); err != nil { + t.Fatalf("ApplyMountOwnership failed: %v", err) + } + + info, err := os.Stat(envPath) + if err != nil { + t.Fatalf("Expected env file to still exist: %v", err) + } + if info.IsDir() { + t.Error("Expected env mount to stay a regular file, got a directory") + } + }) + + t.Run("chowns recursively including pre-existing content", func(t *testing.T) { + base := filepath.Join(deploymentPath, "data") + nested := filepath.Join(base, "deep", "dir") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatalf("Failed to create nested dirs: %v", err) + } + if err := os.WriteFile(filepath.Join(nested, "file.txt"), []byte("x"), 0644); err != nil { + t.Fatalf("Failed to write nested file: %v", err) + } + + user := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + mounts := []MountOwnership{ + { + HostPath: "./data", + User: user, + Subdirectories: []string{"extra"}, + }, + } + + if err := d.ApplyMountOwnership(deploymentPath, mounts); err != nil { + t.Fatalf("ApplyMountOwnership failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(base, "extra")); err != nil { + t.Errorf("Expected subdirectory to be created: %v", err) + } + if _, err := os.Stat(filepath.Join(nested, "file.txt")); err != nil { + t.Errorf("Expected pre-existing file to survive: %v", err) + } + }) } func TestParseUIDGID(t *testing.T) { diff --git a/internal/docker/image.go b/internal/docker/image.go new file mode 100644 index 0000000..56f53c3 --- /dev/null +++ b/internal/docker/image.go @@ -0,0 +1,54 @@ +package docker + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "os/exec" + "strings" + "time" +) + +// ExtractFileFromImage reads a single file from a container image without +// running it, by creating a stopped container and copying the file out. +// Missing images are pulled, so the call is bounded by a timeout. +func ExtractFileFromImage(image, path string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + createOut, err := exec.CommandContext(ctx, "docker", "create", image).Output() + if err != nil { + return nil, fmt.Errorf("create container from %s: %w", image, err) + } + containerID := strings.TrimSpace(string(createOut)) + defer exec.Command("docker", "rm", "-f", containerID).Run() + + cpOut, err := exec.CommandContext(ctx, "docker", "cp", containerID+":"+path, "-").Output() + if err != nil { + return nil, fmt.Errorf("copy %s from %s: %w", path, image, err) + } + + // docker cp to stdout emits a tar stream containing the requested file. + reader := tar.NewReader(bytes.NewReader(cpOut)) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read tar from %s: %w", image, err) + } + if header.Typeflag != tar.TypeReg { + continue + } + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read %s from %s: %w", path, image, err) + } + return content, nil + } + + return nil, fmt.Errorf("no regular file at %s in %s", path, image) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index e31a0f6..9b96cb0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -41,6 +41,12 @@ type Config struct { Cleanup CleanupConfig `yaml:"cleanup"` Plans PlansConfig `yaml:"plans"` AI AIConfig `yaml:"ai"` + Files FilesConfig `yaml:"files"` +} + +type FilesConfig struct { + // Pointer so an explicit false survives reloads; nil means "use default" (true). + ShowHidden *bool `yaml:"show_hidden" json:"show_hidden"` } type AIConfig struct { @@ -426,6 +432,11 @@ func setDefaults(cfg *Config) { if cfg.Plans.RetentionDays == 0 { cfg.Plans.RetentionDays = 30 } + // Files defaults + if cfg.Files.ShowHidden == nil { + showHidden := true + cfg.Files.ShowHidden = &showHidden + } // Cluster defaults if cfg.Cluster.ServerName == "" { hostname, err := os.Hostname() diff --git a/pkg/config/registry_test.go b/pkg/config/registry_test.go index b3defaa..622a560 100644 --- a/pkg/config/registry_test.go +++ b/pkg/config/registry_test.go @@ -119,6 +119,39 @@ func TestGetReturnsEntry(t *testing.T) { } } +func TestFilesShowHiddenDefaultsTrueAndAcceptsFalse(t *testing.T) { + cfg := &Config{} + setDefaults(cfg) + + e, err := Get(cfg, "files.show_hidden") + if err != nil { + t.Fatalf("Get files.show_hidden: %v", err) + } + if e.Type != "bool" { + t.Errorf("type = %q, want bool", e.Type) + } + if v, _ := e.Value.(bool); !v { + t.Errorf("value = %v, want true", e.Value) + } + if d, _ := e.Default.(bool); !d { + t.Errorf("default = %v, want true", e.Default) + } + + if err := Set(cfg, "files.show_hidden", false); err != nil { + t.Fatalf("Set files.show_hidden: %v", err) + } + if cfg.Files.ShowHidden == nil || *cfg.Files.ShowHidden { + t.Errorf("files.show_hidden = %v, want false", cfg.Files.ShowHidden) + } + + // An explicit false must survive a save/load round trip instead of + // being flipped back to the default. + setDefaults(cfg) + if cfg.Files.ShowHidden == nil || *cfg.Files.ShowHidden { + t.Errorf("files.show_hidden reset to %v after setDefaults, want false", cfg.Files.ShowHidden) + } +} + func entryKeys(entries []Entry) []string { out := make([]string, 0, len(entries)) for _, e := range entries { diff --git a/templates/laravel/metadata.yml b/templates/laravel/metadata.yml index 1091336..54cd91c 100644 --- a/templates/laravel/metadata.yml +++ b/templates/laravel/metadata.yml @@ -32,10 +32,11 @@ mounts: container_path: /app description: Laravel application source code type: file - required: true + required: false user: "1000:1000" - id: env name: Environment Config + host_path: ./.env container_path: /app/.env description: Laravel environment configuration file type: file @@ -46,31 +47,49 @@ mounts: container_path: /app/storage description: Logs, cache, and file uploads type: file - required: false + required: true user: "1000:1000" subdirectories: + - app/public + - app/private - framework/cache - framework/sessions + - framework/testing - framework/views - logs -files: - - path: .env - content: | - APP_NAME=${NAME} - APP_ENV=production - APP_DEBUG=false - APP_URL=http://localhost + - id: bootstrap-cache + name: Bootstrap Cache + host_path: ./bootstrap/cache + container_path: /app/bootstrap/cache + description: Compiled framework files + type: file + required: true + user: "1000:1000" +env: + file: .env + example_path: /app/.env.example + template: | + APP_NAME=${NAME} + APP_ENV=production + APP_KEY= + APP_DEBUG=false + APP_URL=http://localhost - DB_CONNECTION=mysql - DB_HOST=${DB_HOST} - DB_PORT=${DB_PORT} - DB_DATABASE=${DB_DATABASE} - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} + DB_CONNECTION=mysql + DB_HOST= + DB_PORT= + DB_DATABASE= + DB_USERNAME= + DB_PASSWORD= - CACHE_DRIVER=file - SESSION_DRIVER=file - QUEUE_CONNECTION=sync + CACHE_DRIVER=file + SESSION_DRIVER=file + QUEUE_CONNECTION=sync + secrets: + - key: APP_KEY + prefix: "base64:" + encoding: base64 + bytes: 32 backup: container_paths: - service: app diff --git a/templates/templates.go b/templates/templates.go index b974f4d..5168682 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -2,10 +2,13 @@ package templates import ( "bytes" + "crypto/sha256" "embed" + "encoding/hex" "io/fs" "net/netip" "path/filepath" + "sort" "strings" "text/template" ) @@ -70,6 +73,27 @@ func listInfraTemplates() ([]string, error) { return templates, nil } +// Checksum identifies the embedded template set, so on-disk copies can +// be detected as stale after an agent upgrade. +func Checksum() string { + ids, err := List() + if err != nil { + return "" + } + sort.Strings(ids) + h := sha256.New() + for _, id := range ids { + h.Write([]byte(id)) + if metadata, err := GetMetadata(id); err == nil { + h.Write(metadata) + } + if compose, err := GetCompose(id); err == nil { + h.Write(compose) + } + } + return hex.EncodeToString(h.Sum(nil)) +} + func GetMetadata(name string) ([]byte, error) { return FS.ReadFile(filepath.Join(name, "metadata.yml")) } diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 93d91fd..5df67e5 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -96,8 +96,13 @@ func TestMain(m *testing.M) { } func prepareDirectories() error { - _ = os.RemoveAll(deploymentsPath) - _ = os.RemoveAll(certsPath) + // The agent container runs as root and leaves root-owned state behind; + // starting on top of it makes unrelated tests fail in confusing ways. + for _, dir := range []string{deploymentsPath, certsPath} { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("leftover state at %s cannot be removed (root-owned files from a previous run; remove the directory manually): %w", dir, err) + } + } if err := os.MkdirAll(deploymentsPath+"/nginx/conf.d", 0755); err != nil { return fmt.Errorf("failed to create deployments directory: %w", err)