v3.0.0 f19f453

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// renderActiveServices renders per-service effective state, sorted by name.
454// Only ids and digests appear — never secret values.
455func renderActiveServices(active dto.AppActiveDTO) []string {
456	names := make([]string, 0, len(active.Services))
457	for name := range active.Services {
458		names = append(names, name)
459	}
460	sort.Strings(names)
461	lines := make([]string, 0, len(names)+1)
462	converged := "converged"
463	if !active.Converged {
464		converged = "diverged"
465	}
466	lines = append(lines, cliRenderMeta("active:", converged))
467	for _, name := range names {
468		svc := active.Services[name]
469		detail := svc.EffectiveRevision
470		if svc.Container != "" {
471			detail += " container=" + svc.Container
472		}
473		if svc.RestartUnsafe {
474			detail += " restart_unsafe"
475		}
476		lines = append(lines, cliRenderMeta("  "+name+":", detail))
477	}
478	return lines
479}
480
481// newAppsDiffCmd creates `apps diff`.
482func newAppsDiffCmd() *cobra.Command {
483	var jsonOut bool
484	cmd := &cobra.Command{
485		Use:   "diff APP",
486		Short: "Show the normalized desired-vs-active diff",
487		Args:  cobra.ExactArgs(1),
488		RunE: func(cmd *cobra.Command, args []string) error {
489			handle, err := resolveAppPlane()
490			if err != nil {
491				return err
492			}
493			defer handle.close()
494			plane := handle.plane
495			return runAppsDiff(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
496		},
497	}
498	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
499	return cmd
500}
501
502func runAppsDiff(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
503	resp, err := plane.DiffApp(ctx, app)
504	if err != nil {
505		return err
506	}
507	if jsonOut {
508		return writeJSON(out, resp)
509	}
510	if len(resp.Diff.Added) == 0 && len(resp.Diff.Removed) == 0 && len(resp.Diff.Changed) == 0 {
511		return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No differences for %s.", app)))
512	}
513	for _, section := range []struct {
514		title string
515		paths []string
516	}{
517		{"added:", resp.Diff.Added},
518		{"removed:", resp.Diff.Removed},
519		{"changed:", resp.Diff.Changed},
520	} {
521		paths := append([]string(nil), section.paths...)
522		sort.Strings(paths)
523		for _, p := range paths {
524			if err := cliWriteLine(out, cliRenderMeta(section.title, p)); err != nil {
525				return err
526			}
527		}
528	}
529	return nil
530}
531
532// newAppsSecretsCmd creates the `apps secrets` parent command.
533func newAppsSecretsCmd() *cobra.Command {
534	cmd := &cobra.Command{
535		Use:   "secrets",
536		Short: "Manage app secret values",
537		Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history),
538--stdin with KEY=VALUE lines, or --stdin --key KEY for one raw value. Names
539must already exist in desired or active state. Only key names are ever echoed
540back — never values.`,
541	}
542	cmd.AddCommand(newAppsSecretsListCmd(), newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())
543	return cmd
544}
545
546func newAppsSecretsListCmd() *cobra.Command {
547	var service string
548	var jsonOut bool
549	cmd := &cobra.Command{Use: "list APP", Short: "List app secret registration metadata", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
550		handle, err := resolveAppPlane()
551		if err != nil {
552			return err
553		}
554		defer handle.close()
555		return runAppsSecretsList(cmd.Context(), handle.plane, cmd.OutOrStdout(), args[0], service, jsonOut)
556	}}
557	cmd.Flags().StringVar(&service, "service", "", "Filter by service")
558	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
559	return cmd
560}
561
562// runAppsSecretsList renders metadata-only registrations for one app,
563// optionally filtered to one service. Secret values never appear.
564func runAppsSecretsList(ctx context.Context, plane ControlPlane, out io.Writer, app, service string, jsonOut bool) error {
565	entries, err := plane.ListAppSecrets(ctx, app, service)
566	if err != nil {
567		return fmt.Errorf("list app secrets for %s: %w", app, err)
568	}
569	if jsonOut {
570		return writeJSON(out, entries)
571	}
572	for _, entry := range entries {
573		if err := cliWriteLine(out, cliRenderMeta(entry.Service+"/"+entry.Key+":", entry.Name+" "+entry.Source+" "+entry.Presence)); err != nil {
574			return err
575		}
576	}
577	if len(entries) == 0 {
578		return cliWriteLine(out, cliRenderMuted("No registered secrets"))
579	}
580	return nil
581}
582
583// newAppsSecretsSetCmd creates `apps secrets set`.
584func newAppsSecretsSetCmd() *cobra.Command {
585	var service string
586	var key string
587	var fromStdin bool
588	var jsonOut bool
589	cmd := &cobra.Command{
590		Use:   "set APP KEY=VALUE…",
591		Short: "Write app secret values",
592		Args:  cobra.MinimumNArgs(0),
593		RunE: func(cmd *cobra.Command, args []string) error {
594			handle, err := resolveAppPlane()
595			if err != nil {
596				return err
597			}
598			defer handle.close()
599			plane := handle.plane
600			if len(args) == 0 {
601				return fmt.Errorf("missing APP argument")
602			}
603			return runAppsSecretsSetMode(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), args[0], args[1:], service, key, fromStdin, jsonOut)
604		},
605	}
606	cmd.Flags().StringVar(&service, "service", "", "Service the secrets belong to (required)")
607	cmd.Flags().StringVar(&key, "key", "", "Secret key for single-value stdin mode")
608	cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Read KEY=VALUE lines, or one raw value with --key")
609	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
610	return cmd
611}
612
613func runAppsSecretsSet(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service string, fromStdin, jsonOut bool) error {
614	return runAppsSecretsSetMode(ctx, plane, stdin, out, app, pairs, service, "", fromStdin, jsonOut)
615}
616
617func runAppsSecretsSetMode(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service, key string, fromStdin, jsonOut bool) error {
618	if app == "" {
619		return fmt.Errorf("missing APP argument")
620	}
621	if service == "" {
622		return fmt.Errorf("missing required flag --service: secrets are service-scoped")
623	}
624	if key != "" {
625		if !fromStdin || len(pairs) != 0 {
626			return fmt.Errorf("--key requires --stdin and forbids KEY=VALUE arguments")
627		}
628		value, err := readRawSecretStdin(stdin)
629		if err != nil {
630			return err
631		}
632		pairs = []string{key + "=" + value}
633	} else if fromStdin {
634		stdinPairs, err := readSecretStdin(stdin)
635		if err != nil {
636			return err
637		}
638		pairs = append(pairs, stdinPairs...)
639	}
640	values, err := parseSecretPairs(pairs)
641	if err != nil {
642		return err
643	}
644	if len(values) == 0 {
645		return fmt.Errorf("no secrets provided: pass KEY=VALUE arguments or --stdin")
646	}
647	if err := plane.SetAppSecrets(ctx, app, dto.AppSecretSetRequest{Service: service, Secrets: values}); err != nil {
648		return err
649	}
650	keys := make([]string, 0, len(values))
651	for k := range values {
652		keys = append(keys, k)
653	}
654	sort.Strings(keys)
655	if jsonOut {
656		return writeJSON(out, map[string]any{"app": app, "service": service, "keys": keys})
657	}
658	if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Set %d secret(s) for %s/%s: %s", len(keys), app, service, strings.Join(keys, ", ")))); err != nil {
659		return err
660	}
661	return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("Running containers keep their old values. Apply them with: gordon apps deploy %s --service %s", app, service)))
662}
663
664// newAppsSecretsDeleteCmd creates `apps secrets delete`.
665func newAppsSecretsDeleteCmd() *cobra.Command {
666	var service string
667	var jsonOut bool
668	cmd := &cobra.Command{
669		Use:   "delete APP KEY",
670		Short: "Delete an app secret value",
671		Args:  cobra.ExactArgs(2),
672		RunE: func(cmd *cobra.Command, args []string) error {
673			handle, err := resolveAppPlane()
674			if err != nil {
675				return err
676			}
677			defer handle.close()
678			plane := handle.plane
679			return runAppsSecretsDelete(cmd.Context(), plane, cmd.OutOrStdout(), args[0], args[1], service, jsonOut)
680		},
681	}
682	cmd.Flags().StringVar(&service, "service", "", "Service the secret belongs to (required)")
683	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
684	return cmd
685}
686
687func runAppsSecretsDelete(ctx context.Context, plane ControlPlane, out io.Writer, app, key, service string, jsonOut bool) error {
688	if service == "" {
689		return fmt.Errorf("missing required flag --service: secrets are service-scoped")
690	}
691	if key == "" {
692		return fmt.Errorf("missing KEY argument")
693	}
694	if err := plane.DeleteAppSecret(ctx, app, dto.AppSecretDeleteRequest{Service: service, Key: key}); err != nil {
695		return err
696	}
697	if jsonOut {
698		return writeJSON(out, map[string]any{"app": app, "service": service, "deleted": key})
699	}
700	return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service)))
701}
702
703// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty and
704// each value must pass validateSecretValue (1-MaxAppEnvValueLen bytes on a
705// single line); an empty value is rejected.
706func parseSecretPairs(pairs []string) (map[string]string, error) {
707	values := make(map[string]string, len(pairs))
708	for index, pair := range pairs {
709		key, value, ok := strings.Cut(pair, "=")
710		if !ok || key == "" {
711			return nil, fmt.Errorf("invalid secret input %d: expected KEY=VALUE", index+1)
712		}
713		if err := validateSecretValue(value); err != nil {
714			return nil, fmt.Errorf("invalid secret input %d: %w", index+1, err)
715		}
716		values[key] = value
717	}
718	return values, nil
719}
720
721func validateSecretValue(value string) error {
722	if value == "" || len(value) > domain.MaxAppEnvValueLen {
723		return fmt.Errorf("value must be 1-%d bytes", domain.MaxAppEnvValueLen)
724	}
725	if strings.ContainsAny(value, "\r\n") {
726		return fmt.Errorf("value must be a single line")
727	}
728	return nil
729}
730
731func readRawSecretStdin(in io.Reader) (string, error) {
732	data, err := io.ReadAll(io.LimitReader(in, domain.MaxAppEnvValueLen+3))
733	if err != nil {
734		return "", fmt.Errorf("failed to read secret from stdin: %w", err)
735	}
736	if strings.HasSuffix(string(data), "\r\n") {
737		data = data[:len(data)-2]
738	} else if strings.HasSuffix(string(data), "\n") {
739		data = data[:len(data)-1]
740	}
741	value := string(data)
742	if err := validateSecretValue(value); err != nil {
743		return "", fmt.Errorf("invalid stdin secret: %w", err)
744	}
745	return value, nil
746}
747
748const maxSecretStdinBytes = 1 << 20
749
750// readSecretStdin reads bounded KEY=VALUE lines. Whitespace-only lines are
751// ignored, while every byte in nonblank values is preserved.
752func readSecretStdin(in io.Reader) ([]string, error) {
753	data, err := io.ReadAll(io.LimitReader(in, maxSecretStdinBytes+1))
754	if err != nil {
755		return nil, fmt.Errorf("failed to read secrets from stdin: %w", err)
756	}
757	if len(data) > maxSecretStdinBytes {
758		return nil, fmt.Errorf("secret input exceeds %d bytes", maxSecretStdinBytes)
759	}
760	var pairs []string
761	for _, line := range strings.Split(string(data), "\n") {
762		line = strings.TrimSuffix(line, "\r")
763		if strings.TrimSpace(line) != "" {
764			pairs = append(pairs, line)
765		}
766	}
767	return pairs, nil
768}
769
770// newAppDeployCmd creates `deploy APP`. At cutover this replaces the
771// domain-based deploy for apps; domain-based deploy keeps working for
772// non-app resources until then (05-api-cli.md §5).
773func newAppDeployCmd() *cobra.Command {
774	var revision string
775	var service string
776	var all bool
777	var jsonOut bool
778	cmd := &cobra.Command{
779		Use:   "deploy APP",
780		Short: "Activate an app revision",
781		Args:  cobra.ExactArgs(1),
782		RunE: func(cmd *cobra.Command, args []string) error {
783			handle, err := resolveAppPlane()
784			if err != nil {
785				return err
786			}
787			defer handle.close()
788			plane := handle.plane
789			req := dto.AppDeployRequest{Revision: revision, Service: service, All: all}
790			return runAppDeploy(cmd.Context(), plane, args[0], req, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)
791		},
792	}
793	cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
794	cmd.Flags().StringVar(&service, "service", "", "Deploy a single service only")
795	cmd.Flags().BoolVar(&all, "all", false, "Deploy every service (required for multi-service apps without --service)")
796	cmd.MarkFlagsMutuallyExclusive("service", "all")
797	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
798	return cmd
799}
800
801// runAppDeploy issues one deploy mutation and, when the daemon answers 202
802// with a running journal, polls the existing by-key endpoint to terminal
803// rather than reissuing the mutation or hiding the outcome.
804func runAppDeploy(ctx context.Context, plane ControlPlane, app string, req dto.AppDeployRequest, out, errOut io.Writer, jsonOut bool) error {
805	resp, key, err := plane.DeployApp(ctx, app, req)
806	if err != nil {
807		if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {
808			return conflictErr
809		}
810		return appMutationError("deploy", app, key, err)
811	}
812	if resp.Status == dto.AppStatusRunning {
813		return watchOperation(ctx, plane, app, key, resp, out, errOut, jsonOut)
814	}
815	if jsonOut {
816		return writeJSON(out, resp)
817	}
818	return renderAppDeployResponse(out, resp)
819}
820
821// newAppRestartCmd creates `restart APP`: pinned digests, no re-resolve.
822func newAppRestartCmd() *cobra.Command {
823	var service string
824	var all bool
825	var jsonOut bool
826	cmd := &cobra.Command{
827		Use:   "restart APP",
828		Short: "Restart an app from pinned digests",
829		Args:  cobra.ExactArgs(1),
830		RunE: func(cmd *cobra.Command, args []string) error {
831			handle, err := resolveAppPlane()
832			if err != nil {
833				return err
834			}
835			defer handle.close()
836			plane := handle.plane
837			return runAppRestart(cmd.Context(), plane, args[0], service, all, cmd.OutOrStdout(), jsonOut)
838		},
839	}
840	cmd.Flags().StringVar(&service, "service", "", "Restart a single service only")
841	cmd.Flags().BoolVar(&all, "all", false, "Restart every service (required for multi-service apps without --service)")
842	cmd.MarkFlagsMutuallyExclusive("service", "all")
843	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
844	return cmd
845}
846
847func runAppRestart(ctx context.Context, plane ControlPlane, app, service string, all bool, out io.Writer, jsonOut bool) error {
848	resp, key, err := plane.RestartApp(ctx, app, service, all)
849	if err != nil {
850		if conflictErr, ok := renderAppOpConflict(out, "restart", app, key, err, jsonOut); ok {
851			return conflictErr
852		}
853		return appMutationError("restart", app, key, err)
854	}
855	if jsonOut {
856		return writeJSON(out, resp)
857	}
858	return renderAppDeployResponse(out, resp)
859}
860
861// newAppStopCmd creates `stop APP`: durable stopped intent, data preserved.
862func newAppStopCmd() *cobra.Command {
863	var jsonOut bool
864	cmd := &cobra.Command{
865		Use:   "stop APP",
866		Short: "Stop an app (preserves all data)",
867		Args:  cobra.ExactArgs(1),
868		RunE: func(cmd *cobra.Command, args []string) error {
869			handle, err := resolveAppPlane()
870			if err != nil {
871				return err
872			}
873			defer handle.close()
874			plane := handle.plane
875			return runAppLifecycle(cmd.Context(), plane.StopApp, "stop", args[0], cmd.OutOrStdout(), jsonOut)
876		},
877	}
878	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
879	return cmd
880}
881
882// newAppStartCmd creates `start APP`: clears intent, ensures running.
883func newAppStartCmd() *cobra.Command {
884	var jsonOut bool
885	cmd := &cobra.Command{
886		Use:   "start APP",
887		Short: "Start a stopped app from active state",
888		Args:  cobra.ExactArgs(1),
889		RunE: func(cmd *cobra.Command, args []string) error {
890			handle, err := resolveAppPlane()
891			if err != nil {
892				return err
893			}
894			defer handle.close()
895			plane := handle.plane
896			return runAppLifecycle(cmd.Context(), plane.StartApp, "start", args[0], cmd.OutOrStdout(), jsonOut)
897		},
898	}
899	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
900	return cmd
901}
902
903// newAppRemoveCmd creates `remove APP`: withdraws workloads, retains data.
904func newAppRemoveCmd() *cobra.Command {
905	var jsonOut bool
906	cmd := &cobra.Command{
907		Use:   "remove APP",
908		Short: "Remove app workloads (volumes and secrets are retained)",
909		Args:  cobra.ExactArgs(1),
910		RunE: func(cmd *cobra.Command, args []string) error {
911			handle, err := resolveAppPlane()
912			if err != nil {
913				return err
914			}
915			defer handle.close()
916			plane := handle.plane
917			return runAppLifecycle(cmd.Context(), plane.RemoveApp, "remove", args[0], cmd.OutOrStdout(), jsonOut)
918		},
919	}
920	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
921	return cmd
922}
923
924// appLifecycleFunc is one body-less app lifecycle mutation.
925type appLifecycleFunc func(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
926
927func runAppLifecycle(ctx context.Context, fn appLifecycleFunc, op, app string, out io.Writer, jsonOut bool) error {
928	resp, key, err := fn(ctx, app)
929	if err != nil {
930		if conflictErr, ok := renderAppOpConflict(out, op, app, key, err, jsonOut); ok {
931			return conflictErr
932		}
933		return appMutationError(op, app, key, err)
934	}
935	if jsonOut {
936		return writeJSON(out, resp)
937	}
938	return renderAppDeployResponse(out, resp)
939}
940
941// renderAppDeployResponse renders the deploy outcome with terminal
942// per-service results, cleanup warnings, and the effective/retained
943// summaries. Sorts services for stable output.
944func renderAppDeployResponse(out io.Writer, resp *dto.AppDeployResponse) error {
945	if err := renderDeployHeadline(out, resp); err != nil {
946		return err
947	}
948	if err := renderDeployServices(out, resp); err != nil {
949		return err
950	}
951	if err := renderDeploySteps(out, resp.Steps); err != nil {
952		return err
953	}
954	return renderDeploySummaries(out, resp)
955}
956
957func renderDeploySteps(out io.Writer, steps []dto.AppStepDTO) error {
958	for _, step := range steps {
959		detail := step.State
960		if step.Detail != "" {
961			detail += ": " + sanitizeTerminalText(step.Detail)
962		}
963		if step.Error != "" {
964			detail += ": " + sanitizeTerminalText(step.Error)
965		}
966		if err := cliWriteLine(out, cliRenderMeta("  step "+step.ID+":", detail)); err != nil {
967			return err
968		}
969		for _, diagnostic := range step.Diagnostics {
970			if err := cliWriteLine(out, cliRenderMuted("    "+sanitizeTerminalText(diagnostic))); err != nil {
971				return err
972			}
973		}
974	}
975	return nil
976}
977
978func sanitizeTerminalText(value string) string {
979	return strings.Map(func(r rune) rune {
980		if r == '\n' || r == '\t' || r >= ' ' && r != '\x7f' {
981			return r
982		}
983		return '�'
984	}, value)
985}
986
987func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error {
988	headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome)
989	if resp.Outcome == "success" {
990		return cliWriteLine(out, cliRenderSuccess(headline))
991	}
992	return cliWriteLine(out, cliRenderWarning(headline))
993}
994
995func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
996	names := make([]string, 0, len(resp.Services))
997	for name := range resp.Services {
998		names = append(names, name)
999	}
1000	sort.Strings(names)
1001	for _, name := range names {
1002		svc := resp.Services[name]
1003		detail := svc.Result + " " + svc.EffectiveRevision
1004		if svc.Result == domain.AppServiceUnchanged {
1005			detail += " (already running this image, config, and secrets)"
1006		}
1007		if svc.RestartUnsafe {
1008			detail += " restart_unsafe"
1009		}
1010		if svc.Error != "" {
1011			detail += ": " + svc.Error
1012		}
1013		if err := cliWriteLine(out, cliRenderMeta("  "+name+":", detail)); err != nil {
1014			return err
1015		}
1016	}
1017	for _, w := range resp.CleanupWarnings {
1018		if err := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("cleanup: %s left %s (%s)", w.Service, w.Leftover, w.Detail))); err != nil {
1019			return err
1020		}
1021	}
1022	return nil
1023}
1024
1025func renderDeploySummaries(out io.Writer, resp *dto.AppDeployResponse) error {
1026	if resp.Effective != nil && len(resp.Effective.Services) > 0 {
1027		pairs := make([]string, 0, len(resp.Effective.Services))
1028		for name, rev := range resp.Effective.Services {
1029			pairs = append(pairs, name+"="+rev)
1030		}
1031		sort.Strings(pairs)
1032		if err := cliWriteLine(out, cliRenderMeta("effective:", strings.Join(pairs, " "))); err != nil {
1033			return err
1034		}
1035	}
1036	if resp.Retained == nil {
1037		return nil
1038	}
1039	owned := append(append([]string(nil), resp.Retained.Volumes...), resp.Retained.Images...)
1040	owned = append(owned, resp.Retained.Secrets...)
1041	if len(owned) > 0 {
1042		sort.Strings(owned)
1043		if err := cliWriteLine(out, cliRenderMeta("retained:", strings.Join(owned, " "))); err != nil {
1044			return err
1045		}
1046	}
1047	return nil
1048}
1049
1050// newAppStatusCmd creates `status APP`: effective vs observed, per service.
1051func newAppStatusCmd() *cobra.Command {
1052	var jsonOut bool
1053	cmd := &cobra.Command{
1054		Use:   "status APP",
1055		Short: "Show effective vs observed state for an app",
1056		Args:  cobra.ExactArgs(1),
1057		RunE: func(cmd *cobra.Command, args []string) error {
1058			handle, err := resolveAppPlane()
1059			if err != nil {
1060				return err
1061			}
1062			defer handle.close()
1063			plane := handle.plane
1064			return runAppStatus(cmd.Context(), plane, args[0], cmd.OutOrStdout(), jsonOut)
1065		},
1066	}
1067	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1068	return cmd
1069}
1070
1071func runAppStatus(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
1072	resp, err := plane.ShowApp(ctx, app)
1073	if err != nil {
1074		return err
1075	}
1076	if jsonOut {
1077		return writeJSON(out, resp)
1078	}
1079	if err := cliWriteLine(out, cliRenderTitle(resp.App)); err != nil {
1080		return err
1081	}
1082	for _, line := range renderActiveServices(resp.Active) {
1083		if err := cliWriteLine(out, line); err != nil {
1084			return err
1085		}
1086	}
1087	var running []string
1088	for _, svc := range resp.Active.Services {
1089		if svc.Container != "" {
1090			running = append(running, svc.Container)
1091		}
1092	}
1093	sort.Strings(running)
1094	observed := "none"
1095	if len(running) > 0 {
1096		observed = strings.Join(running, " ")
1097	}
1098	if err := cliWriteLine(out, cliRenderMeta("observed:", observed)); err != nil {
1099		return err
1100	}
1101	if resp.Intent.Stopped {
1102		return cliWriteLine(out, cliRenderWarning("stopped intent is set"))
1103	}
1104	return nil
1105}
1106
1107// appLogReader streams logs by Gordon app/service reference. The daemon
1108// resolves the reference through its authoritative ACTIVE record.
1109type appLogReader interface {
1110	GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error)
1111	StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error)
1112}
1113
1114// remoteAppLogReader adapts *remote.Client to appLogReader.
1115type remoteAppLogReader struct {
1116	client *remote.Client
1117}
1118
1119func (r *remoteAppLogReader) GetContainerLogs(ctx context.Context, ref string, lines int) ([]string, error) {
1120	return r.client.GetContainerLogs(ctx, ref, lines)
1121}
1122
1123func (r *remoteAppLogReader) StreamContainerLogs(ctx context.Context, ref string, lines int) (<-chan string, error) {
1124	return r.client.StreamContainerLogs(ctx, ref, lines)
1125}
1126
1127// newAppLogsCmd creates `logs APP`.
1128func newAppLogsCmd() *cobra.Command {
1129	var service string
1130	var follow bool
1131	var tail int
1132	var jsonOut bool
1133	cmd := &cobra.Command{
1134		Use:   "logs APP",
1135		Short: "Show logs for an app service",
1136		Args:  cobra.ExactArgs(1),
1137		RunE: func(cmd *cobra.Command, args []string) error {
1138			client, err := resolveAppClient()
1139			if err != nil {
1140				return err
1141			}
1142			return runAppLogs(cmd.Context(), client, &remoteAppLogReader{client: client}, args[0], service, follow, tail, cmd.OutOrStdout(), jsonOut)
1143		},
1144	}
1145	cmd.Flags().StringVar(&service, "service", "", "Service to read logs from (required when the app has several)")
1146	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
1147	cmd.Flags().IntVarP(&tail, "tail", "n", 50, "Number of lines to show")
1148	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1149	return cmd
1150}
1151
1152func runAppLogs(ctx context.Context, plane ControlPlane, reader appLogReader, app, service string, follow bool, tail int, out io.Writer, jsonOut bool) error {
1153	if follow && jsonOut {
1154		return fmt.Errorf("cannot combine --json with --follow: follow streams plain log lines")
1155	}
1156	show, err := plane.ShowApp(ctx, app)
1157	if err != nil {
1158		return err
1159	}
1160	ref, err := appLogRef(show, service)
1161	if err != nil {
1162		return err
1163	}
1164	if follow {
1165		stream, err := reader.StreamContainerLogs(ctx, ref, tail)
1166		if err != nil {
1167			return err
1168		}
1169		for line := range stream {
1170			if err := cliWriteLine(out, line); err != nil {
1171				return err
1172			}
1173		}
1174		return nil
1175	}
1176	lines, err := reader.GetContainerLogs(ctx, ref, tail)
1177	if err != nil {
1178		return err
1179	}
1180	if jsonOut {
1181		return writeJSON(out, map[string]any{"app": app, "service": service, "lines": lines})
1182	}
1183	for _, line := range lines {
1184		if err := cliWriteLine(out, line); err != nil {
1185			return err
1186		}
1187	}
1188	return nil
1189}
1190
1191// appLogRef resolves the container ref for one app service. When service
1192// is empty and the app has exactly one service, that service is used;
1193// otherwise --service is required. A service with no recorded container
1194// has nothing to stream yet.
1195func appLogRef(show *dto.AppShowResponse, service string) (string, error) {
1196	if len(show.Active.Services) == 0 {
1197		return "", fmt.Errorf("app %s has no active services yet", show.App)
1198	}
1199	if service == "" {
1200		if len(show.Active.Services) == 1 {
1201			for name := range show.Active.Services {
1202				service = name
1203			}
1204		} else {
1205			names := make([]string, 0, len(show.Active.Services))
1206			for name := range show.Active.Services {
1207				names = append(names, name)
1208			}
1209			sort.Strings(names)
1210			return "", fmt.Errorf("app %s has several services (%s): pass --service", show.App, strings.Join(names, ", "))
1211		}
1212	}
1213	svc, ok := show.Active.Services[service]
1214	if !ok {
1215		return "", fmt.Errorf("app %s has no service %q", show.App, service)
1216	}
1217	if svc.Container == "" {
1218		return "", fmt.Errorf("service %s has no recorded container yet", service)
1219	}
1220	return show.App + "/" + service, nil
1221}

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 8

Tags 106

↑↓ moveenter openesc close