Skip to content
Open
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
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.
}

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"
}

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
Loading