Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion backend/internal/bootstrap/services_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config
svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings)
svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings)
svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade)
svcs.GitOpsSync = services.NewGitOpsSyncService(db, svcs.GitRepository, svcs.Project, svcs.Event, svcs.Settings)
svcs.GitOpsSync = services.NewGitOpsSyncService(db, svcs.GitRepository, svcs.Project, svcs.Swarm, svcs.Event, svcs.Settings)
svcs.Webhook = services.NewWebhookService(db, svcs.Container, svcs.Updater, svcs.Project, svcs.GitOpsSync, svcs.Event)

return svcs, dockerClient, nil
Expand Down
1 change: 1 addition & 0 deletions backend/internal/models/gitops_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type GitOpsSync struct {
Repository *GitRepository `json:"repository,omitempty" gorm:"foreignKey:RepositoryID"`
Branch string `json:"branch" sortable:"true" search:"branch,main,master,develop,feature,release"`
ComposePath string `json:"composePath" sortable:"true" search:"compose,docker-compose,path,file,yaml,yml"`
TargetType string `json:"targetType" gorm:"column:target_type;default:'project'"` // "project" or "swarm_stack"
ProjectName string `json:"projectName" sortable:"true" search:"project,name,stack,application,service"` // Name of project to create/update
ProjectID *string `json:"projectId,omitempty" sortable:"true"` // Set after project is created
Project *Project `json:"project,omitempty" gorm:"foreignKey:ProjectID"`
Expand Down
67 changes: 65 additions & 2 deletions backend/internal/services/gitops_sync_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ import (
"github.com/getarcaneapp/arcane/backend/pkg/projects"
"github.com/getarcaneapp/arcane/backend/pkg/utils/mapper"
"github.com/getarcaneapp/arcane/types/gitops"
"github.com/getarcaneapp/arcane/types/swarm"
"gorm.io/gorm"
)

type GitOpsSyncService struct {
db *database.DB
repoService *GitRepositoryService
projectService *ProjectService
swarmService *SwarmService
eventService *EventService
settingsService *SettingsService
}
Expand Down Expand Up @@ -118,11 +120,12 @@ func effectiveInt64Limit(syncLimit, environmentLimit int64) int64 {
}
}

func NewGitOpsSyncService(db *database.DB, repoService *GitRepositoryService, projectService *ProjectService, eventService *EventService, settingsService *SettingsService) *GitOpsSyncService {
func NewGitOpsSyncService(db *database.DB, repoService *GitRepositoryService, projectService *ProjectService, swarmService *SwarmService, eventService *EventService, settingsService *SettingsService) *GitOpsSyncService {
return &GitOpsSyncService{
db: db,
repoService: repoService,
projectService: projectService,
swarmService: swarmService,
eventService: eventService,
settingsService: settingsService,
}
Expand Down Expand Up @@ -291,6 +294,7 @@ func (s *GitOpsSyncService) CreateSync(ctx context.Context, environmentID string
RepositoryID: req.RepositoryID,
Branch: req.Branch,
ComposePath: req.ComposePath,
TargetType: req.TargetType,
ProjectName: projectName,
ProjectID: nil, // Will be set during first sync
AutoSync: false,
Expand Down Expand Up @@ -376,6 +380,9 @@ func (s *GitOpsSyncService) UpdateSync(ctx context.Context, environmentID, id st
if req.ComposePath != nil {
updates["compose_path"] = *req.ComposePath
}
if req.TargetType != nil {
updates["target_type"] = *req.TargetType
}
if req.ProjectName != nil {
updates["project_name"] = *req.ProjectName
}
Expand Down Expand Up @@ -492,6 +499,10 @@ func (s *GitOpsSyncService) PerformSync(ctx context.Context, environmentID, id s
return result, err
}

if sync.TargetType == "swarm_stack" {
return s.performSwarmStackSync(syncCtx, sync, id, actor, result, source)
}

if sync.SyncDirectory {
return s.performDirectorySync(syncCtx, sync, id, actor, result, source)
}
Expand Down Expand Up @@ -582,7 +593,11 @@ func (s *GitOpsSyncService) performDirectorySync(ctx context.Context, sync *mode

// performSingleFileSync preserves the legacy compose-only Git sync behavior.
func (s *GitOpsSyncService) performSingleFileSync(ctx context.Context, sync *models.GitOpsSync, id string, actor models.User, result *gitops.SyncResult, source *preparedSyncSource) (*gitops.SyncResult, error) {
slog.InfoContext(ctx, "Using single file sync mode", "syncId", id, "composePath", sync.ComposePath)
slog.InfoContext(ctx, "Using single file sync mode", "syncId", id, "composePath", sync.ComposePath, "targetType", sync.TargetType)

if sync.TargetType == "swarm_stack" {
return s.performSwarmStackSync(ctx, sync, id, actor, result, source)
}
Comment thread
SplinterHead marked this conversation as resolved.
Outdated

project, err := s.getOrCreateProjectInternal(ctx, sync, id, source.composeContent, source.envContent, result, actor)
if err != nil {
Expand All @@ -599,6 +614,54 @@ func (s *GitOpsSyncService) performSingleFileSync(ctx context.Context, sync *mod
return result, nil
}

// performSwarmStackSync executes a single file sync targeted at a Swarm Stack
func (s *GitOpsSyncService) performSwarmStackSync(ctx context.Context, sync *models.GitOpsSync, id string, actor models.User, result *gitops.SyncResult, source *preparedSyncSource) (*gitops.SyncResult, error) {
Comment thread
SplinterHead marked this conversation as resolved.
Outdated
slog.InfoContext(ctx, "Deploying Swarm Stack from GitOps sync", "syncId", id, "stackName", sync.ProjectName)

if s.swarmService == nil {
return result, s.failSync(ctx, id, result, sync, actor, "Swarm service is unavailable", "swarm service is unavailable")
}

envContent := ""
if source.envContent != nil {
envContent = *source.envContent
}

req := swarm.StackDeployRequest{
Name: sync.ProjectName,
ComposeContent: source.composeContent,
EnvContent: envContent,
Prune: true,
WorkingDir: filepath.Dir(filepath.Join(source.repoPath, sync.ComposePath)),
}

if _, err := s.swarmService.DeployStack(ctx, sync.EnvironmentID, req); err != nil {
return result, s.failSync(ctx, id, result, sync, actor, "Failed to deploy swarm stack", err.Error())
}

syncedFiles := []string{filepath.Base(sync.ComposePath)}
s.updateSyncStatusWithFiles(ctx, id, "success", "", source.commitHash, syncedFiles)
result.Success = true
result.Message = fmt.Sprintf("Successfully deployed swarm stack %s from %s", sync.ProjectName, sync.ComposePath)

// Log event
_, _ = s.eventService.CreateEvent(ctx, CreateEventRequest{
Type: models.EventTypeGitSyncRun,
Severity: models.EventSeveritySuccess,
Title: "Git sync completed for stack",
Description: fmt.Sprintf("Successfully synced '%s' to swarm stack '%s'", sync.Name, sync.ProjectName),
ResourceType: new("git_sync"),
ResourceID: new(sync.ID),
ResourceName: new(sync.Name),
UserID: new(actor.ID),
Username: new(actor.Username),
EnvironmentID: new(sync.EnvironmentID),
})

slog.InfoContext(ctx, "GitOps swarm stack sync completed", "syncId", id, "stack", sync.ProjectName)
return result, nil
}

// redeployIfRunningAfterSync redeploys a project only when it is already
// running and the latest sync actually changed managed content.
func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, project *models.Project, actor models.User, syncMode string) {
Expand Down
1 change: 1 addition & 0 deletions backend/internal/services/swarm_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,7 @@ func (s *SwarmService) DeployStack(ctx context.Context, environmentID string, re
},
Prune: req.Prune,
ResolveImage: req.ResolveImage,
WorkingDir: req.WorkingDir,
}); err != nil {
return nil, err
}
Expand Down
26 changes: 20 additions & 6 deletions backend/pkg/libarcane/swarm/stack_deploy_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ type StackDeployOptions struct {
RegistryAuthForImage func(context.Context, string) (string, error)
Prune bool
ResolveImage string
WorkingDir string
}

type StackRenderOptions struct {
Name string
ComposeContent string
EnvContent string
WorkingDir string
}

type StackRenderResult struct {
Expand Down Expand Up @@ -95,7 +97,7 @@ func DeployStack(ctx context.Context, dockerClient *dockerclient.Client, opts St
return err
}

project, err := loadComposeProject(ctx, stackName, opts.ComposeContent, opts.EnvContent)
project, err := loadComposeProject(ctx, stackName, opts.ComposeContent, opts.EnvContent, opts.WorkingDir)
if err != nil {
return err
}
Expand Down Expand Up @@ -245,7 +247,7 @@ func RenderStackConfig(ctx context.Context, opts StackRenderOptions) (*StackRend
return nil, errors.New("stack name is required")
}

project, err := loadComposeProject(ctx, stackName, opts.ComposeContent, opts.EnvContent)
project, err := loadComposeProject(ctx, stackName, opts.ComposeContent, opts.EnvContent, opts.WorkingDir)
if err != nil {
return nil, err
}
Expand All @@ -269,7 +271,7 @@ func RenderStackConfig(ctx context.Context, opts StackRenderOptions) (*StackRend
}, nil
}

func loadComposeProject(ctx context.Context, projectName, composeContent, envContent string) (*composegotypes.Project, error) {
func loadComposeProject(ctx context.Context, projectName, composeContent, envContent, providedWorkingDir string) (*composegotypes.Project, error) {
composeContent = strings.TrimSpace(composeContent)
if composeContent == "" {
return nil, errors.New("compose content is required")
Expand All @@ -280,9 +282,14 @@ func loadComposeProject(ctx context.Context, projectName, composeContent, envCon
return nil, fmt.Errorf("failed to parse env content: %w", err)
}

workingDir, err := os.Getwd()
if err != nil {
workingDir = "/tmp"
workingDir := strings.TrimSpace(providedWorkingDir)
if workingDir == "" {
cwd, err := os.Getwd()
if err != nil {
workingDir = "/tmp"
} else {
workingDir = cwd
}
}
envMap["PWD"] = workingDir

Expand Down Expand Up @@ -636,6 +643,13 @@ func updateSwarmService(
}

if _, err := dockerClient.ServiceUpdate(ctx, existing.ID, opts); err != nil {
if strings.Contains(err.Error(), "service does not have a previous spec") {
opts.RegistryAuthFrom = ""
if _, retryErr := dockerClient.ServiceUpdate(ctx, existing.ID, opts); retryErr != nil {
return fmt.Errorf("failed to update swarm service %s: %w", spec.Name, retryErr)
}
return nil
}
return fmt.Errorf("failed to update swarm service %s: %w", spec.Name, err)
}
return nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE gitops_syncs DROP COLUMN IF EXISTS target_type;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE gitops_syncs ADD COLUMN target_type VARCHAR(255) NOT NULL DEFAULT 'project';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE gitops_syncs DROP COLUMN target_type;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE gitops_syncs ADD COLUMN target_type TEXT NOT NULL DEFAULT 'project';
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
type GitOpsSyncFormProps = {
open: boolean;
syncToEdit: GitOpsSync | null;
targetType?: string;
onSubmit: (detail: { sync: GitOpsSyncCreateDto | GitOpsSyncUpdateDto; isEditMode: boolean }) => void;
isLoading: boolean;
};

let { open = $bindable(false), syncToEdit = $bindable(), onSubmit, isLoading }: GitOpsSyncFormProps = $props();
let { open = $bindable(false), syncToEdit = $bindable(), targetType, onSubmit, isLoading }: GitOpsSyncFormProps = $props();

let isEditMode = $derived(!!syncToEdit);
let showFileBrowser = $state(false);
Expand Down Expand Up @@ -106,6 +107,7 @@
repositoryId: selectedRepository?.value || data.repositoryId,
branch: data.branch,
composePath: data.composePath,
targetType,
projectName: data.name,
syncDirectory: data.syncDirectory,
autoSync: data.autoSync,
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/types/gitops.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface GitOpsSyncCreateDto {
repositoryId: string;
branch: string;
composePath: string;
targetType?: string;
projectName?: string;
autoSync?: boolean;
syncInterval?: number;
Expand All @@ -54,6 +55,7 @@ export interface GitOpsSyncUpdateDto {
repositoryId?: string;
branch?: string;
composePath?: string;
targetType?: string;
projectName?: string;
autoSync?: boolean;
syncInterval?: number;
Expand All @@ -71,6 +73,7 @@ export interface GitOpsSync {
repository?: GitRepository;
branch: string;
composePath: string;
targetType?: string;
projectName: string;
projectId?: string;
autoSync: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,18 @@
};
const syncCounts = $derived(syncs?.counts ?? syncCountsFallback);

let targetTypeFromQuery = $state<string | undefined>(undefined);

$effect(() => {
if (page.url.searchParams.get('action') === 'create') {
targetTypeFromQuery = page.url.searchParams.get('targetType') || undefined;
Comment thread
SplinterHead marked this conversation as resolved.
Outdated
// Use a small timeout to ensure the page is fully mounted and ready
setTimeout(() => {
openCreateSyncDialog();
// Remove the query param so it doesn't reopen on refresh
const newUrl = new URL(page.url);
newUrl.searchParams.delete('action');
newUrl.searchParams.delete('targetType');
goto(newUrl.toString(), { replaceState: true, keepFocus: true });
}, 100);
}
Expand Down Expand Up @@ -209,6 +213,7 @@
<GitOpsSyncFormSheet
bind:open={isSyncDialogOpen}
bind:syncToEdit
targetType={targetTypeFromQuery}
onSubmit={handleSyncDialogSubmit}
isLoading={isLoading.create || isLoading.edit}
/>
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/routes/(app)/swarm/stacks/new/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@
import { templateService } from '$lib/services/template-service.js';
import * as ButtonGroup from '$lib/components/ui/button-group/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { ArrowLeftIcon, TerminalIcon, CopyIcon, TemplateIcon, AddIcon, ArrowDownIcon as ChevronDown } from '$lib/icons';
import {
ArrowLeftIcon,
TerminalIcon,
CopyIcon,
TemplateIcon,
AddIcon,
ArrowDownIcon as ChevronDown,
GitBranchIcon
} from '$lib/icons';
import CodePanel from '../../../projects/components/CodePanel.svelte';
import EditableName from '../../../projects/components/EditableName.svelte';
import { environmentStore } from '$lib/stores/environment.store.svelte';

let { data } = $props();

Expand Down Expand Up @@ -280,6 +289,16 @@
<TerminalIcon class="size-4" />
{m.compose_convert_from_docker_run()}
</DropdownMenu.Item>
<DropdownMenu.Item
class={dropdownItemClass}
onclick={async () =>
goto(
`/environments/${await environmentStore.getCurrentEnvironmentId()}/gitops?action=create&targetType=swarm_stack`
)}
>
<GitBranchIcon class="size-4" />
{m.git_from_git_repo()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class={dropdownItemClass}
Expand Down
21 changes: 18 additions & 3 deletions types/gitops/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ type GitOpsSync struct {
// Required: true
ComposePath string `json:"composePath"`

// ProjectName is the name used to create/identify the project.
// TargetType indicates what entity is being deployed (e.g. "project" or "swarm_stack").
//
// Required: true
TargetType string `json:"targetType"`

// ProjectName is the name used to create/identify the project or stack.
//
// Required: true
ProjectName string `json:"projectName"`
Expand Down Expand Up @@ -313,7 +318,12 @@ type CreateSyncRequest struct {
// Required: true
ComposePath string `json:"composePath" binding:"required"`

// ProjectName is the name of the project to create/update.
// TargetType specifies if this sync targets a "project" or "swarm_stack".
//
// Required: false
TargetType string `json:"targetType,omitempty"`

// ProjectName is the name of the project or stack to create/update.
// The actual project will be created on first sync, and ProjectID will be set then.
// If not provided, defaults to the sync name.
//
Expand Down Expand Up @@ -381,7 +391,12 @@ type UpdateSyncRequest struct {
// Required: false
ComposePath *string `json:"composePath,omitempty"`

// ProjectName is the name of the project to create/update.
// TargetType specifies if this sync targets a "project" or "swarm_stack".
//
// Required: false
TargetType *string `json:"targetType,omitempty"`

// ProjectName is the name of the project or stack to create/update.
//
// Required: false
ProjectName *string `json:"projectName,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions types/swarm/stack_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type StackDeployRequest struct {
//
// Required: false
ResolveImage string `json:"resolveImage,omitempty"`

// WorkingDir defines the working directory context for evaluating compose files.
//
// Required: false
WorkingDir string `json:"workingDir,omitempty"`
}

// StackDeployResponse represents the result of a stack deployment.
Expand Down