Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e21bad4
feat: Add agent sync
maximbigler May 11, 2026
a4e6090
fix: Folder Permission
maximbigler May 11, 2026
c7a0f73
fix: Falls positive in deploy_test.go
maximbigler May 11, 2026
afb1c2e
fix: Check for unsafe application path
maximbigler May 12, 2026
2c32bde
feat: Add managed by label
maximbigler May 12, 2026
3e26e72
fix: Deploying button in applications details
maximbigler May 12, 2026
27853e2
feat: Remove custom deployments dir
maximbigler May 12, 2026
6c1a0eb
docs: Add todo for websocket send logic in agent
maximbigler May 12, 2026
69f7b2b
feat: Move DeploymentInProgress into deployer
maximbigler May 12, 2026
8a2c1b0
fix: Path injection in deploy
maximbigler May 12, 2026
c50e224
fix: Golangci-lint error
maximbigler May 12, 2026
a180044
feat: Change the application status sync
maximbigler May 13, 2026
3016ff0
feat: Split websocket and deployment state
maximbigler May 13, 2026
614c48a
fix: go backend tests
maximbigler May 13, 2026
65c273a
Fix: hub.go formatting
maximbigler May 13, 2026
6e27bea
Merge branch 'main' into feature/add-agent-sync
maximbigler May 13, 2026
4c994d3
feat: Add test for application deploy
maximbigler May 13, 2026
197e154
feat: Add test for hub websocket handler
maximbigler May 18, 2026
92f0412
fix: go linting unused property
maximbigler May 18, 2026
f0ca704
fix: E2E tests
timokoessler May 19, 2026
117b748
fix: False positive in application name in fileUitls
maximbigler May 19, 2026
b0dc066
fix: Docker compose agent volume
maximbigler May 19, 2026
262b9a6
feat: Add configurable deployment dir for dev
maximbigler May 19, 2026
14dee41
refactor: Agent sending method
maximbigler May 20, 2026
0199182
docs: Add todo for deploy manager in hub
maximbigler May 20, 2026
d0801a8
add tests for context claims functions
maximbigler May 20, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
working-directory: ./e2e

- name: Install Playwright Browsers
run: pnpx playwright install --with-deps chromium
run: pnpm exec playwright install --with-deps chromium
working-directory: ./e2e

- name: Run E2E tests
Expand Down
36 changes: 20 additions & 16 deletions backend/internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ type FatalConfigError struct {
func (e *FatalConfigError) Error() string { return e.Msg }

type Config struct {
LogLevel zerolog.Level
LogJSON bool
HubUrl string
AuthToken string
AgentID string
HubPublicKey ed25519.PublicKey
HealthPort string
LogLevel zerolog.Level
LogJSON bool
HubUrl string
AuthToken string
AgentID string
HubPublicKey ed25519.PublicKey
HealthPort string
DeploymentsDir string
Comment thread
maximbigler marked this conversation as resolved.
Comment thread
maximbigler marked this conversation as resolved.
}

func DefaultConfig() (Config, error) {
Expand Down Expand Up @@ -76,13 +77,14 @@ func DefaultConfig() (Config, error) {
}

return Config{
LogLevel: logLevel,
LogJSON: logJSON,
HubUrl: hubUrl,
AuthToken: authToken,
AgentID: agentID,
HubPublicKey: hubPublicKey,
HealthPort: healthPort,
LogLevel: logLevel,
LogJSON: logJSON,
HubUrl: hubUrl,
AuthToken: authToken,
AgentID: agentID,
HubPublicKey: hubPublicKey,
HealthPort: healthPort,
DeploymentsDir: "/deployments",
}, nil
}

Expand Down Expand Up @@ -149,7 +151,7 @@ func Run(cfg Config) error {

Log.Info().Str("version", version.Version).Msg("agent started")

dockerClient, err := docker.New(Log)
dockerClient, err := docker.New(Log, cfg.DeploymentsDir)
if err != nil {
return fmt.Errorf("docker init: %w", err)
}
Expand All @@ -174,6 +176,7 @@ func Run(cfg Config) error {
return err
}
wsConnected.Store(true)
sender := newMessageSender(conn, session)

for {
_, data, readErr := conn.ReadMessage()
Expand All @@ -193,13 +196,14 @@ func Run(cfg Config) error {
return err
}
wsConnected.Store(true)
sender = newMessageSender(conn, session)
continue
}
msg := &messages.ServerMessage{}
if err := proto.Unmarshal(data, msg); err != nil {
Log.Error().Err(err).Msg("unmarshal error")
continue
}
handleServerMessage(msg, conn, session)
handleServerMessage(ctx, msg, session, sender, dockerClient)
}
}
6 changes: 6 additions & 0 deletions backend/internal/agent/agent_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func TestDefaultConfig_Valid(t *testing.T) {
if len(cfg.HubPublicKey) != ed25519.PublicKeySize {
t.Errorf("HubPublicKey length = %d, want %d", len(cfg.HubPublicKey), ed25519.PublicKeySize)
}
if cfg.DeploymentsDir != "/deployments" {
t.Errorf("DeploymentsDir = %q, want %q", cfg.DeploymentsDir, "/deployments")
}
}

func TestDefaultConfig_Defaults(t *testing.T) {
Expand All @@ -88,6 +91,9 @@ func TestDefaultConfig_Defaults(t *testing.T) {
if cfg.LogJSON {
t.Error("LogJSON = true, want false by default")
}
if cfg.DeploymentsDir != "/deployments" {
t.Errorf("DeploymentsDir = %q, want %q", cfg.DeploymentsDir, "/deployments")
}
}

func TestDefaultConfig_LogLevels(t *testing.T) {
Expand Down
109 changes: 109 additions & 0 deletions backend/internal/agent/docker/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package docker

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/OrcaCD/orca-cd/internal/shared/utils"
composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v5/pkg/api"
)

const (
composeFileName = "compose.yaml"
deployWaitTimeout = 2 * time.Minute
)

type DeployRequest struct {
ApplicationID string
ApplicationName string
ComposeFile string
}

var loadProject = func(ctx context.Context, composeService api.Compose, options api.ProjectLoadOptions) (*composetypes.Project, error) {
return composeService.LoadProject(ctx, options)
}

var upProject = func(ctx context.Context, composeService api.Compose, project *composetypes.Project, options api.UpOptions) error {
return composeService.Up(ctx, project, options)
}

func (c *Client) Deploy(ctx context.Context, req DeployRequest) error {
if c.compose == nil {
return errors.New("docker compose service is not initialized")
}
if c.deploymentsDir == "" {
return errors.New("deployments directory is not configured")
}
if !c.Ready() {
return errors.New("docker daemon is not ready")
}

if req.ComposeFile == "" {
return errors.New("compose file is empty")
}

// Validate application name to prevent path traversal
if err := utils.DoesNotLookLikeFilePath(req.ApplicationName); err != nil {
return fmt.Errorf("invalid application name: %w", err)
}

applicationDir := filepath.Join(c.deploymentsDir, req.ApplicationName)
composePath := filepath.Join(applicationDir, composeFileName)

// Verify the final path stays within the deployments directory
if err := utils.IsPathWithinBase(c.deploymentsDir, composePath); err != nil {
return fmt.Errorf("invalid compose file path: %w", err)
}

if err := os.MkdirAll(applicationDir, 0o750); err != nil {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return fmt.Errorf("create deployment directory: %w", err)
Comment thread
maximbigler marked this conversation as resolved.
}
if err := os.WriteFile(composePath, []byte(req.ComposeFile), 0o600); err != nil {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return fmt.Errorf("write compose file: %w", err)
}

project, err := loadProject(ctx, c.compose, api.ProjectLoadOptions{
ProjectName: strings.ToLower(req.ApplicationName),
ConfigPaths: []string{composePath},
WorkingDir: applicationDir,
})
if err != nil {
return fmt.Errorf("load compose project: %w", err)
}

// Add OrcaCD managed label to all services
for _, service := range project.Services {
if service.Labels == nil {
service.Labels = make(map[string]string)
}
service.Labels["managed_by"] = "orca-cd"
Comment thread
timokoessler marked this conversation as resolved.
}

if err := upProject(ctx, c.compose, project, api.UpOptions{
Create: api.CreateOptions{
RemoveOrphans: true,
Recreate: api.RecreateDiverged,
RecreateDependencies: api.RecreateDiverged,
},
Start: api.StartOptions{
Wait: true,
WaitTimeout: deployWaitTimeout,
},
}); err != nil {
return fmt.Errorf("compose up: %w", err)
}

c.log.Info().
Str("application_id", req.ApplicationID).
Str("application_name", req.ApplicationName).
Str("compose_path", composePath).
Msg("deployment completed")

return nil
}
111 changes: 111 additions & 0 deletions backend/internal/agent/docker/deploy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package docker

import (
"context"
"os"
"path/filepath"
"testing"

composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v5/pkg/api"
)

func TestDeploy_WritesComposeFileAndRunsComposeUp(t *testing.T) {
c := newTestClient(t)
c.deploymentsDir = t.TempDir()

originalLoadProject := loadProject
originalUpProject := upProject
t.Cleanup(func() {
loadProject = originalLoadProject
upProject = originalUpProject
})

var gotLoadOptions api.ProjectLoadOptions
loadProject = func(_ context.Context, _ api.Compose, options api.ProjectLoadOptions) (*composetypes.Project, error) {
gotLoadOptions = options
return &composetypes.Project{Name: options.ProjectName}, nil
}

var gotUpOptions api.UpOptions
upProject = func(_ context.Context, _ api.Compose, project *composetypes.Project, options api.UpOptions) error {
if project.Name != "billing" {
t.Fatalf("expected project name %q, got %q", "billing", project.Name)
}
gotUpOptions = options
return nil
}

req := DeployRequest{
ApplicationID: "app-123",
ApplicationName: "billing",
ComposeFile: "services:\n app:\n image: ghcr.io/orcacd/app:1.0.0\n",
}

if err := c.Deploy(t.Context(), req); err != nil {
t.Fatalf("Deploy: %v", err)
}

composePath := filepath.Join(c.deploymentsDir, req.ApplicationName, composeFileName)
//nolint:gosec // composePath is built from t.TempDir() and a fixed test application id
content, err := os.ReadFile(composePath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(content) != req.ComposeFile {
t.Fatalf("expected compose file to be written to deployment volume")
}

if gotLoadOptions.ProjectName != "billing" {
t.Fatalf("expected load project name %q, got %q", "billing", gotLoadOptions.ProjectName)
}
if len(gotLoadOptions.ConfigPaths) != 1 || gotLoadOptions.ConfigPaths[0] != composePath {
t.Fatalf("unexpected config paths: %#v", gotLoadOptions.ConfigPaths)
}
if gotLoadOptions.WorkingDir != filepath.Join(c.deploymentsDir, req.ApplicationName) {
t.Fatalf("expected working dir %q, got %q", filepath.Join(c.deploymentsDir, req.ApplicationName), gotLoadOptions.WorkingDir)
}

if !gotUpOptions.Start.Wait {
t.Fatal("expected compose up to wait for services to become ready")
}
if gotUpOptions.Start.WaitTimeout != deployWaitTimeout {
t.Fatalf("expected wait timeout %s, got %s", deployWaitTimeout, gotUpOptions.Start.WaitTimeout)
}
if !gotUpOptions.Create.RemoveOrphans {
t.Fatal("expected compose up to remove orphaned containers")
}
if gotUpOptions.Create.Recreate != api.RecreateDiverged {
t.Fatalf("expected recreate strategy %q, got %q", api.RecreateDiverged, gotUpOptions.Create.Recreate)
}
}

func TestDeploy_RejectsUnsafeApplicationName(t *testing.T) {
c := newTestClient(t)
c.deploymentsDir = t.TempDir()

err := c.Deploy(t.Context(), DeployRequest{
ApplicationID: "019e1ce8-7938-71b8-be55-4b184f307a2d",
ApplicationName: "../bad",
ComposeFile: "services: {}\n",
})
if err == nil {
t.Fatal("expected deploy to reject unsafe application names")
}
err2 := c.Deploy(t.Context(), DeployRequest{
ApplicationID: "019e1ce8-7938-71b8-be55-4b184f307a2d",
ApplicationName: "test/orcacd-docs",
ComposeFile: "services: {}\n",
})
if err2 == nil {
t.Fatal("expected deploy to reject unsafe application names")
}
err3 := c.Deploy(t.Context(), DeployRequest{
ApplicationID: "019e1ce8-7938-71b8-be55-4b184f307a2d",
ApplicationName: "bad/../name",
ComposeFile: "services: {}\n",
})
if err3 == nil {
t.Fatal("expected deploy to reject unsafe application names")
}
}
28 changes: 15 additions & 13 deletions backend/internal/agent/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ import (
const daemonCheckInterval = 5 * time.Second

type Client struct {
mu sync.RWMutex // protects ready
log zerolog.Logger
cli command.Cli
compose api.Compose
ready bool
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex // protects ready
log zerolog.Logger
cli command.Cli
compose api.Compose
deploymentsDir string
ready bool
ctx context.Context
cancel context.CancelFunc
}

func New(log zerolog.Logger) (*Client, error) {
func New(log zerolog.Logger, deploymentsDir string) (*Client, error) {
dockerCLI, err := command.NewDockerCli()
if err != nil {
return nil, err
Expand All @@ -43,11 +44,12 @@ func New(log zerolog.Logger) (*Client, error) {

ctx, cancel := context.WithCancel(context.Background())
c := &Client{
log: log,
cli: dockerCLI,
compose: composeSvc,
ctx: ctx,
cancel: cancel,
log: log,
cli: dockerCLI,
compose: composeSvc,
deploymentsDir: deploymentsDir,
ctx: ctx,
cancel: cancel,
}

if c.pingDaemon() {
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/agent/docker/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

func newTestClient(t *testing.T) *Client {
t.Helper()
c, err := New(zerolog.Nop())
c, err := New(zerolog.Nop(), t.TempDir())
if err != nil {
t.Fatalf("New: %v", err)
}
Expand Down Expand Up @@ -50,7 +50,7 @@ func TestPingDaemon(t *testing.T) {
func TestNew_DaemonUnreachable(t *testing.T) {
t.Setenv("DOCKER_HOST", "tcp://localhost:1")

c, err := New(zerolog.Nop())
c, err := New(zerolog.Nop(), t.TempDir())
if err != nil {
t.Fatalf("New: %v", err)
}
Expand All @@ -62,7 +62,7 @@ func TestNew_DaemonUnreachable(t *testing.T) {
func TestPingDaemon_Unreachable(t *testing.T) {
t.Setenv("DOCKER_HOST", "tcp://localhost:1")

c, err := New(zerolog.Nop())
c, err := New(zerolog.Nop(), t.TempDir())
if err != nil {
t.Fatalf("New: %v", err)
}
Expand Down
Loading