Skip to content

Commit ba47dad

Browse files
committed
feat(cli): add --app flag to host-targeted commands
Adds --app <name> to status, stats, log, lock, unlock, maintenance on/off, start, stop, restart, env set/get/list/unset, and accessory list. With --app + --host these commands resolve the target app from server-side state instead of requiring a teploy.yml in the cwd — the same pattern rollback --app already used. Motivation: teploy-dash shells out to the CLI for these actions but has no app directory on disk. Previously it could only pass --host, so the CLI fell back to config.LoadApp(".") and either failed or acted on the wrong app. Now dash can target any {server, app} pair. New shared resolveApp(ctx, flags, appName) helper in connect.go unifies the two resolution paths: - appName == "": load teploy.yml from cwd, connect to its server - appName != "": require --host, read deploy state from server (no teploy.yml needed); Domain is enriched from state for commands that need it (maintenance) Notes: - maintenance keeps its 'requires Caddy-managed ingress' guard only on the cwd path — server state doesn't record ingress mode, so --app assumes default Caddy ingress. - accessory stop/start/logs/upgrade/backup/restore still need accessory config from teploy.yml and remain cwd-bound; only 'accessory list' (read-only) gained --app. Verified end-to-end on a live VM: status/env/lock/unlock/restart/log --app all work from a directory with no teploy.yml; the --host-required guard fires cleanly. Unit tests cover the guard + the no-config error.
1 parent 6593dfd commit ba47dad

10 files changed

Lines changed: 196 additions & 120 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to teploy are documented here. Format follows [Keep a Change
55
## [Unreleased]
66

77
### Added
8+
- `--app <name>` flag on `status`, `stats`, `log`, `lock`, `unlock`, `maintenance on/off`, `start`, `stop`, `restart`, `env set/get/list/unset`, and `accessory list`. With `--app` + `--host`, these commands act on a deployed app by reading server-side state instead of requiring a `teploy.yml` in the working directory — the same model `rollback --app` already used. This is what lets teploy-dash (and any automation without an app checkout) drive these commands for arbitrary apps. A shared `resolveApp` helper unifies the cwd-`teploy.yml` path and the `--app`+`--host` path. `maintenance` keeps its Caddy-ingress guard only on the cwd path (server state doesn't record ingress mode); the accessory subcommands that need accessory config from `teploy.yml` (stop/start/logs/upgrade/backup/restore) remain cwd-bound, only `accessory list` gained `--app`.
89
- `keep_versions: N` — auto-prune older app versions on deploy. The current and immediately-previous versions are always protected; older versions beyond `N` are removed (containers + images). Container-deploy only — static deploys use `keep_releases`. Default `0` = keep everything (legacy behavior).
910
- `healthcheck.<proc>.disable` — per-process override that passes `--no-healthcheck` to `docker run`. Useful for worker containers that share an image with a web container and would otherwise inherit a useless HTTP healthcheck.
1011
- `ingress: external` — opt out of Caddy entirely. The container still joins the `teploy` Docker network with its app-name alias, but Teploy doesn't write or reload the Caddyfile. For users fronting the app with Cloudflare Tunnel, Tailscale Funnel, nginx, AWS ALB, or any other external ingress.

‎internal/cli/accessory.go‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,26 +49,28 @@ func newAccessoryCmd(flags *Flags) *cobra.Command {
4949
}
5050

5151
func newAccessoryListCmd(flags *Flags) *cobra.Command {
52-
return &cobra.Command{
52+
var appName string
53+
cmd := &cobra.Command{
5354
Use: "list",
5455
Short: "List accessory containers",
5556
Args: cobra.NoArgs,
5657
RunE: func(cmd *cobra.Command, args []string) error {
57-
return runAccessoryList(flags)
58+
return runAccessoryList(flags, appName)
5859
},
5960
}
61+
// `list` only needs the app name (read-only container listing), so it
62+
// supports --app for use outside an app directory (e.g. teploy-dash).
63+
// The other accessory subcommands need accessory config from teploy.yml
64+
// and remain cwd-bound.
65+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
66+
return cmd
6067
}
6168

62-
func runAccessoryList(flags *Flags) error {
63-
appCfg, err := loadAppCfgForAccessory()
64-
if err != nil {
65-
return err
66-
}
67-
69+
func runAccessoryList(flags *Flags, appName string) error {
6870
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
6971
defer cancel()
7072

71-
executor, err := connectForApp(ctx, flags, appCfg)
73+
appCfg, executor, err := resolveApp(ctx, flags, appName)
7274
if err != nil {
7375
return err
7476
}

‎internal/cli/connect.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,59 @@ import (
77

88
"github.com/useteploy/teploy/internal/config"
99
"github.com/useteploy/teploy/internal/ssh"
10+
"github.com/useteploy/teploy/internal/state"
1011
)
1112

13+
// resolveApp returns the app config plus an open SSH connection for a command
14+
// that operates on a single app. It unifies the two ways a command can be told
15+
// which app + server to act on:
16+
//
17+
// - Without --app (appName == ""): load teploy.yml from the cwd and connect
18+
// to the app's configured server. This is the normal interactive path.
19+
// - With --app (appName != ""): require --host and read the deployed state
20+
// from the server — no teploy.yml needed. This is the path teploy-dash and
21+
// other automation use, where there is no app directory on disk.
22+
//
23+
// In the --app path the returned AppConfig is minimal: App + Server are always
24+
// set, and Domain is enriched from server-side state when available (commands
25+
// like maintenance need it). Fields that only live in teploy.yml (e.g. custom
26+
// Ingress) are not recoverable from server state, so --app assumes the default
27+
// Caddy-managed ingress; commands that depend on non-default ingress should be
28+
// run from an app directory.
29+
//
30+
// The caller owns the returned executor and must Close it.
31+
func resolveApp(ctx context.Context, flags *Flags, appName string) (*config.AppConfig, ssh.Executor, error) {
32+
if appName == "" {
33+
appCfg, err := config.LoadApp(".")
34+
if err != nil {
35+
return nil, nil, err
36+
}
37+
ex, err := connectForApp(ctx, flags, appCfg)
38+
if err != nil {
39+
return nil, nil, err
40+
}
41+
return appCfg, ex, nil
42+
}
43+
44+
if flags.Host == "" {
45+
return nil, nil, fmt.Errorf("--host is required when using --app")
46+
}
47+
host, user, key, err := config.ResolveServer(flags.Host, flags.Host, flags.User, flags.Key)
48+
if err != nil {
49+
return nil, nil, err
50+
}
51+
fmt.Printf("Connecting to %s@%s...\n", user, host)
52+
ex, err := ssh.Connect(ctx, ssh.ConnectConfig{Host: host, User: user, KeyPath: key})
53+
if err != nil {
54+
return nil, nil, err
55+
}
56+
appCfg := &config.AppConfig{App: appName, Server: flags.Host}
57+
if st, err := state.Read(ctx, ex, appName); err == nil && st != nil {
58+
appCfg.Domain = st.Domain
59+
}
60+
return appCfg, ex, nil
61+
}
62+
1263
// connectForApp resolves the server from app config and establishes an SSH connection.
1364
// Uses the first server from appCfg.Servers if Server is empty.
1465
func connectForApp(ctx context.Context, flags *Flags, appCfg *config.AppConfig) (ssh.Executor, error) {

‎internal/cli/connect_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// resolveApp's --app path requires --host. The guard must fire BEFORE any SSH
10+
// connection attempt, so this is pure-logic testable without a live server.
11+
// (The happy --app path does a real SSH connect + state.Read and is covered by
12+
// end-to-end smoke tests against a live VM, not unit tests.)
13+
func TestResolveApp_AppWithoutHostErrors(t *testing.T) {
14+
_, _, err := resolveApp(context.Background(), &Flags{}, "myapp")
15+
if err == nil {
16+
t.Fatal("expected error when --app is set without --host")
17+
}
18+
if !strings.Contains(err.Error(), "--host is required") {
19+
t.Errorf("error = %q, want it to mention --host is required", err.Error())
20+
}
21+
}
22+
23+
// With no --app and no teploy.yml in the cwd, resolveApp should surface the
24+
// config-load error (not panic, not silently succeed). t.Chdir to a temp dir
25+
// guarantees there's no teploy.yml to accidentally pick up.
26+
func TestResolveApp_NoAppNoConfigErrors(t *testing.T) {
27+
t.Chdir(t.TempDir())
28+
_, _, err := resolveApp(context.Background(), &Flags{}, "")
29+
if err == nil {
30+
t.Fatal("expected error when no --app and no teploy.yml present")
31+
}
32+
}

‎internal/cli/env.go‎

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ func newEnvCmd(flags *Flags) *cobra.Command {
2727
}
2828

2929
func newEnvSetCmd(flags *Flags) *cobra.Command {
30-
return &cobra.Command{
30+
var appName string
31+
cmd := &cobra.Command{
3132
Use: "set KEY=value [KEY=value...]",
3233
Short: "Set one or more environment variables",
3334
Args: cobra.MinimumNArgs(1),
@@ -40,21 +41,18 @@ func newEnvSetCmd(flags *Flags) *cobra.Command {
4041
}
4142
pairs[arg[:idx]] = arg[idx+1:]
4243
}
43-
return runEnvSet(flags, pairs)
44+
return runEnvSet(flags, appName, pairs)
4445
},
4546
}
47+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
48+
return cmd
4649
}
4750

48-
func runEnvSet(flags *Flags, pairs map[string]string) error {
49-
appCfg, err := config.LoadApp(".")
50-
if err != nil {
51-
return err
52-
}
53-
51+
func runEnvSet(flags *Flags, appName string, pairs map[string]string) error {
5452
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
5553
defer cancel()
5654

57-
executor, err := connectForApp(ctx, flags, appCfg)
55+
appCfg, executor, err := resolveApp(ctx, flags, appName)
5856
if err != nil {
5957
return err
6058
}
@@ -72,26 +70,24 @@ func runEnvSet(flags *Flags, pairs map[string]string) error {
7270
}
7371

7472
func newEnvGetCmd(flags *Flags) *cobra.Command {
75-
return &cobra.Command{
73+
var appName string
74+
cmd := &cobra.Command{
7675
Use: "get KEY",
7776
Short: "Get the value of an environment variable",
7877
Args: cobra.ExactArgs(1),
7978
RunE: func(cmd *cobra.Command, args []string) error {
80-
return runEnvGet(flags, args[0])
79+
return runEnvGet(flags, appName, args[0])
8180
},
8281
}
82+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
83+
return cmd
8384
}
8485

85-
func runEnvGet(flags *Flags, key string) error {
86-
appCfg, err := config.LoadApp(".")
87-
if err != nil {
88-
return err
89-
}
90-
86+
func runEnvGet(flags *Flags, appName, key string) error {
9187
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
9288
defer cancel()
9389

94-
executor, err := connectForApp(ctx, flags, appCfg)
90+
appCfg, executor, err := resolveApp(ctx, flags, appName)
9591
if err != nil {
9692
return err
9793
}
@@ -110,30 +106,27 @@ func runEnvGet(flags *Flags, key string) error {
110106
func newEnvListCmd(flags *Flags) *cobra.Command {
111107
var reveal bool
112108

109+
var appName string
113110
cmd := &cobra.Command{
114111
Use: "list",
115112
Short: "List all environment variables",
116113
Args: cobra.NoArgs,
117114
RunE: func(cmd *cobra.Command, args []string) error {
118-
return runEnvList(flags, reveal)
115+
return runEnvList(flags, appName, reveal)
119116
},
120117
}
121118

122119
cmd.Flags().BoolVar(&reveal, "reveal", false, "show values instead of masking them")
120+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
123121

124122
return cmd
125123
}
126124

127-
func runEnvList(flags *Flags, reveal bool) error {
128-
appCfg, err := config.LoadApp(".")
129-
if err != nil {
130-
return err
131-
}
132-
125+
func runEnvList(flags *Flags, appName string, reveal bool) error {
133126
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
134127
defer cancel()
135128

136-
executor, err := connectForApp(ctx, flags, appCfg)
129+
appCfg, executor, err := resolveApp(ctx, flags, appName)
137130
if err != nil {
138131
return err
139132
}
@@ -161,26 +154,24 @@ func runEnvList(flags *Flags, reveal bool) error {
161154
}
162155

163156
func newEnvUnsetCmd(flags *Flags) *cobra.Command {
164-
return &cobra.Command{
157+
var appName string
158+
cmd := &cobra.Command{
165159
Use: "unset KEY",
166160
Short: "Remove an environment variable",
167161
Args: cobra.ExactArgs(1),
168162
RunE: func(cmd *cobra.Command, args []string) error {
169-
return runEnvUnset(flags, args[0])
163+
return runEnvUnset(flags, appName, args[0])
170164
},
171165
}
166+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
167+
return cmd
172168
}
173169

174-
func runEnvUnset(flags *Flags, key string) error {
175-
appCfg, err := config.LoadApp(".")
176-
if err != nil {
177-
return err
178-
}
179-
170+
func runEnvUnset(flags *Flags, appName, key string) error {
180171
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
181172
defer cancel()
182173

183-
executor, err := connectForApp(ctx, flags, appCfg)
174+
appCfg, executor, err := resolveApp(ctx, flags, appName)
184175
if err != nil {
185176
return err
186177
}

‎internal/cli/lifecycle.go‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,54 +7,57 @@ import (
77
"os/signal"
88

99
"github.com/spf13/cobra"
10-
"github.com/useteploy/teploy/internal/config"
1110
"github.com/useteploy/teploy/internal/deploy"
1211
"github.com/useteploy/teploy/internal/notify"
1312
)
1413

1514
func newStopCmd(flags *Flags) *cobra.Command {
16-
return &cobra.Command{
15+
var appName string
16+
cmd := &cobra.Command{
1717
Use: "stop",
1818
Short: "Stop all containers for the app",
1919
Args: cobra.NoArgs,
2020
RunE: func(cmd *cobra.Command, args []string) error {
21-
return runLifecycle(flags, "stop")
21+
return runLifecycle(flags, appName, "stop")
2222
},
2323
}
24+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
25+
return cmd
2426
}
2527

2628
func newStartCmd(flags *Flags) *cobra.Command {
27-
return &cobra.Command{
29+
var appName string
30+
cmd := &cobra.Command{
2831
Use: "start",
2932
Short: "Start all stopped containers for the app",
3033
Args: cobra.NoArgs,
3134
RunE: func(cmd *cobra.Command, args []string) error {
32-
return runLifecycle(flags, "start")
35+
return runLifecycle(flags, appName, "start")
3336
},
3437
}
38+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
39+
return cmd
3540
}
3641

3742
func newRestartCmd(flags *Flags) *cobra.Command {
38-
return &cobra.Command{
43+
var appName string
44+
cmd := &cobra.Command{
3945
Use: "restart",
4046
Short: "Restart all containers for the app",
4147
Args: cobra.NoArgs,
4248
RunE: func(cmd *cobra.Command, args []string) error {
43-
return runLifecycle(flags, "restart")
49+
return runLifecycle(flags, appName, "restart")
4450
},
4551
}
52+
cmd.Flags().StringVar(&appName, "app", "", "app name — act on server state instead of teploy.yml (requires --host)")
53+
return cmd
4654
}
4755

48-
func runLifecycle(flags *Flags, action string) error {
49-
appCfg, err := config.LoadApp(".")
50-
if err != nil {
51-
return err
52-
}
53-
56+
func runLifecycle(flags *Flags, appName, action string) error {
5457
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
5558
defer cancel()
5659

57-
executor, err := connectForApp(ctx, flags, appCfg)
60+
appCfg, executor, err := resolveApp(ctx, flags, appName)
5861
if err != nil {
5962
return err
6063
}

0 commit comments

Comments
 (0)