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}