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
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: 63 additions & 4 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,11 +499,15 @@ func (s *GitOpsSyncService) PerformSync(ctx context.Context, environmentID, id s
return result, err
}

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

if sync.SyncDirectory {
return s.performDirectorySync(syncCtx, sync, id, actor, result, source)
}

return s.performSingleFileSync(syncCtx, sync, id, actor, result, source)
return s.performSingleFileSyncInternal(syncCtx, sync, id, actor, result, source)
}

// prepareSyncSource clones the source repository, validates that the configured
Expand Down Expand Up @@ -580,8 +591,8 @@ func (s *GitOpsSyncService) performDirectorySync(ctx context.Context, sync *mode
return result, nil
}

// 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) {
// performSingleFileSyncInternal preserves the legacy compose-only Git sync behavior.
func (s *GitOpsSyncService) performSingleFileSyncInternal(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)

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

// performSwarmStackSyncInternal executes a single file sync targeted at a Swarm Stack
func (s *GitOpsSyncService) performSwarmStackSyncInternal(ctx context.Context, sync *models.GitOpsSync, id string, actor models.User, result *gitops.SyncResult, source *preparedSyncSource) (*gitops.SyncResult, error) {
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
2 changes: 1 addition & 1 deletion backend/internal/services/gitops_sync_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func setupGitOpsSyncDirectoryTestService(t *testing.T) (*GitOpsSyncService, *dat

projectService := NewProjectService(db, settingsService, nil, nil, nil, nil, config.Load())

return NewGitOpsSyncService(db, nil, projectService, nil, settingsService), db, projectsDir
return NewGitOpsSyncService(db, nil, projectService, nil, nil, settingsService), db, projectsDir
}

func TestGitOpsSyncService_SyncProjectDirectory_CreatesProjectPreservingRepoLayout(t *testing.T) {
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 @@ -249,7 +251,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 @@ -273,7 +275,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 @@ -284,9 +286,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 @@ -682,6 +689,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
15 changes: 13 additions & 2 deletions frontend/src/routes/(app)/environments/[id]/gitops/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,22 @@
};
const syncCounts = $derived(syncs?.counts ?? syncCountsFallback);

let targetTypeFromQuery = $derived(
page.url.searchParams.get('action') === 'create' ? (page.url.searchParams.get('targetType') ?? undefined) : undefined
);

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

$effect(() => {
if (page.url.searchParams.get('action') === 'create') {
const typeQuery = targetTypeFromQuery;
// Use a small timeout to ensure the page is fully mounted and ready
setTimeout(() => {
openCreateSyncDialog();
openCreateSyncDialog(typeQuery);
// 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 All @@ -70,13 +78,15 @@
});
}

function openCreateSyncDialog() {
function openCreateSyncDialog(targetType?: string) {
syncToEdit = null;
dialogTargetType = targetType;
isSyncDialogOpen = true;
}

function openEditSyncDialog(sync: GitOpsSync) {
syncToEdit = sync;
dialogTargetType = undefined;
isSyncDialogOpen = true;
}

Expand Down Expand Up @@ -209,6 +219,7 @@
<GitOpsSyncFormSheet
bind:open={isSyncDialogOpen}
bind:syncToEdit
targetType={dialogTargetType}
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
Loading
Loading