v3.3.1 31f1eec

apps.go RawHistoryFolder

1package cli
2
3// App CLI surface for declarative app operations.
4//
5// Conventions (per AGENTS.md): cmd.Context(), cmd.OutOrStdout(), errors
6// from RunE, --json via writeJSON with equivalent semantics, stable
7// sorted ordering, and NO secret values or sensitive output anywhere.
8
9import (
10	"context"
11	"errors"
12	"fmt"
13	"io"
14	"os"
15	"sort"
16	"strings"
17
18	"github.com/spf13/cobra"
19
20	"github.com/bnema/gordon/internal/adapters/dto"
21	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
22	"github.com/bnema/gordon/internal/domain"
23)
24
25// appApplyMaxBytes bounds the apply manifest body (05-api-cli.md §2).
26const appApplyMaxBytes = 1 << 20
27
28// newLocalAppClient constructs the owner-only local daemon client. It is a
29// variable so command tests can inject a socket-backed or unavailable client.
30var newLocalAppClient = remote.NewLocalClient
31
32// resolveAppClient returns the daemon client for app operations. An explicit
33// remote (flag or GORDON_REMOTE) is authoritative and never falls back to the
34// local socket; otherwise the owner-only local admin socket is used.
35func resolveAppClient() (*remote.Client, error) {
36	client, isRemote, err := GetRemoteClient()
37	if err != nil {
38		return nil, err
39	}
40	if isRemote {
41		return client, nil
42	}
43
44	localClient, err := newLocalAppClient()
45	if err != nil {
46		return nil, fmt.Errorf(
47			"daemon-unavailable: no daemon endpoint is reachable; app operations "+
48				"are daemon-owned and have no local-write fallback "+
49				"(start the daemon or pass --remote): %w", err)
50	}
51	return localClient, nil
52}
53
54// resolveAppPlane returns the daemon-backed control plane for app commands.
55// App operations are daemon-owned for BOTH local and remote paths
56// (05-api-cli.md §1): the explicit remote when one is selected, otherwise
57// the owner-only local admin socket. When neither is reachable the command
58// fails with daemon-unavailable — there is no local-write fallback. The
59// returned handle owns the client and must be closed by the caller.
60func resolveAppPlane() (*controlPlaneHandle, error) {
61	client, err := resolveAppClient()
62	if err != nil {
63		return nil, err
64	}
65	return &controlPlaneHandle{plane: client}, nil
66}
67
68// appMutationError translates ambiguous transport outcomes into the
69// outcome-unknown guidance: never blindly retry, re-query by key first.
70func appMutationError(op, app, key string, err error) error {
71	var unknown *remote.OutcomeUnknownError
72	if errors.As(err, &unknown) {
73		return fmt.Errorf(
74			"outcome-unknown: %s %s may have executed; run the by-key lookup "+
75				"gordon apps operations show %s --key %s before retrying with the same key: %w",
76			op, app, app, key, err)
77	}
78	return err
79}
80
81// renderAppOpConflict renders the journaled 409 deploy/lifecycle response
82// carried by remote.AppOpConflictError, then returns the terminal failure
83// (nonzero exit) with the operation key and by-key recovery guidance. It
84// reports false when err is not a journaled conflict so callers fall
85// through to appMutationError.
86func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut bool) (error, bool) {
87	var conflict *remote.AppOpConflictError
88	if !errors.As(err, &conflict) {
89		return nil, false
90	}
91	if jsonOut {
92		if werr := writeJSON(out, conflict.Response); werr != nil {
93			return werr, true
94		}
95	} else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil {
96		return rerr, true
97	}
98	return appOpConflictMessage(op, app, key, conflict), true
99}
100
101// appOpConflictMessage is the by-key recovery guidance shared by the human
102// and JSON renderings of a journaled 409 conflict.
103func appOpConflictMessage(op, app, key string, conflict *remote.AppOpConflictError) error {
104	return fmt.Errorf(
105		"%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+
106			"gordon apps operations show %s --key %s before retrying with the same key: %w",
107		op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, conflict)
108}
109
110// newAppsCmd creates the `apps` parent command.
111func newAppsCmd() *cobra.Command {
112	cmd := &cobra.Command{
113		Use:   "apps",
114		Short: "Manage applications (daemon-owned)",
115		Long: `Validate, persist, inspect, and operate applications.
116
117All mutations are executed by the daemon through the admin API;
118without a reachable daemon the commands fail instead of writing locally.
119
120Examples:
121  gordon apps apply --file blog.toml
122  gordon apps list
123  gordon apps show blog
124  gordon apps diff blog`,
125	}
126	cmd.AddCommand(
127		newAppsApplyCmd(),
128		newAppsOperationsCmd(),
129		newAppsListCmd(),
130		newAppsShowCmd(),
131		newAppsDiffCmd(),
132		newAppsSecretsCmd(),
133		newAppDeployCmd(),
134		newAppRestartCmd(),
135		newAppStopCmd(),
136		newAppStartCmd(),
137		newAppRemoveCmd(),
138		newAppStatusCmd(),
139		newAppLogsCmd(),
140	)
141	return cmd
142}
143
144// newAppsApplyCmd creates `apps apply`.
145func newAppsApplyCmd() *cobra.Command {
146	var file string
147	var dryRun bool
148	var chainDeploy bool
149	var jsonOut bool
150	cmd := &cobra.Command{
151		Use:   "apply",
152		Short: "Validate and persist an app manifest",
153		Long: `Validates a manifest file and persists it as desired state (or dry-runs).
154
155With --deploy, chains exactly the accepted revision into a deploy after
156persistence succeeds; the two outcomes are reported separately because a
157deploy may fail after the apply succeeded.`,
158		RunE: func(cmd *cobra.Command, args []string) error {
159			handle, err := resolveAppPlane()
160			if err != nil {
161				return err
162			}
163			defer handle.close()
164			plane := handle.plane
165			return runAppsApply(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), file, dryRun, chainDeploy, jsonOut)
166		},
167	}
168	cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)")
169	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate without persisting")
170	cmd.Flags().BoolVar(&chainDeploy, "deploy", false, "Deploy the accepted revision after applying")
171	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
172	return cmd
173}
174
175func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out, errOut io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error {
176	if dryRun && chainDeploy {
177		return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy")
178	}
179	if file == "" {
180		return fmt.Errorf("missing required flag --file")
181	}
182	data, err := os.ReadFile(file)
183	if err != nil {
184		return fmt.Errorf("failed to read manifest file: %w", err)
185	}
186	if len(data) > appApplyMaxBytes {
187		return fmt.Errorf("manifest exceeds the 1 MiB apply bound (%d bytes)", len(data))
188	}
189	resp, err := plane.ApplyApp(ctx, dto.AppApplyRequest{ManifestTOML: string(data), DryRun: dryRun})
190	if err != nil {
191		return err
192	}
193	if !chainDeploy {
194		if jsonOut {
195			return writeJSON(out, resp)
196		}
197		return renderAppApply(out, resp, dryRun)
198	}
199	if jsonOut {
200		return runAppsApplyDeployJSON(ctx, plane, resp, out, errOut)
201	}
202	if err := renderAppApply(out, resp, dryRun); err != nil {
203		return err
204	}
205	return runAppsApplyDeploy(ctx, plane, resp, out, errOut)
206}
207
208// appApplyDeployDocument is the single machine-readable apply+deploy result.
209// The apply is always included so a failed chained deploy never hides that
210// the persist itself succeeded.
211type appApplyDeployDocument struct {
212	Apply  *dto.AppApplyResponse  `json:"apply"`
213	Deploy *dto.AppDeployResponse `json:"deploy"`
214}
215
216// runAppsApplyDeploy follows a chained deploy in human mode. The apply outcome
217// is already rendered, then an accepted (202 running) deploy reuses the shared
218// by-key watch: progress only on change, terminal journal, and Ctrl-C guidance
219// that the daemon-side operation keeps running. A terminal partial/failed
220// outcome exits nonzero without hiding the successful apply.
221func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
222	deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
223	if err != nil {
224		if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok {
225			return applySucceededError(apply, conflictErr)
226		}
227		return applySucceededError(apply, appMutationError("deploy", apply.App, key, err))
228	}
229	if deployResp.Status == dto.AppStatusRunning {
230		if werr := watchOperation(ctx, plane, apply.App, key, deployResp, out, errOut, false); werr != nil {
231			return applySucceededError(apply, werr)
232		}
233		return nil
234	}
235	return renderAppDeployResponse(out, deployResp)
236}
237
238// runAppsApplyDeployJSON follows a chained deploy and writes exactly one final
239// {apply,deploy} document once the operation is terminal. The mutation is
240// issued once and observed through the existing by-key endpoint; no initial
241// running document is emitted, and progress, transient warnings, and Ctrl-C
242// resume guidance go to errOut so stdout stays machine-readable.
243func runAppsApplyDeployJSON(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
244	deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision, All: true})
245	if err != nil {
246		var conflict *remote.AppOpConflictError
247		if errors.As(err, &conflict) {
248			if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: &conflict.Response}); werr != nil {
249				return werr
250			}
251			return applySucceededError(apply, appOpConflictMessage("deploy", apply.App, key, conflict))
252		}
253		return applySucceededError(apply, appMutationError("deploy", apply.App, key, err))
254	}
255	terminal := deployResp
256	if deployResp.Status == dto.AppStatusRunning {
257		op, werr := awaitOperation(ctx, plane, apply.App, key, deployResp, errOut)
258		if werr != nil {
259			return applySucceededError(apply, werr)
260		}
261		terminal = op
262	}
263	if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: terminal}); werr != nil {
264		return werr
265	}
266	if terminal.Outcome == domain.AppOutcomeSuccess {
267		return nil
268	}
269	return applySucceededError(apply, &OperationFailedError{App: apply.App, Op: terminal.Op, Outcome: terminal.Outcome})
270}
271
272// applySucceededError preserves the apply outcome in every chained-deploy
273// failure path: the desired state was already persisted, so the caller must
274// not be told the apply failed.
275func applySucceededError(apply *dto.AppApplyResponse, cause error) error {
276	return fmt.Errorf("apply of %s succeeded (%s); %w", apply.App, apply.ResultingRevision, cause)
277}
278
279func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error {
280	if resp.Noop {
281		return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision))))
282	}
283	verb := "Applied"
284	if dryRun {
285		verb = "Validated (dry-run)"
286	}
287	if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("%s %s%s", verb, resp.App, appRevisionSuffix(resp.ResultingRevision)))); err != nil {
288		return err
289	}
290	if err := renderAppDiffSection(out, resp.Diff); err != nil {
291		return err
292	}
293	if resp.Pending {
294		return cliWriteLine(out, cliRenderMeta("intent:", resp.Intent))
295	}
296	return nil
297}
298
299// appRevisionSuffix omits the parenthesized revision when empty (dry-run
300// validation may not produce a revision).
301func appRevisionSuffix(revision string) string {
302	if revision == "" {
303		return ""
304	}
305	return " (" + revision + ")"
306}
307
308// renderAppDiffSection renders normalized diff paths using the same
309// added/removed/changed sections as `apps diff`; empty diffs render nothing.
310func renderAppDiffSection(out io.Writer, diff dto.AppDiffSection) error {
311	if len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) == 0 {
312		return nil
313	}
314	for _, section := range []struct {
315		title string
316		paths []string
317	}{
318		{"added:", diff.Added},
319		{"removed:", diff.Removed},
320		{"changed:", diff.Changed},
321	} {
322		paths := append([]string(nil), section.paths...)
323		sort.Strings(paths)
324		for _, p := range paths {
325			if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil {
326				return err
327			}
328		}
329	}
330	return nil
331}
332
333// newAppsListCmd creates `apps list`.
334func newAppsListCmd() *cobra.Command {
335	var jsonOut bool
336	cmd := &cobra.Command{
337		Use:   "list",
338		Short: "List applications",
339		RunE: func(cmd *cobra.Command, args []string) error {
340			handle, err := resolveAppPlane()
341			if err != nil {
342				return err
343			}
344			defer handle.close()
345			plane := handle.plane
346			return runAppsList(cmd.Context(), plane, cmd.OutOrStdout(), jsonOut)
347		},
348	}
349	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
350	return cmd
351}
352
353func runAppsList(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
354	apps, err := plane.ListApps(ctx)
355	if err != nil {
356		return err
357	}
358	if jsonOut {
359		if apps == nil {
360			apps = []dto.AppSummaryDTO{}
361		}
362		return writeJSON(out, apps)
363	}
364	if len(apps) == 0 {
365		return cliWriteLine(out, cliRenderEmptyState("No apps."))
366	}
367	names := make([]string, 0, len(apps))
368	byName := make(map[string]dto.AppSummaryDTO, len(apps))
369	for _, a := range apps {
370		names = append(names, a.App)
371		byName[a.App] = a
372	}
373	sort.Strings(names)
374	for _, name := range names {
375		a := byName[name]
376		state := "active"
377		switch {
378		case a.Stopped:
379			state = "stopped"
380		case a.Pending:
381			state = "pending"
382		case a.Active == "":
383			state = "applied"
384		case a.LastOutcome != "" && a.LastOutcome != "success":
385			state += " (" + a.LastOutcome + ")"
386		}
387		if err := cliWriteLine(out, cliRenderListItem(fmt.Sprintf("%s (%s)", name, state))); err != nil {
388			return err
389		}
390	}
391	return nil
392}
393
394// newAppsShowCmd creates `apps show`.
395func newAppsShowCmd() *cobra.Command {
396	var jsonOut bool
397	cmd := &cobra.Command{
398		Use:   "show APP",
399		Short: "Show desired and active state for an app",
400		Args:  cobra.ExactArgs(1),
401		RunE: func(cmd *cobra.Command, args []string) error {
402			handle, err := resolveAppPlane()
403			if err != nil {
404				return err
405			}
406			defer handle.close()
407			plane := handle.plane
408			return runAppsShow(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
409		},
410	}
411	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
412	return cmd
413}
414
415func runAppsShow(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
416	resp, err := plane.ShowApp(ctx, app)
417	if err != nil {
418		return err
419	}
420	if jsonOut {
421		return writeJSON(out, resp)
422	}
423	if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil {
424		return err
425	}
426	if err := cliWriteLine(out, cliRenderMeta("desired:", resp.Desired.Revision+" ("+resp.Desired.Status+")")); err != nil {
427		return err
428	}
429	for _, line := range renderActiveServices(resp.Active) {
430		if err := cliWriteLine(out, line); err != nil {
431			return err
432		}
433	}
434	if resp.Intent.Stopped {
435		if err := cliWriteLine(out, cliRenderWarning("stopped intent is set")); err != nil {
436			return err
437		}
438	}
439	if resp.LastOp != nil && resp.LastOp.Op != "" {
440		if err := cliWriteLine(out, cliRenderMeta("last-op:", resp.LastOp.Op+" ("+resp.LastOp.Outcome+")")); err != nil {
441			return err
442		}
443	}
444	owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...)
445	owned = append(owned, resp.Retained.Secrets...)
446	if len(owned) > 0 {
447		sort.Strings(owned)
448		return cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " ")))
449	}
450	return nil
451}
452
453// singleWriterLegend explains the single-writer mark (restart_unsafe in
454// JSON) once per listing: recovery never restarts a replaced generation of
455// a volume- or bind-owning service on top of its replacement.
456const singleWriterLegend = "single-writer: has volumes or binds; recovery never restarts a replaced container over its successor (informational, no action needed)"
457
458// renderActiveServices renders per-service effective state, sorted by name.
459// Only ids and digests appear — never secret values.
460func renderActiveServices(active dto.AppActiveDTO) []string {
461	names := make([]string, 0, len(active.Services))
462	for name := range active.Services {
463		names = append(names, name)
464	}
465	sort.Strings(names)
466	lines := make([]string, 0, len(names)+2)
467	converged := "converged"
468	if !active.Converged {
469		converged = "diverged"
470	}
471	lines = append(lines, cliRenderMeta("active:", converged))
472	singleWriter := false
473	for _, name := range names {
474		svc := active.Services[name]
475		detail := svc.EffectiveRevision
476		if svc.Container != "" {
477			detail += " container=" + svc.Container
478		}
479		if svc.RestartUnsafe {
480			detail += " single-writer"
481			singleWriter = true
482		}
483		lines = append(lines, cliRenderMeta("  "+name+":", detail))
484	}
485	if singleWriter {
486		lines = append(lines, cliRenderMuted("  "+singleWriterLegend))
487	}
488	return lines
489}
490
491// newAppsDiffCmd creates `apps diff`.
492func newAppsDiffCmd() *cobra.Command {
493	var jsonOut bool
494	cmd := &cobra.Command{
495		Use:   "diff APP",
496		Short: "Show the normalized desired-vs-active diff",
497		Args:  cobra.ExactArgs(1),
498		RunE: func(cmd *cobra.Command, args []string) error {
499			handle, err := resolveAppPlane()
500			if err != nil {
501				return err
502			}
503			defer handle.close()
504			plane := handle.plane
505			return runAppsDiff(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
506		},
507	}
508	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
509	return cmd
510}
511
512func runAppsDiff(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
513	resp, err := plane.DiffApp(ctx, app)
514	if err != nil {
515		return err
516	}
517	if jsonOut {
518		return writeJSON(out, resp)
519	}
520	if len(resp.Diff.Added) == 0 && len(resp.Diff.Removed) == 0 && len(resp.Diff.Changed) == 0 {
521		return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No differences for %s.", app)))
522	}
523	for _, section := range []struct {
524		title string
525		paths []string
526	}{
527		{"added:", resp.Diff.Added},
528		{"removed:", resp.Diff.Removed},
529		{"changed:", resp.Diff.Changed},
530	} {
531		paths := append([]string(nil), section.paths...)
532		sort.Strings(paths)
533		for _, p := range paths {
534			if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil {
535				return err
536			}
537		}
538	}
539	return nil
540}
541
542// newAppsSecretsCmd creates the `apps secrets` parent command.
543func newAppsSecretsCmd() *cobra.Command {
544	cmd := &cobra.Command{
545		Use:   "secrets",
546		Short: "Manage app secret values",
547		Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history),
548--stdin with KEY=VALUE lines, or --stdin --key KEY for one raw value. Names
549must already exist in desired or active state. Only key names are ever echoed
550back — never values.`,
551	}
552	cmd.AddCommand(newAppsSecretsListCmd(), newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())
553	return cmd
554}
555
556func newAppsSecretsListCmd() *cobra.Command {
557	var service string
558	var jsonOut bool
559	cmd := &cobra.Command{Use: "list APP", Short: "List app secret registration metadata", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
560		handle, err := resolveAppPlane()
561		if err != nil {
562			return err
563		}
564		defer handle.close()
565		return runAppsSecretsList(cmd.Context(), handle.plane, cmd.OutOrStdout(), args[0], service, jsonOut)
566	}}
567	cmd.Flags().StringVar(&service, "service", "", "Filter by service")
568	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
569	return cmd
570}
571
572// runAppsSecretsList renders metadata-only registrations for one app,
573// optionally filtered to one service. Secret values never appear.
574func runAppsSecretsList(ctx context.Context, plane ControlPlane, out io.Writer, app, service string, jsonOut bool) error {
575	entries, err := plane.ListAppSecrets(ctx, app, service)
576	if err != nil {
577		return fmt.Errorf("list app secrets for %s: %w", app, err)
578	}
579	if jsonOut {
580		return writeJSON(out, entries)
581	}
582	for _, entry := range entries {
583		if err := cliWriteLine(out, cliRenderMeta(entry.Service+"/"+entry.Key+":", entry.Name+" "+entry.Source+" "+entry.Presence)); err != nil {
584			return err
585		}
586	}
587	if len(entries) == 0 {
588		return cliWriteLine(out, cliRenderMuted("No registered secrets"))
589	}
590	return nil
591}
592
593// newAppsSecretsSetCmd creates `apps secrets set`.
594func newAppsSecretsSetCmd() *cobra.Command {
595	var service string
596	var key string
597	var fromStdin bool
598	var jsonOut bool
599	cmd := &cobra.Command{
600		Use:   "set APP KEY=VALUE…",
601		Short: "Write app secret values",
602		Args:  cobra.MinimumNArgs(0),
603		RunE: func(cmd *cobra.Command, args []string) error {
604			handle, err := resolveAppPlane()
605			if err != nil {
606				return err
607			}
608			defer handle.close()
609			plane := handle.plane
610			if len(args) == 0 {
611				return fmt.Errorf("missing APP argument")
612			}
613			return runAppsSecretsSetMode(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), args[0], args[1:], service, key, fromStdin, jsonOut)
614		},
615	}
616	cmd.Flags().StringVar(&service, "service", "", "Service the secrets belong to (required)")
617	cmd.Flags().StringVar(&key, "key", "", "Secret key for single-value stdin mode")
618	cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Read KEY=VALUE lines, or one raw value with --key")
619	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
620	return cmd
621}
622
623func runAppsSecretsSet(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service string, fromStdin, jsonOut bool) error {
624	return runAppsSecretsSetMode(ctx, plane, stdin, out, app, pairs, service, "", fromStdin, jsonOut)
625}
626
627func runAppsSecretsSetMode(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service, key string, fromStdin, jsonOut bool) error {
628	if app == "" {
629		return fmt.Errorf("missing APP argument")
630	}
631	if service == "" {
632		return fmt.Errorf("missing required flag --service: secrets are service-scoped")
633	}
634	if key != "" {
635		if !fromStdin || len(pairs) != 0 {
636			return fmt.Errorf("--key requires --stdin and forbids KEY=VALUE arguments")
637		}
638		value, err := readRawSecretStdin(stdin)
639		if err != nil {
640			return err
641		}
642		pairs = []string{key + "=" + value}
643	} else if fromStdin {
644		stdinPairs, err := readSecretStdin(stdin)
645		if err != nil {
646			return err
647		}
648		pairs = append(pairs, stdinPairs...)
649	}
650	values, err := parseSecretPairs(pairs)
651	if err != nil {
652		return err
653	}
654	if len(values) == 0 {
655		return fmt.Errorf("no secrets provided: pass KEY=VALUE arguments or --stdin")
656	}
657	if err := plane.SetAppSecrets(ctx, app, dto.AppSecretSetRequest{Service: service, Secrets: values}); err != nil {
658		return err
659	}
660	keys := make([]string, 0, len(values))
661	for k := range values {
662		keys = append(keys, k)
663	}
664	sort.Strings(keys)
665	if jsonOut {
666		return writeJSON(out, map[string]any{"app": app, "service": service, "keys": keys})
667	}
668	if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Set %d secret(s) for %s/%s: %s", len(keys), app, service, strings.Join(keys, ", ")))); err != nil {
669		return err
670	}
671	return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("Running containers keep their old values. Apply them with: gordon apps deploy %s --service %s", app, service)))
672}
673
674// newAppsSecretsDeleteCmd creates `apps secrets delete`.
675func newAppsSecretsDeleteCmd() *cobra.Command {
676	var service string
677	var jsonOut bool
678	cmd := &cobra.Command{
679		Use:   "delete APP KEY",
680		Short: "Delete an app secret value",
681		Args:  cobra.ExactArgs(2),
682		RunE: func(cmd *cobra.Command, args []string) error {
683			handle, err := resolveAppPlane()
684			if err != nil {
685				return err
686			}
687			defer handle.close()
688			plane := handle.plane
689			return runAppsSecretsDelete(cmd.Context(), plane, cmd.OutOrStdout(), args[0], args[1], service, jsonOut)
690		},
691	}
692	cmd.Flags().StringVar(&service, "service", "", "Service the secret belongs to (required)")
693	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
694	return cmd
695}
696
697func runAppsSecretsDelete(ctx context.Context, plane ControlPlane, out io.Writer, app, key, service string, jsonOut bool) error {
698	if service == "" {
699		return fmt.Errorf("missing required flag --service: secrets are service-scoped")
700	}
701	if key == "" {
702		return fmt.Errorf("missing KEY argument")
703	}
704	if err := plane.DeleteAppSecret(ctx, app, dto.AppSecretDeleteRequest{Service: service, Key: key}); err != nil {
705		return err
706	}
707	if jsonOut {
708		return writeJSON(out, map[string]any{"app": app, "service": service, "deleted": key})
709	}
710	return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service)))
711}
712
713// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty and
714// each value must pass validateSecretValue (1-MaxAppEnvValueLen bytes on a
715// single line); an empty value is rejected.
716func parseSecretPairs(pairs []string) (map[string]string, error) {
717	values := make(map[string]string, len(pairs))
718	for index, pair := range pairs {
719		key, value, ok := strings.Cut(pair, "=")
720		if !ok || key == "" {
721			return nil, fmt.Errorf("invalid secret input %d: expected KEY=VALUE", index+1)
722		}
723		if err := validateSecretValue(value); err != nil {
724			return nil, fmt.Errorf("invalid secret input %d: %w", index+1, err)
725		}
726		values[key] = value
727	}
728	return values, nil
729}
730
731func validateSecretValue(value string) error {
732	if value == "" || len(value) > domain.MaxAppEnvValueLen {
733		return fmt.Errorf("value must be 1-%d bytes", domain.MaxAppEnvValueLen)
734	}
735	if strings.ContainsAny(value, "\r\n") {
736		return fmt.Errorf("value must be a single line")
737	}
738	return nil
739}
740
741func readRawSecretStdin(in io.Reader) (string, error) {
742	data, err := io.ReadAll(io.LimitReader(in, domain.MaxAppEnvValueLen+3))
743	if err != nil {
744		return "", fmt.Errorf("failed to read secret from stdin: %w", err)
745	}
746	if strings.HasSuffix(string(data), "\r\n") {
747		data = data[:len(data)-2]
748	} else if strings.HasSuffix(string(data), "\n") {
749		data = data[:len(data)-1]
750	}
751	value := string(data)
752	if err := validateSecretValue(value); err != nil {
753		return "", fmt.Errorf("invalid stdin secret: %w", err)
754	}
755	return value, nil
756}
757
758const maxSecretStdinBytes = 1 << 20
759
760// readSecretStdin reads bounded KEY=VALUE lines. Whitespace-only lines are
761// ignored, while every byte in nonblank values is preserved.
762func readSecretStdin(in io.Reader) ([]string, error) {
763	data, err := io.ReadAll(io.LimitReader(in, maxSecretStdinBytes+1))
764	if err != nil {
765		return nil, fmt.Errorf("failed to read secrets from stdin: %w", err)
766	}
767	if len(data) > maxSecretStdinBytes {
768		return nil, fmt.Errorf("secret input exceeds %d bytes", maxSecretStdinBytes)
769	}
770	var pairs []string
771	for _, line := range strings.Split(string(data), "\n") {
772		line = strings.TrimSuffix(line, "\r")
773		if strings.TrimSpace(line) != "" {
774			pairs = append(pairs, line)
775		}
776	}
777	return pairs, nil
778}
779
780// newAppDeployCmd creates `deploy APP`. At cutover this replaces the
781// domain-based deploy for apps; domain-based deploy keeps working for
782// non-app resources until then (05-api-cli.md §5).
783func newAppDeployCmd() *cobra.Command {
784	var revision string
785	var service string
786	var all bool
787	var jsonOut bool
788	cmd := &cobra.Command{
789		Use:   "deploy APP",
790		Short: "Activate an app revision",
791		Args:  cobra.ExactArgs(1),
792		RunE: func(cmd *cobra.Command, args []string) error {
793			handle, err := resolveAppPlane()
794			if err != nil {
795				return err
796			}
797			defer handle.close()
798			plane := handle.plane
799			req := dto.AppDeployRequest{Revision: revision, Service: service, All: all}
800			return runAppDeploy(cmd.Context(), plane, args[0], req, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)
801		},
802	}
803	cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
804	cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only")
805	cmd.Flags().BoolVar(&all, "all", false, "Deploy every service (required for multi-service apps without --service)")
806	cmd.MarkFlagsMutuallyExclusive("service", "all")
807	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
808	return cmd
809}
810
811// runAppDeploy issues one deploy mutation and, when the daemon answers 202
812// with a running journal, polls the existing by-key endpoint to terminal
813// rather than reissuing the mutation or hiding the outcome.
814func runAppDeploy(ctx context.Context, plane ControlPlane, app string, req dto.AppDeployRequest, out, errOut io.Writer, jsonOut bool) error {
815	resp, key, err := plane.DeployApp(ctx, app, req)
816	if err != nil {
817		if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {
818			return conflictErr
819		}
820		return appMutationError("deploy", app, key, err)
821	}
822	if resp.Status == dto.AppStatusRunning {
823		return watchOperation(ctx, plane, app, key, resp, out, errOut, jsonOut)
824	}
825	if jsonOut {
826		return writeJSON(out, resp)
827	}
828	return renderAppDeployResponse(out, resp)
829}
830
831// newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve.
832func newAppRestartCmd() *cobra.Command {
833	var service string
834	var all bool
835	var jsonOut bool
836	cmd := &cobra.Command{
837		Use:   "restart APP",
838		Short: "Restart an app from pinned digests",
839		Args:  cobra.ExactArgs(1),
840		RunE: func(cmd *cobra.Command, args []string) error {
841			handle, err := resolveAppPlane()
842			if err != nil {
843				return err
844			}
845			defer handle.close()
846			plane := handle.plane
847			return runAppRestart(cmd.Context(), plane, args[0], service, all, cmd.OutOrStdout(), jsonOut)
848		},
849	}
850	cmd.Flags().StringVar(&service, "service", "", "Restart a single service only")
851	cmd.Flags().BoolVar(&all, "all", false, "Restart every service (required for multi-service apps without --service)")
852	cmd.MarkFlagsMutuallyExclusive("service", "all")
853	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
854	return cmd
855}
856
857func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, all bool, out io.Writer, jsonOut bool) error {
858	resp, key, err := plane.RestartApp(ctx, app, service, all)
859	if err != nil {
860		if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok {
861			return conflictErr
862		}
863		return appMutationError("restart", app, key, err)
864	}
865	if jsonOut {
866		return writeJSON(out, resp)
867	}
868	return renderAppDeployResponse(out, resp)
869}
870
871// newAppStopCmd creates `stop APP`: durable stopped intent, data preserved.
872func newAppStopCmd() *cobra.Command {
873	var jsonOut bool
874	cmd := &cobra.Command{
875		Use:   "stop APP",
876		Short: "Stop an app (preserves all data)",
877		Args:  cobra.ExactArgs(1),
878		RunE: func(cmd *cobra.Command, args []string) error {
879			handle, err := resolveAppPlane()
880			if err != nil {
881				return err
882			}
883			defer handle.close()
884			plane := handle.plane
885			return runAppLifecycle(cmd.Context(), plane.StopApp, "stop", args[0], cmd.OutOrStdout(), jsonOut)
886		},
887	}
888	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
889	return cmd
890}
891
892// newAppStartCmd creates `start APP`: clears intent, ensures running.
893func newAppStartCmd() *cobra.Command {
894	var jsonOut bool
895	cmd := &cobra.Command{
896		Use:   "start APP",
897		Short: "Start a stopped app from active state",
898		Args:  cobra.ExactArgs(1),
899		RunE: func(cmd *cobra.Command, args []string) error {
900			handle, err := resolveAppPlane()
901			if err != nil {
902				return err
903			}
904			defer handle.close()
905			plane := handle.plane
906			return runAppLifecycle(cmd.Context(), plane.StartApp, "start", args[0], cmd.OutOrStdout(), jsonOut)
907		},
908	}
909	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
910	return cmd
911}
912
913// newAppRemoveCmd creates `remove APP`: withdraws workloads, retains data.
914func newAppRemoveCmd() *cobra.Command {
915	var jsonOut bool
916	cmd := &cobra.Command{
917		Use:   "remove APP",
918		Short: "Remove app workloads (volumes and secrets are retained)",
919		Args:  cobra.ExactArgs(1),
920		RunE: func(cmd *cobra.Command, args []string) error {
921			handle, err := resolveAppPlane()
922			if err != nil {
923				return err
924			}
925			defer handle.close()
926			plane := handle.plane
927			return runAppLifecycle(cmd.Context(), plane.RemoveApp, "remove", args[0], cmd.OutOrStdout(), jsonOut)
928		},
929	}
930	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
931	return cmd
932}
933
934// appLifecycleFunc is one body-less app lifecycle mutation.
935type appLifecycleFunc func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
936
937func runAppLifecycle(ctx context.Context, fn appLifecycleFunc, op, app string, out io.Writer, jsonOut bool) error {
938	resp, key, err := fn(ctx, app)
939	if err != nil {
940		if conflictErr, ok := renderAppOpConflict(out, op, app, key, err, jsonOut); ok {
941			return conflictErr
942		}
943		return appMutationError(op, app, key, err)
944	}
945	if jsonOut {
946		return writeJSON(out, resp)
947	}
948	return renderAppDeployResponse(out, resp)
949}
950
951// renderAppDeployResponse renders the deploy outcome with terminal
952// per-service results, cleanup warnings, and the effective/retained
953// summaries. Sorts services for stable output.
954func renderAppDeployResponse(out io.Writer, resp *dto.AppDeployResponse) error {
955	if err := renderDeployHeadline(out, resp); err != nil {
956		return err
957	}
958	if err := renderDeployServices(out, resp); err != nil {
959		return err
960	}
961	if err := renderDeploySteps(out, resp.Steps); err != nil {
962		return err
963	}
964	return renderDeploySummaries(out, resp)
965}
966
967func renderDeploySteps(out io.Writer, steps []dto.AppStepDTO) error {
968	for _, step := range steps {
969		detail := step.State
970		if step.Detail != "" {
971			detail += ": " + sanitizeTerminalText(step.Detail)
972		}
973		if step.Error != "" {
974			detail += ": " + sanitizeTerminalText(step.Error)
975		}
976		if err := cliWriteLine(out, cliRenderMeta("  step "+step.ID+":", detail)); err != nil {
977			return err
978		}
979		for _, diagnostic := range step.Diagnostics {
980			if err := cliWriteLine(out, cliRenderMuted("    "+sanitizeTerminalText(diagnostic))); err != nil {
981				return err
982			}
983		}
984	}
985	return nil
986}
987
988func sanitizeTerminalText(value string) string {
989	return strings.Map(func(r rune) rune {
990		if r == '\n' || r == '\t' || r >= ' ' && r != '\x7f' {
991			return r
992		}
993		return '�'
994	}, value)
995}
996
997func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error {
998	headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome)
999	if resp.Outcome == "success" {
1000		return cliWriteLine(out, cliRenderSuccess(headline))
1001	}
1002	return cliWriteLine(out, cliRenderWarning(headline))
1003}
1004
1005func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
1006	names := make([]string, 0, len(resp.Services))
1007	for name := range resp.Services {
1008		names = append(names, name)
1009	}
1010	sort.Strings(names)
1011	for _, name := range names {
1012		svc := resp.Services[name]
1013		detail := strings.TrimSpace(svc.Result + " " + svc.EffectiveRevision)
1014		if svc.Result == domain.AppServiceUnchanged {
1015			detail += " (already running this image, config, and secrets)"
1016		}
1017		if svc.Error != "" {
1018			detail += ": " + svc.Error
1019		}
1020		if err := cliWriteLine(out, cliRenderMeta("  "+name+":", detail)); err != nil {
1021			return err
1022		}
1023	}
1024	for _, w := range resp.CleanupWarnings {
1025		if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("cleanup: %s left %s (%s)", w.Service, w.Leftover, w.Detail))); err != nil {
1026			return err
1027		}
1028	}
1029	return nil
1030}
1031
1032func renderDeploySummaries(out io.Writer, resp *dto.AppDeployResponse) error {
1033	if resp.Effective != nil && len(resp.Effective.Services) > 0 {
1034		pairs := make([]string, 0, len(resp.Effective.Services))
1035		for name, rev := range resp.Effective.Services {
1036			pairs = append(pairs, name+"="+rev)
1037		}
1038		sort.Strings(pairs)
1039		if err := cliWriteLine(out, cliRenderMeta("effective:", strings.Join(pairs, " "))); err != nil {
1040			return err
1041		}
1042	}
1043	if resp.Retained == nil {
1044		return nil
1045	}
1046	owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...)
1047	owned = append(owned, resp.Retained.Secrets...)
1048	if len(owned) > 0 {
1049		sort.Strings(owned)
1050		if err := cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " "))); err != nil {
1051			return err
1052		}
1053	}
1054	return nil
1055}
1056
1057// newAppStatusCmd creates `status APP`: effective vs observed, per service.
1058func newAppStatusCmd() *cobra.Command {
1059	var jsonOut bool
1060	cmd := &cobra.Command{
1061		Use:   "status APP",
1062		Short: "Show effective vs observed state for an app",
1063		Args:  cobra.ExactArgs(1),
1064		RunE: func(cmd *cobra.Command, args []string) error {
1065			handle, err := resolveAppPlane()
1066			if err != nil {
1067				return err
1068			}
1069			defer handle.close()
1070			plane := handle.plane
1071			return runAppStatus(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
1072		},
1073	}
1074	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1075	return cmd
1076}
1077
1078func runAppStatus(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
1079	resp, err := plane.ShowApp(ctx, app)
1080	if err != nil {
1081		return err
1082	}
1083	if jsonOut {
1084		return writeJSON(out, resp)
1085	}
1086	if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil {
1087		return err
1088	}
1089	for _, line := range renderActiveServices(resp.Active) {
1090		if err := cliWriteLine(out, line); err != nil {
1091			return err
1092		}
1093	}
1094	var running []string
1095	for _, svc := range resp.Active.Services {
1096		if svc.Container != "" {
1097			running = append(running, svc.Container)
1098		}
1099	}
1100	sort.Strings(running)
1101	observed := "none"
1102	if len(running) > 0 {
1103		observed = strings.Join(running, " ")
1104	}
1105	if err := cliWriteLine(out, cliRenderMeta("observed:", observed)); err != nil {
1106		return err
1107	}
1108	if resp.Intent.Stopped {
1109		return cliWriteLine(out, cliRenderWarning("stopped intent is set"))
1110	}
1111	return nil
1112}
1113
1114// appLogReader streams logs by Gordon app/service reference. The daemon
1115// resolves the reference through its authoritative ACTIVE record.
1116type appLogReader interface {
1117	GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error)
1118	StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error)
1119}
1120
1121// remoteAppLogReader adapts *remote.Client to appLogReader.
1122type remoteAppLogReader struct {
1123	client *remote.Client
1124}
1125
1126func (r *remoteAppLogReader) GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error) {
1127	return r.client.GetContainerLogs(ctx, ref, lines)
1128}
1129
1130func (r *remoteAppLogReader) StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error) {
1131	return r.client.StreamContainerLogs(ctx, ref, lines)
1132}
1133
1134// newAppLogsCmd creates `logs APP`.
1135func newAppLogsCmd() *cobra.Command {
1136	var service string
1137	var follow bool
1138	var tail int
1139	var jsonOut bool
1140	cmd := &cobra.Command{
1141		Use:   "logs APP",
1142		Short: "Show logs for an app service",
1143		Args:  cobra.ExactArgs(1),
1144		RunE: func(cmd *cobra.Command, args []string) error {
1145			client, err := resolveAppClient()
1146			if err != nil {
1147				return err
1148			}
1149			return runAppLogs(cmd.Context(), client, &remoteAppLogReader{client: client}, args[0], service, follow, tail, cmd.OutOrStdout(), jsonOut)
1150		},
1151	}
1152	cmd.Flags().StringVar(&service, "service", "", "Service to read logs from (required when the app has several)")
1153	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
1154	cmd.Flags().IntVarP(&tail, "tail", "n", 50, "Number of lines to show")
1155	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1156	return cmd
1157}
1158
1159func runAppLogs(ctx context.Context, plane ControlPlane, reader appLogReader, app, service string, follow bool, tail int, out io.Writer, jsonOut bool) error {
1160	if follow && jsonOut {
1161		return fmt.Errorf("cannot combine --json with --follow: follow streams plain log lines")
1162	}
1163	show, err := plane.ShowApp(ctx, app)
1164	if err != nil {
1165		return err
1166	}
1167	ref, err := appLogRef(show, service)
1168	if err != nil {
1169		return err
1170	}
1171	if follow {
1172		stream, err := reader.StreamContainerLogs(ctx, ref, tail)
1173		if err != nil {
1174			return err
1175		}
1176		for line := range stream {
1177			if err := cliWriteLine(out, line); err != nil {
1178				return err
1179			}
1180		}
1181		return nil
1182	}
1183	lines, err := reader.GetContainerLogs(ctx, ref, tail)
1184	if err != nil {
1185		return err
1186	}
1187	if jsonOut {
1188		return writeJSON(out, map[string]any{"app": app, "service": service, "lines": lines})
1189	}
1190	for _, line := range lines {
1191		if err := cliWriteLine(out, line); err != nil {
1192			return err
1193		}
1194	}
1195	return nil
1196}
1197
1198// appLogRef resolves the container ref for one app service. When service
1199// is empty and the app has exactly one service, that service is used;
1200// otherwise --service is required. A service with no recorded container
1201// has nothing to stream yet.
1202func appLogRef(show *dto.AppShowResponse, service string) (string, error) {
1203	if len(show.Active.Services) == 0 {
1204		return "", fmt.Errorf("app %s has no active services yet", show.App)
1205	}
1206	if service == "" {
1207		if len(show.Active.Services) == 1 {
1208			for name := range show.Active.Services {
1209				service = name
1210			}
1211		} else {
1212			names := make([]string, 0, len(show.Active.Services))
1213			for name := range show.Active.Services {
1214				names = append(names, name)
1215			}
1216			sort.Strings(names)
1217			return "", fmt.Errorf("app %s has several services (%s): pass --service", show.App, strings.Join(names, ", "))
1218		}
1219	}
1220	svc, ok := show.Active.Services[service]
1221	if !ok {
1222		return "", fmt.Errorf("app %s has no service %q", show.App, service)
1223	}
1224	if svc.Container == "" {
1225		return "", fmt.Errorf("service %s has no recorded container yet", service)
1226	}
1227	return show.App + "/" + service, nil
1228}

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