Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9e57b05
docs: add --no-proxy mode design spec (#102)
t-kim-planitai Apr 21, 2026
5020ca5
docs: add --no-proxy mode implementation plan (#102)
t-kim-planitai Apr 21, 2026
882b4fb
feat(app): add Mode type, marker helpers, and mode resolution
t-kim-planitai Apr 21, 2026
e262c6d
feat(app/init): add --no-proxy branch and persist mode marker
t-kim-planitai Apr 21, 2026
db7b1b9
fix(app/init): make proxy-path marker write fatal (review I1)
t-kim-planitai Apr 21, 2026
b6fa465
feat(app/deploy): add --no-proxy flat deploy path
t-kim-planitai Apr 21, 2026
0281f2f
fix(app/deploy): proxy path also verifies marker (review I1, S2)
t-kim-planitai Apr 21, 2026
5337b93
feat(app/rollback): reject no-proxy mode with recovery guidance
t-kim-planitai Apr 21, 2026
09e9fcd
feat(app/destroy): skip proxy DELETE in no-proxy/legacy mode
t-kim-planitai Apr 21, 2026
21c6fb0
feat(app/logs): dispatch by mode, target active slot in proxy mode
t-kim-planitai Apr 21, 2026
e757b4b
feat(app/stop): dispatch by mode, target active slot in proxy mode
t-kim-planitai Apr 21, 2026
0ce9d80
feat(app/restart): dispatch by mode, target active slot in proxy mode
t-kim-planitai Apr 21, 2026
6eeca3a
feat(app/status): dispatch by mode, skip proxy phase in no-proxy
t-kim-planitai Apr 21, 2026
c80d0e6
fix(app/mode): validate CURRENT_SLOT content before returning (review)
t-kim-planitai Apr 21, 2026
7035a31
feat(app/env): warn when app env targets a proxy-mode app
t-kim-planitai Apr 21, 2026
d469e15
docs: document --no-proxy mode alongside proxy mode
t-kim-planitai Apr 21, 2026
4ff6b2a
fix(app): close two final-review gaps (init mode-flip, env merge)
t-kim-planitai Apr 21, 2026
b38b4b8
fix(app): address adversarial review — env merge, destroy, ordering, …
t-kim-planitai Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ conoha server rename <server-id-or-name> new-name
| `conoha config` | CLI configuration (show / set / path) |
| `conoha skill` | Claude Code skill management (install / update / remove) |

### Two deploy modes

`conoha app` supports two modes that can coexist on the same VPS:

| Mode | When to use | Layout |
|---|---|---|
| **proxy** (default) | Public app with a domain and TLS | Blue/green slots under `/opt/conoha/<name>/<slot>/` managed via conoha-proxy |
| **no-proxy** (`--no-proxy`) | Testing, internal/dev VPS, non-HTTP services, hobby apps | Flat `/opt/conoha/<name>/` with plain `docker compose up` |

Initialize with `conoha app init --no-proxy --app-name <name> <server>`, then `conoha app deploy --no-proxy --app-name <name> <server>`. No `conoha.yml` required in no-proxy mode.

## App deploy (blue/green via conoha-proxy)

Since v0.2.0, `conoha app deploy` uses [conoha-proxy](https://github.com/crowdy/conoha-proxy) for blue/green deploys: automatic Let's Encrypt HTTPS, Host-header routing, and instant rollback inside the drain window. First-time setup:
Expand Down
11 changes: 11 additions & 0 deletions README-ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ conoha server rename <server-id-or-name> new-name
| `conoha config` | CLI 설정 관리 (show / set / path) |
| `conoha skill` | Claude Code 스킬 관리 (install / update / remove) |

### 두 가지 배포 모드

`conoha app`은 동일 VPS에서 공존 가능한 두 가지 모드를 제공합니다:

| 모드 | 용도 | 레이아웃 |
|---|---|---|
| **proxy** (기본) | 도메인 + TLS가 있는 공개 앱 | `/opt/conoha/<name>/<slot>/` 아래의 blue/green 슬롯 (conoha-proxy 관리) |
| **no-proxy** (`--no-proxy`) | 테스트, 내부/개발 VPS, 비 HTTP 서비스, 취미 앱 | `/opt/conoha/<name>/` 플랫 (일반 `docker compose up`) |

`conoha app init --no-proxy --app-name <name> <server>`로 초기화한 뒤 `conoha app deploy --no-proxy --app-name <name> <server>`로 배포합니다. no-proxy 모드에서는 `conoha.yml`이 필요 없습니다.

## 앱 배포 (conoha-proxy 기반 blue/green)

v0.2.0 부터 `conoha app deploy` 는 [conoha-proxy](https://github.com/crowdy/conoha-proxy) 를 경유한 blue/green 배포로 통일되었습니다. Let's Encrypt HTTPS 자동 발급, Host 헤더 라우팅, drain 윈도우 내 즉시 롤백을 제공합니다. 초기 셋업 순서:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ conoha server create --name my-server --user-data-url https://example.com/setup.

詳細は [docs/startup-script.md](docs/startup-script.md) を参照してください。

### 2 つのデプロイモード

`conoha app` は同一 VPS 上で共存可能な 2 つのモードを提供します:

| モード | 用途 | レイアウト |
|---|---|---|
| **proxy** (既定) | ドメイン + TLS の公開アプリ | `/opt/conoha/<name>/<slot>/` の blue/green スロット (conoha-proxy 管理) |
| **no-proxy** (`--no-proxy`) | テスト、内部・開発 VPS、非 HTTP サービス、ホビーアプリ | `/opt/conoha/<name>/` フラット (単純な `docker compose up`) |

`conoha app init --no-proxy --app-name <name> <server>` で初期化し、`conoha app deploy --no-proxy --app-name <name> <server>` でデプロイします。no-proxy モードでは `conoha.yml` は不要です。

## アプリデプロイ(conoha-proxy 経由 blue/green)

v0.2.0 から `conoha app deploy` は [conoha-proxy](https://github.com/crowdy/conoha-proxy) 経由の blue/green デプロイに統一されました。Let's Encrypt による HTTPS、Host ヘッダールーティング、drain 窓内での即時ロールバックを提供します。初回セットアップの流れ:
Expand Down
122 changes: 119 additions & 3 deletions cmd/app/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"bytes"
"errors"
"fmt"
"os"
"strings"
Expand All @@ -11,6 +12,7 @@ import (

"github.com/crowdy/conoha-cli/cmd/proxy"
"github.com/crowdy/conoha-cli/internal/config"
"github.com/crowdy/conoha-cli/internal/model"
proxypkg "github.com/crowdy/conoha-cli/internal/proxy"
internalssh "github.com/crowdy/conoha-cli/internal/ssh"
)
Expand All @@ -19,6 +21,7 @@ func init() {
addAppFlags(deployCmd)
deployCmd.Flags().String("data-dir", proxy.DefaultDataDir, "proxy data directory on the server")
deployCmd.Flags().String("slot", "", "override slot ID (default: git short SHA or timestamp). Must match [a-z0-9][a-z0-9-]{0,63}. Reusing an existing slot removes its work dir before re-extracting; pending drain-teardowns for the same slot will auto-skip")
AddModeFlags(deployCmd)
}

var deployCmd = &cobra.Command{
Expand All @@ -29,11 +32,114 @@ in a new compose slot on a dynamic port, then ask conoha-proxy to probe and
swap. The previous slot is torn down after the drain window.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runDeploy(cmd, args[0])
return runDeployDispatch(cmd, args[0])
},
}

func runDeploy(cmd *cobra.Command, serverID string) error {
// runDeployDispatch resolves mode (flag override + server marker) and calls
// the proxy or no-proxy deploy path.
func runDeployDispatch(cmd *cobra.Command, serverID string) error {
noProxyFlag, _ := cmd.Flags().GetBool("no-proxy")

if noProxyFlag {
appName, _ := cmd.Flags().GetString("app-name")
if appName == "" {
return fmt.Errorf("--app-name is required with --no-proxy")
}
if err := internalssh.ValidateAppName(appName); err != nil {
return err
}
sshClient, s, ip, err := connectToServer(cmd, serverID)
if err != nil {
return err
}
defer func() { _ = sshClient.Close() }()
got, err := ReadMarker(sshClient, appName)
if err != nil {
if errors.Is(err, ErrNoMarker) {
return fmt.Errorf("app %q not initialized on this server — run 'conoha app init --no-proxy --app-name %s %s' first", appName, appName, serverID)
}
return err
}
if got != ModeNoProxy {
return formatModeConflictError(appName, got, ModeNoProxy)
}
return runNoProxyDeploy(cmd, sshClient, s, ip, appName)
}

return runProxyDeploy(cmd, serverID)
}

// runNoProxyDeploy uploads the working tree to /opt/conoha/<app>/ and runs
// 'docker compose -p <app> up -d --build'. No proxy upsert, no slot.
func runNoProxyDeploy(cmd *cobra.Command, sshClient *ssh.Client, s *model.Server, ip, appName string) error {
fmt.Fprintf(os.Stderr, "==> Deploying %q to %s (%s) in no-proxy mode\n", appName, s.Name, ip)

patterns, err := loadIgnorePatterns(".")
if err != nil {
return err
}
var buf bytes.Buffer
if err := createTarGz(".", patterns, &buf); err != nil {
return fmt.Errorf("create archive: %w", err)
}
workDir := "/opt/conoha/" + appName
if err := runRemote(sshClient, buildNoProxyUploadCmd(workDir), &buf); err != nil {
return fmt.Errorf("upload: %w", err)
}

pf := &config.ProjectFile{}
composeFile, err := pf.ResolveComposeFile(".")
if err != nil {
return err
}

if err := runRemote(sshClient, buildNoProxyDeployCmd(workDir, appName, composeFile), nil); err != nil {
return fmt.Errorf("compose up: %w", err)
}
fmt.Fprintln(os.Stderr, "Deploy complete.")
return nil
}

// buildNoProxyUploadCmd extracts the incoming tar archive into the app work
// directory. It removes the previous deploy's merged .env (if any) before
// extracting so the tar becomes authoritative for repo-level .env content;
// the deploy command then overlays /opt/conoha/<app>.env.server on top.
// Other sibling files (e.g. named-volume binds) are preserved.
// Caller MUST pre-validate app via internalssh.ValidateAppName.
func buildNoProxyUploadCmd(workDir string) string {
return fmt.Sprintf(
"mkdir -p '%[1]s' && rm -f '%[1]s/.env' && tar xzf - -C '%[1]s'",
workDir)
}

// buildNoProxyDeployCmd brings the flat-layout compose project up in place.
// The compose project name equals the app name (no slot suffix).
//
// Env merge (v0.1.x parity, spec §3.6): appends /opt/conoha/<app>.env.server
// (written by `conoha app env set`) to <workDir>/.env so server-side values
// win over repo-level ones via last-occurrence semantics. This is the
// expected precedence for runtime-secret override.
//
// Because buildNoProxyUploadCmd cleared any prior merged .env before tar
// extraction, each deploy starts from the repo's committed .env (if any)
// and re-overlays the current .env.server. `app env unset` therefore takes
// effect on the next deploy.
//
// Caller MUST pre-validate app via internalssh.ValidateAppName.
// composeFile is defensively single-quoted — today it comes from the
// ResolveComposeFile whitelist, but quoting hardens against future callers.
func buildNoProxyDeployCmd(workDir, app, composeFile string) string {
envServer := fmt.Sprintf("/opt/conoha/%s.env.server", app)
return fmt.Sprintf(
"cd '%s' && { "+
"touch .env; "+
"if [ -s '%s' ]; then printf '\\n' >> .env && cat '%s' >> .env; fi; "+
"} && docker compose -p %s -f '%s' up -d --build",
workDir, envServer, envServer, app, composeFile)
}

func runProxyDeploy(cmd *cobra.Command, serverID string) error {
pf, err := config.LoadProjectFile(config.ProjectFileName)
if err != nil {
return err
Expand All @@ -55,12 +161,22 @@ func runDeploy(cmd *cobra.Command, serverID string) error {
}
defer func() { _ = sshClient.Close() }()

// Mode dispatch parity: reject if this app was initialized in no-proxy mode.
// Absent marker falls through to the existing "service not found on proxy" path.
got, markerErr := ReadMarker(sshClient, pf.Name)
if markerErr != nil && !errors.Is(markerErr, ErrNoMarker) {
return markerErr
}
if markerErr == nil && got == ModeNoProxy {
return formatModeConflictError(pf.Name, got, ModeProxy)
}

dataDir, _ := cmd.Flags().GetString("data-dir")
admin := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: sshClient}, proxy.SocketPath(dataDir))

// Service must exist — init registers it. Missing = user skipped init.
if _, err := admin.Get(pf.Name); err != nil {
return fmt.Errorf("service %q not found on proxy — run 'conoha app init %s' first: %w", pf.Name, serverID, err)
return fmt.Errorf("app %q not initialized on this server — run 'conoha app init %s' first: %w", pf.Name, serverID, err)
}

slotOverride, _ := cmd.Flags().GetString("slot")
Expand Down
53 changes: 53 additions & 0 deletions cmd/app/deploy_test.go
Original file line number Diff line number Diff line change
@@ -1 +1,54 @@
package app

import (
"strings"
"testing"
)

func TestDeployCmd_HasModeFlags(t *testing.T) {
if deployCmd.Flags().Lookup("proxy") == nil {
t.Error("deploy should have --proxy flag")
}
if deployCmd.Flags().Lookup("no-proxy") == nil {
t.Error("deploy should have --no-proxy flag")
}
if deployCmd.Flags().Lookup("app-name") == nil {
t.Error("deploy should have --app-name flag (required with --no-proxy)")
}
}

func TestBuildNoProxyDeployCmd(t *testing.T) {
got := buildNoProxyDeployCmd("/opt/conoha/myapp", "myapp", "compose.yml")
for _, want := range []string{
"cd '/opt/conoha/myapp'",
"docker compose -p myapp",
"-f 'compose.yml'",
"up -d --build",
// .env.server appended to .env so server-side values override repo (spec §3.6).
"/opt/conoha/myapp.env.server",
">> .env",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %s", want, got)
}
}
}

func TestBuildNoProxyUploadCmd(t *testing.T) {
got := buildNoProxyUploadCmd("/opt/conoha/myapp")
for _, want := range []string{
"mkdir -p '/opt/conoha/myapp'",
"tar xzf - -C '/opt/conoha/myapp'",
// Remove previous deploy's merged .env so tar becomes authoritative
// for repo-level env and `app env unset` takes effect on redeploy (C1 fix).
"rm -f '/opt/conoha/myapp/.env'",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %s", want, got)
}
}
// Must not wipe the entire app dir (would destroy named volumes + env.server dir siblings).
if strings.Contains(got, "rm -rf '/opt/conoha/myapp'") {
t.Errorf("no-proxy upload must not wipe app dir: %s", got)
}
}
45 changes: 33 additions & 12 deletions cmd/app/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func init() {
addAppFlags(destroyCmd)
destroyCmd.Flags().Bool("yes", false, "skip confirmation prompt")
destroyCmd.Flags().String("data-dir", proxy.DefaultDataDir, "proxy data directory on the server")
AddModeFlags(destroyCmd)
}

var destroyCmd = &cobra.Command{
Expand All @@ -32,6 +33,25 @@ var destroyCmd = &cobra.Command{
}
defer func() { _ = ctx.Client.Close() }()

// Resolve mode BEFORE the prompt so a flag/marker conflict aborts
// before the user commits, and BEFORE the destroy script runs
// because the script removes the .conoha-mode marker as part of rm -rf.
mode, modeErr := ResolveMode(cmd, ctx.Client, ctx.AppName)
if modeErr != nil && !errors.Is(modeErr, ErrNoMarker) {
return modeErr
}

// Marker absent: treat as legacy proxy deployment when conoha.yml
// validates locally. Old proxy apps from before this PR have no
// marker; skipping proxy DELETE would leak registrations (review I2).
legacyProxy := false
if errors.Is(modeErr, ErrNoMarker) {
if pf, pfErr := config.LoadProjectFile(config.ProjectFileName); pfErr == nil && pf.Validate() == nil {
legacyProxy = true
fmt.Fprintf(os.Stderr, "==> No mode marker on server; treating as legacy proxy deployment\n")
}
}

yes, _ := cmd.Flags().GetBool("yes")
if !yes {
ok, err := prompt.Confirm(fmt.Sprintf("Destroy app %q on %s? All data will be deleted.", ctx.AppName, ctx.Server.Name))
Expand All @@ -53,18 +73,19 @@ var destroyCmd = &cobra.Command{
return fmt.Errorf("destroy exited with code %d", exitCode)
}

// Best-effort: deregister from proxy if conoha.yml is present.
dataDir, _ := cmd.Flags().GetString("data-dir")
if dataDir == "" {
dataDir = proxy.DefaultDataDir
}
admin := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: ctx.Client}, proxy.SocketPath(dataDir))
pf, pfErr := config.LoadProjectFile(config.ProjectFileName)
if pfErr == nil && pf.Validate() == nil {
if err := admin.Delete(pf.Name); err != nil && !errors.Is(err, proxypkg.ErrNotFound) {
fmt.Fprintf(os.Stderr, "warning: proxy delete %s: %v\n", pf.Name, err)
} else if err == nil {
fmt.Fprintf(os.Stderr, "==> Deregistered %q from proxy\n", pf.Name)
if mode == ModeProxy || legacyProxy {
dataDir, _ := cmd.Flags().GetString("data-dir")
if dataDir == "" {
dataDir = proxy.DefaultDataDir
}
admin := proxypkg.NewClient(&proxypkg.SSHExecutor{Client: ctx.Client}, proxy.SocketPath(dataDir))
pf, pfErr := config.LoadProjectFile(config.ProjectFileName)
if pfErr == nil && pf.Validate() == nil {
if err := admin.Delete(pf.Name); err != nil && !errors.Is(err, proxypkg.ErrNotFound) {
fmt.Fprintf(os.Stderr, "warning: proxy delete %s: %v\n", pf.Name, err)
} else if err == nil {
fmt.Fprintf(os.Stderr, "==> Deregistered %q from proxy\n", pf.Name)
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions cmd/app/destroy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ func TestDestroyCmd_HasYesFlag(t *testing.T) {
t.Errorf("--yes default should be false, got %s", f.DefValue)
}
}

func TestDestroyCmd_HasModeFlags(t *testing.T) {
if destroyCmd.Flags().Lookup("proxy") == nil {
t.Error("destroy should have --proxy flag")
}
if destroyCmd.Flags().Lookup("no-proxy") == nil {
t.Error("destroy should have --no-proxy flag")
}
}
Loading
Loading