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}