Skip to content

Commit 1331f75

Browse files
authored
Merge branch 'main' into feat/custom-project-files
2 parents df3849b + d4de38f commit 1331f75

38 files changed

+637
-156
lines changed

backend/internal/api/ws_handler.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ func (h *WebSocketHandler) ProjectLogs(c *gin.Context) {
182182
}
183183

184184
hub := h.startProjectLogHub(projectID, format, batched, follow, tail, since, timestamps)
185+
// WebSocket connections use context.Background() because they are long-lived and should not
186+
// be tied to the HTTP request context. Cleanup is handled via the hub's OnEmpty callback
187+
// which triggers when all clients disconnect.
185188
ws.ServeClient(context.Background(), hub, conn)
186189
}
187190

@@ -202,10 +205,10 @@ func (h *WebSocketHandler) startProjectLogHub(projectID, format string, batched,
202205
go ls.hub.Run(ctx)
203206

204207
lines := make(chan string, 256)
205-
go func() {
208+
go func(ctx context.Context) {
206209
defer close(lines)
207210
_ = h.projectService.StreamProjectLogs(ctx, projectID, lines, follow, tail, since, timestamps)
208-
}()
211+
}(ctx)
209212

210213
if format == "json" {
211214
msgs := make(chan ws.LogMessage, 256)
@@ -291,6 +294,9 @@ func (h *WebSocketHandler) ContainerLogs(c *gin.Context) {
291294
}
292295

293296
hub := h.startContainerLogHub(containerID, format, batched, follow, tail, since, timestamps)
297+
// WebSocket connections use context.Background() because they are long-lived and should not
298+
// be tied to the HTTP request context. Cleanup is handled via the hub's OnEmpty callback
299+
// which triggers when all clients disconnect.
294300
ws.ServeClient(context.Background(), hub, conn)
295301
}
296302

@@ -311,10 +317,10 @@ func (h *WebSocketHandler) startContainerLogHub(containerID, format string, batc
311317
go ls.hub.Run(ctx)
312318

313319
lines := make(chan string, 256)
314-
go func() {
320+
go func(ctx context.Context) {
315321
defer close(lines)
316322
_ = h.containerService.StreamLogs(ctx, containerID, lines, follow, tail, since, timestamps)
317-
}()
323+
}(ctx)
318324

319325
if format == "json" {
320326
msgs := make(chan ws.LogMessage, 256)
@@ -368,6 +374,9 @@ func (h *WebSocketHandler) ContainerStats(c *gin.Context) {
368374
}
369375

370376
hub := h.startContainerStatsHub(containerID)
377+
// WebSocket connections use context.Background() because they are long-lived and should not
378+
// be tied to the HTTP request context. Cleanup is handled via the hub's OnEmpty callback
379+
// which triggers when all clients disconnect.
371380
ws.ServeClient(context.Background(), hub, conn)
372381
}
373382

@@ -384,10 +393,10 @@ func (h *WebSocketHandler) startContainerStatsHub(containerID string) *ws.Hub {
384393
go hub.Run(ctx)
385394

386395
statsChan := make(chan interface{}, 64)
387-
go func() {
396+
go func(ctx context.Context) {
388397
defer close(statsChan)
389398
_ = h.containerService.StreamStats(ctx, containerID, statsChan)
390-
}()
399+
}(ctx)
391400

392401
go func() {
393402
for {
@@ -457,6 +466,12 @@ func (h *WebSocketHandler) ContainerExec(c *gin.Context) {
457466
defer close(done)
458467
buf := make([]byte, 4096)
459468
for {
469+
select {
470+
case <-ctx.Done():
471+
return
472+
default:
473+
}
474+
460475
n, err := stdout.Read(buf)
461476
if err != nil {
462477
return
@@ -472,6 +487,12 @@ func (h *WebSocketHandler) ContainerExec(c *gin.Context) {
472487
// Read from websocket, write to container
473488
go func() {
474489
for {
490+
select {
491+
case <-ctx.Done():
492+
return
493+
default:
494+
}
495+
475496
_, data, err := conn.ReadMessage()
476497
if err != nil {
477498
cancel()

backend/internal/bootstrap/bootstrap.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func Bootstrap(ctx context.Context) error {
3232
appCtx, cancelApp := context.WithCancel(ctx)
3333
defer cancelApp()
3434

35-
db, err := initializeDBAndMigrate(cfg)
35+
db, err := initializeDBAndMigrate(appCtx, cfg)
3636
if err != nil {
3737
return fmt.Errorf("failed to initialize database: %w", err)
3838
}
@@ -46,6 +46,9 @@ func Bootstrap(ctx context.Context) error {
4646
}(appCtx)
4747

4848
httpClient := httputils.NewHTTPClient()
49+
if cfg.HTTPClientTimeout > 0 {
50+
httpClient = httputils.NewHTTPClientWithTimeout(time.Duration(cfg.HTTPClientTimeout) * time.Second)
51+
}
4952

5053
appServices, dockerClientService, err := initializeServices(appCtx, db, cfg, httpClient)
5154
if err != nil {

backend/internal/bootstrap/db_bootstrap.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
package bootstrap
22

33
import (
4+
"context"
45
"fmt"
56
"log/slog"
67

78
"github.com/getarcaneapp/arcane/backend/internal/config"
89
"github.com/getarcaneapp/arcane/backend/internal/database"
910
)
1011

11-
func initializeDBAndMigrate(cfg *config.Config) (*database.DB, error) {
12-
db, err := database.Initialize(cfg.DatabaseURL)
12+
func initializeDBAndMigrate(ctx context.Context, cfg *config.Config) (*database.DB, error) {
13+
db, err := database.Initialize(ctx, cfg.DatabaseURL)
1314
if err != nil {
1415
return nil, fmt.Errorf("failed to initialize database: %w", err)
1516
}

backend/internal/bootstrap/services_bootstrap.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config
5656
svcs.CustomizeSearch = services.NewCustomizeSearchService()
5757
svcs.AppImages = services.NewApplicationImagesService(resources.FS, svcs.Settings)
5858
svcs.Font = services.NewFontService(resources.FS)
59-
dockerClient := services.NewDockerClientService(db, cfg)
59+
dockerClient := services.NewDockerClientService(db, cfg, svcs.Settings)
6060
svcs.Docker = dockerClient
6161
svcs.User = services.NewUserService(db)
6262
svcs.ContainerRegistry = services.NewContainerRegistryService(db)
@@ -65,19 +65,19 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config
6565
svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification)
6666
svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Event)
6767
svcs.Project = services.NewProjectService(db, svcs.Settings, svcs.Event, svcs.Image, svcs.Docker)
68-
svcs.Environment = services.NewEnvironmentService(db, httpClient, svcs.Docker, svcs.Event)
69-
svcs.Container = services.NewContainerService(db, svcs.Event, svcs.Docker, svcs.Image)
70-
svcs.Volume = services.NewVolumeService(db, svcs.Docker, svcs.Event)
68+
svcs.Environment = services.NewEnvironmentService(db, httpClient, svcs.Docker, svcs.Event, svcs.Settings)
69+
svcs.Container = services.NewContainerService(db, svcs.Event, svcs.Docker, svcs.Image, svcs.Settings)
70+
svcs.Volume = services.NewVolumeService(db, svcs.Docker, svcs.Event, svcs.Settings)
7171
svcs.Network = services.NewNetworkService(db, svcs.Docker, svcs.Event)
7272
svcs.Template = services.NewTemplateService(ctx, db, httpClient, svcs.Settings)
7373
svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, cfg.JWTSecret, cfg)
7474
svcs.Oidc = services.NewOidcService(svcs.Auth, cfg, httpClient)
7575
svcs.ApiKey = services.NewApiKeyService(db, svcs.User)
7676
svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings)
7777
svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker)
78-
svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event)
78+
svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings)
7979
svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade)
80-
svcs.GitRepository = services.NewGitRepositoryService(db, cfg.GitWorkDir, svcs.Event)
80+
svcs.GitRepository = services.NewGitRepositoryService(db, cfg.GitWorkDir, svcs.Event, svcs.Settings)
8181
svcs.GitOpsSync = services.NewGitOpsSyncService(db, svcs.GitRepository, svcs.Project, svcs.Event)
8282

8383
return svcs, dockerClient, nil

backend/internal/config/config.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,17 @@ type Config struct {
5353
GPUMonitoringEnabled bool `env:"GPU_MONITORING_ENABLED" default:"false"`
5454
GPUType string `env:"GPU_TYPE" default:"auto"`
5555

56-
FilePerm os.FileMode `env:"FILE_PERM" default:"0644"`
57-
DirPerm os.FileMode `env:"DIR_PERM" default:"0755"`
58-
AllowedExternalPaths string `env:"ALLOWED_EXTERNAL_PATHS" default:""`
59-
GitWorkDir string `env:"GIT_WORK_DIR" default:"data/git"`
56+
FilePerm os.FileMode `env:"FILE_PERM" default:"0644"`
57+
DirPerm os.FileMode `env:"DIR_PERM" default:"0755"`
58+
AllowedExternalPaths string `env:"ALLOWED_EXTERNAL_PATHS" default:""`
59+
GitWorkDir string `env:"GIT_WORK_DIR" default:"data/git"`
60+
61+
DockerAPITimeout int `env:"DOCKER_API_TIMEOUT" default:"0"`
62+
DockerImagePullTimeout int `env:"DOCKER_IMAGE_PULL_TIMEOUT" default:"0"`
63+
GitOperationTimeout int `env:"GIT_OPERATION_TIMEOUT" default:"0"`
64+
HTTPClientTimeout int `env:"HTTP_CLIENT_TIMEOUT" default:"0"`
65+
RegistryTimeout int `env:"REGISTRY_TIMEOUT" default:"0"`
66+
ProxyRequestTimeout int `env:"PROXY_REQUEST_TIMEOUT" default:"0"`
6067
}
6168

6269
func Load() *Config {
@@ -200,6 +207,11 @@ func setFieldValue(field reflect.Value, value string) {
200207
field.SetUint(i)
201208
}
202209

210+
case reflect.Int:
211+
if i, err := strconv.Atoi(value); err == nil {
212+
field.SetInt(int64(i))
213+
}
214+
203215
default:
204216
// Handle custom types based on underlying kind
205217
if field.Type().ConvertibleTo(reflect.TypeFor[string]()) {

backend/internal/database/database.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package database
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"log/slog"
@@ -35,12 +36,16 @@ func SetGormLogger(l logger.Interface) {
3536
customGormLogger = l
3637
}
3738

38-
func Initialize(databaseURL string) (*DB, error) {
39-
db, err := connectDatabase(databaseURL)
39+
func Initialize(ctx context.Context, databaseURL string) (*DB, error) {
40+
db, err := connectDatabase(ctx, databaseURL)
4041
if err != nil {
4142
return nil, fmt.Errorf("failed to connect to database: %w", err)
4243
}
4344

45+
if err := ctx.Err(); err != nil {
46+
return nil, err
47+
}
48+
4449
// Get underlying sql.DB for migrations
4550
sqlDB, err := db.DB.DB()
4651
if err != nil {
@@ -86,7 +91,7 @@ func Initialize(databaseURL string) (*DB, error) {
8691
return db, nil
8792
}
8893

89-
func connectDatabase(databaseURL string) (*DB, error) {
94+
func connectDatabase(ctx context.Context, databaseURL string) (*DB, error) {
9095
var dialector gorm.Dialector
9196

9297
switch {
@@ -109,6 +114,9 @@ func connectDatabase(databaseURL string) (*DB, error) {
109114
var db *gorm.DB
110115
var err error
111116
for i := 1; i <= 3; i++ {
117+
if err := ctx.Err(); err != nil {
118+
return nil, err
119+
}
112120
db, err = gorm.Open(dialector, &gorm.Config{
113121
Logger: customGormLogger,
114122
NowFunc: func() time.Time {
@@ -123,7 +131,11 @@ func connectDatabase(databaseURL string) (*DB, error) {
123131

124132
slog.Info("Failed to initialize database", "attempt", i)
125133
if i < 3 {
126-
time.Sleep(3 * time.Second)
134+
select {
135+
case <-time.After(3 * time.Second):
136+
case <-ctx.Done():
137+
return nil, ctx.Err()
138+
}
127139
}
128140
}
129141

backend/internal/models/settings.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ type Settings struct {
104104

105105
// API Keys category (admin management page - no actual settings)
106106
ApiKeysCategoryPlaceholder SettingVariable `key:"apiKeysCategory,internal" meta:"label=API Keys;type=internal;keywords=api,keys,tokens,authentication,access,programmatic,integration;category=apikeys;description=Manage API keys for programmatic access" catmeta:"id=apikeys;title=API Keys;icon=apikey;url=/settings/api-keys;description=Create and manage API keys for programmatic access to Arcane"`
107+
108+
// Timeout category
109+
DockerAPITimeout SettingVariable `key:"dockerApiTimeout,envOverride" meta:"label=Docker API Timeout;type=number;keywords=docker,api,timeout,seconds,list,operations;category=timeouts;description=Timeout for Docker list operations in seconds (default: 30)" catmeta:"id=timeouts;title=Timeouts;icon=clock;url=/settings/timeouts;description=Configure operation timeouts for slow networks or hardware"`
110+
DockerImagePullTimeout SettingVariable `key:"dockerImagePullTimeout,envOverride" meta:"label=Docker Image Pull Timeout;type=number;keywords=docker,image,pull,timeout,seconds,download;category=timeouts;description=Timeout for Docker image pulls in seconds (default: 600 = 10 minutes)"`
111+
GitOperationTimeout SettingVariable `key:"gitOperationTimeout,envOverride" meta:"label=Git Operation Timeout;type=number;keywords=git,clone,timeout,seconds,repository;category=timeouts;description=Timeout for Git clone/fetch operations in seconds (default: 300 = 5 minutes)"`
112+
HTTPClientTimeout SettingVariable `key:"httpClientTimeout,envOverride" meta:"label=HTTP Client Timeout;type=number;keywords=http,client,timeout,seconds,api,request;category=timeouts;description=Default timeout for HTTP requests in seconds (default: 30)"`
113+
RegistryTimeout SettingVariable `key:"registryTimeout,envOverride" meta:"label=Registry Timeout;type=number;keywords=registry,timeout,seconds,docker,auth;category=timeouts;description=Timeout for container registry operations in seconds (default: 30)"`
114+
ProxyRequestTimeout SettingVariable `key:"proxyRequestTimeout,envOverride" meta:"label=Proxy Request Timeout;type=number;keywords=proxy,request,timeout,seconds,forward;category=timeouts;description=Timeout for proxied requests in seconds (default: 60)"`
107115
}
108116

109117
func (SettingVariable) TableName() string {

backend/internal/services/container_service.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,28 @@ import (
1717
"github.com/getarcaneapp/arcane/backend/internal/database"
1818
"github.com/getarcaneapp/arcane/backend/internal/models"
1919
"github.com/getarcaneapp/arcane/backend/internal/utils/pagination"
20+
"github.com/getarcaneapp/arcane/backend/internal/utils/timeouts"
2021
containertypes "github.com/getarcaneapp/arcane/types/container"
2122
"github.com/getarcaneapp/arcane/types/containerregistry"
2223
imagetypes "github.com/getarcaneapp/arcane/types/image"
2324
)
2425

2526
type ContainerService struct {
26-
db *database.DB
27-
dockerService *DockerClientService
28-
eventService *EventService
29-
imageService *ImageService
27+
db *database.DB
28+
dockerService *DockerClientService
29+
eventService *EventService
30+
imageService *ImageService
31+
settingsService *SettingsService
3032
}
3133

32-
func NewContainerService(db *database.DB, eventService *EventService, dockerService *DockerClientService, imageService *ImageService) *ContainerService {
33-
return &ContainerService{db: db, eventService: eventService, dockerService: dockerService, imageService: imageService}
34+
func NewContainerService(db *database.DB, eventService *EventService, dockerService *DockerClientService, imageService *ImageService, settingsService *SettingsService) *ContainerService {
35+
return &ContainerService{
36+
db: db,
37+
eventService: eventService,
38+
dockerService: dockerService,
39+
imageService: imageService,
40+
settingsService: settingsService,
41+
}
3442
}
3543

3644
func (s *ContainerService) StartContainer(ctx context.Context, containerID string, user models.User) error {
@@ -193,8 +201,16 @@ func (s *ContainerService) CreateContainer(ctx context.Context, config *containe
193201
pullOptions = image.PullOptions{}
194202
}
195203

196-
reader, pullErr := dockerClient.ImagePull(ctx, config.Image, pullOptions)
204+
settings := s.settingsService.GetSettingsConfig()
205+
pullCtx, pullCancel := timeouts.WithTimeout(ctx, settings.DockerImagePullTimeout.AsInt(), timeouts.DefaultDockerImagePull)
206+
defer pullCancel()
207+
208+
reader, pullErr := dockerClient.ImagePull(pullCtx, config.Image, pullOptions)
197209
if pullErr != nil {
210+
if errors.Is(pullCtx.Err(), context.DeadlineExceeded) {
211+
s.eventService.LogErrorEvent(ctx, models.EventTypeContainerError, "container", "", containerName, user.ID, user.Username, "0", pullErr, models.JSON{"action": "create", "image": config.Image, "step": "pull_image_timeout"})
212+
return nil, fmt.Errorf("image pull timed out for %s (increase DOCKER_IMAGE_PULL_TIMEOUT or setting)", config.Image)
213+
}
198214
s.eventService.LogErrorEvent(ctx, models.EventTypeContainerError, "container", "", containerName, user.ID, user.Username, "0", pullErr, models.JSON{"action": "create", "image": config.Image, "step": "pull_image"})
199215
return nil, fmt.Errorf("failed to pull image %s: %w", config.Image, pullErr)
200216
}

0 commit comments

Comments
 (0)