v2.31.0-dev18 7b9923f

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) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
157	registryDomain := ""
158	if l.configSvc != nil {
159		registryDomain = l.configSvc.GetRegistryDomain()
160	}
161	normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain)
162	if err != nil {
163		return nil, fmt.Errorf("invalid image: %w", err)
164	}
165
166	resp := &dto.BootstrapResponse{
167		Domain: req.Domain,
168		Image:  normalizedImage,
169		Next:   fmt.Sprintf("push %s to trigger deployment", normalizedImage),
170	}
171
172	if l.configSvc == nil {
173		return resp, fmt.Errorf("local config service unavailable")
174	}
175	needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0
176	if needsSecrets && l.secretSvc == nil {
177		return resp, fmt.Errorf("local secret service unavailable")
178	}
179
180	addStep := func(name, status string) {
181		resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
182	}
183
184	err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
185	switch err {
186	case nil:
187		addStep("route", "configured")
188	default:
189		addStep("route", "failed")
190		return resp, err
191	}
192
193	if err := l.bootstrapAttachments(ctx, req, addStep); err != nil {
194		return resp, err
195	}
196
197	if err := l.bootstrapSecrets(ctx, req, addStep); err != nil {
198		return resp, err
199	}
200
201	if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil {
202		return resp, err
203	}
204
205	return resp, nil
206}
207
208func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
209	for _, attachment := range req.Attachments {
210		err := l.configSvc.AddAttachment(ctx, req.Domain, attachment)
211		if err == nil {
212			addStep("attachment:"+attachment, "created")
213			continue
214		}
215		if errors.Is(err, domain.ErrAttachmentExists) {
216			addStep("attachment:"+attachment, "noop")
217			continue
218		}
219		addStep("attachment:"+attachment, "failed")
220		return err
221	}
222
223	return nil
224}
225
226func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
227	if len(req.Env) == 0 {
228		return nil
229	}
230
231	if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
232		addStep("env", "failed")
233		return err
234	}
235
236	addStep("env", "updated")
237	return nil
238}
239
240func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
241	for service, env := range req.AttachmentEnv {
242		if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
243			addStep("attachment_env:"+service, "failed")
244			return err
245		}
246		addStep("attachment_env:"+service, "updated")
247	}
248
249	return nil
250}
251
252func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
253	if l.secretSvc == nil {
254		return nil, fmt.Errorf("local secret service unavailable")
255	}
256
257	keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
258	if err != nil {
259		return nil, fmt.Errorf("list secret keys with attachments: %w", err)
260	}
261
262	result := &remote.SecretsListResult{
263		Domain: secretDomain,
264		Keys:   keys,
265	}
266	for _, att := range attachments {
267		result.Attachments = append(result.Attachments, remote.AttachmentSecrets{
268			Service: att.Service,
269			Keys:    att.Keys,
270		})
271	}
272
273	return result, nil
274}
275
276func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
277	if l.secretSvc == nil {
278		return fmt.Errorf("local secret service unavailable")
279	}
280	return l.secretSvc.Set(ctx, secretDomain, secrets)
281}
282
283func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
284	if l.secretSvc == nil {
285		return fmt.Errorf("local secret service unavailable")
286	}
287	return l.secretSvc.Delete(ctx, secretDomain, key)
288}
289
290func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
291	if l.secretSvc == nil {
292		return fmt.Errorf("local secret service unavailable")
293	}
294	return l.secretSvc.SetAttachment(ctx, domainName, service, secrets)
295}
296
297func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
298	if l.secretSvc == nil {
299		return fmt.Errorf("local secret service unavailable")
300	}
301	return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
302}
303
304func (l *localControlPlane) ListOrphanedAttachments(ctx context.Context) ([]domain.CleanupAttachment, error) {
305	if l.containerSvc == nil {
306		return nil, fmt.Errorf("local container service unavailable")
307	}
308	attachments, err := l.containerSvc.ListOrphanedAttachments(ctx)
309	if err != nil {
310		return nil, fmt.Errorf("list orphaned attachments: %w", err)
311	}
312	return attachments, nil
313}
314
315func (l *localControlPlane) CleanupOrphanedAttachments(ctx context.Context, owner string, stop bool) (*domain.CleanupReport, error) {
316	if l.containerSvc == nil {
317		return nil, fmt.Errorf("local container service unavailable")
318	}
319	report, err := l.containerSvc.CleanupOrphanedAttachments(ctx, owner, stop)
320	if err != nil {
321		return nil, fmt.Errorf("cleanup orphaned attachments: %w", err)
322	}
323	return report, nil
324}
325
326func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
327	if l.configSvc == nil {
328		return nil, fmt.Errorf("local config service unavailable")
329	}
330	return l.configSvc.GetAllAttachments(ctx), nil
331}
332
333func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
334	if l.configSvc == nil {
335		return nil, fmt.Errorf("local config service unavailable")
336	}
337	return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
338}
339
340func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
341	if l.configSvc == nil {
342		return nil, fmt.Errorf("local config service unavailable")
343	}
344	return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil
345}
346
347func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
348	if l.configSvc == nil {
349		return fmt.Errorf("local config service unavailable")
350	}
351	return l.configSvc.AddAttachment(ctx, domainOrGroup, image)
352}
353
354func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
355	if l.configSvc == nil {
356		return fmt.Errorf("local config service unavailable")
357	}
358	return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
359}
360
361func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
362	if l.configSvc == nil {
363		return nil, fmt.Errorf("local config service unavailable")
364	}
365	return l.configSvc.GetAutoRouteAllowedDomains(ctx)
366}
367
368func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
369	if l.configSvc == nil {
370		return fmt.Errorf("local config service unavailable")
371	}
372	return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern)
373}
374
375func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
376	if l.configSvc == nil {
377		return fmt.Errorf("local config service unavailable")
378	}
379	return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
380}
381
382func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) {
383	if l.publicTLSSvc == nil {
384		return &dto.TLSStatusResponse{
385			ACMEEnabled:     false,
386			SelectionReason: "public TLS service not configured",
387		}, nil
388	}
389
390	status := l.publicTLSSvc.Status(ctx)
391	result := dto.TLSStatusFromDomain(status)
392	return &result, nil
393}
394
395func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
396	if l.configSvc == nil {
397		return nil, fmt.Errorf("local config service unavailable")
398	}
399
400	status := &remote.Status{
401		Routes:           len(l.configSvc.GetRoutes(ctx)),
402		RegistryDomain:   l.configSvc.GetRegistryDomain(),
403		RegistryPort:     l.configSvc.GetRegistryPort(),
404		ServerPort:       l.configSvc.GetServerPort(),
405		AutoRoute:        l.configSvc.IsAutoRouteEnabled(),
406		NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(),
407		ContainerStatus:  map[string]string{},
408	}
409
410	if l.containerSvc != nil {
411		for domainName, container := range l.containerSvc.List(ctx) {
412			if container == nil {
413				continue
414			}
415			status.ContainerStatus[domainName] = container.Status
416		}
417	}
418
419	return status, nil
420}
421
422func (l *localControlPlane) Reload(_ context.Context) error {
423	return app.SendReloadSignal()
424}
425
426func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
427	if l.containerSvc == nil {
428		return nil, fmt.Errorf("container service unavailable")
429	}
430	return l.containerSvc.ListNetworks(ctx)
431}
432
433func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
434	if l.configSvc == nil {
435		return nil, fmt.Errorf("config service unavailable")
436	}
437	externalRoutes := l.configSvc.GetExternalRoutes()
438	externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes))
439	for domainName := range externalRoutes {
440		externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName})
441	}
442	sort.Slice(externalResponses, func(i, j int) bool {
443		return externalResponses[i].Domain < externalResponses[j].Domain
444	})
445	cfg := &remote.Config{
446		Routes:         l.configSvc.GetRoutes(ctx),
447		ExternalRoutes: externalResponses,
448	}
449	cfg.Server.Port = l.configSvc.GetServerPort()
450	cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
451	cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
452	cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
453	cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
454	cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
455	return cfg, nil
456}
457
458func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
459	if l.deployCoord != nil {
460		l.deployCoord.SuppressDeployEvent(imageName)
461	}
462	return nil
463}
464
465func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
466	if l.containerSvc != nil && l.configSvc != nil {
467		route, err := l.configSvc.GetRoute(ctx, deployDomain)
468		if err != nil {
469			return nil, err
470		}
471		container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
472		if err != nil {
473			return nil, err
474		}
475		result := &remote.DeployResult{Status: "deployed", Domain: deployDomain}
476		if container != nil {
477			result.ContainerID = container.ID
478		}
479		return result, nil
480	}
481
482	domainName, err := app.SendDeploySignal(deployDomain)
483	if err != nil {
484		return nil, err
485	}
486	return &remote.DeployResult{Status: "queued", Domain: domainName}, nil
487}
488
489func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
490	if l.containerSvc == nil {
491		if withAttachments {
492			return nil, fmt.Errorf("local restart with attachments requires active local container service")
493		}
494		domainName, err := app.SendDeploySignal(restartDomain)
495		if err != nil {
496			return nil, err
497		}
498		return &remote.RestartResult{Status: "queued", Domain: domainName}, nil
499	}
500
501	if err := l.containerSvc.SyncContainers(ctx); err != nil {
502		return nil, err
503	}
504	if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
505		return nil, err
506	}
507
508	return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil
509}
510
511func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
512	if l.registrySvc == nil {
513		return nil, fmt.Errorf("local registry service unavailable")
514	}
515	return l.registrySvc.ListTags(ctx, repository)
516}
517
518func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
519	if l.backupSvc == nil {
520		return nil, fmt.Errorf("local backup service unavailable")
521	}
522	jobs, err := l.backupSvc.ListBackups(ctx, backupDomain)
523	if err != nil {
524		return nil, err
525	}
526	return toDTOBackupJobs(jobs), nil
527}
528
529func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
530	if l.backupSvc == nil {
531		return nil, fmt.Errorf("local backup service unavailable")
532	}
533	jobs, err := l.backupSvc.Status(ctx)
534	if err != nil {
535		return nil, err
536	}
537	return toDTOBackupJobs(jobs), nil
538}
539
540func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
541	if l.backupSvc == nil {
542		return nil, fmt.Errorf("local backup service unavailable")
543	}
544	result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName)
545	if err != nil {
546		return nil, err
547	}
548	if result == nil {
549		return &dto.BackupRunResponse{Status: "ok"}, nil
550	}
551	job := toDTOBackupJob(result.Job)
552	return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil
553}
554
555func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
556	if l.backupSvc == nil {
557		return nil, fmt.Errorf("local backup service unavailable")
558	}
559	dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain)
560	if err != nil {
561		return nil, err
562	}
563	out := make([]dto.DatabaseInfo, 0, len(dbs))
564	for _, db := range dbs {
565		out = append(out, dto.DatabaseInfo{
566			Type:        string(db.Type),
567			Name:        db.Name,
568			Version:     db.Version,
569			Host:        db.Host,
570			Port:        db.Port,
571			ContainerID: db.ContainerID,
572			ImageName:   db.ImageName,
573		})
574	}
575	return out, nil
576}
577
578func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
579	if l.logSvc == nil {
580		return nil, fmt.Errorf("local log service unavailable")
581	}
582	return l.logSvc.GetProcessLogs(ctx, lines)
583}
584
585func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
586	if l.logSvc == nil {
587		return nil, fmt.Errorf("local log service unavailable")
588	}
589	return l.logSvc.GetContainerLogs(ctx, logDomain, lines)
590}
591
592func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
593	if l.logSvc == nil {
594		return nil, fmt.Errorf("local log service unavailable")
595	}
596	return l.logSvc.FollowProcessLogs(ctx, lines)
597}
598
599func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
600	if l.logSvc == nil {
601		return nil, fmt.Errorf("local log service unavailable")
602	}
603	return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
604}
605
606func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
607	if l.volumeSvc == nil {
608		return nil, fmt.Errorf("volume service unavailable")
609	}
610	vols, err := l.volumeSvc.ListVolumes(ctx)
611	if err != nil {
612		return nil, err
613	}
614
615	result := make([]dto.Volume, len(vols))
616	for i, v := range vols {
617		result[i] = dto.Volume{
618			Name:       v.Name,
619			Driver:     v.Driver,
620			MountPoint: v.MountPoint,
621			Size:       v.Size,
622			CreatedAt:  v.CreatedAt,
623			InUse:      v.InUse,
624			Containers: v.Containers,
625			Labels:     v.Labels,
626		}
627	}
628	return result, nil
629}
630
631func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
632	if l.volumeSvc == nil {
633		return nil, fmt.Errorf("volume service unavailable")
634	}
635	report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun)
636	if err != nil {
637		return nil, err
638	}
639
640	vols := make([]dto.Volume, len(removed))
641	for i, v := range removed {
642		vols[i] = dto.Volume{
643			Name: v.Name,
644			Size: v.Size,
645		}
646	}
647
648	return &dto.VolumePruneResponse{
649		VolumesRemoved: report.VolumesRemoved,
650		SpaceReclaimed: report.SpaceReclaimed,
651		Volumes:        vols,
652	}, nil
653}
654
655func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo {
656	detailedByDomain := make(map[string]domain.RouteInfo, len(detailed))
657	for _, info := range detailed {
658		detailedByDomain[info.Domain] = info
659	}
660
661	merged := make([]domain.RouteInfo, 0, len(configured))
662	for _, route := range configured {
663		info, ok := detailedByDomain[route.Domain]
664		if !ok {
665			info = domain.RouteInfo{Domain: route.Domain, Image: route.Image}
666		} else if info.Image == "" {
667			info.Image = route.Image
668		}
669		merged = append(merged, info)
670	}
671
672	return toRemoteRouteInfos(merged)
673}
674
675func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
676	out := make([]remote.RouteInfo, 0, len(routes))
677	for _, route := range routes {
678		attachments := make([]dto.Attachment, 0, len(route.Attachments))
679		for _, attachment := range route.Attachments {
680			attachments = append(attachments, dto.Attachment{
681				Name:        attachment.Name,
682				Image:       attachment.Image,
683				ContainerID: attachment.ContainerID,
684				Status:      attachment.Status,
685				Network:     attachment.Network,
686			})
687		}
688
689		out = append(out, remote.RouteInfo{
690			Domain:          route.Domain,
691			Image:           route.Image,
692			ContainerID:     route.ContainerID,
693			ContainerStatus: route.ContainerStatus,
694			Network:         route.Network,
695			Attachments:     attachments,
696		})
697	}
698	return out
699}
700
701func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
702	out := make([]dto.BackupJob, 0, len(jobs))
703	for _, job := range jobs {
704		out = append(out, toDTOBackupJob(job))
705	}
706	return out
707}
708
709func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
710	var startedAt *time.Time
711	if !job.StartedAt.IsZero() {
712		t := job.StartedAt
713		startedAt = &t
714	}
715	var completedAt *time.Time
716	if !job.CompletedAt.IsZero() {
717		t := job.CompletedAt
718		completedAt = &t
719	}
720
721	return dto.BackupJob{
722		ID:          job.ID,
723		Domain:      job.Domain,
724		DBName:      job.DBName,
725		Schedule:    string(job.Schedule),
726		Type:        string(job.Type),
727		Status:      string(job.Status),
728		StartedAt:   startedAt,
729		CompletedAt: completedAt,
730		SizeBytes:   job.SizeBytes,
731		Error:       job.Error,
732	}
733}

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