v2.31.0 e02f654

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

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