v2.31.0-dev17 bd898f0

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

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