v2.31.0-dev21 b59533b

controlplane_local.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"sort"
8	"time"
9
10	"github.com/bnema/gordon/internal/adapters/dto"
11	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
12	"github.com/bnema/gordon/internal/app"
13	"github.com/bnema/gordon/internal/boundaries/in"
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/gordon/internal/usecase/config"
16)
17
18type localControlPlane struct {
19	configSvc    in.ConfigService
20	secretSvc    in.SecretService
21	containerSvc in.ContainerService
22	backupSvc    in.BackupService
23	registrySvc  in.RegistryService
24	deployCoord  in.DeployCoordinator
25	healthSvc    in.HealthService
26	logSvc       in.LogService
27	volumeSvc    in.VolumeService
28	publicTLSSvc in.PublicTLSService
29}
30
31func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
32	if kernel == nil {
33		return &localControlPlane{}
34	}
35
36	registrySvc := kernel.Registry()
37	var deployCoord in.DeployCoordinator
38	if registrySvc != nil {
39		if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok {
40			deployCoord = coordinator
41		}
42	}
43
44	return &localControlPlane{
45		configSvc:    kernel.Config(),
46		secretSvc:    kernel.Secrets(),
47		containerSvc: kernel.Container(),
48		backupSvc:    kernel.Backup(),
49		registrySvc:  registrySvc,
50		deployCoord:  deployCoord,
51		healthSvc:    kernel.Health(),
52		logSvc:       kernel.Logs(),
53		volumeSvc:    kernel.Volumes(),
54		publicTLSSvc: kernel.PublicTLS(),
55	}
56}
57
58func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
59	if l.containerSvc != nil {
60		if err := l.containerSvc.SyncContainers(ctx); err != nil {
61			return nil, err
62		}
63		detailed := l.containerSvc.ListRoutesWithDetails(ctx)
64		if l.configSvc == nil {
65			return toRemoteRouteInfos(detailed), nil
66		}
67		return mergeConfiguredRemoteRouteInfos(l.configSvc.GetRoutes(ctx), detailed), nil
68	}
69
70	if l.configSvc == nil {
71		return nil, fmt.Errorf("local config service unavailable")
72	}
73
74	routes := l.configSvc.GetRoutes(ctx)
75	infos := make([]remote.RouteInfo, 0, len(routes))
76	for _, route := range routes {
77		infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image})
78	}
79	return infos, nil
80}
81
82func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
83	if l.healthSvc == nil {
84		return map[string]*remote.RouteHealth{}, nil
85	}
86
87	health := l.healthSvc.CheckAllRoutes(ctx)
88	result := make(map[string]*remote.RouteHealth, len(health))
89	for domainName, h := range health {
90		if h == nil {
91			continue
92		}
93		result[domainName] = &remote.RouteHealth{
94			ContainerStatus: h.ContainerStatus,
95			HTTPStatus:      h.HTTPStatus,
96			ResponseTimeMs:  h.ResponseTimeMs,
97			Healthy:         h.Healthy,
98			Error:           h.Error,
99		}
100	}
101	return result, nil
102}
103
104func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
105	if l.configSvc == nil {
106		return nil, fmt.Errorf("local config service unavailable")
107	}
108	return l.configSvc.GetRoute(ctx, routeDomain)
109}
110
111func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
112	if l.configSvc == nil {
113		return nil, fmt.Errorf("local config service unavailable")
114	}
115	return l.configSvc.FindRoutesByImage(ctx, imageName), nil
116}
117
118func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
119	if l.configSvc == nil {
120		return fmt.Errorf("local config service unavailable")
121	}
122	return l.configSvc.AddRoute(ctx, route)
123}
124
125func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
126	if l.configSvc == nil {
127		return fmt.Errorf("local config service unavailable")
128	}
129	return l.configSvc.UpdateRoute(ctx, route)
130}
131
132func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
133	_, err := l.RemoveRouteWithCleanup(ctx, routeDomain)
134	return err
135}
136
137func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
138	if l.configSvc == nil {
139		return nil, fmt.Errorf("local config service unavailable")
140	}
141	if err := l.configSvc.RemoveRoute(ctx, routeDomain); err != nil && !errors.Is(err, domain.ErrRouteNotFound) {
142		return nil, fmt.Errorf("remove route: %w", err)
143	}
144	resp := &dto.RouteDeleteResponse{Status: "removed"}
145	if l.containerSvc == nil {
146		return resp, nil
147	}
148	report, err := l.containerSvc.ReconcileRemovedRoute(ctx, routeDomain)
149	if err != nil {
150		return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err)
151	}
152	resp.Cleanup = dto.CleanupReportFromDomain(report)
153	return resp, nil
154}
155
156func (l *localControlPlane) GetRouteCleanupPreview(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
157	previewer, ok := any(l.containerSvc).(interface {
158		PreviewRemovedRouteCleanup(context.Context, string) (*domain.CleanupReport, error)
159	})
160	if !ok {
161		return nil, fmt.Errorf("route cleanup preview unavailable")
162	}
163
164	report, err := previewer.PreviewRemovedRouteCleanup(ctx, routeDomain)
165	if err != nil {
166		return nil, err
167	}
168	if report == nil {
169		return nil, nil
170	}
171	if l.volumeSvc == nil {
172		return report, nil
173	}
174
175	volumes, err := l.ListVolumes(ctx)
176	if err != nil {
177		return nil, fmt.Errorf("list volumes: %w", err)
178	}
179	if len(volumes) == 0 {
180		return report, nil
181	}
182
183	scope := newRouteResourceScope(routeDomain, l.routeCleanupVolumePrefix(), nil, report.PreservedAttachments)
184	for _, volume := range volumesForRouteDiagnosis(volumes, scope) {
185		report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{
186			Name:   volume.Name,
187			Reason: "volume preserved for explicit cleanup review",
188		})
189	}
190	return report, nil
191}
192
193func (l *localControlPlane) routeCleanupVolumePrefix() string {
194	const defaultPrefix = "gordon"
195	if l.configSvc == nil {
196		return defaultPrefix
197	}
198	if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
199		_, prefix, _ := volumeCfg.GetVolumeConfig()
200		if prefix != "" {
201			return prefix
202		}
203	}
204	return defaultPrefix
205}
206
207func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
208	registryDomain := ""
209	if l.configSvc != nil {
210		registryDomain = l.configSvc.GetRegistryDomain()
211	}
212	normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain)
213	if err != nil {
214		return nil, fmt.Errorf("invalid image: %w", err)
215	}
216
217	resp := &dto.BootstrapResponse{
218		Domain: req.Domain,
219		Image:  normalizedImage,
220		Next:   fmt.Sprintf("push %s to trigger deployment", normalizedImage),
221	}
222
223	if l.configSvc == nil {
224		return resp, fmt.Errorf("local config service unavailable")
225	}
226	needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0
227	if needsSecrets && l.secretSvc == nil {
228		return resp, fmt.Errorf("local secret service unavailable")
229	}
230
231	addStep := func(name, status string) {
232		resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
233	}
234
235	err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
236	switch err {
237	case nil:
238		addStep("route", "configured")
239	default:
240		addStep("route", "failed")
241		return resp, err
242	}
243
244	if err := l.bootstrapAttachments(ctx, req, addStep); err != nil {
245		return resp, err
246	}
247
248	if err := l.bootstrapSecrets(ctx, req, addStep); err != nil {
249		return resp, err
250	}
251
252	if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil {
253		return resp, err
254	}
255
256	return resp, nil
257}
258
259func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
260	for _, attachment := range req.Attachments {
261		err := l.configSvc.AddAttachment(ctx, req.Domain, attachment)
262		if err == nil {
263			addStep("attachment:"+attachment, "created")
264			continue
265		}
266		if errors.Is(err, domain.ErrAttachmentExists) {
267			addStep("attachment:"+attachment, "noop")
268			continue
269		}
270		addStep("attachment:"+attachment, "failed")
271		return err
272	}
273
274	return nil
275}
276
277func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
278	if len(req.Env) == 0 {
279		return nil
280	}
281
282	if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
283		addStep("env", "failed")
284		return err
285	}
286
287	addStep("env", "updated")
288	return nil
289}
290
291func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
292	for service, env := range req.AttachmentEnv {
293		if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
294			addStep("attachment_env:"+service, "failed")
295			return err
296		}
297		addStep("attachment_env:"+service, "updated")
298	}
299
300	return nil
301}
302
303func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
304	if l.secretSvc == nil {
305		return nil, fmt.Errorf("local secret service unavailable")
306	}
307
308	keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
309	if err != nil {
310		return nil, fmt.Errorf("list secret keys with attachments: %w", err)
311	}
312
313	result := &remote.SecretsListResult{
314		Domain: secretDomain,
315		Keys:   keys,
316	}
317	for _, att := range attachments {
318		result.Attachments = append(result.Attachments, remote.AttachmentSecrets{
319			Service: att.Service,
320			Keys:    att.Keys,
321		})
322	}
323
324	return result, nil
325}
326
327func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
328	if l.secretSvc == nil {
329		return fmt.Errorf("local secret service unavailable")
330	}
331	return l.secretSvc.Set(ctx, secretDomain, secrets)
332}
333
334func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
335	if l.secretSvc == nil {
336		return fmt.Errorf("local secret service unavailable")
337	}
338	return l.secretSvc.Delete(ctx, secretDomain, key)
339}
340
341func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
342	if l.secretSvc == nil {
343		return fmt.Errorf("local secret service unavailable")
344	}
345	return l.secretSvc.SetAttachment(ctx, domainName, service, secrets)
346}
347
348func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
349	if l.secretSvc == nil {
350		return fmt.Errorf("local secret service unavailable")
351	}
352	return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
353}
354
355func (l *localControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
356	if l.containerSvc == nil {
357		return nil, fmt.Errorf("local container service unavailable")
358	}
359	attachments, err := l.containerSvc.ListOrphanedAttachments(ctx)
360	if err != nil {
361		return nil, fmt.Errorf("list orphaned attachments: %w", err)
362	}
363	return attachments, nil
364}
365
366func (l *localControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
367	if l.containerSvc == nil {
368		return nil, fmt.Errorf("local container service unavailable")
369	}
370	report, err := l.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop)
371	if err != nil {
372		return nil, fmt.Errorf("cleanup orphaned attachments: %w", err)
373	}
374	return report, nil
375}
376
377func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
378	if l.configSvc == nil {
379		return nil, fmt.Errorf("local config service unavailable")
380	}
381	return l.configSvc.GetAllAttachments(ctx), nil
382}
383
384func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
385	if l.configSvc == nil {
386		return nil, fmt.Errorf("local config service unavailable")
387	}
388	return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
389}
390
391func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
392	if l.configSvc == nil {
393		return nil, fmt.Errorf("local config service unavailable")
394	}
395	return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil
396}
397
398func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
399	if l.configSvc == nil {
400		return fmt.Errorf("local config service unavailable")
401	}
402	return l.configSvc.AddAttachment(ctx, domainOrGroup, image)
403}
404
405func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
406	if l.configSvc == nil {
407		return fmt.Errorf("local config service unavailable")
408	}
409	return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
410}
411
412func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
413	if l.configSvc == nil {
414		return nil, fmt.Errorf("local config service unavailable")
415	}
416	return l.configSvc.GetAutoRouteAllowedDomains(ctx)
417}
418
419func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
420	if l.configSvc == nil {
421		return fmt.Errorf("local config service unavailable")
422	}
423	return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern)
424}
425
426func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
427	if l.configSvc == nil {
428		return fmt.Errorf("local config service unavailable")
429	}
430	return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
431}
432
433func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
434	if l.publicTLSSvc == nil {
435		return &dto.TLSStatusResponse{
436			ACMEEnabled:     false,
437			SelectionReason: "public TLS service not configured",
438		}, nil
439	}
440
441	status := l.publicTLSSvc.Status(ctx)
442	result := dto.TLSStatusFromDomain(status)
443	return &result, nil
444}
445
446func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
447	if l.configSvc == nil {
448		return nil, fmt.Errorf("local config service unavailable")
449	}
450
451	status := &remote.Status{
452		Routes:           len(l.configSvc.GetRoutes(ctx)),
453		RegistryDomain:   l.configSvc.GetRegistryDomain(),
454		RegistryPort:     l.configSvc.GetRegistryPort(),
455		ServerPort:       l.configSvc.GetServerPort(),
456		AutoRoute:        l.configSvc.IsAutoRouteEnabled(),
457		NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(),
458		ContainerStatus:  map[string]string{},
459	}
460
461	if l.containerSvc != nil {
462		for domainName, container := range l.containerSvc.List(ctx) {
463			if container == nil {
464				continue
465			}
466			status.ContainerStatus[domainName] = container.Status
467		}
468	}
469
470	return status, nil
471}
472
473func (l *localControlPlane) Reload(_ context.Context) error {
474	return app.SendReloadSignal()
475}
476
477func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
478	if l.containerSvc == nil {
479		return nil, fmt.Errorf("container service unavailable")
480	}
481	return l.containerSvc.ListNetworks(ctx)
482}
483
484func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
485	if l.configSvc == nil {
486		return nil, fmt.Errorf("config service unavailable")
487	}
488	externalRoutes := l.configSvc.GetExternalRoutes()
489	externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes))
490	for domainName := range externalRoutes {
491		externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName})
492	}
493	sort.Slice(externalResponses, func(i, j int) bool {
494		return externalResponses[i].Domain < externalResponses[j].Domain
495	})
496	cfg := &remote.Config{
497		Routes:         l.configSvc.GetRoutes(ctx),
498		ExternalRoutes: externalResponses,
499	}
500	cfg.Server.Port = l.configSvc.GetServerPort()
501	cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
502	cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
503	cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
504	cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
505	cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
506	if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok {
507		cfg.Volumes.AutoCreate, cfg.Volumes.Prefix, cfg.Volumes.Preserve = volumeCfg.GetVolumeConfig()
508	}
509	return cfg, nil
510}
511
512func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
513	if l.deployCoord != nil {
514		l.deployCoord.SuppressDeployEvent(imageName)
515	}
516	return nil
517}
518
519func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
520	if l.containerSvc != nil && l.configSvc != nil {
521		route, err := l.configSvc.GetRoute(ctx, deployDomain)
522		if err != nil {
523			return nil, err
524		}
525		container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
526		if err != nil {
527			return nil, err
528		}
529		result := &remote.DeployResult{Status: "deployed", Domain: deployDomain}
530		if container != nil {
531			result.ContainerID = container.ID
532		}
533		return result, nil
534	}
535
536	domainName, err := app.SendDeploySignal(deployDomain)
537	if err != nil {
538		return nil, err
539	}
540	return &remote.DeployResult{Status: "queued", Domain: domainName}, nil
541}
542
543func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
544	if l.containerSvc == nil {
545		if withAttachments {
546			return nil, fmt.Errorf("local restart with attachments requires active local container service")
547		}
548		domainName, err := app.SendDeploySignal(restartDomain)
549		if err != nil {
550			return nil, err
551		}
552		return &remote.RestartResult{Status: "queued", Domain: domainName}, nil
553	}
554
555	if err := l.containerSvc.SyncContainers(ctx); err != nil {
556		return nil, err
557	}
558	if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
559		return nil, err
560	}
561
562	return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil
563}
564
565func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
566	if l.registrySvc == nil {
567		return nil, fmt.Errorf("local registry service unavailable")
568	}
569	return l.registrySvc.ListTags(ctx, repository)
570}
571
572func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
573	if l.backupSvc == nil {
574		return nil, fmt.Errorf("local backup service unavailable")
575	}
576	jobs, err := l.backupSvc.ListBackups(ctx, backupDomain)
577	if err != nil {
578		return nil, err
579	}
580	return toDTOBackupJobs(jobs), nil
581}
582
583func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
584	if l.backupSvc == nil {
585		return nil, fmt.Errorf("local backup service unavailable")
586	}
587	jobs, err := l.backupSvc.Status(ctx)
588	if err != nil {
589		return nil, err
590	}
591	return toDTOBackupJobs(jobs), nil
592}
593
594func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
595	if l.backupSvc == nil {
596		return nil, fmt.Errorf("local backup service unavailable")
597	}
598	result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName)
599	if err != nil {
600		return nil, err
601	}
602	if result == nil {
603		return &dto.BackupRunResponse{Status: "ok"}, nil
604	}
605	job := toDTOBackupJob(result.Job)
606	return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil
607}
608
609func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
610	if l.backupSvc == nil {
611		return nil, fmt.Errorf("local backup service unavailable")
612	}
613	dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain)
614	if err != nil {
615		return nil, err
616	}
617	out := make([]dto.DatabaseInfo, 0, len(dbs))
618	for _, db := range dbs {
619		out = append(out, dto.DatabaseInfo{
620			Type:        string(db.Type),
621			Name:        db.Name,
622			Version:     db.Version,
623			Host:        db.Host,
624			Port:        db.Port,
625			ContainerID: db.ContainerID,
626			ImageName:   db.ImageName,
627		})
628	}
629	return out, nil
630}
631
632func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
633	if l.logSvc == nil {
634		return nil, fmt.Errorf("local log service unavailable")
635	}
636	return l.logSvc.GetProcessLogs(ctx, lines)
637}
638
639func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
640	if l.logSvc == nil {
641		return nil, fmt.Errorf("local log service unavailable")
642	}
643	return l.logSvc.GetContainerLogs(ctx, logDomain, lines)
644}
645
646func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
647	if l.logSvc == nil {
648		return nil, fmt.Errorf("local log service unavailable")
649	}
650	return l.logSvc.FollowProcessLogs(ctx, lines)
651}
652
653func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
654	if l.logSvc == nil {
655		return nil, fmt.Errorf("local log service unavailable")
656	}
657	return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
658}
659
660func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
661	if l.volumeSvc == nil {
662		return nil, fmt.Errorf("volume service unavailable")
663	}
664	vols, err := l.volumeSvc.ListVolumes(ctx)
665	if err != nil {
666		return nil, err
667	}
668
669	result := make([]dto.Volume, len(vols))
670	for i, v := range vols {
671		result[i] = dto.Volume{
672			Name:       v.Name,
673			Driver:     v.Driver,
674			MountPoint: v.MountPoint,
675			Size:       v.Size,
676			CreatedAt:  v.CreatedAt,
677			InUse:      v.InUse,
678			Containers: v.Containers,
679			Labels:     v.Labels,
680		}
681	}
682	return result, nil
683}
684
685func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
686	if l.volumeSvc == nil {
687		return nil, fmt.Errorf("volume service unavailable")
688	}
689	report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun)
690	if err != nil {
691		return nil, err
692	}
693
694	vols := make([]dto.Volume, len(removed))
695	for i, v := range removed {
696		vols[i] = dto.Volume{
697			Name: v.Name,
698			Size: v.Size,
699		}
700	}
701
702	return &dto.VolumePruneResponse{
703		VolumesRemoved: report.VolumesRemoved,
704		SpaceReclaimed: report.SpaceReclaimed,
705		Volumes:        vols,
706	}, nil
707}
708
709func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo {
710	detailedByDomain := make(map[string]domain.RouteInfo, len(detailed))
711	for _, info := range detailed {
712		detailedByDomain[info.Domain] = info
713	}
714
715	merged := make([]domain.RouteInfo, 0, len(configured))
716	for _, route := range configured {
717		info, ok := detailedByDomain[route.Domain]
718		if !ok {
719			info = domain.RouteInfo{Domain: route.Domain, Image: route.Image}
720		} else if info.Image == "" {
721			info.Image = route.Image
722		}
723		merged = append(merged, info)
724	}
725
726	return toRemoteRouteInfos(merged)
727}
728
729func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
730	out := make([]remote.RouteInfo, 0, len(routes))
731	for _, route := range routes {
732		attachments := make([]dto.Attachment, 0, len(route.Attachments))
733		for _, attachment := range route.Attachments {
734			attachments = append(attachments, dto.Attachment{
735				Name:        attachment.Name,
736				Image:       attachment.Image,
737				ContainerID: attachment.ContainerID,
738				Status:      attachment.Status,
739				Network:     attachment.Network,
740			})
741		}
742
743		out = append(out, remote.RouteInfo{
744			Domain:          route.Domain,
745			Image:           route.Image,
746			ContainerID:     route.ContainerID,
747			ContainerStatus: route.ContainerStatus,
748			Network:         route.Network,
749			Attachments:     attachments,
750		})
751	}
752	return out
753}
754
755func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
756	out := make([]dto.BackupJob, 0, len(jobs))
757	for _, job := range jobs {
758		out = append(out, toDTOBackupJob(job))
759	}
760	return out
761}
762
763func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
764	var startedAt *time.Time
765	if !job.StartedAt.IsZero() {
766		t := job.StartedAt
767		startedAt = &t
768	}
769	var completedAt *time.Time
770	if !job.CompletedAt.IsZero() {
771		t := job.CompletedAt
772		completedAt = &t
773	}
774
775	return dto.BackupJob{
776		ID:          job.ID,
777		Domain:      job.Domain,
778		DBName:      job.DBName,
779		Schedule:    string(job.Schedule),
780		Type:        string(job.Type),
781		Status:      string(job.Status),
782		StartedAt:   startedAt,
783		CompletedAt: completedAt,
784		SizeBytes:   job.SizeBytes,
785		Error:       job.Error,
786	}
787}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close