Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 55 additions & 10 deletions internal/api/authz.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package api

import (
"fmt"
"net/http"
"os/exec"
"strings"

"github.com/flatrun/agent/internal/auth"
"github.com/flatrun/agent/internal/contextkeys"
"github.com/flatrun/agent/pkg/models"
"github.com/gin-gonic/gin"
)

Expand Down Expand Up @@ -108,14 +110,14 @@ func (s *Server) requireContainerAccess(c *gin.Context, containerID, level strin
}
if actor.Role == auth.RoleAdmin {
// Admins can see missing-container errors; non-admins below get a non-enumerating 403.
if _, err := containerDeploymentName(containerID); err != nil {
if _, err := s.containerDeploymentName(containerID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return false
}
return true
}

deploymentName, err := containerDeploymentName(containerID)
deploymentName, err := s.containerDeploymentName(containerID)
if err != nil || deploymentName == "" {
c.JSON(http.StatusForbidden, gin.H{"error": "No access to this container"})
return false
Expand All @@ -135,25 +137,68 @@ func (s *Server) actorCanAccessContainer(c *gin.Context, containerID, level stri
return true
}

deploymentName, err := containerDeploymentName(containerID)
deploymentName, err := s.containerDeploymentName(containerID)
if err != nil || deploymentName == "" {
return false
}

return actor.CanAccessDeployment(deploymentName, level)
}

func containerDeploymentName(containerID string) (string, error) {
cmd := exec.Command("docker", "inspect", "--format", "{{ index .Config.Labels \""+composeProjectLabel+"\" }}", containerID)
func inspectContainerIdentity(containerID string) (string, string, string, error) {
format := "{{.Id}}\n{{.Name}}\n{{ index .Config.Labels \"" + composeProjectLabel + "\" }}"
cmd := exec.Command("docker", "inspect", "--format", format, containerID)
output, err := cmd.Output()
if err != nil {
return "", err
return "", "", "", err
}

deploymentName := strings.TrimSpace(string(output))
parts := strings.SplitN(strings.TrimSpace(string(output)), "\n", 3)
if len(parts) != 3 {
return "", "", "", fmt.Errorf("unexpected container inspection result")
}
canonicalID := strings.TrimSpace(parts[0])
containerName := strings.TrimPrefix(strings.TrimSpace(parts[1]), "/")
deploymentName := strings.TrimSpace(parts[2])
if deploymentName == "<no value>" {
return "", nil
deploymentName = ""
}
return canonicalID, containerName, deploymentName, nil
}

func (s *Server) containerDeploymentName(containerID string) (string, error) {
canonicalID, _, label, inspectErr := inspectContainerIdentity(containerID)
if s.manager == nil {
return label, inspectErr
}
if label != "" {
if deployment, err := s.manager.GetDeployment(label); err == nil && deploymentContainsContainer(deployment, canonicalID) {
return deployment.Name, nil
}
}
deployments, err := s.manager.FindDeployments()
if err != nil {
return "", err
}
for _, candidate := range deployments {
deployment, getErr := s.manager.GetDeployment(candidate.Name)
if getErr == nil && deploymentContainsContainer(deployment, canonicalID) {
return deployment.Name, nil
}
}
if inspectErr != nil {
return "", inspectErr
}
return "", fmt.Errorf("container does not belong to a deployment")
}

return deploymentName, nil
func deploymentContainsContainer(deployment *models.Deployment, containerID string) bool {
for _, service := range deployment.Services {
if len(service.ContainerID) < 12 || len(containerID) < 12 {
continue
}
if service.ContainerID == containerID || strings.HasPrefix(service.ContainerID, containerID) || strings.HasPrefix(containerID, service.ContainerID) {
return true
}
}
return false
}
11 changes: 11 additions & 0 deletions internal/api/authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ func actorMiddleware(actor *auth.ActorContext) gin.HandlerFunc {
}
}

func TestDeploymentContainsContainerRejectsShortPrefixes(t *testing.T) {
deployment := &models.Deployment{Services: []models.Service{{ContainerID: "abcdef123456"}}}

if deploymentContainsContainer(deployment, "abcdef") {
t.Fatal("short container ID matched a deployment container")
}
if !deploymentContainsContainer(deployment, "abcdef1234567890") {
t.Fatal("canonical container ID did not match its Docker short ID")
}
}

func TestClusterServiceCredentialsRejectUnscopedSensitiveResources(t *testing.T) {
gin.SetMode(gin.TestMode)
actor := &auth.ActorContext{
Expand Down
14 changes: 14 additions & 0 deletions internal/api/backup_handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"context"
"net/http"
"strconv"

Expand All @@ -10,6 +11,19 @@ import (
"github.com/gin-gonic/gin"
)

func (s *Server) retryBackupPublication(c *gin.Context) {
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
return
}
result, err := s.backupManager.RetryRemotePublication(context.Background(), c.Param("id"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"backup": result})
}

func (s *Server) listBackups(c *gin.Context) {
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
Expand Down
130 changes: 130 additions & 0 deletions internal/api/backup_reliability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/flatrun/agent/internal/backup"
"github.com/flatrun/agent/internal/docker"
"github.com/gin-gonic/gin"
)

func runFailingBackupThroughHTTP(t *testing.T, metadata, dockerScript string, setup func(string)) (backup.Job, string) {
t.Helper()
root := t.TempDir()
deploymentDir := filepath.Join(root, "app")
if err := os.MkdirAll(deploymentDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deploymentDir, "docker-compose.yml"), []byte("services:\n web:\n image: nginx\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deploymentDir, "service.yml"), []byte(metadata), 0644); err != nil {
t.Fatal(err)
}
if setup != nil {
setup(deploymentDir)
}

binDir := t.TempDir()
logPath := filepath.Join(binDir, "docker.log")
script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> " + logPath + "\n" + dockerScript + "\n"
if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))

backupManager, err := backup.NewManager(root)
if err != nil {
t.Fatal(err)
}
server := &Server{manager: docker.NewManager(root), backupManager: backupManager}
router := gin.New()
router.POST("/deployments/:name/backups", server.createDeploymentBackup)
router.GET("/deployments/:name/backups/jobs/:id", server.getBackupJob)

created := httptest.NewRecorder()
router.ServeHTTP(created, httptest.NewRequest(http.MethodPost, "/deployments/app/backups", nil))
if created.Code != http.StatusAccepted {
t.Fatalf("create status = %d, body = %s", created.Code, created.Body.String())
}
var createResponse struct {
JobID string `json:"job_id"`
}
if err := json.Unmarshal(created.Body.Bytes(), &createResponse); err != nil {
t.Fatal(err)
}

var job backup.Job
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/deployments/app/backups/jobs/"+createResponse.JobID, nil))
var body struct {
Job backup.Job `json:"job"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
job = body.Job
if job.Status == backup.JobStatusFailed {
break
}
time.Sleep(10 * time.Millisecond)
}
if job.Status != backup.JobStatusFailed {
t.Fatalf("job status = %s, want failed", job.Status)
}
logBytes, _ := os.ReadFile(logPath)
return job, string(logBytes)
}

func TestBackupRequiredDatabaseFailureThroughHTTP(t *testing.T) {
metadata := "name: app\nbackup:\n databases:\n - service: db\n type: unsupported\n post_hooks:\n - service: web\n command: resume\n"
job, dockerLog := runFailingBackupThroughHTTP(t, metadata, "exit 0", nil)
if len(job.ComponentResults) == 0 || job.ComponentResults[len(job.ComponentResults)-1].Status != backup.ResultStatusFailed {
t.Fatalf("component results = %#v", job.ComponentResults)
}
if !strings.Contains(dockerLog, "resume") {
t.Fatalf("cleanup invocation = %q", dockerLog)
}
}

func TestBackupPreparationFailureStillRunsCleanupThroughHTTP(t *testing.T) {
metadata := "name: app\nbackup:\n databases:\n - service: db\n type: unsupported\n pre_hooks:\n - service: web\n command: prepare\n post_hooks:\n - service: web\n command: resume\n"
_, dockerLog := runFailingBackupThroughHTTP(t, metadata, "case \"$*\" in *prepare*) exit 1;; *) exit 0;; esac", nil)
if !strings.Contains(dockerLog, "prepare") || !strings.Contains(dockerLog, "resume") {
t.Fatalf("hook invocations = %q", dockerLog)
}
}

func TestBackupRequiredFileFailureThroughHTTP(t *testing.T) {
metadata := "name: app\nbackup:\n databases:\n - service: db\n type: unsupported\n post_hooks:\n - service: web\n command: resume\n"
job, dockerLog := runFailingBackupThroughHTTP(t, metadata, "exit 0", func(deploymentDir string) {
dataDir := filepath.Join(deploymentDir, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(dataDir, "missing"), filepath.Join(dataDir, "broken")); err != nil {
t.Fatal(err)
}
})
fileFailed := false
for _, result := range job.ComponentResults {
if result.Kind == "files" && result.Status == backup.ResultStatusFailed {
fileFailed = true
}
}
if !fileFailed {
t.Fatalf("component results = %#v", job.ComponentResults)
}
if !strings.Contains(dockerLog, "resume") {
t.Fatalf("cleanup invocation = %q", dockerLog)
}
}
35 changes: 35 additions & 0 deletions internal/api/compose_validation_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,38 @@ networks:
t.Errorf("validateComposeContent with relative env_file in deployment dir = %v, want nil", err)
}
}

func TestValidateNewComposeContent_UsesSuppliedRequiredVariable(t *testing.T) {
s := &Server{config: &config.Config{Infrastructure: config.InfrastructureConfig{DefaultProxyNetwork: "proxy"}}}
compose := `name: required-env
services:
app:
image: ${APP_IMAGE:?APP_IMAGE is required}
`
if err := s.validateNewComposeContent(compose, "required-env", []EnvVar{{Key: "APP_IMAGE", Value: "nginx:alpine"}}, t.TempDir()); err != nil {
t.Fatalf("validation with supplied required variable: %v", err)
}
}

func TestValidateComposeContent_PrefersManagedEnvironment(t *testing.T) {
base := t.TempDir()
name := "managed-env"
deploymentDir := filepath.Join(base, name)
if err := os.MkdirAll(deploymentDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deploymentDir, ".env"), []byte("OTHER=value\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deploymentDir, ".env.flatrun"), []byte("APP_IMAGE=nginx:alpine\n"), 0600); err != nil {
t.Fatal(err)
}
s := &Server{
config: &config.Config{Infrastructure: config.InfrastructureConfig{DefaultProxyNetwork: "proxy"}},
manager: docker.NewManager(base),
}
compose := "services:\n app:\n image: ${APP_IMAGE:?APP_IMAGE is required}\n"
if err := s.validateComposeContent(compose, name); err != nil {
t.Fatalf("validation with managed environment: %v", err)
}
}
4 changes: 2 additions & 2 deletions internal/api/container_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (s *Server) containerExec(c *gin.Context) {
sendError(conn, "No access to this container")
return
}
if deploymentName, err := containerDeploymentName(containerID); err == nil && deploymentName != "" {
if deploymentName, err := s.containerDeploymentName(containerID); err == nil && deploymentName != "" {
if blocked, reason, err := s.protectedDeploymentActionBlocked(deploymentName, protectedActionTerminal); err != nil {
sendError(conn, "Failed to check protected mode: "+err.Error())
return
Expand Down Expand Up @@ -324,7 +324,7 @@ func (s *Server) containerExecHTTP(c *gin.Context) {
}

commandLine := strings.Join(append([]string{req.Command}, req.Args...), " ")
if deploymentName, err := containerDeploymentName(containerID); err == nil && deploymentName != "" {
if deploymentName, err := s.containerDeploymentName(containerID); err == nil && deploymentName != "" {
if blocked, reason, err := s.protectedDeploymentActionBlocked(deploymentName, protectedActionExec); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check protected mode: " + err.Error()})
return
Expand Down
Loading
Loading